Honor session tombstones in association companions
This commit is contained in:
parent
457abc0a51
commit
736d209675
4 changed files with 100 additions and 4 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
"""Exercise the real desktop companion exporter/importer on temporary DBs."""
|
"""Exercise the real desktop companion exporter/importer on temporary DBs."""
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -7,6 +8,14 @@ import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"import_equipment_associations",
|
||||||
|
ROOT / "tools/import_equipment_associations.py",
|
||||||
|
)
|
||||||
|
ASSOCIATIONS = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(ASSOCIATIONS)
|
||||||
SOURCE_ID = "ex_43c7375f-934c-4650-930c-45807d2f2929"
|
SOURCE_ID = "ex_43c7375f-934c-4650-930c-45807d2f2929"
|
||||||
CANONICAL_ID = "ex_2d488c08-194c-4051-a3c9-34471646c1d3"
|
CANONICAL_ID = "ex_2d488c08-194c-4051-a3c9-34471646c1d3"
|
||||||
|
|
||||||
|
|
@ -236,6 +245,67 @@ def main():
|
||||||
)
|
)
|
||||||
assert exported.returncode == 0, exported.stdout + exported.stderr
|
assert exported.returncode == 0, exported.stdout + exported.stderr
|
||||||
assert json.loads(custom_artifact.read_text())['associations'][0]['equipment_id'] == 'custom_rack'
|
assert json.loads(custom_artifact.read_text())['associations'][0]['equipment_id'] == 'custom_rack'
|
||||||
|
|
||||||
|
# A complete causal envelope may contain an older association snapshot
|
||||||
|
# for a session whose tombstone was applied earlier in the transaction.
|
||||||
|
# The same companion remains invalid as a standalone import.
|
||||||
|
deleted_session = 'se_deleted'
|
||||||
|
deleted_entry = 'sxe_deleted'
|
||||||
|
dominated_payload = {
|
||||||
|
'format': 'trainlog-equipment-associations',
|
||||||
|
'version': 2,
|
||||||
|
'generated_at': '2026-01-01T00:00:00+00:00',
|
||||||
|
'associations': [{
|
||||||
|
'session_id': deleted_session,
|
||||||
|
'entry_id': deleted_entry,
|
||||||
|
'exercise_id': 'ex_fixture',
|
||||||
|
'state': 'set',
|
||||||
|
'equipment_id': 'leg_press',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
causal_db = sqlite3.connect(':memory:')
|
||||||
|
causal_db.executescript("""
|
||||||
|
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
|
||||||
|
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE);
|
||||||
|
CREATE TABLE session_exercises(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
entry_id TEXT UNIQUE,
|
||||||
|
session_row_id INTEGER,
|
||||||
|
exercise_row_id INTEGER,
|
||||||
|
equipment_id TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY);
|
||||||
|
CREATE TABLE sync_causal_state(
|
||||||
|
target_kind TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
current_revision_id TEXT,
|
||||||
|
deleted INTEGER,
|
||||||
|
operation_id TEXT,
|
||||||
|
PRIMARY KEY(target_kind, target_id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
causal_db.execute(
|
||||||
|
"INSERT INTO sync_causal_state VALUES('session',?,'del_fixture',1,'del_fixture')",
|
||||||
|
(deleted_session,),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ASSOCIATIONS.apply_associations(
|
||||||
|
causal_db,
|
||||||
|
dominated_payload,
|
||||||
|
{'leg_press'},
|
||||||
|
{(deleted_session, deleted_entry): 'ex_fixture'},
|
||||||
|
)
|
||||||
|
raise AssertionError('standalone missing association was accepted')
|
||||||
|
except ValueError as error:
|
||||||
|
assert 'entrée séance inconnue' in str(error)
|
||||||
|
assert ASSOCIATIONS.apply_associations(
|
||||||
|
causal_db,
|
||||||
|
dominated_payload,
|
||||||
|
{'leg_press'},
|
||||||
|
{(deleted_session, deleted_entry): 'ex_fixture'},
|
||||||
|
complete_causal_envelope=True,
|
||||||
|
) == 1
|
||||||
|
causal_db.close()
|
||||||
print('PASS equipment association exchange')
|
print('PASS equipment association exchange')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -230,11 +230,12 @@ class GenerationTest(unittest.TestCase):
|
||||||
mock.patch.object(generation.import_equipment_definitions,"apply_definitions"), \
|
mock.patch.object(generation.import_equipment_definitions,"apply_definitions"), \
|
||||||
mock.patch.object(generation.import_mobile_export,"apply_payload",side_effect=write_fact), \
|
mock.patch.object(generation.import_mobile_export,"apply_payload",side_effect=write_fact), \
|
||||||
mock.patch.object(generation.import_exercise_aliases,"apply_aliases"), \
|
mock.patch.object(generation.import_exercise_aliases,"apply_aliases"), \
|
||||||
mock.patch.object(generation.import_equipment_associations,"apply_associations"), \
|
mock.patch.object(generation.import_equipment_associations,"apply_associations") as apply_associations, \
|
||||||
mock.patch.object(generation.import_exercise_body_zones,"apply_body_zones"), \
|
mock.patch.object(generation.import_exercise_body_zones,"apply_body_zones"), \
|
||||||
mock.patch.object(generation.import_training_feedback,"apply_feedback",side_effect=ValueError("late semantic failure")):
|
mock.patch.object(generation.import_training_feedback,"apply_feedback",side_effect=ValueError("late semantic failure")):
|
||||||
rejected=generation.consume_desktop(self.database,directory)
|
rejected=generation.consume_desktop(self.database,directory)
|
||||||
self.assertEqual("rejected",rejected["result"]);self.assertIn("late semantic",rejected["diagnostic"])
|
self.assertEqual("rejected",rejected["result"]);self.assertIn("late semantic",rejected["diagnostic"])
|
||||||
|
self.assertTrue(apply_associations.call_args.kwargs["complete_causal_envelope"])
|
||||||
with closing(sqlite3.connect(self.database)) as db:
|
with closing(sqlite3.connect(self.database)) as db:
|
||||||
self.assertEqual(0,db.execute("SELECT COUNT(*) FROM facts WHERE id='partial'").fetchone()[0])
|
self.assertEqual(0,db.execute("SELECT COUNT(*) FROM facts WHERE id='partial'").fetchone()[0])
|
||||||
self.assertEqual(1,db.execute("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'").fetchone()[0])
|
self.assertEqual(1,db.execute("SELECT COUNT(*) FROM sync_consumed_generations WHERE result='rejected'").fetchone()[0])
|
||||||
|
|
|
||||||
|
|
@ -117,14 +117,33 @@ def load_mobile_occurrences(path):
|
||||||
return occurrences
|
return occurrences
|
||||||
|
|
||||||
|
|
||||||
def apply_associations(connection, payload, known, mobile_occurrences):
|
def session_is_causally_deleted(connection, session_id):
|
||||||
|
"""Return whether durable causal state dominates one historical session."""
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT deleted FROM sync_causal_state "
|
||||||
|
"WHERE target_kind='session' AND target_id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None and row[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def apply_associations(connection, payload, known, mobile_occurrences,
|
||||||
|
complete_causal_envelope=False):
|
||||||
if payload.get("format") != FORMAT or payload.get("version") != VERSION or set(payload) != {"format", "version", "generated_at", "associations"}:
|
if payload.get("format") != FORMAT or payload.get("version") != VERSION or set(payload) != {"format", "version", "generated_at", "associations"}:
|
||||||
fail("extension équipement non supportée")
|
fail("extension équipement non supportée")
|
||||||
known = set(known); known.update(row[0] for row in connection.execute("SELECT equipment_id FROM custom_equipment"))
|
known = set(known); known.update(row[0] for row in connection.execute("SELECT equipment_id FROM custom_equipment"))
|
||||||
associations = validate_associations(payload["associations"], known)
|
associations = validate_associations(payload["associations"], known)
|
||||||
for session_id, entry_id, exercise_id, equipment_id in associations:
|
for session_id, entry_id, exercise_id, equipment_id in associations:
|
||||||
exists = connection.execute("SELECT e.exercise_id,se.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?", (session_id, entry_id)).fetchone()
|
exists = connection.execute("SELECT e.exercise_id,se.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE s.session_id=? AND se.entry_id=?", (session_id, entry_id)).fetchone()
|
||||||
if exists is None: fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
if exists is None:
|
||||||
|
# INVARIANT: a complete generation applies its causal operations
|
||||||
|
# before snapshot companions. An association owned by a deleted
|
||||||
|
# session is therefore an older live fact and cannot resurrect or
|
||||||
|
# invalidate that tombstone. Standalone imports remain strict.
|
||||||
|
if complete_causal_envelope and session_is_causally_deleted(
|
||||||
|
connection, session_id):
|
||||||
|
continue
|
||||||
|
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
||||||
stored_canonical = canonical_exercise_id(connection, exists[0]); incoming_canonical = canonical_exercise_id(connection, exercise_id)
|
stored_canonical = canonical_exercise_id(connection, exists[0]); incoming_canonical = canonical_exercise_id(connection, exercise_id)
|
||||||
if stored_canonical != incoming_canonical:
|
if stored_canonical != incoming_canonical:
|
||||||
proof = mobile_occurrences.get((session_id, entry_id))
|
proof = mobile_occurrences.get((session_id, entry_id))
|
||||||
|
|
|
||||||
|
|
@ -641,7 +641,13 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
||||||
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)
|
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)
|
||||||
import_exercise_profile_state.apply_profile_state(db, profile)
|
import_exercise_profile_state.apply_profile_state(db, profile)
|
||||||
import_exercise_aliases.apply_aliases(db, aliases)
|
import_exercise_aliases.apply_aliases(db, aliases)
|
||||||
import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)
|
import_equipment_associations.apply_associations(
|
||||||
|
db,
|
||||||
|
association_payload,
|
||||||
|
reserved,
|
||||||
|
mobile_occurrences,
|
||||||
|
complete_causal_envelope=True,
|
||||||
|
)
|
||||||
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
|
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
|
||||||
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
|
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
|
||||||
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
|
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue