Model/tests/repositories/test_unit_of_work.py
Jun-te Kim de71f9abb6 Resolve overrides on the unit's own session, not a second connection
The modelling_e2e Lambda runs on a single-connection pool (pool_size=1,
max_overflow=0) so one invocation uses one Postgres connection. But re-hydrating
a Property through PostgresUnitOfWork resolved its Landlord Overrides through a
PropertyOverridesPostgresReader built from the unit's session *factory* — which
opens a brand-new Session per call. While the unit's own read transaction was
still open (PropertyPostgresRepository.get_many had checked out the connection),
that second Session asked the pool for a second connection, found none, and timed
out after 30s:

  QueuePool limit of size 1 overflow 0 reached, connection timed out, timeout 30.00

The baseline stage (PropertyBaselineOrchestrator.run -> uow.property.get_many ->
landlord overrides) hit this on every invocation.

Read the overrides on the unit's OWN session instead. property_overrides is
committed reference data, so reading it inside the unit's transaction sees the
same rows and keeps the invocation on one connection. Extract the query/mapping
into a shared helper and add OpenSessionPropertyOverridesReader (reads on a
caller-owned, already-open session without closing it) for the unit; the
standalone PropertyOverridesPostgresReader still opens its own short session for
use outside a unit.

Regression test pins the invariant with a real pool_size=1/max_overflow=0 engine:
without the fix it reproduces the exact QueuePool timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:01:41 +00:00

221 lines
8.6 KiB
Python

