Skip to content

Commit 4bb667d

Browse files
authored
fix: Honor x-ld-fd-fallback header in FDv2 initializer phase and on successful responses (#251)
1 parent 6fc7a91 commit 4bb667d

20 files changed

Lines changed: 1404 additions & 161 deletions

.github/actions/contract-tests/action.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ inputs:
1212
required: false
1313
default: ''
1414
run_fdv2_tests:
15-
description: 'Whether to run contract tests from the v3.0.0-alpha.3 tag'
15+
description: 'Whether to run contract tests from the v3.0.0-alpha.6 tag'
1616
required: false
1717
default: 'false'
1818

@@ -64,16 +64,25 @@ runs:
6464
shell: bash
6565
run: dotnet ${{ inputs.service_dll_file }} > test-service.log 2>&1 & disown
6666

67-
- name: Clone and run contract tests from v3.0.0-alpha.3 tag
67+
- name: Clone and run contract tests from v3.0.0-alpha.6 tag
6868
if: inputs.run_fdv2_tests == 'true'
6969
shell: bash
7070
run: |
7171
mkdir -p /tmp/sdk-test-harness
7272
git clone https://github.com/launchdarkly/sdk-test-harness.git /tmp/sdk-test-harness
7373
cp $(dirname ./${{ inputs.service_project_file }})/test-supressions-fdv2.txt /tmp/sdk-test-harness/testharness-suppressions-fdv2.txt
7474
cd /tmp/sdk-test-harness
75-
git checkout v3.0.0-alpha.3
75+
git checkout v3.0.0-alpha.6
7676
go build -o test-harness .
7777
./test-harness -url http://localhost:8000 -debug -status-timeout=360 --skip-from=testharness-suppressions-fdv2.txt --stop-service-at-end
7878
env:
7979
GITHUB_TOKEN: ${{ inputs.token }}
80+
81+
- name: Upload test service log on failure
82+
if: failure() && inputs.run_fdv2_tests == 'true'
83+
uses: actions/upload-artifact@v4
84+
with:
85+
name: contract-test-service-log-${{ runner.os }}-${{ runner.arch }}
86+
path: test-service.log
87+
if-no-files-found: warn
88+
retention-days: 7

pkgs/sdk/server/contract-tests/Representations.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Text.Json.Serialization;
34
using LaunchDarkly.Sdk;
45

56
// Note, in order for System.Text.Json serialization/deserialization to work correctly, the members of
@@ -153,6 +154,12 @@ public class SdkConfigDataSystemParams
153154
public int? StoreMode { get; set; }
154155
public SdkConfigDataInitializerParams[] Initializers { get; set; }
155156
public SdkConfigDataSynchronizerParams[] Synchronizers { get; set; }
157+
// FDv1Fallback configures the SDK's FDv1 Fallback Synchronizer, which is engaged only when
158+
// the LaunchDarkly server returns the FDv1 fallback directive. It is distinct from the FDv2
159+
// Primary/Fallback Synchronizers above. The harness sends this as "fdv1Fallback" (lowercase
160+
// 'd'); we override the default CamelCase mapping (which would produce "fDv1Fallback").
161+
[JsonPropertyName("fdv1Fallback")]
162+
public SdkConfigPollingParams FDv1Fallback { get; set; }
156163
public string PayloadFilter { get; set; }
157164
}
158165

pkgs/sdk/server/contract-tests/SdkClientEntity.cs

