From bf7d3ef2c5763ab5ccebdd43d8842531f02c7ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Boros?= Date: Wed, 22 Jul 2026 05:22:30 +0300 Subject: [PATCH] fix: cover pg_cron legacy extversion strings with alignment migration and alias scripts pg_cron installs recorded with old non-canonical extversion strings (e.g. bare "1.6" instead of "1.6.4") have no update path once the package builds moved to a canonical-version chain, so ALTER EXTENSION pg_cron UPDATE fails with: extension "pg_cron" has no update path from version "1.6" to "1.6.4" Add an empty 1.6 -> 1.6.4 alignment migration plus symlinked alias scripts for every other legacy version string (1.0, 1.1, 1.2, 1.3, 1.4, 1.4-1, 1.5), and a nix/ext test mode (testAllUpgradeEdges) that exercises every discovered upgrade edge -- including symlinked aliases and no-op alignment scripts -- not just the canonical chain from versions.json. Supersedes #1790 (rebased onto current develop and squashed into a single commit, dropping unrelated arm64 AMI build/cleanup-script diagnostics that had accumulated in that branch's history). Co-Authored-By: Sam Rose --- nix/ext/pg_cron/default.nix | 29 ++++++++ nix/ext/tests/default.nix | 18 +++++ nix/ext/tests/lib.py | 129 ++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/nix/ext/pg_cron/default.nix b/nix/ext/pg_cron/default.nix index a823fa450b..8bc14e7c82 100644 --- a/nix/ext/pg_cron/default.nix +++ b/nix/ext/pg_cron/default.nix @@ -61,8 +61,32 @@ let mv $out/share/postgresql/extension/pg_cron--1.4--1.4-1.sql $out/share/postgresql/extension/pg_cron--1.4.0--1.4.1.sql mv $out/share/postgresql/extension/pg_cron--1.4-1--1.5.sql $out/share/postgresql/extension/pg_cron--1.4.2--1.5.2.sql mv $out/share/postgresql/extension/pg_cron--1.5--1.6.sql $out/share/postgresql/extension/pg_cron--1.5.2--1.6.4.sql + ln -s pg_cron--1.0.0--1.1.0.sql $out/share/postgresql/extension/pg_cron--1.0--1.1.sql + ln -s pg_cron--1.0.0--1.1.0.sql $out/share/postgresql/extension/pg_cron--1.0.0--1.1.sql + ln -s pg_cron--1.1.0--1.2.0.sql $out/share/postgresql/extension/pg_cron--1.1--1.2.sql + ln -s pg_cron--1.1.0--1.2.0.sql $out/share/postgresql/extension/pg_cron--1.1.0--1.2.sql + ln -s pg_cron--1.1.0--1.2.0.sql $out/share/postgresql/extension/pg_cron--1.1--1.2.0.sql + ln -s pg_cron--1.2.0--1.3.1.sql $out/share/postgresql/extension/pg_cron--1.2--1.3.sql + ln -s pg_cron--1.2.0--1.3.1.sql $out/share/postgresql/extension/pg_cron--1.2.0--1.3.sql + ln -s pg_cron--1.2.0--1.3.1.sql $out/share/postgresql/extension/pg_cron--1.2--1.3.1.sql + ln -s pg_cron--1.3.1--1.4.2.sql $out/share/postgresql/extension/pg_cron--1.3--1.4.sql + ln -s pg_cron--1.3.1--1.4.2.sql $out/share/postgresql/extension/pg_cron--1.3.1--1.4.sql + ln -s pg_cron--1.3.1--1.4.2.sql $out/share/postgresql/extension/pg_cron--1.3--1.4.2.sql + ln -s pg_cron--1.4.0--1.4.1.sql $out/share/postgresql/extension/pg_cron--1.4--1.4-1.sql + ln -s pg_cron--1.4.2--1.5.2.sql $out/share/postgresql/extension/pg_cron--1.4-1--1.5.sql + ln -s pg_cron--1.4.2--1.5.2.sql $out/share/postgresql/extension/pg_cron--1.4.2--1.5.sql + ln -s pg_cron--1.4.2--1.5.2.sql $out/share/postgresql/extension/pg_cron--1.4-1--1.5.2.sql + ln -s pg_cron--1.5.2--1.6.4.sql $out/share/postgresql/extension/pg_cron--1.5--1.6.sql + ln -s pg_cron--1.5.2--1.6.4.sql $out/share/postgresql/extension/pg_cron--1.5.2--1.6.sql + ln -s pg_cron--1.5.2--1.6.4.sql $out/share/postgresql/extension/pg_cron--1.5--1.6.4.sql + cat > $out/share/postgresql/extension/pg_cron--1.6--1.6.4.sql << 'EOF' + -- Version alignment migration + -- Both 1.6 and 1.6.4 are actually the same version (1.6.4) + -- This file exists only to allow smooth transition from the old naming scheme + EOF fi + # Create versioned control file with modified module path sed -e "/^default_version =/d" \ -e "/^schema =/d" \ @@ -120,6 +144,11 @@ buildEnv { numberOfVersions = numberOfVersionsBuilt; inherit switch-ext-version latestOnly; hasBackgroundWorker = true; + # pg_cron ships symlinked alias scripts and an empty 1.6 -> 1.6.4 + # alignment migration to cover legacy/non-canonical extversion strings. + # Opt in to scanning and exercising every upgrade edge, not just the + # canonical chain from versions.json. + testAllUpgradeEdges = true; defaultSettings = { shared_preload_libraries = [ "pg_cron" ]; "cron.database_name" = "postgres"; diff --git a/nix/ext/tests/default.nix b/nix/ext/tests/default.nix index 79415c20fe..6b9c23a0f1 100644 --- a/nix/ext/tests/default.nix +++ b/nix/ext/tests/default.nix @@ -230,6 +230,12 @@ let pg_regress_test_name = "${(installedExtension "15").pgRegressTestName or pname}" ext_schema = "${(installedExtension "15").defaultSchema or "public"}" lib_name = "${(installedExtension "15").libName or pname}" + test_all_upgrade_edges = ${ + if (installedExtension "15").testAllUpgradeEdges or false then "True" else "False" + } + ext_dir_15 = "${psql_15}/share/postgresql/extension" + ext_dir_17 = "${psql_17}/share/postgresql/extension" + ext_dir_orioledb = "${orioledb_17}/share/postgresql/extension" print(f"Running tests for extension: {lib_name}") ${builtins.readFile ./lib.py} @@ -247,6 +253,10 @@ let '' with subtest("Check upgrade path with postgresql 15"): test.check_upgrade_path("15") + + if test_all_upgrade_edges: + with subtest("Check all upgrade edges (incl. legacy aliases) with postgresql 15"): + test.check_all_upgrade_edges("15", ext_dir_15) '' else "" @@ -306,6 +316,10 @@ let '' with subtest("Check upgrade path with postgresql 17"): test.check_upgrade_path("17") + + if test_all_upgrade_edges: + with subtest("Check all upgrade edges (incl. legacy aliases) with postgresql 17"): + test.check_all_upgrade_edges("17", ext_dir_17) '' else "" @@ -334,6 +348,10 @@ let with subtest("Check upgrade path with orioledb 17"): test.check_upgrade_path("orioledb-17") + if test_all_upgrade_edges: + with subtest("Check all upgrade edges (incl. legacy aliases) with orioledb 17"): + test.check_all_upgrade_edges("orioledb-17", ext_dir_orioledb) + with subtest("Check pg_regress with orioledb 17 after installing the last version"): test.check_pg_regress(Path("${orioledb_17}/lib/pgxs/src/test/regress/pg_regress"), "orioledb-17", pg_regress_test_name) '' diff --git a/nix/ext/tests/lib.py b/nix/ext/tests/lib.py index 26bd42d49c..f702abbdd0 100644 --- a/nix/ext/tests/lib.py +++ b/nix/ext/tests/lib.py @@ -133,6 +133,135 @@ def check_upgrade_path(self, pg_version: str): self.drop_extension() self.install_extension(version) + def discover_upgrade_scripts(self, extension_dir: str): + """Discover the install scripts and upgrade edges in extension_dir. + + Lists all ``--*.sql`` scripts, including symlinked alias scripts + (without resolving them), and classifies each: + + - ``--V.sql`` is a base install script for version ``V``. + - ``--A--B.sql`` is an upgrade edge from ``A`` to ``B``. + + Version strings use single dashes and the separator between versions is + a double dash, so splitting the filename's version portion on ``--`` is + unambiguous (``1.4-1`` stays intact). + + Args: + extension_dir: Directory holding the extension's SQL scripts + + Returns: + Tuple of (set of base versions, sorted list of (from, to) edges) + """ + listing = self.vm.succeed( + f"ls -1 {extension_dir}/{self.extension_name}--*.sql 2>/dev/null || true" + ).strip() + prefix = f"{self.extension_name}--" + suffix = ".sql" + bases = set() + edges = set() + for line in listing.splitlines(): + name = Path(line).name + if not (name.startswith(prefix) and name.endswith(suffix)): + continue + core = name[len(prefix) : -len(suffix)] + parts = core.split("--") + if len(parts) == 1 and parts[0]: + bases.add(parts[0]) + elif len(parts) == 2 and parts[0] and parts[1]: + edges.add((parts[0], parts[1])) + return bases, sorted(edges) + + @staticmethod + def _reachable_versions(bases, edges): + """Versions installable via ``CREATE EXTENSION ... VERSION 'V'``. + + Postgres can only create a version it can reach from a base install + script by following upgrade edges. A version that only ever appears as + an edge *source* (e.g. pg_cron ``1.0``, which has no base script and no + incoming edge in this packaging) is not installable on a fresh cluster. + """ + reachable = set(bases) + changed = True + while changed: + changed = False + for frm, to in edges: + if frm in reachable and to not in reachable: + reachable.add(to) + changed = True + return reachable + + def check_all_upgrade_edges(self, pg_version: str, extension_dir: str): + """Prove every real project state upgrades cleanly to latest. + + Runs two passes over the upgrade-script graph discovered on disk + (including symlinked alias scripts and empty version-alignment + migrations such as the pg_cron 1.6 -> 1.6.4 no-op, which the canonical + check_upgrade_path never reaches because they are absent from + versions.json): + + 1. Per-edge body coverage. For each ``--A--B.sql`` whose source + ``A`` is reachable, create A from scratch and ``UPDATE TO 'B'``, + asserting the result. This guarantees every individual alias and + alignment script actually executes at least once; a shortest-path + upgrade could otherwise route around a broken one. + + 2. Full upgrade to latest from every reachable version. For every + version a real project could actually sit at -- canonical *or* + legacy-alias form -- create it from scratch and run a single + ``UPDATE TO ''``, asserting it lands on the latest + canonical version. This is the customer-faithful guarantee: any + existing project, whatever extversion string it reports, must reach + latest, with Postgres traversing alias edges as needed. If any + reachable version cannot reach latest, this pass fails loudly -- + which is exactly the stranded-project bug we want to catch. + + A version is "reachable" if a fresh cluster can come to rest at it: it + has a base install script or an incoming upgrade edge. Versions that + are neither (pg_cron ``1.0`` and ``1.4.0``: no base script, no incoming + edge) are not real project states -- no database, fresh or upgraded, + can ever sit at them -- so neither pass tests them. They lose no script + coverage either: each is a symlink to, or shares its body with, a + reachable alias edge that pass 1 runs (``1.0--1.1`` aliases + ``1.0.0--1.1.0``; ``1.4.0--1.4.1`` shares its body with the reachable + ``1.4--1.4-1`` alias). They exist only to keep the version graph + connected for legacy extversion strings, a structural property of the + package rather than an upgrade this test needs to exercise. + + The extension is restored to the latest canonical version on exit so + the subtests that follow see the expected state. + + Args: + pg_version: PostgreSQL version under test (e.g. "15") + extension_dir: Directory holding the extension's SQL scripts + """ + bases, edges = self.discover_upgrade_scripts(extension_dir) + if not edges: + return + + available = self.versions.get(pg_version, []) + latest = available[-1] if available else None + + reachable = self._reachable_versions(bases, edges) + testable = [edge for edge in edges if edge[0] in reachable] + + # Pass 1: every reachable upgrade script executes at least once. + for from_version, to_version in testable: + self.drop_extension() + self.install_extension(from_version) + self.update_extension(to_version) + + # Pass 2: every reachable version upgrades all the way to latest. + if latest is not None: + for from_version in sorted(v for v in reachable if v != latest): + self.drop_extension() + self.install_extension(from_version) + self.update_extension(latest) + + # Restore canonical latest so following subtests have expected state + self.drop_extension() + if available: + self.install_extension(available[-1]) + def check_install_last_version(self, pg_version: str) -> str: """Test if the install of the last version of the extension works for a given PostgreSQL version.