8000 fix(cbio): refuse and surface a restarted sensor counter by shroominic · Pull Request #215 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/cgm_cbio/lib/src/cbio_glucose_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,19 @@ final class CbioGlucoseSession implements CgmSession {
_log(CgmLogLevel.debug, 'cbio.raw.records=${_archive.length}');
_restartHistoryIdleTimer();
_emit();
case CbioArchiveIngestStatus.counterRestart:
// The sensor's numbering restarted and a position came back on a
// different counter. The archive refused the batch rather than
// splicing the new stretch onto the old numbering, so say so where a
// reader can see it: a silently absorbed restart looks exactly like a
// sensor that stopped producing records.
_log(
CgmLogLevel.warning,
'cbio.raw.counter-restart index=${_archive.counterRestartIndex} '
'records=${_archive.length} contiguous=false',
);
_lastSyncAt = _clock().toUtc();
_emit();
case CbioArchiveIngestStatus.duplicate:
case CbioArchiveIngestStatus.notRawBatch:
break;
Expand Down
49 changes: 46 additions & 3 deletions packages/cgm_cbio/lib/src/cbio_history_archive.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
/// back on the same characteristic, so a history fetch is an ingest loop, not a
/// request/response pair. This archive ingests those plaintext frames in
/// arrival order, keeps one record per index, and reports continuity instead of
/// guessing when a batch is missing or out of order.
/// guessing when a batch is missing, out of order, or numbered by a counter
/// that restarted.
library;

import 'cbio_frames.dart';
Expand Down Expand Up @@ -91,6 +92,16 @@ enum CbioArchiveIngestStatus {

/// The frame starts after a missing index range.
gap,

/// The frame re-used an index for a record from a different counter era.
///
/// The sensor stamps each position once, when it produces that record, so a
/// position that comes back carrying a different counter is not a
/// retransmission: the sensor's counter restarted or was reset, and the old
/// numbering is being re-used for a new stretch of time. The batch is refused
/// rather than merged, because merging would staple two unrelated stretches
/// onto one index space.
counterRestart,
}

