@kronos-ts/axon-server 0.5.0 → 0.7.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.
Files changed (68) hide show
  1. package/dist/axon-server-event-store.d.ts.map +1 -1
  2. package/dist/axon-server-event-store.js +72 -77
  3. package/dist/axon-server-event-store.js.map +1 -1
  4. package/dist/axon-server-snapshotting-event-store.d.ts +3 -3
  5. package/dist/axon-server-snapshotting-event-store.d.ts.map +1 -1
  6. package/dist/axon-server-snapshotting-event-store.js +3 -2
  7. package/dist/axon-server-snapshotting-event-store.js.map +1 -1
  8. package/dist/axon-server.d.ts +15 -7
  9. package/dist/axon-server.d.ts.map +1 -1
  10. package/dist/axon-server.js +447 -275
  11. package/dist/axon-server.js.map +1 -1
  12. package/dist/bounded-read.d.ts +19 -0
  13. package/dist/bounded-read.d.ts.map +1 -0
  14. package/dist/bounded-read.js +39 -0
  15. package/dist/bounded-read.js.map +1 -0
  16. package/dist/connection.d.ts +13 -0
  17. package/dist/connection.d.ts.map +1 -1
  18. package/dist/connection.js +78 -40
  19. package/dist/connection.js.map +1 -1
  20. package/dist/control-plane.d.ts +9 -19
  21. package/dist/control-plane.d.ts.map +1 -1
  22. package/dist/control-plane.js +6 -37
  23. package/dist/control-plane.js.map +1 -1
  24. package/dist/event-processor-info.d.ts +5 -23
  25. package/dist/event-processor-info.d.ts.map +1 -1
  26. package/dist/event-processor-info.js +16 -20
  27. package/dist/event-processor-info.js.map +1 -1
  28. package/dist/flow-controlled-sender.d.ts.map +1 -1
  29. package/dist/flow-controlled-sender.js +37 -17
  30. package/dist/flow-controlled-sender.js.map +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/outbound-stream.d.ts +5 -9
  35. package/dist/outbound-stream.d.ts.map +1 -1
  36. package/dist/outbound-stream.js +65 -16
  37. package/dist/outbound-stream.js.map +1 -1
  38. package/dist/platform-service.d.ts +10 -1
  39. package/dist/platform-service.d.ts.map +1 -1
  40. package/dist/platform-service.js +70 -10
  41. package/dist/platform-service.js.map +1 -1
  42. package/dist/resilience.d.ts +2 -0
  43. package/dist/resilience.d.ts.map +1 -1
  44. package/dist/resilience.js +2 -1
  45. package/dist/resilience.js.map +1 -1
  46. package/dist/shutdown-latch.d.ts +1 -0
  47. package/dist/shutdown-latch.d.ts.map +1 -1
  48. package/dist/shutdown-latch.js +20 -1
  49. package/dist/shutdown-latch.js.map +1 -1
  50. package/dist/stream-recovery.d.ts +9 -0
  51. package/dist/stream-recovery.d.ts.map +1 -0
  52. package/dist/stream-recovery.js +70 -0
  53. package/dist/stream-recovery.js.map +1 -0
  54. package/package.json +2 -2
  55. package/src/axon-server-event-store.ts +77 -79
  56. package/src/axon-server-snapshotting-event-store.ts +10 -7
  57. package/src/axon-server.ts +417 -316
  58. package/src/bounded-read.ts +43 -0
  59. package/src/connection.ts +77 -45
  60. package/src/control-plane.ts +19 -63
  61. package/src/event-processor-info.ts +22 -43
  62. package/src/flow-controlled-sender.ts +33 -14
  63. package/src/index.ts +0 -1
  64. package/src/outbound-stream.ts +57 -27
  65. package/src/platform-service.ts +75 -11
  66. package/src/resilience.ts +6 -2
  67. package/src/shutdown-latch.ts +14 -1
  68. package/src/stream-recovery.ts +72 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A read that never answers is cancelled and asked once more.
