M8-R08: add atomic meter close and binding transfer

This commit is contained in:
2026-08-24 18:37:46 +02:00
parent 2be4f78f8a
commit 8dc3f71aaf
15 changed files with 1667 additions and 35 deletions
+109 -4
View File
@@ -250,7 +250,7 @@ def _validate_binding(
channel_id: int,
started_at: datetime,
ended_at: datetime | None,
excluding_id: int | None = None,
excluding_ids: set[int] | None = None,
) -> None:
meter = _get_meter(session, meter_id)
channel = get_channel(session, channel_id)
@@ -264,7 +264,19 @@ def _validate_binding(
)
if ended_at is not None and _as_utc(ended_at) <= _as_utc(started_at):
raise BindingValidationError("Binding ended_at must be strictly after started_at.")
if _as_utc(started_at) < _as_utc(meter.started_at):
raise BindingValidationError("Binding must not start before its meter epoch.")
if meter.ended_at is None:
if ended_at is not None:
# A historical binding on an active epoch is valid, but it must be
# wholly within that epoch (whose upper bound is open).
pass
else:
meter_end = _as_utc(meter.ended_at)
if ended_at is None or _as_utc(ended_at) > meter_end:
raise BindingValidationError("Closed meter bindings must end within the meter epoch.")
excluded = excluding_ids or set()
candidates = session.execute(
select(MeterSourceBinding).where(
or_(
@@ -274,7 +286,7 @@ def _validate_binding(
)
).scalars()
for existing in candidates:
if existing.id == excluding_id:
if existing.id in excluded:
continue
if half_open_intervals_overlap(
_as_utc(started_at),
@@ -295,6 +307,9 @@ def create_binding(
ended_at: datetime | None = None,
) -> MeterSourceBinding:
"""Create a compatible non-overlapping half-open source binding."""
now = _utc_now()
if _as_utc(started_at) > now or (ended_at is not None and _as_utc(ended_at) > now):
raise BindingValidationError("Binding boundaries must not be in the future.")
_validate_binding(
session,
meter_id=meter_id,
@@ -302,7 +317,6 @@ def create_binding(
started_at=started_at,
ended_at=ended_at,
)
now = _utc_now()
binding = MeterSourceBinding(
meter_id=meter_id,
channel_id=channel_id,
@@ -403,13 +417,16 @@ def update_binding(
new_channel_id = binding.channel_id if channel_id is None else channel_id
new_started_at = binding.started_at if started_at is None else started_at
new_ended_at = binding.ended_at if ended_at is _UNSET else ended_at
now = _utc_now()
if _as_utc(new_started_at) > now or (new_ended_at is not None and _as_utc(new_ended_at) > now):
raise BindingValidationError("Binding boundaries must not be in the future.")
_validate_binding(
session,
meter_id=new_meter_id,
channel_id=new_channel_id,
started_at=new_started_at,
ended_at=new_ended_at,
excluding_id=binding.id,
excluding_ids={binding.id},
)
binding.meter_id = new_meter_id
binding.channel_id = new_channel_id
@@ -422,3 +439,91 @@ def update_binding(
def close_binding(session: Session, binding_id: int, *, ended_at: datetime) -> MeterSourceBinding:
"""Close an existing binding at its exclusive end boundary."""
return update_binding(session, binding_id, ended_at=ended_at)
def close_open_bindings_for_meter(session: Session, meter_id: int, *, ended_at: datetime) -> list[MeterSourceBinding]:
"""Close every open binding on a meter at one shared boundary."""
bindings = list(session.execute(
select(MeterSourceBinding).where(
MeterSourceBinding.meter_id == meter_id, MeterSourceBinding.ended_at.is_(None)
)
).scalars())
for binding in bindings:
update_binding(session, binding.id, ended_at=ended_at)
return bindings
def transfer_binding(
session: Session, *, target_meter_id: int, from_binding_id: int, to_channel_id: int,
effective_at: datetime,
) -> tuple[MeterSourceBinding, MeterSourceBinding]:
"""Atomically close a binding and open its replacement on the target meter."""
source = session.get(MeterSourceBinding, from_binding_id)
if source is None:
raise BindingNotFoundError(f"Meter source binding {from_binding_id} was not found.")
target = _get_meter(session, target_meter_id)
old_meter = _get_meter(session, source.meter_id)
effective_at = _as_utc(effective_at)
now = _utc_now()
if effective_at > now:
raise BindingValidationError("Binding transfer effective_at must not be in the future.")
if old_meter.commodity != target.commodity:
raise BindingValidationError("Binding transfer meters must have the same commodity.")
if source.ended_at is not None:
raise BindingValidationError("Only an open binding can be transferred.")
if old_meter.id == target.id:
close_at = effective_at
else:
# Recovery is deliberately narrow: the source meter must be the one
# and only most-recent closed predecessor in this commodity's timeline.
# A manually closed meter may leave an intentional epoch gap before the
# target is declared, so adjacency is not required.
if old_meter.ended_at is None:
raise BindingValidationError("Source binding must belong to a closed predecessor meter.")
timeline = list(session.execute(
select(Meter).where(Meter.commodity == target.commodity)
).scalars())
predecessors = [
meter for meter in timeline
if meter.id != target.id
and meter.ended_at is not None
and _as_utc(meter.ended_at) <= _as_utc(target.started_at)
]
if not predecessors:
raise BindingValidationError("Source meter is not the unique immediately preceding meter.")
latest_end = max(_as_utc(meter.ended_at) for meter in predecessors)
latest = [meter for meter in predecessors if _as_utc(meter.ended_at) == latest_end]
if len(latest) != 1 or latest[0].id != old_meter.id:
raise BindingValidationError("Source meter is not the unique immediately preceding meter.")
# Reject any overlapping epoch around either endpoint. A separate
# meter inside the gap is already excluded by the predecessor check;
# one extending into either endpoint is an ambiguous timeline too.
for meter in timeline:
if meter.id in {old_meter.id, target.id}:
continue
meter_end = _as_utc(meter.ended_at) if meter.ended_at is not None else None
if (
half_open_intervals_overlap(
_as_utc(old_meter.started_at), _as_utc(old_meter.ended_at),
_as_utc(meter.started_at), meter_end,
)
or half_open_intervals_overlap(
_as_utc(target.started_at),
_as_utc(target.ended_at) if target.ended_at is not None else None,
_as_utc(meter.started_at), meter_end,
)
):
raise BindingValidationError("Source meter has an ambiguous commodity timeline.")
close_at = _as_utc(old_meter.ended_at)
if effective_at < _as_utc(target.started_at):
raise BindingValidationError("Transfer effective_at must be within the target meter epoch.")
if effective_at < _as_utc(source.started_at):
raise BindingValidationError("Transfer effective_at precedes the source binding.")
# Validate the target before mutating the old row, then close/create in one session.
_validate_binding(session, meter_id=target.id, channel_id=to_channel_id,
started_at=effective_at, ended_at=None,
excluding_ids={source.id})
update_binding(session, source.id, ended_at=close_at)
created = create_binding(session, meter_id=target.id, channel_id=to_channel_id,
started_at=effective_at)
return source, created