Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions nix/ext/pg_cron/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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";
Expand Down
18 changes: 18 additions & 0 deletions nix/ext/tests/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
""
Expand Down Expand Up @@ -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
""
Expand Down Expand Up @@ -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)
''
Expand Down
129 changes: 129 additions & 0 deletions nix/ext/tests/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<ext>--*.sql`` scripts, including symlinked alias scripts
(without resolving them), and classifies each:

- ``<ext>--V.sql`` is a base install script for version ``V``.
- ``<ext>--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 ``<ext>--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 '<latest>'``, 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.

Expand Down
Loading