Lines changed: 20 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -503,47 +503,29 @@ private static Configuration BuildSdkConfig(SdkConfigParams sdkParams, ILogAdapt
503503
if (synchronizers.Count > 0)
504504
{
505505
dataSystemBuilder.Synchronizers(synchronizers.ToArray());
506-
507-
// Find the best synchronizer to use for FDv1 fallback configuration
508-
// Prefer polling synchronizers since FDv1 fallback is polling-based
509-
SdkConfigDataSynchronizerParams synchronizerForFallback = null;
510-
511-
// First, try to find a polling synchronizer
512-
foreach (var syncParams in sdkParams.DataSystem.Synchronizers)
513-
{
514-
if (syncParams.Polling != null)
515-
{
516-
synchronizerForFallback = syncParams;
517-
break;
518-
}
519-
}
520-
521-
// If no polling synchronizer found, use the first synchronizer (could be streaming)
522-
if (synchronizerForFallback == null && sdkParams.DataSystem.Synchronizers.Length > 0)
523-
{
524-
synchronizerForFallback = sdkParams.DataSystem.Synchronizers[0];
525-
}
526-
527-
if (synchronizerForFallback != null)
528-
{
529-
// Only configure global polling endpoints if we have a polling synchronizer with a custom base URI
530-
// This ensures the FDv1 fallback synchronizer uses the same base URI without overwriting
531-
// existing polling endpoint configuration
532-
if (synchronizerForFallback.Polling != null &&
533-
synchronizerForFallback.Polling.BaseUri != null)
534-
{
535-
endpoints.Polling(synchronizerForFallback.Polling.BaseUri);
536-
}
537-
538-
var fdv1Fallback = CreateFDv1FallbackSynchronizer(synchronizerForFallback);
539-
if (fdv1Fallback != null)
540-
{
541-
dataSystemBuilder.FDv1FallbackSynchronizer(fdv1Fallback);
542-
}
543-
}
544506
}
545507
}
546508

509+
// Configure the FDv1 Fallback Synchronizer directly from dataSystem.fdv1Fallback,
510+
// separate from the FDv2 Primary/Fallback synchronizer chain. This is engaged only
511+
// in response to a server-directed FDv1 Fallback Directive.
512+
if (sdkParams.DataSystem.FDv1Fallback != null)
513+
{
514+
if (sdkParams.DataSystem.FDv1Fallback.BaseUri != null)
515+
{
516+
endpoints.Polling(sdkParams.DataSystem.FDv1Fallback.BaseUri);
517+
}
518+
519+
var fdv1FallbackBuilder = DataSystemComponents.FDv1Polling();
520+
if (sdkParams.DataSystem.FDv1Fallback.PollIntervalMs.HasValue)
521+
{
522+
fdv1FallbackBuilder.PollInterval(
523+
TimeSpan.FromMilliseconds(sdkParams.DataSystem.FDv1Fallback.PollIntervalMs.Value));
524+
}
525+
526+
dataSystemBuilder.FDv1FallbackSynchronizer(fdv1FallbackBuilder);
527+
}
528+
547529
builder.DataSystem(dataSystemBuilder);
548530
}
549531

@@ -595,33 +577,6 @@ private static IComponentConfigurer<IDataSource> CreateSynchronizer(
595577
return null;
596578
}
597579

598-
private static IComponentConfigurer<IDataSource> CreateFDv1FallbackSynchronizer(
599-
SdkConfigDataSynchronizerParams synchronizer)
600-
{
601-
// FDv1 fallback synchronizer is always polling-based
602-
var fdv1PollingBuilder = DataSystemComponents.FDv1Polling();
603-
604-
// Configure polling interval if the synchronizer has polling configuration
605-
if (synchronizer.Polling != null)
606-
{
607-
if (synchronizer.Polling.PollIntervalMs.HasValue)
608-
{
609-
fdv1PollingBuilder.PollInterval(TimeSpan.FromMilliseconds(synchronizer.Polling.PollIntervalMs.Value));
610-
}
611-
// Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI
612-
// will use the global service endpoints configuration
613-
}
614-
else if (synchronizer.Streaming != null)
615-
{
616-
// For streaming synchronizers, we still create a polling fallback
617-
// Use default polling interval since streaming doesn't have a poll interval
618-
// Note: FDv1 polling doesn't support ServiceEndpointsOverride, so base URI
619-
// will use the global service endpoints configuration
620-
}
621-
622-
return fdv1PollingBuilder;
623-
}
624-
625580
private MigrationVariationResponse DoMigrationVariation(MigrationVariationParams migrationVariation)
626581
{
627582
var defaultStage = MigrationStageExtensions.FromDataModelString(migrationVariation.DefaultStage);

pkgs/sdk/server/contract-tests/TestService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ public class Webapp
3838
"inline-context-all",
3939
"anonymous-redaction",
4040
"evaluation-hooks",
41-
"client-prereq-events"
41+
"client-prereq-events",
42+
"fdv1-fallback"
4243
};
4344

