Files
sharedinbox/lib/core/sync/reliability_runner.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 4a25d831fb fix(sync-health): checkNow() now runs regardless of start() (#95)
checkNow() previously delegated to _runAll(), which gated each
account on the _running flag (only true after start() is called).
This meant the manual "Verify sync health" action silently did nothing
if start() had not yet been called, or in any context where the
periodic runner was not active (e.g. widget tests).

Fix: checkNow() now iterates accounts directly and calls
_runForAccount() with force:true, bypassing the _running guard.
The guard is still respected during periodic runs for graceful
shutdown.

Adds three unit tests that reproduce the bug and verify the fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:54:39 +02:00

117 lines
3.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/data/db/database.dart';
/// Periodically verifies local state against the server's "ground truth".
/// Results are stored in the [SyncHealth] table.
class ReliabilityRunner {
ReliabilityRunner(this._db, this._accounts, this._mailboxes, this._emails);
final AppDatabase _db;
final AccountRepository _accounts;
final MailboxRepository _mailboxes;
final EmailRepository _emails;
Timer? _timer;
bool _running = false;
static const _checkInterval = Duration(hours: 1);
void start() {
_running = true;
_timer = Timer.periodic(_checkInterval, (_) {
unawaited(_runAll());
});
// Also run once on startup (after a short delay to not compete with initial sync).
Future.delayed(const Duration(minutes: 5), () {
if (_running) {
unawaited(_runAll());
}
});
}
void stop() {
_running = false;
_timer?.cancel();
_timer = null;
}
Future<void> _runAll() async {
final accounts = await _accounts.observeAccounts().first;
for (final account in accounts) {
if (!_running) break;
await _runForAccount(account.id);
}
}
Future<void> _runForAccount(String accountId, {bool force = false}) async {
try {
final mailboxes = await _mailboxes.observeMailboxes(accountId).first;
var totalMissingLocally = 0;
var totalMissingOnServer = 0;
var totalFlagMismatches = 0;
final details = <String, dynamic>{};
for (final mailbox in mailboxes) {
if (!force && !_running) break;
final result = await _emails.verifySyncReliability(
accountId,
mailbox.path,
);
if (!result.isHealthy) {
totalMissingLocally += result.missingLocally.length;
totalMissingOnServer += result.missingOnServer.length;
totalFlagMismatches += result.flagMismatches.length;
details[mailbox.path] = {
'missingLocally': result.missingLocally.length,
'missingOnServer': result.missingOnServer.length,
'flagMismatches': result.flagMismatches.length,
};
}
}
final isHealthy = totalMissingLocally == 0 &&
totalMissingOnServer == 0 &&
totalFlagMismatches == 0;
await _db.into(_db.syncHealth).insertOnConflictUpdate(
SyncHealthCompanion.insert(
accountId: accountId,
lastVerifiedAt: DateTime.now(),
isHealthy: isHealthy,
discrepancySummary: Value(isHealthy ? null : jsonEncode(details)),
),
);
if (!isHealthy) {
log(
'Sync reliability discrepancies found for $accountId: '
'missingLocally=$totalMissingLocally, '
'missingOnServer=$totalMissingOnServer, '
'flagMismatches=$totalFlagMismatches',
);
}
} catch (e) {
log('ReliabilityRunner failed for $accountId: $e');
}
}
/// Forces a reliability check for all accounts immediately.
///
/// Works regardless of whether [start] has been called, so the UI can
/// trigger a manual check at any time without depending on the periodic
/// runner being active.
Future<void> checkNow() async {
final accounts = await _accounts.observeAccounts().first;
for (final account in accounts) {
await _runForAccount(account.id, force: true);
}
}
}