perf(ordered-collection): back SnapshotableQueue with DoublyLinkedList#27421
perf(ordered-collection): back SnapshotableQueue with DoublyLinkedList#27421anthony-murphy wants to merge 3 commits into
Conversation
|
Hi! Thank you for opening this PR. Want me to review it? Based on the diff (61 lines, 3 files), I've queued these reviewers:
How this works
|
|
|
||
| export class SnapshotableArray<T> extends Array { | ||
| protected data: T[] = []; | ||
| export class SnapshotableArray<T> { |
There was a problem hiding this comment.
Deep Review: The class name SnapshotableArray was chosen in #1395 (vladsud, 2020-03-05) specifically because it was an Array subclass. This PR removes the extends Array — the PR description acknowledges it ("SnapshotableArray no longer extends Array — it is now a plain class") but the name stays. Both blind solution-space proposers independently expected the class to still be array-backed, so a future reader is likely to hit the same surprise.
The symbol is internal to the package (not exported via api-report; only consumer is SnapshotableQueue in consensusQueue.ts), so renaming in-PR is cheap — e.g. SnapshotableCollection or SnapshotableList. If the rename is deferred, a one-line doc comment noting the legacy name predates the linked-list backing would save the next reader from re-deriving "why is this called Array?".
| this.data = from; | ||
| for (const value of from) { | ||
| this.data.push(value); | ||
| } |
There was a problem hiding this comment.
Deep Review: asArray() changes from return this.data (alias) to materializing a fresh T[] by iterating the list; loadFrom changes from this.data = from (alias) to per-element push. Both surfaces newly depend on DoublyLinkedList iteration order matching insertion order, and that's the sole load-bearing invariant in this area — the snapshot wire format. #1395 has no inline review history on these methods and no targeted unit test locks the contract.
There is strong transitive coverage: DoublyLinkedList iteration order is unit-tested (list.spec.ts:50-57, :71-80) and the ordered-collection fuzz model compares asArray() across clients (fuzzUtils.ts:150-159, :182-196). That lowers — but does not erase — the value of a local lock.
Suggest one ~10-line unit test in this PR: loadFrom([a,b,c]) → remove() → assert asArray() returns [b,c] and size() === 2. Locks the new iteration-based snapshot contract against future DoublyLinkedList or refactor regressions, and directly substantiates the "byte-identical wire format" claim. Alternatively, if the existing fuzz consistency check (fuzzUtils.ts:150-196) already exercises a loadFrom → partial remove() → asArray() cycle after a snapshot/load, calling that out in the PR description is sufficient.
Deep ReviewReviewed commit Readiness: 7/10 — ALMOST READY Small, well-scoped perf change backing Path to Ready
Context for Reviewers
For human reviewer
Review history (2 prior reviews)
|
| assert(this.data.length === 0, 0x06b /* "Loading snapshot into a non-empty collection" */); | ||
| this.data = from; | ||
| for (const value of from) { | ||
| this.data.push(value); |
There was a problem hiding this comment.
Deep Review: Both blind solution-space proposers independently chose a head-pointer + amortized compaction scoped to SnapshotableQueue and explicitly rejected the linked-list direction, citing per-add node allocation, GC pressure, and base-class blast radius. The PR mutates the base class (protected data: DoublyLinkedList<T>) and asArray() now materializes T[] per call instead of returning a reference.
The present-tense blast radius is narrow — SnapshotableArray isn't exported, only SnapshotableQueue (and a test mirror) extends it, and consensusOrderedCollection.ts:208-216 immediately serializes the asArray() result so the end-to-end snapshot cost was already O(n). That's why this is a polish point, not a blocker.
Two ways to land cleanly:
- (a) Document the deliberate tradeoff in the new doc comment on
SnapshotableArray— strict O(1) worst-caseremoveand reuse of an in-tree primitive vs. per-addallocation and lostasArray()reference return — so the next reader sees why the head-pointer alternative was rejected. No code change required. - (b) If you'd rather keep base-class storage as
T[], push theDoublyLinkedListfield plusadd/remove/asArray/sizeoverrides down intoSnapshotableQueueso any future sibling subclass doesn't inherit a primitive it doesn't need.
Either is fine; (a) is the cheaper path if there's no near-term plan for a second subclass.
Description
Replaces the array backing
SnapshotableArray(the base class forSnapshotableQueue) withDoublyLinkedList<T>.SnapshotableArrayno longer extendsArray— it is now a plain class.add(value)→list.push(value).remove()inConsensusQueue→list.shift()!.data(O(1) instead ofArray.shift()'s O(n)).asArray()materializes from the list (preserves snapshot wire format byte-for-byte).sizedelegates tolist.length.loadFrompushes each value in order (preserves ordering).Why
SnapshotableQueue.remove()is on the hot path forConsensusOrderedCollection.acquire. Queue depth grows with backlog of unacquired items; in a job-queue scenario everyacquirepaid O(n) for the array shift. The DoublyLinkedList makes it O(1) without changing the snapshot format.Notes
Snapshot wire format is byte-identical (still a raw
T[]on disk viaasArray()).SnapshotableArrayis not exported, and no in-repo callers used Array prototype methods on it. Pure perf prep — noreSubmitSquashed, no squash logic, no tests touched, no api-report changes.