Files
next-tjwater-server/tests/integration/test_realtime_compression.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

102 lines
5.3 KiB
Python

"""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",)