3
+ *
4
+ * WHY THIS EXISTS. Under Bun, a gRPC call whose response has fully arrived —
5
+ * headers, the message, trailers carrying an OK status, the HTTP/2 stream
6
+ * closed cleanly — can still never complete on the client. grpc-js releases
7
+ * an OK status only once the stream's `end` event has fired, and Bun's http2
8
+ * client intermittently never emits it (Node does). The Axon integration
9
+ * suites hit it about one run in four on a loaded machine, on `source` and on
10
+ * the snapshot store's `getLast`; the command handler above the read then
11
+ * never replies, and Axon Server cancels the command at its own 300 s timeout.
12
+ *
13
+ * A deadline is the one lever this side of grpc-js: a cancelled call DOES
14
+ * surface, because a non-OK status needs no `end`. The reads this guards are
15
+ * idempotent, so the answer to a lost one is simply to ask again. A second
16
+ * loss is reported rather than retried forever.
17
+ */
18
+ export async function boundedRead<T>(
19
+ deadlineMs: number,
20
+ read: (signal: AbortSignal) => Promise<T>,
21
+ ): Promise<T> {
22
+ for (let attempt = 1; ; attempt++) {
23
+ const cancel = new AbortController()
24
+ const timer = setTimeout(() => cancel.abort(), deadlineMs)
25
+ try {
26
+ return await read(cancel.signal)
27
+ } catch (err) {
28
+ if (!cancel.signal.aborted) throw err
29
+ if (attempt === 2) {
30
+ throw new Error(
31
+ `Axon Server read did not complete within ${deadlineMs} ms, twice in a row`,
32
+ { cause: err },
33
+ )
34
+ }
35
+ console.warn(
36
+ `Axon Server read did not complete within ${deadlineMs} ms; cancelled it and asking again ` +
37
+ "(a known Bun http2 client fault: the stream's end event is lost, see bounded-read.ts)",
38
+ )
39
+ } finally {
40
+ clearTimeout(timer)
41
+ }
42
+ }
43
+ }
package/src/connection.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { withMessagingTimeout } from "@kronos-ts/core"
1
2
  import {
2
3
  createChannel,
3
- createClient,
4
+ waitForChannelReady, createClient,
4
5
  type Channel,
5
6
  type Client,
6
7
  type ChannelCredentials,
@@ -71,6 +72,17 @@ export type AxonServerConnectionConfig = {
71
72
  * Default: true.
72
73
  */
73
74
  keepAlivePermitWithoutCalls?: boolean
75
+ /**
76
+ * How long a read (`source`, the snapshot store's `getLast`, `getHead`) may
77
+ * take before it is cancelled and asked once more. A read whose answer has
78
+ * fully arrived can still never complete under Bun — its http2 client
79
+ * intermittently loses the stream's `end` event, and grpc-js releases an OK
80
+ * status only after it — so without a bound a command handler parks on the
81
+ * read until Axon Server cancels the command (300 s). Reads are idempotent;
82
+ * a lost one is simply repeated. Raise this if a single sourcing read of
83
+ * yours legitimately takes longer. Default: 15000.
84
+ */
85
+ readTimeoutMs?: number
74
86
 
75
87
  /**
76
88
  * TLS/SSL configuration. When enabled, the connection uses a secure gRPC channel.
@@ -165,6 +177,7 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
165
177
  keepAliveTimeMs: config.keepAliveTimeMs ?? 30000,
166
178
  keepAliveTimeoutMs: config.keepAliveTimeoutMs ?? 10000,
167
179
  keepAlivePermitWithoutCalls: config.keepAlivePermitWithoutCalls ?? true,
180
+ readTimeoutMs: config.readTimeoutMs ?? 15000,
168
181
  servers: config.servers,
169
182
  ssl: config.ssl,
170
183
  }
@@ -189,6 +202,12 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
189
202
  "grpc.keepalive_time_ms": config.keepAliveTimeMs ?? 30000,
190
203
  "grpc.keepalive_timeout_ms": config.keepAliveTimeoutMs ?? 10000,
191
204
  "grpc.keepalive_permit_without_calls": (config.keepAlivePermitWithoutCalls ?? true) ? 1 : 0,
205
+ // Own subchannel pool per channel. grpc-js pools subchannels PROCESS-WIDE by
206
+ // target address by default, so a later connection to the same host:port —
207
+ // a fresh container on a reused mapped port, in a test process that already
208
+ // talked to another one — can be handed the previous server's HTTP/2
209
+ // session, and its unary calls hang until the server-side timeout.
210
+ "grpc.use_local_subchannel_pool": 1,
192
211
  }
193
212
 
194
213
  // Build server address list for failover
@@ -208,6 +227,9 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
208
227
 
209
228
  let channel = createGrpcChannel()
210
229
  let state: ConnectionState = "connected"
230
+ let reconnectPromise: Promise<void> | undefined
231
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
232
+ let finishRetryDelay: (() => void) | undefined
211
233
 
212
234
  const reconnectCallbacks: Array<() => void> = []
213
235
  const disconnectCallbacks: Array<(error?: Error) => void> = []
@@ -258,52 +280,59 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
258
280
  },
259
281
 
260
282
  close() {
283
+ if (state === "closed") return
261
284
  state = "closed"
285
+ for (const callback of disconnectCallbacks) {
286
+ try { callback() } catch { /* Listener failures cannot prevent teardown. */ }
287
+ }
288
+ clearTimeout(retryTimer)
289
+ finishRetryDelay?.()
262
290
  channel.close()
