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 13 additions and 564 deletions
+12 -124
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'``
@@ -35,7 +34,6 @@ from .dao_instances import (
EVENT_UNCOMPLETED, EVENT_UNCOMPLETED,
CompletionEventRecord, CompletionEventRecord,
CompletionEventsDao, CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao, QuestInstancesDao,
) )
from .db import NestQuestDatabase from .db import NestQuestDatabase
@@ -65,64 +63,6 @@ def _stamp_occurred_at(now: datetime.datetime | None) -> str:
) )
def _completion_moment(
now: datetime.datetime | None,
today: datetime.date | None,
) -> tuple[datetime.date, datetime.time | None]:
"""Return the HA-local completion date and optional wall-clock time.
``today`` is the HA-local calendar date when threaded (Feature 07
style). ``now`` is a timezone-aware HA-local datetime; its wall
clock is used as-is and is never converted to UTC. When ``today``
is omitted the date comes from ``now``. When both are omitted the
host calendar date is the Feature 07 fallback — never the UTC
clock. Time-of-day is None unless ``now`` is supplied; comparing
against ``due_time`` requires ``now``.
"""
if now is not None:
if not isinstance(now, datetime.datetime):
raise ValueError(f"now must be a datetime, got {now!r}")
if now.tzinfo is None:
raise ValueError("now must be timezone-aware")
if today is not None:
completion_date = today
elif now is not None:
completion_date = now.date()
else:
completion_date = datetime.date.today()
if now is None:
return completion_date, None
return completion_date, now.time()
def _was_on_time(
instance: QuestInstanceRecord,
now: datetime.datetime | None,
today: datetime.date | None,
) -> bool:
"""Return whether the completion moment is on time for ``instance``.
On ``due_date`` before ``due_time`` is on time; on ``due_date``
after ``due_time``, or any later date, is late. With no
``due_time``, on time if the completion date equals ``due_date``
(or is earlier); late if after ``due_date``. Same-day
``due_time`` comparison requires ``now``; UTC time-of-day is never
substituted.
"""
completion_date, completion_time = _completion_moment(now, today)
due_date = datetime.date.fromisoformat(instance.due_date)
if completion_date < due_date:
return True
if completion_date > due_date:
return False
if instance.due_time is None:
return True
if completion_time is None:
raise ValueError("now is required to compare due_time")
due_time = datetime.datetime.strptime(instance.due_time, "%H:%M").time()
return completion_time <= due_time
class _TaskReentrantLock: class _TaskReentrantLock:
"""Shared connection lock the owning task may re-enter.""" """Shared connection lock the owning task may re-enter."""
@@ -164,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))
@@ -337,11 +276,11 @@ async def complete_instance(
append run under one connection lock so a concurrent complete append run under one connection lock so a concurrent complete
cannot double-append. cannot double-append.
``was_on_time`` is computed from the HA-local completion moment ``was_on_time`` is recorded as True — a placeholder until the
(threaded ``now`` / ``today``) against the instance ``due_date`` on-time task computes it from ``now`` / ``today`` against the
and optional ``due_time``. ``now`` is the timezone-aware HA-local instance due_date and optional due_time. ``today`` is accepted so
datetime; its wall clock is never converted to UTC. Same-day callers can thread the HA-local date through without a signature
``due_time`` comparison requires ``now``. change; it is unused here.
""" """
instance_id = _validate_int_id(instance_id, "instance_id") 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 = _validate_actor(
@@ -371,58 +310,7 @@ async def complete_instance(
actor_source=actor_source, actor_source=actor_source,
actor_user_id=actor_user_id, actor_user_id=actor_user_id,
actor_child_id=actor_child_id, actor_child_id=actor_child_id,
was_on_time=_was_on_time(instance, now, today), was_on_time=True,
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 -440
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,441 +684,3 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
await database.close() await database.close()
_run(_main()) _run(_main())
_HA_TZ = datetime.timezone(datetime.timedelta(hours=-8))
def _ha_local(day: datetime.date, hour: int, minute: int = 0) -> datetime.datetime:
return datetime.datetime(
day.year, day.month, day.day, hour, minute, 0,
tzinfo=_HA_TZ,
)
def test_complete_instance_on_due_date_before_due_time_is_on_time(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 11, 0),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-before-due-time.db")(_body)
def test_complete_instance_on_due_date_after_due_time_is_late(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 12, 1),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-after-due-time.db")(_body)
def test_complete_instance_microsecond_after_due_time_is_late(tmp_path) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
now = datetime.datetime(
due_date.year, due_date.month, due_date.day, 12, 0, 0, 500,
tzinfo=_HA_TZ,
)
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=now,
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-microsecond-after-due-time.db")(_body)
def test_complete_instance_no_due_time_on_due_date_is_on_time(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
assert instance.due_time is None
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(due_date, 23, 59),
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-no-due-time.db")(_body)
def test_complete_instance_no_due_time_after_due_date_is_late(
tmp_path,
) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
later = due_date + datetime.timedelta(days=1)
assert instance.due_time is None
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=_ha_local(later, 8, 0),
today=later,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is False
_with_db(tmp_path, "late-after-due-date.db")(_body)
def test_complete_instance_ha_local_wall_clock_not_utc(tmp_path) -> None:
"""HA-local 11:00 with due_time 12:00 is on time even when UTC is 19:00.
The helper's zone is UTC-8, so 11:00 local is 19:00 UTC. Converting
to UTC before comparing would mark this late.
"""
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
now = _ha_local(due_date, 11, 0)
assert now.astimezone(datetime.timezone.utc).hour == 19
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
now=now,
today=due_date,
)
rows = await CompletionEventsDao(database).list_by_instance(
instance.id
)
assert rows[0].was_on_time is True
_with_db(tmp_path, "ontime-ha-local-not-utc.db")(_body)
def test_complete_instance_due_time_requires_now_on_due_date(tmp_path) -> None:
async def _body(database, child, instance):
due_date = datetime.date.fromisoformat(instance.due_date)
instance = await QuestInstancesDao(database).upsert(
instance.definition_id,
child.id,
instance.due_date,
_now_stamp(),
window=instance.window,
due_time="12:00",
)
with pytest.raises(ValueError, match="now is required to compare due_time"):
await complete_instance(
database,
instance.id,
actor_source="user",
actor_user_id="user-1",
today=due_date,
)
_with_db(tmp_path, "due-time-requires-now.db")(_body)
# ---------------------------------------------------------------------------
# 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())