4445
public readonly Handler Handler;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace LaunchDarkly.Sdk.Server.Internal.DataSources
2+
{
3+
/// <summary>
4+
/// Categorizes an entry in a composite source's list. Appliers use this to express
5+
/// "block every FDv2 entry" via <see cref="ICompositeSourceActionable.BlockAll"/>
6+
/// without having to count positions or know which phase they were attached at.
7+
/// </summary>
8+
internal enum CompositeEntryKind
9+
{
10+
/// <summary>
11+
/// Default kind. Entries that participate in the FDv2 protocol -- initializers and
12+
/// FDv2 synchronizers in the outer composite, and any entry that is not the
13+
/// FDv1 fallback synchronizer.
14+
/// </summary>
15+
FDv2,
16+
17+
/// <summary>
18+
/// The FDv1 fallback synchronizer entry. Used by the FDv1 fallback applier to
19+
/// distinguish the fallback target from the FDv2 entries it should block.
20+
/// </summary>
21+
FDv1Fallback
22+
}
23+
}

pkgs/sdk/server/src/Internal/DataSources/CompositeDataSource/CompositeSource.cs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,31 @@ internal sealed class CompositeSource : IDataSource, ICompositeSourceActionable
3030

3131
private readonly IDataSourceUpdatesV2 _originalUpdateSink;
3232
private readonly IDataSourceUpdatesV2 _sanitizedUpdateSink;
33-
private readonly SourcesList<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)> _sourcesList;
33+
private readonly SourcesList<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory, CompositeEntryKind Kind)> _sourcesList;
3434
private readonly DisableableDataSourceUpdatesTracker _disableableTracker;
3535

3636
// Tracks the entry from the sources list that was used to create the current
3737
// data source instance. This allows operations such as blacklist to remove
3838
// the correct factory/action-applier-factory tuple from the list.
39-
private (SourceFactory Factory, ActionApplierFactory ActionApplierFactory) _currentEntry;
39+
private (SourceFactory Factory, ActionApplierFactory ActionApplierFactory, CompositeEntryKind Kind) _currentEntry;
4040
private IDataSource _currentDataSource;
4141

4242
/// <summary>
43-
/// Creates a new <see cref="CompositeSource"/>.
43+
/// Creates a new <see cref="CompositeSource"/>. Each entry carries an explicit
44+
/// <see cref="CompositeEntryKind"/> so appliers can express "block every FDv2 entry"
45+
/// via <see cref="ICompositeSourceActionable.BlockAll"/> without needing to know list
46+
/// positions. For inner sub-composites whose entries never participate in cross-kind
47+
/// blocking, callers should pass <see cref="CompositeEntryKind.FDv2"/> uniformly.
4448
/// </summary>
4549
/// <param name="compositeDescription">description of the composite source for logging purposes</param>
4650
/// <param name="updatesSink">the sink that receives updates from the active source</param>
47-
/// <param name="factoryTuples">the ordered list of source factories and their associated action applier factories</param>
51+
/// <param name="factoryTuples">the ordered list of source factories, action applier factories, and entry kinds</param>
4852
/// <param name="logger">the logger instance to use</param>
4953
/// <param name="circular">whether to loop off the end of the list back to the start when fallback occurs</param>
5054
public CompositeSource(
5155
string compositeDescription,
5256
IDataSourceUpdatesV2 updatesSink,
53-
IList<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory)> factoryTuples,
57+
IList<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory, CompositeEntryKind Kind)> factoryTuples,
5458
Logger logger,
5559
bool circular = true)
5660
{
@@ -72,7 +76,7 @@ public CompositeSource(
7276
// this tracker is used to disconnect the current source from the updates sink when it is no longer needed.
7377
_disableableTracker = new DisableableDataSourceUpdatesTracker();
7478

75-
_sourcesList = new SourcesList<(SourceFactory SourceFactory, ActionApplierFactory ActionApplierFactory)>(
79+
_sourcesList = new SourcesList<(SourceFactory Factory, ActionApplierFactory ActionApplierFactory, CompositeEntryKind Kind)>(
7680
circular: circular,
7781
initialList: factoryTuples
7882
);
@@ -451,6 +455,38 @@ public void BlockCurrent()
451455
});
452456
}
453457

