@s2-dev/streamstore 0.17.6 → 0.18.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 (160) hide show
  1. package/README.md +69 -1
  2. package/dist/cjs/accessTokens.d.ts +3 -2
  3. package/dist/cjs/accessTokens.d.ts.map +1 -1
  4. package/dist/cjs/accessTokens.js +22 -37
  5. package/dist/cjs/accessTokens.js.map +1 -1
  6. package/dist/cjs/basin.d.ts +4 -3
  7. package/dist/cjs/basin.d.ts.map +1 -1
  8. package/dist/cjs/basin.js +7 -5
  9. package/dist/cjs/basin.js.map +1 -1
  10. package/dist/cjs/basins.d.ts +10 -10
  11. package/dist/cjs/basins.d.ts.map +1 -1
  12. package/dist/cjs/basins.js +36 -64
  13. package/dist/cjs/basins.js.map +1 -1
  14. package/dist/cjs/batch-transform.d.ts +1 -1
  15. package/dist/cjs/batch-transform.d.ts.map +1 -1
  16. package/dist/cjs/batch-transform.js +36 -5
  17. package/dist/cjs/batch-transform.js.map +1 -1
  18. package/dist/cjs/common.d.ts +42 -0
  19. package/dist/cjs/common.d.ts.map +1 -1
  20. package/dist/cjs/error.d.ts +40 -2
  21. package/dist/cjs/error.d.ts.map +1 -1
  22. package/dist/cjs/error.js +268 -2
  23. package/dist/cjs/error.js.map +1 -1
  24. package/dist/cjs/generated/client/types.gen.d.ts +7 -0
  25. package/dist/cjs/generated/client/types.gen.d.ts.map +1 -1
  26. package/dist/cjs/generated/client/utils.gen.d.ts +1 -0
  27. package/dist/cjs/generated/client/utils.gen.d.ts.map +1 -1
  28. package/dist/cjs/generated/client/utils.gen.js.map +1 -1
  29. package/dist/cjs/generated/core/types.gen.d.ts +2 -0
  30. package/dist/cjs/generated/core/types.gen.d.ts.map +1 -1
  31. package/dist/cjs/index.d.ts +46 -3
  32. package/dist/cjs/index.d.ts.map +1 -1
  33. package/dist/cjs/index.js +28 -2
  34. package/dist/cjs/index.js.map +1 -1
  35. package/dist/cjs/lib/result.d.ts +57 -0
  36. package/dist/cjs/lib/result.d.ts.map +1 -0
  37. package/dist/cjs/lib/result.js +43 -0
  38. package/dist/cjs/lib/result.js.map +1 -0
  39. package/dist/cjs/lib/retry.d.ts +151 -0
  40. package/dist/cjs/lib/retry.d.ts.map +1 -0
  41. package/dist/cjs/lib/retry.js +839 -0
  42. package/dist/cjs/lib/retry.js.map +1 -0
  43. package/dist/cjs/lib/stream/factory.d.ts +0 -1
  44. package/dist/cjs/lib/stream/factory.d.ts.map +1 -1
  45. package/dist/cjs/lib/stream/factory.js +0 -1
  46. package/dist/cjs/lib/stream/factory.js.map +1 -1
  47. package/dist/cjs/lib/stream/transport/fetch/index.d.ts +24 -32
  48. package/dist/cjs/lib/stream/transport/fetch/index.d.ts.map +1 -1
  49. package/dist/cjs/lib/stream/transport/fetch/index.js +247 -187
  50. package/dist/cjs/lib/stream/transport/fetch/index.js.map +1 -1
  51. package/dist/cjs/lib/stream/transport/fetch/shared.d.ts +1 -2
  52. package/dist/cjs/lib/stream/transport/fetch/shared.d.ts.map +1 -1
  53. package/dist/cjs/lib/stream/transport/fetch/shared.js +49 -72
  54. package/dist/cjs/lib/stream/transport/fetch/shared.js.map +1 -1
  55. package/dist/cjs/lib/stream/transport/s2s/index.d.ts +0 -1
  56. package/dist/cjs/lib/stream/transport/s2s/index.d.ts.map +1 -1
  57. package/dist/cjs/lib/stream/transport/s2s/index.js +309 -352
  58. package/dist/cjs/lib/stream/transport/s2s/index.js.map +1 -1
  59. package/dist/cjs/lib/stream/types.d.ts +102 -8
  60. package/dist/cjs/lib/stream/types.d.ts.map +1 -1
  61. package/dist/cjs/metrics.d.ts +3 -2
  62. package/dist/cjs/metrics.d.ts.map +1 -1
  63. package/dist/cjs/metrics.js +24 -39
  64. package/dist/cjs/metrics.js.map +1 -1
  65. package/dist/cjs/s2.d.ts +1 -0
  66. package/dist/cjs/s2.d.ts.map +1 -1
  67. package/dist/cjs/s2.js +14 -3
  68. package/dist/cjs/s2.js.map +1 -1
  69. package/dist/cjs/stream.d.ts +5 -3
  70. package/dist/cjs/stream.d.ts.map +1 -1
  71. package/dist/cjs/stream.js +29 -18
  72. package/dist/cjs/stream.js.map +1 -1
  73. package/dist/cjs/streams.d.ts +10 -10
  74. package/dist/cjs/streams.d.ts.map +1 -1
  75. package/dist/cjs/streams.js +36 -64
  76. package/dist/cjs/streams.js.map +1 -1
  77. package/dist/cjs/utils.d.ts +3 -3
  78. package/dist/cjs/utils.d.ts.map +1 -1
  79. package/dist/cjs/utils.js +3 -3
  80. package/dist/cjs/utils.js.map +1 -1
  81. package/dist/esm/accessTokens.d.ts +3 -2
  82. package/dist/esm/accessTokens.d.ts.map +1 -1
  83. package/dist/esm/accessTokens.js +23 -38
  84. package/dist/esm/accessTokens.js.map +1 -1
  85. package/dist/esm/basin.d.ts +4 -3
  86. package/dist/esm/basin.d.ts.map +1 -1
  87. package/dist/esm/basin.js +7 -5
  88. package/dist/esm/basin.js.map +1 -1
  89. package/dist/esm/basins.d.ts +10 -10
  90. package/dist/esm/basins.d.ts.map +1 -1
  91. package/dist/esm/basins.js +37 -65
  92. package/dist/esm/basins.js.map +1 -1
  93. package/dist/esm/batch-transform.d.ts +1 -1
  94. package/dist/esm/batch-transform.d.ts.map +1 -1
  95. package/dist/esm/batch-transform.js +37 -6
  96. package/dist/esm/batch-transform.js.map +1 -1
  97. package/dist/esm/common.d.ts +42 -0
  98. package/dist/esm/common.d.ts.map +1 -1
  99. package/dist/esm/error.d.ts +40 -2
  100. package/dist/esm/error.d.ts.map +1 -1
  101. package/dist/esm/error.js +260 -2
  102. package/dist/esm/error.js.map +1 -1
  103. package/dist/esm/generated/client/types.gen.d.ts +7 -0
  104. package/dist/esm/generated/client/types.gen.d.ts.map +1 -1
  105. package/dist/esm/generated/client/utils.gen.d.ts +1 -0
  106. package/dist/esm/generated/client/utils.gen.d.ts.map +1 -1
  107. package/dist/esm/generated/client/utils.gen.js.map +1 -1
  108. package/dist/esm/generated/core/types.gen.d.ts +2 -0
  109. package/dist/esm/generated/core/types.gen.d.ts.map +1 -1
  110. package/dist/esm/index.d.ts +46 -3
  111. package/dist/esm/index.d.ts.map +1 -1
  112. package/dist/esm/index.js +23 -1
  113. package/dist/esm/index.js.map +1 -1
  114. package/dist/esm/lib/result.d.ts +57 -0
  115. package/dist/esm/lib/result.d.ts.map +1 -0
  116. package/dist/esm/lib/result.js +37 -0
  117. package/dist/esm/lib/result.js.map +1 -0
  118. package/dist/esm/lib/retry.d.ts +151 -0
  119. package/dist/esm/lib/retry.d.ts.map +1 -0
  120. package/dist/esm/lib/retry.js +830 -0
  121. package/dist/esm/lib/retry.js.map +1 -0
  122. package/dist/esm/lib/stream/factory.d.ts +0 -1
  123. package/dist/esm/lib/stream/factory.d.ts.map +1 -1
  124. package/dist/esm/lib/stream/factory.js +0 -1
  125. package/dist/esm/lib/stream/factory.js.map +1 -1
  126. package/dist/esm/lib/stream/transport/fetch/index.d.ts +24 -32
  127. package/dist/esm/lib/stream/transport/fetch/index.d.ts.map +1 -1
  128. package/dist/esm/lib/stream/transport/fetch/index.js +247 -187
  129. package/dist/esm/lib/stream/transport/fetch/index.js.map +1 -1
  130. package/dist/esm/lib/stream/transport/fetch/shared.d.ts +1 -2
  131. package/dist/esm/lib/stream/transport/fetch/shared.d.ts.map +1 -1
  132. package/dist/esm/lib/stream/transport/fetch/shared.js +51 -74
  133. package/dist/esm/lib/stream/transport/fetch/shared.js.map +1 -1
  134. package/dist/esm/lib/stream/transport/s2s/index.d.ts +0 -1
  135. package/dist/esm/lib/stream/transport/s2s/index.d.ts.map +1 -1
  136. package/dist/esm/lib/stream/transport/s2s/index.js +310 -353
  137. package/dist/esm/lib/stream/transport/s2s/index.js.map +1 -1
  138. package/dist/esm/lib/stream/types.d.ts +102 -8
  139. package/dist/esm/lib/stream/types.d.ts.map +1 -1
  140. package/dist/esm/metrics.d.ts +3 -2
  141. package/dist/esm/metrics.d.ts.map +1 -1
  142. package/dist/esm/metrics.js +25 -40
  143. package/dist/esm/metrics.js.map +1 -1
  144. package/dist/esm/s2.d.ts +1 -0
  145. package/dist/esm/s2.d.ts.map +1 -1
  146. package/dist/esm/s2.js +14 -3
  147. package/dist/esm/s2.js.map +1 -1
  148. package/dist/esm/stream.d.ts +5 -3
  149. package/dist/esm/stream.d.ts.map +1 -1
  150. package/dist/esm/stream.js +30 -19
  151. package/dist/esm/stream.js.map +1 -1
  152. package/dist/esm/streams.d.ts +10 -10
  153. package/dist/esm/streams.d.ts.map +1 -1
  154. package/dist/esm/streams.js +37 -65
  155. package/dist/esm/streams.js.map +1 -1
  156. package/dist/esm/utils.d.ts +3 -3
  157. package/dist/esm/utils.d.ts.map +1 -1
  158. package/dist/esm/utils.js +2 -2
  159. package/dist/esm/utils.js.map +1 -1
  160. package/package.json +4 -2
