Repository navigation
feat(streams): add writer session seam #3832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| '@workflow/world': minor | ||
| --- | ||
|
|
||
| Add an optional stateful stream writer-session seam with stable writer identity and sequence tracking. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { | |
| WorkflowRuntimeError, | ||
| } from '@workflow/errors'; | ||
| import { once } from '@workflow/utils'; | ||
| import type { StreamWriteSession } from '@workflow/world'; | ||
| import { envNumber } from '@workflow/world/env-config'; | ||
| import { parse, stringify, unflatten } from 'devalue'; | ||
| import { monotonicFactory } from 'ulid'; | ||
|
|
@@ -1310,6 +1311,20 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> { | |
| } | ||
| }; | ||
|
|
||
| // One stable identity and sequence space per in-memory sink. Start session | ||
| // construction as soon as the run-ready barrier permits, so transports may | ||
| // negotiate eagerly without allowing a write to overtake run creation. | ||
| // This eager chain cannot strand a new rejection: ensureRunReady absorbs its | ||
| // ordering-only failure, and every path that can observe worldPromise also | ||
| // awaits this session promise before it writes, closes, or disposes. | ||
| const writerId = `wrtr_${defaultUlid()}` as const; | ||
| const writeSessionPromise: Promise<StreamWriteSession | undefined> = | ||
| ensureRunReady().then(async () => { | ||
| const world = await worldPromise; | ||
| return world.streams.createWriteSession?.(runId, name, { writerId }); | ||
| }); | ||
| let nextChunkSeq = 0; | ||
|
|
||
| // ------------------------------------------------------------------ | ||
| // Group-commit buffering. | ||
| // | ||
|
|
@@ -1427,15 +1442,24 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> { | |
| ): Promise<void> => { | ||
| await ensureRunReady(); | ||
| const world = await worldPromise; | ||
| const session = await writeSessionPromise; | ||
| const dispatchAt = Date.now(); | ||
| if (typeof world.streams.writeMulti === 'function' && group.length > 1) { | ||
| if (session) { | ||
| await session.write(nextChunkSeq, group); | ||
| } else if ( | ||
| typeof world.streams.writeMulti === 'function' && | ||
| group.length > 1 | ||
| ) { | ||
| await world.streams.writeMulti(runId, name, group); | ||
| } else { | ||
| // Fall back to sequential writes | ||
| for (const chunk of group) { | ||
| await world.streams.write(runId, name, chunk); | ||
| } | ||
| } | ||
| // `inFlight` admits only one dispatch loop, so no second group can read | ||
| // this sequence space until the current group has advanced it. | ||
| nextChunkSeq += group.length; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Note
Since the whole point of the sequence space is that a transport can rely on it to reconstruct ordering, a one-line comment tying this increment to the |
||
| if (groupT0 !== undefined) { | ||
| recordStreamWriteFlush( | ||
| groupT0, | ||
|
|
@@ -1635,8 +1659,13 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> { | |
| await ensureRunReady(); | ||
|
|
||
| const world = await worldPromise; | ||
| const session = await writeSessionPromise; | ||
| const closeStart = Date.now(); | ||
| await world.streams.close(runId, name); | ||
| if (session) { | ||
| await session.close(); | ||
| } else { | ||
| await world.streams.close(runId, name); | ||
| } | ||
| recordStreamClose(closeStart, runId, name); | ||
| }, | ||
| async abort(reason) { | ||
|
|
@@ -1671,6 +1700,11 @@ export class WorkflowServerWritableStream extends WritableStream<Uint8Array> { | |
| // Reject blocked writers and drain waiters so nothing leaks or | ||
| // hangs on a promise whose timer was just cleared. | ||
| rejectWaiters(sinkError); | ||
| // Abort is transport cleanup, not semantic stream completion. A | ||
| // stateful World releases its socket without sending close; stateless | ||
| // Worlds keep the existing no-op behavior. | ||
| const session = await writeSessionPromise.catch(() => undefined); | ||
| await session?.dispose?.(); | ||
| }, | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,25 @@ import type { | |
| StepWithoutData, | ||
| } from './steps.js'; | ||
|
|
||
| export interface StreamWriteSession { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AI Review: Nit The changeset marks |
||
| /** | ||
| * Write one ordered group from this in-memory writer lifetime. | ||
| * `chunkSeq` is writer-local and identifies the first chunk in `chunks`. | ||
| */ | ||
| write(chunkSeq: number, chunks: (string | Uint8Array)[]): Promise<void>; | ||
|
|
||
| /** Close this writer lifetime after all prior writes are durable. */ | ||
| close(): Promise<void>; | ||
|
|
||
| /** Release transport resources without semantically closing the stream. */ | ||
| dispose?(): Promise<void> | void; | ||
| } | ||
|
|
||
| export interface CreateStreamWriteSessionOptions { | ||
| /** Stable observational id for this in-memory writer lifetime. */ | ||
| writerId: `wrtr_${string}`; | ||
| } | ||
|
|
||
| export interface Streamer { | ||
| /** | ||
| * Number of milliseconds a stream waits for additional chunks to arrive | ||
|
|
@@ -57,6 +76,17 @@ export interface Streamer { | |
| streamFlushIntervalMs?: number; | ||
|
|
||
| streams: { | ||
| /** | ||
| * Optionally create a stateful writer session. Core creates at most one | ||
| * session per in-memory WritableStream and otherwise uses the stateless | ||
| * write/writeMulti/close methods below unchanged. | ||
| */ | ||
| createWriteSession?( | ||
| runId: string, | ||
| name: string, | ||
| options: CreateStreamWriteSessionOptions | ||
| ): StreamWriteSession; | ||
|
|
||
| write( | ||
| runId: string, | ||
| name: string, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI Review: Nit
I checked the unhandled-rejection question on this eagerly-constructed promise, since it is created for every writable whether or not a World implements the seam, and it comes out fine:
ensureRunReadyswallows its own rejection by design ("ordering barrier only"), and the only other rejection source,worldPromise, is already awaited on the same paths that await this. Noting it because the eager.thenat construction time is the kind of thing worth a comment saying it cannot strand a rejection, given a stream that is created and then never written, closed, or aborted attaches no handler until it is.The
abortpath usingdispose()rather thanclose()is the right call, and the distinction between transport cleanup and semantic completion is well drawn.