@lossless.org/client 1.10.0 → 1.12.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.
@@ -31,12 +31,36 @@ interface ISmartdataSessionBinding {
31
31
  transactionSignal?: AbortSignal;
32
32
  transactionAttempt?: symbol;
33
33
  transactionFailure?: { error: unknown };
34
+ /** Set once the current attempt's callback returned and the driver moves on to commit. */
35
+ transactionCommitReached: boolean;
36
+ /** Set when the session ended while a transaction ran, stating how far the transaction had got. */
37
+ transactionEnded?: TSmartdataTransactionEnd;
34
38
  operationSettled?: Promise<void>;
35
39
  resolveOperationSettled?: () => void;
36
40
  invalidated: boolean;
37
41
  closing?: Promise<void>;
38
42
  }
39
43
 
44
+ /** How far a transaction had got when its session ended outside the callback's control. */
45
+ type TSmartdataTransactionEnd = 'before_commit' | 'during_commit';
46
+
47
+ /**
48
+ * The session of a transaction ended while it ran, for example through the
49
+ * database's `close()`. The driver then returns the callback's result without
50
+ * committing, so the call must reject instead.
51
+ */
52
+ const createTransactionSessionEndedError = (
53
+ endArg: TSmartdataTransactionEnd,
54
+ causeArg?: unknown,
55
+ ): SmartdataPersistenceError =>
56
+ new SmartdataPersistenceError(
57
+ 'unsupported_operation',
58
+ endArg === 'during_commit'
59
+ ? 'SmartData transaction session ended during commit; the commit outcome is unknown.'
60
+ : 'SmartData transaction session ended before commit; nothing was committed.',
61
+ causeArg === undefined ? undefined : { cause: causeArg },
62
+ );
63
+
40
64
  const bindings = new WeakMap<SmartdataSession, ISmartdataSessionBinding>();
41
65
  const bindingsByOwner = new WeakMap<SmartdataDb, Set<ISmartdataSessionBinding>>();
