M8-R08: add atomic meter close and binding transfer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user