mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-07-18 06:35:13 +00:00
fix(restore): drop text search objects in the postgres --empty pre-clean
A schema shipping a custom text search dictionary (taiga's english_stem_nostop) survived the pre-clean and aborted the dump replay with a duplicate pg_ts_dict_dictname_index violation under ON_ERROR_STOP. The discovery SELECT now also enumerates user-owned pg_ts_config and pg_ts_dict entries. The string-assertion unit test is replaced by real scenario data in the e2e: the seeded schema now contains an overloaded f()/f(int) pair and the nostop dictionary plus configuration, and the restored database is queried to prove each survives the backup, pre-clean and replay cycle exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,16 @@ SELECT format('DROP %s IF EXISTS public.%s CASCADE', obj.type, obj.name)
|
|||||||
FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace
|
FROM pg_collation col JOIN pg_namespace n ON n.oid = col.collnamespace
|
||||||
WHERE n.nspname = 'public'
|
WHERE n.nspname = 'public'
|
||||||
AND pg_get_userbyid(col.collowner) = current_user
|
AND pg_get_userbyid(col.collowner) = current_user
|
||||||
|
UNION ALL
|
||||||
|
SELECT format('%I', ts.cfgname) AS name, 'TEXT SEARCH CONFIGURATION' AS type
|
||||||
|
FROM pg_ts_config ts JOIN pg_namespace n ON n.oid = ts.cfgnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND pg_get_userbyid(ts.cfgowner) = current_user
|
||||||
|
UNION ALL
|
||||||
|
SELECT format('%I', d.dictname) AS name, 'TEXT SEARCH DICTIONARY' AS type
|
||||||
|
FROM pg_ts_dict d JOIN pg_namespace n ON n.oid = d.dictnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND pg_get_userbyid(d.dictowner) = current_user
|
||||||
) obj
|
) obj
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', n.nspname)
|
SELECT format('DROP SCHEMA IF EXISTS %I CASCADE', n.nspname)
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from .helpers import (
|
|||||||
# --empty runs against the still-populated DB (no wipe), so the pre-clean must
|
# --empty runs against the still-populated DB (no wipe), so the pre-clean must
|
||||||
# drop discourse_functions too or the dump's CREATE SCHEMA aborts the replay
|
# drop discourse_functions too or the dump's CREATE SCHEMA aborts the replay
|
||||||
# under ON_ERROR_STOP; the old public-only DROP left it and broke discourse.
|
# under ON_ERROR_STOP; the old public-only DROP left it and broke discourse.
|
||||||
|
# The f()/f(int) pair reproduces discourse's overload abort ("function name
|
||||||
|
# is not unique") and english_stem_nostop reproduces taiga's text search
|
||||||
|
# dictionary abort (duplicate pg_ts_dict_dictname_index).
|
||||||
SCENARIO_SQL = (
|
SCENARIO_SQL = (
|
||||||
"CREATE SCHEMA discourse_functions;"
|
"CREATE SCHEMA discourse_functions;"
|
||||||
"CREATE TABLE discourse_functions.helper (id int);"
|
"CREATE TABLE discourse_functions.helper (id int);"
|
||||||
@@ -31,7 +34,13 @@ SCENARIO_SQL = (
|
|||||||
"CREATE SEQUENCE public.s;"
|
"CREATE SEQUENCE public.s;"
|
||||||
"CREATE TYPE public.mood AS ENUM ('ok', 'bad');"
|
"CREATE TYPE public.mood AS ENUM ('ok', 'bad');"
|
||||||
"CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';"
|
"CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';"
|
||||||
|
"CREATE FUNCTION public.f(i int) RETURNS int LANGUAGE sql AS 'SELECT i';"
|
||||||
"CREATE COLLATION public.c (locale = 'C');"
|
"CREATE COLLATION public.c (locale = 'C');"
|
||||||
|
"CREATE TEXT SEARCH DICTIONARY public.english_stem_nostop"
|
||||||
|
" (Template = snowball, Language = english);"
|
||||||
|
"CREATE TEXT SEARCH CONFIGURATION public.english_nostop (COPY = english);"
|
||||||
|
"ALTER TEXT SEARCH CONFIGURATION public.english_nostop"
|
||||||
|
" ALTER MAPPING FOR asciiword WITH public.english_stem_nostop;"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,6 +161,26 @@ class TestE2EPostgresEmptyDropHard(unittest.TestCase):
|
|||||||
"1",
|
"1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_overloaded_functions_restored_once_each(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._scalar("SELECT count(*) FROM pg_proc WHERE proname='f';"), "2"
|
||||||
|
)
|
||||||
|
self.assertEqual(self._scalar("SELECT public.f(41) + public.f();"), "42")
|
||||||
|
|
||||||
|
def test_text_search_dictionary_restored_once(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
self._scalar(
|
||||||
|
"SELECT count(*) FROM pg_ts_dict WHERE dictname='english_stem_nostop';"
|
||||||
|
),
|
||||||
|
"1",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self._scalar(
|
||||||
|
"SELECT count(*) FROM pg_ts_config WHERE cfgname='english_nostop';"
|
||||||
|
),
|
||||||
|
"1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""The --empty pre-clean drop must cover every object class a dump can
|
|
||||||
re-CREATE ahead of tables; a missed class aborts the replay under
|
|
||||||
ON_ERROR_STOP (OpenProject's ICU collation public.versions_name did)."""
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from baudolo.restore.db import postgres
|
|
||||||
|
|
||||||
|
|
||||||
class TestPostgresEmptyDrop(unittest.TestCase):
|
|
||||||
def _drop_sql(self) -> str:
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".sql") as fh:
|
|
||||||
Path(fh.name).write_bytes(b"SELECT 1;\n")
|
|
||||||
with mock.patch.object(postgres, "docker_exec") as run:
|
|
||||||
postgres.restore_postgres_sql(
|
|
||||||
container="c",
|
|
||||||
db_name="db",
|
|
||||||
user="u",
|
|
||||||
password="p",
|
|
||||||
sql_path=fh.name,
|
|
||||||
empty=True,
|
|
||||||
)
|
|
||||||
first_call = run.call_args_list[0]
|
|
||||||
return first_call.kwargs["stdin"].decode()
|
|
||||||
|
|
||||||
def test_drop_covers_collations(self) -> None:
|
|
||||||
sql = self._drop_sql()
|
|
||||||
self.assertIn("pg_collation", sql)
|
|
||||||
self.assertIn("'COLLATION' AS type", sql)
|
|
||||||
self.assertIn("pg_get_userbyid(col.collowner) = current_user", sql)
|
|
||||||
|
|
||||||
def test_drop_still_covers_the_other_classes(self) -> None:
|
|
||||||
sql = self._drop_sql()
|
|
||||||
for marker in ("pg_class", "pg_proc", "'SEQUENCE' AS type", "'TYPE' AS type"):
|
|
||||||
self.assertIn(marker, sql)
|
|
||||||
self.assertIn("DROP %s IF EXISTS public.%s CASCADE", sql)
|
|
||||||
|
|
||||||
def test_functions_drop_with_identity_signature(self) -> None:
|
|
||||||
"""Overloaded functions share a proname; a signature-less DROP aborts
|
|
||||||
with "function name is not unique", so the function branch must emit
|
|
||||||
name(identity args) while every other branch stays %I-quoted."""
|
|
||||||
sql = self._drop_sql()
|
|
||||||
self.assertIn("pg_get_function_identity_arguments(p.oid)", sql)
|
|
||||||
self.assertIn("format('%I(%s)', p.proname", sql)
|
|
||||||
for branch in (
|
|
||||||
"format('%I', c.relname)",
|
|
||||||
"format('%I', t.typname)",
|
|
||||||
"format('%I', col.collname)",
|
|
||||||
):
|
|
||||||
self.assertIn(branch, sql)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user