diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 8efa2922b6..d14822844d 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -248,10 +248,26 @@ function buildDescendants( childrenByParent: ReadonlyMap, sessionId: SessionId, ): SessionLineageNode[] { - return (childrenByParent.get(sessionId) ?? []).map(child => ({ - session: cloneRecord(child), - descendants: buildDescendants(childrenByParent, child.header.id), - })) + const descendants: SessionLineageNode[] = [] + const stack = [{ sessionId, descendants }] + while (stack.length > 0) { + // The length guard proves a frame exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const frame = stack.pop()! + const nodes: SessionLineageNode[] = [] + for (const child of childrenByParent.get(frame.sessionId) ?? []) { + const node = { session: cloneRecord(child), descendants: [] } + nodes.push(node) + frame.descendants.push(node) + } + for (let index = nodes.length - 1; index >= 0; index -= 1) { + // The loop bounds prove this indexed node exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[index]! + stack.push({ sessionId: node.session.header.id, descendants: node.descendants }) + } + } + return descendants } function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 3128cb243f..9f4752feee 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -195,6 +195,27 @@ describe('session lineage tracing', () => { await expect(ctx.sessionQuery.traceSession(durable.id)) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) + + it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } }) + let parent = root + for (let depth = 1; depth < 3_000; depth += 1) { + parent = ctx.sessions.create(SessionId(`deep-${depth}`), { + meta: { createdAt: depth, parentSession: parent.id }, + }) + } + + const trace = await ctx.sessionQuery.traceSession(root.id) + expect(trace.complete).toBe(true) + let node = trace.descendants[0] + for (let depth = 1; depth < 3_000; depth += 1) { + if (node === undefined) throw new Error(`lineage ended before depth ${depth}`) + if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999')) + node = node.descendants[0] + } + expect(node).toBeUndefined() + }) }) describe('session event tracing', () => {