فهرست منبع

test(postgres): build the test schema once per worker and empty it between tests

Every test rebuilt the whole schema on PostgreSQL (DROP SCHEMA + create_all,
a few hundred DDL statements), which was 90% of a --full PostgreSQL pass,
with 30 workers and the server competing for the same cores. The schema is
now built once per worker and emptied before each test (rows deleted with
foreign-key triggers off, sequences back to 1); a test that changed the
schema, or a model that adds a table, still gets a full rebuild. Full
PostgreSQL pass: 477 s -> 152 s, same results.
maziggy 5 روز پیش
والد
کامیت
be7182ae9b
3فایلهای تغییر یافته به همراه158 افزوده شده و 6 حذف شده
  1. 71 5
      backend/tests/conftest.py
  2. 86 0
      backend/tests/unit/test_pg_test_schema_reset.py
  3. 1 1
      test_backend.sh

+ 71 - 5
backend/tests/conftest.py

@@ -326,6 +326,76 @@ def event_loop():
     loop.close()
 
 
+# The test database's schema as built, per worker: built once and emptied
+# between tests. Rebuilding it for every test (DROP SCHEMA + create_all, a few
+# hundred DDL statements) was 90% of a PostgreSQL run, with 30 workers and the
+# server competing for the same cores.
+_pg_schema: dict[str, object] = {}
+
+# Everything a test could change about the schema: relations (tables, indexes,
+# sequences, views) with their columns, types, nullability and defaults, and
+# every constraint.
+_PG_SCHEMA_FINGERPRINT = """
+SELECT md5(coalesce(string_agg(item, ',' ORDER BY item), '')) FROM (
+    SELECT c.relname || ':' || c.relkind::text || ':' || coalesce(a.attname, '') || ':'
+        || coalesce(format_type(a.atttypid, a.atttypmod), '') || ':' || coalesce(a.attnotnull::text, '')
+        || ':' || coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS item
+    FROM pg_class c
+    JOIN pg_namespace n ON n.oid = c.relnamespace
+    LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
+    LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
+    WHERE n.nspname = 'public'
+    UNION ALL
+    SELECT 'constraint:' || conname || ':' || pg_get_constraintdef(oid)
+    FROM pg_constraint WHERE connamespace = 'public'::regnamespace
+) AS schema_items
+"""
+
+
+# Every row out and every id back to 1. DELETE rather than TRUNCATE: TRUNCATE
+# gives each table and index a new file, about three times the cost for the few
+# rows a test leaves. Foreign-key triggers are off for this transaction only
+# (the throwaway server's superuser), so the order does not matter.
+_PG_EMPTY_TABLES = """
+DO $$
+DECLARE r record;
+BEGIN
+    PERFORM set_config('session_replication_role', 'replica', true);
+    FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename LOOP
+        EXECUTE format('DELETE FROM public.%I', r.tablename);
+    END LOOP;
+    FOR r IN SELECT c.oid::regclass AS seq FROM pg_class c
+             WHERE c.relkind = 'S' AND c.relnamespace = 'public'::regnamespace LOOP
+        PERFORM setval(r.seq, 1, false);
+    END LOOP;
+END $$
+"""
+
+
+async def _reset_pg_test_schema(conn) -> None:
+    """An empty test database with the models' schema, as cheaply as possible.
+
+    Emptied (ids restart at 1) when the schema is still the one built for this
+    worker; a full rebuild the first time, after a test changed the schema (a
+    migration test adding a column), and when a model loaded since adds a table.
+    """
+    from sqlalchemy import text
+
+    from backend.app.core.database import load_all_models
+
+    load_all_models()
+    tables = set(Base.metadata.tables)
+    fingerprint = (await conn.execute(text(_PG_SCHEMA_FINGERPRINT))).scalar_one()
+    if _pg_schema.get("fingerprint") != fingerprint or _pg_schema.get("tables") != tables:
+        await conn.execute(text("DROP SCHEMA public CASCADE"))
+        await conn.execute(text("CREATE SCHEMA public"))
+        await conn.run_sync(Base.metadata.create_all)
+        _pg_schema["fingerprint"] = (await conn.execute(text(_PG_SCHEMA_FINGERPRINT))).scalar_one()
+        _pg_schema["tables"] = tables
+        return
+    await conn.execute(text(_PG_EMPTY_TABLES))
+
+
 @pytest.fixture
 async def test_engine():
     """Create a test database engine."""