263
291
  },
264
292
 
265
- async reconnect() {
266
- if (state === "closed") {
267
- throw new Error("Connection is permanently closed")
268
- }
269
- if (state === "connected" || state === "connecting") return
270
-
271
- state = "reconnecting"
272
- const maxAttempts = resolvedConfig.maxReconnectAttempts
273
- let attempt = 0
274
-
275
- while (state === "reconnecting") {
276
- attempt++
277
- try {
278
- // Try the next server in the list on each reconnect attempt
279
- currentServerIndex++
280
- channel = createGrpcChannel()
281
- clients = createClients()
282
- state = "connected"
283
-
284
- // Notify listeners that we're back
285
- for (const cb of reconnectCallbacks) {
286
- try {
287
- cb()
288
- } catch {
289
- /* ignore listener errors */
293
+ reconnect() {
294
+ if (state === "closed") return Promise.reject(new Error("Connection is permanently closed"))
295
+ if (reconnectPromise) return reconnectPromise
296
+ reconnectPromise = (async () => {
297
+ state = "reconnecting"
298
+ channel.close()
299
+ for (const callback of disconnectCallbacks) {
300
+ try { callback() } catch { /* Listener failures cannot prevent recovery. */ }
301
+ }
302
+ let attempt = 0
303
+ while (state === "reconnecting") {
304
+ attempt++
305
+ try {
306
+ currentServerIndex++
307
+ channel = createGrpcChannel()
308
+ // Channel construction is lazy. Only report recovery once the new
309
+ // transport is ready, otherwise a dead server falsely "succeeds".
310
+ await waitForChannelReady(channel, new Date(Date.now() + resolvedConfig.keepAliveTimeoutMs))
311
+ if ((state as ConnectionState) === "closed") return
312
+ clients = createClients()
313
+ state = "connected"
314
+ for (const callback of reconnectCallbacks) {
315
+ try { callback() } catch { /* Listener failures are isolated. */ }
290
316
  }
317
+ return
318
+ } catch (error) {
319
+ channel.close()
320
+ if ((state as ConnectionState) === "closed") return
321
+ if (resolvedConfig.maxReconnectAttempts > 0 && attempt >= resolvedConfig.maxReconnectAttempts) {
322
+ state = "disconnected"
323
+ throw new Error(`Failed to reconnect after ${attempt} attempts: ${error}`)
324
+ }
325
+ const backoff = Math.min(resolvedConfig.reconnectIntervalMs * 2 ** (attempt - 1), 30000)
326
+ await new Promise<void>((resolve) => {
327
+ finishRetryDelay = resolve
328
+ retryTimer = setTimeout(resolve, backoff * (0.75 + Math.random() * 0.5))
329
+ })
330
+ finishRetryDelay = undefined
291
331
  }
292
- return
293
- } catch (err) {
294
- if (maxAttempts > 0 && attempt >= maxAttempts) {
295
- state = "disconnected"
296
- throw new Error(`Failed to reconnect after ${attempt} attempts: ${err}`)
297
- }
298
-
299
- // Exponential backoff: base interval * 2^attempt, capped at 30s
300
- const delay = Math.min(
301
- resolvedConfig.reconnectIntervalMs * Math.pow(2, attempt - 1),
302
- 30000,
303
- )
304
- await new Promise((r) => setTimeout(r, delay))
305
332
  }
306
- }
333
+ })().finally(() => { reconnectPromise = undefined })
334
+ return reconnectPromise
335
+
307
336
  },
308
337
  }
309
338
 
@@ -326,6 +355,8 @@ export function connectToAxonServer(config: AxonServerConnectionConfig): AxonSer
326
355
  * their honest two-argument shapes.
327
356
  */
328
357
  export type AxonServerConnectionOptions = AxonServerConnectionConfig & {
358
+ /** Maximum graceful drain time; transport closes even when this expires. Default: 30000ms. */
359
+ shutdownTimeoutMs?: number
329
360
  /** Payload codec for every message this client exchanges with Axon Server. */
330
361
  serializer: Serializer
331
362
  /** Retry / health-check policy for the initial connect and stream re-establishment. */
@@ -516,11 +547,12 @@ export async function axonServerConnection(
516
547
  },
517
548
 
518
549
  async close() {
519
- await shutdown.initiateShutdown()
520
- // Idempotent, and independent of `control.close()` a connection that was
521
- // never administered still stops a platform stream someone else started.
522
- platform.stop()
523
- connection.close()
550
+ try {
551
+ await withMessagingTimeout(shutdown.initiateShutdown(), options.shutdownTimeoutMs ?? 30000, "Messaging shutdown")
552
+ } finally {
553
+ platform.stop()
554
+ connection.close()
555
+ }
524
556
  },
525
557
  }
526
558
  }
@@ -6,8 +6,9 @@
6
6
  * Axon Server's admin surface needs from a client:
7
7
  *
8
8
  * 1. inbound — Axon Server pushes processor instructions (pause-processor,
9
- * start-processor, release-segment, split-segment, merge-segment) which
10
- * have to be routed to the live processor of that name;
9
+ * start-processor) which have to be routed to the live processor of that
10
+ * name; segment instructions (split, merge, release) are ignored, a kronos
11
+ * processor has one lane;
11
12
  * 2. outbound — the client periodically reports each processor's status so
12
13
  * the Axon Dashboard can render it.
13
14
  *
@@ -39,19 +40,12 @@
39
40
  * caller to get wrong.
40
41
  */
41
42
  import type { AxonServerPlatformSource } from "./connection.js"
42
- import type { ProcessorStatus, SegmentStatus } from "./event-processor-info.js"
43
+ import type { ProcessorStatus } from "./event-processor-info.js"
43
44
 
44
45
  /**
45
- * A processor Axon Server is allowed to observe and control.
46
- *
47
- * The container version reached for `app.processors()` and cast the result to
48
- * `any` before poking at `start` / `stop` / `releaseSegment` / … — this is that
49
- * cast, written down. Both `TrackingEventProcessor` and
50
- * `StreamingEventProcessor` satisfy it structurally; anything else that can
51
- * name itself and answer some of these calls does too. Every member past the
52
- * name is optional because Axon Server asks for things a given processor kind
53
- * may not implement (a subscribing processor has no segments), and the
54
- * instruction handler simply skips what is absent.
46
+ * A processor Axon Server is allowed to observe and control. `RunningProcessor`
47
+ * satisfies it structurally; everything past `name` is optional so a foreign
48
+ * processor-shaped object is skipped rather than crashed on.
55
49
  */
56
50
  export type ManagedEventProcessor = {
57
51
  readonly name: string
@@ -60,19 +54,12 @@ export type ManagedEventProcessor = {
60
54
  readonly position?: bigint
61
55
  start?(): Promise<void> | void
62
56
  stop?(): void
63
- supportsReset?(): boolean
64
- processingStatus?(): ReadonlyMap<
65
- number,
66
- {
67
- readonly position?: bigint
68
- readonly caughtUp?: boolean
69
- readonly replaying?: boolean
70
- readonly error?: Error
71
- }
72
- >
73
- releaseSegment?(segmentId: number): Promise<unknown> | unknown
74
- splitSegment?(segmentId: number): Promise<unknown> | unknown
75
- mergeSegment?(segmentId: number): Promise<unknown> | unknown
57
+ status?(): {
58
+ readonly caughtUp?: boolean
59
+ readonly replaying?: boolean
60
+ readonly position?: bigint
61
+ readonly error?: Error
62
+ }
76
63
  }
77
64
 
78
65
  /**
@@ -92,37 +79,14 @@ export type AxonServerControlPlane = {
92
79
  /** Map the managed processors into the status shape the platform stream reports. */
93
80
  function processorStatuses(processors: Iterable<ManagedEventProcessor>): ProcessorStatus[] {
94
81
  return Array.from(processors, (proc) => {
95
- const isStreamingProcessor = proc.supportsReset?.() !== false
96
- const perSegment = proc.processingStatus?.()
97
- const segments: SegmentStatus[] = perSegment
98
- ? Array.from(perSegment.entries()).map(([segmentId, status]) => ({
99
- segmentId,
100
- caughtUp: status.caughtUp ?? false,
101
- replaying: status.replaying ?? false,
102
- onePartOf: 1,
103
- tokenPosition: status.position ?? 0n,
104
- errorState: status.error?.message ?? "",
105
- }))
106
- : [
107
- {
108
- segmentId: 0,
109
- caughtUp: true,
110
- replaying: proc.replaying ?? false,
111
- onePartOf: 1,
112
- tokenPosition: proc.position ?? 0n,
113
- errorState: "",
114
- },
115
- ]
82
+ const status = proc.status?.()
116
83
  return {
117
84
  name: proc.name,
118
85
  running: proc.running ?? false,
119
- mode: isStreamingProcessor ? "Tracking" : "Subscribing",
120
- isStreamingProcessor,
121
- activeThreads: proc.running ? 1 : 0,
122
- availableThreads: 0,
123
- error: false,
124
- tokenStoreIdentifier: "",
125
- segments,
86
+ caughtUp: status?.caughtUp ?? true,
87
+ replaying: status?.replaying ?? proc.replaying ?? false,
88
+ position: status?.position ?? proc.position ?? 0n,
89
+ error: status?.error?.message,
126
90
  }
127
91
  })
128
92
  }
@@ -167,15 +131,7 @@ export async function axonServerControlPlane(
167
131
  case "start-processor":
168
132
  await byName.get(instruction.processorName)?.start?.()
169
133
  break
170
- case "release-segment":
171
- await byName.get(instruction.processorName)?.releaseSegment?.(instruction.segmentId)
172
- break
173
- case "split-segment":
174
- await byName.get(instruction.processorName)?.splitSegment?.(instruction.segmentId)
175
- break
176
- case "merge-segment":
177
- await byName.get(instruction.processorName)?.mergeSegment?.(instruction.segmentId)
178
- break
134
+ // release/split/merge-segment: a kronos processor has one lane. Ignored.
179
135
  }
180
136
  })
