fix: make cloud ride bundles deterministic across retries
This commit is contained in:
parent
815ca09ab4
commit
f080410005
|
|
@ -57,7 +57,7 @@ class FieldTestExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 15: Complete JSON Forensic Session Export with Versioning Identity
|
/// Step 15: Complete JSON Forensic Session Export with Versioning Identity
|
||||||
Future<String> generateFieldTestSessionJson(int rideId) async {
|
Future<String> generateFieldTestSessionJson(int rideId, {DateTime? generatedAt}) async {
|
||||||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
|
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
|
||||||
final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
|
final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
|
||||||
final events = await (_db.select(_db.fieldTestEvents)
|
final events = await (_db.select(_db.fieldTestEvents)
|
||||||
|
|
@ -70,7 +70,7 @@ class FieldTestExportService {
|
||||||
|
|
||||||
final Map<String, dynamic> exportMap = {
|
final Map<String, dynamic> exportMap = {
|
||||||
'exportVersion': '1.0.0',
|
'exportVersion': '1.0.0',
|
||||||
'generatedAt': DateTime.now().toIso8601String(),
|
'generatedAt': (generatedAt ?? DateTime.now()).toIso8601String(),
|
||||||
'software': appIdentity.toJson(),
|
'software': appIdentity.toJson(),
|
||||||
'rideMetadata': {
|
'rideMetadata': {
|
||||||
'id': ride?.id,
|
'id': ride?.id,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ Uint8List compressFilesToZipIsolate(Map<String, String> filesMap) {
|
||||||
archive.addFile(ArchiveFile(entry.key, bytes.length, bytes));
|
archive.addFile(ArchiveFile(entry.key, bytes.length, bytes));
|
||||||
}
|
}
|
||||||
final zipEncoder = ZipEncoder();
|
final zipEncoder = ZipEncoder();
|
||||||
final compressed = zipEncoder.encode(archive);
|
// Keep retry bytes and the recorded SHA-256 stable for unchanged ride data.
|
||||||
|
final compressed = zipEncoder.encode(archive, modified: DateTime.utc(1980, 1, 1));
|
||||||
return Uint8List.fromList(compressed);
|
return Uint8List.fromList(compressed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +42,10 @@ class SyncBundleWorker {
|
||||||
final exportService = ExportService(_db);
|
final exportService = ExportService(_db);
|
||||||
|
|
||||||
// 1. Query local authoritative data on the main isolate
|
// 1. Query local authoritative data on the main isolate
|
||||||
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId);
|
final sessionJson = await fieldTestExport.generateFieldTestSessionJson(
|
||||||
|
rideId,
|
||||||
|
generatedAt: ride.endTime ?? ride.startTime,
|
||||||
|
);
|
||||||
final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId);
|
final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId);
|
||||||
final telemetryCsv = await exportService.exportRideCsv(rideId);
|
final telemetryCsv = await exportService.exportRideCsv(rideId);
|
||||||
final gpxContent = await exportService.exportRideGpx(rideId);
|
final gpxContent = await exportService.exportRideGpx(rideId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:motorcycle_dash/database/database.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/r2_upload_service.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_bundle_worker.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
||||||
|
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
||||||
|
|
||||||
|
class _R2ThenFirestoreProvider implements SyncProvider {
|
||||||
|
int attempts = 0;
|
||||||
|
bool firestoreAvailable = false;
|
||||||
|
final List<String> uploadedRemoteIds = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get providerId => 'firebase';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isAvailable() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requiresAuthentication() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
|
attempts++;
|
||||||
|
uploadedRemoteIds.add('ride_stable_123');
|
||||||
|
if (!firestoreAvailable) {
|
||||||
|
return SyncResult.temporaryFailure(
|
||||||
|
'Firestore unavailable after R2 upload.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SyncResult.success(remoteId: 'ride_stable_123');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('R2 and Firestore retry hardening', () {
|
||||||
|
test(
|
||||||
|
'retries target one deterministic R2 object, including changed bundle bytes',
|
||||||
|
() async {
|
||||||
|
final remoteIds = <String>[];
|
||||||
|
final objectKeys = <String>[];
|
||||||
|
final client = MockClient((request) async {
|
||||||
|
remoteIds.add(request.headers['X-Remote-Ride-Id']!);
|
||||||
|
final remoteRideId = request.headers['X-Remote-Ride-Id']!;
|
||||||
|
final objectKey = 'users/pilot_123/rides/$remoteRideId/telemetry.zip';
|
||||||
|
objectKeys.add(objectKey);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'objectKey': objectKey, 'size': 4}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
final service = R2UploadService(
|
||||||
|
client: client,
|
||||||
|
workerBaseUrl: 'https://worker.test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final first = await service.uploadTelemetryBundle(
|
||||||
|
idToken: 'token',
|
||||||
|
remoteRideId: 'ride_stable_123',
|
||||||
|
zipBytes: Uint8List.fromList([1, 2, 3, 4]),
|
||||||
|
sha256Hash: 'a' * 64,
|
||||||
|
appVersion: '1.0.2',
|
||||||
|
);
|
||||||
|
final retry = await service.uploadTelemetryBundle(
|
||||||
|
idToken: 'token',
|
||||||
|
remoteRideId: 'ride_stable_123',
|
||||||
|
zipBytes: Uint8List.fromList([4, 3, 2, 1]),
|
||||||
|
sha256Hash: 'b' * 64,
|
||||||
|
appVersion: '1.0.2',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first.success, isTrue);
|
||||||
|
expect(retry.success, isTrue);
|
||||||
|
expect(remoteIds, ['ride_stable_123', 'ride_stable_123']);
|
||||||
|
expect(objectKeys.toSet(), hasLength(1));
|
||||||
|
expect(first.objectKey, retry.objectKey);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'R2 success then Firestore failure remains durable and retries one cloud ride',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final outbox = SyncOutbox(prefs);
|
||||||
|
final provider = _R2ThenFirestoreProvider();
|
||||||
|
var now = DateTime(2026, 9, 3, 12);
|
||||||
|
final manager = SyncManager(
|
||||||
|
outbox,
|
||||||
|
initialProviders: [provider],
|
||||||
|
defaultProviderId: 'firebase',
|
||||||
|
nowProvider: () => now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.enqueueRide(42, autoProcess: false);
|
||||||
|
await manager.processQueue();
|
||||||
|
expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.retryWait);
|
||||||
|
expect(outbox.isRideSynced(42, 'firebase'), isFalse);
|
||||||
|
|
||||||
|
provider.firestoreAvailable = true;
|
||||||
|
now = now.add(const Duration(seconds: 5));
|
||||||
|
await manager.processQueue();
|
||||||
|
|
||||||
|
expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.synced);
|
||||||
|
expect(provider.attempts, 2);
|
||||||
|
expect(provider.uploadedRemoteIds.toSet(), {'ride_stable_123'});
|
||||||
|
manager.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'interrupted uploading state reloads as retryable queued work',
|
||||||
|
() async {
|
||||||
|
final item = SyncItem(
|
||||||
|
localRideId: 42,
|
||||||
|
providerId: 'firebase',
|
||||||
|
status: SyncStatus.uploading,
|
||||||
|
createdAt: DateTime(2026, 9, 3),
|
||||||
|
);
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([
|
||||||
|
item.toJson(),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final reloaded = SyncOutbox(prefs);
|
||||||
|
expect(reloaded.getItem(42, 'firebase')?.status, SyncStatus.queued);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'completed ride bundle is byte-stable when regenerated for a retry',
|
||||||
|
() async {
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
final rideId = await db
|
||||||
|
.into(db.rides)
|
||||||
|
.insert(
|
||||||
|
RidesCompanion.insert(
|
||||||
|
startTime: DateTime(2026, 9, 3, 10),
|
||||||
|
endTime: drift.Value(DateTime(2026, 9, 3, 11)),
|
||||||
|
distanceMiles: const drift.Value(12.4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final worker = SyncBundleWorker(db);
|
||||||
|
|
||||||
|
final first = await worker.generateCompressedBundle(rideId);
|
||||||
|
final retry = await worker.generateCompressedBundle(rideId);
|
||||||
|
|
||||||
|
expect(first, isNotNull);
|
||||||
|
expect(retry, isNotNull);
|
||||||
|
expect(
|
||||||
|
sha256.convert(first!).toString(),
|
||||||
|
sha256.convert(retry!).toString(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'production path upserts one Firestore document after R2 and records its hash',
|
||||||
|
() {
|
||||||
|
final providerSource = File(
|
||||||
|
'lib/services/sync/firebase_sync_provider.dart',
|
||||||
|
).readAsStringSync();
|
||||||
|
final workerSource = File(
|
||||||
|
'cloudflare/motorsignal-r2-worker/src/index.ts',
|
||||||
|
).readAsStringSync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
workerSource,
|
||||||
|
contains(
|
||||||
|
r'const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
workerSource,
|
||||||
|
contains('await env.R2_BUCKET.put(objectKey, payloadBuffer,'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
providerSource.indexOf('uploadTelemetryBundle('),
|
||||||
|
lessThan(
|
||||||
|
providerSource.indexOf(
|
||||||
|
".collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(providerSource, contains("'sha256': sha256Hash"));
|
||||||
|
expect(
|
||||||
|
providerSource,
|
||||||
|
contains(
|
||||||
|
".doc(remoteRideId).set(firestorePayload, SetOptions(merge: true))",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(providerSource, contains("'telemetryBundlePath': objectKey"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue