Recover interrupted sync uploads after process restart

This commit is contained in:
bponders12-cell 2026-09-01 16:57:09 -04:00
parent 693f5f8aec
commit 48fa29aca2
1 changed files with 26 additions and 84 deletions

View File

@ -3,133 +3,75 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'sync_models.dart'; import 'sync_models.dart';
/// Persistent, crash-resilient synchronization outbox outside Drift Schema 5.
class SyncOutbox { class SyncOutbox {
static const String _outboxStorageKey = 'motorsignal_sync_outbox_manifest_v1'; static const String _outboxStorageKey = 'motorsignal_sync_outbox_manifest_v1';
static const int maxOutboxCapacity = 500; static const int maxOutboxCapacity = 500;
final SharedPreferences _prefs; final SharedPreferences _prefs;
final Map<String, SyncItem> _items = {}; final Map<String, SyncItem> _items = {};
Future<void> _saveChain = Future<void>.value(); Future<void> _saveChain = Future<void>.value();
bool _isLoaded = false; bool _isLoaded = false;
SyncOutbox(this._prefs) { SyncOutbox(this._prefs) { load(); }
load();
}
bool get isLoaded => _isLoaded; bool get isLoaded => _isLoaded;
int get count => _items.length; int get count => _items.length;
void load() { void load() {
_items.clear(); _items.clear();
final rawJson = _prefs.getString(_outboxStorageKey); final rawJson = _prefs.getString(_outboxStorageKey);
if (rawJson == null || rawJson.trim().isEmpty) { if (rawJson == null || rawJson.trim().isEmpty) { _isLoaded = true; return; }
_isLoaded = true;
return;
}
try { try {
final decoded = jsonDecode(rawJson); final decoded = jsonDecode(rawJson);
if (decoded is! List) throw const FormatException('Outbox manifest is not a list'); if (decoded is! List) throw const FormatException('Outbox manifest is not a list');
for (final entry in decoded) { for (final entry in decoded) {
try { try {
final map = entry is Map<String, dynamic> final map = entry is Map<String, dynamic> ? entry : Map<String, dynamic>.from(entry as Map);
? entry
: Map<String, dynamic>.from(entry as Map);
final item = SyncItem.fromJson(map); final item = SyncItem.fromJson(map);
_items[item.key] = item; // An app/process death cannot leave an upload genuinely in progress.
} catch (itemError) { // Requeue it so the provider can safely retry its deterministic operation.
debugPrint('[SyncOutbox] Skipped malformed item: $itemError'); _items[item.key] = item.status == SyncStatus.uploading
} ? item.copyWith(status: SyncStatus.queued, clearNextRetry: true)
: item;
} catch (itemError) { debugPrint('[SyncOutbox] Skipped malformed item: $itemError'); }
} }
} catch (e) { } catch (e) { debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e'); _items.clear(); }
debugPrint('[SyncOutbox] Corrupt manifest recovered safely: $e');
_items.clear();
}
_isLoaded = true; _isLoaded = true;
} }
Future<bool> _save() { Future<bool> _save() {
// Serialize writes so concurrent enqueue/update calls cannot overwrite
// one another with stale snapshots.
final operation = _saveChain.then((_) async { final operation = _saveChain.then((_) async {
if (_items.length > maxOutboxCapacity) { if (_items.length > maxOutboxCapacity) {
final terminal = _items.values.where((i) => i.status.isTerminal).toList() final terminal = _items.values.where((i) => i.status.isTerminal).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
..sort((a, b) => a.createdAt.compareTo(b.createdAt)); while (_items.length > maxOutboxCapacity && terminal.isNotEmpty) _items.remove(terminal.removeAt(0).key);
while (_items.length > maxOutboxCapacity && terminal.isNotEmpty) {
final item = terminal.removeAt(0);
_items.remove(item.key);
}
} }
if (_items.length > maxOutboxCapacity) { if (_items.length > maxOutboxCapacity) throw StateError('Sync outbox capacity reached with no evictable terminal items.');
throw StateError('Sync outbox capacity reached with no evictable terminal items.'); return _prefs.setString(_outboxStorageKey, jsonEncode(_items.values.map((e) => e.toJson()).toList()));
}
return _prefs.setString(
_outboxStorageKey,
jsonEncode(_items.values.map((e) => e.toJson()).toList()),
);
}); });
_saveChain = operation.then((_) {}, onError: (_) {}); _saveChain = operation.then((_) {}, onError: (_) {});
return operation; return operation;
} }
SyncItem? getItem(int localRideId, String providerId) => _items['$providerId:$localRideId']; SyncItem? getItem(int localRideId, String providerId) => _items['${providerId.trim()}:$localRideId'];
List<SyncItem> getAllItems() => _items.values.toList(growable: false); List<SyncItem> getAllItems() => _items.values.toList(growable: false);
List<SyncItem> getPendingItems() => _items.values.where((i) => i.status.isPending).toList(growable: false); List<SyncItem> getPendingItems() => _items.values.where((i) => i.status.isPending).toList(growable: false);
List<SyncItem> getEligibleItems(DateTime now) => _items.values.where((i) => i.isEligibleForAttempt(now)).toList(growable: false);
List<SyncItem> getEligibleItems(DateTime now) => _items.values
.where((item) => item.isEligibleForAttempt(now))
.toList(growable: false);
Future<SyncItem> enqueue(int localRideId, String providerId, {DateTime? now}) async { Future<SyncItem> enqueue(int localRideId, String providerId, {DateTime? now}) async {
if (localRideId <= 0 || providerId.trim().isEmpty) { if (localRideId <= 0 || providerId.trim().isEmpty) throw ArgumentError('A positive localRideId and non-empty providerId are required.');
throw ArgumentError('A positive localRideId and non-empty providerId are required.');
}
final normalizedProvider = providerId.trim(); final normalizedProvider = providerId.trim();
final key = '$normalizedProvider:$localRideId'; final key = '$normalizedProvider:$localRideId';
final existing = _items[key]; final existing = _items[key];
if (existing != null) { if (existing != null) {
if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) { if (existing.status == SyncStatus.synced || existing.status == SyncStatus.queued || existing.status == SyncStatus.uploading) return existing;
return existing; final rearmed = existing.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true);
} _items[key] = rearmed; await _save(); return rearmed;
final rearmed = existing.copyWith(
status: SyncStatus.queued,
clearLastError: true,
clearNextRetry: true,
);
_items[key] = rearmed;
await _save();
return rearmed;
} }
if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) throw StateError('Sync outbox capacity reached; pending rides were not discarded.');
if (_items.length >= maxOutboxCapacity && !_items.values.any((i) => i.status.isTerminal)) { final item = SyncItem(localRideId: localRideId, providerId: normalizedProvider, status: SyncStatus.queued, createdAt: now ?? DateTime.now());
throw StateError('Sync outbox capacity reached; pending rides were not discarded.'); _items[key] = item; await _save(); return item;
}
final item = SyncItem(
localRideId: localRideId,
providerId: normalizedProvider,
status: SyncStatus.queued,
createdAt: now ?? DateTime.now(),
);
_items[key] = item;
await _save();
return item;
}
Future<void> update(SyncItem item) async {
_items[item.key] = item;
await _save();
}
Future<void> remove(int localRideId, String providerId) async {
if (_items.remove('$providerId:$localRideId') != null) await _save();
}
Future<void> clear() async {
_items.clear();
_saveChain = Future<void>.value();
await _prefs.remove(_outboxStorageKey);
} }
Future<void> update(SyncItem item) async { _items[item.key] = item; await _save(); }
Future<void> remove(int localRideId, String providerId) async { if (_items.remove('${providerId.trim()}:$localRideId') != null) await _save(); }
Future<void> clear() async { _items.clear(); _saveChain = Future<void>.value(); await _prefs.remove(_outboxStorageKey); }
bool isRideSynced(int localRideId, String providerId) => getItem(localRideId, providerId)?.status == SyncStatus.synced; bool isRideSynced(int localRideId, String providerId) => getItem(localRideId, providerId)?.status == SyncStatus.synced;
} }