Skip to content
Merged
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
15 changes: 7 additions & 8 deletions src/Cocoar.Configuration/Core/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,7 @@
composer?.Build();
_capabilityScope.Owner.GetComposition()?.UsingEach<IDeferredConfiguration>(c => c.Apply());

// The global pipeline's recompute accessor is `this` — byte-identical to before.
_global.Initialize(this, ScheduleRecompute);
_global.Initialize(ScheduleRecompute);
}
return this;
}
Expand All @@ -194,7 +193,7 @@
composer?.Build();
_capabilityScope.Owner.GetComposition()?.UsingEach<IDeferredConfiguration>(c => c.Apply());

await _global.InitializeAsync(this, ScheduleRecompute, cancellationToken).ConfigureAwait(false);
await _global.InitializeAsync(ScheduleRecompute, cancellationToken).ConfigureAwait(false);
}
return this;
}
Expand Down Expand Up @@ -245,7 +244,7 @@
/// <inheritdoc cref="GetConfig{T}"/>
public object GetConfig(Type type) => _accessor.GetConfig(type);

/// <inheritdoc cref="TryGetConfig{T}(out T?)"/>

Check warning on line 247 in src/Cocoar.Configuration/Core/ConfigManager.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

XML comment has cref attribute 'TryGetConfig{T}(out T?)' that could not be resolved

Check warning on line 247 in src/Cocoar.Configuration/Core/ConfigManager.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

XML comment has cref attribute 'TryGetConfig{T}(out T?)' that could not be resolved

Check warning on line 247 in src/Cocoar.Configuration/Core/ConfigManager.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

XML comment has cref attribute 'TryGetConfig{T}(out T?)' that could not be resolved
public bool TryGetConfig(Type type, out object? value) => _accessor.TryGetConfig(type, out value);

/// <summary>
Expand Down Expand Up @@ -340,7 +339,7 @@
internal string HealthDescription => _state.HealthDescription;

internal void ScheduleRecompute(int startIndex) =>
_engine.ScheduleRecompute(_ruleManagers, this, startIndex);
_engine.ScheduleRecompute(_ruleManagers, _global.RecomputeAccessor, startIndex);

internal Task? CurrentRecomputeTask => _engine.CurrentRecomputeTask;

Expand All @@ -350,7 +349,7 @@
/// concurrent provider-change signal cannot cancel activation before Layer 2 has committed (ADR-006 §7 readiness).
/// </summary>
internal Task RecomputeNowAsync(int startIndex, CancellationToken cancellationToken = default) =>
_engine.RecomputeAndUpdateHealthAsync(_ruleManagers, this, startIndex, cancellationToken);
_engine.RecomputeAndUpdateHealthAsync(_ruleManagers, _global.RecomputeAccessor, startIndex, cancellationToken);

