Compare commits

..
Author SHA1 Message Date
overseer b074a32c16 fix: atomic complete_instance check+append 2026-09-18 06:46:49 -04:00
overseer 5e951935d9 feat: complete_instance no-op if already done 2026-09-18 06:40:22 -04:00
2 changed files with 7 additions and 298 deletions
+6 -59
View File
@@ -6,10 +6,9 @@ validation and the UTC occurred-at stamp here, and never talk to the
DAO or SQL directly. This module is the only Feature-08 caller of DAO or SQL directly. This module is the only Feature-08 caller of
:meth:`~.dao_instances.CompletionEventsDao.append`. Completing an :meth:`~.dao_instances.CompletionEventsDao.append`. Completing an
instance (no-op if already done) is :func:`complete_instance`; instance (no-op if already done) is :func:`complete_instance`;
un-completing (no-op if already open) is :func:`uncomplete_instance`. un-completing arrives in a later task. Instance current state is
Instance current state is derived from the latest event via derived from the latest event via :func:`instance_state`
:func:`instance_state` (``open`` / ``done``); there is no status (``open`` / ``done``); there is no status column.
column.
Actor policy matches the schema CHECKs (D-008): ``actor_source`` is Actor policy matches the schema CHECKs (D-008): ``actor_source`` is
``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'`` ``'user'`` or ``'panel'`` only — never ``'service'``. ``'user'``
@@ -105,10 +104,9 @@ def _reentrant_connection_lock(database) -> _TaskReentrantLock:
"""Return the shared connection lock, made task-reentrant. """Return the shared connection lock, made task-reentrant.
``CompletionEventsDao.append`` also acquires this lock, so ``CompletionEventsDao.append`` also acquires this lock, so
check+append in :func:`complete_instance` and check+append in :func:`complete_instance` must re-enter on the
:func:`uncomplete_instance` must re-enter on the same task. The same task. The wrapper is stored in the shared dict so the DAO
wrapper is stored in the shared dict so the DAO sees the same sees the same object.
object.
""" """
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
key = (id(database), id(loop)) key = (id(database), id(loop))
@@ -316,54 +314,3 @@ async def complete_instance(
now=now, now=now,
) )
return "done" return "done"
async def uncomplete_instance(
database: NestQuestDatabase,
instance_id: int,
*,
actor_source: str,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
now: datetime.datetime | None = None,
) -> str:
"""Append an uncompleted event unless the instance is already open.
Returns the derived state ``open``. 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.
"""
instance_id = _validate_int_id(instance_id, "instance_id")
actor_source, actor_user_id, actor_child_id = _validate_actor(
actor_source, actor_user_id, actor_child_id
)
async with _reentrant_connection_lock(database):
instance = await QuestInstancesDao(database).get_by_id(instance_id)
if instance is None:
raise ValueError(
f"instance_id: quest instance {instance_id} does not exist"
)
latest = await CompletionEventsDao(database).get_latest_for_instance(
instance_id
)
if latest is None or latest.event_type == EVENT_UNCOMPLETED:
return "open"
if latest.event_type != EVENT_COMPLETED:
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {latest.event_type!r}"
)
await append_event(
database,
instance.id,
instance.child_id,
EVENT_UNCOMPLETED,
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=None,
now=now,
)
return "open"
+1 -239
View File
@@ -12,7 +12,6 @@ from custom_components.nestquest.completion import (
append_event, append_event,
complete_instance, complete_instance,
instance_state, instance_state,
uncomplete_instance,
) )
from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import ( from custom_components.nestquest.dao_instances import (
@@ -339,7 +338,7 @@ def test_module_never_exposes_update_or_delete() -> None:
assert "append_event" in public assert "append_event" in public
assert "instance_state" in public assert "instance_state" in public
assert "complete_instance" in public assert "complete_instance" in public
assert "uncomplete_instance" in public assert "uncomplete_instance" not in public
source = inspect.getsource(completion) source = inspect.getsource(completion)
assert "UPDATE" not in source assert "UPDATE" not in source
assert "DELETE" not in source assert "DELETE" not in source
@@ -685,240 +684,3 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
await database.close() await database.close()
_run(_main()) _run(_main())
# ---------------------------------------------------------------------------
# uncomplete_instance: append uncompleted, no-op if already open
# ---------------------------------------------------------------------------
def test_uncomplete_instance_appends_and_leaves_original_untouched(
tmp_path,
) -> None:
async def _body(database, child, instance):
completed_at = datetime.datetime(
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=completed_at,
)
events = CompletionEventsDao(database)
before = await events.list_by_instance(instance.id)
assert len(before) == 1
original = before[0]
assert original.event_type == EVENT_COMPLETED
assert original.occurred_at == "2026-09-18T08:00:00+00:00"
assert original.actor_source == "user"
assert original.actor_user_id == "user-1"
assert original.actor_child_id is None
uncompleted_at = completed_at + datetime.timedelta(seconds=1)
state = await uncomplete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=child.id,
now=uncompleted_at,
)
assert state == "open"
assert await instance_state(database, instance.id) == "open"
after = await events.list_by_instance(instance.id)
assert len(after) == 2
assert after[0].id == original.id
assert after[0].event_type == EVENT_COMPLETED
assert after[0].occurred_at == original.occurred_at
assert after[0].actor_source == original.actor_source
assert after[0].actor_user_id == original.actor_user_id
assert after[0].actor_child_id == original.actor_child_id
assert after[1].event_type == EVENT_UNCOMPLETED
assert after[1].actor_source == "panel"
assert after[1].actor_child_id == child.id
assert after[1].was_on_time is None
_with_db(tmp_path, "uncomplete-append.db")(_body)
def test_uncomplete_instance_already_open_is_noop(tmp_path) -> None:
async def _body(database, child, instance):
events = CompletionEventsDao(database)
assert await events.list_by_instance(instance.id) == []
first = await uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
)
after_first = await events.list_by_instance(instance.id)
assert first == "open"
assert after_first == []
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
)
await uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
)
before = await events.list_by_instance(instance.id)
assert len(before) == 2
second = await uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-2",
)
after = await events.list_by_instance(instance.id)
assert second == "open"
assert after == before
assert after[0].actor_user_id == "user-1"
assert after[1].actor_user_id == "user-1"
_with_db(tmp_path, "uncomplete-noop.db")(_body)
def test_uncomplete_instance_unknown_instance_id_raises(tmp_path) -> None:
async def _body(database, child, instance):
with pytest.raises(ValueError, match="instance_id"):
await uncomplete_instance(
database,
instance.id + 999,
actor_source="user",
actor_user_id="user-1",
)
_with_db(tmp_path, "uncomplete-unknown.db")(_body)
def test_uncomplete_instance_rejects_non_integer_id() -> None:
async def _body():
with pytest.raises(ValueError, match="instance_id"):
await uncomplete_instance(
object(),
"1",
actor_source="user",
actor_user_id="user-1",
)
with pytest.raises(ValueError, match="instance_id"):
await uncomplete_instance(
object(),
True,
actor_source="user",
actor_user_id="user-1",
)
_run(_body())
def test_uncomplete_instance_now_pins_occurred_at(tmp_path) -> None:
async def _body(database, child, instance):
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=datetime.datetime(
2026, 9, 18, 8, 0, 0, tzinfo=datetime.timezone.utc
),
)
pinned = datetime.datetime(
2026, 9, 18, 8, 0, 1, tzinfo=datetime.timezone.utc
)
await uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=pinned,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[1].occurred_at == "2026-09-18T08:00:01+00:00"
_with_db(tmp_path, "uncomplete-pinned.db")(_body)
def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
"""Two racing uncompletes cannot both append.
Deterministic gate: task A is paused at its INSERT (it holds the
connection lock); task B is then started and must queue. A
finishes, B sees open and appends nothing.
"""
async def _main():
armed = {"active": False, "gated": False}
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "uncomplete-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = QuestDefinitionsDao(database)
instances = QuestInstancesDao(database)
child = await children.create("Ada", _now_stamp())
rule = await rules.create("daily", D1)
definition = await definitions.create(
"Brush teeth",
rule.id,
_now_stamp(),
assignee_child_ids=[child.id],
)
instance = await instances.upsert(
definition.id,
child.id,
D1,
_now_stamp(),
window="morning",
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
)
armed["active"] = True
task_a = asyncio.ensure_future(
uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
)
)
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
uncomplete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-2",
)
)
await asyncio.sleep(0)
gate_open.set()
state_a, state_b = await asyncio.gather(task_a, task_b)
assert state_a == "open"
assert state_b == "open"
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert len(rows) == 2
assert rows[0].event_type == EVENT_COMPLETED
assert rows[1].event_type == EVENT_UNCOMPLETED
assert rows[1].actor_user_id == "user-1"
finally:
await database.close()
_run(_main())