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:
- 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.
- 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).
- 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.
- 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.
- 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 trigger — MainWindowViewModel.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 refresh — RefreshSubscribedGetsAsync / RefreshSubscribedQueriesAsync (EntityBroker.cs:746-822) iterate every live subscription and call subscribedGet.RefreshAsync / subscribedQuery.RefreshAsync, each of which fires its own Task.Run → GetAsync / 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:
- 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).
- 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:
- 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).
- 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.
Summary
EntityBrokercurrently issues its background data-access queries (GetChangedEntitiesAsyncduring the 3-secondOnRefreshTick, per-subscriptionGetAsync/QueryAsyncrefreshes, on-demandLoadSnapshotsAsync, and per-UpdateAsyncrefresh fan-outs) with no deduplication, no subsumption, no concurrency policy, and no coordination between on-demand and periodic work. Every liveSubscribedGet/SubscribedQueryrefresh spawns its ownTask.Run→ data-access call with unbounded overlap acrossRefreshAsync/UpdateAsync/SubscribeXxxAsyncinvocations, and identical queries issued from independent call sites each round-trip separately.Introduce an
EntityBrokerQuerySatisfier(internal, owned byEntityBroker) whose job is to minimize redundant data-access round-trips by letting one executed query satisfy (complete) other queries it covers. Specifically:GetChangedEntitiesAsyncand per-subscriptionGetAsync/QueryAsyncrefreshes) are throttled to one at a time and subject to satisfaction.Root Cause / Current Behavior
All background query issuance in
EntityBrokergoes straight throughTask.Run(() => this.entityRepository.DataAccessLayer.<Call>Async(...))with no intervening satisfier. Distinguishing periodic vs on-demand sites:Periodic sites (driven by
OnRefreshTick, 3s cadence)Tick trigger —
MainWindowViewModel.OnRefreshTick(features\Phantom.Workspaces\ViewModels\MainWindowViewModel.cs:1764-1769, timer at:171) fires every 3s and callsEntityBroker.RefreshAsync():EntityBroker.RefreshAsync(features\Phantom.Workspaces\EntityBroker.cs:327-380) issues an unthrottled full-scopeGetChangedEntitiesAsync, then sequentially awaits per-subscription refreshes:Per-subscription refresh —
RefreshSubscribedGetsAsync/RefreshSubscribedQueriesAsync(EntityBroker.cs:746-822) iterate every live subscription and callsubscribedGet.RefreshAsync/subscribedQuery.RefreshAsync, each of which fires its ownTask.Run→GetAsync/QueryAsync:On-demand sites
LoadSnapshotsAsync(EntityBroker.cs:430-453) — invoked byGetEntitiesAsyncand subscription warm-up;Task.Runs aGetAsyncfor the requested set with no dedup against any in-flight tick refresh or another concurrentLoadSnapshotsAsync:UpdateAsyncpost-update fan-out (EntityBroker.cs:233-311) — after applying an update, always callsRefreshSubscribedGetsAsync+RefreshSubscribedQueriesAsync(:299-300), each of which spawns per-subscriptionTask.Runreads. 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
GetChangedEntitiesAsyncper tick already covers every id, but per-subscriptionGetAsync/QueryAsynccalls still hit the wire.UpdateAsyncfan-out and a concurrent tick refresh interleave freely.SemaphoreSlim,Channel.Create,Coalesc,Throttl,Batcher,Debounceinfeatures\Phantom.Workspacesfound only unrelated uses (WorkspacesTransportHost.cs,AgentSessionWorkspaceTabViewModel.cs,GitWorktreeWatcher.cs).Existing dedup state is limited to the
subscribedGets/subscribedQueriesweak-reference dictionaries keyed onJsonSerializer.Serialize(request)(EntityBroker.cs:18-19, 144, 180), which dedup subscription registrations but not the background data-access calls they issue.Affected Files
features\Phantom.Workspaces\EntityBroker.csfeatures\Phantom.Workspaces\EntityBrokerQuerySatisfier.cs(new)features\Phantom.Workspaces\ViewModels\MainWindowViewModel.csOnRefreshTick(line 1764) drives periodic queries; no behavioral change but relevant for tests.features\Phantom.Workspaces.Tests\EntityBrokerQuerySatisfierTests.cs(new)features\Phantom.Workspaces.Tests\EntityBrokerTests.csEntityBrokerroutes periodic vs on-demand correctly through the satisfier.Design / Fix
Introduce a new
EntityBrokerQuerySatisfier(internal, owned byEntityBroker) that funnels every background data-access call through a satisfaction-aware entry point.EntityBroker.RefreshAsync,RefreshSubscribedGetsAsync,RefreshSubscribedQueriesAsync,LoadSnapshotsAsync, andUpdateAsync's post-update fan-out delegate to it instead of callingTask.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
Satisfaction relation (how A satisfies B)
A
PendingQueryA 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:GetRequestfor id-setS_B; A is aGetRequestfor id-setS_AwithS_B ⊆ S_A(strict-superset in coverage sense: A also covers extra ids or B has fewer ids than A) → A satisfies B.GetRequestfor id X; A is a full-scopeGetChangedEntitiesRequestwhoseEntityIdTimestampscover 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).QueryRequestand B is aGetRequestfor 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.QueryRequest's predicate provably containing anotherQueryRequest's predicate is a possible future extension (not required in v1; call sites that use identicalQueryRequests coalesce via dedup anyway).GetRequestfor id X; A is any executed query whose result id-set includes X → A satisfies B. Applies especially to broadQueryRequests whose result contents happen to cover a narrow GET even though issuance-time analysis could not prove it.GetRequestforS_B; A's result id-set coversS_Bin full → A satisfies B.Both lists are updated separately per B:
IssuanceTimeSubsumersis populated once (at B's issuance) by scanningallPendingfor candidate A's whose request shapes cover B;ResultTimeSubsumersis 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:
GetChangedEntitiesResult"not-changed" marker on a subsumed id, resolve from theEntityRepositorycache (see below).B does not issue its own data-access call in any of these cases.
Execution classes
On-demand queries (
SatisfyOrIssueOnDemand…) — invoked byLoadSnapshotsAsync, explicitGetEntitiesAsync, andUpdateAsyncpost-update reads. These run in parallel with all other on-demand queries — no throttle. They are still satisfaction-aware:periodicSlotacquired.Periodic queries (
SatisfyOrEnqueuePeriodic…) — invoked byRefreshAsync(GetChangedEntitiesAsync) and per-subscription refreshes. These are throttled to one at a time viaperiodicSlot(single-readerChannel+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
allPendingfor 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:IssuanceTimeSubsumersORResultTimeSubsumerslist 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.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 atEntityBroker.cs:144, 180).pendingByKeylookup at every satisfier entry point:PendingQuery, computeIssuanceTimeSubsumersby scanningallPending, register in both dictionaries; on-demand path issues immediately, periodic path writes toperiodicQueue.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 returnedTask<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
NotChangedWhen a
GetChangedEntitiesResultreturns a "not-changed" marker (ChangedEntitySnapshot.Entity == null) for a subsumed narrow id, the slice awaiter resolves itsGetResultby pulling the corresponding entity's cached snapshot fromEntityRepository. If not present in cache, the slice awaiter falls through to enqueue a fresh narrowSatisfyOrIssueOnDemandGetAsync(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.DisposeAsynccompletes the periodic channel writer, awaits the periodic loop, cancels every still-pending TCS (direct + slice + cross-trigger) withTaskCanceledException, and disposesperiodicSlot. Enqueue calls issued after disposal return a canceledTask<TResult>(matches the existingEntityBrokercancellation contract).Delegation from EntityBroker
Each existing
Task.Run(() => DataAccessLayer.XxxAsync(...))becomes a satisfier call, tagged with its class:RefreshAsync(:341)GetChangedEntitiesAsyncsatisfier.SatisfyOrEnqueuePeriodicGetChangedAsync(...)GetSubscribedEntitiesForGetRequestAsync(:388) when called fromRefreshSubscribedGetsAsyncvia ticksatisfier.SatisfyOrEnqueuePeriodicGetAsync(...)GetSubscribedEntitiesForQueryRequestAsync(:412) when called fromRefreshSubscribedQueriesAsyncvia ticksatisfier.SatisfyOrEnqueuePeriodicQueryAsync(...)LoadSnapshotsAsync(:440)satisfier.SatisfyOrIssueOnDemandGetAsync(...)UpdateAsyncwrite (:238)satisfier.RunUpdateAsync(...)UpdateAsyncpost-update fan-out (:299-300) — per-subscription readssatisfier.SatisfyOrIssueOnDemandGetAsync/SatisfyOrIssueOnDemandQueryAsyncResult 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 rawGetResult/QueryResult/GetChangedEntitiesResultand lets the existingUpsert…/ApplyPendingSnapshotUpdatespaths dispatch. No change to UI-marshaling contracts.Interaction with #1326
#1326 tracks handling of
OnRefreshTickfailures. 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'sTask, andOnRefreshTick'sasync voidstill 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
EntityBrokerQuerySatisfierdirectly 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 anEntityBrokerQuerySatisfierdirectly against a fake data-access layer (or fake query-executor delegate) that:GetAsync/QueryAsync/GetChangedEntitiesAsynccalls per key.TaskCompletionSource<TResult>so tests can hold queries mid-flight, release them in a deterministic order, and observe concurrency.SemaphoreSlimcounters 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 existingSubject_Scenario_ExpectedOutcomePascalCase convention. Assertions useAssert.*matchingEntityBrokerTests.cs.TestContext.Current.CancellationTokenis used for each test's outer token. Subject prefix:EntityBrokerQuerySatisfier_.Satisfaction via query analysis (issuance-time)
EntityBrokerQuerySatisfier_MultiIdGetSupersetSatisfiesSubsetGet_NoSecondCallGetAsync({a,b,c})in flight; narrowGetAsync({b})issued → issuance-time analysis puts the broad in narrow'sIssuanceTimeSubsumers; zero additional underlying calls; narrow completes withbslice.EntityBrokerQuerySatisfier_FullScopeGetChangedSatisfiesNarrowGet_NoSecondCallGetChangedEntitiesAsynccovering{a,b,c,…}in flight; narrowGetAsync({x})wherexis covered → issuance-time analysis; zero additionalGetAsync; narrow resolves from theGetChangedEntitiesResultslice.EntityBrokerQuerySatisfier_NarrowGetWithIdNotCoveredByBroad_IssuesOwnCall{a,b,c}; narrowGetAsync({z})→ no issuance-time subsumer; narrow issues its own call.EntityBrokerQuerySatisfier_QueryRequestNotIssuanceTimeSubsumingNarrowGetGetAsync({x})while aQueryRequest(unknown result id-set) is in flight → narrow'sIssuanceTimeSubsumersdoes not include the query; narrow issues its own call unless result-time subsumption later applies.EntityBrokerQuerySatisfier_IdenticalQueryRequestsDoNotSubsumeByAnalysis_TheyDedupQueryRequests → they coalesce via dedup, not via subsumption (verifies dedup path is distinct from the subsumer lists).Satisfaction via results (result-time)
EntityBrokerQuerySatisfier_BroadQueryResultsCoverNarrowGetId_ResultTimeSubsumesGetAsync({X})and broadQueryRequestin flight simultaneously; broad completes first with a result set that includes X → narrow'sResultTimeSubsumersgains the broad; narrow completes from broad's sliced result; no underlyingGetAsync({X})was issued.EntityBrokerQuerySatisfier_BroadQueryResultsDoNotCoverNarrowGetId_NarrowIssuesFallthroughGetAsync({X})and broadQueryRequest; broad completes without X →ResultTimeSubsumersunchanged; narrow issues its ownGetAsync({X}).EntityBrokerQuerySatisfier_IssuanceAndResultTimeListsMaintainedSeparatelyIssuanceTimeSubsumersat B's issuance; (b) a later broad A2 whose results cover B is added toResultTimeSubsumersonly; both lists observed to hold their own entries and drive completion independently.EntityBrokerQuerySatisfier_ResultTimeSubsumerCompletesAfterIssuanceTimeSubsumerFaultsDedup
EntityBrokerQuerySatisfier_TwoConcurrentIdenticalGetRequests_IssuesOneUnderlyingCallSatisfyOrIssueOnDemandGetAsyncwith equal-content requests → exactly one underlying call; both awaiters observe the same result.EntityBrokerQuerySatisfier_TwoConcurrentIdenticalPeriodicRequests_IssuesOneUnderlyingCallEntityBrokerQuerySatisfier_DuplicateAfterFirstCompletes_IssuesFreshUnderlyingCallpendingByKey→ a second underlying call is issued (no stale caching).EntityBrokerQuerySatisfier_DedupKeyByValueNotIdentityGetRequestinstances with equal content coalesce (single call).EntityBrokerQuerySatisfier_TwoRequestsDifferingByOneField_DoNotCoalesceEntityIdTimestampselement orEntities[0].EntityIddo not coalesce.On-demand parallelism
EntityBrokerQuerySatisfier_ThreeDistinctOnDemandQueries_ExecuteConcurrentlyEntityBrokerQuerySatisfier_OnDemandQueryDoesNotAcquirePeriodicSlotperiodicSlot, an on-demand query still starts immediately (does not block on the slot).Periodic throttle
EntityBrokerQuerySatisfier_ThreeDistinctPeriodicQueries_ObserveAtMostOneConcurrentCallEntityBrokerQuerySatisfier_ThreeDistinctPeriodicQueries_CompleteInFifoOrderEntityBrokerQuerySatisfier_FifoPreservedAfterSubsumptionSkipMixed
EntityBrokerQuerySatisfier_PeriodicHoldingSlot_OnDemandQueriesRunConcurrentlyBesideItEntityBrokerQuerySatisfier_OnDemandDoesNotStarvePeriodic_And_ViceVersaperiodicSlot; sustained periodic load does not block on-demand issuance.Cross-trigger (on-demand → periodic immediate refresh)
EntityBrokerQuerySatisfier_OnDemandGetSatisfyingPendingPeriodicGet_ImmediatelyRefreshesPeriodicGetAsync({b})is pending (queued or in flight); on-demand AGetAsync({a,b,c})issued → on completion of A, B's subscriber-notification fires with A'sbslice before the next periodic tick; B's periodic cycle records this as its most-recent refresh; no separate underlyingGetAsync({b}).EntityBrokerQuerySatisfier_OnDemandGetChangedSatisfyingPeriodicQuery_ImmediatelyRefreshesQueryGetChangedEntitiesAsynccovering the periodic query's known id-set → cross-trigger fires; periodic query does not issue its own call this cycle.EntityBrokerQuerySatisfier_ResultTimeCrossTrigger_OnDemandQueryResultsCoverPeriodicGetQueryRequest(issuance-time not analyzable) completes with results covering periodic B's id → result-time cross-trigger fires; B refreshed from A.EntityBrokerQuerySatisfier_CrossTriggerDoesNotFireForNonSatisfyingOnDemandSkip-refresh
EntityBrokerQuerySatisfier_PeriodicDueForRefreshWhileSubsumerLive_SkipsAndIsServedBySubsumerEntityBrokerQuerySatisfier_PeriodicWithBothListsPopulated_SkipsRefreshIssuanceTimeSubsumersandResultTimeSubsumers→ skip-refresh applies (either list alone is sufficient).EntityBrokerQuerySatisfier_PreviouslySubsumedPeriodicResumesRefreshing_AfterSubsumersGoneEntityBrokerQuerySatisfier_SubsumerFaultsDuringBRefreshCycle_BFallsThroughToOwnCallCancellation / faults / lifecycle
EntityBrokerQuerySatisfier_PerCallTokenCancelledBeforeStart_CancelsOnlyThatAwaiterEntityBrokerQuerySatisfier_OneOfTwoDedupedAwaitersCancelled_UnderlyingCallStillCompletesEntityBrokerQuerySatisfier_AllDedupedAwaitersCancelled_UnderlyingCallLinkedTokenIsCancelledEntityBrokerQuerySatisfier_UnderlyingCallThrows_FaultsAwaiterAndPeriodicLoopContinuesEntityBrokerQuerySatisfier_UnderlyingOnDemandThrows_DoesNotAffectOtherOnDemandEntityBrokerQuerySatisfier_UnderlyingBroadCallThrows_FaultsAllSliceAwaiters{a,b,c}throws → broad awaiter and every subsumed narrow slice awaiter observe the same fault.EntityBrokerQuerySatisfier_GetChangedReturnsNotChangedMarkerForSubsumedNarrowId_ResolvesFromCachedSnapshotGetChangedEntitiesResult"not-changed" marker (ChangedEntitySnapshot { Entity = null }) for the subsumed id → narrow slice awaiter resolves from the fakeEntityRepositorycache.EntityBrokerQuerySatisfier_GetChangedReturnsNotChangedMarkerAndCacheMisses_FallsThroughToFreshNarrowCallEntityBrokerQuerySatisfier_DisposeAsync_CancelsOutstandingAwaitersAndStopsLoopDisposeAsync→ Q1's task completes as cancelled; periodic loop exits; executor was never called.EntityBrokerQuerySatisfier_EnqueueAfterDispose_ReturnsCanceledTaskDisposeAsync→ returned task is already cancelled; executor never called.EntityBrokerQuerySatisfier_ManyMixedConcurrentEnqueues_BoundedUnderlyingCallsNoStuckAwaiterTotal: ~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 thatEntityBrokerroutes periodic vs on-demand correctly and that cross-trigger + skip-refresh work end-to-end. Naming follows the existingSubject_Scenario_ExpectedOutcomePascalCase pattern (e.g.CreateSubscriptionAsync_LoadsBindableEntities).EntityBroker_DuplicateOnDemandGet_CoalescesToSingleRequestGetEntitiesAsync(new[]{id})calls issued concurrently before either completes.DataAccessLayer.GetAsync; both callers receive the same snapshot.EntityBroker_OnDemandGet_SubsumedByPendingBroadPeriodicRefresh_DoesNotIssueSeparateRequestRefreshAsyncis issuingGetChangedEntitiesAsynccoveringx, caller issuesGetEntitiesAsync([x]).GetAsyncforx; on-demand caller receives snapshot returned (or cached-fresh-marker resolved) by theGetChangedEntitiesResult.EntityBroker_PeriodicQueries_ExecuteOneAtATime_WhileOnDemandRunInParallelGetEntitiesAsyncall live at once against a gated fakeIDataAccessLayer.EntityBroker_OnDemandGetSatisfyingPeriodicSubscription_ImmediatelyRefreshesSubscriptionSubscribedGetfor{b}is due for periodic refresh; user action triggersGetEntitiesAsync({a,b,c})(on-demand).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_WhileBroaderQueryInFlightGetChangedEntitiesAsync) is in flight; a narrowerSubscribedGetis scheduled for the same tick.Considered / Background
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.EntityBrokerQuerySchedulerfunnelling every background call (periodic and on-demand) through a single FIFO channel withSemaphoreSlim(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.