@@ -0,0 +1,830 @@
1
+ import createDebug from "debug";
2
+ import { abortedError, invariantViolation, S2Error, s2Error, withS2Error, } from "../error.js";
3
+ import { meteredBytes } from "../utils.js";
4
+ import { err, errClose, ok, okClose } from "./result.js";
5
+ const debugWith = createDebug("s2:retry:with");
6
+ const debugRead = createDebug("s2:retry:read");
7
+ const debugSession = createDebug("s2:retry:session");
8
+ /**
9
+ * Default retry configuration.
10
+ */
11
+ export const DEFAULT_RETRY_CONFIG = {
12
+ maxAttempts: 3,
13
+ retryBackoffDurationMillis: 100,
14
+ appendRetryPolicy: "noSideEffects",
15
+ requestTimeoutMillis: 5000, // 5 seconds
16
+ };
17
+ const RETRYABLE_STATUS_CODES = new Set([
18
+ 408, // request_timeout
19
+ 429, // too_many_requests
20
+ 500, // internal_server_error
21
+ 502, // bad_gateway
22
+ 503, // service_unavailable
23
+ ]);
24
+ /**
25
+ * Determines if an error should be retried based on its characteristics.
26
+ * 400-level errors (except 408, 429) are non-retryable validation/client errors.
27
+ */
28
+ export function isRetryable(error) {
29
+ if (!error.status)
30
+ return false;
31
+ // Explicit retryable codes (including some 4xx like 408, 429)
32
+ if (RETRYABLE_STATUS_CODES.has(error.status)) {
33
+ return true;
34
+ }
35
+ // 400-level errors are generally non-retryable (validation, bad request)
36
+ if (error.status >= 400 && error.status < 500) {
37
+ return false;
38
+ }
39
+ return false;
40
+ }
41
+ /**
42
+ * Calculates the delay before the next retry attempt using fixed backoff
43
+ * with jitter. The `attempt` parameter is currently ignored to keep a
44
+ * constant base delay per attempt.
45
+ */
46
+ export function calculateDelay(attempt, baseDelayMillis) {
47
+ // Apply ±50% jitter around the base delay
48
+ const jitterRange = 0.5; // 50% up or down
49
+ const factor = 1 + (Math.random() * 2 - 1) * jitterRange; // [0.5, 1.5]
50
+ const delay = Math.max(0, baseDelayMillis * factor);
51
+ return Math.floor(delay);
52
+ }
53
+ /**
54
+ * Sleeps for the specified duration.
55
+ */
56
+ export function sleep(ms) {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+ /**
60
+ * Executes an async function with automatic retry logic for transient failures.
61
+ *
62
+ * @param retryConfig Retry configuration (max attempts, backoff duration)
63
+ * @param fn The async function to execute
64
+ * @returns The result of the function
65
+ * @throws The last error if all retry attempts are exhausted
66
+ */
67
+ export async function withRetries(retryConfig, fn, isPolicyCompliant = () => true) {
68
+ const config = {
69
+ ...DEFAULT_RETRY_CONFIG,
70
+ ...retryConfig,
71
+ };
72
+ // Enforce minimum of 1 attempt (1 = no retries)
73
+ if (config.maxAttempts < 1)
74
+ config.maxAttempts = 1;
75
+ let lastError = undefined;
76
+ // attemptNo is 1-based: 1..maxAttempts
77
+ for (let attemptNo = 1; attemptNo <= config.maxAttempts; attemptNo++) {
78
+ try {
79
+ const result = await fn();
80
+ if (attemptNo > 1) {
81
+ debugWith("succeeded after %d retries", attemptNo - 1);
82
+ }
83
+ return result;
84
+ }
85
+ catch (error) {
86
+ // withRetry only handles S2Errors (withS2Error should be called first)
87
+ if (!(error instanceof S2Error)) {
88
+ debugWith("non-S2Error thrown, rethrowing immediately: %s", error);
89
+ throw error;
90
+ }
91
+ lastError = error;
92
+ // Don't retry if this is the last attempt
93
+ if (attemptNo === config.maxAttempts) {
94
+ debugWith("max attempts exhausted, throwing error");
95
+ break;
96
+ }
97
+ // Check if error is retryable
98
+ if (!isPolicyCompliant(config, lastError) || !isRetryable(lastError)) {
99
+ debugWith("error not retryable, throwing immediately");
100
+ throw error;
101
+ }
102
+ // Calculate delay and wait before retrying
103
+ const delay = calculateDelay(attemptNo - 1, config.retryBackoffDurationMillis);
104
+ debugWith("retryable error, backing off for %dms, status=%s", delay, error.status);
105
+ await sleep(delay);
106
+ }
107
+ }
108
+ throw lastError;
109
+ }
110
+ export class RetryReadSession extends ReadableStream {
111
+ _nextReadPosition = undefined;
112
+ _lastObservedTail = undefined;
113
+ _recordsRead = 0;
114
+ _bytesRead = 0;
115
+ static async create(generator, args = {}, config) {
116
+ return new RetryReadSession(args, generator, config);
117
+ }
118
+ constructor(args, generator, config) {
119
+ const retryConfig = {
120
+ ...DEFAULT_RETRY_CONFIG,
121
+ ...config,
122
+ };
123
+ let session = undefined;
124
+ const startTimeMs = performance.now(); // Capture start time before super()
125
+ super({
126
+ start: async (controller) => {
127
+ let nextArgs = { ...args };
128
+ // Capture original request budget so retries compute from a stable baseline
129
+ const baselineCount = args?.count;
130
+ const baselineBytes = args?.bytes;
131
+ const baselineWait = args?.wait;
132
+ let attempt = 0;
133
+ while (true) {
134
+ debugRead("starting read session with args: %o", nextArgs);
135
+ session = await generator(nextArgs);
136
+ const reader = session.getReader();
137
+ while (true) {
138
+ const { done, value: result } = await reader.read();
139
+ // Update last observed tail if transport exposes it
140
+ try {
141
+ const tail = session.lastObservedTail?.();
142
+ if (tail)
143
+ this._lastObservedTail = tail;
144
+ }
145
+ catch { }
146
+ if (done) {
147
+ reader.releaseLock();
148
+ controller.close();
149
+ return;
150
+ }
151
+ // Check if result is an error
152
+ if (!result.ok) {
153
+ reader.releaseLock();
154
+ const error = result.error;
155
+ // Check if we can retry (track session attempts, not record reads)
156
+ const effectiveMax = Math.max(1, retryConfig.maxAttempts);
157
+ if (isRetryable(error) && attempt < effectiveMax - 1) {
158
+ if (this._nextReadPosition) {
159
+ nextArgs.seq_num = this._nextReadPosition.seq_num;
160
+ // Clear alternative start position fields to avoid conflicting params
161
+ delete nextArgs.timestamp;
162
+ delete nextArgs.tail_offset;
163
+ }
164
+ // Compute planned backoff delay now so we can subtract it from wait budget
165
+ const delay = calculateDelay(attempt, retryConfig.retryBackoffDurationMillis);
166
+ // Recompute remaining budget from original request each time to avoid double-subtraction
167
+ if (baselineCount !== undefined) {
168
+ nextArgs.count = Math.max(0, baselineCount - this._recordsRead);
169
+ }
170
+ if (baselineBytes !== undefined) {
171
+ nextArgs.bytes = Math.max(0, baselineBytes - this._bytesRead);
172
+ }
173
+ // Adjust wait from original budget based on total elapsed time since start
174
+ if (baselineWait !== undefined) {
175
+ const elapsedSeconds = (performance.now() - startTimeMs) / 1000;
176
+ nextArgs.wait = Math.max(0, baselineWait - (elapsedSeconds + delay / 1000));
177
+ }
178
+ // Proactively cancel the current transport session before retrying
179
+ try {
180
+ await session.cancel?.("retry");
181
+ }
182
+ catch { }
183
+ debugRead("will retry after %dms, status=%s", delay, error.status);
184
+ await sleep(delay);
185
+ attempt++;
186
+ break; // Break inner loop to retry
187
+ }
188
+ // Error is not retryable or attempts exhausted
189
+ debugRead("error in retry loop: %s", error);
190
+ controller.error(error);
191
+ return;
192
+ }
193
+ // Success: enqueue the record and reset retry attempt counter
194
+ const record = result.value;
195
+ this._nextReadPosition = {
196
+ seq_num: record.seq_num + 1,
197
+ timestamp: record.timestamp,
198
+ };
199
+ this._recordsRead++;
200
+ this._bytesRead += meteredBytes(record);
201
+ attempt = 0;
202
+ controller.enqueue(record);
203
+ }
204
+ }
205
+ },
206
+ cancel: async (reason) => {
207
+ try {
208
+ await session?.cancel(reason);
209
+ }
210
+ catch (err) {
211
+ // Ignore ERR_INVALID_STATE - stream may already be closed/cancelled
212
+ if (err?.code !== "ERR_INVALID_STATE") {
213
+ throw err;
214
+ }
215
+ }
216
+ },
217
+ });
218
+ }
219
+ async [Symbol.asyncDispose]() {
220
+ await this.cancel("disposed");
221
+ }
222
+ // Polyfill for older browsers / Node.js environments
223
+ [Symbol.asyncIterator]() {
224
+ const fn = ReadableStream.prototype[Symbol.asyncIterator];
225
+ if (typeof fn === "function")
226
+ return fn.call(this);
227
+ const reader = this.getReader();
228
+ return {
229
+ next: async () => {
230
+ const r = await reader.read();
231
+ if (r.done) {
232
+ reader.releaseLock();
233
+ return { done: true, value: undefined };
234
+ }
235
+ return { done: false, value: r.value };
236
+ },
237
+ throw: async (e) => {
238
+ try {
239
+ await reader.cancel(e);
240
+ }
241
+ catch (err) {
242
+ if (err?.code !== "ERR_INVALID_STATE")
243
+ throw err;
244
+ }
245
+ reader.releaseLock();
246
+ return { done: true, value: undefined };
247
+ },
248
+ return: async () => {
249
+ try {
250
+ await reader.cancel("done");
251
+ }
252
+ catch (err) {
253
+ if (err?.code !== "ERR_INVALID_STATE")
254
+ throw err;
255
+ }
256
+ reader.releaseLock();
257
+ return { done: true, value: undefined };
258
+ },
259
+ [Symbol.asyncIterator]() {
260
+ return this;
261
+ },
262
+ };
263
+ }
264
+ lastObservedTail() {
265
+ return this._lastObservedTail;
266
+ }
267
+ nextReadPosition() {
268
+ return this._nextReadPosition;
269
+ }
270
+ }
271
+ const DEFAULT_MAX_INFLIGHT_BYTES = 10 * 1024 * 1024; // 10 MiB default
272
+ export class RetryAppendSession {
273
+ generator;
274
+ sessionOptions;
275
+ requestTimeoutMillis;
276
+ maxQueuedBytes;
277
+ maxInflightBatches;
278
+ retryConfig;
279
+ inflight = [];
280
+ capacityWaiter; // Single waiter (WritableStream writer lock)
281
+ session;
282
+ queuedBytes = 0;
283
+ pendingBytes = 0;
284
+ consecutiveFailures = 0;
285
+ currentAttempt = 0;
286
+ pumpPromise;
287
+ pumpStopped = false;
288
+ closing = false;
289
+ pumpWakeup;
290
+ closed = false;
291
+ fatalError;
292
+ _lastAckedPosition;
293
+ acksController;
294
+ readable;
295
+ writable;
296
+ /**
297
+ * If the session has failed, returns the original fatal error that caused
298
+ * the pump to stop. Returns undefined when the session has not failed.
299
+ */
300
+ failureCause() {
301
+ return this.fatalError;
302
+ }
303
+ constructor(generator, sessionOptions, config) {
304
+ this.generator = generator;
305
+ this.sessionOptions = sessionOptions;
306
+ this.retryConfig = {
307
+ ...DEFAULT_RETRY_CONFIG,
308
+ ...config,
309
+ };
310
+ this.requestTimeoutMillis = this.retryConfig.requestTimeoutMillis;
311
+ this.maxQueuedBytes =
312
+ this.sessionOptions?.maxInflightBytes ?? DEFAULT_MAX_INFLIGHT_BYTES;
313
+ this.maxInflightBatches = this.sessionOptions?.maxInflightBatches;
314
+ this.readable = new ReadableStream({
315
+ start: (controller) => {
316
+ this.acksController = controller;
317
+ },
318
+ });
319
+ this.writable = new WritableStream({
320
+ write: async (chunk) => {
321
+ const recordsArray = Array.isArray(chunk.records)
322
+ ? chunk.records
323
+ : [chunk.records];
324
+ // Calculate metered size
325
+ let batchMeteredSize = 0;
326
+ for (const record of recordsArray) {
327
+ batchMeteredSize += meteredBytes(record);
328
+ }
329
+ // Wait for capacity (backpressure for writable only)
330
+ await this.waitForCapacity(batchMeteredSize);
331
+ const { records: _records, ...rest } = chunk;
332
+ const args = rest;
333
+ args.precalculatedSize = batchMeteredSize;
334
+ // Move reserved bytes to queued bytes accounting before submission
335
+ this.pendingBytes = Math.max(0, this.pendingBytes - batchMeteredSize);
336
+ // Submit without waiting for ack (writable doesn't need per-batch resolution)
337
+ const promise = this.submitInternal(recordsArray, args, batchMeteredSize);
338
+ promise.catch(() => {
339
+ // Swallow to avoid unhandled rejection; pump surfaces errors via readable stream
340
+ });
341
+ },
342
+ close: async () => {
343
+ await this.close();
344
+ },
345
+ abort: async (reason) => {
346
+ const error = abortedError(`AppendSession aborted: ${reason}`);
347
+ await this.abort(error);
348
+ },
349
+ });
350
+ }
351
+ static async create(generator, sessionOptions, config) {
352
+ return new RetryAppendSession(generator, sessionOptions, config);
353
+ }
354
+ /**
355
+ * Submit an append request. Returns a promise that resolves with the ack.
356
+ * This method does not block on capacity (only writable.write() does).
357
+ */
358
+ async submit(records, args) {
359
+ const recordsArray = Array.isArray(records) ? records : [records];
360
+ // Calculate metered size if not provided
361
+ let batchMeteredSize = args?.precalculatedSize ?? 0;
362
+ if (batchMeteredSize === 0) {
363
+ for (const record of recordsArray) {
364
+ batchMeteredSize += meteredBytes(record);
365
+ }
366
+ }
367
+ const result = await this.submitInternal(recordsArray, args, batchMeteredSize);
368
+ // Convert discriminated union back to throw pattern for public API
369
+ if (result.ok) {
370
+ return result.value;
371
+ }
372
+ else {
373
+ throw result.error;
374
+ }
375
+ }
376
+ /**
377
+ * Internal submit that returns discriminated union.
378
+ * Creates inflight entry and starts pump if needed.
379
+ */
380
+ submitInternal(records, args, batchMeteredSize) {
381
+ if (this.closed || this.closing) {
382
+ return Promise.resolve(err(new S2Error({ message: "AppendSession is closed", status: 400 })));
383
+ }
384
+ // Check for fatal error (e.g., from abort())
385
+ if (this.fatalError) {
386
+ debugSession("[SUBMIT] rejecting due to fatal error: %s", this.fatalError.message);
387
+ return Promise.resolve(err(this.fatalError));
388
+ }
389
+ // Create promise for submit() callers
390
+ return new Promise((resolve) => {
391
+ // Create inflight entry (innerPromise will be set when pump processes it)
392
+ const entry = {
393
+ records,
394
+ args,
395
+ expectedCount: records.length,
396
+ meteredBytes: batchMeteredSize,
397
+ innerPromise: new Promise(() => { }), // Never-resolving placeholder
398
+ maybeResolve: resolve,
399
+ needsSubmit: true, // Mark for pump to submit
400
+ };
401
+ debugSession("[SUBMIT] enqueueing %d records (%d bytes): inflight=%d->%d, queuedBytes=%d->%d", records.length, batchMeteredSize, this.inflight.length, this.inflight.length + 1, this.queuedBytes, this.queuedBytes + batchMeteredSize);
402
+ this.inflight.push(entry);
403
+ this.queuedBytes += batchMeteredSize;
404
+ // Wake pump if it's sleeping
405
+ if (this.pumpWakeup) {
406
+ this.pumpWakeup();
407
+ }
408
+ // Start pump if not already running
409
+ this.ensurePump();
410
+ });
411
+ }
412
+ /**
413
+ * Wait for capacity before allowing write to proceed (writable only).
414
+ */
415
+ async waitForCapacity(bytes) {
416
+ debugSession("[CAPACITY] checking for %d bytes: queuedBytes=%d, pendingBytes=%d, maxQueuedBytes=%d, inflight=%d", bytes, this.queuedBytes, this.pendingBytes, this.maxQueuedBytes, this.inflight.length);
417
+ // Check if we have capacity
418
+ while (true) {
419
+ // Check for fatal error before adding to pendingBytes
420
+ if (this.fatalError) {
421
+ debugSession("[CAPACITY] fatal error detected, rejecting: %s", this.fatalError.message);
422
+ throw this.fatalError;
423
+ }
424
+ // Byte-based gating
425
+ if (this.queuedBytes + this.pendingBytes + bytes <= this.maxQueuedBytes) {
426
+ // Batch-based gating (if configured)
427
+ if (this.maxInflightBatches === undefined ||
428
+ this.inflight.length < this.maxInflightBatches) {
429
+ debugSession("[CAPACITY] capacity available, adding %d to pendingBytes", bytes);
430
+ this.pendingBytes += bytes;
431
+ return;
432
+ }
433
+ }
434
+ // No capacity - wait
435
+ // WritableStream enforces writer lock, so only one write can be blocked at a time
436
+ debugSession("[CAPACITY] no capacity, waiting for release");
437
+ await new Promise((resolve) => {
438
+ this.capacityWaiter = resolve;
439
+ });
440
+ debugSession("[CAPACITY] woke up, rechecking");
441
+ }
442
+ }
443
+ /**
444
+ * Release capacity and wake waiter if present.
445
+ */
446
+ releaseCapacity(bytes) {
447
+ debugSession("[CAPACITY] releasing %d bytes: queuedBytes=%d->%d, pendingBytes=%d->%d, hasWaiter=%s", bytes, this.queuedBytes, this.queuedBytes - bytes, this.pendingBytes, Math.max(0, this.pendingBytes - bytes), !!this.capacityWaiter);
448
+ this.queuedBytes -= bytes;
449
+ this.pendingBytes = Math.max(0, this.pendingBytes - bytes);
450
+ // Wake single waiter
451
+ const waiter = this.capacityWaiter;
452
+ if (waiter) {
453
+ debugSession("[CAPACITY] waking waiter");
454
+ this.capacityWaiter = undefined;
455
+ waiter();
456
+ }
457
+ }
458
+ /**
459
+ * Ensure pump loop is running.
460
+ */
461
+ ensurePump() {
462
+ if (this.pumpPromise || this.pumpStopped) {
463
+ return;
464
+ }
465
+ this.pumpPromise = this.runPump().catch((e) => {
466
+ debugSession("pump crashed unexpectedly: %s", e);
467
+ // This should never happen - pump handles all errors internally
468
+ });
469
+ }
470
+ /**
471
+ * Main pump loop: processes inflight queue, handles acks, retries, and recovery.
472
+ */
473
+ async runPump() {
474
+ debugSession("pump started");
475
+ while (true) {
476
+ debugSession("[PUMP] loop: inflight=%d, queuedBytes=%d, pendingBytes=%d, closing=%s, pumpStopped=%s", this.inflight.length, this.queuedBytes, this.pendingBytes, this.closing, this.pumpStopped);
477
+ // Check if we should stop
478
+ if (this.pumpStopped) {
479
+ debugSession("[PUMP] stopped by flag");
480
+ return;
481
+ }
482
+ // If closing and queue is empty, stop
483
+ if (this.closing && this.inflight.length === 0) {
484
+ debugSession("[PUMP] closing and queue empty, stopping");
485
+ this.pumpStopped = true;
486
+ return;
487
+ }
488
+ // If no entries, sleep and continue
489
+ if (this.inflight.length === 0) {
490
+ debugSession("[PUMP] no entries, sleeping 10ms");
491
+ // Use interruptible sleep - can be woken by new submissions
492
+ await Promise.race([
493
+ sleep(10),
494
+ new Promise((resolve) => {
495
+ this.pumpWakeup = resolve;
496
+ }),
497
+ ]);
498
+ this.pumpWakeup = undefined;
499
+ continue;
500
+ }
501
+ // Get head entry (we know it exists because we checked length above)
502
+ const head = this.inflight[0];
503
+ debugSession("[PUMP] processing head: expectedCount=%d, meteredBytes=%d", head.expectedCount, head.meteredBytes);
504
+ // Ensure session exists
505
+ debugSession("[PUMP] ensuring session exists");
506
+ await this.ensureSession();
507
+ if (!this.session) {
508
+ // Session creation failed - will retry
509
+ debugSession("[PUMP] session creation failed, sleeping 100ms");
510
+ await sleep(100);
511
+ continue;
512
+ }
513
+ // Submit ALL entries that need submitting (enables HTTP/2 pipelining for S2S)
514
+ for (const entry of this.inflight) {
515
+ if (!entry.innerPromise || entry.needsSubmit) {
516
+ debugSession("[PUMP] submitting entry to inner session (%d records, %d bytes)", entry.expectedCount, entry.meteredBytes);
517
+ entry.attemptStartedMonotonicMs = performance.now();
518
+ entry.innerPromise = this.session.submit(entry.records, entry.args);
519
+ delete entry.needsSubmit;
520
+ }
521
+ }
522
+ // Wait for head with timeout
523
+ debugSession("[PUMP] waiting for head result");
524
+ const result = await this.waitForHead(head);
525
+ debugSession("[PUMP] got result: kind=%s", result.kind);
526
+ if (result.kind === "timeout") {
527
+ // Ack timeout - fatal (per-attempt)
528
+ const attemptElapsed = head.attemptStartedMonotonicMs != null
529
+ ? Math.round(performance.now() - head.attemptStartedMonotonicMs)
530
+ : undefined;
531
+ const error = new S2Error({
532
+ message: `Request timeout after ${attemptElapsed ?? "unknown"}ms (${head.expectedCount} records, ${head.meteredBytes} bytes)`,
533
+ status: 408,
534
+ code: "REQUEST_TIMEOUT",
535
+ });
536
+ debugSession("ack timeout for head entry: %s", error.message);
537
+ await this.abort(error);
538
+ return;
539
+ }
540
+ // Promise settled
541
+ const appendResult = result.value;
542
+ if (appendResult.ok) {
543
+ // Success!
544
+ const ack = appendResult.value;
545
+ debugSession("[PUMP] success, got ack", { ack });
546
+ // Invariant check: ack count matches batch count
547
+ const ackCount = Number(ack.end.seq_num) - Number(ack.start.seq_num);
548
+ if (ackCount !== head.expectedCount) {
549
+ const error = invariantViolation(`Ack count mismatch: expected ${head.expectedCount}, got ${ackCount}`);
550
+ debugSession("invariant violation: %s", error.message);
551
+ await this.abort(error);
552
+ return;
553
+ }
554
+ // Invariant check: sequence numbers must be strictly increasing
555
+ if (this._lastAckedPosition) {
556
+ const prevEnd = BigInt(this._lastAckedPosition.end.seq_num);
557
+ const currentEnd = BigInt(ack.end.seq_num);
558
+ if (currentEnd <= prevEnd) {
559
+ const error = invariantViolation(`Sequence number not strictly increasing: previous=${prevEnd}, current=${currentEnd}`);
560
+ debugSession("invariant violation: %s", error.message);
561
+ await this.abort(error);
562
+ return;
563
+ }
564
+ }
565
+ // Update last acked position
566
+ this._lastAckedPosition = ack;
567
+ // Resolve submit() caller if present
568
+ if (head.maybeResolve) {
569
+ head.maybeResolve(ok(ack));
570
+ }
571
+ // Emit to readable stream
572
+ try {
573
+ this.acksController?.enqueue(ack);
574
+ }
575
+ catch (e) {
576
+ debugSession("failed to enqueue ack: %s", e);
577
+ }
578
+ // Remove from inflight and release capacity
579
+ debugSession("[PUMP] removing head from inflight, releasing %d bytes", head.meteredBytes);
580
+ this.inflight.shift();
581
+ this.releaseCapacity(head.meteredBytes);
582
+ // Reset consecutive failures on success
583
+ this.consecutiveFailures = 0;
584
+ this.currentAttempt = 0;
585
+ }
586
+ else {
587
+ // Error result
588
+ const error = appendResult.error;
589
+ debugSession("[PUMP] error: status=%s, message=%s", error.status, error.message);
590
+ // Check if retryable
591
+ if (!isRetryable(error)) {
592
+ debugSession("error not retryable, aborting");
593
+ await this.abort(error);
594
+ return;
595
+ }
596
+ // Check policy compliance
597
+ if (this.retryConfig.appendRetryPolicy === "noSideEffects" &&
598
+ !this.isIdempotent(head)) {
599
+ debugSession("error not policy-compliant (noSideEffects), aborting");
600
+ await this.abort(error);
601
+ return;
602
+ }
603
+ // Check max attempts (total attempts include initial; retries = max - 1)
604
+ const effectiveMax = Math.max(1, this.retryConfig.maxAttempts);
605
+ const allowedRetries = effectiveMax - 1;
606
+ if (this.currentAttempt >= allowedRetries) {
607
+ debugSession("max attempts reached (%d), aborting", effectiveMax);
608
+ const wrappedError = new S2Error({
609
+ message: `Max attempts (${effectiveMax}) exhausted: ${error.message}`,
610
+ status: error.status,
611
+ code: error.code,
612
+ });
613
+ await this.abort(wrappedError);
614
+ return;
615
+ }
616
+ // Perform recovery
617
+ this.consecutiveFailures++;
618
+ this.currentAttempt++;
619
+ debugSession("performing recovery (retry %d/%d)", this.currentAttempt, allowedRetries);
620
+ await this.recover();
621
+ }
622
+ }
623
+ }
624
+ /**
625
+ * Wait for head entry's innerPromise with timeout.
626
+ * Returns either the settled result or a timeout indicator.
627
+ *
628
+ * Per-attempt ack timeout semantics:
629
+ * - The deadline is computed from the most recent (re)submit attempt using
630
+ * a monotonic clock (performance.now) to avoid issues with wall clock
631
+ * adjustments.
632
+ * - If attempt start is missing (for backward compatibility), we measure
633
+ * from "now" with the full timeout window.
634
+ */
635
+ async waitForHead(head) {
636
+ const startMono = head.attemptStartedMonotonicMs ?? performance.now();
637
+ const deadline = startMono + this.requestTimeoutMillis;
638
+ const remaining = Math.max(0, deadline - performance.now());
639
+ let timer;
640
+ const timeoutP = new Promise((resolve) => {
641
+ timer = setTimeout(() => resolve({ kind: "timeout" }), remaining);
642
+ });
643
+ const settledP = head.innerPromise.then((result) => ({
644
+ kind: "settled",
645
+ value: result,
646
+ }));
647
+ try {
648
+ return await Promise.race([settledP, timeoutP]);
649
+ }
650
+ finally {
651
+ if (timer)
652
+ clearTimeout(timer);
653
+ }
654
+ }
655
+ /**
656
+ * Recover from transient error: recreate session and resubmit all inflight entries.
657
+ */
658
+ async recover() {
659
+ debugSession("starting recovery");
660
+ // Calculate backoff delay
661
+ const delay = calculateDelay(this.consecutiveFailures - 1, this.retryConfig.retryBackoffDurationMillis);
662
+ debugSession("backing off for %dms", delay);
663
+ await sleep(delay);
664
+ // Teardown old session
665
+ if (this.session) {
666
+ try {
667
+ const closeResult = await this.session.close();
668
+ if (!closeResult.ok) {
669
+ debugSession("error closing old session during recovery: %s", closeResult.error.message);
670
+ }
671
+ }
672
+ catch (e) {
673
+ debugSession("exception closing old session: %s", e);
674
+ }
675
+ this.session = undefined;
676
+ }
677
+ // Create new session
678
+ await this.ensureSession();
679
+ if (!this.session) {
680
+ debugSession("failed to create new session during recovery");
681
+ // Will retry on next pump iteration
682
+ return;
683
+ }
684
+ // Store session in local variable to help TypeScript type narrowing
685
+ const session = this.session;
686
+ // Resubmit all inflight entries (replace their innerPromise and reset attempt start)
687
+ debugSession("resubmitting %d inflight entries", this.inflight.length);
688
+ for (const entry of this.inflight) {
689
+ // Attach .catch to superseded promise to avoid unhandled rejection
690
+ entry.innerPromise.catch(() => { });
691
+ // Create new promise from new session
692
+ entry.attemptStartedMonotonicMs = performance.now();
693
+ entry.innerPromise = session.submit(entry.records, entry.args);
694
+ }
695
+ debugSession("recovery complete");
696
+ }
697
+ /**
698
+ * Check if append can be retried under noSideEffects policy.
699
+ * For appends, idempotency requires match_seq_num.
700
+ */
701
+ isIdempotent(entry) {
702
+ const args = entry.args;
703
+ if (!args)
704
+ return false;
705
+ return args.match_seq_num !== undefined;
706
+ }
707
+ /**
708
+ * Ensure session exists, creating it if necessary.
709
+ */
710
+ async ensureSession() {
711
+ if (this.session) {
712
+ return;
713
+ }
714
+ try {
715
+ this.session = await this.generator(this.sessionOptions);
716
+ }
717
+ catch (e) {
718
+ const error = s2Error(e);
719
+ debugSession("failed to create session: %s", error.message);
720
+ // Don't set this.session - will retry later
721
+ }
722
+ }
723
+ /**
724
+ * Abort the session with a fatal error.
725
+ */
726
+ async abort(error) {
727
+ if (this.pumpStopped) {
728
+ return; // Already aborted
729
+ }
730
+ debugSession("aborting session: %s", error.message);
731
+ this.fatalError = error;
732
+ this.pumpStopped = true;
733
+ // Resolve all inflight entries with error
734
+ for (const entry of this.inflight) {
735
+ if (entry.maybeResolve) {
736
+ entry.maybeResolve(err(error));
737
+ }
738
+ }
739
+ this.inflight.length = 0;
740
+ this.queuedBytes = 0;
741
+ this.pendingBytes = 0;
742
+ // Error the readable stream
743
+ try {
744
+ this.acksController?.error(error);
745
+ }
746
+ catch (e) {
747
+ debugSession("failed to error acks controller: %s", e);
748
+ }
749
+ // Wake capacity waiter to unblock any pending writer
750
+ if (this.capacityWaiter) {
751
+ this.capacityWaiter();
752
+ this.capacityWaiter = undefined;
753
+ }
754
+ // Close inner session
755
+ if (this.session) {
756
+ try {
757
+ await this.session.close();
758
+ }
759
+ catch (e) {
760
+ debugSession("error closing session during abort: %s", e);
761
+ }
762
+ this.session = undefined;
763
+ }
764
+ }
765
+ /**
766
+ * Close the append session.
767
+ * Waits for all pending appends to complete before resolving.
768
+ * Does not interrupt recovery - allows it to complete.
769
+ */
770
+ async close() {
771
+ if (this.closed) {
772
+ if (this.fatalError) {
773
+ throw this.fatalError;
774
+ }
775
+ return;
776
+ }
777
+ debugSession("close requested");
778
+ this.closing = true;
779
+ // Wake pump if it's sleeping so it can check closing flag
780
+ if (this.pumpWakeup) {
781
+ this.pumpWakeup();
782
+ }
783
+ // Wait for pump to stop (drains inflight queue, including through recovery)
784
+ if (this.pumpPromise) {
785
+ await this.pumpPromise;
786
+ }
787
+ // Close inner session
788
+ if (this.session) {
789
+ try {
790
+ const result = await this.session.close();
791
+ if (!result.ok) {
792
+ debugSession("error closing inner session: %s", result.error.message);
793
+ }
794
+ }
795
+ catch (e) {
796
+ debugSession("exception closing inner session: %s", e);
797
+ }
798
+ this.session = undefined;
799
+ }
800
+ // Close readable stream
801
+ try {
802
+ this.acksController?.close();
803
+ }
804
+ catch (e) {
805
+ debugSession("error closing acks controller: %s", e);
806
+ }
807
+ this.closed = true;
808
+ // If fatal error occurred, throw it
809
+ if (this.fatalError) {
810
+ throw this.fatalError;
811
+ }
812
+ debugSession("close complete");
813
+ }
814
+ async [Symbol.asyncDispose]() {
815
+ await this.close();
816
+ }
817
+ /**
818
+ * Get a stream of acknowledgements for appends.
819
+ */
820
+ acks() {
821
+ return this.readable;
822
+ }
823
+ /**
824
+ * Get the last acknowledged position.
825
+ */
826
+ lastAckedPosition() {
827
+ return this._lastAckedPosition;
828
+ }
829
+ }
830
+ //# sourceMappingURL=retry.js.map