From 831c217e29fbb31c57c1d230318965078c7870c3 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 20:23:02 +0100 Subject: [PATCH] Add failure backoff for sync auto triggers --- docs/progress.md | 19 +++++++++++++- .../sync/sync_auto_trigger_controller.dart | 25 +++++++++++++++++-- .../sync_auto_trigger_controller_test.dart | 23 +++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 3b33ac2..7d73a71 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,23 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Sync Auto-Trigger Backoff + +Improved sync trigger resilience for repeated failures: + +- Updated `lib/features/sync/sync_auto_trigger_controller.dart`: + - tracks consecutive sync failures + - applies exponential cooldown backoff before auto-trigger retries + - resets backoff after a successful sync run +- Added coverage: + - `test/features/sync/sync_auto_trigger_controller_test.dart` + - validates cooldown behavior after failures + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Reminder Delivery Interface Wiring Implemented the notification scheduling boundary and connected it to local data @@ -68,7 +85,7 @@ Validation for this milestone: - current UX shows and dismisses rejections; still missing guided resolve/retry/inspect flows per entity. 3. Sync trigger maturity: - - add connectivity-aware triggers and backoff policy for repeated failures. + - connectivity-aware triggers are still missing. 4. Test depth: - add integration tests for auth + sync + CRUD end-to-end app flows. diff --git a/lib/features/sync/sync_auto_trigger_controller.dart b/lib/features/sync/sync_auto_trigger_controller.dart index dacc98f..bc620ad 100644 --- a/lib/features/sync/sync_auto_trigger_controller.dart +++ b/lib/features/sync/sync_auto_trigger_controller.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:relationship_saver/features/sync/sync_coordinator.dart'; @@ -18,6 +20,7 @@ class SyncAutoTriggerController { bool _running = false; DateTime? _lastTriggeredAt; + int _consecutiveFailures = 0; @visibleForTesting bool get isRunning => _running; @@ -25,6 +28,9 @@ class SyncAutoTriggerController { @visibleForTesting DateTime? get lastTriggeredAt => _lastTriggeredAt; + @visibleForTesting + int get consecutiveFailures => _consecutiveFailures; + /// Triggers `syncNow` when not already running and outside cooldown window. Future trigger({bool ignoreCooldown = false}) async { if (_running) { @@ -35,19 +41,34 @@ class SyncAutoTriggerController { final DateTime? last = _lastTriggeredAt; if (!ignoreCooldown && last != null && - now.difference(last) < minimumInterval) { + now.difference(last) < _cooldownForCurrentState()) { return false; } _running = true; _lastTriggeredAt = now; try { - await _syncNow(); + final SyncRunResult result = await _syncNow(); + if (result.success) { + _consecutiveFailures = 0; + } else { + _consecutiveFailures += 1; + } return true; } catch (_) { + _consecutiveFailures += 1; return true; } finally { _running = false; } } + + Duration _cooldownForCurrentState() { + if (_consecutiveFailures <= 0) { + return minimumInterval; + } + final int cappedFailures = math.min(_consecutiveFailures, 4); + final int multiplier = 1 << cappedFailures; + return minimumInterval * multiplier; + } } diff --git a/test/features/sync/sync_auto_trigger_controller_test.dart b/test/features/sync/sync_auto_trigger_controller_test.dart index e6afe60..d0ac079 100644 --- a/test/features/sync/sync_auto_trigger_controller_test.dart +++ b/test/features/sync/sync_auto_trigger_controller_test.dart @@ -30,6 +30,29 @@ void main() { expect(controller.lastTriggeredAt, now); }); + test('applies backoff after failed sync attempts', () async { + DateTime now = DateTime(2026, 2, 15, 14); + bool shouldFail = true; + + final SyncAutoTriggerController controller = SyncAutoTriggerController( + syncNow: () async => _result(success: !shouldFail), + now: () => now, + minimumInterval: const Duration(seconds: 60), + ); + + final bool first = await controller.trigger(); + now = now.add(const Duration(seconds: 61)); + final bool second = await controller.trigger(); + now = now.add(const Duration(seconds: 60)); + shouldFail = false; + final bool third = await controller.trigger(); + + expect(first, isTrue); + expect(second, isFalse); + expect(third, isTrue); + expect(controller.consecutiveFailures, 0); + }); + test('does not run a second sync while a run is still in progress', () async { int callCount = 0; final Completer completer = Completer();