@@ -378,16 +448,12 @@ async def test_engine():
     if TEST_PG_URL is not None:
         # The same engine setup as the app's (UTC session, aware datetimes
         # made naive), on this worker's own database, emptied for each test.
-        from sqlalchemy import text
-
         from backend.app.core.database import _create_engine
 
         await engine.dispose()
         engine = _create_engine(TEST_PG_URL)
         async with engine.begin() as conn:
-            await conn.execute(text("DROP SCHEMA public CASCADE"))
-            await conn.execute(text("CREATE SCHEMA public"))
-            await conn.run_sync(Base.metadata.create_all)
+            await _reset_pg_test_schema(conn)
         yield engine
         await engine.dispose()
         return

+ 86 - 0
backend/tests/unit/test_pg_test_schema_reset.py

@@ -0,0 +1,86 @@
+"""The PostgreSQL test database is emptied between tests, not rebuilt.
+
+Rebuilding it for every test (DROP SCHEMA + create_all) was 90% of a
+PostgreSQL run. It is now built once per worker and emptied, so these pin what
+emptying has to give each test: no rows, ids from 1, and the models' schema,
+rebuilt when a test changed it. They run only on PostgreSQL.
+"""
+
+import pytest
+from sqlalchemy import text
+
+from backend.tests import conftest
+
+pytestmark = pytest.mark.skipif(conftest.TEST_PG_URL is None, reason="the PostgreSQL test database only")
+
+
+async def _reset(test_engine):
+    async with test_engine.begin() as conn:
+        await conn.execute(text("SELECT 1"))
+        await conftest._reset_pg_test_schema(conn)
+
+
+def _printer(n: int, **extra):
+    from backend.app.models.printer import Printer
+
+    return Printer(name=f"p{n}", serial_number=f"S{n}", ip_address=f"10.0.0.{n}", access_code="x", model="X1C", **extra)
+
+
+async def _add(test_engine, *rows):
+    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+    async with async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)() as session:
+        for row in rows:
+            session.add(row)
+            await session.flush()
+        await session.commit()
+
+
+async def test_rows_go_and_ids_start_at_one_again(test_engine):
+    await _add(test_engine, _printer(1), _printer(2))
+
+    await _reset(test_engine)
+
+    async with test_engine.begin() as conn:
+        assert (await conn.execute(text("SELECT count(*) FROM printers"))).scalar_one() == 0
+    fresh = _printer(3)
+    await _add(test_engine, fresh)
+    assert fresh.id == 1
+
+
+async def test_rows_that_reference_each_other_go_too(test_engine):
+    """locations is emptied before spool, whose rows point at it with no
+    ON DELETE action: only possible with the foreign-key triggers off."""
+    from backend.app.models.location import Location
+    from backend.app.models.spool import Spool
+
+    location = Location(name="Shelf", name_key="shelf")
+    await _add(test_engine, location)
+    await _add(test_engine, Spool(material="PLA", location_id=location.id))
+
+    await _reset(test_engine)
+
+    async with test_engine.begin() as conn:
+        assert (await conn.execute(text("SELECT count(*) FROM spool"))).scalar_one() == 0
+        assert (await conn.execute(text("SELECT count(*) FROM locations"))).scalar_one() == 0
+
+
+@pytest.mark.parametrize(
+    "change",
+    [
+        "ALTER TABLE printers ADD COLUMN leftover integer",
+        "ALTER TABLE printers ALTER COLUMN location SET DEFAULT 'x'",
+        "CREATE TABLE leftover_table (id integer)",
+        "CREATE INDEX leftover_index ON printers (name)",
+        "ALTER TABLE printers DROP CONSTRAINT printers_serial_number_key",
+    ],
+)
+async def test_a_schema_a_test_changed_is_rebuilt(test_engine, change):
+    async with test_engine.begin() as conn:
+        before = (await conn.execute(text(conftest._PG_SCHEMA_FINGERPRINT))).scalar_one()
+        await conn.execute(text(change))
+
+    await _reset(test_engine)
+
+    async with test_engine.begin() as conn:
+        assert (await conn.execute(text(conftest._PG_SCHEMA_FINGERPRINT))).scalar_one() == before

+ 1 - 1
test_backend.sh

@@ -79,7 +79,7 @@ else
 fi
 
 # The same tests on PostgreSQL, in a throwaway container (--full only: about
-# eight minutes). Tests of SQLite-only behaviour are skipped there.
+# three minutes). Tests of SQLite-only behaviour are skipped there.
 if [ "$1" != "--full" ]; then
 echo "Skipping the tests on PostgreSQL (run with --full)."
 elif ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then