181
137
 
@@ -1,62 +1,41 @@
1
- import type { EventProcessorInfo, EventProcessorInfo_SegmentStatus } from "./generated/control.js"
1
+ import type { EventProcessorInfo } from "./generated/control.js"
2
2
 
3
3
  /**
4
- * Status of a single event processor, reported to Axon Server.
5
- * Aligned with Java's EventProcessorInfo proto message.
4
+ * What a processor reports about itself. SIX FIELDS — see the kronosdb
5
+ * package's twin for why the wire's thread counts, token-store identifier and
6
+ * per-segment list are filled with constants here rather than carried.
6
7
  */
7
8
  export type ProcessorStatus = {
8
9
  readonly name: string
9
10
  readonly running: boolean
10
- readonly mode: "Tracking" | "Subscribing"
11
- readonly isStreamingProcessor: boolean
12
- readonly activeThreads: number
13
- readonly availableThreads: number
14
- readonly error: boolean
15
- readonly errorMessage?: string
16
- readonly tokenStoreIdentifier: string
17
- readonly segments: SegmentStatus[]
18
- }
19
-
20
- export type SegmentStatus = {
21
- readonly segmentId: number
22
11
  readonly caughtUp: boolean
23
12
  readonly replaying: boolean
24
- readonly onePartOf: number
25
- readonly tokenPosition: bigint
26
- readonly errorState: string
13
+ readonly position: bigint
14
+ readonly error?: string
27
15
  }