42
66
  const smartdataSessionInvalidationAggregateBrand = Symbol(
@@ -162,6 +186,12 @@ export class SmartdataSession {
162
186
  * Any operation on the session that its deadline or a signal interrupts
163
187
  * fails the attempt: the call rolls it back and rejects with that
164
188
  * operation's error even when the callback caught it.
189
+ *
190
+ * When the session ends while the call runs — the database's `close()`
191
+ * ends every session — the call never reports a commit. It rejects with
192
+ * `unsupported_operation`: before the callback returned nothing was
193
+ * committed, afterwards the commit outcome is unknown. The callback's
194
+ * error, if it threw, is the `cause`.
165
195
  */
166
196
  public async withTransaction<TResult>(
167
197
  callbackArg: (sessionArg: SmartdataSession) => Promise<TResult>,
@@ -185,19 +215,24 @@ export class SmartdataSession {
185
215
  binding.transactionActive = true;
186
216
  binding.transactionTimeoutMS = timeoutMS;
187
217
  binding.transactionSignal = signal;
218
+ binding.transactionEnded = undefined;
188
219
  let callbackError: unknown;
189
- let commitReached = false;
220
+ /** The signal or deadline interruption that failed the last attempt. */
221
+ let attemptInterruption: { error: unknown } | undefined;
222
+ let sessionEndedError: SmartdataPersistenceError | undefined;
190
223
  const runAttempt = async () => {
191
224
  const attempt = Symbol('smartdataTransactionAttempt');
192
225
  binding.transactionAttempt = attempt;
193
226
  binding.transactionCallbackOpen = true;
194
227
  binding.transactionUsesOrdinaryWrite = false;
195
228
  binding.transactionFailure = undefined;
229
+ binding.transactionCommitReached = false;
196
230
  callbackError = undefined;
197
- commitReached = false;
231
+ attemptInterruption = undefined;
198
232
  try {
199
233
  let result: TResult;
200
234
  let callbackReturned = false;
235
+ let callbackThrown: { error: unknown } | undefined;
201
236
  try {
202
237
  // A retry the driver starts after an abort ends before the callback.
203
238
  signal?.throwIfAborted();
@@ -206,6 +241,9 @@ export class SmartdataSession {
206
241
  async () => callbackArg(this),
207
242
  );
208
243
  callbackReturned = true;
244
+ } catch (errorArg) {
245
+ callbackThrown = { error: errorArg };
246
+ throw errorArg;
209
247
  } finally {
210
248
  const activeOperation = binding.operationActive
211
249
  ? binding.operationSettled
@@ -217,7 +255,17 @@ export class SmartdataSession {
217
255
  if (activeOperation) {
218
256
  await activeOperation;
219
257
  }
258
+ attemptInterruption = binding.transactionFailure;
220
259
  assertTransactionSucceeded(binding);
260
+ if (binding.transactionEnded) {
261
+ // The driver would commit or return the result on a session that is
262
+ // being ended; the attempt fails instead, keeping the callback's error.
263
+ sessionEndedError = createTransactionSessionEndedError(
264
+ binding.transactionEnded,
265
+ callbackThrown?.error,
266
+ );
267
+ throw sessionEndedError;
268
+ }
221
269
  if (callbackReturned && activeOperation) {
222
270
  throw new SmartdataPersistenceError(
223
271
  'unsupported_operation',
@@ -227,26 +275,49 @@ export class SmartdataSession {
227
275
  }
228
276
  // An abort that arrived while the callback ran rolls the attempt back.
229
277
  signal?.throwIfAborted();
230
- // The driver commits next; a deadline that expires from here on
231
- // leaves the commit outcome unknown.
232
- commitReached = true;
278
+ // The driver commits next; a deadline that expires or a session that
279
+ // ends from here on leaves the commit outcome unknown.
280
+ binding.transactionCommitReached = true;
233
281
  return result;
234
282
  } catch (errorArg) {
235
283
  callbackError = errorArg;
284
+ if (signal?.aborted && errorArg === signal.reason) {
285
+ attemptInterruption ??= { error: errorArg };
286
+ }
236
287
  throw errorArg;
237
288
  }
238
289
  };
290
+ let result: TResult | undefined;
291
+ let transactionEnded: TSmartdataTransactionEnd | undefined;
239
292
  try {
240
- return timeoutMS === undefined
293
+ result = timeoutMS === undefined
241
294
  ? await binding.rawSession.withTransaction(runAttempt)
242
295
  : await binding.rawSession.withTransaction(runAttempt, { timeoutMS });
296
+ transactionEnded = binding.transactionEnded;
243
297
  } catch (errorArg) {
298
+ // The driver's closing abort replaces the attempt's error when it finds
299
+ // the transaction already aborted, as a session that close() ended leaves
300
+ // it; the interruption that failed the attempt keeps its rejection.
301
+ if (attemptInterruption) {
302
+ throw normalizeDeadlineError(attemptInterruption.error);
303
+ }
304
+ if (sessionEndedError) {
305
+ throw sessionEndedError;
306
+ }
307
+ if (
308
+ binding.transactionEnded
309
+ && errorArg !== signal?.reason
310
+ && !isMongoDeadlineError(errorArg)
311
+ && !(errorArg instanceof SmartdataPersistenceError && errorArg.code === 'timeout')
312
+ ) {
313
+ throw createTransactionSessionEndedError(binding.transactionEnded, errorArg);
314
+ }
244
315
  if (errorArg === callbackError) {
245
316
  throw normalizeDeadlineError(errorArg);
246
317
  }
247
318
  if (isMongoDeadlineError(errorArg)) {
248
319
  throw createSmartdataTimeoutError(
249
- commitReached
320
+ binding.transactionCommitReached
250
321
  ? 'SmartData transaction exceeded its client deadline during commit; the commit outcome is unknown.'
251
322
  : 'SmartData transaction exceeded its client deadline before commit; nothing was committed.',
252
323
  errorArg,
@@ -265,7 +336,13 @@ export class SmartdataSession {
265
336
  binding.transactionSignal = undefined;
266
337
  binding.transactionUsesOrdinaryWrite = false;
267
338
  binding.transactionFailure = undefined;
339
+ binding.transactionCommitReached = false;
340
+ binding.transactionEnded = undefined;
341
+ }
342
+ if (transactionEnded) {
343
+ throw createTransactionSessionEndedError(transactionEnded);
268
344
  }
345
+ return result;
269
346
  }
270
347
 
271
348
  public async close(): Promise<void> {
@@ -301,6 +378,7 @@ export const createSmartdataSession = (ownerArg: SmartdataDb): SmartdataSession
301
378
  transactionActive: false,
302
379
  transactionCallbackOpen: false,
303
380
  transactionUsesOrdinaryWrite: false,
381
+ transactionCommitReached: false,
304
382
  invalidated: false,
305
383
  };
306
384
  bindings.set(session, binding);
@@ -559,8 +637,13 @@ export const runWithOrdinarySmartdataSession = async <TResult>(
559
637
  }
560
638
  };
561
639
 
640
+ /**
641
+ * Ends every session of the owner. `endOptionsArg.timeoutMS` is the driver's
642
+ * deadline for aborting a transaction a session still holds.
643
+ */
562
644
  export const invalidateSmartdataSessions = async (
563
645
  ownerArg: SmartdataDb,
646
+ endOptionsArg?: Pick<plugins.mongodb.EndSessionOptions, 'timeoutMS'>,
564
647
  ): Promise<void> => {
565
648
  const ownerBindings = bindingsByOwner.get(ownerArg);
566
649
  if (!ownerBindings) {
@@ -568,11 +651,11 @@ export const invalidateSmartdataSessions = async (
568
651
  }
569
652
  const sessions = [...ownerBindings];
570
653
  for (const bindingArg of sessions) {
571
- bindingArg.invalidated = true;
654
+ invalidateSessionBinding(bindingArg);
572
655
  }
573
656
  const results = await Promise.allSettled(
574
657
  sessions.map((bindingArg) =>
575
- closeSessionBinding(bindingArg),
658
+ closeSessionBinding(bindingArg, endOptionsArg),
576
659
  ),
577
660
  );
578
661
  const errors = results.flatMap((resultArg) =>
@@ -589,12 +672,28 @@ export const invalidateSmartdataSessions = async (
589
672
  }
590
673
  };
591
674
 
675
+ /**
676
+ * Refuses every later operation on the session. A transaction still running on
677
+ * it records how far it had got, so it rejects instead of reporting a commit.
678
+ */
679
+ const invalidateSessionBinding = (bindingArg: ISmartdataSessionBinding): void => {
680
+ bindingArg.invalidated = true;
681
+ if (bindingArg.transactionActive) {
682
+ bindingArg.transactionEnded ??= bindingArg.transactionCommitReached
683
+ ? 'during_commit'
684
+ : 'before_commit';
685
+ }
686
+ };
687
+
592
688
  /** Failed cleanup remains owned and invalidated until an explicit retry succeeds. */
593
- const closeSessionBinding = async (bindingArg: ISmartdataSessionBinding): Promise<void> => {
689
+ const closeSessionBinding = async (
690
+ bindingArg: ISmartdataSessionBinding,
691
+ endOptionsArg?: Pick<plugins.mongodb.EndSessionOptions, 'timeoutMS'>,
692
+ ): Promise<void> => {
594
693
  if (bindingArg.closing) return bindingArg.closing;
595
- bindingArg.invalidated = true;
694
+ invalidateSessionBinding(bindingArg);
596
695
  const closing = Promise.resolve().then(async () => {
597
- await bindingArg.rawSession.endSession();
696
+ await bindingArg.rawSession.endSession(endOptionsArg);
598
697
  bindingsByOwner.get(bindingArg.owner)?.delete(bindingArg);
599
698
  bindings.delete(bindingArg.session);
600
699
  });