Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import 'dart:math';

/// The longest server-supplied fallback TTL that is honored, and the
/// (jittered) default used when the server gives no usable TTL.
const Duration maxFallbackTtl = Duration(hours: 1);

/// The FDv1 fallback directive parsed from a connection's response
/// headers. Its presence means the server asked the SDK to fall back;
/// [ttl] is how long to remain on the fallback before retrying FDv2
/// (null when the server gave no TTL; [Duration.zero] means indefinitely).
/// [ttl] is the server-supplied time to remain on the fallback before
/// retrying FDv2, unvalidated (null when absent or not an integer). See
/// [resolveFallbackTtl].
final class FallbackDirective {
final Duration? ttl;
const FallbackDirective(this.ttl);
Expand All @@ -18,3 +25,18 @@ FallbackDirective? readFallbackDirective(Map<String, String> headers) {
final seconds = raw == null ? null : int.tryParse(raw);
return FallbackDirective(seconds == null ? null : Duration(seconds: seconds));
}

/// Returns how long to remain on the fallback before retrying FDv2. A
/// server-supplied [ttl] in (0, [maxFallbackTtl]] is used as-is. Anything
/// else (absent, zero, negative, or too long) uses [maxFallbackTtl] minus
/// a uniformly random jitter of up to half of it, so SDKs that fell back
/// together do not all retry at once. Fallback is never indefinite.
Duration resolveFallbackTtl(Duration? ttl, [Random? random]) {
if (ttl case final valid?
when valid > Duration.zero && valid <= maxFallbackTtl) {
return valid;
}
final maxJitterSeconds = maxFallbackTtl.inSeconds ~/ 2;
final jitterSeconds = (random ?? Random()).nextInt(maxJitterSeconds + 1);
return maxFallbackTtl - Duration(seconds: jitterSeconds);
}
129 changes: 66 additions & 63 deletions packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import '../data_source_status.dart';
import '../data_source_status_manager.dart';
import 'conditions.dart';
import 'entry_factories.dart';
import 'fallback_directive.dart';
import 'payload.dart';
import 'selector.dart';
import 'source.dart';
Expand Down Expand Up @@ -39,13 +40,11 @@ enum _DirectiveAction {
/// No directive present; carry on normally.
none,

/// An FDv1 fallback tier exists and was engaged; advance to it.
engageFdv1Fallback,

/// The directive was received but no FDv1 fallback tier is configured.
/// The SDK stays interrupted and retries FDv2 after the directive's TTL
/// rather than removing the source.
deferRetry,
/// The server directed FDv1 fallback: the FDv2 synchronizers are
/// disabled (and the FDv1 fallback tier, if configured, enabled) until
/// the directive's TTL elapses; advance to the next available
/// synchronizer.
fallBack,
}

/// The FDv2 data source orchestrator.
Expand All @@ -68,6 +67,7 @@ final class FDv2DataSourceOrchestrator implements DataSource {
final Duration _fallbackTimeout;
final Duration _recoveryTimeout;
final Duration _recycleDelay;
final Duration Function(Duration? ttl) _fallbackTtlResolver;
final LDLogger _logger;

final StreamController<DataSourceEvent> _controller =
Expand All @@ -78,13 +78,13 @@ final class FDv2DataSourceOrchestrator implements DataSource {
bool _emittedPayload = false;
bool _initialized = false;

/// Default wait before retrying FDv2 after a fallback directive arrives
/// with no FDv1 fallback tier configured and no server-provided TTL.
static const Duration _defaultFallbackRetryInterval = Duration(hours: 1);
/// Restores the FDv2 synchronizers once a fallback directive's TTL
/// elapses.
Timer? _fdv2RecoveryTimer;

/// Overrides the next recycle delay when set (consumed once), used to
/// honor a fallback directive's retry TTL.
Duration? _pendingRetryDelay;
/// Completes when [_fdv2RecoveryTimer] fires, so a synchronizer loop
/// with nothing available can wait for FDv2 to be restored.
Completer<void>? _fdv2Recovered;

/// Completes when [stop] is called so a long interruptible wait (e.g. a
/// fallback retry delay) wakes promptly on shutdown.
Expand All @@ -110,13 +110,15 @@ final class FDv2DataSourceOrchestrator implements DataSource {
Duration fallbackTimeout = defaultFallbackTimeout,
Duration recoveryTimeout = defaultRecoveryTimeout,
Duration recycleDelay = const Duration(seconds: 1),
Duration Function(Duration? ttl) fallbackTtlResolver = resolveFallbackTtl,
}) : _initializerFactories = initializerFactories,
_synchronizerSlots = synchronizerSlots,
_selectorUpdater = selectorUpdater,
_statusManager = statusManager,
_fallbackTimeout = fallbackTimeout,
_recoveryTimeout = recoveryTimeout,
_recycleDelay = recycleDelay,
_fallbackTtlResolver = fallbackTtlResolver,
_logger = logger.subLogger('FDv2Orchestrator'),
_cacheOnlyDataSystem = initializerFactories.isNotEmpty &&
initializerFactories.every((f) => f.isCache) &&
Expand Down Expand Up @@ -144,6 +146,7 @@ final class FDv2DataSourceOrchestrator implements DataSource {
if (_closed) return;
_closed = true;
if (!_stopCompleter.isCompleted) _stopCompleter.complete();
_fdv2RecoveryTimer?.cancel();
_sourceManager.close();
// Wake the active synchronizer run so it can observe the closed
// state.
Expand Down Expand Up @@ -217,45 +220,41 @@ final class FDv2DataSourceOrchestrator implements DataSource {
_controller.add(InitializedEvent());
}

/// Classifies a server-directed FDv1 fallback and, for
/// [_DirectiveAction.engageFdv1Fallback], makes the source manager switch
/// tiers. The directive is ignored ([_DirectiveAction.none]) when the
/// FDv1 fallback synchronizer is already the active source, so it cannot
/// loop (the fallback source also never re-asserts the directive). When
/// there is no FDv1 fallback tier we defer retrying FDv2
/// ([_DirectiveAction.deferRetry]).
/// Handles a server-directed FDv1 fallback: disables the FDv2
/// synchronizers (enabling the FDv1 fallback tier, if configured) and
/// schedules their recovery after the directive's TTL. The directive is
/// ignored ([_DirectiveAction.none]) when the FDv1 fallback synchronizer
/// is already the active source, so it cannot loop (the fallback source
/// also never re-asserts the directive).
_DirectiveAction _processDirective(FDv2SourceResult result) {
if (!result.fdv1Fallback ||
_sourceManager.isCurrentSynchronizerFdv1Fallback) {
return _DirectiveAction.none;
}
final ttl = _fallbackTtlResolver(result.fdv1FallbackTtl);
if (_sourceManager.hasFdv1FallbackConfigured) {
_logger.warn('Server directed fallback to FDv1; engaging the FDv1 '
'fallback synchronizer.');
_sourceManager.engageFdv1Fallback();
return _DirectiveAction.engageFdv1Fallback;
'fallback synchronizer and retrying FDv2 in ${ttl.inSeconds}s.');
} else {
_logger.warn('Server directed FDv1 fallback but no fallback tier is '
'configured; pausing FDv2 and retrying it in ${ttl.inSeconds}s.');
}
return _DirectiveAction.deferRetry;
_sourceManager.engageFdv1Fallback();
_scheduleFdv2Recovery(ttl);
return _DirectiveAction.fallBack;
}

/// Honors a fallback directive that has no FDv1 tier to engage: the SDK
/// stays interrupted and schedules an FDv2 retry after the directive's
/// TTL. A TTL of zero means remain indefinitely (no retry); an absent
/// TTL uses [_defaultFallbackRetryInterval].
void _scheduleFallbackRetry(
Duration? ttl, void Function(_SynchronizerOutcome) resolve) {
if (ttl == Duration.zero) {
_logger.warn('Server directed FDv1 fallback with no fallback tier '
'configured and an indefinite TTL; FDv2 updates are paused.');
resolve(_SynchronizerOutcome.stop);
return;
}
final delay = ttl ?? _defaultFallbackRetryInterval;
_logger.warn('Server directed FDv1 fallback but no fallback tier is '
'configured; staying interrupted and retrying FDv2 in '
'${delay.inSeconds}s.');
_pendingRetryDelay = delay;
resolve(_SynchronizerOutcome.recycle);
void _scheduleFdv2Recovery(Duration ttl) {
_fdv2RecoveryTimer?.cancel();
final recovered = Completer<void>();
_fdv2Recovered = recovered;
_fdv2RecoveryTimer = Timer(ttl, () {
if (_closed) return;
_logger.info('Fallback TTL elapsed; retrying FDv2.');
_sourceManager.restoreFdv2Synchronizers();
recovered.complete();
_resolveCurrentOutcome?.call(_SynchronizerOutcome.recover);
});
}

/// Waits [d], returning early if the orchestrator is stopped so a long
Expand Down Expand Up @@ -283,16 +282,15 @@ final class FDv2DataSourceOrchestrator implements DataSource {
dataReceived = true;

switch (_processDirective(result)) {
case _DirectiveAction.engageFdv1Fallback:
case _DirectiveAction.fallBack:
// Data received but the server directed FDv1 fallback;
// move on to synchronizers where the fallback tier runs
// and its first change set completes initialization.
// move on to synchronizers. The FDv1 tier's first change
// set completes initialization; without one, this data
// does.
if (!_sourceManager.hasFdv1FallbackConfigured) {
_emitInitialized();
}
return;
case _DirectiveAction.deferRetry:
// An initializer is one-shot and cannot retry itself;
// let the chain exhaust and leave retry timing to the
// synchronizer tier.
break;
case _DirectiveAction.none:
if (result.changeSet.selector.isNotEmpty) {
// A selector means a complete, server-versioned payload:
Expand All @@ -317,8 +315,10 @@ final class FDv2DataSourceOrchestrator implements DataSource {
case SourceState.goodbye:
break;
}
if (_processDirective(result) ==
_DirectiveAction.engageFdv1Fallback) {
if (_processDirective(result) == _DirectiveAction.fallBack) {
if (dataReceived && !_sourceManager.hasFdv1FallbackConfigured) {
_emitInitialized();
}
return;
}
}
Expand Down Expand Up @@ -363,18 +363,25 @@ final class FDv2DataSourceOrchestrator implements DataSource {
while (!_closed) {
if (recycleCurrent) {
recycleCurrent = false;
final delay = _pendingRetryDelay ?? _recycleDelay;
_pendingRetryDelay = null;
if (delay > Duration.zero) {
await _delay(delay);
if (_recycleDelay > Duration.zero) {
await _delay(_recycleDelay);
if (_closed) return;
}
synchronizer = _sourceManager.recreateCurrentSynchronizer();
// The current slot may have been blocked during the delay (e.g.
// FDv2 was restored from the FDv1 fallback); move on instead.
synchronizer = _sourceManager.recreateCurrentSynchronizer() ??
_sourceManager.nextAvailableSynchronizer();
} else {
synchronizer = _sourceManager.nextAvailableSynchronizer();
}

if (synchronizer == null) {
// Fallback is never indefinite: with no FDv1 tier (or an unusable
// one), wait for FDv2 to be restored.
if (_fdv2Recovered case final recovered? when !recovered.isCompleted) {
await Future.any([recovered.future, _stopCompleter.future]);
continue;
}
if (!_emittedPayload) {
_halt('All FDv2 data sources exhausted without receiving data');
} else if (_synchronizerSlots.isNotEmpty) {
Expand Down Expand Up @@ -461,10 +468,8 @@ final class FDv2DataSourceOrchestrator implements DataSource {
'${result.message ?? 'unknown error'}');
_reportTransientError(result);
switch (_processDirective(result)) {
case _DirectiveAction.engageFdv1Fallback:
case _DirectiveAction.fallBack:
resolve(_SynchronizerOutcome.advance);
case _DirectiveAction.deferRetry:
_scheduleFallbackRetry(result.fdv1FallbackTtl, resolve);
case _DirectiveAction.none:
_sourceManager.blockCurrentSynchronizer();
resolve(_SynchronizerOutcome.advance);
Expand Down Expand Up @@ -492,13 +497,11 @@ final class FDv2DataSourceOrchestrator implements DataSource {
}

switch (_processDirective(result)) {
case _DirectiveAction.engageFdv1Fallback:
case _DirectiveAction.fallBack:
// A change set carrying the directive (e.g. a successful stream
// that delivered its basis then asked us to fall back) has been
// applied above; now advance to the fallback tier.
resolve(_SynchronizerOutcome.advance);
case _DirectiveAction.deferRetry:
_scheduleFallbackRetry(result.fdv1FallbackTtl, resolve);
case _DirectiveAction.none:
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ final class ActionGoodbye extends ProtocolAction {
final String reason;

/// When present, the goodbye carries an in-band protocol-fallback
/// directive (FDv2 to FDv1) with this time-to-live; [Duration.zero]
/// means remain on the fallback indefinitely. `null` means an ordinary
/// disconnect with no fallback directive.
/// directive (FDv2 to FDv1) with this unvalidated time-to-live (see
/// `resolveFallbackTtl`). `null` means an ordinary disconnect with no
/// fallback directive.
final Duration? protocolFallbackTtl;

const ActionGoodbye(this.reason, {this.protocolFallbackTtl});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,8 @@ final class GoodbyeEvent {
/// When present, the server is directing a protocol fallback (FDv2 to
/// FDv1) in-band -- the same signal as the `x-ld-fd-fallback` response
/// header, for transports that cannot read response headers. The value
/// is how long to remain on the fallback before retrying; [Duration.zero]
/// means indefinitely. `null` means no fallback was directed.
/// is how long to remain on the fallback before retrying, unvalidated
/// (see `resolveFallbackTtl`). `null` means no fallback was directed.
final Duration? protocolFallbackTtl;

const GoodbyeEvent({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,12 @@ final class SourceManager {
_synchronizerIndex = -1;
}

/// Block all non-FDv1 synchronizers, unblock the FDv1 fallback, and
/// reset the scan cursor so the next activation selects the fallback.
/// Does nothing when no FDv1 fallback slot is configured, since
/// blocking every slot without unblocking one would leave nothing to
/// activate. The active source keeps running until the next
/// activation closes it.
/// Block all non-FDv1 synchronizers, unblock the FDv1 fallback (if
/// configured), and reset the scan cursor so the next activation
/// selects the fallback. With no FDv1 fallback slot nothing remains to
/// activate until [restoreFdv2Synchronizers]. The active source keeps
/// running until the next activation closes it.
void engageFdv1Fallback() {
if (!hasFdv1FallbackConfigured) {
return;
}
for (final slot in _synchronizerSlots) {
slot.state = slot.isFdv1Fallback
? SynchronizerSlotState.available
Expand All @@ -186,6 +182,20 @@ final class SourceManager {
_synchronizerIndex = -1;
}

/// Undo [engageFdv1Fallback] once the fallback TTL elapses: return
/// every slot to its initial state (FDv2 synchronizers available, the
/// FDv1 fallback blocked) and reset the scan cursor so the next
/// activation selects the primary. The active source keeps running
/// until the next activation closes it.
void restoreFdv2Synchronizers() {
for (final slot in _synchronizerSlots) {
slot.state = slot.isFdv1Fallback
? SynchronizerSlotState.blocked
: SynchronizerSlotState.available;
}
_synchronizerIndex = -1;
}

/// True if the active synchronizer occupies the first available slot
/// (the primary). Reads the scan cursor, so it is only meaningful
/// while the cursor identifies the active slot (see the class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ sealed class FDv2SourceResult {
final bool fdv1Fallback;

/// How long to remain on the fallback before retrying FDv2, when
/// [fdv1Fallback] is set. `null` means the server gave no time-to-live
/// (the caller applies its default); [Duration.zero] means remain
/// indefinitely with no automatic retry.
/// [fdv1Fallback] is set, as supplied by the server and unvalidated.
/// `null` means the server gave no usable time-to-live. The orchestrator
/// validates it and applies the default (see `resolveFallbackTtl`).
final Duration? fdv1FallbackTtl;

const FDv2SourceResult({this.fdv1Fallback = false, this.fdv1FallbackTtl});
Expand Down
Loading
Loading