fix: consistent edit snapshot and rollback coverage (CQ-01, CQ-02)
This commit is contained in:
@@ -165,16 +165,18 @@ class QuestDefinitionWindowRecord:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class QuestDefinitionSnapshot:
|
class QuestDefinitionSnapshot:
|
||||||
"""A definition plus its assignees and windows, read atomically.
|
"""A definition plus its rule, assignees and windows, read atomically.
|
||||||
|
|
||||||
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
|
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
|
||||||
with the assignees and windows read inside the SAME transaction that
|
and :meth:`QuestDefinitionsDao.edit_definition` with the rule,
|
||||||
inserted them, so the snapshot is a consistent creation-time view —
|
assignees and windows read inside the SAME transaction that wrote
|
||||||
a concurrent assignee or window mutation cannot change it between
|
them, so the snapshot is a consistent creation/edit-time view — a
|
||||||
the write and the read.
|
concurrent rule, assignee or window mutation cannot change it
|
||||||
|
between the write and the read.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
definition: QuestDefinitionRecord
|
definition: QuestDefinitionRecord
|
||||||
|
rule: ScheduleRuleRecord
|
||||||
assignees: list[ChildRecord]
|
assignees: list[ChildRecord]
|
||||||
windows: list[QuestDefinitionWindowRecord]
|
windows: list[QuestDefinitionWindowRecord]
|
||||||
|
|
||||||
@@ -741,9 +743,14 @@ class QuestDefinitionsDao:
|
|||||||
definition = await self.get(definition_id)
|
definition = await self.get(definition_id)
|
||||||
assignees = await self.list_assignees(definition_id)
|
assignees = await self.list_assignees(definition_id)
|
||||||
window_records = await self.list_windows(definition_id)
|
window_records = await self.list_windows(definition_id)
|
||||||
|
rule_record = await ScheduleRulesDao(self._database).get(
|
||||||
|
rule_id
|
||||||
|
)
|
||||||
assert definition is not None
|
assert definition is not None
|
||||||
|
assert rule_record is not None
|
||||||
return QuestDefinitionSnapshot(
|
return QuestDefinitionSnapshot(
|
||||||
definition=definition,
|
definition=definition,
|
||||||
|
rule=rule_record,
|
||||||
assignees=assignees,
|
assignees=assignees,
|
||||||
windows=window_records,
|
windows=window_records,
|
||||||
)
|
)
|
||||||
@@ -762,8 +769,8 @@ class QuestDefinitionsDao:
|
|||||||
|
|
||||||
Mirrors :meth:`create_with_rule_and_windows`: everything runs
|
Mirrors :meth:`create_with_rule_and_windows`: everything runs
|
||||||
inside ONE transaction under the connection lock, and the
|
inside ONE transaction under the connection lock, and the
|
||||||
returned snapshot's assignees and windows are read back inside
|
returned snapshot's rule, assignees and windows are read back
|
||||||
that transaction. Arguments default to the module sentinel
|
inside that transaction. Arguments default to the module sentinel
|
||||||
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
||||||
NULL so optional metadata (description, icon) can be cleared.
|
NULL so optional metadata (description, icon) can be cleared.
|
||||||
|
|
||||||
@@ -840,9 +847,14 @@ class QuestDefinitionsDao:
|
|||||||
updated = await self.get(definition_id)
|
updated = await self.get(definition_id)
|
||||||
assignees = await self.list_assignees(definition_id)
|
assignees = await self.list_assignees(definition_id)
|
||||||
window_records = await self.list_windows(definition_id)
|
window_records = await self.list_windows(definition_id)
|
||||||
|
rule_record = await ScheduleRulesDao(self._database).get(
|
||||||
|
updated.schedule_rule_id
|
||||||
|
)
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
|
assert rule_record is not None
|
||||||
return QuestDefinitionSnapshot(
|
return QuestDefinitionSnapshot(
|
||||||
definition=updated,
|
definition=updated,
|
||||||
|
rule=rule_record,
|
||||||
assignees=assignees,
|
assignees=assignees,
|
||||||
windows=window_records,
|
windows=window_records,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ from .dao_rules import (
|
|||||||
QuestDefinitionWindowRecord,
|
QuestDefinitionWindowRecord,
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
ScheduleRuleStorage,
|
ScheduleRuleStorage,
|
||||||
ScheduleRulesDao,
|
|
||||||
_UNSET,
|
_UNSET,
|
||||||
_validate_time,
|
_validate_time,
|
||||||
_validate_window,
|
_validate_window,
|
||||||
@@ -312,16 +311,9 @@ async def edit_quest_definition(
|
|||||||
windows=window_specs,
|
windows=window_specs,
|
||||||
)
|
)
|
||||||
|
|
||||||
if rule_storage is not _UNSET:
|
decoded_rule = schedule_rule_from_storage(
|
||||||
decoded_rule = schedule_rule_from_storage(rule_storage)
|
schedule_rule_storage_from_record(snapshot.rule)
|
||||||
else:
|
)
|
||||||
rule_record = await ScheduleRulesDao(database).get(
|
|
||||||
snapshot.definition.schedule_rule_id
|
|
||||||
)
|
|
||||||
assert rule_record is not None
|
|
||||||
decoded_rule = schedule_rule_from_storage(
|
|
||||||
schedule_rule_storage_from_record(rule_record)
|
|
||||||
)
|
|
||||||
|
|
||||||
return CreatedQuestDefinition(
|
return CreatedQuestDefinition(
|
||||||
definition=snapshot.definition,
|
definition=snapshot.definition,
|
||||||
|
|||||||
@@ -776,3 +776,107 @@ def test_edit_rejects_non_int_definition_id(tmp_path, bad) -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
_with_db(tmp_path, "edit-bad-definition-id.db")(_body)
|
_with_db(tmp_path, "edit-bad-definition-id.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# edit_quest_definition: consistent snapshot and rollback (CQ-01, CQ-02)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_edit_omitted_rule_snapshot_consistent(tmp_path) -> None:
|
||||||
|
"""Two concurrent edits never return a mixed-time snapshot.
|
||||||
|
|
||||||
|
One edit changes windows only (rule omitted); the other changes the
|
||||||
|
rule only (windows omitted). Because each edit reads its rule,
|
||||||
|
assignees and windows back inside its own locked transaction, the
|
||||||
|
windows-only edit reports the rule as it was AT THAT EDIT's
|
||||||
|
transaction: the ORIGINAL rule when it ran first, the NEW rule when
|
||||||
|
it ran second. That yields a strict invariant — the windows-only
|
||||||
|
result's rule is the original exactly when the rule-only result
|
||||||
|
still saw the original windows — which a post-transaction rule read
|
||||||
|
would violate.
|
||||||
|
"""
|
||||||
|
async def _body(database, children, rules, definitions, child):
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||||
|
)
|
||||||
|
weekly = ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY,
|
||||||
|
weekday_set={2},
|
||||||
|
start_date="2026-09-14",
|
||||||
|
)
|
||||||
|
windows_result, rule_result = await asyncio.gather(
|
||||||
|
edit_quest_definition(
|
||||||
|
database,
|
||||||
|
created.definition.id,
|
||||||
|
windows=[("evening", "19:00")],
|
||||||
|
),
|
||||||
|
edit_quest_definition(database, created.definition.id, rule=weekly),
|
||||||
|
)
|
||||||
|
# Each edit reports its own change...
|
||||||
|
assert [w.window for w in windows_result.windows] == ["evening"]
|
||||||
|
assert rule_result.rule == weekly
|
||||||
|
# ...and the pair is self-consistent: the windows-only edit saw
|
||||||
|
# the original rule iff the rule-only edit saw the original
|
||||||
|
# windows (they ran in that order).
|
||||||
|
assert (windows_result.rule == _daily_rule()) == (
|
||||||
|
[w.window for w in rule_result.windows] == ["evening"]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-concurrent-snapshot.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_post_write_failure_rolls_back_everything(tmp_path) -> None:
|
||||||
|
"""A post-write failure during an edit rolls every table back together.
|
||||||
|
|
||||||
|
Deterministic trigger: call the DAO's edit directly with a window
|
||||||
|
name outside const.QUEST_WINDOWS. The DAO applies the metadata and
|
||||||
|
rule writes and the window delete, THEN validates the window name
|
||||||
|
and raises ValueError. That failure lands after several writes
|
||||||
|
inside the transaction, so the metadata, rule and window changes
|
||||||
|
must all roll back — the baseline definition, rule and windows
|
||||||
|
survive unchanged.
|
||||||
|
"""
|
||||||
|
async def _body(database, children, rules, definitions, child):
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Brush teeth",
|
||||||
|
_daily_rule(),
|
||||||
|
[child.id],
|
||||||
|
[("morning", "07:00")],
|
||||||
|
)
|
||||||
|
rule_id = created.definition.schedule_rule_id
|
||||||
|
weekly_storage = schedule_rule_to_storage(
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY,
|
||||||
|
weekday_set={2},
|
||||||
|
start_date="2026-09-14",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dao = QuestDefinitionsDao(database)
|
||||||
|
with pytest.raises(ValueError, match="window must be one of"):
|
||||||
|
await dao.edit_definition(
|
||||||
|
created.definition.id,
|
||||||
|
title="Changed",
|
||||||
|
rule=weekly_storage,
|
||||||
|
windows=[("noon", None)],
|
||||||
|
)
|
||||||
|
stored = await definitions.get(created.definition.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.title == "Brush teeth"
|
||||||
|
rule_row = await rules.get(rule_id)
|
||||||
|
assert rule_row is not None
|
||||||
|
assert rule_row.rule_type == "daily"
|
||||||
|
assert rule_row.weekday_set is None
|
||||||
|
by_name = {
|
||||||
|
w.window: w.due_time
|
||||||
|
for w in await definitions.list_windows(created.definition.id)
|
||||||
|
}
|
||||||
|
assert by_name == {"morning": "07:00"}
|
||||||
|
assert [
|
||||||
|
c.id for c in await definitions.list_assignees(created.definition.id)
|
||||||
|
] == [child.id]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "edit-post-write-rollback.db")(_body)
|
||||||
|
|||||||
Reference in New Issue
Block a user