28
16
 
29
- /**
30
- * Converts a ProcessorStatus to the proto EventProcessorInfo format.
31
- */
32
17
  export function toEventProcessorInfo(status: ProcessorStatus): EventProcessorInfo {
33
18
  return {
34
19
  processorName: status.name,
35
- mode: status.mode,
36
- activeThreads: status.activeThreads,
20
+ mode: "Tracking",
21
+ activeThreads: status.running ? 1 : 0,
37
22
  running: status.running,
38
- error: status.error,
39
- segmentStatus: status.segments.map(toSegmentStatus),
40
- availableThreads: status.availableThreads,
41
- tokenStoreIdentifier: status.tokenStoreIdentifier,
42
- isStreamingProcessor: status.isStreamingProcessor,
23
+ error: status.error !== undefined,
24
+ segmentStatus: [
25
+ {
26
+ segmentId: 0,
27
+ caughtUp: status.caughtUp,
28
+ replaying: status.replaying,
29
+ onePartOf: 1,
30
+ tokenPosition: status.position,
31
+ errorState: status.error ?? "",
32
+ },
33
+ ],
34
+ availableThreads: 0,
35
+ tokenStoreIdentifier: "",
36
+ isStreamingProcessor: true,
43
37
  loadBalancingStrategyName: "",
44
38
  }
45
39
  }
