fix(telemetry): accurately track dropped telemetry samples under queue bounds in ride state and field test summary
This commit is contained in:
parent
bb509a4bac
commit
0282a2972f
|
|
@ -105,7 +105,8 @@ class RideSession {
|
|||
elevationLoss: elevationLoss ?? this.elevationLoss,
|
||||
averageSpeedMph: averageSpeedMph ?? this.averageSpeedMph,
|
||||
totalSamplesReceived: totalSamplesReceived ?? this.totalSamplesReceived,
|
||||
totalSamplesPersisted: totalSamplesPersisted ?? this.totalSamplesPersisted,
|
||||
totalSamplesPersisted:
|
||||
totalSamplesPersisted ?? this.totalSamplesPersisted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -181,14 +182,30 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// retryable rather than transitioning to finished.
|
||||
static const int _maxFinalDrainPasses = 4;
|
||||
|
||||
RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) {
|
||||
// Explicit per-ride counter of telemetry samples discarded due to queue capacity bounds.
|
||||
int _telemetrySamplesLost = 0;
|
||||
int get telemetrySamplesLost => _telemetrySamplesLost;
|
||||
|
||||
RideStateMachine(
|
||||
this.db,
|
||||
this.telemetryService,
|
||||
this.recordsService,
|
||||
this.audioAlertService,
|
||||
this.ref,
|
||||
) : super(RideSession()) {
|
||||
_telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate);
|
||||
|
||||
// Write to DB every 3 seconds for tight crash-durability
|
||||
_batchWriteTimer = Timer.periodic(const Duration(seconds: 3), (_) => _runScheduledFlush());
|
||||
_batchWriteTimer = Timer.periodic(
|
||||
const Duration(seconds: 3),
|
||||
(_) => _runScheduledFlush(),
|
||||
);
|
||||
|
||||
// Watchdog timer (every 2 seconds) checks if telemetry pipeline is alive
|
||||
_watchdogTimer = Timer.periodic(const Duration(seconds: 2), (_) => _checkWatchdog());
|
||||
_watchdogTimer = Timer.periodic(
|
||||
const Duration(seconds: 2),
|
||||
(_) => _checkWatchdog(),
|
||||
);
|
||||
|
||||
// Recover any unfinished rides from previous sessions
|
||||
_recoverInterruptedRides();
|
||||
|
|
@ -278,25 +295,34 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// is also what prevents monotonic freeze-blindness: when samples stop, the
|
||||
// monotonic reference stops advancing, but this term still goes stale.
|
||||
final accepted = _lastAcceptedTelemetryTime;
|
||||
final telemetryAgeMs =
|
||||
accepted == null ? null : now.difference(accepted).inMilliseconds;
|
||||
final telemetryAgeMs = accepted == null
|
||||
? null
|
||||
: now.difference(accepted).inMilliseconds;
|
||||
// Fail closed on a backwards clock jump: negative age is not freshness.
|
||||
final telemetryActive = telemetryAgeMs != null &&
|
||||
final telemetryActive =
|
||||
telemetryAgeMs != null &&
|
||||
telemetryAgeMs >= 0 &&
|
||||
telemetryAgeMs <= sensorBound;
|
||||
|
||||
// B. GPS and D. IMU evidence: monotonic, presence-proven.
|
||||
final gpsOperational =
|
||||
_isFresh(_monotonicAgeMs(_lastGpsEvidenceMonotonicMs), sensorBound);
|
||||
final sensorActive =
|
||||
_isFresh(_monotonicAgeMs(_lastImuEvidenceMonotonicMs), sensorBound);
|
||||
final gpsOperational = _isFresh(
|
||||
_monotonicAgeMs(_lastGpsEvidenceMonotonicMs),
|
||||
sensorBound,
|
||||
);
|
||||
final sensorActive = _isFresh(
|
||||
_monotonicAgeMs(_lastImuEvidenceMonotonicMs),
|
||||
sensorBound,
|
||||
);
|
||||
|
||||
// E. Database evidence: advances ONLY after a real successful telemetry
|
||||
// commit (the RID-3B-2 commit point). A scheduled flush is not evidence, and
|
||||
// a failed pre-commit batch does not advance it.
|
||||
final dbOperational = !_dbPersistenceFailed &&
|
||||
_isFresh(_monotonicAgeMs(_lastDbCommitMonotonicMs),
|
||||
RecordingHealthContract.databaseWriteStaleAfterMs);
|
||||
final dbOperational =
|
||||
!_dbPersistenceFailed &&
|
||||
_isFresh(
|
||||
_monotonicAgeMs(_lastDbCommitMonotonicMs),
|
||||
RecordingHealthContract.databaseWriteStaleAfterMs,
|
||||
);
|
||||
|
||||
final previous = state.health;
|
||||
final health = previous.copyWith(
|
||||
|
|
@ -335,10 +361,13 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
if (stalled && previousMisses == 0) {
|
||||
// First detection of this stall episode: logged once, as before.
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.telemetryGap,
|
||||
severity: EventSeverity.warning,
|
||||
message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
|
||||
message:
|
||||
'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -357,24 +386,30 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
|
||||
Future<void> _recoverInterruptedRides() async {
|
||||
try {
|
||||
final unclosedRides = await (db.select(db.rides)
|
||||
..where((r) => r.endTime.isNull())
|
||||
).get();
|
||||
final unclosedRides = await (db.select(
|
||||
db.rides,
|
||||
)..where((r) => r.endTime.isNull())).get();
|
||||
|
||||
for (final r in unclosedRides) {
|
||||
// Find last recorded telemetry sample for this ride to determine actual end time
|
||||
final lastSample = await (db.select(db.telemetrySamples)
|
||||
final lastSample =
|
||||
await (db.select(db.telemetrySamples)
|
||||
..where((t) => t.rideId.equals(r.id))
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp, mode: drift.OrderingMode.desc)])
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
..orderBy([
|
||||
(t) => drift.OrderingTerm(
|
||||
expression: t.timestamp,
|
||||
mode: drift.OrderingMode.desc,
|
||||
),
|
||||
])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
final calculatedEndTime = lastSample?.timestamp ?? r.startTime.add(Duration(seconds: r.movingTime + r.stoppedTime));
|
||||
final calculatedEndTime =
|
||||
lastSample?.timestamp ??
|
||||
r.startTime.add(Duration(seconds: r.movingTime + r.stoppedTime));
|
||||
|
||||
await (db.update(db.rides)..where((t) => t.id.equals(r.id))).write(
|
||||
RidesCompanion(
|
||||
endTime: drift.Value(calculatedEndTime),
|
||||
),
|
||||
RidesCompanion(endTime: drift.Value(calculatedEndTime)),
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -382,7 +417,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
logger.log(
|
||||
eventType: FieldTestEventType.databaseRecovery,
|
||||
severity: EventSeverity.critical,
|
||||
message: 'Interrupted ride #${r.id} recovered after unexpected exit',
|
||||
message:
|
||||
'Interrupted ride #${r.id} recovered after unexpected exit',
|
||||
rideId: r.id,
|
||||
);
|
||||
await logger.generateAndSaveSummary(r.id, wasRecovered: true);
|
||||
|
|
@ -425,7 +461,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
/// must never be invoked directly.
|
||||
Future<void> _performStartRide() async {
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.rideStartRequested,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Ride start requested by user or motion trigger',
|
||||
|
|
@ -448,6 +486,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// IMU or the 3-second batch; health warms up automatically as real evidence
|
||||
// arrives and _evaluateRecordingHealth promotes the status.
|
||||
_resetHealthEvidence();
|
||||
_telemetrySamplesLost = 0;
|
||||
state = state.copyWith(
|
||||
dbId: id,
|
||||
state: RideState.riding,
|
||||
|
|
@ -516,7 +555,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
WakelockPlus.disable().catchError((_) {});
|
||||
} catch (_) {}
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.appPaused,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Ride session paused',
|
||||
|
|
@ -528,7 +569,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// RID-3C-1: resuming does not by itself prove recording health. Evidence ages
|
||||
// out monotonically across the pause, so the next accepted sample
|
||||
// re-establishes truthfully whether the ride is active or degraded.
|
||||
state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingArmed);
|
||||
state = state.copyWith(
|
||||
state: RideState.riding,
|
||||
healthStatus: RecordingHealthStatus.recordingArmed,
|
||||
);
|
||||
_lastSample = null; // reset to avoid jumping
|
||||
_lastTelemetryReceivedTime = DateTime.now();
|
||||
// RID-3C-1: re-publish truthful health for the resumed session.
|
||||
|
|
@ -537,7 +581,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
WakelockPlus.enable().catchError((_) {});
|
||||
} catch (_) {}
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.appResumed,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Ride session resumed',
|
||||
|
|
@ -581,10 +627,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
/// [finishRide] and must never be invoked directly.
|
||||
Future<void> _performFinishRide() async {
|
||||
if (state.dbId != null) {
|
||||
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingFinalizing);
|
||||
state = state.copyWith(
|
||||
healthStatus: RecordingHealthStatus.recordingFinalizing,
|
||||
);
|
||||
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.rideFinishRequested,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Ride finish requested for ride #${state.dbId}',
|
||||
|
|
@ -616,7 +666,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
);
|
||||
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.rideFinalized,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Ride record #${state.dbId} finalized in database',
|
||||
|
|
@ -644,11 +696,13 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
try {
|
||||
final logger = ref.read(fieldTestLoggerProvider);
|
||||
if (logger.isSessionActive) {
|
||||
logger.telemetrySamplesLost = _telemetrySamplesLost;
|
||||
await logger.stopSession();
|
||||
logger.log(
|
||||
eventType: FieldTestEventType.rideSummaryCreated,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Field test summary generated and saved for ride #${state.dbId}',
|
||||
message:
|
||||
'Field test summary generated and saved for ride #${state.dbId}',
|
||||
rideId: state.dbId,
|
||||
);
|
||||
}
|
||||
|
|
@ -713,7 +767,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
|
||||
try {
|
||||
if (sample.quality != null) {
|
||||
ref.read(fieldTestLoggerProvider).processTelemetrySample(sample, sample.quality!);
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.processTelemetrySample(sample, sample.quality!);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
@ -726,10 +782,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// Bound queue to prevent memory leaks during extended DB locks
|
||||
if (_telemetryBatch.length >= _maxBatchCapacity) {
|
||||
_telemetryBatch.removeAt(0);
|
||||
_telemetrySamplesLost++;
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
final logger = ref.read(fieldTestLoggerProvider);
|
||||
logger.recordTelemetryLoss(1);
|
||||
logger.log(
|
||||
eventType: FieldTestEventType.databaseQueueOverflow,
|
||||
message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample',
|
||||
message:
|
||||
'Telemetry batch buffer reached capacity (1000); dropping oldest sample',
|
||||
severity: EventSeverity.warning,
|
||||
);
|
||||
} catch (_) {}
|
||||
|
|
@ -752,10 +812,12 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
accelZ: drift.Value(sample.accelZ),
|
||||
longitudinalG: drift.Value(sample.longitudinalG),
|
||||
lateralG: drift.Value(sample.lateralG),
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
state = state.copyWith(totalSamplesReceived: state.totalSamplesReceived + 1);
|
||||
state = state.copyWith(
|
||||
totalSamplesReceived: state.totalSamplesReceived + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// RID-3A ownership semantics, plus RID-3B-1 active-flush publication.
|
||||
|
|
@ -840,7 +902,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// RID-2's existing UI boundary containment handles the rejection and the
|
||||
// ride remains retryable. RID-3A has already restored any failed owned
|
||||
// snapshot to the front of the buffer in correct order.
|
||||
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive);
|
||||
state = state.copyWith(
|
||||
healthStatus: RecordingHealthStatus.recordingActive,
|
||||
);
|
||||
throw StateError(
|
||||
'Ride finalization aborted: telemetry durability could not be established',
|
||||
);
|
||||
|
|
@ -873,6 +937,28 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// arrived during the await, so a later legitimate flush can retry them.
|
||||
// No automatic retry worker is added.
|
||||
_telemetryBatch.insertAll(0, batchToInsert);
|
||||
// Enforce live queue capacity after failed-snapshot restoration by
|
||||
// dropping the excess oldest samples (from the front), preserving the
|
||||
// existing oldest-first overflow policy and queue ordering.
|
||||
final excess = _telemetryBatch.length - _maxBatchCapacity;
|
||||
if (excess > 0) {
|
||||
_telemetryBatch.removeRange(0, excess);
|
||||
_telemetrySamplesLost += excess;
|
||||
if (mounted) {
|
||||
try {
|
||||
final logger = ref.read(fieldTestLoggerProvider);
|
||||
logger.recordTelemetryLoss(excess);
|
||||
for (var i = 0; i < excess; i++) {
|
||||
logger.log(
|
||||
eventType: FieldTestEventType.databaseQueueOverflow,
|
||||
message:
|
||||
'Telemetry batch buffer reached capacity (1000); dropping oldest sample',
|
||||
severity: EventSeverity.warning,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
// RID-3C-1: a pre-commit failure means this telemetry is NOT durable, so
|
||||
// database health evidence must not advance and is marked non-operational
|
||||
// until a real commit succeeds.
|
||||
|
|
@ -882,7 +968,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// case; it remains bounded either way.
|
||||
if (mounted) {
|
||||
try {
|
||||
ref.read(fieldTestLoggerProvider).log(
|
||||
ref
|
||||
.read(fieldTestLoggerProvider)
|
||||
.log(
|
||||
eventType: FieldTestEventType.databaseQueueWarning,
|
||||
message: 'Database batch write retry: $e',
|
||||
severity: EventSeverity.warning,
|
||||
|
|
@ -917,8 +1005,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
_lastDbCommitMonotonicMs = _latestObservedMonotonicMs;
|
||||
_dbPersistenceFailed = false;
|
||||
state = state.copyWith(
|
||||
totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length,
|
||||
health: state.health.copyWith(lastDatabaseWriteTime: DateTime.now()),
|
||||
totalSamplesPersisted:
|
||||
state.totalSamplesPersisted + batchToInsert.length,
|
||||
health: state.health.copyWith(
|
||||
lastDatabaseWriteTime: DateTime.now(),
|
||||
),
|
||||
);
|
||||
_evaluateRecordingHealth();
|
||||
} catch (_) {
|
||||
|
|
@ -993,7 +1084,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// Incrementally persist ongoing ride summary stats to SQLite so crashes/terminations never lose stats
|
||||
if (state.dbId != null && state.state == RideState.riding) {
|
||||
try {
|
||||
await (db.update(db.rides)..where((t) => t.id.equals(state.dbId!))).write(
|
||||
await (db.update(
|
||||
db.rides,
|
||||
)..where((t) => t.id.equals(state.dbId!))).write(
|
||||
RidesCompanion(
|
||||
distanceMiles: drift.Value(state.tripDistanceMiles),
|
||||
topSpeedMph: drift.Value(state.topSpeedMph),
|
||||
|
|
@ -1034,7 +1127,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
|
||||
// Continuous sub-second time accumulation across 20Hz frames
|
||||
if (_lastSample != null && sample.speed != null) {
|
||||
final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0;
|
||||
final dt =
|
||||
sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds /
|
||||
1000.0;
|
||||
if (dt > 0 && dt < 2.0) {
|
||||
if (sample.speed! > 1.8) {
|
||||
_movingTimeAccumulator += dt;
|
||||
|
|
@ -1049,23 +1144,34 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records)
|
||||
final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0;
|
||||
|
||||
if (isDynamicallyMoving && sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) {
|
||||
if (sample.leanAngle! < 0 && sample.leanAngle!.abs() > state.maxLeanLeft) {
|
||||
if (isDynamicallyMoving &&
|
||||
sample.leanAngle != null &&
|
||||
sample.leanAngle!.abs() <= 65.0) {
|
||||
if (sample.leanAngle! < 0 &&
|
||||
sample.leanAngle!.abs() > state.maxLeanLeft) {
|
||||
newMaxLeanLeft = sample.leanAngle!.abs();
|
||||
} else if (sample.leanAngle! > 0 && sample.leanAngle! > state.maxLeanRight) {
|
||||
} else if (sample.leanAngle! > 0 &&
|
||||
sample.leanAngle! > state.maxLeanRight) {
|
||||
newMaxLeanRight = sample.leanAngle!;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDynamicallyMoving && sample.longitudinalG != null && sample.longitudinalG!.abs() <= 2.5) {
|
||||
if (sample.longitudinalG! > 0 && sample.longitudinalG! > state.maxAccelG) {
|
||||
if (isDynamicallyMoving &&
|
||||
sample.longitudinalG != null &&
|
||||
sample.longitudinalG!.abs() <= 2.5) {
|
||||
if (sample.longitudinalG! > 0 &&
|
||||
sample.longitudinalG! > state.maxAccelG) {
|
||||
newMaxAccelG = sample.longitudinalG!;
|
||||
} else if (sample.longitudinalG! < 0 && sample.longitudinalG!.abs() > state.maxBrakingG) {
|
||||
} else if (sample.longitudinalG! < 0 &&
|
||||
sample.longitudinalG!.abs() > state.maxBrakingG) {
|
||||
newMaxBrakingG = sample.longitudinalG!.abs();
|
||||
}
|
||||
}
|
||||
|
||||
if (sample.latitude != null && sample.longitude != null && _lastSample?.latitude != null && _lastSample?.longitude != null) {
|
||||
if (sample.latitude != null &&
|
||||
sample.longitude != null &&
|
||||
_lastSample?.latitude != null &&
|
||||
_lastSample?.longitude != null) {
|
||||
final distMeters = Geolocator.distanceBetween(
|
||||
_lastSample!.latitude!,
|
||||
_lastSample!.longitude!,
|
||||
|
|
@ -1074,8 +1180,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
);
|
||||
|
||||
// Accumulate distance ONLY if moving and GPS accuracy is reasonable (suppresses stationary jitter at stop lights)
|
||||
final isMoving = (sample.speed != null && sample.speed! >= 1.0) || (_lastSample!.speed != null && _lastSample!.speed! >= 1.0);
|
||||
final hasGoodGps = sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0;
|
||||
final isMoving =
|
||||
(sample.speed != null && sample.speed! >= 1.0) ||
|
||||
(_lastSample!.speed != null && _lastSample!.speed! >= 1.0);
|
||||
final hasGoodGps =
|
||||
sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0;
|
||||
if (isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
|
||||
_accumulatedDistance += distMeters;
|
||||
}
|
||||
|
|
@ -1131,25 +1240,41 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
final lastTime = _lastEventTimes[type];
|
||||
if (lastTime == null || now.difference(lastTime) > _eventCooldown) {
|
||||
_lastEventTimes[type] = now;
|
||||
db.into(db.rideEvents).insert(RideEventsCompanion.insert(
|
||||
db
|
||||
.into(db.rideEvents)
|
||||
.insert(
|
||||
RideEventsCompanion.insert(
|
||||
rideId: state.dbId!,
|
||||
timestamp: now,
|
||||
eventType: type,
|
||||
value: value,
|
||||
latitude: drift.Value(sample.latitude),
|
||||
longitude: drift.Value(sample.longitude),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sample.longitudinalG != null) {
|
||||
checkAndLogEvent('Hard Braking', sample.longitudinalG!, sample.longitudinalG! < -0.8);
|
||||
checkAndLogEvent('Hard Acceleration', sample.longitudinalG!, sample.longitudinalG! > 0.6);
|
||||
checkAndLogEvent(
|
||||
'Hard Braking',
|
||||
sample.longitudinalG!,
|
||||
sample.longitudinalG! < -0.8,
|
||||
);
|
||||
checkAndLogEvent(
|
||||
'Hard Acceleration',
|
||||
sample.longitudinalG!,
|
||||
sample.longitudinalG! > 0.6,
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.leanAngle != null) {
|
||||
checkAndLogEvent('Extreme Lean', sample.leanAngle!, sample.leanAngle!.abs() > 45.0);
|
||||
checkAndLogEvent(
|
||||
'Extreme Lean',
|
||||
sample.leanAngle!,
|
||||
sample.leanAngle!.abs() > 45.0,
|
||||
);
|
||||
}
|
||||
|
||||
if (sample.speed != null) {
|
||||
|
|
@ -1166,10 +1291,17 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
}
|
||||
|
||||
final rideStateMachineProvider = StateNotifierProvider<RideStateMachine, RideSession>((ref) {
|
||||
final rideStateMachineProvider =
|
||||
StateNotifierProvider<RideStateMachine, RideSession>((ref) {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final telemetryService = ref.watch(telemetryServiceProvider);
|
||||
final recordsService = ref.watch(recordsServiceProvider.notifier);
|
||||
final audioAlertService = ref.watch(audioAlertServiceProvider);
|
||||
return RideStateMachine(db, telemetryService, recordsService, audioAlertService, ref);
|
||||
});
|
||||
return RideStateMachine(
|
||||
db,
|
||||
telemetryService,
|
||||
recordsService,
|
||||
audioAlertService,
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,20 @@ class FieldTestLogger {
|
|||
int get totalEnqueued => _totalEnqueued;
|
||||
int get totalFlushed => _totalFlushed;
|
||||
int get droppedEventsCount => _droppedEventsCount;
|
||||
int _telemetrySamplesLost = 0;
|
||||
int get telemetrySamplesLost => _telemetrySamplesLost;
|
||||
|
||||
/// Records the loss of telemetry samples due to buffer overflow or queue bounds.
|
||||
void recordTelemetryLoss(int count) {
|
||||
if (count > 0) {
|
||||
_telemetrySamplesLost += count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets or replaces the explicit telemetry loss counter for the active session.
|
||||
set telemetrySamplesLost(int value) {
|
||||
_telemetrySamplesLost = value < 0 ? 0 : value;
|
||||
}
|
||||
String? get oldestDroppedItem => _oldestDroppedItem;
|
||||
String? get newestDroppedItem => _newestDroppedItem;
|
||||
int get lastFlushDurationMs => _lastFlushDurationMs;
|
||||
|
|
@ -267,6 +281,7 @@ class FieldTestLogger {
|
|||
_oldestDroppedItem = null;
|
||||
_newestDroppedItem = null;
|
||||
_failedFlushCount = 0;
|
||||
_telemetrySamplesLost = 0;
|
||||
|
||||
_recentSampleRateHz = 0.0;
|
||||
_recentSampleCount = 0;
|
||||
|
|
@ -700,7 +715,12 @@ class FieldTestLogger {
|
|||
}
|
||||
|
||||
/// Generates forensic summary from recorded ride telemetry and events
|
||||
Future<FieldTestSummary> generateAndSaveSummary(int rideId, {bool wasRecovered = false}) async {
|
||||
Future<FieldTestSummary> generateAndSaveSummary(
|
||||
int rideId, {
|
||||
int? telemetrySamplesLost,
|
||||
bool wasRecovered = false,
|
||||
}) async {
|
||||
final effectiveTelemetryLost = telemetrySamplesLost ?? _telemetrySamplesLost;
|
||||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
|
||||
final startTime = ride?.startTime ?? DateTime.now();
|
||||
final endTime = ride?.endTime ?? DateTime.now();
|
||||
|
|
@ -815,7 +835,7 @@ class FieldTestLogger {
|
|||
timestampGaps: _telemetryGapCount,
|
||||
gpsFixCount: gpsFixCount,
|
||||
totalGpsAccuracy: accuracySum,
|
||||
databaseSamplesLost: _droppedEventsCount,
|
||||
databaseSamplesLost: effectiveTelemetryLost,
|
||||
imuSaturationCount: _imuSaturationCount,
|
||||
isMountCalibrated: true,
|
||||
);
|
||||
|
|
@ -852,7 +872,7 @@ class FieldTestLogger {
|
|||
bestZeroTo60: best0to60,
|
||||
bestZeroTo100: best0to100,
|
||||
databaseSamplesWritten: totalSamples,
|
||||
databaseSamplesLost: _droppedEventsCount,
|
||||
databaseSamplesLost: effectiveTelemetryLost,
|
||||
eventCount: events.length,
|
||||
overallConfidence: qualityReport.overallScore / 100.0,
|
||||
rideQualityScore: qualityReport.overallScore,
|
||||
|
|
@ -896,7 +916,7 @@ class FieldTestLogger {
|
|||
bestZeroTo60: drift.Value(best0to60),
|
||||
bestZeroTo100: drift.Value(best0to100),
|
||||
databaseSamplesWritten: drift.Value(totalSamples),
|
||||
databaseSamplesLost: drift.Value(_droppedEventsCount),
|
||||
databaseSamplesLost: drift.Value(effectiveTelemetryLost),
|
||||
eventCount: drift.Value(events.length),
|
||||
overallConfidence: drift.Value(qualityReport.overallScore / 100.0),
|
||||
rideQualityScore: drift.Value(qualityReport.overallScore),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
|
@ -29,14 +29,21 @@ void main() {
|
|||
expect(logger.isSessionActive, isTrue);
|
||||
expect(logger.activeRideId, 10);
|
||||
|
||||
expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.rideStarted), isTrue);
|
||||
expect(
|
||||
logger.timelineEvents.any(
|
||||
(e) => e.eventType == FieldTestEventType.rideStarted,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final summary = await logger.stopSession();
|
||||
expect(logger.isSessionActive, isFalse);
|
||||
expect(summary.rideId, 10);
|
||||
});
|
||||
|
||||
test('Queue drops lowest-priority events when buffer capacity is reached', () {
|
||||
test(
|
||||
'Queue drops lowest-priority events when buffer capacity is reached',
|
||||
() {
|
||||
logger.startSession(11);
|
||||
|
||||
// Fill queue with 1,000 info events
|
||||
|
|
@ -58,7 +65,82 @@ void main() {
|
|||
);
|
||||
|
||||
expect(logger.droppedEventsCount, greaterThanOrEqualTo(5));
|
||||
expect(logger.timelineEvents.any((e) => e.severity == EventSeverity.critical), isTrue);
|
||||
expect(
|
||||
logger.timelineEvents.any(
|
||||
(e) => e.severity == EventSeverity.critical,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Dropped diagnostic events alone do not inflate databaseSamplesLost',
|
||||
() async {
|
||||
logger.startSession(12);
|
||||
|
||||
// Saturate diagnostic event queue so diagnostic events are dropped
|
||||
for (int i = 0; i < 1010; i++) {
|
||||
logger.log(
|
||||
eventType: FieldTestEventType.databaseFlush,
|
||||
severity: EventSeverity.info,
|
||||
message: 'Flush info event $i',
|
||||
rideId: 12,
|
||||
);
|
||||
}
|
||||
|
||||
expect(logger.droppedEventsCount, greaterThanOrEqualTo(10));
|
||||
// Zero telemetry lost recorded
|
||||
expect(logger.telemetrySamplesLost, 0);
|
||||
|
||||
final summary = await logger.stopSession();
|
||||
// databaseSamplesLost MUST be 0 despite droppedEventsCount > 0
|
||||
expect(summary.databaseSamplesLost, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'Recorded telemetry loss is preserved across diagnostic event drops and reflected in summary',
|
||||
() async {
|
||||
logger.startSession(13);
|
||||
|
||||
// Record 101 telemetry drops
|
||||
logger.recordTelemetryLoss(101);
|
||||
expect(logger.telemetrySamplesLost, 101);
|
||||
|
||||
// Saturate diagnostic event queue with warning burst
|
||||
for (int i = 0; i < 1020; i++) {
|
||||
logger.log(
|
||||
eventType: FieldTestEventType.databaseQueueOverflow,
|
||||
severity: EventSeverity.warning,
|
||||
message: 'Overflow warning $i',
|
||||
rideId: 13,
|
||||
);
|
||||
}
|
||||
|
||||
expect(logger.droppedEventsCount, greaterThanOrEqualTo(20));
|
||||
// Telemetry loss tally must NOT be corrupted by diagnostic event drops
|
||||
expect(logger.telemetrySamplesLost, 101);
|
||||
|
||||
final summary = await logger.stopSession();
|
||||
expect(summary.databaseSamplesLost, 101);
|
||||
},
|
||||
);
|
||||
|
||||
test('Starting a new session resets telemetry loss tally', () async {
|
||||
logger.startSession(14);
|
||||
logger.recordTelemetryLoss(55);
|
||||
expect(logger.telemetrySamplesLost, 55);
|
||||
|
||||
final summary1 = await logger.stopSession();
|
||||
expect(summary1.databaseSamplesLost, 55);
|
||||
|
||||
// Start new session #15
|
||||
logger.startSession(15);
|
||||
expect(logger.telemetrySamplesLost, 0);
|
||||
|
||||
final summary2 = await logger.stopSession();
|
||||
expect(summary2.databaseSamplesLost, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ import 'package:drift/native.dart';
|
|||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:motorcycle_dash/database/database.dart'
|
||||
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
|
||||
import 'package:motorcycle_dash/database/database.dart' as dbgen
|
||||
import 'package:motorcycle_dash/database/database.dart'
|
||||
as dbgen
|
||||
show TelemetrySample;
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:motorcycle_dash/models/field_test_summary.dart';
|
||||
import 'package:motorcycle_dash/models/field_test_event.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/services/location_service.dart';
|
||||
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
||||
|
|
@ -35,6 +37,7 @@ import 'package:motorcycle_dash/services/audio_alert_service.dart';
|
|||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
||||
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
||||
import 'package:motorcycle_dash/models/recording_health_status.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
|
||||
/// Drift's generated row class for telemetry_samples is also named
|
||||
|
|
@ -53,7 +56,8 @@ class _Rid3aAppDatabase extends AppDatabase {
|
|||
|
||||
@override
|
||||
Future<void> batch(
|
||||
FutureOr<void> Function(drift.Batch batch) runInBatch) async {
|
||||
FutureOr<void> Function(drift.Batch batch) runInBatch,
|
||||
) async {
|
||||
batchInvocations++;
|
||||
// Gate FIRST, then optionally fail, so a flush can be parked in-flight and
|
||||
// made to fail on release - required by the ordered-restoration test.
|
||||
|
|
@ -79,6 +83,51 @@ class _Rid3aFieldTestLogger extends FieldTestLogger {
|
|||
_Rid3aFieldTestLogger(super.db);
|
||||
|
||||
int stopSessionInvocations = 0;
|
||||
int overflowEventCount = 0;
|
||||
final List<FieldTestEventType> loggedEventTypes = [];
|
||||
|
||||
@override
|
||||
void log({
|
||||
required FieldTestEventType eventType,
|
||||
required EventSeverity severity,
|
||||
required String message,
|
||||
DateTime? timestamp,
|
||||
int? rideId,
|
||||
DateTime? telemetryTimestamp,
|
||||
int? gpsAgeMs,
|
||||
double? speed,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
double? gpsAccuracy,
|
||||
double? leanAngle,
|
||||
double? longitudinalG,
|
||||
double? lateralG,
|
||||
double? overallConfidence,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) {
|
||||
loggedEventTypes.add(eventType);
|
||||
if (eventType == FieldTestEventType.databaseQueueOverflow) {
|
||||
overflowEventCount++;
|
||||
}
|
||||
super.log(
|
||||
eventType: eventType,
|
||||
severity: severity,
|
||||
message: message,
|
||||
timestamp: timestamp,
|
||||
rideId: rideId,
|
||||
telemetryTimestamp: telemetryTimestamp,
|
||||
gpsAgeMs: gpsAgeMs,
|
||||
speed: speed,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
gpsAccuracy: gpsAccuracy,
|
||||
leanAngle: leanAngle,
|
||||
longitudinalG: longitudinalG,
|
||||
lateralG: lateralG,
|
||||
overallConfidence: overallConfidence,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> flush() async {}
|
||||
|
|
@ -238,8 +287,17 @@ void main() {
|
|||
}
|
||||
}
|
||||
|
||||
Future<List<TelemetryRow>> persistedRows() => (db.select(db.telemetrySamples)
|
||||
..orderBy([(t) => drift.OrderingTerm(expression: t.id)]))
|
||||
Future<List<TelemetryRow>> persistedRows() =>
|
||||
(db.select(db.telemetrySamples)..orderBy([
|
||||
(t) => drift.OrderingTerm(
|
||||
expression: t.timestamp,
|
||||
mode: drift.OrderingMode.asc,
|
||||
),
|
||||
(t) => drift.OrderingTerm(
|
||||
expression: t.id,
|
||||
mode: drift.OrderingMode.asc,
|
||||
),
|
||||
]))
|
||||
.get();
|
||||
|
||||
Map<int, int> countsByIndex(List<TelemetryRow> rows) {
|
||||
|
|
@ -263,8 +321,7 @@ void main() {
|
|||
];
|
||||
|
||||
group('RID-3A: telemetry batch ownership', () {
|
||||
test('single flush persists every sample exactly once, in order',
|
||||
() async {
|
||||
test('single flush persists every sample exactly once, in order', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
final rideId = await startRide(container);
|
||||
|
|
@ -286,14 +343,18 @@ void main() {
|
|||
expect(counts.length, 10, reason: 'N distinct identities');
|
||||
expect(duplicatedIn(counts), isEmpty, reason: '0 duplicates');
|
||||
expect(missingOf(10, counts), isEmpty, reason: '0 missing');
|
||||
expect(rows.map((r) => r.speed).toList(),
|
||||
[for (var i = 0; i < 10; i++) 1000.0 + i],
|
||||
reason: 'correct chronological order');
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId},
|
||||
reason: 'correct ride association');
|
||||
expect(rows.map((r) => r.speed).toList(), [
|
||||
for (var i = 0; i < 10; i++) 1000.0 + i,
|
||||
], reason: 'correct chronological order');
|
||||
expect(rows.map((r) => r.rideId).toSet(), {
|
||||
rideId,
|
||||
}, reason: 'correct ride association');
|
||||
expect(session.totalSamplesReceived, 10);
|
||||
expect(session.totalSamplesPersisted, 10,
|
||||
reason: 'buffer empty after a successful final flush');
|
||||
expect(
|
||||
session.totalSamplesPersisted,
|
||||
10,
|
||||
reason: 'buffer empty after a successful final flush',
|
||||
);
|
||||
});
|
||||
|
||||
test('overlapping periodic and finishRide flushes persist all 30 accepted '
|
||||
|
|
@ -310,7 +371,11 @@ void main() {
|
|||
// Tick 1: the periodic flush takes exclusive ownership of 0..9, removes
|
||||
// them from the live buffer, and parks inside db.batch.
|
||||
await Future<void>.delayed(tickWait);
|
||||
expect(db.batchInvocations, 1, reason: 'periodic flush parked in db.batch');
|
||||
expect(
|
||||
db.batchInvocations,
|
||||
1,
|
||||
reason: 'periodic flush parked in db.batch',
|
||||
);
|
||||
final flushA = db.batchGates.single;
|
||||
|
||||
// Newer telemetry (indices 10..29) arrives while A is still in flight.
|
||||
|
|
@ -327,8 +392,11 @@ void main() {
|
|||
// drains the newer samples only after A has committed.
|
||||
final finishing = notifier.finishRide();
|
||||
await pumpEventQueue(times: 30);
|
||||
expect(db.batchInvocations, 1,
|
||||
reason: 'while the owner is parked, no second flush may take ownership');
|
||||
expect(
|
||||
db.batchInvocations,
|
||||
1,
|
||||
reason: 'while the owner is parked, no second flush may take ownership',
|
||||
);
|
||||
|
||||
// Release ONLY flush A, leaving the gate armed so the RID-3B-1 durability
|
||||
// barrier's own drain parks as the next gate. That yields a deterministic
|
||||
|
|
@ -339,10 +407,16 @@ void main() {
|
|||
|
||||
final rowsOwnedByA = await persistedRows();
|
||||
final countsOwnedByA = countsByIndex(rowsOwnedByA);
|
||||
expect(duplicatedIn(countsOwnedByA), isEmpty,
|
||||
reason: 'no duplication from overlapping ownership');
|
||||
expect(missingOf(10, countsOwnedByA), isEmpty,
|
||||
reason: 'A persisted its whole owned snapshot');
|
||||
expect(
|
||||
duplicatedIn(countsOwnedByA),
|
||||
isEmpty,
|
||||
reason: 'no duplication from overlapping ownership',
|
||||
);
|
||||
expect(
|
||||
missingOf(10, countsOwnedByA),
|
||||
isEmpty,
|
||||
reason: 'A persisted its whole owned snapshot',
|
||||
);
|
||||
// The real RID-3A ownership property: flush A's write contains exactly its
|
||||
// own identity set, and none of the newer samples - those remain buffered
|
||||
// for later ownership. This is observed BEFORE finishRide can complete the
|
||||
|
|
@ -350,13 +424,21 @@ void main() {
|
|||
// intentionally drains all accepted pre-boundary telemetry before
|
||||
// finalizing. The previous "only 10 rows after await finishing" expectation
|
||||
// depended on the finish flush being a no-op, which RID-3B-1 removed.
|
||||
expect(countsOwnedByA.keys.toList()..sort(),
|
||||
expect(
|
||||
countsOwnedByA.keys.toList()..sort(),
|
||||
[for (var i = 0; i < 10; i++) i],
|
||||
reason: 'flush A owns exactly indices 0-9');
|
||||
expect(countsOwnedByA.keys.where((i) => i >= 10).toList(), isEmpty,
|
||||
reason: 'no newer sample was included in flush A write');
|
||||
expect(db.batchGates, hasLength(2),
|
||||
reason: 'the barrier now owns the newer batch and is parked');
|
||||
reason: 'flush A owns exactly indices 0-9',
|
||||
);
|
||||
expect(
|
||||
countsOwnedByA.keys.where((i) => i >= 10).toList(),
|
||||
isEmpty,
|
||||
reason: 'no newer sample was included in flush A write',
|
||||
);
|
||||
expect(
|
||||
db.batchGates,
|
||||
hasLength(2),
|
||||
reason: 'the barrier now owns the newer batch and is parked',
|
||||
);
|
||||
|
||||
// Release the barrier's drain and let finishRide complete. It persists
|
||||
// 10..29 itself, so no later periodic flush is required.
|
||||
|
|
@ -376,23 +458,36 @@ void main() {
|
|||
debugPrint('distinct identities : ${counts.length}');
|
||||
debugPrint('duplicated indices : ${duplicatedIn(counts)}');
|
||||
debugPrint('missing indices : ${missingOf(30, counts)}');
|
||||
debugPrint('received / persisted : '
|
||||
'${session.totalSamplesReceived} / ${session.totalSamplesPersisted}');
|
||||
debugPrint('rideIds : ${rows.map((r) => r.rideId).toSet()}');
|
||||
debugPrint(
|
||||
'received / persisted : '
|
||||
'${session.totalSamplesReceived} / ${session.totalSamplesPersisted}',
|
||||
);
|
||||
debugPrint(
|
||||
'rideIds : ${rows.map((r) => r.rideId).toSet()}',
|
||||
);
|
||||
|
||||
// Previously: 30 rows / 20 distinct, indices 0-9 twice, 10-19 destroyed.
|
||||
expect(rows, hasLength(30), reason: '30 accepted -> 30 durable rows');
|
||||
expect(counts.length, 30, reason: '30 distinct identities');
|
||||
expect(duplicatedIn(counts), isEmpty, reason: '0 duplicates');
|
||||
expect(missingOf(30, counts), isEmpty, reason: '0 missing');
|
||||
expect(rows.map((r) => r.speed).toList(),
|
||||
expect(
|
||||
rows.map((r) => r.speed).toList(),
|
||||
[for (var i = 0; i < 30; i++) 1000.0 + i],
|
||||
reason: 'chronological identity order preserved');
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId},
|
||||
reason: 'every sample against the correct ride');
|
||||
expect(session.totalSamplesPersisted, 30,
|
||||
reason: 'counters agree with distinct durable samples; nothing left '
|
||||
'intentionally buffered after the final flush');
|
||||
reason: 'chronological identity order preserved',
|
||||
);
|
||||
expect(
|
||||
rows.map((r) => r.rideId).toSet(),
|
||||
{rideId},
|
||||
reason: 'every sample against the correct ride',
|
||||
);
|
||||
expect(
|
||||
session.totalSamplesPersisted,
|
||||
30,
|
||||
reason:
|
||||
'counters agree with distinct durable samples; nothing left '
|
||||
'intentionally buffered after the final flush',
|
||||
);
|
||||
});
|
||||
|
||||
test('telemetry arriving during an in-flight flush is persisted exactly '
|
||||
|
|
@ -434,11 +529,15 @@ void main() {
|
|||
debugPrint('order : ${rows.map((r) => r.speed).toList()}');
|
||||
|
||||
expect(rows, hasLength(10));
|
||||
expect(duplicatedIn(counts), isEmpty, reason: 'A and B each exactly once');
|
||||
expect(
|
||||
duplicatedIn(counts),
|
||||
isEmpty,
|
||||
reason: 'A and B each exactly once',
|
||||
);
|
||||
expect(missingOf(10, counts), isEmpty, reason: 'nothing missing');
|
||||
expect(rows.map((r) => r.speed).toList(),
|
||||
[for (var i = 0; i < 10; i++) 1000.0 + i],
|
||||
reason: 'A persisted before B');
|
||||
expect(rows.map((r) => r.speed).toList(), [
|
||||
for (var i = 0; i < 10; i++) 1000.0 + i,
|
||||
], reason: 'A persisted before B');
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId});
|
||||
expect(session.totalSamplesPersisted, 10);
|
||||
});
|
||||
|
|
@ -468,11 +567,17 @@ void main() {
|
|||
await pumpEventQueue(times: 40);
|
||||
|
||||
var session = container.read(rideStateMachineProvider);
|
||||
expect(session.totalSamplesPersisted, 0,
|
||||
reason: 'a failed persistence must not increment the success counter');
|
||||
expect(
|
||||
session.totalSamplesPersisted,
|
||||
0,
|
||||
reason: 'a failed persistence must not increment the success counter',
|
||||
);
|
||||
expect(await persistedRows(), isEmpty, reason: 'nothing durable yet');
|
||||
expect(session.state, RideState.riding,
|
||||
reason: 'telemetry recording continues so a later flush can recover');
|
||||
expect(
|
||||
session.state,
|
||||
RideState.riding,
|
||||
reason: 'telemetry recording continues so a later flush can recover',
|
||||
);
|
||||
|
||||
// Existing periodic timer provides the legitimate retry. No automatic
|
||||
// retry worker is introduced.
|
||||
|
|
@ -490,19 +595,332 @@ void main() {
|
|||
debugPrint('duplicated : ${duplicatedIn(counts)}');
|
||||
debugPrint('missing : ${missingOf(6, counts)}');
|
||||
debugPrint('order : ${rows.map((r) => r.speed).toList()}');
|
||||
debugPrint('received / persisted : '
|
||||
'${session.totalSamplesReceived} / ${session.totalSamplesPersisted}');
|
||||
debugPrint(
|
||||
'received / persisted : '
|
||||
'${session.totalSamplesReceived} / ${session.totalSamplesPersisted}',
|
||||
);
|
||||
|
||||
expect(rows, hasLength(6));
|
||||
expect(duplicatedIn(counts), isEmpty, reason: 'retry causes no duplicates');
|
||||
expect(
|
||||
duplicatedIn(counts),
|
||||
isEmpty,
|
||||
reason: 'retry causes no duplicates',
|
||||
);
|
||||
expect(missingOf(6, counts), isEmpty, reason: 'no rows missing');
|
||||
expect(rows.map((r) => r.speed).toList(),
|
||||
expect(
|
||||
rows.map((r) => r.speed).toList(),
|
||||
[1000.0, 1001.0, 1002.0, 1003.0, 1004.0, 1005.0],
|
||||
reason: 'failed snapshot A B C restored AHEAD of newer D E F, both '
|
||||
'internal orders preserved');
|
||||
reason:
|
||||
'failed snapshot A B C restored AHEAD of newer D E F, both '
|
||||
'internal orders preserved',
|
||||
);
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId});
|
||||
expect(session.totalSamplesPersisted, 6,
|
||||
reason: 'counter reflects only the successfully persisted snapshot');
|
||||
expect(
|
||||
session.totalSamplesPersisted,
|
||||
6,
|
||||
reason: 'counter reflects only the successfully persisted snapshot',
|
||||
);
|
||||
});
|
||||
|
||||
test('failed flush restoration above 1000 capacity bounds queue to 1000 '
|
||||
'by dropping oldest samples', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
final rideId = await startRide(container);
|
||||
|
||||
// 1. Emit 600 samples (indices 0..599).
|
||||
emitSamples(0, 600);
|
||||
await pumpEventQueue(times: 30);
|
||||
expect(
|
||||
container.read(rideStateMachineProvider).totalSamplesReceived,
|
||||
600,
|
||||
);
|
||||
|
||||
// 2. Start flush of those 600 samples, but gate db.batch.
|
||||
db.gateBatch = true;
|
||||
db.batchFailuresRemaining = 1;
|
||||
final finishAttempt = notifier.finishRide();
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(db.batchInvocations, 1);
|
||||
final failingFlush = db.batchGates.single;
|
||||
|
||||
// 3. While write is pending, emit 500 new samples (indices 600..1099).
|
||||
// Total received = 1100.
|
||||
emitSamples(600, 500);
|
||||
await pumpEventQueue(times: 30);
|
||||
expect(
|
||||
container.read(rideStateMachineProvider).totalSamplesReceived,
|
||||
1100,
|
||||
);
|
||||
|
||||
// 4. Fail the write. Snapshot of 600 is restored to front of the 500 new samples.
|
||||
// Combined = 1100 > 1000.
|
||||
db.gateBatch = false;
|
||||
failingFlush.complete();
|
||||
await expectLater(finishAttempt, throwsStateError);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
// Assert ride remains open and no final summary produced after failed Stop
|
||||
final intermediateSession = container.read(rideStateMachineProvider);
|
||||
expect(
|
||||
intermediateSession.state,
|
||||
RideState.riding,
|
||||
reason: 'ride remains riding on stop failure',
|
||||
);
|
||||
expect(
|
||||
intermediateSession.healthStatus,
|
||||
RecordingHealthStatus.recordingActive,
|
||||
reason: 'recordingActive restored on stop failure',
|
||||
);
|
||||
final intermediateRides = await db.select(db.rides).get();
|
||||
expect(
|
||||
intermediateRides.singleWhere((r) => r.id == rideId).endTime,
|
||||
isNull,
|
||||
reason: 'no endTime written to DB on stop failure',
|
||||
);
|
||||
expect(
|
||||
logger.stopSessionInvocations,
|
||||
0,
|
||||
reason: 'no logger stopSession called on stop failure',
|
||||
);
|
||||
expect(
|
||||
await db.select(db.fieldTestSummaries).get(),
|
||||
isEmpty,
|
||||
reason: 'no final summary produced on stop failure',
|
||||
);
|
||||
expect(
|
||||
notifier.telemetrySamplesLost,
|
||||
100,
|
||||
reason: '100 samples lost on restoration excess',
|
||||
);
|
||||
expect(logger.telemetrySamplesLost, 100);
|
||||
|
||||
// 5. Emit 1 more sample (index 1100).
|
||||
emitSamples(1100, 1);
|
||||
await pumpEventQueue(times: 30);
|
||||
expect(
|
||||
container.read(rideStateMachineProvider).totalSamplesReceived,
|
||||
1101,
|
||||
);
|
||||
expect(
|
||||
notifier.telemetrySamplesLost,
|
||||
101,
|
||||
reason: '101 samples lost after 1 drop on arrival',
|
||||
);
|
||||
expect(logger.telemetrySamplesLost, 101);
|
||||
|
||||
// 6. Now do a successful finish flush to persist whatever remains in queue.
|
||||
await notifier.finishRide();
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
final rows = await persistedRows();
|
||||
final counts = countsByIndex(rows);
|
||||
final session = container.read(rideStateMachineProvider);
|
||||
|
||||
debugPrint('==== RID-3A OVERFLOW REGRESSION ====');
|
||||
debugPrint('durable rows : ${rows.length}');
|
||||
debugPrint('distinct identities : ${counts.length}');
|
||||
debugPrint('overflow events : ${logger.overflowEventCount}');
|
||||
debugPrint(
|
||||
'first row speed : ${rows.isNotEmpty ? rows.first.speed : "empty"}',
|
||||
);
|
||||
debugPrint(
|
||||
'last row speed : ${rows.isNotEmpty ? rows.last.speed : "empty"}',
|
||||
);
|
||||
|
||||
// Must be capped at 1000.
|
||||
expect(rows, hasLength(1000));
|
||||
// First 101 oldest samples (indices 0..100) dropped (100 on restoration + 1 on sample 1100).
|
||||
// Verify complete retained sample identity sequence in exact order.
|
||||
expect(
|
||||
rows.map((r) => r.speed).toList(),
|
||||
[for (var i = 101; i <= 1100; i++) 1000.0 + i],
|
||||
reason: 'complete sequence of 1000 retained samples in exact order',
|
||||
);
|
||||
expect(duplicatedIn(counts), isEmpty);
|
||||
expect(logger.overflowEventCount, 101);
|
||||
expect(session.totalSamplesPersisted, 1000);
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId});
|
||||
|
||||
final summaries = await db.select(db.fieldTestSummaries).get();
|
||||
expect(summaries, hasLength(1));
|
||||
final summary = summaries.single;
|
||||
expect(
|
||||
summary.databaseSamplesLost,
|
||||
101,
|
||||
reason: 'databaseSamplesLost reports exact dropped telemetry',
|
||||
);
|
||||
expect(summary.databaseSamplesWritten, 1000);
|
||||
expect(notifier.telemetrySamplesLost, 101);
|
||||
expect(logger.telemetrySamplesLost, 101);
|
||||
});
|
||||
|
||||
test('repeated flush failures keep queue bounded to exactly 1000 capacity', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
final rideId = await startRide(container);
|
||||
|
||||
// Cycle 1: Emit 800 samples (0..799).
|
||||
emitSamples(0, 800);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
db.gateBatch = true;
|
||||
db.batchFailuresRemaining = 1;
|
||||
final finish1 = notifier.finishRide();
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(db.batchInvocations, 1);
|
||||
final flush1 = db.batchGates.single;
|
||||
|
||||
// 300 arrive during await (800..1099). Total received: 1100.
|
||||
emitSamples(800, 300);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
db.gateBatch = false;
|
||||
flush1.complete();
|
||||
await expectLater(finish1, throwsStateError);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
// Assert ride remains open and no final summary produced after fail 1
|
||||
final sessionAfterFail1 = container.read(rideStateMachineProvider);
|
||||
expect(sessionAfterFail1.state, RideState.riding);
|
||||
expect(
|
||||
sessionAfterFail1.healthStatus,
|
||||
RecordingHealthStatus.recordingActive,
|
||||
);
|
||||
final ridesAfterFail1 = await db.select(db.rides).get();
|
||||
expect(
|
||||
ridesAfterFail1.singleWhere((r) => r.id == rideId).endTime,
|
||||
isNull,
|
||||
);
|
||||
expect(logger.stopSessionInvocations, 0);
|
||||
expect(await db.select(db.fieldTestSummaries).get(), isEmpty);
|
||||
|
||||
// First failure: 800 + 300 = 1100 -> 100 oldest dropped (0..99). Retained: 100..1099.
|
||||
expect(logger.overflowEventCount, 100);
|
||||
expect(notifier.telemetrySamplesLost, 100);
|
||||
expect(logger.telemetrySamplesLost, 100);
|
||||
|
||||
// Cycle 2: Flush 2 starts, owns all 1000 currently in queue.
|
||||
db.batchGates.clear();
|
||||
db.gateBatch = true;
|
||||
db.batchFailuresRemaining = 1;
|
||||
final finish2 = notifier.finishRide();
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(db.batchInvocations, 2);
|
||||
final flush2 = db.batchGates.single;
|
||||
|
||||
// 200 arrive during await (1100..1299). Total received: 1300.
|
||||
emitSamples(1100, 200);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
db.gateBatch = false;
|
||||
flush2.complete();
|
||||
await expectLater(finish2, throwsStateError);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
// Assert ride remains open and no final summary produced after fail 2
|
||||
final sessionAfterFail2 = container.read(rideStateMachineProvider);
|
||||
expect(sessionAfterFail2.state, RideState.riding);
|
||||
expect(
|
||||
sessionAfterFail2.healthStatus,
|
||||
RecordingHealthStatus.recordingActive,
|
||||
);
|
||||
final ridesAfterFail2 = await db.select(db.rides).get();
|
||||
expect(
|
||||
ridesAfterFail2.singleWhere((r) => r.id == rideId).endTime,
|
||||
isNull,
|
||||
);
|
||||
expect(logger.stopSessionInvocations, 0);
|
||||
expect(await db.select(db.fieldTestSummaries).get(), isEmpty);
|
||||
|
||||
// Second failure: 1000 + 200 = 1200 -> 200 oldest dropped (100..299). Retained: 300..1299.
|
||||
expect(logger.overflowEventCount, 300);
|
||||
expect(notifier.telemetrySamplesLost, 300);
|
||||
expect(logger.telemetrySamplesLost, 300);
|
||||
|
||||
// Subsequent 50 arrivals (1300..1349).
|
||||
emitSamples(1300, 50);
|
||||
await pumpEventQueue(times: 30);
|
||||
// Each drops 1 oldest (300..349). Retained: 350..1349 (1000 samples).
|
||||
expect(logger.overflowEventCount, 350);
|
||||
expect(notifier.telemetrySamplesLost, 350);
|
||||
expect(logger.telemetrySamplesLost, 350);
|
||||
|
||||
// Successful finalization persists retained 1000.
|
||||
await notifier.finishRide();
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
final rows = await persistedRows();
|
||||
final counts = countsByIndex(rows);
|
||||
final session = container.read(rideStateMachineProvider);
|
||||
|
||||
debugPrint('==== RID-3A REPEATED FAILURES ====');
|
||||
debugPrint('durable rows : ${rows.length}');
|
||||
debugPrint('distinct identities : ${counts.length}');
|
||||
debugPrint('overflow events : ${logger.overflowEventCount}');
|
||||
debugPrint(
|
||||
'first row speed : ${rows.isNotEmpty ? rows.first.speed : "empty"}',
|
||||
);
|
||||
debugPrint(
|
||||
'last row speed : ${rows.isNotEmpty ? rows.last.speed : "empty"}',
|
||||
);
|
||||
|
||||
expect(rows, hasLength(1000));
|
||||
// Verify complete retained sample identity sequence in exact order.
|
||||
expect(
|
||||
rows.map((r) => r.speed).toList(),
|
||||
[for (var i = 350; i <= 1349; i++) 1000.0 + i],
|
||||
reason:
|
||||
'complete sequence of 1000 retained samples in exact order after repeated drops',
|
||||
);
|
||||
expect(duplicatedIn(counts), isEmpty);
|
||||
expect(logger.overflowEventCount, 350);
|
||||
expect(session.totalSamplesPersisted, 1000);
|
||||
expect(rows.map((r) => r.rideId).toSet(), {rideId});
|
||||
|
||||
final summaries = await db.select(db.fieldTestSummaries).get();
|
||||
expect(summaries, hasLength(1));
|
||||
final summary = summaries.single;
|
||||
expect(
|
||||
summary.databaseSamplesLost,
|
||||
350,
|
||||
reason: 'databaseSamplesLost reflects 350 total dropped telemetry',
|
||||
);
|
||||
expect(summary.databaseSamplesWritten, 1000);
|
||||
expect(notifier.telemetrySamplesLost, 350);
|
||||
expect(logger.telemetrySamplesLost, 350);
|
||||
});
|
||||
|
||||
test(
|
||||
'clean ride with zero telemetry loss produces databaseSamplesLost == 0 and new ride resets counter',
|
||||
() async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
final logger = container.read(fieldTestLoggerProvider);
|
||||
final rideId = await startRide(container);
|
||||
|
||||
// Emit 50 samples (< 1000 buffer capacity)
|
||||
emitSamples(0, 50);
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
expect(notifier.telemetrySamplesLost, 0);
|
||||
expect(logger.telemetrySamplesLost, 0);
|
||||
|
||||
// Finish cleanly
|
||||
await notifier.finishRide();
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
final summaries = await db.select(db.fieldTestSummaries).get();
|
||||
expect(summaries, hasLength(1));
|
||||
expect(summaries.single.databaseSamplesLost, 0);
|
||||
expect(summaries.single.databaseSamplesWritten, 50);
|
||||
|
||||
// Start new ride: counters must reset to 0
|
||||
final rideId2 = await startRide(container);
|
||||
expect(rideId2, isNot(rideId));
|
||||
expect(notifier.telemetrySamplesLost, 0);
|
||||
expect(logger.telemetrySamplesLost, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue