Conversation
|
P1: Bulk removal introduces quadratic processing. Location: Each removal walks the surviving suffix and rewrites its list positions and dictionary entries. Clearing a cache in insertion order therefore performs a quadratic number of index updates. Measured with
These are local measurements, not a statistical benchmark, but the suffix-update loop explains the scaling. A single synchronous cache clear now blocks for seconds at ordinary collection sizes. Suggested fix: avoid maintaining every absolute index after each individual removal. Batch position maintenance for the changeset or use an ordered key index with efficient rank lookup and removal. Preserve the individual output reasons and their sequential indexes rather than changing the public output to a |
|
P1: Partial streams can remove the wrong list item. Location: The inferred index assumes this subscription has observed the complete collection. A Reproduced against the PR and its base commit:
The PR leaves Suggested fix: distinguish complete-history streams from partial streams before inferring absolute positions. Preserve unspecified indexes when completeness is not established, while retaining keyed positions for complete snapshots. The generic changeset stream does not identify whether its first batch is a snapshot, so this needs an explicit contract or separate path rather than another heuristic based on the first observed change. Add a regression covering a pre-populated target, a late |
|
Follow-up on the design, separate from the two defect comments above. The contract question I raised (complete stream versus partial history) has an answer that simplifies this considerably: Under that contract, the operator needs only enough state to translate key identity into list position. Adds establish positions, updates/removes/refreshes locate the occurrence by key, moves adjust it. Equal values stay distinguishable because the key is retained internally until the outgoing change has an index. No value storage is duplicated, and no inference is required. That means:
Net: a small complete-history adapter that maintains ordered keys and positions, with a structure chosen so structural edits are not O(n) each. Deciding the contract first is what shrinks the implementation. |
Problem
RemoveKey()projected each cache change straight into a list change with no memory of where items lived. Equal-valued items, or multiple references to the same object, collapsed into each other because downstream list consumers had to fall back on item equality to locate an entry. Removals and refreshes for keys whose position had never been supplied produced unusable indexes, corrupting any bound list.Fixes #1182
Fix
RemoveKey()now tracks positions by cache key, per subscription, before discarding the key from the output:List<ItemWithIndex<TKey>>holds only the positions actually observed, with aDictionary<TKey, int>mirror so refresh lookups are O(1) instead of scanning.keys.Countonly while the observed history is complete.Because positions key off the cache key, two entries with equal values (or the same object reference added twice under different keys) remain distinct, which is the core of the reported bug.
Tradeoff
This replaces a stateless
Selectprojection with per-subscription state: one list entry and one dictionary entry per observed key, allocated on subscribe rather than per changeset. In exchange the per-changeset path allocates a single right-sizedChangeSet<TObject>(changes.Count + changes.Updates) instead of walking an enumerator, and refresh lookups drop from a linear scan to a dictionary probe. For the pathological equal-value cases that motivated the issue, the previous code was not merely slower, it was wrong. The memory cost scales with distinct keys observed, which is bounded by the cache the operator is reading from.Validation
Covered by focused fixtures added alongside the fix:
RemoveKeyFixture.Identity.csexercises distinct positioning for equal values and shared references, andRemoveKeyFixture.Compatibility.cspins the existing Update/Refresh/Moved change-reason shapes and partial-stream behavior so the change is verified non-breaking for current consumers. Documentation for the operator's change-reason table was updated in the cache and list instruction files.