from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import Engine
from sqlmodel import Session, create_engine
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance
from domain.property_baseline.performance import Performance
from infrastructure.postgres.property_override_table import PropertyOverrideRow
from infrastructure.postgres.property_table import PropertyRow
from repositories.epc.epc_postgres_repository import EpcPostgresRepository
from repositories.plan.plan_repository import PlanRepository
from repositories.postgres_unit_of_work import PostgresUnitOfWork
from repositories.product.product_repository import ProductRepository
from repositories.scenario.scenario_repository import ScenarioRepository
from repositories.spatial.spatial_repository import SpatialRepository
_JSON_SAMPLES = Path(__file__).resolve().parents[2] / "backend/epc_api/json_samples"
def _session_factory(db_engine: Engine) -> Callable[[], Session]:
return lambda: Session(db_engine)
def _epc() -> EpcPropertyData:
raw: dict[str, Any] = json.loads(
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
)
return EpcPropertyDataMapper.from_api_response(raw)
def _baseline() -> PropertyBaselinePerformance:
perf = Performance(
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
)
return PropertyBaselinePerformance(
lodged=perf,
effective=perf,
rebaseline_reason="none",
space_heating_kwh=5000.0,
water_heating_kwh=2000.0,
)
def test_committed_work_is_visible_to_a_later_unit(db_engine: Engine) -> None:
# Arrange
new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine))
baseline = _baseline()
# Act
with new_unit() as uow:
uow.property_baseline.save(baseline, property_id=10)
uow.commit()
# Assert — a fresh unit reads back what the first one committed.
with new_unit() as uow:
loaded = uow.property_baseline.get_for_property(10)
assert loaded == baseline
def test_an_exception_in_the_block_rolls_the_batch_back(db_engine: Engine) -> None:
# Arrange
new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine))
# Act — a property mid-batch raises after a write but before commit.
with pytest.raises(RuntimeError, match="boom"):
with new_unit() as uow:
uow.property_baseline.save(_baseline(), property_id=10)
raise RuntimeError("boom")
# Assert — nothing from the aborted batch is persisted.
with new_unit() as uow:
assert uow.property_baseline.get_for_property(10) is None
def test_unit_exposes_the_modelling_repos_bound_to_its_session(
db_engine: Engine,
) -> None:
# Arrange / Act
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
# Assert — the Modelling stage reads Scenario + Product and writes Plan
# through the same unit (ADR-0017).
assert isinstance(uow.scenario, ScenarioRepository)
assert isinstance(uow.product, ProductRepository)
assert isinstance(uow.plan, PlanRepository)
def test_unit_exposes_the_spatial_cache_repo_bound_to_its_session(
db_engine: Engine,
) -> None:
# Arrange / Act
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
# Assert — Ingestion writes the OS spatial reference cache through the
# same unit it persists the EPC/solar with (ADR-0020).
assert isinstance(uow.spatial, SpatialRepository)
def test_unit_hydrates_a_property_with_its_landlord_overrides_folded(
db_engine: Engine,
) -> None:
"""A Property re-hydrated through the unit folds its Landlord Overrides onto
the Effective EPC (ADR-0032) — the same overlay the live modelling path
applies. Without this the Baseline Performance the orchestrator scores off
``uow.property`` diverges from the Plan, which does apply the overrides.
"""
# Arrange — a lodged EPC (cavity main wall, 4) plus a solid-brick / internal
# wall override, persisted through the unit.
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
row = PropertyRow(portfolio_id=1, postcode="A0 0AA", address="1 St", uprn=1)
uow._session.add(row) # pyright: ignore[reportPrivateUsage]
uow._session.flush() # pyright: ignore[reportPrivateUsage]
property_id = row.id
assert property_id is not None
EpcPostgresRepository(uow._session).save( # pyright: ignore[reportPrivateUsage]
_epc(), property_id=property_id
)
uow._session.add( # pyright: ignore[reportPrivateUsage]
PropertyOverrideRow(
property_id=property_id,
portfolio_id=1,
building_part=0,
override_component="wall_type",
override_value="Solid brick, with internal insulation",
original_spreadsheet_description="solid brick, insulated",
)
)
uow.commit()
# Act — re-hydrate through a fresh unit.
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
prop = uow.property.get(property_id)
main = next(
part
for part in prop.effective_epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
# Assert — the override is folded: cavity (4) → solid brick (3) / internal (3).
assert main.wall_construction == 3
assert main.wall_insulation_type == 3
def test_hydrating_a_property_with_overrides_stays_on_one_connection(
db_engine: Engine,
) -> None:
"""Resolving Landlord Overrides during re-hydration must read on the unit's
OWN session, not open a second one. The modelling_e2e Lambda runs on a
single-connection pool (``pool_size=1, max_overflow=0``); a second session
opened while the unit's read transaction is still open checks out a second
connection and deadlocks ("QueuePool limit of size 1 overflow 0 reached,
connection timed out"). This pins that the unit holds exactly one connection
while it hydrates a Property that has overrides.
"""
# Arrange — seed a property + EPC + an override (committed reference data).
with PostgresUnitOfWork(_session_factory(db_engine)) as uow:
row = PropertyRow(portfolio_id=1, postcode="A0 0AA", address="1 St", uprn=1)
uow._session.add(row) # pyright: ignore[reportPrivateUsage]
uow._session.flush() # pyright: ignore[reportPrivateUsage]
property_id = row.id
assert property_id is not None
EpcPostgresRepository(uow._session).save( # pyright: ignore[reportPrivateUsage]
_epc(), property_id=property_id
)
uow._session.add( # pyright: ignore[reportPrivateUsage]
PropertyOverrideRow(
property_id=property_id,
portfolio_id=1,
building_part=0,
override_component="wall_type",
override_value="Solid brick, with internal insulation",
original_spreadsheet_description="solid brick, insulated",
)
)
uow.commit()
# A pool that admits exactly one connection and fails fast (not after 30s) if
# a second is requested — the production modelling_e2e shape.
single_connection = create_engine(
db_engine.url, pool_size=1, max_overflow=0, pool_timeout=2
)
try:
# Act — hydrate through the unit; this resolves the override.
with PostgresUnitOfWork(_session_factory(single_connection)) as uow:
prop = uow.property.get(property_id)
finally:
single_connection.dispose()
# Assert — reached here without a QueuePool timeout, and the override folded:
# cavity (4) → solid brick (3) / internal (3).
main = next(
part
for part in prop.effective_epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
assert main.wall_construction == 3
assert main.wall_insulation_type == 3
def test_leaving_the_block_without_commit_persists_nothing(db_engine: Engine) -> None:
# Arrange
new_unit = lambda: PostgresUnitOfWork(_session_factory(db_engine))
# Act — write but never commit.
with new_unit() as uow:
uow.property_baseline.save(_baseline(), property_id=10)
# Assert
with new_unit() as uow:
assert uow.property_baseline.get_for_property(10) is None