458+
public void BlockAll(Predicate<CompositeEntryKind> kindMatches)
459+
{
460+
if (kindMatches is null) throw new ArgumentNullException(nameof(kindMatches));
461+
if (_disposed)
462+
{
463+
return;
464+
}
465+
466+
EnqueueAction(() =>
467+
{
468+
lock (_lock)
469+
{
470+
var removed = _sourcesList.RemoveAll(entry => kindMatches(entry.Kind));
471+
if (removed == 0)
472+
{
473+
return;
474+
}
475+
476+
// If the current entry was removed, clear the reference so a subsequent
477+
// BlockCurrent doesn't accidentally remove a remaining entry. The current
478+
// data source is left running -- callers are expected to follow BlockAll
479+
// with DisposeCurrent / GoToNext when they want it torn down.
480+
if (_currentEntry != default && kindMatches(_currentEntry.Kind))
481+
{
482+
_currentEntry = default;
483+
}
484+
485+
_log.Debug("{0} blocked {1} entries by kind predicate.", _compositeDescription, removed);
486+
}
487+
});
488+
}
489+
454490
private void logTransition(String previousDescription, String currentDescription) {
455491
if (previousDescription != null && currentDescription != null) {
456492
_log.Debug("{0} transitioned from {1} to {2}.", _compositeDescription, previousDescription, currentDescription);

pkgs/sdk/server/src/Internal/DataSources/CompositeDataSource/ICompositeSourceActionable.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Threading.Tasks;
23

34
namespace LaunchDarkly.Sdk.Server.Internal.DataSources
@@ -34,11 +35,20 @@ internal interface ICompositeSourceActionable
3435
bool IsAtFirst();
3536

3637
/// <summary>
37-
/// Blocks the the current source's factory. This prevents the current source's factory from being used again.
38+
/// Blocks the the current source's factory. This prevents the current source's factory from being used again.
3839
/// Note that this does not tear down the current data source, it just prevents its factory from being used again.
3940
/// </summary>
4041
void BlockCurrent();
42+
43+
/// <summary>
44+
/// Removes every entry whose kind matches the predicate from the source list, regardless
45+
/// of its position. The current data source is not disposed -- callers that want the
46+
/// current source torn down should follow this with <see cref="DisposeCurrent"/> or
47+
/// <see cref="GoToNext"/>. If the current entry was removed, the internal "current entry"
48+
/// reference is cleared so subsequent block operations don't accidentally remove a
49+
/// remaining entry.
50+
/// </summary>
51+
/// <param name="kindMatches">predicate selecting which kinds to remove</param>
52+
void BlockAll(Predicate<CompositeEntryKind> kindMatches);
4153
}
4254
}
43-
44-

pkgs/sdk/server/src/Internal/DataSources/CompositeDataSource/SourcesList.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,38 @@ public int IndexOf(T element)
112112
return _list.IndexOf(element);
113113
}
114114

