Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/common_client/lib/launchdarkly_common_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export 'src/data_sources/fdv2/mode_resolution.dart'
ModeResolutionEntry,
resolveMode,
flutterDefaultResolutionTable;
export 'src/data_sources/fdv2/state_debounce_manager.dart'
show
DebouncedState,
OnDebounceReconcile,
DebounceTimerFactory,
StateDebounceManager;
export 'src/data_sources/data_source_status.dart'
show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'dart:async';

import '../../fdv2_connection_mode.dart';

/// Snapshot of the desired state accumulated within a debounce window.
///
/// Each field is one of the axes that participate in debouncing per the
/// FDv2 connection-mode resolution spec: network availability, application
/// lifecycle, and the user-requested mode (when set via the public
/// `setMode` API). `identify` calls intentionally do not participate.
final class DebouncedState {
final bool networkAvailable;
final bool inForeground;
final FDv2ConnectionMode? requestedMode;

const DebouncedState({
required this.networkAvailable,
required this.inForeground,
required this.requestedMode,
});

DebouncedState _copyWith({
bool? networkAvailable,
bool? inForeground,
Object? requestedMode = _sentinel,
}) {
return DebouncedState(
networkAvailable: networkAvailable ?? this.networkAvailable,
inForeground: inForeground ?? this.inForeground,
requestedMode: identical(requestedMode, _sentinel)
? this.requestedMode
: requestedMode as FDv2ConnectionMode?,
);
}

static const _sentinel = Object();
}

/// Callback fired when the debounce window closes with the final
/// accumulated [DebouncedState].
typedef OnDebounceReconcile = void Function(DebouncedState state);

/// Factory that produces a one-shot timer used to schedule the debounce
/// fire. Exists primarily so tests can substitute a controllable
/// implementation (e.g. via `fake_async`).
typedef DebounceTimerFactory = Timer Function(
Duration duration, void Function() callback);

Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb);

/// Debounces network availability, lifecycle, and user-requested mode
/// signals into a single reconciliation callback, per CSFDV2 CONNMODE
/// section 3.5.
///
/// Each `setX` call updates the relevant component of the pending state
/// and resets the debounce timer. When the timer fires, [onReconcile] is
/// invoked with the final [DebouncedState]. Per-setter early-return
/// suppresses unchanged values; the consumer is responsible for deciding
/// whether the resolved state requires action.
///
/// A [debounceWindow] of [Duration.zero] bypasses the timer entirely:
/// state changes fire [onReconcile] synchronously. Useful for tests and
/// FDv1-style immediate-application paths.
final class StateDebounceManager {
final Duration _debounceWindow;
final OnDebounceReconcile _onReconcile;
final DebounceTimerFactory _timerFactory;

DebouncedState _pending;
Timer? _timer;
bool _closed = false;

StateDebounceManager({
required DebouncedState initialState,
required Duration debounceWindow,
required OnDebounceReconcile onReconcile,
DebounceTimerFactory? timerFactory,
}) : _pending = initialState,
_debounceWindow = debounceWindow,
_onReconcile = onReconcile,
_timerFactory = timerFactory ?? _defaultTimerFactory;

void setNetworkAvailable(bool available) {
if (_pending.networkAvailable == available) {
return;
}
_pending = _pending._copyWith(networkAvailable: available);
_scheduleOrFire();
}

void setInForeground(bool inForeground) {
if (_pending.inForeground == inForeground) {
return;
}
_pending = _pending._copyWith(inForeground: inForeground);
_scheduleOrFire();
}

void setRequestedMode(FDv2ConnectionMode? mode) {
if (_pending.requestedMode == mode) {
return;
}
_pending = _pending._copyWith(requestedMode: mode);
_scheduleOrFire();
}

void close() {
_closed = true;
_timer?.cancel();
_timer = null;
}

void _scheduleOrFire() {
if (_closed) {
return;
}
if (_debounceWindow == Duration.zero) {
_onReconcile(_pending);
return;
}
_timer?.cancel();
_timer = _timerFactory(_debounceWindow, _onTimer);
}

void _onTimer() {
_timer = null;
if (_closed) {
return;
}
_onReconcile(_pending);
}
}
1 change: 1 addition & 0 deletions packages/common_client/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ dev_dependencies:
test: ^1.24.3
lints: ^3.0.0
mocktail: ^1.0.1
fake_async: ^1.3.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import 'package:fake_async/fake_async.dart';
import 'package:launchdarkly_common_client/src/data_sources/fdv2/state_debounce_manager.dart';
import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart';
import 'package:test/test.dart';

