Model/repositories/product/product_postgres_repository.py
Jun-te Kim 17b9ae08eb Hold one DB connection per modelling_e2e invocation
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>
2026-06-24 16:58:21 +00:00

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