fix(sync): bound slow MTP rollout and retained generations

This commit is contained in:
fy59 2026-10-01 08:20:38 +02:00
parent df41300353
commit 3a4ec34eb9
8 changed files with 31 additions and 13 deletions

View file

@ -5,9 +5,13 @@
The isolated `trainlog-program-recalage-v1` candidate adds an explicit The isolated `trainlog-program-recalage-v1` candidate adds an explicit
preview-and-confirm cascade for unfinished Program sessions. It retains source preview-and-confirm cascade for unfinished Program sessions. It retains source
and actual dates, stores absolute current dates and ceded-slot state, and and actual dates, stores absolute current dates and ceded-slot state, and
selects a separate Programs V2 Android projection by capability. This candidate selects a separate Programs V2 Android projection by capability. The source
is not merged into the stable installation or deployed to Android. candidate remains isolated from the stable checkout while its private desktop
Rollout packaging now rejects a native build without the embedded Web frontend. and compatible Android build are installed for the coordinated rollout.
Rollout packaging rejects a native build without the embedded Web frontend.
The private rollout preserves parsed V1 Program number types during Android V2
upgrade and raises the bounded local MTP operation budget to 90 seconds and
retained outgoing generation capacity to 16 after interrupted exchanges.
Development opened after the stable v0.1.6 release. This line is restricted to Development opened after the stable v0.1.6 release. This line is restricted to
observed bug fixes, regression fixes, directly related small UI corrections, observed bug fixes, regression fixes, directly related small UI corrections,

View file

@ -59,7 +59,7 @@ callbacks complete before the mutable generation reference is published; the
reference and Android ACK use the same confirmation. Bounded diagnostic logs reference and Android ACK use the same confirmation. Bounded diagnostic logs
contain only run/generation identities, artifact names, phases and results. contain only run/generation identities, artifact names, phases and results.
The desktop worker performs lightweight local correlation checks continuously The desktop worker performs lightweight local correlation checks continuously
but opens a full MTP pull no more than once every three seconds. A 30-second but opens a full MTP pull no more than once every three seconds. A 90-second
individual adapter budget is distinct from the overall conversation deadline, individual adapter budget is distinct from the overall conversation deadline,
so an absent correlated generation reports a protocol wait timeout while an so an absent correlated generation reports a protocol wait timeout while an
actually blocked adapter reports `transport_timeout`. actually blocked adapter reports `transport_timeout`.

View file

@ -1112,7 +1112,7 @@ named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history. Android generations, desktop publications, or unrelated exchange history.
Local correlation is checked every 50 ms without USB work; complete MTP pulls Local correlation is checked every 50 ms without USB work; complete MTP pulls
are throttled to a three-second cadence. Each MTP operation has its own are throttled to a three-second cadence. Each MTP operation has its own
30-second ceiling while the conversation retains its configured global 90-second ceiling while the conversation retains its configured global
deadline. Exhausting the global deadline while waiting for a correlated deadline. Exhausting the global deadline while waiting for a correlated
reference reports `timeout waiting for correlated android-generation-v1.json`; reference reports `timeout waiting for correlated android-generation-v1.json`;
only an adapter that exceeds its independent operation budget reports only an adapter that exceeds its independent operation budget reports

View file

@ -110,7 +110,7 @@ These are unsigned development evidence, not deployment authorization.
Do not delete an unacknowledged generation, the last acknowledged generation, Do not delete an unacknowledged generation, the last acknowledged generation,
its previous valid generation, or causal evidence. An ACK timeout is unresolved, its previous valid generation, or causal evidence. An ACK timeout is unresolved,
not rollback. Eight retained outgoing generations exhaust admission and require not rollback. Sixteen retained outgoing generations exhaust admission and require
operator diagnosis; this tranche adds no tombstone or evidence garbage operator diagnosis; this tranche adds no tombstone or evidence garbage
collection. A migrated database is recovered from its verified backup, never by collection. A migrated database is recovered from its verified backup, never by
assuming an older binary reverses its migration. assuming an older binary reverses its migration.

View file

@ -128,21 +128,25 @@ class SyncErrorClassificationTest(unittest.TestCase):
) )
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self): def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30) operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS
timeout = subprocess.TimeoutExpired(
["adapter", "push"], operation_budget,
)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
transport = Path(directory) / "transport" transport = Path(directory) / "transport"
transport.mkdir() transport.mkdir()
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout): with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
r"^transport_timeout: MTP push did not finish within 30 seconds$", rf"^transport_timeout: MTP push did not finish within "
rf"{operation_budget:g} seconds$",
) as caught: ) as caught:
sync_peer_worker.run_adapter( sync_peer_worker.run_adapter(
Path("/adapter"), Path("/adapter"),
"push", "push",
"peer_00000000-0000-4000-8000-000000000001", "peer_00000000-0000-4000-8000-000000000001",
transport, transport,
sync_peer_worker.time.monotonic() + 60, sync_peer_worker.time.monotonic() + operation_budget + 10,
) )
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout") self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")

View file

@ -58,7 +58,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run: with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
worker.run_adapter( worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed", Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 100.0, clock=lambda: 50.0, self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
clock=lambda: 50.0,
) )
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"]) self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
@ -112,7 +113,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "transport_timeout"): with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
worker.run_adapter( worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed", Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 100.0, clock=lambda: 50.0, self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
clock=lambda: 50.0,
) )
def test_delayed_correlated_generation_survives_multiple_polls(self): def test_delayed_correlated_generation_survives_multiple_polls(self):

View file

@ -51,7 +51,11 @@ MAX_GENERATION = 256 * 1024 * 1024
MAX_NAME = 64 MAX_NAME = 64
MAX_PATH = 240 MAX_PATH = 240
MAX_DIAGNOSTIC = 1024 MAX_DIAGNOSTIC = 1024
MAX_RETAINED_PER_PEER = 8 # WHY: interrupted MTP publications can leave immutable generations awaiting
# an ACK alongside the two acknowledged lineage members kept for recovery.
# CONTRACT: admission remains bounded and never erases unresolved generations.
# INVARIANT: captured, published and waiting generations still consume a slot.
MAX_RETAINED_PER_PEER = 16
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HEX = re.compile(r"^[0-9a-f]{64}$") HEX = re.compile(r"^[0-9a-f]{64}$")

View file

@ -37,7 +37,11 @@ CAPS = {
ACK_RECOVERY_CAP = "generation-ack-recovery-v1" ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
MTP_OPERATION_TIMEOUT_SECONDS = 30.0 # WHY: a verified personal phone can need more than 30 seconds to publish an
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
# call independently of the configured overall conversation deadline.
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
MTP_POLL_INTERVAL_SECONDS = 3.0 MTP_POLL_INTERVAL_SECONDS = 3.0