feat(galaxy): writer borrows live subscription item handles (skip redundant AddItem)

GatewayGalaxyDataWriter now accepts an optional subscribedHandleSource
delegate; TryResolveCachedOrBorrowed checks _itemHandles first then the
source, so the first write to an already-subscribed tag skips the
AddItem round-trip. Borrowed handles are not cached (subscription
registry owns lifecycle). AddItemCallCount seam confirms gateway calls.
This commit is contained in:
Joseph Doherty
2026-06-18 04:18:35 -04:00
parent 1411950077
commit 2e3f528afc
2 changed files with 95 additions and 2 deletions
@@ -195,10 +195,10 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
private async Task<int> EnsureItemHandleAsync(
MxGatewaySession session, int serverHandle, string fullRef, CancellationToken ct)
{
if (_itemHandles.TryGetValue(fullRef, out var existing)) return existing;
if (TryResolveCachedOrBorrowed(fullRef) is int resolved) return resolved;
var handle = await session.AddItemAsync(serverHandle, fullRef, ct).ConfigureAwait(false);
_itemHandles[fullRef] = handle;
Interlocked.Increment(ref _addItemCallCount);
_itemHandles[fullRef] = handle;
return handle;
}
@@ -88,4 +88,97 @@ public sealed class GatewayGalaxyDataWriterTests
writer.CachedItemHandleCount.ShouldBe(0);
writer.CachedSupervisedHandleCount.ShouldBe(0);
}
// ===== subscribedHandleSource / TryResolveCachedOrBorrowed tests =====
/// <summary>
/// When a <c>subscribedHandleSource</c> delegate returns a positive handle for the
/// requested ref, <see cref="GatewayGalaxyDataWriter.TryResolveCachedOrBorrowed"/> must
/// return that handle. Critically, the borrowed handle must NOT be stored in the writer's
/// own item-handle cache (the subscription registry owns its lifecycle), and
/// <see cref="GatewayGalaxyDataWriter.AddItemCallCount"/> must remain zero (no real
/// gateway round-trip issued).
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_returns_borrowed_handle_without_caching_it()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: fr => fr == "Tag.X" ? 7 : (int?)null);
var result = writer.TryResolveCachedOrBorrowed("Tag.X");
result.ShouldBe(7);
writer.CachedItemHandleCount.ShouldBe(0); // borrow NOT stored in _itemHandles
writer.AddItemCallCount.ShouldBe(0); // no real AddItem issued
}
/// <summary>
/// When a handle is present in the writer's own <c>_itemHandles</c> cache (seeded via
/// <see cref="GatewayGalaxyDataWriter.SeedHandleCachesForTest"/>), that cached handle must
/// win over the <c>subscribedHandleSource</c> delegate — the writer's own earlier AddItem
/// takes priority.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_cached_handle_wins_over_source()
{
// Source would return 99 for "Tag.Y", but the writer already cached handle 42 for it.
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: fr => fr == "Tag.Y" ? 99 : (int?)null);
writer.SeedHandleCachesForTest("Tag.Y", itemHandle: 42, supervised: false);
var result = writer.TryResolveCachedOrBorrowed("Tag.Y");
result.ShouldBe(42); // cached wins, not 99 from the source
}
/// <summary>
/// When no <c>subscribedHandleSource</c> is provided (<c>null</c>), the method must
/// return <c>null</c> for a ref that has never been cached — preserving today's
/// AddItem-required behavior.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_null_source_returns_null_when_not_cached()
{
var writer = new GatewayGalaxyDataWriter(MinimalSession(), writeUserId: 0);
var result = writer.TryResolveCachedOrBorrowed("Tag.Z");
result.ShouldBeNull();
}
/// <summary>
/// A <c>subscribedHandleSource</c> that returns 0 (a sentinel meaning "subscribe
/// pending / failed") must be treated as no borrow — the method returns <c>null</c>
/// so the caller issues a fresh AddItem.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_source_returning_zero_treated_as_no_borrow()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: _ => 0);
var result = writer.TryResolveCachedOrBorrowed("Tag.W");
result.ShouldBeNull();
}
/// <summary>
/// A <c>subscribedHandleSource</c> that returns a negative value (another failure
/// sentinel) must also be treated as no borrow — the method returns <c>null</c>.
/// </summary>
[Fact]
public void TryResolveCachedOrBorrowed_source_returning_negative_treated_as_no_borrow()
{
var writer = new GatewayGalaxyDataWriter(
MinimalSession(), writeUserId: 0, logger: null,
subscribedHandleSource: _ => -5);
var result = writer.TryResolveCachedOrBorrowed("Tag.V");
result.ShouldBeNull();
}
}