Model/repositories/property/property_overrides_postgres_reader.py
Jun-te Kim de5e9a2362 perf(modelling_e2e): bulk reads and batch writes to cut RDS load
The handler fired ~2+2N read round-trips and N+N write transactions per
SQS batch, pinning RDS CPU under ~32 concurrent containers on pool_size=1.

Reads: merge the duplicate property query and add overrides_for_many /
SolarRepository.get_many so overrides, solar, and property rows each load
in one query (2+2N -> 3).

Writes: buffer each modelled property's persistence intent in memory
(_PropertyWrite) during the loop, then flush the whole batch in one
PostgresUnitOfWork with a single commit, and run the baseline orchestrator
once for all written ids (N+N -> 2 transactions). Per-property modelling
failures stay isolated in the loop; the batch write is all-or-nothing and
retried via SQS (saves are idempotent upserts).

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

107 lines
4.3 KiB
Python

"""Postgres adapter for the ``property_overrides`` read side.
Read-only and uow-independent: ``property_overrides`` is committed reference
data the ``bulk_upload_finaliser`` Lambda writes at Finalise, long before First
Run executes — there is no transactional coupling to the ingestion run, so the
standalone reader opens its own short read session per call via the injected
session factory (mirroring the composition root's ``lambda: Session(engine)``).
Inside a Unit of Work the overrides must instead be read on the UoW's *own*
session (``OpenSessionPropertyOverridesReader``): a second session opened
concurrently checks out a second connection, which deadlocks the modelling_e2e
Lambda's single-connection pool while the UoW's read transaction is still open.
Reading committed reference data inside the UoW's transaction is correct — it
sees the same committed rows — and keeps the invocation on one connection.
"""
from __future__ import annotations
from collections.abc import Callable
from sqlmodel import Session, col, select
from infrastructure.postgres.property_override_table import PropertyOverrideRow
from repositories.property.property_overrides_reader import (
PropertyOverridesReader,
ResolvedPropertyOverride,
ResolvedPropertyOverrides,
)
def _resolve_overrides(session: Session, property_id: int) -> ResolvedPropertyOverrides:
rows = session.exec(
select(PropertyOverrideRow).where(
col(PropertyOverrideRow.property_id) == property_id
)
).all()
return ResolvedPropertyOverrides(
rows=tuple(
ResolvedPropertyOverride(
override_component=row.override_component,
building_part=row.building_part,
override_value=row.override_value,
)
for row in rows
)
)
def _resolve_overrides_many(
session: Session, property_ids: list[int]
) -> dict[int, ResolvedPropertyOverrides]:
"""Resolve overrides for many Properties in one query. Returns an entry for
every requested id; a Property with no rows resolves to an empty snapshot."""
rows = session.exec(
select(PropertyOverrideRow).where(
col(PropertyOverrideRow.property_id).in_(property_ids)
)
).all()
grouped: dict[int, list[ResolvedPropertyOverride]] = {
property_id: [] for property_id in property_ids
}
for row in rows:
grouped.setdefault(row.property_id, []).append(
ResolvedPropertyOverride(
override_component=row.override_component,
building_part=row.building_part,
override_value=row.override_value,
)
)
return {
property_id: ResolvedPropertyOverrides(rows=tuple(overrides))
for property_id, overrides in grouped.items()
}
class PropertyOverridesPostgresReader(PropertyOverridesReader):
"""Opens its own short read session per call — for standalone use outside a
Unit of Work, where there is no shared session/connection to reuse."""
def __init__(self, session_factory: Callable[[], Session]) -> None:
self._session_factory = session_factory
def overrides_for(self, property_id: int) -> ResolvedPropertyOverrides:
with self._session_factory() as session:
return _resolve_overrides(session, property_id)
def overrides_for_many(
self, property_ids: list[int]
) -> dict[int, ResolvedPropertyOverrides]:
"""Every requested Property's resolved overrides in one query — the batch
form of ``overrides_for``. The returned dict has an entry for every
requested id; a Property with no rows resolves to an empty snapshot
(exactly as ``overrides_for`` returns for a Property with no rows)."""
with self._session_factory() as session:
return _resolve_overrides_many(session, property_ids)
class OpenSessionPropertyOverridesReader(PropertyOverridesReader):
"""Reads on a caller-owned, already-open session without closing it — for use
inside a Unit of Work so resolving overrides reuses the UoW's single
connection instead of checking out a second one."""
def __init__(self, session: Session) -> None:
self._session = session
def overrides_for(self, property_id: int) -> ResolvedPropertyOverrides:
return _resolve_overrides(self._session, property_id)