@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.
- package/dist/Ablo.d.ts +3 -0
- package/dist/local/client/createModelProxy.d.ts +4 -2
- package/dist/local/client/createModelProxy.js +81 -75
- package/dist/local/client/reactiveEngine.js +41 -27
- package/dist/local/client/resourceTypes.d.ts +1 -1
- package/dist/local/sync/createClaimStream.d.ts +9 -4
- package/dist/local/sync/createClaimStream.js +37 -28
- package/dist/local/transactions/mutations/MutationQueue.d.ts +19 -0
- package/dist/local/transactions/mutations/MutationQueue.js +19 -1
- package/dist/local/transactions/mutations/commitLane.d.ts +7 -0
- package/dist/local/transactions/mutations/commitLane.js +8 -5
- package/dist/local/transactions/mutations/commitPayload.d.ts +2 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +2 -1
- package/dist/local/transactions/mutations/failureHandling.js +16 -11
- package/package.json +2 -2
- package/src/Ablo.ts +6 -0
- package/src/local/client/createModelProxy.ts +102 -80
- package/src/local/client/reactiveEngine.ts +48 -26
- package/src/local/client/resourceTypes.ts +3 -0
- package/src/local/sync/createClaimStream.ts +68 -37
- package/src/local/transactions/mutations/MutationQueue.ts +27 -1
- package/src/local/transactions/mutations/commitLane.ts +21 -5
- package/src/local/transactions/mutations/commitPayload.ts +2 -0
- package/src/local/transactions/mutations/failureHandling.ts +25 -10
package/dist/Ablo.d.ts
CHANGED
|
@@ -171,6 +171,9 @@ export declare namespace Ablo {
|
|
|
171
171
|
type Held<T = Record<string, unknown>> = import('@abloatai/transaction/types/streams').HeldClaim<T>;
|
|
172
172
|
type CreateOptions = import('./local/client/resourceTypes.js').ClaimCreateOptions;
|
|
173
173
|
type WaitOptions = import('./local/client/resourceTypes.js').ClaimWaitOptions;
|
|
174
|
+
type ContentionOptions = import('@abloatai/transaction/resources/modelOperations').ClaimContentionOptions;
|
|
175
|
+
type AttemptEvent = import('@abloatai/transaction/resources/modelOperations').ClaimAttemptEvent;
|
|
176
|
+
type QueueView = import('@abloatai/transaction/resources/modelOperations').ClaimQueueView;
|
|
174
177
|
type Client = import('./local/client/resourceTypes.js').ClaimResource;
|
|
175
178
|
}
|
|
176
179
|
namespace Model {
|
|
@@ -17,9 +17,9 @@ import type { SyncClient } from '../SyncClient.js';
|
|
|
17
17
|
import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
|
|
18
18
|
import type { JoinedParticipant } from '../sync/participants.js';
|
|
19
19
|
import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions, Snapshot } from '@abloatai/transaction/types/streams';
|
|
20
|
-
export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
|
|
20
|
+
export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
|
|
21
21
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
22
|
-
import type { ClaimApi, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
|
|
22
|
+
import type { ClaimApi, ClaimAttemptEvent, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
|
|
23
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/httpClient';
|
|
24
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
25
25
|
export interface ModelClientMeta {
|
|
@@ -56,6 +56,8 @@ export interface ModelCollaboration {
|
|
|
56
56
|
waitTimeoutMs?: number;
|
|
57
57
|
/** Abort the queued wait — rejects with `claim_wait_aborted`. */
|
|
58
58
|
signal?: AbortSignal;
|
|
59
|
+
/** Request-scoped queued / granted / skipped / failed status events. */
|
|
60
|
+
onStatus?: (event: ClaimAttemptEvent) => void;
|
|
59
61
|
}): Promise<Claim>;
|
|
60
62
|
createSnapshot(modelKey: string, id: string): Snapshot;
|
|
61
63
|
/**
|
|
@@ -26,6 +26,7 @@ import { subTarget } from '@abloatai/transaction/coordination';
|
|
|
26
26
|
// here like the other enumerated crossings.
|
|
27
27
|
import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
|
|
28
28
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
29
|
+
import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/resources/modelOperations';
|
|
29
30
|
const modelClientMeta = new WeakMap();
|
|
30
31
|
export function getModelClientMeta(modelClient) {
|
|
31
32
|
if (typeof modelClient !== 'object' || modelClient === null)
|
|
@@ -172,22 +173,13 @@ defaultWait) {
|
|
|
172
173
|
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
173
174
|
}
|
|
174
175
|
const { id, ...options } = params;
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
176
|
+
// Read the local snapshot only to decide whether a post-grant re-read may
|
|
177
|
+
// be needed. Admission itself always goes to the server: a local presence
|
|
178
|
+
// snapshot may be stale or incomplete across instances.
|
|
178
179
|
const held = collaboration.state({ model: wireModel, id });
|
|
179
180
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
// not an error, so it resolves `null` — the caller reads `if (!claim)`
|
|
183
|
-
// and moves on; who holds it stays readable via `claim.state`. Best-effort
|
|
184
|
-
// at the client (a racing claim not yet synced into our snapshot slips
|
|
185
|
-
// through here) — the commit-time claim guard is the authoritative
|
|
186
|
-
// backstop that rejects the loser's first write. For work-distribution
|
|
187
|
-
// dedup that's exactly right: don't wait (that would double-process), skip.
|
|
188
|
-
if (failFast && contended) {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
181
|
+
const contention = resolveClaimContentionOptions(options);
|
|
182
|
+
const failFast = !contention.wait;
|
|
191
183
|
// Ensure the row exists locally before claiming.
|
|
192
184
|
let model = ownRowOrThrow(id);
|
|
193
185
|
if (!model) {
|
|
@@ -207,27 +199,40 @@ defaultWait) {
|
|
|
207
199
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
208
200
|
// Acquire the lease. By default (`queue` is not false) this goes through the
|
|
209
201
|
// server's fair FIFO queue: `queue: true` resolves only once the lease is
|
|
210
|
-
// genuinely ours, blocking behind any current holder
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
202
|
+
// genuinely ours, blocking behind any current holder. Fail-fast skips the
|
|
203
|
+
// queue but still awaits the server: a conflict invisible in the local
|
|
204
|
+
// snapshot resolves `null`, never a speculative handle.
|
|
205
|
+
let lease;
|
|
206
|
+
try {
|
|
207
|
+
lease = await collaboration.createClaim({
|
|
208
|
+
target: {
|
|
209
|
+
model: wireModel,
|
|
210
|
+
id,
|
|
211
|
+
// The whole sub-entity locator in one move — listing its members here
|
|
212
|
+
// is what let `fields` die between the caller and the lease, so the
|
|
213
|
+
// claim covered the whole row while the caller believed it named parts.
|
|
214
|
+
...subTarget(options, schemaKey),
|
|
215
|
+
},
|
|
216
|
+
description: claimDescription(options),
|
|
217
|
+
ttl: options.ttl,
|
|
218
|
+
queue: contention.wait,
|
|
219
|
+
maxQueueDepth: contention.maxDepth,
|
|
220
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
221
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
222
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
223
|
+
signal: contention.signal,
|
|
224
|
+
onStatus: contention.onStatus,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
const normalized = toAbloError(err);
|
|
229
|
+
if (failFast &&
|
|
230
|
+
normalized instanceof AbloClaimedError &&
|
|
231
|
+
normalized.code === 'claim_conflict') {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
throw normalized;
|
|
235
|
+
}
|
|
231
236
|
// Only when the claim actually waited behind another holder can the row have
|
|
232
237
|
// changed underneath us — re-read so the claimed snapshot reflects what that
|
|
233
238
|
// holder committed before releasing. Either of two signals suffices:
|
|
@@ -340,45 +345,45 @@ defaultWait) {
|
|
|
340
345
|
if (!collaboration) {
|
|
341
346
|
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
342
347
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// a claim the server would refuse.
|
|
346
|
-
const held = collaboration.state({ model: wireModel, id });
|
|
347
|
-
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
348
|
-
const failFast = options.queue === false;
|
|
349
|
-
// The try-claim (`queue: false`): resolve `null` if a holder is already
|
|
350
|
-
// visible — an expected outcome, not an error. Best-effort at the client —
|
|
351
|
-
// a row this participant never synced usually carries no local claim state
|
|
352
|
-
// either, so a peer gets the deterministic `null` only once it has
|
|
353
|
-
// observed the holder (entered the row's entity scope). The server's
|
|
354
|
-
// queue is the backstop for the queuing path.
|
|
355
|
-
if (failFast && contended) {
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
348
|
+
const contention = resolveClaimContentionOptions(options);
|
|
349
|
+
const failFast = !contention.wait;
|
|
358
350
|
// Enter the entity scope before acquiring the lease so the holder's claim
|
|
359
351
|
// presence broadcasts to everyone in this entity group — the same ordering
|
|
360
352
|
// the row-bearing path relies on. No pool `load` and no `entity_not_found`
|
|
361
353
|
// throw: the row lives only in the customer's database, so there is nothing
|
|
362
354
|
// to hydrate here and nothing to re-read after the grant.
|
|
363
355
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
356
|
+
let lease;
|
|
357
|
+
try {
|
|
358
|
+
lease = await collaboration.createClaim({
|
|
359
|
+
target: {
|
|
360
|
+
model: wireModel,
|
|
361
|
+
id,
|
|
362
|
+
// The whole sub-entity locator in one move — listing its members here
|
|
363
|
+
// is what let `fields` die between the caller and the lease, so the
|
|
364
|
+
// claim covered the whole row while the caller believed it named parts.
|
|
365
|
+
...subTarget(options, schemaKey),
|
|
366
|
+
},
|
|
367
|
+
description: claimDescription(options),
|
|
368
|
+
ttl: options.ttl,
|
|
369
|
+
queue: contention.wait,
|
|
370
|
+
maxQueueDepth: contention.maxDepth,
|
|
371
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
372
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
373
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
374
|
+
signal: contention.signal,
|
|
375
|
+
onStatus: contention.onStatus,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
const normalized = toAbloError(err);
|
|
380
|
+
if (failFast &&
|
|
381
|
+
normalized instanceof AbloClaimedError &&
|
|
382
|
+
normalized.code === 'claim_conflict') {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
throw normalized;
|
|
386
|
+
}
|
|
382
387
|
// A watermark-only snapshot: `createSnapshot` still reads the engine's
|
|
383
388
|
// current `lastSyncId` even though the pool holds no row (the bucket is
|
|
384
389
|
// empty). It costs nothing extra and gives a write taken under this lease a
|
|
@@ -537,10 +542,7 @@ defaultWait) {
|
|
|
537
542
|
};
|
|
538
543
|
},
|
|
539
544
|
queue(params) {
|
|
540
|
-
return {
|
|
541
|
-
object: 'list',
|
|
542
|
-
data: collaboration?.queue({ model: wireModel, id: params.id }) ?? [],
|
|
543
|
-
};
|
|
545
|
+
return claimQueueView(collaboration?.queue({ model: wireModel, id: params.id }) ?? []);
|
|
544
546
|
},
|
|
545
547
|
reorder(params) {
|
|
546
548
|
collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
|
|
@@ -627,6 +629,7 @@ defaultWait) {
|
|
|
627
629
|
// race — see `takeClaim`). Released with the lease in the `finally`
|
|
628
630
|
// below. Awaited for broadcast ordering; still best-effort.
|
|
629
631
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
632
|
+
const contention = resolveClaimContentionOptions(claim);
|
|
630
633
|
autoLease = await collaboration.createClaim({
|
|
631
634
|
target: {
|
|
632
635
|
model: wireModel,
|
|
@@ -635,8 +638,11 @@ defaultWait) {
|
|
|
635
638
|
},
|
|
636
639
|
description: claimDescription(claim, 'creating'),
|
|
637
640
|
ttl: claim.ttl,
|
|
638
|
-
queue:
|
|
639
|
-
maxQueueDepth:
|
|
641
|
+
queue: contention.wait,
|
|
642
|
+
maxQueueDepth: contention.maxDepth,
|
|
643
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
644
|
+
signal: contention.signal,
|
|
645
|
+
onStatus: contention.onStatus,
|
|
640
646
|
});
|
|
641
647
|
}
|
|
642
648
|
// Default `organizationId` from the client's identity, matching the other
|
|
@@ -724,7 +730,7 @@ defaultWait) {
|
|
|
724
730
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
725
731
|
if (autoClaim) {
|
|
726
732
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
727
|
-
// A
|
|
733
|
+
// A skipped try-claim is `null` only on the standalone verb; a
|
|
728
734
|
// write that could not take its claim is a failed write.
|
|
729
735
|
if (!handle) {
|
|
730
736
|
throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this update's claim could not be taken.`, { code: 'entity_claimed' });
|
|
@@ -25,6 +25,7 @@ import { awaitClaimGrant } from '@abloatai/transaction/coordination/awaitClaimGr
|
|
|
25
25
|
import { createSnapshot } from '../sync/createSnapshot.js';
|
|
26
26
|
import { createParticipantManager } from '../sync/participants.js';
|
|
27
27
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
28
|
+
import { claimAttemptFailure, emitClaimStatus, } from '@abloatai/transaction/resources/modelOperations';
|
|
28
29
|
import { createModelProxy } from './createModelProxy.js';
|
|
29
30
|
import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
30
31
|
export function buildReactiveEngine(inputs) {
|
|
@@ -257,8 +258,8 @@ export function buildReactiveEngine(inputs) {
|
|
|
257
258
|
return Promise.resolve();
|
|
258
259
|
};
|
|
259
260
|
// The token is server-stamped and arrives on the grant frame, so prefer
|
|
260
|
-
// the one `awaitClaimGrant` read there;
|
|
261
|
-
// already
|
|
261
|
+
// the one `awaitClaimGrant` read there; retain the handle fallback for
|
|
262
|
+
// wire-compatible transports that already stamped it locally.
|
|
262
263
|
const resolvedFenceToken = fenceToken ?? claim.fenceToken;
|
|
263
264
|
return {
|
|
264
265
|
object: 'claim',
|
|
@@ -280,6 +281,35 @@ export function buildReactiveEngine(inputs) {
|
|
|
280
281
|
const publicClaims = Object.assign(claimStream, {
|
|
281
282
|
async create(claimOptions) {
|
|
282
283
|
await ready();
|
|
284
|
+
// Subscribe before announcing the claim. A fast rejection can arrive
|
|
285
|
+
// in the same turn as `send` in tests and on a low-latency socket; if
|
|
286
|
+
// the listener is installed afterwards, that authoritative answer is
|
|
287
|
+
// lost and the locally minted handle looks like a grant.
|
|
288
|
+
const claimId = crypto.randomUUID();
|
|
289
|
+
const grant = awaitClaimGrant(transport, claimId, {
|
|
290
|
+
timeoutMs: claimOptions.waitTimeoutMs,
|
|
291
|
+
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
292
|
+
signal: claimOptions.signal,
|
|
293
|
+
logger,
|
|
294
|
+
onQueued: ({ position }) => {
|
|
295
|
+
emitClaimStatus(claimOptions.onStatus, {
|
|
296
|
+
type: 'queued',
|
|
297
|
+
claimId,
|
|
298
|
+
position,
|
|
299
|
+
ahead: position + 1,
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
onGranted: ({ waited }) => {
|
|
303
|
+
emitClaimStatus(claimOptions.onStatus, {
|
|
304
|
+
type: 'granted',
|
|
305
|
+
claimId,
|
|
306
|
+
waited,
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
onFailed: (error) => {
|
|
310
|
+
emitClaimStatus(claimOptions.onStatus, claimAttemptFailure(claimOptions.queue !== false, error));
|
|
311
|
+
},
|
|
312
|
+
});
|
|
283
313
|
const claim = claimStream.claim({
|
|
284
314
|
...streamTarget(claimOptions.target),
|
|
285
315
|
...subTarget(claimOptions.target),
|
|
@@ -287,32 +317,16 @@ export function buildReactiveEngine(inputs) {
|
|
|
287
317
|
description: claimOptions.description,
|
|
288
318
|
ttl: claimOptions.ttl,
|
|
289
319
|
queue: claimOptions.queue,
|
|
320
|
+
}, claimId);
|
|
321
|
+
// A claim is ours only after the server says so. This applies equally
|
|
322
|
+
// to queued claims and try-claims (`queue: false`): the latter must
|
|
323
|
+
// observe `claim_rejected` instead of returning a phantom handle.
|
|
324
|
+
const { waited, fenceToken, readAt } = await grant.catch((err) => {
|
|
325
|
+
// Give up the local/reconnect record after any rejection, timeout,
|
|
326
|
+
// abort, or lost lease. For queued claims this also leaves the line.
|
|
327
|
+
claim.revoke?.();
|
|
328
|
+
throw err;
|
|
290
329
|
});
|
|
291
|
-
// With `queue`, the claim is only really *ours* once the server says
|
|
292
|
-
// so (`claim_acquired` if the target was free, `claim_granted` once
|
|
293
|
-
// we reach the head of the FIFO line). Block here on that grant so
|
|
294
|
-
// callers — chiefly `ablo.<model>.claim` — get a handle that already
|
|
295
|
-
// holds the lease, never a half-claimed one racing the queue.
|
|
296
|
-
let waited = false;
|
|
297
|
-
let fenceToken;
|
|
298
|
-
let readAt;
|
|
299
|
-
if (claimOptions.queue) {
|
|
300
|
-
try {
|
|
301
|
-
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
302
|
-
timeoutMs: claimOptions.waitTimeoutMs,
|
|
303
|
-
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
304
|
-
signal: claimOptions.signal,
|
|
305
|
-
logger,
|
|
306
|
-
}));
|
|
307
|
-
}
|
|
308
|
-
catch (err) {
|
|
309
|
-
// Gave up waiting (queue too deep, timed out, or lost) — abandon
|
|
310
|
-
// the queued claim so we don't leave a phantom entry in the
|
|
311
|
-
// line that would block or mislead other claimers.
|
|
312
|
-
claim.revoke?.();
|
|
313
|
-
throw err;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
330
|
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
317
331
|
},
|
|
318
332
|
list(target) {
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
* live participant handle.
|
|
9
9
|
*/
|
|
10
10
|
export * from '@abloatai/transaction/resources/httpResources';
|
|
11
|
-
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
|
|
11
|
+
export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
|
|
12
12
|
export type { ModelOperations } from './createModelProxy.js';
|
|
@@ -41,13 +41,18 @@ export interface ClaimStreamConfig {
|
|
|
41
41
|
}
|
|
42
42
|
export interface AttachableClaimStream extends ClaimStream {
|
|
43
43
|
/**
|
|
44
|
-
* Mints
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Mints the local handle and sends its `claim_begin` frame. The handle is a
|
|
45
|
+
* request until the resource layer observes the server's grant; it must never
|
|
46
|
+
* be returned to application code before that acknowledgement. This is an
|
|
47
|
+
* internal entry point, not part of the public
|
|
47
48
|
* {@link ClaimStream}; application code takes a claim through
|
|
48
49
|
* `ablo.<model>.claim({ id })`, which is built on this.
|
|
50
|
+
*
|
|
51
|
+
* `claimId` lets that resource layer subscribe for the acknowledgement before
|
|
52
|
+
* this method sends. Omitting it preserves the direct stream API's generated
|
|
53
|
+
* id for internal callers that do not await the grant.
|
|
49
54
|
*/
|
|
50
|
-
claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
|
|
55
|
+
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
51
56
|
attach(transport: ClaimTransport): void;
|
|
52
57
|
/**
|
|
53
58
|
* Seeds the participant identity once the host resolves it. The stream can
|
|
@@ -83,6 +83,28 @@ export function createClaimStream(config, transport = null) {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
|
+
const observeForeignClaim = (heldBy, claim, participantKind, isAgent) => {
|
|
87
|
+
const description = claim.description ??
|
|
88
|
+
descriptionFromMeta(claim.meta) ??
|
|
89
|
+
'editing';
|
|
90
|
+
const { meta, ...details } = subTarget(claim);
|
|
91
|
+
activeByClaimId.set(claim.claimId, {
|
|
92
|
+
object: 'claim',
|
|
93
|
+
id: claim.claimId,
|
|
94
|
+
status: 'active',
|
|
95
|
+
heldBy,
|
|
96
|
+
participantKind: participantKindFromWire(participantKind, isAgent),
|
|
97
|
+
target: {
|
|
98
|
+
...streamTarget(claim),
|
|
99
|
+
...details,
|
|
100
|
+
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
101
|
+
},
|
|
102
|
+
description,
|
|
103
|
+
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
104
|
+
createdAt: claim.declaredAt,
|
|
105
|
+
expiresAt: claim.expiresAt,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
86
108
|
// ── Wire wiring ──────────────────────────────────────────────────
|
|
87
109
|
let attached = null;
|
|
88
110
|
const unsubs = [];
|
|
@@ -125,30 +147,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
125
147
|
// `settled()`. Absent status means active (wire back-compat).
|
|
126
148
|
if (claim.status && claim.status !== 'active')
|
|
127
149
|
continue;
|
|
128
|
-
|
|
129
|
-
// carries the value in `meta` rather than as an explicit description.
|
|
130
|
-
const description = claim.description ??
|
|
131
|
-
descriptionFromMeta(claim.meta) ??
|
|
132
|
-
'editing';
|
|
133
|
-
// The frame is parsed permissively, on purpose; `declaredMeta` is where
|
|
134
|
-
// that wire value becomes the shape the program declared.
|
|
135
|
-
const { meta, ...details } = subTarget(claim);
|
|
136
|
-
activeByClaimId.set(claim.claimId, {
|
|
137
|
-
object: 'claim',
|
|
138
|
-
id: claim.claimId,
|
|
139
|
-
status: 'active',
|
|
140
|
-
heldBy: event.userId,
|
|
141
|
-
participantKind: participantKindFromWire(event.participantKind, event.isAgent),
|
|
142
|
-
target: {
|
|
143
|
-
...streamTarget(claim),
|
|
144
|
-
...details,
|
|
145
|
-
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
146
|
-
},
|
|
147
|
-
description,
|
|
148
|
-
ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
|
|
149
|
-
createdAt: claim.declaredAt,
|
|
150
|
-
expiresAt: claim.expiresAt,
|
|
151
|
-
});
|
|
150
|
+
observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
|
|
152
151
|
mutated = true;
|
|
153
152
|
}
|
|
154
153
|
if (mutated)
|
|
@@ -168,6 +167,16 @@ export function createClaimStream(config, transport = null) {
|
|
|
168
167
|
// a claim the server already rejected (would just spam both
|
|
169
168
|
// sides with conflicts).
|
|
170
169
|
ownClaims.delete(rejection.claimId);
|
|
170
|
+
// A holder on another server may have claimed before this client joined
|
|
171
|
+
// the row group, so its one-shot presence frame was missed. A conflict
|
|
172
|
+
// reply carries the authoritative holder summary; seed the same local
|
|
173
|
+
// state immediately instead of continuing to report the row as free.
|
|
174
|
+
if (rejection.reason === 'conflict' &&
|
|
175
|
+
rejection.heldBy &&
|
|
176
|
+
rejection.heldByClaim) {
|
|
177
|
+
observeForeignClaim(rejection.heldBy, rejection.heldByClaim, rejection.heldByKind);
|
|
178
|
+
notifyListeners();
|
|
179
|
+
}
|
|
171
180
|
for (const l of rejectionListeners) {
|
|
172
181
|
try {
|
|
173
182
|
l(rejection);
|
|
@@ -348,8 +357,8 @@ export function createClaimStream(config, transport = null) {
|
|
|
348
357
|
// The locator half derives from `OwnClaim` rather than being restated: a
|
|
349
358
|
// member spelled out here is a member that dies before `sendBegin`, which is
|
|
350
359
|
// how `fields` used to be lost between `claim()` and the socket.
|
|
351
|
-
function mintHandle(args) {
|
|
352
|
-
const claimId = crypto.randomUUID();
|
|
360
|
+
function mintHandle(args, requestedClaimId) {
|
|
361
|
+
const claimId = requestedClaimId ?? crypto.randomUUID();
|
|
353
362
|
const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
|
|
354
363
|
// The handle the caller reads back is a public claim, so its `meta` is the
|
|
355
364
|
// declared shape; the `OwnClaim` below stays wire-typed, because that is
|
|
@@ -402,7 +411,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
402
411
|
return target;
|
|
403
412
|
}
|
|
404
413
|
return {
|
|
405
|
-
claim(target, opts) {
|
|
414
|
+
claim(target, opts, claimId) {
|
|
406
415
|
const resolved = resolveTarget(target);
|
|
407
416
|
return mintHandle({
|
|
408
417
|
...wireTarget(resolved),
|
|
@@ -410,7 +419,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
410
419
|
description: claimDescription({ ...opts, meta: resolved.meta }),
|
|
411
420
|
ttl: opts?.ttl,
|
|
412
421
|
queue: opts?.queue,
|
|
413
|
-
});
|
|
422
|
+
}, claimId);
|
|
414
423
|
},
|
|
415
424
|
get others() {
|
|
416
425
|
return claimsSnapshot;
|
|
@@ -45,6 +45,15 @@ export interface MutationQueueConfig {
|
|
|
45
45
|
maxBatchSize: number;
|
|
46
46
|
batchDelay: number;
|
|
47
47
|
maxRetries: number;
|
|
48
|
+
/**
|
|
49
|
+
* Minimum wall-clock window for retrying transient write failures with the
|
|
50
|
+
* same durable envelope and idempotency key. This absorbs managed-database
|
|
51
|
+
* promotion and brief regional network incidents without double-applying a
|
|
52
|
+
* write. Defaults to 120 seconds: the Aurora promotion drill recovered
|
|
53
|
+
* writes just beyond 60 seconds, so a one-minute boundary discarded exact
|
|
54
|
+
* envelopes at the instant the new writer became usable.
|
|
55
|
+
*/
|
|
56
|
+
availabilityRetryWindowMs: number;
|
|
48
57
|
conflictResolution: ConflictResolution;
|
|
49
58
|
enablePersistence: boolean;
|
|
50
59
|
enableOptimistic: boolean;
|
|
@@ -127,6 +136,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
127
136
|
private replicationLagTimeouts;
|
|
128
137
|
private replicationLagErrors;
|
|
129
138
|
private commitProcessing;
|
|
139
|
+
private commitRetryTimer;
|
|
130
140
|
private lastCommitSequence;
|
|
131
141
|
private durableReplayBlock;
|
|
132
142
|
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
@@ -418,6 +428,15 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
418
428
|
maxBatchSize: number;
|
|
419
429
|
batchDelay: number;
|
|
420
430
|
maxRetries: number;
|
|
431
|
+
/**
|
|
432
|
+
* Minimum wall-clock window for retrying transient write failures with the
|
|
433
|
+
* same durable envelope and idempotency key. This absorbs managed-database
|
|
434
|
+
* promotion and brief regional network incidents without double-applying a
|
|
435
|
+
* write. Defaults to 120 seconds: the Aurora promotion drill recovered
|
|
436
|
+
* writes just beyond 60 seconds, so a one-minute boundary discarded exact
|
|
437
|
+
* envelopes at the instant the new writer became usable.
|
|
438
|
+
*/
|
|
439
|
+
availabilityRetryWindowMs: number;
|
|
421
440
|
conflictResolution: ConflictResolution;
|
|
422
441
|
enablePersistence: boolean;
|
|
423
442
|
enableOptimistic: boolean;
|
|
@@ -104,6 +104,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
104
104
|
replicationLagTimeouts = new Map();
|
|
105
105
|
replicationLagErrors = new Map();
|
|
106
106
|
commitProcessing = false;
|
|
107
|
+
commitRetryTimer = null;
|
|
107
108
|
lastCommitSequence = 0;
|
|
108
109
|
durableReplayBlock = null;
|
|
109
110
|
/** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
|
|
@@ -125,7 +126,11 @@ export class MutationQueue extends EventEmitter {
|
|
|
125
126
|
get commitLaneContext() {
|
|
126
127
|
return {
|
|
127
128
|
runtime: this.runtime,
|
|
128
|
-
config: {
|
|
129
|
+
config: {
|
|
130
|
+
maxRetries: this.config.maxRetries,
|
|
131
|
+
availabilityRetryWindowMs: this.config.availabilityRetryWindowMs,
|
|
132
|
+
retryBackoff: this.config.retryBackoff,
|
|
133
|
+
},
|
|
129
134
|
commitLane: this.commitLane,
|
|
130
135
|
commitNotifications: this.commitNotifications,
|
|
131
136
|
commitMissingIds: this.commitMissingIds,
|
|
@@ -147,6 +152,14 @@ export class MutationQueue extends EventEmitter {
|
|
|
147
152
|
noteAck: (syncId) => this.noteAck(syncId),
|
|
148
153
|
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
149
154
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
155
|
+
scheduleRetry: (delayMs) => {
|
|
156
|
+
if (this.commitRetryTimer !== null)
|
|
157
|
+
clearTimeout(this.commitRetryTimer);
|
|
158
|
+
this.commitRetryTimer = setTimeout(() => {
|
|
159
|
+
this.commitRetryTimer = null;
|
|
160
|
+
void this.processCommitLane();
|
|
161
|
+
}, delayMs);
|
|
162
|
+
},
|
|
150
163
|
emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
|
|
151
164
|
};
|
|
152
165
|
}
|
|
@@ -516,6 +529,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
516
529
|
maxBatchSize: 50, // send up to this many operations per commit
|
|
517
530
|
batchDelay: 150, // milliseconds to wait for more operations before sending
|
|
518
531
|
maxRetries: 3,
|
|
532
|
+
availabilityRetryWindowMs: 120_000,
|
|
519
533
|
conflictResolution: {
|
|
520
534
|
strategy: 'last-write-wins',
|
|
521
535
|
},
|
|
@@ -1506,6 +1520,10 @@ export class MutationQueue extends EventEmitter {
|
|
|
1506
1520
|
clearTimeout(this.commitOfflineGraceTimer);
|
|
1507
1521
|
this.commitOfflineGraceTimer = null;
|
|
1508
1522
|
}
|
|
1523
|
+
if (this.commitRetryTimer !== null) {
|
|
1524
|
+
clearTimeout(this.commitRetryTimer);
|
|
1525
|
+
this.commitRetryTimer = null;
|
|
1526
|
+
}
|
|
1509
1527
|
// Clear store
|
|
1510
1528
|
this.store.clear();
|
|
1511
1529
|
this.localMutationPort.updates.clear();
|
|
@@ -21,6 +21,7 @@ export interface CommitTransaction {
|
|
|
21
21
|
createdAt: number;
|
|
22
22
|
attempts: number;
|
|
23
23
|
transientAttempts?: number;
|
|
24
|
+
firstTransientFailureAt?: number;
|
|
24
25
|
lastSyncId?: number;
|
|
25
26
|
correlationId?: string;
|
|
26
27
|
error?: Error;
|
|
@@ -34,6 +35,11 @@ export interface CommitLaneContext {
|
|
|
34
35
|
readonly runtime: RuntimeContext;
|
|
35
36
|
readonly config: {
|
|
36
37
|
maxRetries: number;
|
|
38
|
+
availabilityRetryWindowMs: number;
|
|
39
|
+
retryBackoff: {
|
|
40
|
+
baseMs: number;
|
|
41
|
+
capMs: number;
|
|
42
|
+
};
|
|
37
43
|
};
|
|
38
44
|
readonly commitLane: CommitTransaction[];
|
|
39
45
|
readonly commitNotifications: Map<string, StaleNotification[]>;
|
|
@@ -52,6 +58,7 @@ export interface CommitLaneContext {
|
|
|
52
58
|
readonly noteAck: (syncId: number | undefined) => void;
|
|
53
59
|
readonly isDefinitiveRejection: (error: Error) => boolean;
|
|
54
60
|
readonly isPermanentError: (error: Error) => boolean;
|
|
61
|
+
readonly scheduleRetry: (delayMs: number) => void;
|
|
55
62
|
readonly emitCommitLifecycle: (event: string, payload: object) => void;
|
|
56
63
|
}
|
|
57
64
|
export interface CommitReceiptContext {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { transientRetryDelayMs } from './failureHandling.js';
|
|
2
2
|
export function waitForCommitReceipt(ctx, clientTxId) {
|
|
3
3
|
const drainNotifications = () => {
|
|
4
4
|
const notifications = ctx.commitNotifications.get(clientTxId);
|
|
@@ -112,15 +112,18 @@ export async function processCommitLane(ctx) {
|
|
|
112
112
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
113
113
|
if (dispatchStarted && ctx.isDefinitiveRejection(error))
|
|
114
114
|
await ctx.removeDurableCommit(tx.id);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
115
|
+
tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
|
|
116
|
+
tx.firstTransientFailureAt ??= Date.now();
|
|
117
|
+
const outsideAvailabilityWindow = Date.now() - tx.firstTransientFailureAt >= ctx.config.availabilityRetryWindowMs;
|
|
118
|
+
const exhausted = tx.transientAttempts > ctx.config.maxRetries && outsideAvailabilityWindow;
|
|
118
119
|
if (!ctx.isPermanentError(error) && !exhausted) {
|
|
119
120
|
tx.status = 'pending';
|
|
121
|
+
const delayMs = transientRetryDelayMs(error, tx.transientAttempts, ctx.config.retryBackoff);
|
|
120
122
|
ctx.runtime.logger.debug('[MutationQueue] commit lane transient', {
|
|
121
123
|
txId: tx.id.slice(0, 12), attempts: tx.attempts,
|
|
122
|
-
transientAttempts: tx.transientAttempts
|
|
124
|
+
transientAttempts: tx.transientAttempts, delayMs, message: error.message,
|
|
123
125
|
});
|
|
126
|
+
ctx.scheduleRetry(delayMs);
|
|
124
127
|
break;
|
|
125
128
|
}
|
|
126
129
|
tx.status = 'failed';
|