@abloatai/humans 0.44.0 → 0.46.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.
@@ -158,6 +158,15 @@ export interface MutationQueueConfig {
158
158
  maxBatchSize: number;
159
159
  batchDelay: number;
160
160
  maxRetries: number;
161
+ /**
162
+ * Minimum wall-clock window for retrying transient write failures with the
163
+ * same durable envelope and idempotency key. This absorbs managed-database
164
+ * promotion and brief regional network incidents without double-applying a
165
+ * write. Defaults to 120 seconds: the Aurora promotion drill recovered
166
+ * writes just beyond 60 seconds, so a one-minute boundary discarded exact
167
+ * envelopes at the instant the new writer became usable.
168
+ */
169
+ availabilityRetryWindowMs: number;
161
170
  conflictResolution: ConflictResolution;
162
171
  enablePersistence: boolean;
163
172
  enableOptimistic: boolean;
@@ -289,6 +298,7 @@ export class MutationQueue extends EventEmitter {
289
298
  private replicationLagTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
290
299
  private replicationLagErrors = new Map<string, AbloConnectionError>();
291
300
  private commitProcessing = false;
301
+ private commitRetryTimer: ReturnType<typeof setTimeout> | null = null;
292
302
  private lastCommitSequence = 0;
293
303
  private durableReplayBlock: AbloIdempotencyError | null = null;
294
304
  /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
@@ -312,7 +322,11 @@ export class MutationQueue extends EventEmitter {
312
322
  private get commitLaneContext(): CommitLaneContext {
313
323
  return {
314
324
  runtime: this.runtime,
315
- config: { maxRetries: this.config.maxRetries },
325
+ config: {
326
+ maxRetries: this.config.maxRetries,
327
+ availabilityRetryWindowMs: this.config.availabilityRetryWindowMs,
328
+ retryBackoff: this.config.retryBackoff,
329
+ },
316
330
  commitLane: this.commitLane,
317
331
  commitNotifications: this.commitNotifications,
318
332
  commitMissingIds: this.commitMissingIds,
@@ -336,6 +350,13 @@ export class MutationQueue extends EventEmitter {
336
350
  noteAck: (syncId) => this.noteAck(syncId),
337
351
  isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
338
352
  isPermanentError: (error) => this.isPermanentError(error),
353
+ scheduleRetry: (delayMs) => {
354
+ if (this.commitRetryTimer !== null) clearTimeout(this.commitRetryTimer);
355
+ this.commitRetryTimer = setTimeout(() => {
356
+ this.commitRetryTimer = null;
357
+ void this.processCommitLane();
358
+ }, delayMs);
359
+ },
339
360
  emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
340
361
  };
341
362
  }
@@ -782,6 +803,7 @@ export class MutationQueue extends EventEmitter {
782
803
  maxBatchSize: 50, // send up to this many operations per commit
783
804
  batchDelay: 150, // milliseconds to wait for more operations before sending
784
805
  maxRetries: 3,
806
+ availabilityRetryWindowMs: 120_000,
785
807
  conflictResolution: {
786
808
  strategy: 'last-write-wins',
787
809
  },
@@ -2004,6 +2026,10 @@ export class MutationQueue extends EventEmitter {
2004
2026
  clearTimeout(this.commitOfflineGraceTimer);
2005
2027
  this.commitOfflineGraceTimer = null;
2006
2028
  }
2029
+ if (this.commitRetryTimer !== null) {
2030
+ clearTimeout(this.commitRetryTimer);
2031
+ this.commitRetryTimer = null;
2032
+ }
2007
2033
 
2008
2034
  // Clear store
2009
2035
  this.store.clear();
@@ -1,12 +1,12 @@
1
1
  import type { RuntimeContext } from '../../RuntimeContext.js';
2
2
  import type { ReadDependency, TrackDependency, OnStaleMode, StaleNotification } from '@abloatai/transaction/coordination/schema';
3
- import { AbloConnectionError } from '@abloatai/transaction/errors';
4
3
  import type { MutationCommitResult } from '@abloatai/transaction/wire/commit';
5
4
  import type {
6
5
  DurableCommitEnvelope,
7
6
  DurableCommitOperation,
8
7
  } from '@abloatai/transaction/transactions/settlement/commitEnvelope';
9
8
  import type { SealDurableCommitInput } from './commitTransport.js';
9
+ import { transientRetryDelayMs } from './failureHandling.js';
10
10
 
11
11
  export interface CommitTransaction {
12
12
  id: string;
@@ -26,6 +26,7 @@ export interface CommitTransaction {
26
26
  createdAt: number;
27
27
  attempts: number;
28
28
  transientAttempts?: number;
29
+ firstTransientFailureAt?: number;
29
30
  lastSyncId?: number;
30
31
  correlationId?: string;
31
32
  error?: Error;
@@ -38,7 +39,11 @@ export interface CommitTransaction {
38
39
 
39
40
  export interface CommitLaneContext {
40
41
  readonly runtime: RuntimeContext;
41
- readonly config: { maxRetries: number };
42
+ readonly config: {
43
+ maxRetries: number;
44
+ availabilityRetryWindowMs: number;
45
+ retryBackoff: { baseMs: number; capMs: number };
46
+ };
42
47
  readonly commitLane: CommitTransaction[];
43
48
  readonly commitNotifications: Map<string, StaleNotification[]>;
44
49
  readonly commitMissingIds: Map<string, string[]>;
@@ -56,6 +61,7 @@ export interface CommitLaneContext {
56
61
  readonly noteAck: (syncId: number | undefined) => void;
57
62
  readonly isDefinitiveRejection: (error: Error) => boolean;
58
63
  readonly isPermanentError: (error: Error) => boolean;
64
+ readonly scheduleRetry: (delayMs: number) => void;
59
65
  readonly emitCommitLifecycle: (event: string, payload: object) => void;
60
66
  }
61
67
 
@@ -165,14 +171,24 @@ export async function processCommitLane(ctx: CommitLaneContext): Promise<void> {
165
171
  } catch (cause) {
166
172
  const error = cause instanceof Error ? cause : new Error(String(cause));
167
173
  if (dispatchStarted && ctx.isDefinitiveRejection(error)) await ctx.removeDurableCommit(tx.id);
168
- if (!(error instanceof AbloConnectionError)) tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
169
- const exhausted = (tx.transientAttempts ?? 0) > ctx.config.maxRetries;
174
+ tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
175
+ tx.firstTransientFailureAt ??= Date.now();
176
+ const outsideAvailabilityWindow =
177
+ Date.now() - tx.firstTransientFailureAt >= ctx.config.availabilityRetryWindowMs;
178
+ const exhausted =
179
+ tx.transientAttempts > ctx.config.maxRetries && outsideAvailabilityWindow;
170
180
  if (!ctx.isPermanentError(error) && !exhausted) {
171
181
  tx.status = 'pending';
182
+ const delayMs = transientRetryDelayMs(
183
+ error,
184
+ tx.transientAttempts,
185
+ ctx.config.retryBackoff,
186
+ );
172
187
  ctx.runtime.logger.debug('[MutationQueue] commit lane transient', {
173
188
  txId: tx.id.slice(0, 12), attempts: tx.attempts,
174
- transientAttempts: tx.transientAttempts ?? 0, message: error.message,
189
+ transientAttempts: tx.transientAttempts, delayMs, message: error.message,
175
190
  });
191
+ ctx.scheduleRetry(delayMs);
176
192
  break;
177
193
  }
178
194
  tx.status = 'failed';
@@ -108,6 +108,8 @@ export interface QueuedMutation {
108
108
  status: 'pending' | 'executing' | 'awaiting_delta' | 'completed' | 'failed' | 'rolled_back';
109
109
  createdAt: number;
110
110
  attempts: number;
111
+ /** First transient dispatch failure in the current availability incident. */
112
+ firstTransientFailureAt?: number;
111
113
  priority: 'normal' | 'high';
112
114
  priorityScore: number; // foreign-key-aware priority, derived, used for sorting
113
115
  writeOptions?: WriteOptions;
@@ -7,7 +7,10 @@ import { extractStatusCode } from './commitPayload.js';
7
7
 
8
8
  export interface FailureHandlingContext {
9
9
  readonly runtime: RuntimeContext;
10
- readonly config: Pick<MutationQueueConfig, 'enableOptimistic' | 'maxRetries' | 'retryBackoff'>;
10
+ readonly config: Pick<
11
+ MutationQueueConfig,
12
+ 'enableOptimistic' | 'maxRetries' | 'retryBackoff' | 'availabilityRetryWindowMs'
13
+ >;
11
14
  readonly store: MutationStore;
12
15
  readonly isPermanentError: (error: Error) => boolean;
13
16
  readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
@@ -17,6 +20,21 @@ export interface FailureHandlingContext {
17
20
  readonly emit: (event: string, payload: object) => boolean;
18
21
  }
19
22
 
23
+ export function transientRetryDelayMs(
24
+ error: Error,
25
+ attempt: number,
26
+ retryBackoff: MutationQueueConfig['retryBackoff'],
27
+ ): number {
28
+ const { baseMs, capMs } = retryBackoff;
29
+ let base = baseMs;
30
+ try {
31
+ const status = extractStatusCode(error);
32
+ if (status === 429 || status === 503) base = Math.max(baseMs, 1_000);
33
+ } catch {}
34
+ const ceiling = Math.min(capMs, base * Math.pow(2, Math.max(0, attempt - 1)));
35
+ return Math.floor(Math.random() * ceiling);
36
+ }
37
+
20
38
  export async function handleFailure(ctx: FailureHandlingContext, transaction: QueuedMutation, error: Error): Promise<void> {
21
39
  transaction.attempts++;
22
40
 
@@ -120,20 +138,17 @@ export async function handleFailure(ctx: FailureHandlingContext, transaction: Qu
120
138
  return;
121
139
  }
122
140
 
123
- if (transaction.attempts < ctx.config.maxRetries) {
141
+ transaction.firstTransientFailureAt ??= Date.now();
142
+ const insideAvailabilityWindow =
143
+ Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
144
+
145
+ if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
124
146
  // Exponential backoff with full jitter on every transient retry:
125
147
  // `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
126
148
  // (429/503) use a longer base than other transient errors. The re-enqueue
127
149
  // is scheduled rather than awaited, so one backing-off transaction cannot
128
150
  // stall unrelated commits.
129
- const { baseMs, capMs } = ctx.config.retryBackoff;
130
- let base = baseMs;
131
- try {
132
- const status = extractStatusCode(error);
133
- if (status === 429 || status === 503) base = Math.max(baseMs, 1_000);
134
- } catch {}
135
- const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
136
- const delay = Math.floor(Math.random() * ceiling);
151
+ const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
137
152
 
138
153
  ctx.store.updateStatus(transaction.id, 'pending');
139
154
  setTimeout(() => {