@rdfc/js-runner 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/writer.ts CHANGED
@@ -4,19 +4,79 @@ import { Logger } from 'winston'
4
4
  import { Any } from './reader'
5
5
 
6
6
  type Writable = (msg: FromRunner) => Promise<unknown>
7
+ export type Handler<T = void> = [T] extends [void]
8
+ ? () => void | Promise<void>
9
+ : (value: T) => void | Promise<void>
10
+
7
11
  export interface Writer {
8
12
  readonly uri: string
13
+ readonly canceled: boolean
14
+ on(event: 'cancel', listener: Handler): this
15
+
16
+ /**
17
+ * Writes a complete buffer to the channel. The Promise resolves once the message is fully processed by the remote.
18
+ *
19
+ * @throws Error if the channel is closed or canceled at the moment of the write operation.
20
+ * @param buffer - The data to send as a Uint8Array
21
+ * @returns A Promise that resolves when the message is acknowledged as processed by the remote.
22
+ */
9
23
  buffer(buffer: Uint8Array): Promise<void>
10
24
 
25
+ /**
26
+ * Writes a stream of data to a separate stream-specific channel.
27
+ * The Promise resolves once the entire stream is fully processed by the remote.
28
+ *
29
+ * @throws Error if the channel is closed or canceled at the moment of initiating a stream-specific channel.
30
+ * @param buffer - An AsyncIterable that produces the data to send as Uint8Arrays
31
+ * @returns A Promise that resolves when the entire stream is acknowledged as processed by the remote.
32
+ */
11
33
  stream(buffer: AsyncIterable<Uint8Array>): Promise<void>
34
+
35
+ /**
36
+ * Writes a stream of data to a separate stream-specific channel.
37
+ * The Promise resolves once the entire stream is fully processed by the remote.
38
+ *
39
+ * @throws Error if the channel is closed or canceled at the moment of initiating a stream-specific channel.
40
+ * @param buffer - An AsyncIterable that produces the data to send, which will be transformed into Uint8Arrays using the provided transform function
41
+ * @param transform - A function that transforms items from the buffer AsyncIterable into Uint8Arrays for sending. If not provided, items are assumed to already be Uint8Arrays.
42
+ * @returns A Promise that resolves when the entire stream is acknowledged as processed by the remote.
43
+ */
12
44
  stream<T>(
13
45
  buffer: AsyncIterable<T>,
14
46
  transform: (x: T) => Uint8Array,
15
47
  ): Promise<void>
16
48
 
49
+ /**
50
+ * Writes a string message to the channel. The Promise resolves once the message is fully processed by the remote.
51
+ *
52
+ * @throws Error if the channel is closed or canceled at the moment of the write operation.
53
+ * @param buffer - The string message to send
54
+ * @returns A Promise that resolves when the message is acknowledged as processed by the remote.
55
+ */
17
56
  string(buffer: string): Promise<void>
57
+
58
+ /**
59
+ * Writes a message of any supported type (string, buffer, or stream) to the channel.
60
+ * The Promise resolves once the message is fully processed by the remote.
61
+ *
62
+ * @throws Error if the channel is closed or canceled at the moment of the write operation.
63
+ * @param any - An object containing one of the supported message types (string, buffer, or stream)
64
+ * @returns A Promise that resolves when the message is acknowledged as processed by the remote.
65
+ */
18
66
  any(any: Any): Promise<void>
19
- close(): Promise<void>
67
+
68
+ /**
69
+ * Gracefully closes this channel.
70
+ *
71
+ * Behavior:
72
+ * - If there are still active streams, closing is deferred until they complete.
73
+ * - If multiple callers invoke `close()` while waiting, their Promises are queued and
74
+ * resolved once the channel actually closes.
75
+ * - If this side initiated the close (`issued = false`), a close message is sent to the remote.
76
+ *
77
+ * @param issued - If true, indicates the close request originated remotely
78
+ */
79
+ close(issued?: boolean): Promise<void>
20
80
  }
21
81
  const encoder = new TextEncoder()
