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,
|
The rollout-specific backup hashes, deployed commit, causal operation,
|
||||||
generation/ACK identifiers and restart/non-resurrection results are appended
|
generation/ACK identifiers and restart/non-resurrection results are appended
|
||||||
after the controlled private rollout. No public tag or release is created.
|
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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
IMPORTER = ROOT / "tools/import_mobile_export.py"
|
||||||
EXPORTER = ROOT / "tools/export_pc_mobile.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
|
from test_mobile_import_variable_sets import SCHEMA
|
||||||
|
|
||||||
V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """
|
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 "
|
"SELECT target_id,current_revision_id,deleted FROM sync_causal_state "
|
||||||
"WHERE target_kind='session' ORDER BY target_id"
|
"WHERE target_kind='session' ORDER BY target_id"
|
||||||
).fetchall() == revisions_before
|
).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"
|
exported = root / "pc-v3.json"
|
||||||
run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)])
|
run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)])
|
||||||
roundtrip = json.loads(exported.read_text())
|
roundtrip = json.loads(exported.read_text())
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,10 @@ class GenerationTest(unittest.TestCase):
|
||||||
filename=name+".json";(directory/filename).write_text("{}")
|
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})
|
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}
|
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)), \
|
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.causal_delete_exchange,"load",return_value={"operations":[]}), \
|
||||||
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \
|
mock.patch.object(generation.execution_draft_exchange,"load",return_value={}), \
|
||||||
|
|
|
||||||
|
|
@ -1960,7 +1960,30 @@ def import_body(
|
||||||
report["body_imported"] += 1
|
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."""
|
"""Apply a validated artifact without owning commit or rollback."""
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
report = {
|
report = {
|
||||||
|
|
@ -1988,9 +2011,17 @@ def apply_payload(connection, payload, trace_exercises=False):
|
||||||
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
|
{("body_observation", value["observation_id"]) for value in payload["body_observations"]} |
|
||||||
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
|
{("exercise", value["exercise_id"]) for value in payload["exercises"]})
|
||||||
collision = protected & incoming
|
collision = protected & incoming
|
||||||
if collision:
|
if collision and not complete_causal_envelope:
|
||||||
kind, identity = sorted(collision)[0]
|
kind, identity = sorted(collision)[0]
|
||||||
raise ImportFailure(f"causal protection refuses legacy/live replay: {kind}/{identity}")
|
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"])
|
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:
|
if has_explicit_max and schema_version < 9:
|
||||||
raise ImportFailure("max_weight_kg exige le schéma desktop v9")
|
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)
|
causal_delete_exchange.apply_operation(db, operation)
|
||||||
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
|
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
|
||||||
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=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_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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue