Skip to content

Background EntityBroker queries should optimize themselves #1328

Description

@JoshuaRowePhantom

Summary

EntityBroker currently issues its background data-access queries (GetChangedEntitiesAsync during the 3-second OnRefreshTick, per-subscription GetAsync / QueryAsync refreshes, on-demand LoadSnapshotsAsync, and per-UpdateAsync refresh fan-outs) with no deduplication, no subsumption, no concurrency policy, and no coordination between on-demand and periodic work. Every live SubscribedGet / SubscribedQuery refresh spawns its own Task.Run → data-access call with unbounded overlap across RefreshAsync / UpdateAsync / SubscribeXxxAsync invocations, and identical queries issued from independent call sites each round-trip separately.

Introduce an EntityBrokerQuerySatisfier (internal, owned by EntityBroker) whose job is to minimize redundant data-access round-trips by letting one executed query satisfy (complete) other queries it covers. Specifically:

  1. Satisfaction relation — query A satisfies query B iff A returns a strict superset of the data/entities that B would return. This is determined either by query analysis at issuance time, or by inspecting actual results at result time.
  2. Two subsuming lists, maintained separately per pending query B:
    • Issuance-time subsuming set — computed from request shapes alone (static query analysis), the moment B is issued.
    • Result-time subsuming set — computed when a candidate A's results arrive and are observed to cover B's target (e.g. B is a GET for entity id X and A's result-id-set happens to contain X, even if static analysis could not prove it).
  3. Two execution classes with different concurrency rules:
    • On-demand (newly issued) queries run in parallel with all other on-demand queries — they are not throttled — but are still subject to satisfaction (dedup + subsumption).
    • Periodic queries (the 3s refresh cycle: GetChangedEntitiesAsync and per-subscription GetAsync/QueryAsync refreshes) are throttled to one at a time and subject to satisfaction.
  4. Cross-trigger — a newly-issued on-demand query that satisfies a pending periodic query immediately refreshes that periodic query: its subscribers receive the sliced results from the on-demand query without waiting for the next periodic slot, and the periodic query's refresh cycle treats this as having refreshed.
  5. Skip-refresh — when a subsumed periodic query B (i.e. B currently has a live subsuming query A) is due for its periodic refresh, B does not refresh; A executes in B's stead during B's refresh cycle and B is served from A's results.

Interpretation note — the owner's phrasing "when a subsuming query is up for refresh, it should simply not refresh, allowing the subsuming query to execute in its stead" is interpreted here as: the subsumed query (the one that has a subsuming query) is the one that skips its refresh, and the subsuming query executes in its stead. If the opposite was intended (the subsuming query is the one that skips), please correct in a comment and this section will be reversed.

Root Cause / Current Behavior

All background query issuance in EntityBroker goes straight through Task.Run(() => this.entityRepository.DataAccessLayer.<Call>Async(...)) with no intervening satisfier. Distinguishing periodic vs on-demand sites:

Periodic sites (driven by OnRefreshTick, 3s cadence)

  • Tick triggerMainWindowViewModel.OnRefreshTick (features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs:1764-1769, timer at :171) fires every 3s and calls EntityBroker.RefreshAsync():

    this.refreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
    this.refreshTimer.Tick += this.OnRefreshTick;
    ...
    private async void OnRefreshTick(object? sender, EventArgs e)
        => await this.EntityBroker.RefreshAsync();
  • EntityBroker.RefreshAsync (features\Phantom.Workspaces\EntityBroker.cs:327-380) issues an unthrottled full-scope GetChangedEntitiesAsync, then sequentially awaits per-subscription refreshes:

    var changedEntitiesResult = await Task.Run(() => this.entityRepository.DataAccessLayer.GetChangedEntitiesAsync(
        new GetChangedEntitiesRequest
        {
            EntityIdTimestamps = snapshotsById.Select(
                static pair => new EntityIdTimestamp(pair.Key, pair.Value.ModifiedTime)).ToArray(),
        },
        cancellationToken));                                                          // :341-347
    ...
    var getsChanged = await this.RefreshSubscribedGetsAsync(changedEntityIds, cancellationToken);      // :366
    var queriesChanged = await this.RefreshSubscribedQueriesAsync(changedEntityIds, cancellationToken); // :367
  • Per-subscription refreshRefreshSubscribedGetsAsync / RefreshSubscribedQueriesAsync (EntityBroker.cs:746-822) iterate every live subscription and call subscribedGet.RefreshAsync / subscribedQuery.RefreshAsync, each of which fires its own Task.RunGetAsync / QueryAsync:

    // GetSubscribedEntitiesForGetRequestAsync — :382-404
    var getResult = await Task.Run(() => this.entityRepository.DataAccessLayer.GetAsync(request, cancellationToken));   // :388
    // GetSubscribedEntitiesForQueryRequestAsync — :406-428
    var queryResult = await Task.Run(() => this.entityRepository.DataAccessLayer.QueryAsync(request, cancellationToken)); // :412

On-demand sites

  • LoadSnapshotsAsync (EntityBroker.cs:430-453) — invoked by GetEntitiesAsync and subscription warm-up; Task.Runs a GetAsync for the requested set with no dedup against any in-flight tick refresh or another concurrent LoadSnapshotsAsync:

    var getResult = await Task.Run(() => this.entityRepository.DataAccessLayer.GetAsync(
        new GetRequest { Entities = entityRequests }, cancellationToken));            // :440-445
  • UpdateAsync post-update fan-out (EntityBroker.cs:233-311) — after applying an update, always calls RefreshSubscribedGetsAsync + RefreshSubscribedQueriesAsync (:299-300), each of which spawns per-subscription Task.Run reads. This is on-demand (it is caused by a discrete user action, not the 3s tick), even though it reuses the same per-subscription refresh helpers as the periodic path.

What's missing

  • No dedup: identical requests from independent call sites each round-trip.
  • No subsumption: a full-scope GetChangedEntitiesAsync per tick already covers every id, but per-subscription GetAsync/QueryAsync calls still hit the wire.
  • No throttle on periodic work: N live subscriptions ⇒ 1 + N overlapping calls every 3s.
  • No coordination between on-demand and periodic work: an UpdateAsync fan-out and a concurrent tick refresh interleave freely.
  • No reusable coalescer: a repo-wide search for SemaphoreSlim, Channel.Create, Coalesc, Throttl, Batcher, Debounce in features\Phantom.Workspaces found only unrelated uses (WorkspacesTransportHost.cs, AgentSessionWorkspaceTabViewModel.cs, GitWorktreeWatcher.cs).

Existing dedup state is limited to the subscribedGets / subscribedQueries weak-reference dictionaries keyed on JsonSerializer.Serialize(request) (EntityBroker.cs:18-19, 144, 180), which dedup subscription registrations but not the background data-access calls they issue.

Affected Files

File Role
features\Phantom.Workspaces\EntityBroker.cs All background query issuing sites; delegates to satisfier. Classifies each call site as periodic or on-demand.
features\Phantom.Workspaces\EntityBrokerQuerySatisfier.cs (new) The satisfier: pending-query registry, per-query issuance-time / result-time subsuming lists, periodic single-slot throttle, parallel on-demand path, cross-trigger, skip-refresh.
features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs OnRefreshTick (line 1764) drives periodic queries; no behavioral change but relevant for tests.
features\Phantom.Workspaces.Tests\EntityBrokerQuerySatisfierTests.cs (new) Direct satisfier unit tests.
features\Phantom.Workspaces.Tests\EntityBrokerTests.cs Integration tests confirming EntityBroker routes periodic vs on-demand correctly through the satisfier.

Design / Fix

Introduce a new EntityBrokerQuerySatisfier (internal, owned by EntityBroker) that funnels every background data-access call through a satisfaction-aware entry point. EntityBroker.RefreshAsync, RefreshSubscribedGetsAsync, RefreshSubscribedQueriesAsync, LoadSnapshotsAsync, and UpdateAsync's post-update fan-out delegate to it instead of calling Task.Run(() => DataAccessLayer.*Async(...)) directly. Each call site classifies its work as periodic or on-demand so the satisfier can apply the correct concurrency rule.

Satisfier shape

internal sealed class EntityBrokerQuerySatisfier : IAsyncDisposable
{
    private readonly EntityRepository repository;
    private readonly SemaphoreSlim periodicSlot = new(1, 1);   // periodic queries: 1-at-a-time
    private readonly Channel<PendingQuery> periodicQueue =
        Channel.CreateUnbounded<PendingQuery>(new UnboundedChannelOptions { SingleReader = true });
    private readonly object gate = new();
    private readonly Dictionary<string, PendingQuery> pendingByKey = new(StringComparer.Ordinal);
    private readonly List<PendingQuery> allPending = new(); // for subsumption scans (both classes)

    // On-demand path: parallel, satisfaction-aware, not throttled.
    public Task<GetResult>                 SatisfyOrIssueOnDemandGetAsync(GetRequest r, CancellationToken ct);
    public Task<QueryResult>               SatisfyOrIssueOnDemandQueryAsync(QueryRequest r, CancellationToken ct);
    public Task<GetChangedEntitiesResult>  SatisfyOrIssueOnDemandGetChangedAsync(GetChangedEntitiesRequest r, CancellationToken ct);

    // Periodic path: single-slot throttle, satisfaction-aware, subject to skip-refresh.
    public Task<GetResult>                 SatisfyOrEnqueuePeriodicGetAsync(GetRequest r, CancellationToken ct);
    public Task<QueryResult>               SatisfyOrEnqueuePeriodicQueryAsync(QueryRequest r, CancellationToken ct);
    public Task<GetChangedEntitiesResult>  SatisfyOrEnqueuePeriodicGetChangedAsync(GetChangedEntitiesRequest r, CancellationToken ct);

    // Write path (still routed through the satisfier so the read-side sees the update happened).
    public Task<UpdateResult>              RunUpdateAsync(UpdateRequest r, CancellationToken ct);

    public ValueTask DisposeAsync();
}

private sealed class PendingQuery
{
    public required string Key;                                // JsonSerializer.Serialize(request)
    public required QueryKind Kind;                            // Get | Query | GetChanged
    public required ExecutionClass Class;                      // OnDemand | Periodic
    public required object Request;
    public required TaskCompletionSource<object> Tcs;          // typed by Kind

    // Attached slice awaiters (their TCSes are completed from THIS query's results).
    public List<SliceAwaiter> SliceAwaiters = new();

    // Subsuming lists — maintained SEPARATELY (owner's explicit requirement).
    public List<PendingQuery> IssuanceTimeSubsumers = new();   // determined at issuance from request shapes
    public List<PendingQuery> ResultTimeSubsumers = new();     // added when another query's results are observed to cover this one
}

Satisfaction relation (how A satisfies B)

A PendingQuery A satisfies B iff A's data/entity coverage is a strict superset of B's. Determined by two mechanisms, tracked as two separate lists per B:

  1. Issuance-time (query analysis) — computed from the request shapes at the moment B is issued:
    • B is a GetRequest for id-set S_B; A is a GetRequest for id-set S_A with S_B ⊆ S_A (strict-superset in coverage sense: A also covers extra ids or B has fewer ids than A) → A satisfies B.
    • B is a GetRequest for id X; A is a full-scope GetChangedEntitiesRequest whose EntityIdTimestamps cover X → A satisfies B (A's result contains an up-to-date snapshot for X or a "not-changed" marker signalling B's cached snapshot is still fresh).
    • A is a QueryRequest and B is a GetRequest for id X: cannot be determined at issuance time from shapes alone (query predicates are not analyzed for containment in v1); handled at result time instead.
    • A QueryRequest's predicate provably containing another QueryRequest's predicate is a possible future extension (not required in v1; call sites that use identical QueryRequests coalesce via dedup anyway).
  2. Result-time (from A's actual results) — computed when A's results materialize:
    • B is a GetRequest for id X; A is any executed query whose result id-set includes X → A satisfies B. Applies especially to broad QueryRequests whose result contents happen to cover a narrow GET even though issuance-time analysis could not prove it.
    • B is a multi-id GetRequest for S_B; A's result id-set covers S_B in full → A satisfies B.

Both lists are updated separately per B: IssuanceTimeSubsumers is populated once (at B's issuance) by scanning allPending for candidate A's whose request shapes cover B; ResultTimeSubsumers is updated every time some A completes with results that cover B (or when B is enqueued and there are already-completed-but-still-observable results in the current cycle).

Completion contract

Whenever a query A is actually executed, before the loop releases A's slot the satisfier does:

  1. For every pending B ∈ (A.SliceAwaiters ∪ any B whose subsuming lists contain A): slice A's result down to B's target and complete B's TCS. For a GET-by-id B, this is the specific entity snapshot extracted from A's result; for a multi-id B, the covered subset. For a GetChangedEntitiesResult "not-changed" marker on a subsumed id, resolve from the EntityRepository cache (see below).
  2. Cross-trigger check: for every periodic B satisfied by A that is currently the "next refresh" target, mark B as refreshed-by-A — its periodic timer treats this as a fresh refresh and its subscribers are notified now.

B does not issue its own data-access call in any of these cases.

Execution classes

  • On-demand queries (SatisfyOrIssueOnDemand…) — invoked by LoadSnapshotsAsync, explicit GetEntitiesAsync, and UpdateAsync post-update reads. These run in parallel with all other on-demand queries — no throttle. They are still satisfaction-aware:

    • Dedup: an in-flight identical-key on-demand query attaches this call as an extra awaiter.
    • Subsumption: an in-flight A (on-demand or periodic) that satisfies this new B by issuance-time analysis attaches this call as a slice awaiter of A and does not issue a new call. Result-time subsumption is checked when in-flight A's complete.
    • No enqueue on the periodic channel; no periodicSlot acquired.
  • Periodic queries (SatisfyOrEnqueuePeriodic…) — invoked by RefreshAsync (GetChangedEntitiesAsync) and per-subscription refreshes. These are throttled to one at a time via periodicSlot (single-reader Channel + SemaphoreSlim(1)), FIFO order preserved. Also satisfaction-aware (dedup + subsumption + skip-refresh, below).

  • Cross-trigger — when the satisfier admits a new on-demand A, it scans allPending for periodic B's satisfied by A (issuance-time analysis). Any such B is immediately refreshed from A: as soon as A completes, B's subscriber-notification path fires with A's sliced results, and B's periodic-refresh cycle records this as its most recent refresh (so the next 3s tick's periodic slot does not re-issue B). This applies also at result-time: if A's results turn out to cover B (result-time subsumption), the cross-trigger fires then.

Skip-refresh

When the periodic loop dequeues a query B, it checks under gate:

  • If B has a non-empty IssuanceTimeSubsumers OR ResultTimeSubsumers list containing a live A (still pending or recently completed within B's refresh cycle): B does not execute. B is served from A's results (either immediately from A's cached most-recent result if A already completed, or as a slice awaiter of A if A is still in flight). The periodic slot is released without doing wire work for B.
  • Otherwise B executes normally.

This is a direct encoding of the owner's rule "when a subsuming query is up for refresh, it should simply not refresh, allowing the subsuming query to execute in its stead" — interpreted (see Interpretation note in Summary) as the subsumed query being the one that skips. Once B's subsuming A's are gone (completed, expired, or no longer satisfying), B resumes normal periodic refresh.

Dedup

Key: JsonSerializer.Serialize(request) (matches the existing pattern at EntityBroker.cs:144, 180). pendingByKey lookup at every satisfier entry point:

  • Miss — create a new PendingQuery, compute IssuanceTimeSubsumers by scanning allPending, register in both dictionaries; on-demand path issues immediately, periodic path writes to periodicQueue.
  • Hit — attach this caller's TCS as an awaiter and return pending.Tcs.Task — no second data-access call.

Cancellation of shared awaiters: each caller supplies its own CancellationToken. Cancelling a single caller's token completes that caller's returned Task<TResult> as cancelled but does not cancel the underlying data-access call while any other awaiter (or slice awaiter or cross-trigger periodic-refresh registration) is still attached. Only when the last attached awaiter observes cancellation is the underlying call's linked token cancelled.

Slice resolution for NotChanged

When a GetChangedEntitiesResult returns a "not-changed" marker (ChangedEntitySnapshot.Entity == null) for a subsumed narrow id, the slice awaiter resolves its GetResult by pulling the corresponding entity's cached snapshot from EntityRepository. If not present in cache, the slice awaiter falls through to enqueue a fresh narrow SatisfyOrIssueOnDemandGetAsync (single-hop retry, not a loop).

Faults

If the underlying data-access call throws, the exception faults the pending query's TCS and every attached slice-awaiter TCS (they are defined as slices of the faulting result). The periodic consumer loop catches, releases periodicSlot, and continues draining. The on-demand parallel path likewise isolates faults per-call. A single failed query never terminates the satisfier.

Disposal / shutdown

IAsyncDisposable. DisposeAsync completes the periodic channel writer, awaits the periodic loop, cancels every still-pending TCS (direct + slice + cross-trigger) with TaskCanceledException, and disposes periodicSlot. Enqueue calls issued after disposal return a canceled Task<TResult> (matches the existing EntityBroker cancellation contract).

Delegation from EntityBroker

Each existing Task.Run(() => DataAccessLayer.XxxAsync(...)) becomes a satisfier call, tagged with its class:

Call site New call Class
RefreshAsync (:341) GetChangedEntitiesAsync satisfier.SatisfyOrEnqueuePeriodicGetChangedAsync(...) Periodic
GetSubscribedEntitiesForGetRequestAsync (:388) when called from RefreshSubscribedGetsAsync via tick satisfier.SatisfyOrEnqueuePeriodicGetAsync(...) Periodic
GetSubscribedEntitiesForQueryRequestAsync (:412) when called from RefreshSubscribedQueriesAsync via tick satisfier.SatisfyOrEnqueuePeriodicQueryAsync(...) Periodic
LoadSnapshotsAsync (:440) satisfier.SatisfyOrIssueOnDemandGetAsync(...) On-demand
UpdateAsync write (:238) satisfier.RunUpdateAsync(...) (Write)
UpdateAsync post-update fan-out (:299-300) — per-subscription reads satisfier.SatisfyOrIssueOnDemandGetAsync / SatisfyOrIssueOnDemandQueryAsync On-demand

Result delivery still marshals mutations to the UI thread via EntityBroker.UiMarshal (EntityBroker.cs:46-70, 288-295, 401, 425, 863-864); the satisfier only produces the raw GetResult/QueryResult/GetChangedEntitiesResult and lets the existing Upsert… / ApplyPendingSnapshotUpdates paths dispatch. No change to UI-marshaling contracts.

Interaction with #1326

#1326 tracks handling of OnRefreshTick failures. The satisfier introduced here changes only how background queries are issued (and coordinated), not error propagation, so #1326 remains orthogonal — a failed satisfier query still faults its awaiter's Task, and OnRefreshTick's async void still needs the try/catch #1326 will add. Cross-referenced but not blocking.

Expected Tests

Tests split across two files: dedicated satisfier unit tests that construct EntityBrokerQuerySatisfier directly against a fake executor, and EntityBroker integration tests that confirm the broker routes periodic vs on-demand correctly.

Satisfier unit tests

New file: features\Phantom.Workspaces.Tests\EntityBrokerQuerySatisfierTests.cs. Each test constructs an EntityBrokerQuerySatisfier directly against a fake data-access layer (or fake query-executor delegate) that:

  • Counts the number of underlying GetAsync / QueryAsync / GetChangedEntitiesAsync calls per key.
  • Gates each call on a per-key TaskCompletionSource<TResult> so tests can hold queries mid-flight, release them in a deterministic order, and observe concurrency.
  • Uses SemaphoreSlim counters to assert "at most one concurrent underlying periodic call" and "more than one concurrent on-demand call".

Tests are plain xUnit [Fact] (no [AvaloniaFact] — the satisfier has no UI dependency) and follow the existing Subject_Scenario_ExpectedOutcome PascalCase convention. Assertions use Assert.* matching EntityBrokerTests.cs. TestContext.Current.CancellationToken is used for each test's outer token. Subject prefix: EntityBrokerQuerySatisfier_.

Satisfaction via query analysis (issuance-time)

Test Verifies
EntityBrokerQuerySatisfier_MultiIdGetSupersetSatisfiesSubsetGet_NoSecondCall Broad GetAsync({a,b,c}) in flight; narrow GetAsync({b}) issued → issuance-time analysis puts the broad in narrow's IssuanceTimeSubsumers; zero additional underlying calls; narrow completes with b slice.
EntityBrokerQuerySatisfier_FullScopeGetChangedSatisfiesNarrowGet_NoSecondCall Full-scope GetChangedEntitiesAsync covering {a,b,c,…} in flight; narrow GetAsync({x}) where x is covered → issuance-time analysis; zero additional GetAsync; narrow resolves from the GetChangedEntitiesResult slice.
EntityBrokerQuerySatisfier_NarrowGetWithIdNotCoveredByBroad_IssuesOwnCall Broad covers {a,b,c}; narrow GetAsync({z}) → no issuance-time subsumer; narrow issues its own call.
EntityBrokerQuerySatisfier_QueryRequestNotIssuanceTimeSubsumingNarrowGet Narrow GetAsync({x}) while a QueryRequest (unknown result id-set) is in flight → narrow's IssuanceTimeSubsumers does not include the query; narrow issues its own call unless result-time subsumption later applies.
EntityBrokerQuerySatisfier_IdenticalQueryRequestsDoNotSubsumeByAnalysis_TheyDedup Two identical QueryRequests → they coalesce via dedup, not via subsumption (verifies dedup path is distinct from the subsumer lists).

Satisfaction via results (result-time)

Test Verifies
EntityBrokerQuerySatisfier_BroadQueryResultsCoverNarrowGetId_ResultTimeSubsumes Narrow GetAsync({X}) and broad QueryRequest in flight simultaneously; broad completes first with a result set that includes X → narrow's ResultTimeSubsumers gains the broad; narrow completes from broad's sliced result; no underlying GetAsync({X}) was issued.
EntityBrokerQuerySatisfier_BroadQueryResultsDoNotCoverNarrowGetId_NarrowIssuesFallthrough Narrow GetAsync({X}) and broad QueryRequest; broad completes without X → ResultTimeSubsumers unchanged; narrow issues its own GetAsync({X}).
EntityBrokerQuerySatisfier_IssuanceAndResultTimeListsMaintainedSeparately For a narrow B: (a) a broad A1 already in flight is added to B's IssuanceTimeSubsumers at B's issuance; (b) a later broad A2 whose results cover B is added to ResultTimeSubsumers only; both lists observed to hold their own entries and drive completion independently.
EntityBrokerQuerySatisfier_ResultTimeSubsumerCompletesAfterIssuanceTimeSubsumerFaults Issuance-time subsumer A1 faults; result-time subsumer A2 subsequently completes with covering results → narrow B completes from A2, not from A1's fault.

Dedup

Test Verifies
EntityBrokerQuerySatisfier_TwoConcurrentIdenticalGetRequests_IssuesOneUnderlyingCall Two SatisfyOrIssueOnDemandGetAsync with equal-content requests → exactly one underlying call; both awaiters observe the same result.
EntityBrokerQuerySatisfier_TwoConcurrentIdenticalPeriodicRequests_IssuesOneUnderlyingCall Same, but both on the periodic path.
EntityBrokerQuerySatisfier_DuplicateAfterFirstCompletes_IssuesFreshUnderlyingCall Duplicate issued after the first completed and was removed from pendingByKey → a second underlying call is issued (no stale caching).
EntityBrokerQuerySatisfier_DedupKeyByValueNotIdentity Two distinct GetRequest instances with equal content coalesce (single call).
EntityBrokerQuerySatisfier_TwoRequestsDifferingByOneField_DoNotCoalesce Requests differing only in a single EntityIdTimestamps element or Entities[0].EntityId do not coalesce.

On-demand parallelism

Test Verifies
EntityBrokerQuerySatisfier_ThreeDistinctOnDemandQueries_ExecuteConcurrently Three distinct-key on-demand queries issued against a gated executor → the fake observes concurrency > 1 (specifically all three in flight simultaneously); no throttle applied.
EntityBrokerQuerySatisfier_OnDemandQueryDoesNotAcquirePeriodicSlot While a periodic query holds periodicSlot, an on-demand query still starts immediately (does not block on the slot).

Periodic throttle

Test Verifies
EntityBrokerQuerySatisfier_ThreeDistinctPeriodicQueries_ObserveAtMostOneConcurrentCall Three distinct-key periodic queries → underlying fake never observes more than one concurrent call.
EntityBrokerQuerySatisfier_ThreeDistinctPeriodicQueries_CompleteInFifoOrder Same setup → awaiter completions occur in enqueue order.
EntityBrokerQuerySatisfier_FifoPreservedAfterSubsumptionSkip Middle periodic query is skip-refreshed via subsumption → head and tail drain in original enqueue order.

Mixed

Test Verifies
EntityBrokerQuerySatisfier_PeriodicHoldingSlot_OnDemandQueriesRunConcurrentlyBesideIt Periodic Q0 stuck in flight; on-demand N1, N2, N3 issued → all three on-demand execute concurrently in parallel with Q0; the periodic slot is not shared with the on-demand path.
EntityBrokerQuerySatisfier_OnDemandDoesNotStarvePeriodic_And_ViceVersa Sustained on-demand load does not prevent periodic queries from acquiring periodicSlot; sustained periodic load does not block on-demand issuance.

Cross-trigger (on-demand → periodic immediate refresh)

Test Verifies
EntityBrokerQuerySatisfier_OnDemandGetSatisfyingPendingPeriodicGet_ImmediatelyRefreshesPeriodic Periodic B GetAsync({b}) is pending (queued or in flight); on-demand A GetAsync({a,b,c}) issued → on completion of A, B's subscriber-notification fires with A's b slice before the next periodic tick; B's periodic cycle records this as its most-recent refresh; no separate underlying GetAsync({b}).
EntityBrokerQuerySatisfier_OnDemandGetChangedSatisfyingPeriodicQuery_ImmediatelyRefreshesQuery On-demand GetChangedEntitiesAsync covering the periodic query's known id-set → cross-trigger fires; periodic query does not issue its own call this cycle.
EntityBrokerQuerySatisfier_ResultTimeCrossTrigger_OnDemandQueryResultsCoverPeriodicGet On-demand QueryRequest (issuance-time not analyzable) completes with results covering periodic B's id → result-time cross-trigger fires; B refreshed from A.
EntityBrokerQuerySatisfier_CrossTriggerDoesNotFireForNonSatisfyingOnDemand On-demand A whose coverage does not include B's id → no cross-trigger; B still scheduled normally.

Skip-refresh

Test Verifies
EntityBrokerQuerySatisfier_PeriodicDueForRefreshWhileSubsumerLive_SkipsAndIsServedBySubsumer Broad periodic A live; subsumed periodic B reaches its refresh slot → B does not execute; B's subscriber refresh is served from A's most recent (or in-flight) result; the periodic slot is released without a wire call for B.
EntityBrokerQuerySatisfier_PeriodicWithBothListsPopulated_SkipsRefresh B has entries in both IssuanceTimeSubsumers and ResultTimeSubsumers → skip-refresh applies (either list alone is sufficient).
EntityBrokerQuerySatisfier_PreviouslySubsumedPeriodicResumesRefreshing_AfterSubsumersGone B was skip-refreshed while A was live; A completes and no other satisfying query remains → B resumes normal periodic refresh on its next slot (issues its own call).
EntityBrokerQuerySatisfier_SubsumerFaultsDuringBRefreshCycle_BFallsThroughToOwnCall A faults while B was relying on it for the current cycle → B issues its own periodic call (single-hop fallthrough, not a loop).

Cancellation / faults / lifecycle

Test Verifies
EntityBrokerQuerySatisfier_PerCallTokenCancelledBeforeStart_CancelsOnlyThatAwaiter Q1 held in flight; Q2's token cancelled before Q2 starts → Q2's task is cancelled; unrelated Q3 still runs after Q1 completes.
EntityBrokerQuerySatisfier_OneOfTwoDedupedAwaitersCancelled_UnderlyingCallStillCompletes Two callers dedup on same key; one cancels → that caller's task is cancelled; the shared underlying call continues; the other caller receives the result.
EntityBrokerQuerySatisfier_AllDedupedAwaitersCancelled_UnderlyingCallLinkedTokenIsCancelled Both callers dedup and cancel → underlying call observes cancellation on its linked token (last-awaiter-cancels contract).
EntityBrokerQuerySatisfier_UnderlyingCallThrows_FaultsAwaiterAndPeriodicLoopContinues Gated executor throws for Q1 (periodic) → Q1's awaiter observes the exception; Q2 (next in periodic queue) still runs to completion; periodic loop is not terminated.
EntityBrokerQuerySatisfier_UnderlyingOnDemandThrows_DoesNotAffectOtherOnDemand On-demand A throws → other in-flight on-demand queries unaffected; parallel path isolates faults.
EntityBrokerQuerySatisfier_UnderlyingBroadCallThrows_FaultsAllSliceAwaiters Broad {a,b,c} throws → broad awaiter and every subsumed narrow slice awaiter observe the same fault.
EntityBrokerQuerySatisfier_GetChangedReturnsNotChangedMarkerForSubsumedNarrowId_ResolvesFromCachedSnapshot GetChangedEntitiesResult "not-changed" marker (ChangedEntitySnapshot { Entity = null }) for the subsumed id → narrow slice awaiter resolves from the fake EntityRepository cache.
EntityBrokerQuerySatisfier_GetChangedReturnsNotChangedMarkerAndCacheMisses_FallsThroughToFreshNarrowCall Not-changed marker but no cached snapshot → slice awaiter falls through to a single-hop fresh narrow call (no retry loop).
EntityBrokerQuerySatisfier_DisposeAsync_CancelsOutstandingAwaitersAndStopsLoop Q1 queued but not started; DisposeAsync → Q1's task completes as cancelled; periodic loop exits; executor was never called.
EntityBrokerQuerySatisfier_EnqueueAfterDispose_ReturnsCanceledTask Enqueue after DisposeAsync → returned task is already cancelled; executor never called.
EntityBrokerQuerySatisfier_ManyMixedConcurrentEnqueues_BoundedUnderlyingCallsNoStuckAwaiter Soak: 200 concurrent enqueues mixing on-demand/periodic/narrow/broad/duplicates → underlying-call count ≤ distinct-non-subsumed-key count; every returned task eventually completes; on-demand path shows concurrency > 1; periodic path shows concurrency ≤ 1.

Total: ~35 satisfier unit tests grouped as above (issuance-time analysis 5, result-time 4, dedup 5, on-demand parallelism 2, periodic throttle 3, mixed 2, cross-trigger 4, skip-refresh 4, cancellation/faults/lifecycle 11).

EntityBroker integration tests

In features\Phantom.Workspaces.Tests\EntityBrokerTests.cs — verify that EntityBroker routes periodic vs on-demand correctly and that cross-trigger + skip-refresh work end-to-end. Naming follows the existing Subject_Scenario_ExpectedOutcome PascalCase pattern (e.g. CreateSubscriptionAsync_LoadsBindableEntities).

Test Scenario Expected outcome
EntityBroker_DuplicateOnDemandGet_CoalescesToSingleRequest Two GetEntitiesAsync(new[]{id}) calls issued concurrently before either completes. Exactly one underlying DataAccessLayer.GetAsync; both callers receive the same snapshot.
EntityBroker_OnDemandGet_SubsumedByPendingBroadPeriodicRefresh_DoesNotIssueSeparateRequest While RefreshAsync is issuing GetChangedEntitiesAsync covering x, caller issues GetEntitiesAsync([x]). No additional GetAsync for x; on-demand caller receives snapshot returned (or cached-fresh-marker resolved) by the GetChangedEntitiesResult.
EntityBroker_PeriodicQueries_ExecuteOneAtATime_WhileOnDemandRunInParallel Two periodic per-subscription refreshes and three on-demand GetEntitiesAsync all live at once against a gated fake IDataAccessLayer. Periodic side observes at-most-one concurrent underlying call; on-demand side observes concurrency > 1.
EntityBroker_OnDemandGetSatisfyingPeriodicSubscription_ImmediatelyRefreshesSubscription SubscribedGet for {b} is due for periodic refresh; user action triggers GetEntitiesAsync({a,b,c}) (on-demand). On-demand call fires; the SubscribedGet's bindable object is refreshed from the on-demand result before the next 3s tick; the periodic path does not separately round-trip {b} this cycle.
EntityBroker_SubsumedSubscriptionSkipsPeriodicRefresh_WhileBroaderQueryInFlight A broader periodic query (e.g. full-scope GetChangedEntitiesAsync) is in flight; a narrower SubscribedGet is scheduled for the same tick. The narrower subscription's periodic refresh is skipped; it is served from the broader query's results; underlying-call count for the narrow subscription this cycle is zero.

Considered / Background

  • Do nothing / rely on server-side caching — rejected: the owner explicitly calls out unbounded background query concurrency; a server cache does not eliminate wire round-trips or fan-out cost.
  • Per-subscription throttle only — rejected: does not address dedup across independent call sites nor subsumption between narrow and broad requests.
  • Global SemaphoreSlim(1) around every data-access call, no queue registry — rejected: enforces a throttle but delivers neither dedup nor subsumption; identical queries still round-trip; and throttling on-demand queries harms interactive latency.
  • Merge narrow requests into the next broad refresh — considered as a follow-up (batch narrow reads until the next tick issues a broad get-changed). Can be layered on top of the satisfier later.
  • Superseded design: single FIFO coalescing scheduler (max-concurrency 1 for ALL background queries) — the previous iteration of this issue proposed an EntityBrokerQueryScheduler funnelling every background call (periodic and on-demand) through a single FIFO channel with SemaphoreSlim(1), dedup + bidirectional subsumption between queued/in-flight queries, and slice-awaiter completion. Superseded by the satisfier model above: the owner now wants only periodic queries throttled to one-at-a-time, on-demand queries running in parallel, two separate subsuming lists (issuance-time vs result-time) maintained per pending query, cross-trigger from on-demand to periodic, and skip-refresh for subsumed periodic queries. The single-FIFO model is retained here for historical context and to make the delta from the previous design explicit.

Metadata

Metadata

Labels

diagnosedRoot cause identifiedenhancementNew feature or requestverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions