Skip to content

Commit 3e51aca

Browse files
mkotelnikovclaude
andcommitted
feat(shared-dataflow): UpdatesStore orderBy + streaming k-way URI merge
UpdatesStore.readEntries / readUpdatedEntries gain an optional `orderBy: "stamp" | "uri"` argument (new exported ReadOrderBy type). Default stays "stamp" — the temporal-replay order existing callers rely on. `"uri"` yields URI-ascending, which is the only order under which several per-signal diff streams can be merged without buffering. readUpstreamChanges rewritten to open one orderBy:"uri" iterator per upstream signal of the cell and run a streaming k-way merge by URI. Memory is now O(M) in the number of upstream signals, independent of the number of matching URIs. URI ties between upstreams still resolve to signal-declaration order (graph.getCellInputs). The iterators are closed in a finally block so early `break` doesn't leak. Tests: six new cases on InMemoryUpdatesStore covering the default vs explicit "stamp", "uri" ordering for both read methods, and the filter-independence property; one new case on readUpstreamChanges asserting non-buffering behaviour via a Proxy-spied store (reads stop when the consumer breaks). 124 → 131 tests in the package. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d466760 commit 3e51aca

6 files changed

Lines changed: 266 additions & 43 deletions

File tree

packages/shared-dataflow/src/in-memory-updates-store.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import type { Signal } from "./types.js";
2-
import type { SerializedUpdatesStore, UpdateEntry, UpdatesStore } from "./updates-store.js";
2+
import type {
3+
ReadOrderBy,
4+
SerializedUpdatesStore,
5+
UpdateEntry,
6+
UpdatesStore,
7+
} from "./updates-store.js";
8+
9+
function compareMatches(orderBy: ReadOrderBy | undefined) {
10+
if (orderBy === "uri") {
11+
return (a: [string, number], b: [string, number]) => {
12+
if (a[0] === b[0]) return 0;
13+
return a[0] < b[0] ? -1 : 1;
14+
};
15+
}
16+
return (a: [string, number], b: [string, number]) => a[1] - b[1];
17+
}
318

419
/**
520
* In-memory `UpdatesStore` reference implementation. State lives in this
@@ -59,6 +74,7 @@ export class InMemoryUpdatesStore implements UpdatesStore {
5974
signal: Signal;
6075
since: number;
6176
uriPrefix?: string;
77+
orderBy?: ReadOrderBy;
6278
}): AsyncIterable<UpdateEntry> {
6379
const inner = this.state.get(opts.signal);
6480
if (!inner) return;
@@ -69,7 +85,7 @@ export class InMemoryUpdatesStore implements UpdatesStore {
6985
matches.push([uri, stamp]);
7086
}
7187
}
72-
matches.sort((a, b) => a[1] - b[1]);
88+
matches.sort(compareMatches(opts.orderBy));
7389
for (const [uri, stamp] of matches) {
7490
yield { signal: opts.signal, uri, stamp };
7591
}
@@ -79,6 +95,7 @@ export class InMemoryUpdatesStore implements UpdatesStore {
7995
upstreamSignal: Signal;
8096
currentSignal: Signal;
8197
uriPrefix?: string;
98+
orderBy?: ReadOrderBy;
8299
}): AsyncIterable<UpdateEntry> {
83100
const upstream = this.state.get(opts.upstreamSignal);
84101
if (!upstream) return;
@@ -90,7 +107,7 @@ export class InMemoryUpdatesStore implements UpdatesStore {
90107
const currentStamp = current?.get(uri) ?? 0;
91108
if (upstreamStamp > currentStamp) matches.push([uri, upstreamStamp]);
92109
}
93-
matches.sort((a, b) => a[1] - b[1]);
110+
matches.sort(compareMatches(opts.orderBy));
94111
for (const [uri, stamp] of matches) {
95112
yield { signal: opts.upstreamSignal, uri, stamp };
96113
}

packages/shared-dataflow/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,9 @@ export type {
1414
UpdatesManagerOptions,
1515
} from "./updates-manager.js";
1616
export { UpdatesManager } from "./updates-manager.js";
17-
export type { SerializedUpdatesStore, UpdateEntry, UpdatesStore } from "./updates-store.js";
17+
export type {
18+
ReadOrderBy,
19+
SerializedUpdatesStore,
20+
UpdateEntry,
21+
UpdatesStore,
22+
} from "./updates-store.js";

packages/shared-dataflow/src/read-upstream-changes.ts

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,23 @@ import type { UpdateEntry, UpdatesStore } from "./updates-store.js";
99
* declared output (convention: cells whose primary output also serves
1010
* as their per-URI watermark).
1111
*
12-
* Opens one diff stream per upstream signal (via
13-
* `readUpdatedEntries`), merges them, and yields the combined result
14-
* sorted by URI (lexicographic ascending). When the same URI appears
15-
* in multiple upstream signals, the entries are emitted adjacently in
16-
* the order the upstream signals were declared in
17-
* `graph.getCellInputs(cellId)` — so consumers can collapse
18-
* per-URI in one pass.
12+
* Opens one URI-ordered diff stream per upstream signal (via
13+
* `readUpdatedEntries({orderBy: "uri"})`) and merges them with a
14+
* streaming k-way URI merge. Memory is O(M) where M is the number of
15+
* upstream signals — independent of the number of matching URIs.
16+
*
17+
* Output order is URI-ascending. When the same URI appears in multiple
18+
* upstream signals, the entries are emitted adjacently in the order
19+
* the upstream signals were declared in `graph.getCellInputs(cellId)`,
20+
* so consumers can collapse per-URI in one forward pass without
21+
* buffering.
1922
*
2023
* A URI updated by N upstream signals appears N times (once per
21-
* upstream entry). Use `aggregateByUri` if the consumer wants
22-
* one record per URI.
24+
* upstream entry). Use `aggregateByUri` if the consumer wants one
25+
* record per URI.
2326
*
2427
* Throws if the cell has inputs but no outputs (no watermark to compare
2528
* against). Cells with no inputs (probers) yield nothing.
26-
*
27-
* Implementation note: URI-sorted output requires buffering all
28-
* matching entries before yielding, since the per-signal streams are
29-
* stamp-ordered, not URI-ordered. Memory = O(matching URIs × upstream
30-
* signals).
3129
*/
3230
export async function* readUpstreamChanges(
3331
store: UpdatesStore,
@@ -46,33 +44,64 @@ export async function* readUpstreamChanges(
4644
}
4745
const uriPrefix = opts?.uriPrefix;
4846

49-
// Drain every upstream's per-URI diff into one buffer so we can sort
50-
// the combined stream by URI. (Within a single signal `readUpdatedEntries`
51-
// yields by stamp; merging by URI across signals isn't possible without
52-
// buffering.)
53-
const signalOrder = new Map<string, number>();
54-
for (let i = 0; i < inputs.length; i++) {
55-
signalOrder.set(inputs[i] as string, i);
56-
}
57-
const buffered: UpdateEntry[] = [];
58-
for (const upstream of inputs) {
59-
for await (const entry of store.readUpdatedEntries({
47+
// One URI-ordered iterator per upstream signal.
48+
const iters: Array<AsyncIterator<UpdateEntry>> = inputs.map((upstream) => {
49+
const iterable = store.readUpdatedEntries({
6050
upstreamSignal: upstream,
6151
currentSignal: watermark,
6252
uriPrefix,
63-
})) {
64-
buffered.push(entry);
65-
}
66-
}
67-
68-
buffered.sort((a, b) => {
69-
if (a.uri !== b.uri) return a.uri < b.uri ? -1 : 1;
70-
const sa = signalOrder.get(a.signal) ?? 0;
71-
const sb = signalOrder.get(b.signal) ?? 0;
72-
return sa - sb;
53+
orderBy: "uri",
54+
});
55+
return iterable[Symbol.asyncIterator]();
7356
});
7457

75-
for (const entry of buffered) yield entry;
58+
try {
59+
// Prime the head of each stream.
60+
const heads: Array<UpdateEntry | null> = await Promise.all(
61+
iters.map(async (it) => {
62+
const r = await it.next();
63+
return r.done ? null : r.value;
64+
}),
65+
);
66+
67+
// Streaming k-way merge by URI. For URI ties, keep
68+
// signal-declaration order by preferring the smaller index.
69+
while (true) {
70+
let minIdx = -1;
71+
for (let i = 0; i < heads.length; i++) {
72+
const h = heads[i];
73+
if (h === null || h === undefined) continue;
74+
if (minIdx === -1) {
75+
minIdx = i;
76+
continue;
77+
}
78+
// biome-ignore lint/style/noNonNullAssertion: minIdx ≥ 0 implies heads[minIdx] is not null
79+
const cur = heads[minIdx]!;
80+
if (h.uri < cur.uri) minIdx = i;
81+
}
82+
if (minIdx === -1) return;
83+
// biome-ignore lint/style/noNonNullAssertion: minIdx selected from a non-null head
84+
const entry = heads[minIdx]!;
85+
yield entry;
86+
// biome-ignore lint/style/noNonNullAssertion: iters[minIdx] paired with heads[minIdx]
87+
const next = await iters[minIdx]!.next();
88+
heads[minIdx] = next.done ? null : next.value;
89+
}
90+
} finally {
91+
// Best-effort close of any non-exhausted iterators on early break.
92+
await Promise.all(
93+
iters.map(async (it) => {
94+
if (it.return) {
95+
try {
96+
await it.return(undefined);
97+
} catch {
98+
// Swallow: a throwing iterator close must not mask the
99+
// primary outcome of the merge.
100+
}
101+
}
102+
}),
103+
);
104+
}
76105
}
77106

78107
/**

packages/shared-dataflow/src/updates-store.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,33 @@ export type SerializedUpdatesStore = {
4242
* signal naming consistency with any `DataflowGraph` topology, tombstone
4343
* naming conventions.
4444
*/
45+
/**
46+
* Yield ordering for read methods on `UpdatesStore`. Both options yield
47+
* the same set of entries — only the order differs.
48+
*
49+
* - `"stamp"` (default) — last-transaction-id ascending. Best when the
50+
* consumer wants temporal-order replay.
51+
* - `"uri"` — URI ascending. Best when the consumer wants to collapse
52+
* per-URI work in one forward pass, or to merge multiple read streams
53+
* by URI without buffering (see `readUpstreamChanges`).
54+
*/
55+
export type ReadOrderBy = "stamp" | "uri";
56+
4557
export interface UpdatesStore {
4658
/**
4759
* Read entries on a signal whose stamp > `since`, optionally filtered by
48-
* URI prefix. Yields full `UpdateEntry` objects in stamp-ascending order.
60+
* URI prefix. Yields full `UpdateEntry` objects.
4961
*
5062
* - `since` is exclusive: `stamp > since`. Use `since = 0` to read everything.
5163
* - `uriPrefix` (optional, defaults to no filter) restricts to entries whose
5264
* `uri.startsWith(uriPrefix)`. An empty string is treated as no filter.
65+
* - `orderBy` (optional, default `"stamp"`) — see `ReadOrderBy`.
5366
*/
5467
readEntries(opts: {
5568
signal: Signal;
5669
since: number;
5770
uriPrefix?: string;
71+
orderBy?: ReadOrderBy;
5872
}): AsyncIterable<UpdateEntry>;
5973

6074
/**
@@ -72,8 +86,10 @@ export interface UpdatesStore {
7286
* since cleaned it up via tombstone, so the current cell has nothing
7387
* to do.
7488
*
75-
* Yields in upstream-stamp-ascending order. `uriPrefix` filters the
76-
* same way as `readEntries`.
89+
* Default order is upstream-stamp-ascending. Pass `orderBy: "uri"`
90+
* for URI-ascending order — used by `readUpstreamChanges` to merge
91+
* several per-signal streams in O(1) buffering. `uriPrefix` filters
92+
* the same way as `readEntries`.
7793
*
7894
* Caller contract: after handling a yielded entry, save a
7995
* `currentSignal` entry with stamp >= the yielded upstream stamp to
@@ -84,6 +100,7 @@ export interface UpdatesStore {
84100
upstreamSignal: Signal;
85101
currentSignal: Signal;
86102
uriPrefix?: string;
103+
orderBy?: ReadOrderBy;
87104
}): AsyncIterable<UpdateEntry>;
88105

89106
/** Upsert by `(signal, uri)`. Replaces blindly — last write wins. */

packages/shared-dataflow/tests/in-memory-updates-store.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,101 @@ describe("InMemoryUpdatesStore — readUpdatedEntries (per-URI cell diff)", () =
568568
});
569569
});
570570