const _initial = DebouncedState(
networkAvailable: true,
inForeground: true,
requestedMode: null,
);

const _debounceWindow = Duration(seconds: 1);

void main() {
group('default window', () {
test('does not fire when state never changes', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(true);
manager.setInForeground(true);

async.elapse(const Duration(seconds: 5));
expect(calls, isEmpty);
manager.close();
});
});

test('fires once after the window closes following a single change', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
async.elapse(_debounceWindow);

expect(calls, hasLength(1));
expect(calls.single.networkAvailable, isFalse);
manager.close();
});
});

test('rapid changes reset the timer; one fire after final quiet', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
async.elapse(const Duration(milliseconds: 500));
manager.setNetworkAvailable(true);
async.elapse(const Duration(milliseconds: 500));
manager.setNetworkAvailable(false);
async.elapse(const Duration(milliseconds: 500));

expect(calls, isEmpty);

async.elapse(_debounceWindow);
expect(calls, hasLength(1));
expect(calls.single.networkAvailable, isFalse);
manager.close();
});
});

test('flap-and-return fires the resolved (matching-actual) state', () {
// Spec 3.5 example 1: starting from {online,...}, network flaps offline
// and back to online; resolved state matches the starting actual state.
// The debouncer still fires (its job is to deliver the resolved tuple);
// the consumer is responsible for the no-op-if-no-change check.
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
manager.setNetworkAvailable(true);
manager.setNetworkAvailable(false);
manager.setNetworkAvailable(true);

async.elapse(_debounceWindow);
expect(calls, hasLength(1));
expect(calls.single.networkAvailable, isTrue);
manager.close();
});
});

test('combined lifecycle + network change fires a single reconcile', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
async.elapse(const Duration(milliseconds: 100));
manager.setInForeground(false);

async.elapse(_debounceWindow);
expect(calls, hasLength(1));
expect(calls.single.networkAvailable, isFalse);
expect(calls.single.inForeground, isFalse);
manager.close();
});
});

test('requested mode change debounces along with the other axes', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setRequestedMode(const FDv2Polling());
async.elapse(_debounceWindow);

expect(calls, hasLength(1));
expect(calls.single.requestedMode, const FDv2Polling());
manager.close();
});
});

test('close cancels a pending timer', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
manager.close();

async.elapse(const Duration(seconds: 5));
expect(calls, isEmpty);
});
});

test('setters after close do not schedule a new fire', () {
fakeAsync((async) {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: _debounceWindow,
onReconcile: calls.add,
);

manager.close();
manager.setNetworkAvailable(false);
manager.setInForeground(false);
manager.setRequestedMode(const FDv2Offline());

async.elapse(const Duration(seconds: 5));
expect(calls, isEmpty);
});
});
});

group('zero window (immediate mode)', () {
test('changes fire synchronously without a timer', () {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: Duration.zero,
onReconcile: calls.add,
);

manager.setNetworkAvailable(false);
expect(calls, hasLength(1));
expect(calls.single.networkAvailable, isFalse);

manager.setInForeground(false);
expect(calls, hasLength(2));
expect(calls.last.inForeground, isFalse);

manager.close();
});

test('unchanged setters do not fire even in immediate mode', () {
final calls = <DebouncedState>[];
final manager = StateDebounceManager(
initialState: _initial,
debounceWindow: Duration.zero,
onReconcile: calls.add,
);

manager.setNetworkAvailable(true);
manager.setInForeground(true);
manager.setRequestedMode(null);

expect(calls, isEmpty);
manager.close();
});
});
}
Loading
Loading