Skip to content

Commit ffbf5be

Browse files
committed
merge: bring EventFeed fan-out benchmark into ownership stack
2 parents b79e543 + 85f9a1f commit ffbf5be

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, expect } from "bun:test"
2+
import { EventV2 } from "@opencode-ai/core/event"
3+
import { AbsolutePath } from "@opencode-ai/core/schema"
4+
import { DateTime, Effect, Fiber, Stream } from "effect"
5+
import { it } from "../../core/test/lib/effect"
6+
import { EventFeed } from "../src/event-feed"
7+
8+
const Clients = [1, 2, 4, 8, 11, 16] as const
9+
const Sizes = {
10+
small: 1 * 1024,
11+
medium: 64 * 1024,
12+
large: 256 * 1024,
13+
} as const
14+
15+
function makeSource() {
16+
let subscriber: EventV2.Subscriber | undefined
17+
return {
18+
observe: (next: EventV2.Subscriber) =>
19+
Effect.sync(() => {
20+
subscriber = next
21+
return Effect.sync(() => {
22+
if (subscriber === next) subscriber = undefined
23+
})
24+
}),
25+
publish: (event: EventV2.Payload) => Effect.suspend(() => (subscriber ? subscriber(event) : Effect.void)),
26+
}
27+
}
28+
29+
function sessionEvent(id: string, sessionID: string, location: { directory: string }): EventV2.Payload {
30+
return {
31+
id: EventV2.ID.make(`evt_${id}`),
32+
created: DateTime.makeUnsafe(Date.now()),
33+
type: "session.renamed",
34+
data: { sessionID, title: id },
35+
durable: { aggregateID: sessionID, seq: EventV2.Seq.make(1), version: EventV2.Version.make(1) },
36+
location: {
37+
directory: AbsolutePath.make(location.directory),
38+
},
39+
}
40+
}
41+
42+
function eventLoopDelayMs() {
43+
const start = performance.now()
44+
return new Promise<number>((resolve) => {
45+
setTimeout(() => resolve(performance.now() - start), 0)
46+
})
47+
}
48+
49+
type Row = {
50+
clients: number
51+
size: keyof typeof Sizes
52+
payloadBytes: number
53+
interested: number
54+
serializedEvents: number
55+
serializedBytes: number
56+
peakActive: number
57+
receivedInterested: number
58+
receivedUninterested: number
59+
publishMs: number
60+
clientHandleMs: number
61+
rssBefore: number
62+
rssAfter: number
63+
eventLoopDelayMs: number
64+
}
65+
66+
describe("EventFeed fan-out benchmark", () => {
67+
it.live(
68+
"accounting laws hold across client counts and payload sizes",
69+
() =>
70+
Effect.gen(function* () {
71+
const rows: Row[] = []
72+
73+
for (const clients of Clients) {
74+
for (const size of Object.keys(Sizes) as Array<keyof typeof Sizes>) {
75+
rows.push(yield* measureCell(clients, size))
76+
}
77+
}
78+
79+
// Same payload class → same serialized byte cost regardless of uninterested N.
80+
for (const size of Object.keys(Sizes) as Array<keyof typeof Sizes>) {
81+
const bytes = rows.filter((row) => row.size === size).map((row) => row.serializedBytes)
82+
expect(new Set(bytes).size).toBe(1)
83+
expect(bytes[0]).toBe(Sizes[size])
84+
}
85+
86+
// Encode-once: every cell accepted exactly one hot publish.
87+
expect(rows.every((row) => row.serializedEvents === 1)).toBe(true)
88+
89+
console.log(JSON.stringify({ benchmark: "event-feed-fanout", rows }, null, 2))
90+
}),
91+
60_000,
92+
)
93+
})
94+
95+
function measureCell(clients: number, size: keyof typeof Sizes) {
96+
const payloadBytes = Sizes[size]
97+
const interested = Math.max(1, Math.floor(clients / 2))
98+
const frame = "x".repeat(payloadBytes)
99+
100+
return Effect.scoped(
101+
Effect.gen(function* () {
102+
let serializedEvents = 0
103+
let serializedBytes = 0
104+
const source = makeSource()
105+
const feed = yield* EventFeed.make(source.observe, {
106+
encode: () => {
107+
serializedEvents += 1
108+
serializedBytes += Buffer.byteLength(frame, "utf-8")
109+
return frame
110+
},
111+
})
112+
113+
const received = Array.from({ length: clients }, () => 0)
114+
const fibers: Array<Fiber.Fiber<void, EventFeed.Error>> = []
115+
116+
for (let index = 0; index < clients; index++) {
117+
const sessions = index < interested ? ["ses_hot"] : [`ses_${index}`]
118+
const stream = yield* feed.subscribe({
119+
location: { directory: "/tmp/project" },
120+
sessions,
121+
})
122+
fibers.push(
123+
yield* stream.pipe(
124+
Stream.take(1),
125+
Stream.runForEach(() => Effect.sync(() => (received[index] += 1))),
126+
Effect.forkScoped,
127+
),
128+
)
129+
}
130+
131+
const rssBefore = process.memoryUsage().rss
132+
const loopDelay = yield* Effect.promise(eventLoopDelayMs)
133+
const publishStarted = performance.now()
134+
yield* source.publish(sessionEvent("hot", "ses_hot", { directory: "/tmp/project" }))
135+
const publishMs = performance.now() - publishStarted
136+
137+
const handleStarted = performance.now()
138+
for (const fiber of fibers.slice(0, interested)) yield* Fiber.join(fiber)
139+
const clientHandleMs = performance.now() - handleStarted
140+
141+
for (const fiber of fibers.slice(interested)) yield* Fiber.interrupt(fiber)
142+
143+
expect(serializedEvents).toBe(1)
144+
expect(serializedBytes).toBe(payloadBytes)
145+
expect(received.slice(0, interested).every((count) => count === 1)).toBe(true)
146+
expect(received.slice(interested).every((count) => count === 0)).toBe(true)
147+
148+
return {
149+
clients,
150+
size,
151+
payloadBytes,
152+
interested,
153+
serializedEvents,
154+
serializedBytes,
155+
peakActive: clients,
156+
receivedInterested: received.slice(0, interested).reduce((sum, count) => sum + count, 0),
157+
receivedUninterested: received.slice(interested).reduce((sum, count) => sum + count, 0),
158+
publishMs,
159+
clientHandleMs,
160+
rssBefore,
161+
rssAfter: process.memoryUsage().rss,
162+
eventLoopDelayMs: loopDelay,
163+
} satisfies Row
164+
}),
165+
)
166+
}

0 commit comments

Comments
 (0)