Skip to content

Commit 4cbcb92

Browse files
committed
Skip tree walk for content-only updates during streaming
During token streaming, every tree update triggered a full O(n) tree walk even though the tree structure hadn't changed - only a single message's content was updated. With conversations of hundreds of messages and tokens arriving at high frequency, this became the dominant cost on the hot path. The Tree now exposes a structuralVersion counter (on TreeInternal) that increments on insert, delete, and serial promotion - but not on content-only upsert of an existing node. The View compares this counter against its last-seen version: when unchanged, it takes a fast path that compares cached message references against the snapshot in O(visible_count) instead of re-walking the tree. A monotonic counter was chosen over alternatives (tagged events, dirty sets, boolean flags) because it requires no public API change, is multi-View safe (each View tracks its own last-seen version), and cannot produce false negatives.
1 parent 8262244 commit 4cbcb92

File tree

5 files changed

+88
-15
lines changed

5 files changed

+88
-15
lines changed

docs/internals/conversation-tree.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ Note that serial order is not necessarily delivery order - messages published co
1616
## Data structures
1717

1818
```
19-
_nodeIndex: Map<msgId, InternalNode> Primary index
20-
_sortedList: InternalNode[] All nodes, sorted by serial
21-
_parentIndex: Map<parentId, Set<msgId>> Children of each parent
22-
_selections: Map<groupRootId, index> Selected sibling at each fork
19+
_nodeIndex: Map<msgId, InternalNode> Primary index
20+
_sortedList: InternalNode[] All nodes, sorted by serial
21+
_parentIndex: Map<parentId, Set<msgId>> Children of each parent
22+
_selections: Map<groupRootId, index> Selected sibling at each fork
23+
_structuralVersion: number Monotonic counter (see below)
2324
```
2425

2526
Each `MessageNode` stores:
@@ -50,6 +51,10 @@ Each `MessageNode` stores:
5051

5152
Serial promotion handles the common case where a client inserts an optimistic message (null serial), then the server publishes it to the channel (with serial). The node moves from the end of the sorted list to its correct serial-order position.
5253

