Decide event transitions under the completion lock

complete/uncomplete return a CompletionResult str subclass carrying
appended (decided with the write under the lock), so duplicate
concurrent calls fire one transition event; day-complete only
evaluates for an instance due today.
This commit is contained in:
2026-09-19 12:03:42 -04:00
parent fe38e5040e
commit 2a4eec658f
4 changed files with 140 additions and 29 deletions
+41 -17
View File
@@ -366,6 +366,25 @@ async def instance_state(
return _derived_state(instance, latest, today_date)
class CompletionResult(str):
"""The derived instance state, carrying the append verdict.
A ``str`` subclass so every existing ``result == "done"`` contract
keeps working; ``appended`` reports whether THIS call actually
appended an event (decided under the same lock as the append), so
a caller — the Feature 09 service path firing bus events —
announces a transition exactly once even under a concurrent
duplicate call where the loser's write is a no-op.
"""
appended: bool
def __new__(cls, state: str, *, appended: bool) -> "CompletionResult":
obj = super().__new__(cls, state)
obj.appended = appended
return obj
async def complete_instance(
database: NestQuestDatabase,
instance_id: int,
@@ -375,14 +394,14 @@ async def complete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> str:
) -> CompletionResult:
"""Append a completed event unless the instance is already done.
Returns the derived state ``done``. An already-done instance is a
no-op: nothing is appended. Unknown ``instance_id`` raises
ValueError naming the field. Existence, latest-event, and the
append run under one connection lock so a concurrent complete
cannot double-append.
Returns the derived state ``done``; ``appended`` reports whether
THIS call appended (False on an already-done no-op). Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent complete cannot double-append.
``was_on_time`` is computed from the HA-local completion moment
(threaded ``now`` / ``today``) against the instance ``due_date``
@@ -404,7 +423,7 @@ async def complete_instance(
instance_id
)
if latest is not None and latest.event_type == EVENT_COMPLETED:
return "done"
return CompletionResult("done", appended=False)
if latest is not None and latest.event_type != EVENT_UNCOMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
@@ -421,7 +440,7 @@ async def complete_instance(
was_on_time=_was_on_time(instance, now, today),
now=now,
)
return "done"
return CompletionResult("done", appended=True)
async def uncomplete_instance(
@@ -433,16 +452,16 @@ async def uncomplete_instance(
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
today: datetime.date | None = None,
) -> str:
) -> CompletionResult:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``, or ``missed`` when the instance
is past due (``due_date`` before HA-local ``today``). An
already-open instance is a no-op: nothing is appended. Unknown
``instance_id`` raises ValueError naming the field. Existence,
latest-event, and the append run under one connection lock so a
concurrent uncomplete cannot double-append. The original completed
row is not modified.
is past due (``due_date`` before HA-local ``today``); ``appended``
reports whether THIS call appended (False on an already-open
no-op). Unknown ``instance_id`` raises ValueError naming the
field. Existence, latest-event, and the append run under one
connection lock so a concurrent uncomplete cannot double-append.
The original completed row is not modified.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
@@ -459,7 +478,10 @@ async def uncomplete_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return _derived_state(instance, latest, today_date)
return CompletionResult(
_derived_state(instance, latest, today_date),
appended=False,
)
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
@@ -476,7 +498,9 @@ async def uncomplete_instance(
was_on_time=None,
now=now,
)
return _derived_state(instance, latest, today_date)
return CompletionResult(
_derived_state(instance, latest, today_date), appended=True
)
async def list_missed_for_child(