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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- **ConfigHub class selection** — typed `FromConfigHub<T>` rules send `ConfigHub-Class` with `typeof(T).Name`, or the optional `alias`, on snapshot and SSE requests. The server returns only that merged root object. Delivery and schema registration derive the same key. `Select` and `MountAt` keep working as on every other rule and apply to the returned class object; registration ignores them and always reports the full type of the rule under its key. An existing `.Select("Storage")` on a full document becomes `alias: "Storage"`, with the stored settings wrapped under that object. Different types with the same key at one endpoint require distinct aliases. The low-level query API retains full-document delivery when `classKey` is omitted.

### Fixed
- A rule with `.Select(...)` whose selected path disappears from its source at runtime no longer keeps serving the previous values. The change now triggers a recompute that treats the rule as it would at startup: an optional rule contributes nothing and is reported as failed (health `Degraded`), a required rule keeps the last good snapshot and is reported. Previously the removal was ignored silently and only a restart corrected it.
- Updated `Cocoar.Json.Mutable` to 1.2.1, which fixes character escaping in the JSON merge pipeline.

### Documentation
Expand Down
27 changes: 26 additions & 1 deletion src/Cocoar.Configuration/Rules/TransformCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal sealed class TransformCache : IDisposable
private string? _lastSelectionHash;
private bool _dirty;
private bool _dirtyFromTransformChange;
private bool _selectionMissing;

/// <summary>
/// Gets whether the cache is dirty and needs refresh.
Expand Down Expand Up @@ -89,6 +90,7 @@ public void StoreTransformedBytes(byte[] transformedBytes)

_dirty = false;
_dirtyFromTransformChange = false;
_selectionMissing = false;
}
}

Expand Down Expand Up @@ -123,13 +125,35 @@ public bool ProcessProviderChange(byte[] rawBytes, string? selectPath, string? m
}
_dirty = true;
_dirtyFromTransformChange = false;
_selectionMissing = false;

return true; // Data changed
}
}
catch (KeyNotFoundException)
{
// A well-formed document without the selected path is new content, not a transient error.
// Dropping the cache sends the next recompute through the fetch path, which decides
// optional (empty contribution) versus required (rollback) exactly as it does at startup.
lock (_lock)
{
if (_selectionMissing)
{
return false; // Already reported; the source still lacks the path
}

_selectionMissing = true;
_lastSelectionHash = null;
_dirty = true;
_cachedBytes?.Dispose();
_cachedBytes = null;

return true;
}
}
catch
{
return false; // Ignore transform errors in change handler
return false; // Ignore transform errors in change handler (e.g. a document caught mid-write)
}
}

Expand All @@ -149,6 +173,7 @@ public void Invalidate()
lock (_lock)
{
_lastSelectionHash = null;
_selectionMissing = false;
_dirty = true;
_cachedBytes?.Dispose();
_cachedBytes = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Reactive.Subjects;
using Cocoar.Configuration.Core.Tests.TestUtilities;
using Cocoar.Configuration.Health;

namespace Cocoar.Configuration.Core.Tests.Integration;

/// <summary>
/// A selected path that disappears from its source while the application runs must behave like the
/// same source at startup: the rule contributes nothing, instead of keeping the values it had before.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Component", "ConfigManager")]
public class SelectedSectionRemovedTests
{
public sealed class Logging { public string Level { get; set; } = "Default"; }
public sealed class App { public string Name { get; set; } = ""; }
public sealed class Host { public Logging Logging { get; set; } = new(); }

[Fact]
[Trait("Type", "Unit")]
public async Task OptionalRule_SectionRemovedAtRuntime_FallsBackToDefaults_AndNotifies()
{
using var source = new BehaviorSubject<string>("""{ "App": { "Name": "x" }, "Logging": { "Level": "Debug" } }""");
using var manager = ConfigManager.Create(c => c.UseConfiguration(rules =>
[
rules.For<Logging>().FromObservable(source).Select("Logging"),
rules.For<App>().FromObservable(source).Select("App"),
]).UseDebounce(25));
var logging = manager.GetReactiveConfig<Logging>();
var levels = new List<string>();
using var subscription = logging.Subscribe(value => { lock (levels) levels.Add(value.Level); });
Assert.Equal("Debug", logging.CurrentValue.Level);

source.OnNext("""{ "App": { "Name": "x" } }""");

await ActiveWaitHelpers.WaitUntilAsync(() => logging.CurrentValue.Level == "Default", description: "defaults after the section was removed");
lock (levels) Assert.Equal("Default", levels[^1]);
Assert.Equal(HealthStatus.Degraded, manager.HealthStatus);

// The rule recovers as soon as the section is back.
source.OnNext("""{ "App": { "Name": "x" }, "Logging": { "Level": "Trace" } }""");

await ActiveWaitHelpers.WaitUntilAsync(() => logging.CurrentValue.Level == "Trace", description: "values after the section returned");
Assert.Equal(HealthStatus.Healthy, manager.HealthStatus);
}

[Fact]
[Trait("Type", "Unit")]
public async Task OptionalRule_SectionRemovedAtRuntime_WithMountAt_FallsBackToDefaults()
{
using var source = new BehaviorSubject<string>("""{ "Logging": { "Level": "Debug" } }""");
using var manager = ConfigManager.Create(c => c.UseConfiguration(rules =>
[rules.For<Host>().FromObservable(source).Select("Logging").MountAt("Logging")]).UseDebounce(25));
Assert.Equal("Debug", manager.GetConfig<Host>()!.Logging.Level);

source.OnNext("{ }");

await ActiveWaitHelpers.WaitUntilAsync(() => manager.GetConfig<Host>()!.Logging.Level == "Default", description: "defaults after the section was removed");
}

[Fact]
[Trait("Type", "Unit")]
public async Task RequiredRule_SectionRemovedAtRuntime_KeepsTheLastGoodSnapshot_AndReportsIt()
{
using var source = new BehaviorSubject<string>("""{ "Logging": { "Level": "Debug" } }""");
using var manager = ConfigManager.Create(c => c.UseConfiguration(rules =>
[rules.For<Logging>().FromObservable(source).Select("Logging").Required()]).UseDebounce(25));

source.OnNext("{ }");

await ActiveWaitHelpers.WaitUntilAsync(() => manager.HealthStatus != HealthStatus.Healthy, description: "the failed required rule to be reported");
Assert.Equal("Debug", manager.GetConfig<Logging>()!.Level);
}
}
Loading