571+
describe("InMemoryUpdatesStore — orderBy 'stamp' vs 'uri'", () => {
572+
it("readEntries defaults to stamp-ascending order", async () => {
573+
const store = new InMemoryUpdatesStore();
574+
await store.saveEntries([
575+
{ signal: "x", uri: "c", stamp: 9 },
576+
{ signal: "x", uri: "a", stamp: 3 },
577+
{ signal: "x", uri: "b", stamp: 5 },
578+
]);
579+
const got = await collect(store.readEntries({ signal: "x", since: 0 }));
580+
expect(got.map((e) => e.uri)).toEqual(["a", "b", "c"]);
581+
expect(got.map((e) => e.stamp)).toEqual([3, 5, 9]);
582+
});
583+
584+
it("readEntries with orderBy: 'uri' yields URI-ascending regardless of stamp", async () => {
585+
const store = new InMemoryUpdatesStore();
586+
await store.saveEntries([
587+
{ signal: "x", uri: "b", stamp: 9 },
588+
{ signal: "x", uri: "a", stamp: 100 },
589+
{ signal: "x", uri: "c", stamp: 1 },
590+
]);
591+
const got = await collect(store.readEntries({ signal: "x", since: 0, orderBy: "uri" }));
592+
expect(got.map((e) => e.uri)).toEqual(["a", "b", "c"]);
593+
// The stamps reflect each URI's stored value (not re-stamped).
594+
expect(got.map((e) => e.stamp)).toEqual([100, 9, 1]);
595+
});
596+
597+
it("readEntries orderBy: 'stamp' is the explicit equivalent of the default", async () => {
598+
const store = new InMemoryUpdatesStore();
599+
await store.saveEntries([
600+
{ signal: "x", uri: "b", stamp: 9 },
601+
{ signal: "x", uri: "a", stamp: 100 },
602+
{ signal: "x", uri: "c", stamp: 1 },
603+
]);
604+
const def = await collect(store.readEntries({ signal: "x", since: 0 }));
605+
const explicit = await collect(store.readEntries({ signal: "x", since: 0, orderBy: "stamp" }));
606+
expect(explicit).toEqual(def);
607+
});
608+
609+
it("readUpdatedEntries defaults to stamp-ascending order", async () => {
610+
const store = new InMemoryUpdatesStore();
611+
await store.saveEntries([
612+
{ signal: "src", uri: "z", stamp: 9 },
613+
{ signal: "src", uri: "a", stamp: 3 },
614+
{ signal: "src", uri: "m", stamp: 5 },
615+
]);
616+
const got = await collect(
617+
store.readUpdatedEntries({ upstreamSignal: "src", currentSignal: "ext" }),
618+
);
619+
expect(got.map((e) => e.uri)).toEqual(["a", "m", "z"]);
620+
expect(got.map((e) => e.stamp)).toEqual([3, 5, 9]);
621+
});
622+
623+
it("readUpdatedEntries with orderBy: 'uri' yields URI-ascending", async () => {
624+
const store = new InMemoryUpdatesStore();
625+
await store.saveEntries([
626+
{ signal: "src", uri: "z", stamp: 1 },
627+
{ signal: "src", uri: "a", stamp: 9 },
628+
{ signal: "src", uri: "m", stamp: 5 },
629+
]);
630+
const got = await collect(
631+
store.readUpdatedEntries({
632+
upstreamSignal: "src",
633+
currentSignal: "ext",
634+
orderBy: "uri",
635+
}),
636+
);
637+
expect(got.map((e) => e.uri)).toEqual(["a", "m", "z"]);
638+
});
639+
640+
it("orderBy is independent of since/uriPrefix filtering — same entries, different order", async () => {
641+
const store = new InMemoryUpdatesStore();
642+
await store.saveEntries([
643+
{ signal: "x", uri: "/keep/3", stamp: 1 },
644+
{ signal: "x", uri: "/keep/1", stamp: 7 },
645+
{ signal: "x", uri: "/keep/2", stamp: 3 },
646+
{ signal: "x", uri: "/drop/9", stamp: 5 },
647+
]);
648+
const byStamp = await collect(
649+
store.readEntries({ signal: "x", since: 0, uriPrefix: "/keep/" }),
650+
);
651+
const byUri = await collect(
652+
store.readEntries({
653+
signal: "x",
654+
since: 0,
655+
uriPrefix: "/keep/",
656+
orderBy: "uri",
657+
}),
658+
);
659+
// Same set, different order.
660+
expect(new Set(byStamp.map((e) => e.uri))).toEqual(new Set(byUri.map((e) => e.uri)));
661+
expect(byStamp.map((e) => e.uri)).toEqual(["/keep/3", "/keep/2", "/keep/1"]);
662+
expect(byUri.map((e) => e.uri)).toEqual(["/keep/1", "/keep/2", "/keep/3"]);
663+
});
664+
});
665+
571666
describe("InMemoryUpdatesStore — stamp validation", () => {
572667
it("rejects a NaN stamp instead of storing an unreadable entry", async () => {
573668
// A NaN stamp would round-trip into the store but be invisible to

0 commit comments

Comments
 (0)