/// Ordered history assembled from a stream of `08` batches.
Expand All @@ -99,6 +110,8 @@ final class CbioHistoryArchive {
final List<int> _batchCounts = <int>[];
var _gapDetected = false;
var _outOfOrder = false;
var _counterRestart = false;
int? _counterRestartIndex;

/// Records sorted by index, oldest first.
List<CbioRawGlucoseRecord> get records {
Expand All @@ -121,8 +134,22 @@ final class CbioHistoryArchive {
/// True when a batch started at or below an index already received.
bool get sawOverlap => _outOfOrder;

/// True when the archive covers every index between its ends.
bool get contiguous => !_gapDetected;
/// True when an index came back carrying a record from a different counter.
///
/// The archive keeps the record it already held and refuses the conflicting
/// batch, so this flag is the only signal that the sensor's numbering
/// restarted. Consumers must read the positions as two timelines rather than
/// one, and no position may be spliced onto the other's numbering.
bool get sawCounterRestart => _counterRestart;

/// Lowest index of the refused batch that came back on a different counter.
int? get counterRestartIndex => _counterRestartIndex;

/// True when the archive covers every index between its ends, on one counter.
///
/// A detected counter restart also clears this: the index space no longer
/// describes a single unbroken stretch of the sensor's own timeline.
bool get contiguous => !_gapDetected && !_counterRestart;

/// Lowest sensor counter in the archive, or null when it is empty.
///
Expand All @@ -143,6 +170,22 @@ final class CbioHistoryArchive {
} on CbioFrameException {
return CbioArchiveIngestStatus.notRawBatch;
}
// A position the archive already holds is only a retransmission when it
// carries the counter it carried before. The sensor stamps a record once,
// and the three captured sessions agree on the stamp for every shared index
// (index 1 is the same counter in all of them), so a differing counter is
// the restart, not a re-delivery. Refuse it instead of merging it.
final conflicting = batch.records.where((r) {
final stored = _byIndex[r.processed.index];
return stored != null && stored.rawTime != r.processed.rawTime;
}).length;
if (conflicting > 0) {
_counterRestart = true;
_counterRestartIndex = batch.records
.map((r) => r.processed.index)
.reduce((a, b) => a < b ? a : b);
return CbioArchiveIngestStatus.counterRestart;
}
final known = batch.records
.where((r) => _byIndex.containsKey(r.processed.index))
.length;
Expand Down
66 changes: 66 additions & 0 deletions packages/cgm_cbio/test/cbio_glucose_session_test.dart
8000
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,72 @@ void main() {
},
);

test(
'surfaces a restarted sensor counter instead of absorbing it',
() async {
final connection = _FakeConnection();
final transport = _FakeTransport(connection);
final now = DateTime.now().toUtc();
final base = now.millisecondsSinceEpoch ~/ 1000 - 120;
await _defaultResponder(
connection,
rawBatches: <List<int>>[
_rawBatch(
startIndex: 1,
baseEpochSeconds: base,
baseReindex: 3,
currents: <int>[64, 80, 97],
),
// The same three positions come back stamped from a counter a week
// away: the sensor's numbering restarted, so these are different
// records wearing indexes the archive already holds.
_rawBatch(
startIndex: 1,
baseEpochSeconds: base - 604800,
baseReindex: 3,
currents: <int>[70, 88, 99],
),
],
);

final session = CbioGlucoseSession(
sensor: _sensor,
transport: transport,
credentials: _syntheticSource,
timing: _fastTiming,
clock: () => now,
);
final messages = <String>[];
final subscription = session.logs.listen(
(entry) => messages.add(entry.message),
);
await session.initialize();
await _pumpUntil(() => session.currentSnapshot.history.length == 3);
await _pumpUntil(
() => messages.any((m) => m.contains('counter-restart')),
);

// The old numbering keeps its records: the new cycle is not spliced on
// to it, and no position is silently renumbered.
expect(
session.currentSnapshot.history.map((r) => r.sensorMinute),
<int>[1, 2, 3],
);
expect(session.currentSnapshot.history.map((r) => r.rawValue), <int>[
64,
80,
97,
]);
expect(
messages.any((m) => m.contains('counter-restart')),
isTrue,
reason: 'a restart must be visible, not absorbed as a duplicate',
);
await subscription.cancel();
await session.disconnect();
},
);

test(
'reports history progress until the archive reaches the live edge',
() async {
Expand Down
54 changes: 54 additions & 0 deletions packages/cgm_cbio/test/cbio_history_archive_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,58 @@ void main() {
final indexes = archive.records.map((r) => r.index).toList();
expect(indexes, List<int>.generate(1520, (i) => i + 1));
});

test('treats a re-used index with a different counter as a restart', () {
final archive = CbioHistoryArchive();
archive.ingest(_rawBatch(first: 1, count: 3, baseTime: 1000));

// The same three positions arrive again carrying a counter from a different
// era. A retransmission repeats a record, and the sensor stamps each
// position once when it produces it, so this cannot be one. Reading it as a
// duplicate keeps the old cycle and silently drops the new one.
final status = archive.ingest(
_rawBatch(first: 1, count: 3, baseTime: 1000000),
);

expect(status, CbioArchiveIngestStatus.counterRestart);
expect(archive.sawCounterRestart, isTrue);
expect(archive.contiguous, isFalse);
expect(archive.hasGap, isFalse);
expect(archive.length, 3);
expect(archive.batchCounts, [3]);
expect(archive.records.first.rawTime, 1000);
});

test('keeps a plain retransmission quiet', () {
final archive = CbioHistoryArchive();
final batch = _rawBatch(first: 1, count: 3, baseTime: 1000);
archive.ingest(batch);

expect(
archive.ingest(_rawBatch(first: 1, count: 3, baseTime: 1000)),
CbioArchiveIngestStatus.duplicate,
);
expect(archive.sawCounterRestart, isFalse);
expect(archive.contiguous, isTrue);
expect(archive.batchCounts, [3]);
});

test('refuses to splice a restarting batch onto the old index space', () {
final archive = CbioHistoryArchive();
archive.ingest(_rawBatch(first: 1, count: 3, baseTime: 1000, current: 60));

// Index 3 re-appears with a counter from a different era while 4 and 5 are
// positions the archive has never seen. Merging that would staple the new
// cycle's first records onto the old cycle's numbering.
final status = archive.ingest(
_rawBatch(first: 3, count: 3, baseTime: 500000, current: 95),
);

expect(status, CbioArchiveIngestStatus.counterRestart);
expect(archive.sawCounterRestart, isTrue);
expect(archive.contiguous, isFalse);
expect(archive.length, 3);
expect([archive.oldestIndex, archive.newestIndex], [1, 3]);
expect(archive.records.last.rawPayload, 60);
});
}
Loading
0