Harden SyncManager auth recovery and startup behavior
This commit is contained in:
parent
48fa29aca2
commit
6d2d3494e3
|
|
@ -4,8 +4,7 @@ import 'sync_models.dart';
|
||||||
import 'sync_outbox.dart';
|
import 'sync_outbox.dart';
|
||||||
import 'sync_provider.dart';
|
import 'sync_provider.dart';
|
||||||
|
|
||||||
/// Central, provider-agnostic synchronization manager.
|
/// Central provider-agnostic synchronization coordinator.
|
||||||
/// Coordinates queueing, deduplication, retry exponential backoff, and provider dispatch.
|
|
||||||
class SyncManager extends ChangeNotifier {
|
class SyncManager extends ChangeNotifier {
|
||||||
final SyncOutbox _outbox;
|
final SyncOutbox _outbox;
|
||||||
final Map<String, SyncProvider> _providers = {};
|
final Map<String, SyncProvider> _providers = {};
|
||||||
|
|
@ -13,185 +12,97 @@ class SyncManager extends ChangeNotifier {
|
||||||
bool _isProcessing = false;
|
bool _isProcessing = false;
|
||||||
DateTime? Function() _nowProvider;
|
DateTime? Function() _nowProvider;
|
||||||
|
|
||||||
SyncManager(
|
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||||
this._outbox, {
|
if (initialProviders != null) for (final p in initialProviders) registerProvider(p);
|
||||||
List<SyncProvider>? initialProviders,
|
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
||||||
String? defaultProviderId,
|
|
||||||
DateTime? Function()? nowProvider,
|
|
||||||
}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
|
||||||
if (initialProviders != null) {
|
|
||||||
for (final p in initialProviders) {
|
|
||||||
registerProvider(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (defaultProviderId != null) {
|
|
||||||
_activeProviderId = defaultProviderId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setNowProvider(DateTime? Function() provider) {
|
|
||||||
_nowProvider = provider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
||||||
DateTime _now() => _nowProvider() ?? DateTime.now();
|
DateTime _now() => _nowProvider() ?? DateTime.now();
|
||||||
|
|
||||||
String get activeProviderId => _activeProviderId;
|
String get activeProviderId => _activeProviderId;
|
||||||
set activeProviderId(String id) {
|
set activeProviderId(String id) { if (_activeProviderId != id) { _activeProviderId = id; notifyListeners(); } }
|
||||||
if (_activeProviderId != id) {
|
|
||||||
_activeProviderId = id;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get isProcessing => _isProcessing;
|
bool get isProcessing => _isProcessing;
|
||||||
int get outboxCount => _outbox.count;
|
int get outboxCount => _outbox.count;
|
||||||
List<SyncItem> get allItems => _outbox.getAllItems();
|
List<SyncItem> get allItems => _outbox.getAllItems();
|
||||||
List<SyncItem> get pendingItems => _outbox.getPendingItems();
|
List<SyncItem> get pendingItems => _outbox.getPendingItems();
|
||||||
|
|
||||||
SyncManagerState get currentState {
|
SyncManagerState get currentState {
|
||||||
final items = _outbox.getAllItems();
|
int pending = 0, synced = 0, failed = 0;
|
||||||
int pending = 0;
|
|
||||||
int synced = 0;
|
|
||||||
int failed = 0;
|
|
||||||
DateTime? lastSuccess;
|
DateTime? lastSuccess;
|
||||||
String? lastErr;
|
String? lastErr;
|
||||||
|
for (final item in _outbox.getAllItems()) {
|
||||||
for (final item in items) {
|
|
||||||
if (item.status == SyncStatus.synced) {
|
if (item.status == SyncStatus.synced) {
|
||||||
synced++;
|
synced++;
|
||||||
if (item.uploadedAt != null) {
|
if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt;
|
||||||
if (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess)) {
|
|
||||||
lastSuccess = item.uploadedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) {
|
} else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) {
|
||||||
failed++;
|
failed++;
|
||||||
if (item.lastError != null) lastErr = item.lastError;
|
if (item.lastError != null) lastErr = item.lastError;
|
||||||
} else {
|
} else { pending++; }
|
||||||
pending++;
|
|
||||||
}
|
}
|
||||||
|
return SyncManagerState(isProcessing: _isProcessing, pendingCount: pending, syncedCount: synced, failedCount: failed, lastSuccessfulSync: lastSuccess, lastError: lastErr, activeProviderId: _activeProviderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SyncManagerState(
|
|
||||||
isProcessing: _isProcessing,
|
|
||||||
pendingCount: pending,
|
|
||||||
syncedCount: synced,
|
|
||||||
failedCount: failed,
|
|
||||||
lastSuccessfulSync: lastSuccess,
|
|
||||||
lastError: lastErr,
|
|
||||||
activeProviderId: _activeProviderId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registers a synchronization provider implementation
|
|
||||||
void registerProvider(SyncProvider provider) {
|
void registerProvider(SyncProvider provider) {
|
||||||
_providers[provider.providerId] = provider;
|
_providers[provider.providerId] = provider;
|
||||||
if (_activeProviderId == 'none' || _activeProviderId == 'fake') {
|
if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId;
|
||||||
_activeProviderId = provider.providerId;
|
|
||||||
}
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves a registered provider by ID
|
|
||||||
SyncProvider? getProvider(String providerId) => _providers[providerId];
|
SyncProvider? getProvider(String providerId) => _providers[providerId];
|
||||||
|
|
||||||
/// Enqueues a local completed ride for synchronization
|
Future<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async {
|
||||||
Future<SyncItem> enqueueRide(
|
|
||||||
int localRideId, {
|
|
||||||
String? targetProviderId,
|
|
||||||
bool autoProcess = true,
|
|
||||||
}) async {
|
|
||||||
final providerId = targetProviderId ?? _activeProviderId;
|
final providerId = targetProviderId ?? _activeProviderId;
|
||||||
final item = await _outbox.enqueue(localRideId, providerId, now: _now());
|
final item = await _outbox.enqueue(localRideId, providerId, now: _now());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
if (autoProcess) unawaited(processQueue());
|
||||||
if (autoProcess) {
|
|
||||||
// Fire-and-forget processing on microtask queue
|
|
||||||
unawaited(processQueue());
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculates exponential backoff duration based on attempt number:
|
/// Rearms only authentication-blocked work. Call this after a successful login/link.
|
||||||
/// attempt 1 -> 5s
|
Future<void> retryAuthRequired() async {
|
||||||
/// attempt 2 -> 30s
|
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) {
|
||||||
/// attempt 3 -> 2m (120s)
|
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true));
|
||||||
/// attempt 4 -> 5m (300s)
|
}
|
||||||
/// attempt 5 -> 15m (900s)
|
await processQueue();
|
||||||
/// attempt 6+ -> 30m (1800s) maximum
|
}
|
||||||
|
|
||||||
static Duration calculateRetryDelay(int attemptCount) {
|
static Duration calculateRetryDelay(int attemptCount) {
|
||||||
switch (attemptCount) {
|
switch (attemptCount) {
|
||||||
case 1:
|
case 1: return const Duration(seconds: 5);
|
||||||
return const Duration(seconds: 5);
|
case 2: return const Duration(seconds: 30);
|
||||||
case 2:
|
case 3: return const Duration(minutes: 2);
|
||||||
return const Duration(seconds: 30);
|
case 4: return const Duration(minutes: 5);
|
||||||
case 3:
|
case 5: return const Duration(minutes: 15);
|
||||||
return const Duration(minutes: 2);
|
default: return const Duration(minutes: 30);
|
||||||
case 4:
|
|
||||||
return const Duration(minutes: 5);
|
|
||||||
case 5:
|
|
||||||
return const Duration(minutes: 15);
|
|
||||||
default:
|
|
||||||
return const Duration(minutes: 30);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes eligible outbox items sequentially without blocking the caller or UI thread
|
|
||||||
Future<void> processQueue() async {
|
Future<void> processQueue() async {
|
||||||
if (_isProcessing) return;
|
if (_isProcessing) return;
|
||||||
|
|
||||||
_isProcessing = true;
|
_isProcessing = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final now = _now();
|
final now = _now();
|
||||||
|
// Snapshot eligible items once; each item is durably marked before network I/O.
|
||||||
final eligibleItems = _outbox.getEligibleItems(now);
|
final eligibleItems = _outbox.getEligibleItems(now);
|
||||||
|
|
||||||
for (final item in eligibleItems) {
|
for (final item in eligibleItems) {
|
||||||
final provider = _providers[item.providerId];
|
final provider = _providers[item.providerId];
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
final updated = item.copyWith(
|
await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now));
|
||||||
status: SyncStatus.failed,
|
continue;
|
||||||
lastError: 'Provider "${item.providerId}" not registered',
|
}
|
||||||
lastAttemptAt: now,
|
if (!await provider.isAvailable()) {
|
||||||
);
|
await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now));
|
||||||
await _outbox.update(updated);
|
continue;
|
||||||
|
}
|
||||||
|
if (await provider.requiresAuthentication()) {
|
||||||
|
await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check provider availability / network
|
final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now);
|
||||||
final isAvailable = await provider.isAvailable();
|
|
||||||
if (!isAvailable) {
|
|
||||||
final updated = item.copyWith(
|
|
||||||
status: SyncStatus.waitingForNetwork,
|
|
||||||
lastAttemptAt: now,
|
|
||||||
);
|
|
||||||
await _outbox.update(updated);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check authentication requirement
|
|
||||||
final requiresAuth = await provider.requiresAuthentication();
|
|
||||||
if (requiresAuth) {
|
|
||||||
final updated = item.copyWith(
|
|
||||||
status: SyncStatus.authRequired,
|
|
||||||
lastError: 'Authentication required for provider "${item.providerId}"',
|
|
||||||
lastAttemptAt: now,
|
|
||||||
);
|
|
||||||
await _outbox.update(updated);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark in-progress
|
|
||||||
final uploadingItem = item.copyWith(
|
|
||||||
status: SyncStatus.uploading,
|
|
||||||
lastAttemptAt: now,
|
|
||||||
);
|
|
||||||
await _outbox.update(uploadingItem);
|
await _outbox.update(uploadingItem);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Perform upload through provider
|
|
||||||
SyncResult result;
|
SyncResult result;
|
||||||
try {
|
try {
|
||||||
result = await provider.uploadRide(uploadingItem);
|
result = await provider.uploadRide(uploadingItem);
|
||||||
|
|
@ -200,41 +111,14 @@ class SyncManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
final syncedItem = uploadingItem.copyWith(
|
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true));
|
||||||
status: SyncStatus.synced,
|
|
||||||
remoteId: result.remoteId,
|
|
||||||
uploadedAt: result.timestamp,
|
|
||||||
clearLastError: true,
|
|
||||||
clearNextRetry: true,
|
|
||||||
);
|
|
||||||
await _outbox.update(syncedItem);
|
|
||||||
} else if (result.requiresAuth) {
|
} else if (result.requiresAuth) {
|
||||||
final authItem = uploadingItem.copyWith(
|
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage));
|
||||||
status: SyncStatus.authRequired,
|
|
||||||
lastError: result.errorMessage,
|
|
||||||
);
|
|
||||||
await _outbox.update(authItem);
|
|
||||||
} else if (result.isRetryable) {
|
} else if (result.isRetryable) {
|
||||||
final newAttempts = uploadingItem.attemptCount + 1;
|
final attempts = uploadingItem.attemptCount + 1;
|
||||||
final delay = calculateRetryDelay(newAttempts);
|
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage));
|
||||||
final nextRetry = now.add(delay);
|
|
||||||
|
|
||||||
final retryItem = uploadingItem.copyWith(
|
|
||||||
status: SyncStatus.retryWait,
|
|
||||||
attemptCount: newAttempts,
|
|
||||||
nextRetryAt: nextRetry,
|
|
||||||
lastError: result.errorMessage,
|
|
||||||
);
|
|
||||||
await _outbox.update(retryItem);
|
|
||||||
} else {
|
} else {
|
||||||
// Non-retryable permanent failure
|
await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true));
|
||||||
final failedItem = uploadingItem.copyWith(
|
|
||||||
status: SyncStatus.failed,
|
|
||||||
attemptCount: uploadingItem.attemptCount + 1,
|
|
||||||
lastError: result.errorMessage,
|
|
||||||
clearNextRetry: true,
|
|
||||||
);
|
|
||||||
await _outbox.update(failedItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -243,25 +127,17 @@ class SyncManager extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels an active or pending item in the outbox
|
|
||||||
Future<void> cancelSync(int localRideId, String providerId) async {
|
Future<void> cancelSync(int localRideId, String providerId) async {
|
||||||
final item = _outbox.getItem(localRideId, providerId);
|
final item = _outbox.getItem(localRideId, providerId);
|
||||||
if (item != null && item.status != SyncStatus.synced) {
|
if (item != null && !item.status.isTerminal) {
|
||||||
final cancelled = item.copyWith(status: SyncStatus.cancelled);
|
await _outbox.update(item.copyWith(status: SyncStatus.cancelled));
|
||||||
await _outbox.update(cancelled);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manually retries all failed, waiting, or auth-required items immediately
|
|
||||||
Future<void> retryAllPending() async {
|
Future<void> retryAllPending() async {
|
||||||
for (final item in _outbox.getPendingItems()) {
|
for (final item in _outbox.getPendingItems()) {
|
||||||
final rearmed = item.copyWith(
|
await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true));
|
||||||
status: SyncStatus.queued,
|
|
||||||
clearLastError: true,
|
|
||||||
clearNextRetry: true,
|
|
||||||
);
|
|
||||||
await _outbox.update(rearmed);
|
|
||||||
}
|
}
|
||||||
await processQueue();
|
await processQueue();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue