mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
The modelling_e2e Lambda held up to ~4 concurrent Postgres connections per invocation: the read Session stayed open across the write loop (the catalogue was queried live and overrides were read per-Property), each per-Property Unit of Work opened a second, and the TaskOrchestrator ran on its own NullPool engine — so the pool needed pool_size=2 + max_overflow=1 just for the modelling work. Under 32 concurrent containers that approached RDS max_connections. Restructure the handler to read everything up front — overrides, Scenario, an in-memory catalogue snapshot, and stored Solar — through one short-lived read Session, close it, then write each Property in a sequential Unit of Work. The read and write Sessions no longer overlap, so the engine drops to pool_size=1, max_overflow=0. Fold the orchestrator onto the same pooled engine: its repos commit on every save, releasing the connection between bookkeeping calls, so it holds none during the work. One invocation now uses one connection at a time. The catalogue becomes a per-invocation snapshot (MaterialSnapshotRepository), mirroring ProductPostgresRepository.get exactly — same drift mapping, lowest-id pick, and errors — but priced after the Session closes. Transaction isolation is preserved: per-Property writes and orchestrator bookkeeping keep their own independent transactions, just drawn sequentially from a single connection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
5.2 KiB
Python
118 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from sqlmodel import Session, col, select
|
|
|
|
from domain.modelling.contingencies import contingency_rate
|
|
from domain.modelling.product import Product
|
|
from infrastructure.postgres.product_table import MaterialRow
|
|
from repositories.product.product_repository import ProductNotFound, ProductRepository
|
|
|
|
|
|
# The domain ``MeasureType`` vocabulary and the catalogue's ``material.type``
|
|
# pgEnum drifted apart: these five measures are spelled differently on the
|
|
# catalogue side (and querying the domain spelling raises a pgEnum DataError
|
|
# that poisons the session's transaction). Translate them to the catalogue's
|
|
# own vocabulary at this boundary so the domain enum stays stable. Every other
|
|
# MeasureType already matches its material.type and maps to itself.
|
|
_MATERIAL_TYPE_BY_MEASURE: dict[str, str] = {
|
|
"low_energy_lighting": "low_energy_lighting_installation",
|
|
"gas_boiler_upgrade": "boiler_upgrade",
|
|
"system_tune_up": "roomstat_programmer_trvs",
|
|
"system_tune_up_zoned": "time_temperature_zone_control",
|
|
"sloping_ceiling_insulation": "room_roof_insulation",
|
|
}
|
|
|
|
|
|
class ProductPostgresRepository(ProductRepository):
|
|
"""Reads the ``material`` catalogue table and maps an active row to a
|
|
Product: `total_cost` becomes the fully-loaded `unit_cost_per_m2`, and the
|
|
per-Measure-Type contingency is joined from config."""
|
|
|
|
def __init__(self, session: Session) -> None:
|
|
self._session = session
|
|
|
|
def get(self, measure_type: str) -> Product:
|
|
# Resolve the domain MeasureType to the catalogue's ``material.type``
|
|
# spelling (identity for all but the five drifted types above).
|
|
catalogue_type = _MATERIAL_TYPE_BY_MEASURE.get(measure_type, measure_type)
|
|
# The live catalogue holds many active rows per type; order by id so the
|
|
# pick is deterministic (a re-seed prices the same) rather than relying
|
|
# on the database's physical row order.
|
|
row: MaterialRow | None = self._session.exec(
|
|
select(MaterialRow)
|
|
.where(
|
|
col(MaterialRow.type) == catalogue_type,
|
|
col(MaterialRow.is_active).is_(True),
|
|
)
|
|
.order_by(col(MaterialRow.id))
|
|
).first()
|
|
if row is None:
|
|
raise ProductNotFound(
|
|
f"no active product for measure type {measure_type!r}"
|
|
)
|
|
if row.total_cost is None:
|
|
raise ValueError(f"product {measure_type!r} has no total_cost")
|
|
return Product(
|
|
measure_type=measure_type,
|
|
unit_cost_per_m2=row.total_cost,
|
|
contingency_rate=contingency_rate(measure_type),
|
|
id=row.id,
|
|
)
|
|
|
|
|
|
class MaterialSnapshotRepository(ProductRepository):
|
|
"""An in-memory snapshot of the active ``material`` catalogue, read once from
|
|
a Session via ``load``.
|
|
|
|
It lets the Modelling stage price Measure Options *after* the read Session is
|
|
closed, so a Lambda invocation can hold a single DB connection at a time — the
|
|
read Session, then each per-Property write Unit of Work, never overlapping —
|
|
instead of keeping the read Session's connection checked out across the whole
|
|
write loop while the catalogue is queried lazily.
|
|
|
|
``get`` mirrors ``ProductPostgresRepository.get`` exactly: the same
|
|
domain→``material.type`` drift mapping, the lowest-id active row per type, and
|
|
the same ``ProductNotFound`` / missing-``total_cost`` errors. Because the
|
|
snapshot is a plain dict lookup (no live query), the off-catalogue measures
|
|
that would poison a live session's transaction simply miss and raise the
|
|
benign ``ProductNotFound`` — the ``CompositeProductRepository`` override still
|
|
keeps them off the catalogue first.
|
|
"""
|
|
|
|
def __init__(
|
|
self, rows_by_catalogue_type: dict[str, tuple[Optional[float], int]]
|
|
) -> None:
|
|
self._rows_by_catalogue_type = rows_by_catalogue_type
|
|
|
|
@classmethod
|
|
def load(cls, session: Session) -> "MaterialSnapshotRepository":
|
|
rows = session.exec(
|
|
select(MaterialRow)
|
|
.where(col(MaterialRow.is_active).is_(True))
|
|
.order_by(col(MaterialRow.id))
|
|
).all()
|
|
rows_by_catalogue_type: dict[str, tuple[Optional[float], int]] = {}
|
|
for row in rows:
|
|
# Lowest id wins per type — mirrors ``.first()`` after ``order_by(id)``.
|
|
if row.type not in rows_by_catalogue_type:
|
|
rows_by_catalogue_type[row.type] = (row.total_cost, row.id)
|
|
return cls(rows_by_catalogue_type)
|
|
|
|
def get(self, measure_type: str) -> Product:
|
|
catalogue_type = _MATERIAL_TYPE_BY_MEASURE.get(measure_type, measure_type)
|
|
entry = self._rows_by_catalogue_type.get(catalogue_type)
|
|
if entry is None:
|
|
raise ProductNotFound(
|
|
f"no active product for measure type {measure_type!r}"
|
|
)
|
|
total_cost, row_id = entry
|
|
if total_cost is None:
|
|
raise ValueError(f"product {measure_type!r} has no total_cost")
|
|
return Product(
|
|
measure_type=measure_type,
|
|
unit_cost_per_m2=total_cost,
|
|
contingency_rate=contingency_rate(measure_type),
|
|
id=row_id,
|
|
)
|