115+
/// <summary>
116+
/// Removes every element that matches the predicate, adjusting the head position so it
117+
/// continues to point at whichever element followed the previous head. Returns the number
118+
/// of elements removed.
119+
/// </summary>
120+
public int RemoveAll(Predicate<T> match)
121+
{
122+
if (match is null) return 0;
123+
// Walk back-to-front so a removal never shifts an unvisited index. Adjust _pos as we go
124+
// by mirroring SourcesList.Remove's "if removed index is before head, head moves left".
125+
var removed = 0;
126+
for (var i = _list.Count - 1; i >= 0; i--)
127+
{
128+
if (!match(_list[i])) continue;
129+
_list.RemoveAt(i);
130+
removed++;
131+
if (i < _pos)
132+
{
133+
_pos -= 1;
134+
}
135+
}
136+
if (_list.Count == 0)
137+
{
138+
_pos = 0;
139+
}
140+
else if (_circular && _pos > _list.Count - 1)
141+
{
142+
_pos = 0;
143+
}
144+
return removed;
145+
}
146+
115147
/// <summary>
116148
/// Reset the head position to the start of the list.
117149
/// </summary>

pkgs/sdk/server/src/Internal/DataSystem/FDv2DataSystem.cs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,20 @@ public static FDv2DataSystem Create(Logger logger, Configuration configuration,
7979

8080
var contextWithSelectorSource =
8181
clientContext.WithSelectorSource(new SelectorSourceFacade(writeThroughStore));
82+
// FDv1 fallback synchronizer is optional; only build a list entry when one is
83+
// configured. An always-present list entry that captured a null configurer would
84+
// throw NRE the moment the action applier advanced to the FDv1 fallback entry.
85+
var fdv1FallbackFactories = dataSystemConfiguration.FDv1FallbackSynchronizer == null
86+
? new List<SourceFactory>()
87+
: new List<SourceFactory>
88+
{
89+
FactoryWithContext(clientContext)(dataSystemConfiguration.FDv1FallbackSynchronizer)
90+
};
8291
var compositeDataSource = configuration.Offline ? Components.ExternalUpdatesOnly.Build(contextWithSelectorSource) : FDv2DataSource.CreateFDv2DataSource(
8392
dataSourceUpdates,
8493
dataSystemConfiguration.Initializers.Select(FactoryWithContext(contextWithSelectorSource)).ToList(),
8594
dataSystemConfiguration.Synchronizers.Select(FactoryWithContext(contextWithSelectorSource)).ToList(),
86-
new List<SourceFactory>
87-
{
88-
FactoryWithContext(clientContext)(dataSystemConfiguration.FDv1FallbackSynchronizer)
89-
},
95+
fdv1FallbackFactories,
9096
logger
9197
);
9298

pkgs/sdk/server/src/Internal/FDv2DataSources/FDv2ChangeSetTranslator.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ internal static class FDv2ChangeSetTranslator
1818
/// <param name="changeset">The FDv2 changeset to convert.</param>
1919
/// <param name="log">Logger for diagnostic messages.</param>
2020
/// <param name="environmentId">The environment ID to include in the changeset.</param>
21+
/// <param name="fdv1Fallback">Whether to mark the changeset as carrying the FDv1 fallback directive.</param>
2122
/// <returns>A DataStoreTypes.ChangeSet containing the converted data.</returns>
2223
/// <exception cref="ArgumentOutOfRangeException">Thrown when the changeset type is unknown.</exception>
2324
public static DataStoreTypes.ChangeSet<DataStoreTypes.ItemDescriptor> ToChangeSet(
2425
FDv2ChangeSet changeset,
2526
Logger log,
26-
string environmentId = null)
27+
string environmentId = null,
28+
bool fdv1Fallback = false)
2729
{
2830
DataStoreTypes.ChangeSetType changeSetType;
2931
switch (changeset.Type)
@@ -102,7 +104,8 @@ internal static class FDv2ChangeSetTranslator
102104
changeSetType,
103105
changeset.Selector,
104106
dataBuilder.ToImmutable(),
105-
environmentId);
107+
environmentId,
108+
fdv1Fallback);
106109
}
107110

108111
/// <summary>

0 commit comments

Comments
 (0)