mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
221 lines
8.6 KiB
Python
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
|