Honor causal tombstones in full generation snapshots
This commit is contained in:
parent
68af06b470
commit
2c0fa714c3
5 changed files with 72 additions and 4 deletions
|
|
@ -44,3 +44,12 @@ exposing SQL or paths.
|
|||
The rollout-specific backup hashes, deployed commit, causal operation,
|
||||
generation/ACK identifiers and restart/non-resurrection results are appended
|
||||
after the controlled private rollout. No public tag or release is created.
|
||||
|
||||
During the first post-delete hardware exchange, the desktop correctly rejected
|
||||
Android's still-live snapshot with `causal protection refuses legacy/live
|
||||
replay`. This exposed a missing full-envelope dominance rule: Android publishes
|
||||
its half before consuming the desktop tombstone in the same conversation. The
|
||||
production full-generation consumer now filters only live facts already
|
||||
dominated by causal state applied from that complete envelope; standalone
|
||||
snapshot imports retain their strict rejection. A regression exercises this
|
||||
ordering and proves the tombstone and absent session remain unchanged.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
||||
EXPORTER = ROOT / "tools/export_pc_mobile.py"
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import import_mobile_export
|
||||
from test_mobile_import_variable_sets import SCHEMA
|
||||
|
||||
V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """
|
||||
|
|
@ -163,6 +165,29 @@ def main():
|
|||
"SELECT target_id,current_revision_id,deleted FROM sync_causal_state "
|
||||
"WHERE target_kind='session' ORDER BY target_id"
|
||||
).fetchall() == revisions_before
|
||||
|
||||
# A full generation applies causal operations before its mobile
|
||||
# snapshot. The older live fact is dominated rather than rejected
|
||||
# or reinserted; standalone import keeps the stricter collision.
|
||||
con.execute("DELETE FROM sessions WHERE session_id='se_v3'")
|
||||
con.execute(
|
||||
"UPDATE sync_causal_state SET current_revision_id='del_fixture',"
|
||||
"deleted=1,operation_id='del_fixture' WHERE target_kind='session' "
|
||||
"AND target_id='se_v3'"
|
||||
)
|
||||
report = import_mobile_export.apply_payload(
|
||||
con,
|
||||
import_mobile_export.load_payload(artifact),
|
||||
complete_causal_envelope=True,
|
||||
)
|
||||
assert report["sessions_imported"] == 0
|
||||
assert con.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE session_id='se_v3'"
|
||||
).fetchone()[0] == 0
|
||||
assert tuple(con.execute(
|
||||
"SELECT current_revision_id,deleted FROM sync_causal_state "
|
||||
"WHERE target_kind='session' AND target_id='se_v3'"
|
||||
).fetchone()) == ("del_fixture", 1)
|
||||
exported = root / "pc-v3.json"
|
||||
run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)])
|
||||
roundtrip = json.loads(exported.read_text())
|
||||
|
|
|
|||
|
|
@ -210,7 +210,10 @@ class GenerationTest(unittest.TestCase):
|
|||
filename=name+".json";(directory/filename).write_text("{}")
|
||||
artifacts.append({"logical_name":name,"format":"test","version":1,"filename":f"generations/{GEN}/{filename}","size":2,"sha256":"0"*64,"required":True})
|
||||
manifest={"format":generation.MANIFEST,"version":1,"generation_id":GEN,"run_id":RUN,"producer":{"peer_id":PEER_A,"kind":"android"},"consumer_peer_id":PEER_B,"generated_at":"2026-09-17T10:00:00+02:00","parent_generation_id":None,"artifacts":artifacts}
|
||||
def write_fact(db,_payload,_trace=False):db.execute("INSERT INTO facts VALUES('partial','must roll back')");return {}
|
||||
def write_fact(db, _payload, _trace=False, complete_causal_envelope=False):
|
||||
self.assertTrue(complete_causal_envelope)
|
||||
db.execute("INSERT INTO facts VALUES('partial','must roll back')")
|
||||
return {}
|
||||
with mock.patch.object(generation,"validate_published",return_value=(manifest,"a"*64)), \
|
||||
mock.patch.object(generation.causal_delete_exchange,"load",return_value={"operations":[]}), \
|
||||
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \
|
||||
|
|
|
|||
|
|
@ -1960,7 +1960,30 @@ def import_body(
|
|||
report["body_imported"] += 1
|
||||
|
||||
|
||||
def apply_payload(connection, payload, trace_exercises=False):
|
||||
def causal_snapshot_view(payload, protected):
|
||||
"""Return the live subset of a snapshot after its causal envelope."""
|
||||
filtered = dict(payload)
|
||||
filtered["exercises"] = [
|
||||
value for value in payload["exercises"]
|
||||
if ("exercise", value["exercise_id"]) not in protected
|
||||
]
|
||||
filtered["sessions"] = [
|
||||
value for value in payload["sessions"]
|
||||
if ("session", value["session_id"]) not in protected
|
||||
]
|
||||
filtered["body_observations"] = []
|
||||
for value in payload["body_observations"]:
|
||||
if ("body_observation", value["observation_id"]) in protected:
|
||||
continue
|
||||
if payload["version"] == 4 and value["session_id"] is not None and \
|
||||
("session", value["session_id"]) in protected:
|
||||
value = dict(value)
|
||||
value["session_id"] = None
|
||||
filtered["body_observations"].append(value)
|
||||
return filtered
|
||||
|
||||
|
||||
def apply_payload(connection, payload, trace_exercises=False, complete_causal_envelope=False):
|
||||
"""Apply a validated artifact without owning commit or rollback."""
|
||||
connection.row_factory = sqlite3.Row
|
||||
report = {
|
||||
|
|
@ -1988,9 +2011,17 @@ def apply_payload(connection, payload, trace_exercises=False):
|
|||
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
|
||||
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
|
||||
collision = protected & incoming
|
||||
if collision:
|
||||
if collision and not complete_causal_envelope:
|
||||
kind, identity = sorted(collision)[0]
|
||||
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
|
||||
if complete_causal_envelope:
|
||||
# WHY: Android publishes its snapshot before consuming this run's
|
||||
# desktop half, so that snapshot may still contain objects deleted
|
||||
# by the causal artifact already applied above. CONTRACT: only the
|
||||
# complete generation consumer may discard those dominated live
|
||||
# facts. INVARIANT: the durable tombstone remains authoritative and
|
||||
# ordinary standalone imports continue to reject the collision.
|
||||
payload = causal_snapshot_view(payload, protected)
|
||||
has_explicit_max = any("max_weight_kg" in entry for session in payload["sessions"] for entry in session["exercises"])
|
||||
if has_explicit_max and schema_version < 9:
|
||||
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
|
||||
|
|
|
|||
|
|
@ -633,7 +633,7 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
causal_delete_exchange.apply_operation(db, operation)
|
||||
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
|
||||
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
|
||||
import_mobile_export.apply_payload(db, history)
|
||||
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)
|
||||
import_exercise_profile_state.apply_profile_state(db, profile)
|
||||
import_exercise_aliases.apply_aliases(db, aliases)
|
||||
import_equipment_associations.apply_associations(db, association_payload, reserved, mobile_occurrences)
|
||||
|
|
|
|||
Loading…
Reference in a new issue