/// <summary>
/// Runs the same direct recompute on every already-initialized tenant pipeline from <paramref name="startIndex"/>.
Expand Down Expand Up @@ -384,7 +383,7 @@
// isolates the narrow dispose-race (a tenant removed mid-fan-out can surface ObjectDisposed /
// index races from its health update) so one removed/faulting tenant never blocks the others.
await pipeline.Engine.RecomputeAndUpdateHealthAsync(
pipeline.RuleManagers, pipeline.Accessor, startIndex, cancellationToken).ConfigureAwait(false);
pipeline.RuleManagers, pipeline.RecomputeAccessor, startIndex, cancellationToken).ConfigureAwait(false);
}
catch
{
Expand Down Expand Up @@ -505,8 +504,8 @@
// lock-ordering hazards a shared seed-from-global path would carry. The trade-off is linear resource use
// (N tenants re-run the base); the seed-from-global sharing optimization is a documented, deferred TODO.
await pipeline.InitializeAsync(
pipeline.Accessor,
startIndex => pipeline.Engine.ScheduleRecompute(pipeline.RuleManagers, pipeline.Accessor, startIndex),
startIndex => pipeline.Engine.ScheduleRecompute(
pipeline.RuleManagers, pipeline.RecomputeAccessor, startIndex),
cancellationToken).ConfigureAwait(false);

return pipeline;
Expand Down
37 changes: 36 additions & 1 deletion src/Cocoar.Configuration/Core/ConfigurationAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,29 @@
private readonly ExposureRegistry _bindingRegistry;
private readonly ILogger _logger;
private readonly List<ConfigRule> _rules;
private readonly bool _preferPendingState;
private ConfigManagerCapabilityScope? _capabilityScope;

/// <param name="preferPendingState">
/// Resolve from the configuration state before the backplane. The backplane only ever holds the last
/// COMMITTED snapshot, so a rule factory reading it mid-recompute sees the previous pass — which is what
/// made config-aware rules (derived paths, URLs, values) lag or freeze. The engine's accessor sets this so
/// factories observe the in-flight pass, exactly as guide/configuration/config-aware.md describes; accessors
/// handed to application code leave it false and keep reading committed state only.
/// </param>
public ConfigurationAccessor(
ConfigurationState state,

Check warning on line 39 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Parameter 'state' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 39 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Parameter 'state' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 39 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'state' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)
ExposureRegistry bindingRegistry,

Check warning on line 40 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Parameter 'bindingRegistry' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 40 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Parameter 'bindingRegistry' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 40 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'bindingRegistry' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)
ILogger logger,

Check warning on line 41 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Parameter 'logger' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 41 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Parameter 'logger' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 41 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'logger' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)
List<ConfigRule> rules,

Check warning on line 42 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Parameter 'rules' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 42 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Parameter 'rules' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 42 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'rules' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)
string? tenant = null)
string? tenant = null,

Check warning on line 43 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Parameter 'tenant' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 43 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Parameter 'tenant' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)

Check warning on line 43 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Parameter 'tenant' has no matching param tag in the XML comment for 'ConfigurationAccessor.ConfigurationAccessor(ConfigurationState, ExposureRegistry, ILogger, List<ConfigRule>, string?, bool)' (but other parameters do)
bool preferPendingState = false)
{
_state = state;
_bindingRegistry = bindingRegistry;
_logger = logger;
_rules = rules;
_preferPendingState = preferPendingState;
Tenant = tenant;
}

