@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
|
@@ -54,6 +54,8 @@ export interface QueuedMutation {
|
|
|
54
54
|
status: 'pending' | 'executing' | 'awaiting_delta' | 'completed' | 'failed' | 'rolled_back';
|
|
55
55
|
createdAt: number;
|
|
56
56
|
attempts: number;
|
|
57
|
+
/** First transient dispatch failure in the current availability incident. */
|
|
58
|
+
firstTransientFailureAt?: number;
|
|
57
59
|
priority: 'normal' | 'high';
|
|
58
60
|
priorityScore: number;
|
|
59
61
|
writeOptions?: WriteOptions;
|
|
@@ -4,7 +4,7 @@ import type { QueuedMutation } from './commitPayload.js';
|
|
|
4
4
|
import type { MutationStore } from './MutationStore.js';
|
|
5
5
|
export interface FailureHandlingContext {
|
|
6
6
|
readonly runtime: RuntimeContext;
|
|
7
|
-
readonly config: Pick<MutationQueueConfig, 'enableOptimistic' | 'maxRetries' | 'retryBackoff'>;
|
|
7
|
+
readonly config: Pick<MutationQueueConfig, 'enableOptimistic' | 'maxRetries' | 'retryBackoff' | 'availabilityRetryWindowMs'>;
|
|
8
8
|
readonly store: MutationStore;
|
|
9
9
|
readonly isPermanentError: (error: Error) => boolean;
|
|
10
10
|
readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
|
|
@@ -13,4 +13,5 @@ export interface FailureHandlingContext {
|
|
|
13
13
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
14
14
|
readonly emit: (event: string, payload: object) => boolean;
|
|
15
15
|
}
|
|
16
|
+
export declare function transientRetryDelayMs(error: Error, attempt: number, retryBackoff: MutationQueueConfig['retryBackoff']): number;
|
|
16
17
|
export declare function handleFailure(ctx: FailureHandlingContext, transaction: QueuedMutation, error: Error): Promise<void>;
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { AbloError } from '@abloatai/transaction/errors';
|
|
2
2
|
import { extractStatusCode } from './commitPayload.js';
|
|
3
|
+
export function transientRetryDelayMs(error, attempt, retryBackoff) {
|
|
4
|
+
const { baseMs, capMs } = retryBackoff;
|
|
5
|
+
let base = baseMs;
|
|
6
|
+
try {
|
|
7
|
+
const status = extractStatusCode(error);
|
|
8
|
+
if (status === 429 || status === 503)
|
|
9
|
+
base = Math.max(baseMs, 1_000);
|
|
10
|
+
}
|
|
11
|
+
catch { }
|
|
12
|
+
const ceiling = Math.min(capMs, base * Math.pow(2, Math.max(0, attempt - 1)));
|
|
13
|
+
return Math.floor(Math.random() * ceiling);
|
|
14
|
+
}
|
|
3
15
|
export async function handleFailure(ctx, transaction, error) {
|
|
4
16
|
transaction.attempts++;
|
|
5
17
|
// Check whether this is a permanent error that should not be retried.
|
|
@@ -95,22 +107,15 @@ export async function handleFailure(ctx, transaction, error) {
|
|
|
95
107
|
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
96
108
|
return;
|
|
97
109
|
}
|
|
98
|
-
|
|
110
|
+
transaction.firstTransientFailureAt ??= Date.now();
|
|
111
|
+
const insideAvailabilityWindow = Date.now() - transaction.firstTransientFailureAt < ctx.config.availabilityRetryWindowMs;
|
|
112
|
+
if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
|
|
99
113
|
// Exponential backoff with full jitter on every transient retry:
|
|
100
114
|
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
101
115
|
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
102
116
|
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
103
117
|
// stall unrelated commits.
|
|
104
|
-
const
|
|
105
|
-
let base = baseMs;
|
|
106
|
-
try {
|
|
107
|
-
const status = extractStatusCode(error);
|
|
108
|
-
if (status === 429 || status === 503)
|
|
109
|
-
base = Math.max(baseMs, 1_000);
|
|
110
|
-
}
|
|
111
|
-
catch { }
|
|
112
|
-
const ceiling = Math.min(capMs, base * Math.pow(2, transaction.attempts - 1));
|
|
113
|
-
const delay = Math.floor(Math.random() * ceiling);
|
|
118
|
+
const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
|
|
114
119
|
ctx.store.updateStatus(transaction.id, 'pending');
|
|
115
120
|
setTimeout(() => {
|
|
116
121
|
// The queue may have shut down or the tx may have been settled
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.46.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/Ablo.ts
CHANGED
|
@@ -388,6 +388,12 @@ export namespace Ablo {
|
|
|
388
388
|
export type Held<T = Record<string, unknown>> = import('@abloatai/transaction/types/streams').HeldClaim<T>;
|
|
389
389
|
export type CreateOptions = import('./local/client/resourceTypes.js').ClaimCreateOptions;
|
|
390
390
|
export type WaitOptions = import('./local/client/resourceTypes.js').ClaimWaitOptions;
|
|
391
|
+
export type ContentionOptions =
|
|
392
|
+
import('@abloatai/transaction/resources/modelOperations').ClaimContentionOptions;
|
|
393
|
+
export type AttemptEvent =
|
|
394
|
+
import('@abloatai/transaction/resources/modelOperations').ClaimAttemptEvent;
|
|
395
|
+
export type QueueView =
|
|
396
|
+
import('@abloatai/transaction/resources/modelOperations').ClaimQueueView;
|
|
391
397
|
export type Client = import('./local/client/resourceTypes.js').ClaimResource;
|
|
392
398
|
}
|
|
393
399
|
|
|
@@ -78,6 +78,11 @@ export type {
|
|
|
78
78
|
ServerRetrieveOptions,
|
|
79
79
|
ClaimTargetOptions,
|
|
80
80
|
ClaimParams,
|
|
81
|
+
ClaimContentionOptions,
|
|
82
|
+
ClaimAttemptEvent,
|
|
83
|
+
ClaimQueueView,
|
|
84
|
+
ClaimSkipOptions,
|
|
85
|
+
ClaimSkipParams,
|
|
81
86
|
ClaimLookupParams,
|
|
82
87
|
ClaimReorderParams,
|
|
83
88
|
ClaimOptions,
|
|
@@ -97,6 +102,10 @@ import type {
|
|
|
97
102
|
ClaimLookupParams,
|
|
98
103
|
ClaimOptions,
|
|
99
104
|
ClaimParams,
|
|
105
|
+
ClaimSkipOptions,
|
|
106
|
+
ClaimSkipParams,
|
|
107
|
+
ClaimAttemptEvent,
|
|
108
|
+
ClaimQueueView,
|
|
100
109
|
ClaimReorderParams,
|
|
101
110
|
JoinOptions,
|
|
102
111
|
LocalCountOptions,
|
|
@@ -109,6 +118,10 @@ import type {
|
|
|
109
118
|
ModelUpdateParams,
|
|
110
119
|
ServerReadOptions,
|
|
111
120
|
} from '@abloatai/transaction/resources/modelOperations';
|
|
121
|
+
import {
|
|
122
|
+
claimQueueView,
|
|
123
|
+
resolveClaimContentionOptions,
|
|
124
|
+
} from '@abloatai/transaction/resources/modelOperations';
|
|
112
125
|
import type { HttpModelClient } from '@abloatai/transaction/transport/httpClient';
|
|
113
126
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
114
127
|
|
|
@@ -160,6 +173,8 @@ export interface ModelCollaboration {
|
|
|
160
173
|
waitTimeoutMs?: number;
|
|
161
174
|
/** Abort the queued wait — rejects with `claim_wait_aborted`. */
|
|
162
175
|
signal?: AbortSignal;
|
|
176
|
+
/** Request-scoped queued / granted / skipped / failed status events. */
|
|
177
|
+
onStatus?: (event: ClaimAttemptEvent) => void;
|
|
163
178
|
}): Promise<Claim>;
|
|
164
179
|
createSnapshot(modelKey: string, id: string): Snapshot;
|
|
165
180
|
/**
|
|
@@ -557,23 +572,13 @@ export function createModelProxy<T, C>(
|
|
|
557
572
|
);
|
|
558
573
|
}
|
|
559
574
|
const { id, ...options } = params;
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
575
|
+
// Read the local snapshot only to decide whether a post-grant re-read may
|
|
576
|
+
// be needed. Admission itself always goes to the server: a local presence
|
|
577
|
+
// snapshot may be stale or incomplete across instances.
|
|
563
578
|
const held = collaboration.state({ model: wireModel, id });
|
|
564
579
|
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
// The try-claim (`queue: false`): a held target is an expected outcome,
|
|
568
|
-
// not an error, so it resolves `null` — the caller reads `if (!claim)`
|
|
569
|
-
// and moves on; who holds it stays readable via `claim.state`. Best-effort
|
|
570
|
-
// at the client (a racing claim not yet synced into our snapshot slips
|
|
571
|
-
// through here) — the commit-time claim guard is the authoritative
|
|
572
|
-
// backstop that rejects the loser's first write. For work-distribution
|
|
573
|
-
// dedup that's exactly right: don't wait (that would double-process), skip.
|
|
574
|
-
if (failFast && contended) {
|
|
575
|
-
return null;
|
|
576
|
-
}
|
|
580
|
+
const contention = resolveClaimContentionOptions(options);
|
|
581
|
+
const failFast = !contention.wait;
|
|
577
582
|
|
|
578
583
|
// Ensure the row exists locally before claiming.
|
|
579
584
|
let model = ownRowOrThrow(id);
|
|
@@ -599,27 +604,41 @@ export function createModelProxy<T, C>(
|
|
|
599
604
|
|
|
600
605
|
// Acquire the lease. By default (`queue` is not false) this goes through the
|
|
601
606
|
// server's fair FIFO queue: `queue: true` resolves only once the lease is
|
|
602
|
-
// genuinely ours, blocking behind any current holder
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
607
|
+
// genuinely ours, blocking behind any current holder. Fail-fast skips the
|
|
608
|
+
// queue but still awaits the server: a conflict invisible in the local
|
|
609
|
+
// snapshot resolves `null`, never a speculative handle.
|
|
610
|
+
let lease: Claim;
|
|
611
|
+
try {
|
|
612
|
+
lease = await collaboration.createClaim({
|
|
613
|
+
target: {
|
|
614
|
+
model: wireModel,
|
|
615
|
+
id,
|
|
616
|
+
// The whole sub-entity locator in one move — listing its members here
|
|
617
|
+
// is what let `fields` die between the caller and the lease, so the
|
|
618
|
+
// claim covered the whole row while the caller believed it named parts.
|
|
619
|
+
...subTarget(options, schemaKey),
|
|
620
|
+
},
|
|
621
|
+
description: claimDescription(options),
|
|
622
|
+
ttl: options.ttl,
|
|
623
|
+
queue: contention.wait,
|
|
624
|
+
maxQueueDepth: contention.maxDepth,
|
|
625
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
626
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
627
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
628
|
+
signal: contention.signal,
|
|
629
|
+
onStatus: contention.onStatus,
|
|
630
|
+
});
|
|
631
|
+
} catch (err) {
|
|
632
|
+
const normalized = toAbloError(err);
|
|
633
|
+
if (
|
|
634
|
+
failFast &&
|
|
635
|
+
normalized instanceof AbloClaimedError &&
|
|
636
|
+
normalized.code === 'claim_conflict'
|
|
637
|
+
) {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
throw normalized;
|
|
641
|
+
}
|
|
623
642
|
|
|
624
643
|
// Only when the claim actually waited behind another holder can the row have
|
|
625
644
|
// changed underneath us — re-read so the claimed snapshot reflects what that
|
|
@@ -748,22 +767,8 @@ export function createModelProxy<T, C>(
|
|
|
748
767
|
{ code: 'model_claim_not_configured' },
|
|
749
768
|
);
|
|
750
769
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
// a claim the server would refuse.
|
|
754
|
-
const held = collaboration.state({ model: wireModel, id });
|
|
755
|
-
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
756
|
-
const failFast = options.queue === false;
|
|
757
|
-
|
|
758
|
-
// The try-claim (`queue: false`): resolve `null` if a holder is already
|
|
759
|
-
// visible — an expected outcome, not an error. Best-effort at the client —
|
|
760
|
-
// a row this participant never synced usually carries no local claim state
|
|
761
|
-
// either, so a peer gets the deterministic `null` only once it has
|
|
762
|
-
// observed the holder (entered the row's entity scope). The server's
|
|
763
|
-
// queue is the backstop for the queuing path.
|
|
764
|
-
if (failFast && contended) {
|
|
765
|
-
return null;
|
|
766
|
-
}
|
|
770
|
+
const contention = resolveClaimContentionOptions(options);
|
|
771
|
+
const failFast = !contention.wait;
|
|
767
772
|
|
|
768
773
|
// Enter the entity scope before acquiring the lease so the holder's claim
|
|
769
774
|
// presence broadcasts to everyone in this entity group — the same ordering
|
|
@@ -772,24 +777,38 @@ export function createModelProxy<T, C>(
|
|
|
772
777
|
// to hydrate here and nothing to re-read after the grant.
|
|
773
778
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
774
779
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
780
|
+
let lease: Claim;
|
|
781
|
+
try {
|
|
782
|
+
lease = await collaboration.createClaim({
|
|
783
|
+
target: {
|
|
784
|
+
model: wireModel,
|
|
785
|
+
id,
|
|
786
|
+
// The whole sub-entity locator in one move — listing its members here
|
|
787
|
+
// is what let `fields` die between the caller and the lease, so the
|
|
788
|
+
// claim covered the whole row while the caller believed it named parts.
|
|
789
|
+
...subTarget(options, schemaKey),
|
|
790
|
+
},
|
|
791
|
+
description: claimDescription(options),
|
|
792
|
+
ttl: options.ttl,
|
|
793
|
+
queue: contention.wait,
|
|
794
|
+
maxQueueDepth: contention.maxDepth,
|
|
795
|
+
// The one wait cap, declared once on ClaimTargetOptions — the socket
|
|
796
|
+
// wait and the HTTP poll-wait both honor it as `grant_timeout`.
|
|
797
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
798
|
+
signal: contention.signal,
|
|
799
|
+
onStatus: contention.onStatus,
|
|
800
|
+
});
|
|
801
|
+
} catch (err) {
|
|
802
|
+
const normalized = toAbloError(err);
|
|
803
|
+
if (
|
|
804
|
+
failFast &&
|
|
805
|
+
normalized instanceof AbloClaimedError &&
|
|
806
|
+
normalized.code === 'claim_conflict'
|
|
807
|
+
) {
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
throw normalized;
|
|
811
|
+
}
|
|
793
812
|
|
|
794
813
|
// A watermark-only snapshot: `createSnapshot` still reads the engine's
|
|
795
814
|
// current `lastSyncId` even though the pool holds no row (the bucket is
|
|
@@ -881,12 +900,12 @@ export function createModelProxy<T, C>(
|
|
|
881
900
|
const guardedTakeClaim = guard(takeClaim);
|
|
882
901
|
const guardedTakeRowFreeClaim = guard(takeRowFreeClaim);
|
|
883
902
|
function claim(
|
|
884
|
-
params:
|
|
903
|
+
params: ClaimSkipParams<C>,
|
|
885
904
|
): Promise<HeldClaim<T> | null>;
|
|
886
905
|
function claim(params: ClaimParams<C>): Promise<HeldClaim<T>>;
|
|
887
906
|
function claim(
|
|
888
907
|
id: string,
|
|
889
|
-
opts:
|
|
908
|
+
opts: ClaimSkipOptions<C>,
|
|
890
909
|
): Promise<HeldLease | null>;
|
|
891
910
|
function claim(id: string, opts?: ClaimOptions<C>): Promise<HeldLease>;
|
|
892
911
|
function claim(
|
|
@@ -973,11 +992,10 @@ export function createModelProxy<T, C>(
|
|
|
973
992
|
};
|
|
974
993
|
},
|
|
975
994
|
|
|
976
|
-
queue(params: ClaimLookupParams<T>):
|
|
977
|
-
return
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
};
|
|
995
|
+
queue(params: ClaimLookupParams<T>): ClaimQueueView {
|
|
996
|
+
return claimQueueView(
|
|
997
|
+
collaboration?.queue({ model: wireModel, id: params.id }) ?? [],
|
|
998
|
+
);
|
|
981
999
|
},
|
|
982
1000
|
|
|
983
1001
|
reorder(params: ClaimReorderParams<T>): void {
|
|
@@ -1086,6 +1104,7 @@ export function createModelProxy<T, C>(
|
|
|
1086
1104
|
// race — see `takeClaim`). Released with the lease in the `finally`
|
|
1087
1105
|
// below. Awaited for broadcast ordering; still best-effort.
|
|
1088
1106
|
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
1107
|
+
const contention = resolveClaimContentionOptions(claim);
|
|
1089
1108
|
autoLease = await collaboration.createClaim({
|
|
1090
1109
|
target: {
|
|
1091
1110
|
model: wireModel,
|
|
@@ -1094,8 +1113,11 @@ export function createModelProxy<T, C>(
|
|
|
1094
1113
|
},
|
|
1095
1114
|
description: claimDescription(claim, 'creating'),
|
|
1096
1115
|
ttl: claim.ttl,
|
|
1097
|
-
queue:
|
|
1098
|
-
maxQueueDepth:
|
|
1116
|
+
queue: contention.wait,
|
|
1117
|
+
maxQueueDepth: contention.maxDepth,
|
|
1118
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
1119
|
+
signal: contention.signal,
|
|
1120
|
+
onStatus: contention.onStatus,
|
|
1099
1121
|
});
|
|
1100
1122
|
}
|
|
1101
1123
|
|
|
@@ -1200,7 +1222,7 @@ export function createModelProxy<T, C>(
|
|
|
1200
1222
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1201
1223
|
if (autoClaim) {
|
|
1202
1224
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
1203
|
-
// A
|
|
1225
|
+
// A skipped try-claim is `null` only on the standalone verb; a
|
|
1204
1226
|
// write that could not take its claim is a failed write.
|
|
1205
1227
|
if (!handle) {
|
|
1206
1228
|
throw new AbloClaimedError(
|
|
@@ -62,6 +62,10 @@ import type {
|
|
|
62
62
|
CreateAgentSessionParams,
|
|
63
63
|
CreateSessionParams,
|
|
64
64
|
} from './resourceTypes.js';
|
|
65
|
+
import {
|
|
66
|
+
claimAttemptFailure,
|
|
67
|
+
emitClaimStatus,
|
|
68
|
+
} from '@abloatai/transaction/resources/modelOperations';
|
|
65
69
|
import { createModelProxy, type ModelOperations } from './createModelProxy.js';
|
|
66
70
|
import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
67
71
|
import type { AbloClient as Ablo } from '../../client.js';
|
|
@@ -409,8 +413,8 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
409
413
|
return Promise.resolve();
|
|
410
414
|
};
|
|
411
415
|
// The token is server-stamped and arrives on the grant frame, so prefer
|
|
412
|
-
// the one `awaitClaimGrant` read there;
|
|
413
|
-
// already
|
|
416
|
+
// the one `awaitClaimGrant` read there; retain the handle fallback for
|
|
417
|
+
// wire-compatible transports that already stamped it locally.
|
|
414
418
|
const resolvedFenceToken = fenceToken ?? claim.fenceToken;
|
|
415
419
|
return {
|
|
416
420
|
object: 'claim',
|
|
@@ -433,6 +437,38 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
433
437
|
const publicClaims: ClaimResource = Object.assign(claimStream, {
|
|
434
438
|
async create(claimOptions: ClaimCreateOptions): Promise<Claim> {
|
|
435
439
|
await ready();
|
|
440
|
+
// Subscribe before announcing the claim. A fast rejection can arrive
|
|
441
|
+
// in the same turn as `send` in tests and on a low-latency socket; if
|
|
442
|
+
// the listener is installed afterwards, that authoritative answer is
|
|
443
|
+
// lost and the locally minted handle looks like a grant.
|
|
444
|
+
const claimId = crypto.randomUUID();
|
|
445
|
+
const grant = awaitClaimGrant(transport, claimId, {
|
|
446
|
+
timeoutMs: claimOptions.waitTimeoutMs,
|
|
447
|
+
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
448
|
+
signal: claimOptions.signal,
|
|
449
|
+
logger,
|
|
450
|
+
onQueued: ({ position }) => {
|
|
451
|
+
emitClaimStatus(claimOptions.onStatus, {
|
|
452
|
+
type: 'queued',
|
|
453
|
+
claimId,
|
|
454
|
+
position,
|
|
455
|
+
ahead: position + 1,
|
|
456
|
+
});
|
|
457
|
+
},
|
|
458
|
+
onGranted: ({ waited }) => {
|
|
459
|
+
emitClaimStatus(claimOptions.onStatus, {
|
|
460
|
+
type: 'granted',
|
|
461
|
+
claimId,
|
|
462
|
+
waited,
|
|
463
|
+
});
|
|
464
|
+
},
|
|
465
|
+
onFailed: (error) => {
|
|
466
|
+
emitClaimStatus(
|
|
467
|
+
claimOptions.onStatus,
|
|
468
|
+
claimAttemptFailure(claimOptions.queue !== false, error),
|
|
469
|
+
);
|
|
470
|
+
},
|
|
471
|
+
});
|
|
436
472
|
const claim = claimStream.claim(
|
|
437
473
|
{
|
|
438
474
|
...streamTarget(claimOptions.target),
|
|
@@ -443,31 +479,17 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
443
479
|
ttl: claimOptions.ttl,
|
|
444
480
|
queue: claimOptions.queue,
|
|
445
481
|
},
|
|
482
|
+
claimId,
|
|
446
483
|
);
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
try {
|
|
457
|
-
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
458
|
-
timeoutMs: claimOptions.waitTimeoutMs,
|
|
459
|
-
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
460
|
-
signal: claimOptions.signal,
|
|
461
|
-
logger,
|
|
462
|
-
}));
|
|
463
|
-
} catch (err) {
|
|
464
|
-
// Gave up waiting (queue too deep, timed out, or lost) — abandon
|
|
465
|
-
// the queued claim so we don't leave a phantom entry in the
|
|
466
|
-
// line that would block or mislead other claimers.
|
|
467
|
-
claim.revoke?.();
|
|
468
|
-
throw err;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
484
|
+
// A claim is ours only after the server says so. This applies equally
|
|
485
|
+
// to queued claims and try-claims (`queue: false`): the latter must
|
|
486
|
+
// observe `claim_rejected` instead of returning a phantom handle.
|
|
487
|
+
const { waited, fenceToken, readAt } = await grant.catch((err: unknown) => {
|
|
488
|
+
// Give up the local/reconnect record after any rejection, timeout,
|
|
489
|
+
// abort, or lost lease. For queued claims this also leaves the line.
|
|
490
|
+
claim.revoke?.();
|
|
491
|
+
throw err;
|
|
492
|
+
});
|
|
471
493
|
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
472
494
|
},
|
|
473
495
|
list(target?: Partial<ModelTarget>): readonly ModelClaim[] {
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
claimDescription,
|
|
45
45
|
descriptionFromMeta,
|
|
46
46
|
participantKindFromWire,
|
|
47
|
+
type WireClaimSummary,
|
|
47
48
|
} from '@abloatai/transaction/coordination/schema';
|
|
48
49
|
import {
|
|
49
50
|
isTargetTuple,
|
|
@@ -88,13 +89,18 @@ const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
|
|
|
88
89
|
|
|
89
90
|
export interface AttachableClaimStream extends ClaimStream {
|
|
90
91
|
/**
|
|
91
|
-
* Mints
|
|
92
|
-
*
|
|
93
|
-
*
|
|
92
|
+
* Mints the local handle and sends its `claim_begin` frame. The handle is a
|
|
93
|
+
* request until the resource layer observes the server's grant; it must never
|
|
94
|
+
* be returned to application code before that acknowledgement. This is an
|
|
95
|
+
* internal entry point, not part of the public
|
|
94
96
|
* {@link ClaimStream}; application code takes a claim through
|
|
95
97
|
* `ablo.<model>.claim({ id })`, which is built on this.
|
|
98
|
+
*
|
|
99
|
+
* `claimId` lets that resource layer subscribe for the acknowledgement before
|
|
100
|
+
* this method sends. Omitting it preserves the direct stream API's generated
|
|
101
|
+
* id for internal callers that do not await the grant.
|
|
96
102
|
*/
|
|
97
|
-
claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
|
|
103
|
+
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
98
104
|
attach(transport: ClaimTransport): void;
|
|
99
105
|
/**
|
|
100
106
|
* Seeds the participant identity once the host resolves it. The stream can
|
|
@@ -193,6 +199,38 @@ export function createClaimStream(
|
|
|
193
199
|
}
|
|
194
200
|
};
|
|
195
201
|
|
|
202
|
+
const observeForeignClaim = (
|
|
203
|
+
heldBy: string,
|
|
204
|
+
claim: WireClaimSummary,
|
|
205
|
+
participantKind?: 'user' | 'agent' | 'system',
|
|
206
|
+
isAgent?: boolean,
|
|
207
|
+
): void => {
|
|
208
|
+
const description =
|
|
209
|
+
claim.description ??
|
|
210
|
+
descriptionFromMeta(claim.meta) ??
|
|
211
|
+
'editing';
|
|
212
|
+
const { meta, ...details } = subTarget(claim);
|
|
213
|
+
activeByClaimId.set(claim.claimId, {
|
|
214
|
+
object: 'claim',
|
|
215
|
+
id: claim.claimId,
|
|
216
|
+
status: 'active',
|
|
217
|
+
heldBy,
|
|
218
|
+
participantKind: participantKindFromWire(participantKind, isAgent),
|
|
219
|
+
target: {
|
|
220
|
+
...streamTarget(claim),
|
|
221
|
+
...details,
|
|
222
|
+
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
223
|
+
},
|
|
224
|
+
description,
|
|
225
|
+
ttlSeconds: Math.max(
|
|
226
|
+
0,
|
|
227
|
+
Math.floor((claim.expiresAt - Date.now()) / 1000),
|
|
228
|
+
),
|
|
229
|
+
createdAt: claim.declaredAt,
|
|
230
|
+
expiresAt: claim.expiresAt,
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
|
|
196
234
|
// ── Wire wiring ──────────────────────────────────────────────────
|
|
197
235
|
let attached: ClaimTransport | null = null;
|
|
198
236
|
const unsubs: (() => void)[] = [];
|
|
@@ -236,37 +274,12 @@ export function createClaimStream(
|
|
|
236
274
|
// drops it from `others`, which is what resolves a contender's
|
|
237
275
|
// `settled()`. Absent status means active (wire back-compat).
|
|
238
276
|
if (claim.status && claim.status !== 'active') continue;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// The frame is parsed permissively, on purpose; `declaredMeta` is where
|
|
246
|
-
// that wire value becomes the shape the program declared.
|
|
247
|
-
const { meta, ...details } = subTarget(claim);
|
|
248
|
-
activeByClaimId.set(claim.claimId, {
|
|
249
|
-
object: 'claim',
|
|
250
|
-
id: claim.claimId,
|
|
251
|
-
status: 'active',
|
|
252
|
-
heldBy: event.userId,
|
|
253
|
-
participantKind: participantKindFromWire(
|
|
254
|
-
event.participantKind,
|
|
255
|
-
event.isAgent,
|
|
256
|
-
),
|
|
257
|
-
target: {
|
|
258
|
-
...streamTarget(claim),
|
|
259
|
-
...details,
|
|
260
|
-
...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
|
|
261
|
-
},
|
|
262
|
-
description,
|
|
263
|
-
ttlSeconds: Math.max(
|
|
264
|
-
0,
|
|
265
|
-
Math.floor((claim.expiresAt - Date.now()) / 1000),
|
|
266
|
-
),
|
|
267
|
-
createdAt: claim.declaredAt,
|
|
268
|
-
expiresAt: claim.expiresAt,
|
|
269
|
-
});
|
|
277
|
+
observeForeignClaim(
|
|
278
|
+
event.userId,
|
|
279
|
+
claim,
|
|
280
|
+
event.participantKind,
|
|
281
|
+
event.isAgent,
|
|
282
|
+
);
|
|
270
283
|
mutated = true;
|
|
271
284
|
}
|
|
272
285
|
if (mutated) notifyListeners();
|
|
@@ -290,6 +303,22 @@ export function createClaimStream(
|
|
|
290
303
|
// a claim the server already rejected (would just spam both
|
|
291
304
|
// sides with conflicts).
|
|
292
305
|
ownClaims.delete(rejection.claimId);
|
|
306
|
+
// A holder on another server may have claimed before this client joined
|
|
307
|
+
// the row group, so its one-shot presence frame was missed. A conflict
|
|
308
|
+
// reply carries the authoritative holder summary; seed the same local
|
|
309
|
+
// state immediately instead of continuing to report the row as free.
|
|
310
|
+
if (
|
|
311
|
+
rejection.reason === 'conflict' &&
|
|
312
|
+
rejection.heldBy &&
|
|
313
|
+
rejection.heldByClaim
|
|
314
|
+
) {
|
|
315
|
+
observeForeignClaim(
|
|
316
|
+
rejection.heldBy,
|
|
317
|
+
rejection.heldByClaim,
|
|
318
|
+
rejection.heldByKind,
|
|
319
|
+
);
|
|
320
|
+
notifyListeners();
|
|
321
|
+
}
|
|
293
322
|
for (const l of rejectionListeners) {
|
|
294
323
|
try {
|
|
295
324
|
l(rejection);
|
|
@@ -523,8 +552,9 @@ export function createClaimStream(
|
|
|
523
552
|
ttl?: ClaimLeaseOptions['ttl'];
|
|
524
553
|
queue?: boolean;
|
|
525
554
|
},
|
|
555
|
+
requestedClaimId?: string,
|
|
526
556
|
): Claim {
|
|
527
|
-
const claimId = crypto.randomUUID();
|
|
557
|
+
const claimId = requestedClaimId ?? crypto.randomUUID();
|
|
528
558
|
const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
|
|
529
559
|
// The handle the caller reads back is a public claim, so its `meta` is the
|
|
530
560
|
// declared shape; the `OwnClaim` below stays wire-typed, because that is
|
|
@@ -589,6 +619,7 @@ export function createClaimStream(
|
|
|
589
619
|
claim(
|
|
590
620
|
target: PresenceTarget,
|
|
591
621
|
opts?: ClaimOptions,
|
|
622
|
+
claimId?: string,
|
|
592
623
|
): Claim {
|
|
593
624
|
const resolved = resolveTarget(target);
|
|
594
625
|
return mintHandle({
|
|
@@ -597,7 +628,7 @@ export function createClaimStream(
|
|
|
597
628
|
description: claimDescription({ ...opts, meta: resolved.meta }),
|
|
598
629
|
ttl: opts?.ttl,
|
|
599
630
|
queue: opts?.queue,
|
|
600
|
-
});
|
|
631
|
+
}, claimId);
|
|
601
632
|
},
|
|
602
633
|
get others() {
|
|
603
634
|
return claimsSnapshot;
|