"""Opt-in TimescaleDB regression; only accepts an independently disposable DB.""" import asyncio import os import psycopg import pytest from psycopg.conninfo import conninfo_to_dict from app.core.config import settings from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository DSN = os.environ.get("REALTIME_COMPRESSION_TEST_DSN", "") pytestmark = pytest.mark.skipif(not DSN, reason="Needs an isolated TimescaleDB test database") START = "2026-10-08T04:00:00Z" @pytest.fixture def compressed_snapshot(): assert conninfo_to_dict(DSN).get("dbname", "").startswith("tjw_test_realtime_"), "Refusing a non-test database" with psycopg.connect(DSN, autocommit=True) as conn: conn.execute("CREATE SCHEMA realtime") conn.execute("""CREATE TABLE realtime.node_results ( time timestamptz NOT NULL, node_id text NOT NULL, actual_demand double precision, total_head double precision, pressure double precision, quality double precision, PRIMARY KEY(time,node_id))""") conn.execute("""CREATE TABLE realtime.link_results ( time timestamptz NOT NULL, link_id text NOT NULL, flow double precision, friction double precision, headloss double precision, quality double precision, reaction double precision, setting double precision, status double precision, velocity double precision, PRIMARY KEY(time,link_id))""") for table, element, value in [("node_results", "node_id", "pressure"), ("link_results", "link_id", "flow")]: conn.execute("SELECT create_hypertable(%s,'time',chunk_time_interval=>interval '1 day')", (f"realtime.{table}",)) conn.execute(f"""INSERT INTO realtime.{table}(time,{element},{value}) SELECT %s::timestamptz + period * interval '15 minutes', id::text, 0 FROM generate_series(0,3) period CROSS JOIN generate_series(1,100) id""", (START,)) conn.execute(f"ALTER TABLE realtime.{table} SET (timescaledb.compress, timescaledb.compress_orderby='{element},time')") conn.execute("SELECT compress_chunk(show_chunks(%s))", (f"realtime.{table}",)) # Scale down the production 100000-row guard: each compressed table has # 400 rows, and deleting one snapshot needs batch decompression. conn.execute("SET timescaledb.max_tuples_decompressed_per_dml_transaction=100") try: yield conn finally: conn.execute("DROP SCHEMA realtime CASCADE") def snapshot(): return ( [{"node": str(i), "result": [{"pressure": 999}]} for i in range(1,101)], [{"link": str(i), "result": [{"flow": 888}]} for i in range(1,101)], ) def assert_snapshot(conn, expected_pressure, expected_flow): for table, value, expected in [("node_results", "pressure", expected_pressure), ("link_results", "flow", expected_flow)]: assert conn.execute(f"SELECT count(*),min({value}),max({value}) FROM realtime.{table} WHERE time=%s", (START,)).fetchone() == (100,expected,expected) assert conn.execute(f"SELECT count(*) FROM realtime.{table}").fetchone() == (400,) @pytest.mark.parametrize("asynchronous", [False, True]) def test_compressed_snapshot_replacement_and_transaction_local_budget(monkeypatch, compressed_snapshot, asynchronous): conn = compressed_snapshot nodes, links = snapshot() monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1000) # Execute the old DELETE in the actual compressed database, then roll it # back. This proves the guard really fires rather than trusting a SQL mock. with pytest.raises(psycopg.Error, match="tuple decompression limit exceeded"): with conn.transaction(): conn.execute("DELETE FROM realtime.node_results WHERE time=%s", (START,)) assert_snapshot(conn, 0, 0) if asynchronous: async def run(): async with await psycopg.AsyncConnection.connect(DSN, autocommit=True) as async_conn: await async_conn.execute("SET timescaledb.max_tuples_decompressed_per_dml_transaction=100") await RealtimeRepository.store_realtime_simulation_result(async_conn, nodes, links, START) cursor = await async_conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction") assert await cursor.fetchone() == ("100",) asyncio.run(run()) else: RealtimeRepository.store_realtime_simulation_result_sync(conn, nodes, links, START) assert_snapshot(conn, 999, 888) assert conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction").fetchone() == ("100",) def test_copy_failure_rolls_back_both_tables_and_local_budget(monkeypatch, compressed_snapshot): conn = compressed_snapshot monkeypatch.setattr(settings, "TIMESCALEDB_REALTIME_MAX_DECOMPRESSED_TUPLES", 1000) def fail_copy(*_): raise RuntimeError("fixture link copy failure") monkeypatch.setattr(RealtimeRepository, "_copy_links_sync", fail_copy) nodes, links = snapshot() with pytest.raises(RuntimeError, match="fixture link copy failure"): RealtimeRepository.store_realtime_simulation_result_sync(conn, nodes, links, START) assert_snapshot(conn, 0, 0) assert conn.execute("SHOW timescaledb.max_tuples_decompressed_per_dml_transaction").fetchone() == ("100",)