54+
### Structural version
55+
56+
The tree maintains a `structuralVersion` counter (exposed via `TreeInternal`) that increments on changes affecting the `flattenNodes()` output structure - node inserts, deletions, and serial promotions (which reorder the sorted list). Content-only updates (replacing an existing node's message) do not increment the counter. The [View](message-lifecycle.md#cached-message-list) uses this to skip full tree walks during streaming - when only message content changed, the cached node list is still structurally valid.
57+
5358
## Sibling groups and fork chains
5459

5560
When a user calls `regenerate(msgId)` or `edit(msgId)`, the new message carries an [`x-ably-fork-of`](wire-protocol.md#branching-headers) header pointing to `msgId`. Messages that fork the same target (or transitively fork each other) form a **sibling group** - alternative messages at the same point in the conversation.
@@ -91,20 +96,20 @@ Sibling group resolution is cached per `flattenNodes()` call using a `resolvedGr
9196

9297
The public `Tree` interface exposes:
9398

94-
| Method | Returns |
95-
|---|---|
99+
| Method | Returns |
100+
| -------------------- | ---------------------------------------------------- |
96101
| `getSiblings(msgId)` | All messages in the sibling group containing `msgId` |
97-
| `hasSiblings(msgId)` | Whether the message has alternative versions |
98-
| `getNode(msgId)` | The `MessageNode` by msg-id |
99-
| `getHeaders(msgId)` | Headers for a specific message |
102+
| `hasSiblings(msgId)` | Whether the message has alternative versions |
103+
| `getNode(msgId)` | The `MessageNode` by msg-id |
104+
| `getHeaders(msgId)` | Headers for a specific message |
100105

101106
The following are on the `View`, not the public `Tree` interface:
102107

103-
| Method | Returns |
104-
|---|---|
105-
| `flattenNodes()` | Linear message list following selected branches |
106-
| `select(msgId, index)` | Switch to a different sibling at a fork point |
107-
| `getSelectedIndex(msgId)` | Currently selected index in the sibling group |
108+
| Method | Returns |
109+
| ------------------------- | ----------------------------------------------- |
110+
| `flattenNodes()` | Linear message list following selected branches |
111+
| `select(msgId, index)` | Switch to a different sibling at a fork point |
112+
| `getSelectedIndex(msgId)` | Currently selected index in the sibling group |
108113

109114
## Delete
110115

docs/internals/message-lifecycle.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,15 @@ The View caches the result of `flattenNodes()` in a `_cachedNodes` field. The pu
125125
| Trigger | What refreshes the cache |
126126
| ------------------------------------------------------------- | ----------------------------------------------------- |
127127
| Tree structural change (new node, deletion, serial promotion) | `_onTreeUpdate()` calls `_computeFlatNodes()` |
128+
| Content-only update (streaming token) | `_onTreeUpdate()` shallow-copies the cached array |
128129
| Branch selection change | `select()` calls `_computeFlatNodes()` |
129130
| Fork auto-selection after `send()` | `send()` auto-select path calls `_computeFlatNodes()` |
130131
| History page revealed | `_releaseWithheld()` calls `_computeFlatNodes()` |
131132

133+
### Content-only fast path
134+
135+
The tree exposes a [`structuralVersion`](conversation-tree.md#structural-version) counter that increments on insert, delete, and serial promotion - but not on content-only message updates. When `_onTreeUpdate()` sees the version unchanged, it skips the full tree walk entirely. The cached node list is still structurally valid because only a message reference changed on an existing `MessageNode`. The View compares each cached node's `.message` against the last-seen snapshot to detect which message changed, creates a new array reference (`[...cache]`) so React sees a state change, and emits `'update'`. This reduces the streaming hot path from O(total_nodes) to O(visible_count).
136+
132137
All consumers go through the cached `view.flattenNodes()`:
133138

134139
| Consumer | When it calls `flattenNodes()` |
@@ -138,6 +143,6 @@ All consumers go through the cached `view.flattenNodes()`:
138143
| `send()` / `regenerate()` | To build the HTTP POST body's message history |
139144
| `view.loadOlder()` | To snapshot the current tree state for pagination |
140145

141-
Because all consumers read the cache, a tree update triggers one tree walk (inside the View), not one per consumer. React hooks calling `flattenNodes()` after an `'update'` event get the pre-computed result without a redundant traversal.
146+
Because all consumers read the cache, a structural tree update triggers one tree walk (inside the View), not one per consumer. Content-only updates (streaming tokens) trigger zero tree walks - only a reference comparison over visible messages. React hooks calling `flattenNodes()` after an `'update'` event get the pre-computed result without a redundant traversal.
142147

143148
See [Conversation tree](conversation-tree.md) for how `flattenNodes()` works. See [Codec interface](codec-interface.md#accumulator) for the accumulator's role. See [History hydration](history.md) for the history decode pipeline.

src/core/transport/tree.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ interface InternalNode<TMessage> {
3939

4040
/** Internal tree surface used by View — not part of the public Tree API. */
4141
export interface TreeInternal<TMessage> extends Tree<TMessage> {
42+
/**
43+
* Monotonic counter that increments on structural changes (node insert,
44+
* delete, serial promotion/reorder) but NOT on content-only updates
45+
* (existing node's message replaced). Allows the View to skip full
46+
* tree walks when only message content changed.
47+
*/
48+
readonly structuralVersion: number;
49+
4250
/**
4351
* Flatten the tree along selected branches into a linear node list.
4452
* The `selections` map provides the selected sibling's msgId at each
@@ -108,6 +116,13 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
108116
/** Monotonically increasing counter for insertion sequence. */
109117
private _seqCounter = 0;
110118

119+
/** Incremented on structural changes; unchanged on content-only updates. */
120+
private _structuralVersion = 0;
121+
122+
get structuralVersion(): number {
123+
return this._structuralVersion;
124+
}
125+
111126
constructor(logger: Logger) {
112127
this._logger = logger;
113128
this._emitter = new EventEmitter<TreeEventsMap>(logger);
@@ -394,6 +409,7 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
394409
// Re-sort: remove from current position, re-insert at correct position.
395410
this._removeSorted(existing);
396411
this._insertSorted(existing);
412+
this._structuralVersion++;
397413
}
398414
this._emitter.emit('update');
399415
return;
@@ -415,6 +431,7 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
415431
this._nodeIndex.set(msgId, internal);
416432
this._addToParentIndex(parentId, msgId);
417433
this._insertSorted(internal);
434+
this._structuralVersion++;
418435
this._emitter.emit('update');
419436
}
420437

@@ -437,6 +454,7 @@ export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
437454

438455
// Children are NOT deleted — they become unreachable in flattenNodes()
439456
// because their parent is no longer on the active path.
457+
this._structuralVersion++;
440458
this._emitter.emit('update');
441459
}
442460

src/core/transport/view.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
152152
*/
153153
private _cachedNodes: MessageNode<TMessage>[] = [];
154154

155+
/** Last seen tree structural version - used to distinguish content-only from structural updates. */
156+
private _lastStructuralVersion = -1;
157+
155158
private _loadingOlder = false;
156159
private _processingHistory = false;
157160
private _closed = false;
@@ -168,6 +171,7 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
168171

169172
// Compute initial cache and snapshot visible state
170173
this._cachedNodes = this._computeFlatNodes();
174+
this._lastStructuralVersion = this._tree.structuralVersion;
171175
this._updateVisibleSnapshot(this._cachedNodes);
172176

173177
// Subscribe to tree events and re-emit scoped versions
@@ -641,6 +645,27 @@ export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
641645
// updates arriving during the async history fetch are still forwarded.
642646
if (this._processingHistory) return;
643647

648+
const currentVersion = this._tree.structuralVersion;
649+
650+
// Content-only fast path: the tree structure hasn't changed (no new
651+
// nodes, deletions, or serial reorders), so the cached node list is
652+
// still structurally valid. The tree mutated an existing node's
653+
// .message in place - check if any visible message reference changed.
654+
// JS single-threaded: structuralVersion cannot change between the
655+
// check and the response within this synchronous handler invocation.
656+
if (currentVersion === this._lastStructuralVersion) {
657+
const changed = this._cachedNodes.some((node, i) => node.message !== this._lastVisibleMessages[i]);
658+
if (changed) {
659+
this._lastVisibleMessages = this._cachedNodes.map((n) => n.message);
660+
this._cachedNodes = [...this._cachedNodes];
661+
this._emitter.emit('update');
662+
}
663+
return;
664+
}
665+
666+
// Structural update: full re-walk required.
667+
this._lastStructuralVersion = currentVersion;
668+
644669
// Pin selections for previously-visible nodes that now have siblings.
645670
// This prevents new forks (from other views' edits/regenerates) from
646671
// shifting this view to a branch the user didn't navigate to.

test/core/transport/view.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,26 @@ describe('DefaultView', () => {
11581158
expect(ref2).toBe(ref1);
11591159
});
11601160

1161+
it('does not re-walk the tree during a content-only message update', () => {
1162+
tree.upsert('m1', { id: '1', content: 'first' }, makeHeaders('m1'), 'serial-1');
1163+
tree.upsert('m2', { id: '2', content: 'second' }, makeHeaders('m2', 'turn-1'), 'serial-2');
1164+
1165+
// Capture the current cached state so the view has a baseline
1166+
view.flattenNodes();
1167+
1168+
const spy = vi.spyOn(tree, 'flattenNodes');
1169+
spy.mockClear();
1170+
1171+
// Content-only update: same msgId, different message content, no serial change
1172+
tree.upsert('m2', { id: '2', content: 'streaming token' }, makeHeaders('m2', 'turn-1'), 'serial-2');
1173+
1174+
// The view should detect this is a content-only update and skip the
1175+
// full tree walk - using the cached node list instead.
1176+
expect(spy).not.toHaveBeenCalled();
1177+
1178+
spy.mockRestore();
1179+
});
1180+
11611181
it('preserves unchanged message references after a content-only update', () => {
11621182
const msg1 = { id: '1', content: 'stable' };
11631183
const msg2 = { id: '2', content: 'will-change' };

0 commit comments

Comments
 (0)