46
40
 
47
- function toSegmentStatus(seg: SegmentStatus): EventProcessorInfo_SegmentStatus {
48
- return {
49
- segmentId: seg.segmentId,
50
- caughtUp: seg.caughtUp,
51
- replaying: seg.replaying,
52
- onePartOf: seg.onePartOf,
53
- tokenPosition: seg.tokenPosition,
54
- errorState: seg.errorState,
55
- }
56
- }
57
-
58
- /**
59
- * Supplier function that returns the current status of all event processors.
60
- * Registered with the platform connection for periodic reporting.
61
- */
62
41
  export type ProcessorStatusSupplier = () => ProcessorStatus[]
@@ -33,29 +33,49 @@ export function flowControlledSender<T>(
33
33
  onError?: (error: Error) => void,
34
34
  maxBufferSize: number = 256,
35
35
  ): FlowControlledSender<T> {
36
+ if (!Number.isSafeInteger(maxBufferSize) || maxBufferSize <= 0) throw new RangeError("maxBufferSize must be a positive integer")
36
37
  const buffer: T[] = []
37
38
  let permits = 0
38
39
  let isActive = true
40
+ let completionRequested = false
41
+
42
+ function fail(error: Error) {
43
+ if (!isActive) return
44
+ isActive = false
45
+ buffer.length = 0
46
+ onError?.(error)
47
+ }
48
+ function sendOne(value: T) {
49
+ try { send(value) }
50
+ catch (error) {
51
+ fail(error instanceof Error ? error : new Error(String(error)))
52
+ throw error
53
+ }
54
+ }
55
+ function finishIfDrained() {
56
+ if (isActive && completionRequested && !buffer.length) {
57
+ isActive = false
58
+ onComplete?.()
59
+ }
60
+ }
61
+
39
62
 
40
63
  function drain() {
41
64
  while (permits > 0 && buffer.length > 0 && isActive) {
42
65
  const value = buffer.shift()!
43
66
  permits--
44
- try {
45
- send(value)
46
- } catch (err) {
47
- console.warn("FlowControlledSender: send error", err)
48
- }
67
+ sendOne(value)
49
68
  }
69
+ finishIfDrained()
50
70
  }
51
71
 
52
72
  return {
53
73
  offer(value: T): boolean {
54
- if (!isActive) return false
74
+ if (!isActive || completionRequested) return false
55
75
 
56
76
  if (permits > 0) {
57
77
  permits--
58
- send(value)
78
+ sendOne(value)
59
79
  return true
60
80
  }
61
81
 
@@ -68,24 +88,23 @@ export function flowControlledSender<T>(
68
88
  },
69
89
 
70
90
  addPermits(count: number) {
91
+ if (!Number.isSafeInteger(count) || count <= 0 || !Number.isSafeInteger(permits + count)) throw new RangeError("Permits must be positive safe integers")
92
+ if (!isActive) return
71
93
  permits += count
72
94
  drain()
73
95
  },
74
96
 
75
97
  complete() {
76
- isActive = false
77
- buffer.length = 0
78
- if (onComplete) onComplete()
98
+ completionRequested = true
99
+ finishIfDrained()
79
100
  },
80
101
 
81
102
  completeExceptionally(error: Error) {
82
- isActive = false
83
- buffer.length = 0
84
- if (onError) onError(error)
103
+ fail(error)
85
104
  },
86
105
 
87
106
  get active() {
88
- return isActive
107
+ return isActive && !completionRequested
89
108
  },
90
109
  }
91
110
  }
package/src/index.ts CHANGED
@@ -37,7 +37,6 @@ export {
37
37
 
38
38
  export {
39
39
  type ProcessorStatus,
40
- type SegmentStatus,
41
40
  type ProcessorStatusSupplier,
42
41
  toEventProcessorInfo,
43
42
  } from "./event-processor-info.js"
@@ -1,52 +1,82 @@
1
- /**
2
- * A queue-backed async iterable for feeding outbound messages to a
3
- * bidirectional gRPC stream. Buffers messages when the stream isn't
4
- * consuming, and resolves promises when the stream is waiting.
5
- */
1
+ /** Bounded single-consumer queue feeding a gRPC request stream. */
6
2
  export type OutboundStream<T> = {
7
- /** Send a message into the stream. */
8
3
  send(message: T): void
9
- /** The async iterable to pass to the gRPC client. */
10
4
  readonly iterable: AsyncIterable<T>
11
- /** Close the stream. */
5
+ /** Wait until the consumer has requested the next frame after this batch. Not a server acknowledgement. */
6
+ flush(): Promise<void>
7
+ readonly buffered: number
12
8
  close(): void
13
9
  }
14
10
 
15
- export function outboundStream<T>(): OutboundStream<T> {
16
- let resolve: ((value: IteratorResult<T>) => void) | null = null
11
+ export function outboundStream<T>(maxBuffered = 4096): OutboundStream<T> {
12
+ if (!Number.isSafeInteger(maxBuffered) || maxBuffered <= 0) throw new RangeError("maxBuffered must be a positive integer")
13
+ let resolve: ((value: IteratorResult<T>) => void) | undefined
17
14
  const queue: T[] = []
18
15
  let closed = false
19
-
16
+ let claimed = false
17
+ let handingOff = false
18
+ let flushed: { promise: Promise<void>; resolve(): void; reject(error: Error): void } | undefined
19
+ function acknowledgeRead() {
20
+ handingOff = false
21
+ if (!queue.length) { flushed?.resolve(); flushed = undefined }
22
+ }
23
+ const done = (): IteratorResult<T> => ({ value: undefined, done: true })
24
+ function close() {
25
+ closed = true
26
+ if (!queue.length && !handingOff) { flushed?.resolve(); flushed = undefined }
27
+ resolve?.(done())
28
+ resolve = undefined
29
+ }
20
30
  return {
21
- send(message: T) {
31
+ get buffered() { return queue.length },
32
+ flush() {
33
+ if (!queue.length && !handingOff) return Promise.resolve()
34
+ if (closed) return Promise.reject(new Error("Outbound stream closed before flush"))
35
+ if (!flushed) {
36
+ let resolve!: () => void, reject!: (error: Error) => void
37
+ const promise = new Promise<void>((a, b) => { resolve = a; reject = b })
38
+ flushed = { promise, resolve, reject }
39
+ }
40
+ return flushed.promise
41
+ },
42
+ send(message) {
43
+ if (closed) throw new Error("Outbound stream is closed")
22
44
  if (resolve) {
23
- const r = resolve
24
- resolve = null
25
- r({ value: message, done: false })
45
+ const waiter = resolve
46
+ resolve = undefined
47
+ handingOff = true
48
+ waiter({ value: message, done: false })
26
49
  } else {
50
+ if (queue.length >= maxBuffered) throw new Error("Outbound stream buffer overflow")
27
51
  queue.push(message)
28
52
  }
29
53
  },
30
-
31
54
  iterable: {
32
55
  [Symbol.asyncIterator]() {
56
+ if (claimed) throw new Error("Outbound stream supports one consumer")
57
+ claimed = true
33
58
  return {
34
59
  next(): Promise<IteratorResult<T>> {
35
- const queued = queue.shift()
36
- if (queued) return Promise.resolve({ value: queued, done: false })
37
- if (closed) return Promise.resolve({ value: undefined as any, done: true })
60
+ acknowledgeRead()
61
+ if (queue.length) {
62
+ handingOff = true
63
+ return Promise.resolve({ value: queue.shift()!, done: false })
64
+ }
65
+ if (closed) return Promise.resolve(done())
66
+ if (resolve) return Promise.reject(new Error("Concurrent outbound stream reads are not supported"))
38
67
  return new Promise((r) => { resolve = r })
39
68
  },
69
+ return(): Promise<IteratorResult<T>> {
70
+ queue.length = 0
71
+ flushed?.reject(new Error("Outbound stream cancelled before flush"))
72
+ flushed = undefined
73
+ handingOff = false
74
+ close()
75
+ return Promise.resolve(done())
76
+ },
40
77
  }
41
78
  },
42
79
  },
43
-
44
- close() {
45
- closed = true
46
- if (resolve) {
47
- resolve({ value: undefined as any, done: true })
48
- resolve = null
49
- }
50
- },
80
+ close,
51
81
  }
52
82
  }