Expand All @@ -59,10 +69,15 @@
/// <exception cref="InvalidOperationException">No configuration rule is registered for type T.</exception>
public T? GetConfig<T>() where T : class
{
if (_preferPendingState && HasConfigurationInState(typeof(T)))
{
return FallbackDeserialize<T>();
}

// First try the backplane (has cached instances after initialization)
try
{
var result = _state.Backplane.GetConfig(typeof(T));

Check warning on line 80 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on ubuntu-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 80 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on windows-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)

Check warning on line 80 in src/Cocoar.Configuration/Core/ConfigurationAccessor.cs

View workflow job for this annotation

GitHub Actions / Test on macos-latest

Prefer the generic overload 'Cocoar.Configuration.Core.MasterBackplane.GetConfig<T>()' instead of 'Cocoar.Configuration.Core.MasterBackplane.GetConfig(System.Type)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2263)
if (result is T typed)
{
return typed;
Expand Down Expand Up @@ -196,6 +211,11 @@
/// <exception cref="InvalidOperationException">No configuration rule is registered for the type.</exception>
public object GetConfig(Type type)
{
if (_preferPendingState && HasConfigurationInState(type))
{
return FallbackDeserialize(type);
}

// First try the backplane
try
{
Expand All @@ -214,6 +234,21 @@
return FallbackDeserialize(type);
}

/// <summary>
/// Whether the configuration state can serve this type right now. A type whose rules have not run yet in the
/// current pass has no entry, so the caller falls back to the committed backplane instead of throwing.
/// </summary>
private bool HasConfigurationInState(Type type)
{
var targetType = type;
if (type.IsInterface && _bindingRegistry.TryGetConcreteType(type, out var concreteType))
{
targetType = concreteType;
}

return _state.TryGetConfiguration(targetType, out var json) && json != null;
}

private object FallbackDeserialize(Type type)
{
// Try to resolve interface to concrete type
Expand Down
26 changes: 17 additions & 9 deletions src/Cocoar.Configuration/Core/TenantPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ internal sealed class TenantPipeline : IWritableStoreHost, IDisposable, IAsyncDi
internal ConfigurationState State { get; }
internal ProviderRegistry ProviderRegistry { get; }
internal ConfigurationAccessor Accessor { get; }

/// <summary>
/// The accessor handed to the engine for rule factories and <c>.When()</c> predicates. Resolves in-flight
/// pass state before the committed backplane; never handed to application code.
/// </summary>
internal ConfigurationAccessor RecomputeAccessor { get; }
internal ReactiveConfigManager ReactiveConfigManager { get; }
internal ReactiveConfigurationFactory ReactiveFactory { get; }
internal ConfigurationEngine Engine { get; }
Expand Down Expand Up @@ -73,6 +79,13 @@ internal TenantPipeline(
ProviderRegistry = new ProviderRegistry(_logger, enableDiagnostics: false, factory: providerFactory);
Accessor = new ConfigurationAccessor(State, _bindingRegistry, _logger, Rules, tenantId);
Accessor.SetCapabilityScope(_capabilityScope);

// Rule factories must observe the pass they are running in, not the last committed one — see
// guide/configuration/config-aware.md ("Re-evaluation on Change"). That is why the engine gets its own
// accessor: application-facing reads keep seeing committed state only.
RecomputeAccessor = new ConfigurationAccessor(
State, _bindingRegistry, _logger, Rules, tenantId, preferPendingState: true);
RecomputeAccessor.SetCapabilityScope(_capabilityScope);
ReactiveConfigManager = new ReactiveConfigManager(_logger, _bindingRegistry);

// Reactive reads bind to the global ConfigManager for the global pipeline (byte-identical) and to this
Expand All @@ -84,24 +97,19 @@ internal TenantPipeline(
Engine = new ConfigurationEngine(State, _logger);
}

/// <param name="recomputeAccessor">
/// The <see cref="IConfigurationAccessor"/> handed to the engine for recompute-window fallback reads and
/// provider option factories. For the global pipeline this is the owning <see cref="ConfigManager"/>
/// (byte-identical to before); for a tenant pipeline it is the tenant's own accessor.
/// </param>
internal void Initialize(IConfigurationAccessor recomputeAccessor, Action<int> scheduleRecompute)
internal void Initialize(Action<int> scheduleRecompute)
{
Engine.InitializeAndCompute(
Rules, RuleManagers, ProviderRegistry, recomputeAccessor,
Rules, RuleManagers, ProviderRegistry, RecomputeAccessor,
_bindingRegistry, _capabilityScope, scheduleRecompute, _debounceMilliseconds);
ReactiveConfigManager.SetBackplane(State.Backplane);
Volatile.Write(ref _initialized, 1);
}

internal async Task InitializeAsync(IConfigurationAccessor recomputeAccessor, Action<int> scheduleRecompute, CancellationToken cancellationToken)
internal async Task InitializeAsync(Action<int> scheduleRecompute, CancellationToken cancellationToken)
{
await Engine.InitializeAndComputeAsync(
Rules, RuleManagers, ProviderRegistry, recomputeAccessor,
Rules, RuleManagers, ProviderRegistry, RecomputeAccessor,
_bindingRegistry, _capabilityScope, scheduleRecompute, _debounceMilliseconds, cancellationToken).ConfigureAwait(false);
ReactiveConfigManager.SetBackplane(State.Backplane);
Volatile.Write(ref _initialized, 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ private static byte[] SerializeToBytes(JsonElement value)
public override Task<byte[]> FetchConfigurationBytesAsync(StaticJsonProviderQueryOptions query,
CancellationToken ct = default)
{
return Task.FromResult(_cachedBytes);
// Callers may zero the array they receive, and one provider instance is shared by every
// rule with identical payload, so the cached buffer must never leave this object.
return Task.FromResult((byte[])_cachedBytes.Clone());
}

public override IObservable<byte[]> ChangesAsBytes(StaticJsonProviderQueryOptions queryOptions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ namespace Cocoar.Configuration.Providers;

public record StaticJsonProviderQueryOptions() : IProviderQuery;

public record StaticJsonProviderOptions(JsonElement Value) : IProviderConfiguration
{
public string? GenerateProviderKey() => null;
}
/// <summary>
/// Uses the default provider key (the serialized options), so the payload itself identifies the provider. A
/// config-aware <c>FromStatic</c> factory is re-evaluated every recompute, and a changed payload has to produce a
/// changed key — otherwise the lease reuses the first provider and the rule serves its startup value forever.
/// </summary>
public record StaticJsonProviderOptions(JsonElement Value) : IProviderConfiguration;
7 changes: 6 additions & 1 deletion src/Cocoar.Configuration/Rules/RuleManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ public RuleManager(ConfigRule rule, ILogger logger, ProviderRegistry registry)

var queryOptions = _rule.ResolveQueryOptions(accessor);
EnsureSubscription(queryOptions);
var newTransformKey = ComputeTransformKey(_rule.Options);

// The query says WHAT is read — file name, URL, environment prefix — and a config-aware factory may
// return a different one on the next pass. It is not part of the provider key (a file provider is keyed
// by directory), so without folding it in here the cached bytes of the PREVIOUS query would stay valid
// and the rule would keep serving the old source forever.
var newTransformKey = ComputeTransformKey(_rule.Options) + "|" + ComputeQueryKey(queryOptions);
_cache.UpdateTransformKey(newTransformKey);

try
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json;
using Cocoar.Configuration.Providers;

namespace Cocoar.Configuration.Core.Tests.Providers;

[Trait("Category", "Unit")]
[Trait("Component", "StaticJsonProvider")]
public class StaticJsonProviderContractTests
{
/// <summary>
/// <c>ConfigurationProvider</c> requires a provider to "return fresh arrays a caller may zero", and
/// <c>RuleManager.ComputeAsync</c> does zero every buffer it receives. Handing out the provider's own cached
/// array therefore leaves the next fetch reading a run of zero bytes.
/// </summary>
[Fact]
[Trait("Type", "Unit")]
public async Task FetchDoesNotHandOutTheProvidersOwnBuffer()
{
using var document = JsonDocument.Parse("""{"Region":"eu"}""");
var provider = new StaticJsonProvider(new StaticJsonProviderOptions(document.RootElement.Clone()));
var query = new StaticJsonProviderQueryOptions();

var first = await provider.FetchConfigurationBytesAsync(query);
Array.Clear(first, 0, first.Length);

var second = await provider.FetchConfigurationBytesAsync(query);

Assert.Equal("""{"Region":"eu"}""", System.Text.Encoding.UTF8.GetString(second));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Reactive.Subjects;
using Cocoar.Configuration.Core.Tests.TestUtilities;
using Cocoar.Configuration.Fluent;
using Cocoar.Configuration.Providers;

namespace Cocoar.Configuration.Core.Tests.Verification;

/// <summary>
/// The library's promise is that the provider behind a rule does not change the semantics — every provider
/// yields JSON and the engine does the rest. These tests run the same config-aware scenario (an upstream value
/// changes at runtime, a dependent rule must follow) across the providers reachable from this project, so a
/// regression in one provider cannot hide behind another's coverage.
/// <para>The HTTP case lives in Cocoar.Configuration.Providers.Tests, which is the project that references it.</para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Component", "ConfigAware")]
public class ConfigAwareProviderMatrixTests
{
public class SourceCfg
{
public string Name { get; set; } = "unset";
public bool Enabled { get; set; }
}

public class TargetCfg { public string Value { get; set; } = "unset"; }

public class MiddleCfg { public string Value { get; set; } = "unset"; }

public class LeafCfg { public string Value { get; set; } = "unset"; }

[Fact]
[Trait("Type", "Unit")]
public async Task Environment_DerivedPrefix_FollowsItsSource()
{
var run = "MTX" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
Environment.SetEnvironmentVariable($"{run}A_Value", "A");
Environment.SetEnvironmentVariable($"{run}B_Value", "B");
try
{
using var source = new BehaviorSubject<string>($$"""{"Name":"{{run}}A_"}""");
var builder = new RulesBuilder();

var rules = new List<ConfigRule>
{
TestRules.ObservableString<SourceCfg>(source),
builder.For<TargetCfg>().FromEnvironment(a =>
new EnvironmentVariableRuleOptions(a.GetConfig<SourceCfg>()!.Name)),
};

using var mgr = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50));
Assert.Equal("A", mgr.GetConfig<TargetCfg>()!.Value);

source.OnNext($$"""{"Name":"{{run}}B_"}""");
await ActiveWaitHelpers.WaitUntilAsync(
() => mgr.GetConfig<TargetCfg>()!.Value == "B",
description: "environment rule to follow the derived prefix");

Assert.Equal("B", mgr.GetConfig<TargetCfg>()!.Value);
}
finally
{
Environment.SetEnvironmentVariable($"{run}A_Value", null);
Environment.SetEnvironmentVariable($"{run}B_Value", null);
}
}

/// <summary>
/// <c>.When()</c> reads the same accessor as a provider factory, so a predicate must also see the pass it
/// runs in — otherwise a rule switches on or off one recompute late.
/// </summary>
[Fact]
[Trait("Type", "Unit")]
public async Task ConditionalRule_TogglesInTheSamePassAsItsSource()
{
using var source = new BehaviorSubject<string>("""{"Enabled":false}""");
var builder = new RulesBuilder();

var rules = new List<ConfigRule>
{
TestRules.ObservableString<SourceCfg>(source),
builder.For<TargetCfg>().FromStaticJson("""{"Value":"base"}"""),
builder.For<TargetCfg>().FromStaticJson("""{"Value":"overlay"}""")
.When(a => a.GetConfig<SourceCfg>()!.Enabled),
};

using var mgr = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50));
Assert.Equal("base", mgr.GetConfig<TargetCfg>()!.Value);

source.OnNext("""{"Enabled":true}""");
await ActiveWaitHelpers.WaitUntilAsync(
() => mgr.GetConfig<TargetCfg>()!.Value == "overlay",
description: "conditional rule to switch on");

source.OnNext("""{"Enabled":false}""");
await ActiveWaitHelpers.WaitUntilAsync(
() => mgr.GetConfig<TargetCfg>()!.Value == "base",
description: "conditional rule to switch off again");
}

/// <summary>
/// A → B → C in one rule list. Rules run in order within a pass, so a single upstream change has to reach
/// the leaf in that same pass rather than taking one recompute per link.
/// </summary>
[Fact]
[Trait("Type", "Unit")]
public async Task ChainedDerivations_ReachTheLeafInOnePass()
{
using var source = new BehaviorSubject<string>("""{"Name":"one"}""");
var builder = new RulesBuilder();

var rules = new List<ConfigRule>
{
TestRules.ObservableString<SourceCfg>(source),
builder.For<MiddleCfg>().FromStatic(a =>
new MiddleCfg { Value = "mid-" + a.GetConfig<SourceCfg>()!.Name }),
builder.For<LeafCfg>().FromStatic(a =>
new LeafCfg { Value = "leaf-" + a.GetConfig<MiddleCfg>()!.Value }),
};

using var mgr = ConfigManager.Create(c => c.UseConfiguration(rules).UseDebounce(50));
Assert.Equal("leaf-mid-one", mgr.GetConfig<LeafCfg>()!.Value);

source.OnNext("""{"Name":"two"}""");
await ActiveWaitHelpers.WaitUntilAsync(
() => mgr.GetConfig<SourceCfg>()!.Name == "two",
description: "source to update");
await Task.Delay(300);

Assert.Equal("mid-two", mgr.GetConfig<MiddleCfg>()!.Value);
Assert.Equal("leaf-mid-two", mgr.GetConfig<LeafCfg>()!.Value);
}
}
Loading
Loading