22
82
  export class WriterInstance implements Writer {
@@ -26,10 +86,20 @@ export class WriterInstance implements Writer {
26
86
  private readonly notifyOrchestrator: Writable
27
87
  private readonly logger: Logger
28
88
 
29
- private awaitingProcessed: Array<() => void> = []
89
+ // FIFO of message-level acknowledgements coming back from the orchestrator.
90
+ private awaitingProcessed: Array<{
91
+ resolve: () => void
92
+ reject: (reason: Error) => void
93
+ }> = []
30
94
 
31
95
  private openStreams: number = 0
96
+ // Close callers wait here while active streams are still flushing.
32
97
  private shouldClose: Array<() => void> = []
98
+ private closed = false
99
+ private _canceled = false
100
+
101
+ // Processors can subscribe here to stop upstream work when downstream cancels.
102
+ private readonly cancelHandlers = new Set<Handler>()
33
103
 
34
104
  private readonly runnerId: string
35
105
 
@@ -47,8 +117,22 @@ export class WriterInstance implements Writer {
47
117
  this.runnerId = runnerId
48
118
  }
49
119
 
50
- private awaitProcessed(): Promise<void> {
51
- return new Promise((res) => this.awaitingProcessed.push(res))
120
+ get canceled(): boolean {
121
+ return this._canceled
122
+ }
123
+
124
+ on(event: 'cancel', listener: Handler): this {
125
+ if (event === 'cancel') {
126
+ this.cancelHandlers.add(listener)
127
+ }
128
+
129
+ return this
130
+ }
131
+
132
+ private cancellationError(): Error {
133
+ return new Error(
134
+ `Writer for channel ${this.uri} was canceled by the connected reader`,
135
+ )
52
136
  }
53
137
 
54
138
  async any(any: Any): Promise<void> {
@@ -63,7 +147,18 @@ export class WriterInstance implements Writer {
63
147
  }
64
148
  }
65
149
 
150
+ private assertCanWrite() {
151
+ if (this._canceled) {
152
+ throw this.cancellationError()
153
+ }
154
+
155
+ if (this.closed) {
156
+ throw new Error(`Writer for channel ${this.uri} is closed`)
157
+ }
158
+ }
159
+
66
160
  async buffer(buffer: Uint8Array): Promise<void> {
161
+ this.assertCanWrite()
67
162
  this.logger.debug(`${this.uri} sends buffer ${buffer.length} bytes`)
68
163
  const localSequenceNumber = this.localSequenceNumber++
69
164
  const handledPromise = this.awaitProcessed()
@@ -77,45 +172,59 @@ export class WriterInstance implements Writer {
77
172
  async stream<T = Uint8Array>(
78
173
  buffer: AsyncIterable<T>,
79
174
  transform?: (x: T) => Uint8Array,
80
- ) {
175
+ ): Promise<void> {
176
+ this.assertCanWrite()
81
177
  this.openStreams += 1
82
178
  const t = transform || ((x: unknown) => <Uint8Array>x)
83
179
  const stream = this.client.sendStreamMessage()
84
180
 
85
- const handledPromise = this.awaitProcessed()
86
- const writeStreamMessageChunk = promisify(stream.write.bind(stream))
87
- const localSequenceNumber = this.localSequenceNumber++
88
- await writeStreamMessageChunk({
89
- id: {
90
- channel: this.uri,
91
- localSequenceNumber,
92
- runner: this.runnerId,
93
- },
94
- })
181
+ try {
182
+ // Message-level ack that signals the whole stream message is fully handled.
183
+ const handledPromise = this.awaitProcessed()
184
+ const writeStreamMessageChunk = promisify(stream.write.bind(stream))
185
+ const localSequenceNumber = this.localSequenceNumber++
186
+ await writeStreamMessageChunk({
187
+ id: {
188
+ channel: this.uri,
189
+ localSequenceNumber,
190
+ runner: this.runnerId,
191
+ },
192
+ })
95
193
 
96
- const id = await new Promise((res) => stream.once('data', res))
194
+ // First response confirms stream id registration on the remote side.
195
+ const id = await new Promise((res) => stream.once('data', res))
97
196
 
98
- this.logger.debug(
99
- `${this.uri} streams message with id ${JSON.stringify(id)}`,
100
- )
197
+ this.logger.debug(
198
+ `${this.uri} streams message with id ${JSON.stringify(id)}`,
199
+ )
101
200
 
102
- for await (const msg of buffer) {
103
- const processedPromise = new Promise((res) => stream.once('data', res))
104
- await writeStreamMessageChunk({ data: { data: t(msg) } })
105
- // Await a message on the stream, indicating that the chunk has been processed
106
- await processedPromise
107
- }
201
+ // TODO: don't await to allow consuming processors to read and handle in parallel.
202
+ for await (const msg of buffer) {
203
+ const processedPromise = new Promise((res) => stream.once('data', res))
204
+ await writeStreamMessageChunk({ data: { data: t(msg) } })
205
+ // Await a message on the stream, indicating that the chunk has been processed
206
+ await processedPromise
207
+ }
108
208
 
109
- stream.end()
209
+ stream.end()
110
210
 
111
- await handledPromise
211
+ await handledPromise
212
+ } finally {
213
+ this.openStreams -= 1
112
214
 
113
- this.openStreams -= 1
215
+ if (!stream.writableEnded) {
216
+ stream.end()
217
+ }
114
218
 
115
- if (this.shouldClose.length > 0) await this.close()
219
+ // If a close call was deferred while streaming, complete it now.
220
+ if (this.shouldClose.length > 0) {
221
+ await this.close()
222
+ }
223
+ }
116
224
  }
117
225
 
118
226
  async string(msg: string): Promise<void> {
227
+ this.assertCanWrite()
119
228
  this.logger.debug(`${this.uri} sends string ${msg.length} characters`)
120
229
  const localSequenceNumber = this.localSequenceNumber++
121
230
  const handledPromise = this.awaitProcessed()
@@ -131,18 +240,15 @@ export class WriterInstance implements Writer {
131
240
  await handledPromise
132
241
  }
133
242
 
134
- /**
135
- * Gracefully closes this channel.
136
- *
137
- * Behavior:
138
- * - If there are still active streams, closing is deferred until they complete.
139
- * - If multiple callers invoke `close()` while waiting, their Promises are queued and
140
- * resolved once the channel actually closes.
141
- * - If this side initiated the close (`issued = false`), a close message is sent to the remote.
142
- *
143
- * @param issued - If true, indicates the close request originated remotely
144
- */
145
243
  async close(issued = false): Promise<void> {
244
+ this.closed = true
245
+ if (issued && !this._canceled) {
246
+ // Remote initiated close: mark writer canceled to fail future writes.
247
+ this._canceled = true
248
+
249
+ // Notify processors so they can stop producing upstream work as well.
250
+ await this.emitCancel()
251
+ }
146
252
  // Case 1: Active streams still running → wait until they finish
147
253
  if (this.openStreams !== 0) {
148
254
  await new Promise<void>((resolve) => this.shouldClose.push(resolve))
@@ -167,9 +273,16 @@ export class WriterInstance implements Writer {
167
273
  /**
168
274
  * A message is handled, let's notify the fifo {@link awaitProcessed}
169
275
  */
170
- handled(): void {
276
+ handled(error?: string): void {
171
277
  if (this.awaitingProcessed.length > 0) {
172
- this.awaitingProcessed.shift()!()
278
+ if (error) {
279
+ this.awaitingProcessed.shift()!.reject(new Error(error))
280
+ } else {
281
+ this.awaitingProcessed.shift()!.resolve()
282
+ }
283
+ } else if (this.closed || this._canceled) {
284
+ // A late ack can arrive after a close/cancel race; nothing to resolve anymore.
285
+ return
173
286
  } else {
174
287
  this.logger.error(
175
288
  'Expected to be waiting for a message to be processed, but this is not the case ' +
@@ -177,4 +290,24 @@ export class WriterInstance implements Writer {
177
290
  )
178
291
  }
179
292
  }
293
+
294
+ private async emitCancel() {
295
+ await Promise.all(
296
+ Array.from(this.cancelHandlers).map(async (handler) => {
297
+ try {
298
+ await handler()
299
+ } catch (error: unknown) {
300
+ this.logger.error(
301
+ `Cancel listener for channel ${this.uri} failed: ${String(error)}`,
302
+ )
303
+ }
304
+ }),
305
+ )
306
+ }
307
+
308
+ private awaitProcessed(): Promise<void> {
309
+ return new Promise((resolve, reject) =>
310
+ this.awaitingProcessed.push({ resolve, reject }),
311
+ )
312
+ }
180
313
  }