-
-
Notifications
You must be signed in to change notification settings - Fork 75
offload data loss fixes #235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3ccc263
d2ccec4
04a887e
a974918
a434800
4928f28
52ee804
161ded7
fe5510b
ed45455
72774a9
25a75f4
90f9588
8573d7e
997e149
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -155,6 +155,33 @@ bool burstPacketCountMatches({ | |
| }) => | ||
| expectedPacketCount == actualBurstPacketCount + droppedThisBurst; | ||
|
|
||
| /// Honest burst-completeness signal for TELEMETRY ONLY — this NEVER gates the | ||
| /// commit/ACK decision (see the log-only call site). | ||
| /// | ||
| /// [receivedTrafficCount] is every frame we actually received this burst, ALL | ||
| /// types (historical R24 data + interleaved console/event/unknown) — i.e. | ||
| /// [BurstStats.totalTrafficPacketCount], NOT the banked historical subset. The | ||
| /// band's [expectedPacketCount] (num_packets) likewise counts every frame it | ||
| /// transmitted, so comparing the two all-types totals is type-agnostic and | ||
| /// interleaving-immune: benign console/event frames riding along cannot fake a | ||
| /// shortfall the way comparing against the R24-only subset did. | ||
| /// | ||
| /// [droppedThisBurst] (RecordGate plausibility rejections this burst) is added | ||
| /// back because the band counted those frames but they never entered | ||
| /// [receivedTrafficCount]. A POSITIVE result is frames the band counted that we | ||
| /// did NOT count as valid received traffic — i.e. missing OR corrupted traffic | ||
| /// (would-flag / potential loss): CRC-failed frames also never enter | ||
| /// [receivedTrafficCount], so a positive shortfall cannot by itself prove a | ||
| /// frame never arrived. Zero is complete; negative just means we tallied more | ||
| /// than expected (retried/duplicate frames), which is not loss. | ||
| @visibleForTesting | ||
| int burstPacketShortfall({ | ||
| required int expectedPacketCount, | ||
| required int receivedTrafficCount, | ||
| int droppedThisBurst = 0, | ||
| }) => | ||
| expectedPacketCount - (receivedTrafficCount + droppedThisBurst); | ||
|
|
||
| /// Fired for every LIVE high-rate frame (0x28/0x2B/0x33). These are EPHEMERAL — | ||
| /// they are NOT persisted to raw_records (that bloated storage ~50x and stalled | ||
| /// derivation). The caller routes them to an in-memory sink for the live UI / | ||
|
|
@@ -820,6 +847,20 @@ class BleEngine { | |
| // Lifetime count of GET_CLOCK `clock_epoch` reads rejected by the same gate | ||
| // (ClockPolicy.acceptsClockRead) — see the clock_epoch handler below. | ||
| int _corruptClockReadCount = 0; | ||
| // True when the last GET_CLOCK showed a plausible strap RTC reading > 1 day in | ||
| // the FUTURE relative to the phone — the phone clock is likely wrong (slow), so | ||
| // history offload is DEFERRED (not drained-and-trimmed) until the clocks agree. | ||
| // See ClockPolicy.phoneClockSuspect and _startHistoricalRefresh. | ||
| bool _phoneClockSuspect = false; | ||
| DateTime? _phoneClockSuspectSince; | ||
| bool get historyPausedForClock => _deferForClock; | ||
| /// Defer history only while the disagreement is still young. A slow phone | ||
| /// re-syncs over NTP in minutes; one that persists past the grace window is a | ||
| /// strap RTC running fast, and deferring forever would stall sync for good. | ||
| bool get _deferForClock => | ||
| _phoneClockSuspect && | ||
| !ClockPolicy.suspectGraceExpired(_phoneClockSuspectSince, DateTime.now()); | ||
| int _clockPausedOffloads = 0; // diagnostics: offloads deferred for this reason | ||
| DateTime? _bondTime; // when the handshake completed (bond confirmed) | ||
| DateTime? _armTime; // when live (R10/R11) streams were last armed | ||
| // Run-state for a chain of auto-continued offload rounds: how many | ||
|
|
@@ -1311,10 +1352,20 @@ class BleEngine { | |
| _clockCorrectTries = 0; // fresh retry budget for this connection | ||
| // Drop the previous session's clock correlation so an alarm armed before | ||
| // THIS session's GET_CLOCK reply lands falls back to the raw wall epoch | ||
| // (drift 0) instead of the stale strap-RTC frame. setClock()→getClock() | ||
| // below repopulates it for this connection. | ||
| // (drift 0) instead of the stale strap-RTC frame. The reads below | ||
| // repopulate it for this connection. | ||
| _clockRef = null; | ||
| await setClock(); | ||
| // READ BEFORE WRITE. This used to be an unconditional SET_CLOCK, which is | ||
| // precisely the write [ClockPolicy.phoneClockSuspect] says we must never | ||
| // make: on a phone running >1 day slow it stamps that slow time onto a | ||
| // CORRECT strap RTC — and worse, it destroys the evidence, because the | ||
| // read-back then "agrees" and every later suspect-clock gate sees a | ||
| // healthy pair. Read first; skip the write while the PHONE is the suspect | ||
| // one. Unset/behind/garbage-low RTCs are unaffected (not suspect) and are | ||
| // still corrected here and by the clock_epoch handler's bounded re-issue. | ||
| await getClock(); | ||
| await Future.delayed(const Duration(milliseconds: 120)); | ||
| if (!_deferForClock) await setClock(); | ||
| _lastClockVerifyAt = DateTime.now(); | ||
| // Per-connection policy reset. Marginal-radio + post-bond-loop are NOT reset | ||
| // here — they count consecutive bad cycles across reconnects and self-reset on | ||
|
|
@@ -1393,9 +1444,25 @@ class BleEngine { | |
| ); | ||
| _setPhase(BleConnState.listening); | ||
| _log('Connected + subscribed — listening (history + live).'); | ||
| _setOffloadActive(true); | ||
| _lastBackfillAt = _wallSecs(); | ||
| await sendInit(); // triggers the historical offload flood | ||
| // INIT seq4 IS SEND_HISTORICAL_DATA, so it needs the SAME data-safety gate | ||
| // as _startHistoricalRefresh — without it every fresh connection drains | ||
| // and trims under exactly the untrustworthy phone clock we refuse to drain | ||
| // under there, which is the common case (a dead-battery reboot lands a bad | ||
| // clock and a reconnect together). | ||
| final drainOnInit = !_deferForClock; | ||
| if (!drainOnInit) { | ||
| _clockPausedOffloads++; | ||
| _log( | ||
| '[SYNC] INIT drain DEFERRED — phone clock appears wrong relative to ' | ||
| 'the strap RTC; not draining history until they agree ' | ||
| '(deferred_total=$_clockPausedOffloads).', | ||
| ); | ||
| } | ||
| _setOffloadActive(drainOnInit); | ||
| // Only a real drain spends the backfill floor; a deferred one leaves it | ||
| // open so a foreground trigger can retry as soon as the phone corrects. | ||
| if (drainOnInit) _lastBackfillAt = _wallSecs(); | ||
| await sendInit(drain: drainOnInit); // seq4 triggers the offload flood | ||
| return true; | ||
| } catch (e) { | ||
| _log('connect setup failed: $e'); | ||
|
|
@@ -1565,6 +1632,27 @@ class BleEngine { | |
| // has time to emit the range response before we request another drain. | ||
| await Future.delayed(const Duration(milliseconds: 120)); | ||
| } | ||
| // Data-safety gate: never drain-and-trim history under an untrustworthy phone | ||
| // clock. Poll the strap RTC and compare; if the phone clock looks slow (strap | ||
| // plausible but > 1 day ahead), DEFER — draining now would drop the strap's | ||
| // real records as "future" and the ACK would trim them off the band forever. | ||
| // The strap retains everything; we drain on a later refresh once the clocks | ||
| // agree (the phone's clock almost always self-corrects via NTP). SET_CLOCK is | ||
| // deliberately NOT issued here — pushing the strap back to the slow phone | ||
| // would corrupt a correct RTC (see ClockPolicy.phoneClockSuspect). | ||
| await _send(Cmd.getClock, const <int>[]); | ||
| await Future.delayed(const Duration(milliseconds: 120)); | ||
| if (_session?.connected != true) return; | ||
| if (_deferForClock) { | ||
| _clockPausedOffloads++; | ||
| _log( | ||
| '[SYNC] refresh($reason) DEFERRED — phone clock appears wrong relative ' | ||
| 'to the strap RTC; not draining history until they agree ' | ||
| '(deferred_total=$_clockPausedOffloads).', | ||
| ); | ||
| _setOffloadActive(false); | ||
| return; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| final wait = HistoricalSyncCommandPolicy.waitSeconds( | ||
| _lastHistoricalSendAt, | ||
| _wallSecs(), | ||
|
|
@@ -2209,6 +2297,26 @@ class BleEngine { | |
| if (f.containsKey('clock_epoch')) { | ||
| final dev = f['clock_epoch'] as int; | ||
| final wall = DateTime.now().millisecondsSinceEpoch ~/ 1000; | ||
| // Assess phone-clock trust from the RAW read, before the alarm-safety gate | ||
| // below diverts a future reading. A plausible strap RTC that reads > 1 day | ||
| // ahead of the phone means the phone clock is likely slow — history offload | ||
| // then DEFERS (see _startHistoricalRefresh) instead of dropping the strap's | ||
| // real records as "future" and trimming them off the band. Cleared the | ||
| // moment a read agrees (the phone almost always self-corrects via NTP). | ||
| final wasSuspect = _phoneClockSuspect; | ||
| _phoneClockSuspect = ClockPolicy.phoneClockSuspect(dev, wall); | ||
| if (_phoneClockSuspect && !wasSuspect) { | ||
| _phoneClockSuspectSince = DateTime.now(); | ||
| } else if (!_phoneClockSuspect) { | ||
| _phoneClockSuspectSince = null; | ||
|
Comment on lines
+2306
to
+2311
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical Gate response-driven SET_CLOCK retries on the grace policy. After these lines set Require Suggested guard- if (ClockPolicy.shouldSetClock(dev, wall)) {
+ if (!_deferForClock && ClockPolicy.shouldSetClock(dev, wall)) {As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| if (_phoneClockSuspect != wasSuspect) { | ||
| _log(_phoneClockSuspect | ||
| ? '[SYNC] Phone clock appears wrong: strap RTC=$dev is > 1 day ahead ' | ||
| 'of phone wall=$wall — DEFERRING history offload until they agree.' | ||
| : '[SYNC] Phone/strap clocks agree again (strap=$dev wall=$wall) — ' | ||
| 'history offload may resume.'); | ||
| } | ||
| // SANITY GATE, mirroring the one `range_newest` gets below. An | ||
| // implausibly far-future `clock_epoch` yields a large NEGATIVE driftSec, | ||
| // and setAlarm arms at `when - driftSec` — years out, where the alarm | ||
|
|
@@ -2596,6 +2704,20 @@ class BleEngine { | |
| expectedPacketCount: expected, | ||
| droppedThisBurst: droppedThisBurst, | ||
| ); | ||
| // Honest, LOG-ONLY completeness signal (never gates the ACK). Compares | ||
| // num_packets against the ALL-TYPES received total (currentBurstTrafficCount), | ||
| // not the banked R24 subset — see burstPacketShortfall. Only a POSITIVE | ||
| // shortfall means frames the band counted that we did not count as valid | ||
| // received traffic (missing OR CRC-corrupted — potential loss); this is | ||
| // the signal we want visible in telemetry BEFORE ever wiring a FAIL gate | ||
| // (which needs its own design + field validation to avoid re-flood). | ||
| final shortfall = expected == null | ||
| ? 0 | ||
| : burstPacketShortfall( | ||
| expectedPacketCount: expected, | ||
| receivedTrafficCount: d.currentBurstTrafficCount, | ||
| droppedThisBurst: droppedThisBurst, | ||
| ); | ||
| // ADVISORY ONLY, never a gate: `expectedPacketCount`'s exact semantics | ||
| // (which transport packet types the band itself counts — command | ||
| // responses interleaved with the burst? retried/duplicate frames?) are | ||
|
|
@@ -2635,11 +2757,27 @@ class BleEngine { | |
| 'traffic_burst_packets': d.currentBurstTrafficCount, | ||
| 'burst_validation_failures': d.consecutiveValidationFailures, | ||
| 'burst_breakdown': d.currentBurstBreakdown, | ||
| 'burst_shortfall': shortfall, | ||
| }, | ||
| )); | ||
| } else { | ||
| _burstMismatchStreak = 0; | ||
| } | ||
| // Would-flag: the correct-signal completeness diagnostic. LOG-ONLY — the | ||
| // commit + verbatim-token ACK below are unchanged. A positive shortfall | ||
| // is the honest missing/corrupted-traffic telemetry we want to watch | ||
| // before a later, field-validated FAIL gate ever acts on it. | ||
| if (shortfall > 0) { | ||
| _log( | ||
| '[SYNC] burst completeness would-flag (LOG-ONLY, commit+ACK ' | ||
| 'unchanged): expected=$expected ' | ||
| 'received=${d.currentBurstTrafficCount} ' | ||
| 'dropped_this_burst=$droppedThisBurst shortfall=$shortfall ' | ||
| '(all-types received total — frames the band counted that we did ' | ||
| 'not; missing or CRC-corrupted, potential loss; groundwork for a ' | ||
| 'future FAIL gate, NOT gating today)', | ||
| ); | ||
| } | ||
| final r = d.bufferedRecTsRange; | ||
| final droppedThisBurstForLog = droppedThisBurst; | ||
| final hadDurableRows = | ||
|
|
@@ -2939,10 +3077,15 @@ class BleEngine { | |
| inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); | ||
|
|
||
| // ── high-level flows ───────────────────────────────────────────────────────────── | ||
| Future<void> sendInit() async { | ||
| _log('Sending 5-packet INIT…'); | ||
| /// [drain] false sends the first FOUR packets only: seq4 is | ||
| /// SEND_HISTORICAL_DATA (the flash drain), and it is skipped when the phone | ||
| /// clock is suspect — see _doConnect and [ClockPolicy.phoneClockSuspect]. | ||
| Future<void> sendInit({bool drain = true}) async { | ||
| final pkts = | ||
| drain ? initPackets : initPackets.take(initPackets.length - 1).toList(); | ||
| _log('Sending ${pkts.length}-packet INIT…'); | ||
| try { | ||
| for (final pkt in initPackets) { | ||
| for (final pkt in pkts) { | ||
| await _write(pkt); | ||
| await Future.delayed(const Duration(milliseconds: 120)); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1836,13 +1836,16 @@ class DerivationEngine { | |
| rangePages: rangePages, | ||
| rangeRows: rangeRows, | ||
| ); | ||
| final firstCounter = (decodedRows.first['counter'] as num?)?.toInt(); | ||
| final lastCounter = (decodedRows.last['counter'] as num?)?.toInt(); | ||
| final rrRows = firstCounter == null || lastCounter == null | ||
| // The page is ordered rec_ts ASC, so first = min second, last = max. | ||
| // decoded_rr shares the rec_ts key, so this pulls exactly the page's | ||
| // beats — no counter span (which broke across the strap's reboot reset). | ||
| final firstRecTs = (decodedRows.first['rec_ts'] as num?)?.toInt(); | ||
| final lastRecTs = (decodedRows.last['rec_ts'] as num?)?.toInt(); | ||
| final rrRows = firstRecTs == null || lastRecTs == null | ||
| ? const <Map<String, dynamic>>[] | ||
| : await LocalDb.decodedRrByCounterRange( | ||
| fromCounter: firstCounter, | ||
| toCounter: lastCounter, | ||
| : await LocalDb.decodedRrByRecTsRange( | ||
| fromRecTs: firstRecTs, | ||
| toRecTs: lastRecTs, | ||
| ); | ||
|
Comment on lines
+1839
to
1849
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check whether kAlgoVersion changed in this PR and whether a changelog entry accompanies it.
set -euo pipefail
echo "== kAlgoVersion declaration =="
rg -nP --type=dart -C4 '\bkAlgoVersion\s*=' || echo "not found"
echo "== kAlgoVersion changes in this branch vs base =="
git diff origin/HEAD... -- '*.dart' 2>/dev/null | rg -n -C4 'kAlgoVersion' || echo "no diff hunks touching kAlgoVersion"
echo "== changelog files =="
fd -i -t f 'changelog' | head -20
echo "== changelog diff =="
git diff origin/HEAD... -- '*CHANGELOG*' 2>/dev/null | head -60 || echo "no changelog diff"Repository: OpenStrap/edge Length of output: 193 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Candidate analytics/version files =="
git ls-files | rg '(^|/)(derivation_engine\.dart|.*CHANGELOG.*|changelog.*)$|(^|/)pubspec\.yaml$'
echo "== Version declarations and changelog entries =="
rg -n -i -C3 'kAlgoVersion|algo.?version|changelog|change log' --glob '*.dart' --glob '*.md' --glob '*.yaml' . || true
echo "== Repository refs and diff summary =="
git branch --all --no-color
git diff --stat
git diff --stat origin/HEAD...HEAD 2>/dev/null || true
echo "== Relevant derivation-engine diff =="
git diff -- lib/compute/derivation_engine.dartRepository: OpenStrap/edge Length of output: 50371 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Revisions =="
git rev-parse --show-toplevel
git rev-parse HEAD
git rev-parse origin/HEAD 2>/dev/null || true
echo "== Version/changelog section =="
sed -n '250,710p' lib/compute/derivation_engine.dart
echo "== Current change around the reviewed code =="
sed -n '1800,1870p' lib/compute/derivation_engine.dart
echo "== Version-related diff only =="
git diff --unified=3 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
| rg -n -C5 'kAlgoVersion|^[-+].*// v[0-9]+|^[-+].*version' || true
echo "== Reviewed-code diff only =="
git diff --unified=8 origin/HEAD...HEAD -- lib/compute/derivation_engine.dart 2>/dev/null \
| rg -n -C12 'decodedRrByRecTsRange|counter|decodedRows' || trueRepository: OpenStrap/edge Length of output: 36432 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import re
import subprocess
path = "lib/compute/derivation_engine.dart"
for label, rev in (("base", "origin/HEAD"), ("head", "HEAD")):
text = subprocess.check_output(["git", "show", f"{rev}:{path}"], text=True)
m = re.search(r"const\s+int\s+kAlgoVersion\s*=\s*(\d+)\s*;", text)
print(f"{label}: kAlgoVersion={m.group(1) if m else 'not found'}")
PY
echo "== Derivation gate around finalized-day selection =="
sed -n '1100,1160p' lib/compute/derivation_engine.dart
sed -n '1310,1360p' lib/compute/derivation_engine.dart
sed -n '1900,1950p' lib/compute/derivation_engine.dart
echo "== Finalized-day lookup implementation =="
rg -n -C8 'finalizedDayIds|dayResultIds' lib/data/db.dart lib/compute/derivation_engine.dart
echo "== RR range implementations =="
rg -n -C12 'decodedRrByRecTsRange|decodedRrByCounterRange' lib/data/db.dart lib/compute/derivation_engine.dartRepository: OpenStrap/edge Length of output: 22826 Bump 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| worker.send({'type': 'page', 'frames': decodedRows, 'rr': rrRows}); | ||
| final last = decodedRows.last; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔴 Critical
Make GET_CLOCK completion session-bound before clock correction or history draining. Both paths await only the command write and then sleep 120 ms. A late or missing
clock_epochleaves_deferForClockstale, allowing history or clock correction to proceed before the current session is classified.lib/ble/ble_engine.dart#L1366-L1368: await the current session's clock response before deciding whether to callsetClock().lib/ble/ble_engine.dart#L1643-L1655: reuse the completed clock result and wait for any required correction before sendingSEND_HISTORICAL_DATA.lib/ble/ble_engine.dart#L1452-L1465: derivedrainOnInitfrom that validated result before callingsendInit.📍 Affects 1 file
lib/ble/ble_engine.dart#L1366-L1368(this comment)lib/ble/ble_engine.dart#L1643-L1655lib/ble/ble_engine.dart#L1452-L1465🤖 Prompt for AI Agents