@abloatai/humans 0.37.1 → 0.38.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/core.d.ts +1 -0
- package/dist/core.js +4 -0
- package/dist/local/BaseSyncedStore.d.ts +4 -2
- package/dist/local/client/createModelProxy.js +14 -12
- package/dist/local/client/options.d.ts +7 -0
- package/dist/local/client/reactiveEngine.js +5 -3
- package/dist/local/client/storeLifecycle.js +6 -3
- package/dist/local/stores/DatabaseManager.d.ts +2 -2
- package/dist/local/stores/DatabaseManager.js +2 -2
- package/dist/local/stores/persistenceIdentity.d.ts +7 -8
- package/dist/local/stores/persistenceIdentity.js +4 -5
- package/dist/local/sync/SyncWebSocket.d.ts +7 -0
- package/dist/local/sync/SyncWebSocket.js +18 -0
- package/dist/local/sync/deltaPipeline.js +21 -13
- package/dist/local/sync/drainProfile.d.ts +59 -0
- package/dist/local/sync/drainProfile.js +127 -0
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.js +32 -12
- package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.js +2 -1
- package/package.json +2 -2
- package/src/core.ts +12 -0
- package/src/local/BaseSyncedStore.ts +4 -2
- package/src/local/client/createModelProxy.ts +14 -12
- package/src/local/client/options.ts +9 -0
- package/src/local/client/reactiveEngine.ts +5 -2
- package/src/local/client/storeLifecycle.ts +10 -4
- package/src/local/stores/DatabaseManager.ts +4 -4
- package/src/local/stores/persistenceIdentity.ts +10 -12
- package/src/local/sync/SyncWebSocket.ts +17 -0
- package/src/local/sync/deltaPipeline.ts +31 -21
- package/src/local/sync/drainProfile.ts +164 -0
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +31 -12
- package/src/local/transactions/mutations/pendingDrain.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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.38.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/core.ts
CHANGED
|
@@ -102,3 +102,15 @@ export { LoadStrategy } from '@abloatai/transaction/types';
|
|
|
102
102
|
// client around an existing store/provider. Kept on the explicit extension
|
|
103
103
|
// surface so those packages do not need the private alias package.
|
|
104
104
|
export type { InternalAbloOptions } from './local/client/options.js';
|
|
105
|
+
|
|
106
|
+
// Stage timings for the delta drain, so a benchmark harness can report where
|
|
107
|
+
// an observer's catch-up time went instead of inferring it. Inert unless
|
|
108
|
+
// `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
|
|
109
|
+
export {
|
|
110
|
+
drainProfileSnapshot,
|
|
111
|
+
resetDrainProfile,
|
|
112
|
+
drainProfilingEnabled,
|
|
113
|
+
type DrainProfile,
|
|
114
|
+
type DrainStage,
|
|
115
|
+
type DrainStageTotals,
|
|
116
|
+
} from './local/sync/drainProfile.js';
|
|
@@ -146,8 +146,10 @@ export interface UserContext {
|
|
|
146
146
|
organizationId: string;
|
|
147
147
|
/** Authenticated data-plane coordinates used to isolate local persistence. */
|
|
148
148
|
projectId?: string | null;
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
/** Immutable branch target. Authoritative whenever present. */
|
|
150
|
+
branchId: string;
|
|
151
|
+
/** True only when branchId is the project's production root. */
|
|
152
|
+
branchRoot?: boolean;
|
|
151
153
|
role?: string;
|
|
152
154
|
teamIds?: string[];
|
|
153
155
|
/** Participant kind on the wire. Default 'user' for browser
|
|
@@ -521,15 +521,17 @@ export function createModelProxy<T, C>(
|
|
|
521
521
|
| ModelDeleteParams<T, C>,
|
|
522
522
|
): MutationOptions => {
|
|
523
523
|
const rest: MutationOptions = {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
524
|
+
...(params.idempotencyKey !== undefined
|
|
525
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
526
|
+
: {}),
|
|
527
|
+
...(params.label !== undefined ? { label: params.label } : {}),
|
|
528
|
+
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
529
|
+
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
530
|
+
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
531
|
+
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
532
|
+
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
533
|
+
...(params.reads !== undefined ? { reads: params.reads } : {}),
|
|
534
|
+
...(params.track !== undefined ? { track: params.track } : {}),
|
|
533
535
|
};
|
|
534
536
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
535
537
|
// Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
|
|
@@ -713,7 +715,7 @@ export function createModelProxy<T, C>(
|
|
|
713
715
|
return {
|
|
714
716
|
object: 'claim',
|
|
715
717
|
id: lease.id,
|
|
716
|
-
readAt: snapshot.stamp,
|
|
718
|
+
readAt: lease.readAt ?? snapshot.stamp,
|
|
717
719
|
// The fencing token the server minted for this grant, forwarded from the
|
|
718
720
|
// lease so writes taken under this handle carry it (Option B).
|
|
719
721
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -1227,7 +1229,7 @@ export function createModelProxy<T, C>(
|
|
|
1227
1229
|
const effective: MutationOptions | undefined = claimed
|
|
1228
1230
|
? {
|
|
1229
1231
|
wait: 'confirmed',
|
|
1230
|
-
readAt: claimed.snapshot.stamp,
|
|
1232
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1231
1233
|
onStale: 'reject',
|
|
1232
1234
|
claimRef: { id: claimed.lease.id },
|
|
1233
1235
|
...opts,
|
|
@@ -1311,7 +1313,7 @@ export function createModelProxy<T, C>(
|
|
|
1311
1313
|
const effective: MutationOptions | undefined = claimed
|
|
1312
1314
|
? {
|
|
1313
1315
|
wait: 'confirmed',
|
|
1314
|
-
readAt: claimed.snapshot.stamp,
|
|
1316
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1315
1317
|
onStale: 'reject',
|
|
1316
1318
|
claimRef: { id: claimed.lease.id },
|
|
1317
1319
|
...(claimed.lease.fenceToken !== undefined
|
|
@@ -520,6 +520,15 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
520
520
|
*/
|
|
521
521
|
organizationId?: string;
|
|
522
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Immutable branch selected by a self-hosted credential. Hosted clients
|
|
525
|
+
* receive this from the credential exchange.
|
|
526
|
+
*/
|
|
527
|
+
branchId?: string;
|
|
528
|
+
|
|
529
|
+
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
530
|
+
branchRoot?: boolean;
|
|
531
|
+
|
|
523
532
|
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
524
533
|
* from the public option rather than restated, so the two cannot diverge. */
|
|
525
534
|
wait?: AbloOptions['wait'];
|
|
@@ -401,6 +401,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
401
401
|
claim: Claim,
|
|
402
402
|
waited = false,
|
|
403
403
|
fenceToken?: number,
|
|
404
|
+
readAt?: number,
|
|
404
405
|
): Claim {
|
|
405
406
|
const release = (): Promise<void> => {
|
|
406
407
|
claim.revoke?.();
|
|
@@ -416,6 +417,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
416
417
|
description: claim.description,
|
|
417
418
|
target: claim.target,
|
|
418
419
|
waited,
|
|
420
|
+
...(readAt !== undefined ? { readAt } : {}),
|
|
419
421
|
...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
|
|
420
422
|
release,
|
|
421
423
|
revoke: claim.revoke,
|
|
@@ -448,9 +450,10 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
448
450
|
// holds the lease, never a half-claimed one racing the queue.
|
|
449
451
|
let waited = false;
|
|
450
452
|
let fenceToken: number | undefined;
|
|
453
|
+
let readAt: number | undefined;
|
|
451
454
|
if (claimOptions.queue) {
|
|
452
455
|
try {
|
|
453
|
-
({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
|
|
456
|
+
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
454
457
|
timeoutMs: claimOptions.waitTimeoutMs,
|
|
455
458
|
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
456
459
|
signal: claimOptions.signal,
|
|
@@ -464,7 +467,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
464
467
|
throw err;
|
|
465
468
|
}
|
|
466
469
|
}
|
|
467
|
-
return wrapClaimHandle(claim, waited, fenceToken);
|
|
470
|
+
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
468
471
|
},
|
|
469
472
|
list(target?: Partial<ModelTarget>): readonly ModelClaim[] {
|
|
470
473
|
return listModelClaims(target);
|
|
@@ -194,8 +194,8 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
194
194
|
userId,
|
|
195
195
|
accountScope,
|
|
196
196
|
projectId,
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
branchId,
|
|
198
|
+
branchRoot,
|
|
199
199
|
teamIds,
|
|
200
200
|
capabilityToken,
|
|
201
201
|
syncGroups,
|
|
@@ -257,13 +257,19 @@ export function startStoreLifecycle<S extends SchemaRecord>(
|
|
|
257
257
|
// option doc) and everyone else defaults to 'full'.
|
|
258
258
|
const resolvedBootstrapMode: 'full' | 'none' =
|
|
259
259
|
internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
|
|
260
|
+
if (!branchId) {
|
|
261
|
+
throw new AbloConnectionError(
|
|
262
|
+
'The server did not resolve an Ablo branch for this credential.',
|
|
263
|
+
{ code: 'invalid_request' },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
260
266
|
|
|
261
267
|
const gen = store.initialize({
|
|
262
268
|
userId,
|
|
263
269
|
organizationId: accountScope,
|
|
264
270
|
projectId,
|
|
265
|
-
|
|
266
|
-
|
|
271
|
+
branchId,
|
|
272
|
+
branchRoot,
|
|
267
273
|
teamIds,
|
|
268
274
|
kind: participantKind,
|
|
269
275
|
capabilityToken,
|
|
@@ -31,8 +31,8 @@ export interface DatabaseInfo {
|
|
|
31
31
|
workspaceId: string;
|
|
32
32
|
participantKind: string;
|
|
33
33
|
projectId: string | null;
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
branchId: string;
|
|
35
|
+
branchRoot: boolean;
|
|
36
36
|
schemaHash: string;
|
|
37
37
|
schemaVersion: number;
|
|
38
38
|
userVersion?: number;
|
|
@@ -173,8 +173,8 @@ export class DatabaseManager {
|
|
|
173
173
|
workspaceId: identity.organizationId,
|
|
174
174
|
participantKind: identity.participantKind,
|
|
175
175
|
projectId: identity.projectId,
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
branchId: identity.branchId,
|
|
177
|
+
branchRoot: identity.branchRoot,
|
|
178
178
|
schemaHash,
|
|
179
179
|
schemaVersion,
|
|
180
180
|
userVersion,
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { AbloConnectionError } from '@abloatai/transaction/errors';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The complete authenticated
|
|
5
|
-
*
|
|
6
|
-
* those replicas must never share a namespace.
|
|
4
|
+
* The complete authenticated branch that owns one local replica. A branch id
|
|
5
|
+
* is authoritative.
|
|
7
6
|
*/
|
|
8
7
|
export interface PersistenceIdentity {
|
|
9
8
|
readonly participantId: string;
|
|
10
9
|
readonly participantKind: string;
|
|
11
10
|
readonly organizationId: string;
|
|
12
11
|
readonly projectId: string | null;
|
|
13
|
-
readonly
|
|
14
|
-
readonly
|
|
12
|
+
readonly branchId: string;
|
|
13
|
+
readonly branchRoot: boolean;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export interface PersistedIdentityMetadata {
|
|
@@ -20,11 +19,11 @@ export interface PersistedIdentityMetadata {
|
|
|
20
19
|
readonly workspaceId: string;
|
|
21
20
|
readonly participantKind?: string;
|
|
22
21
|
readonly projectId?: string | null;
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
22
|
+
readonly branchId?: string;
|
|
23
|
+
readonly branchRoot?: boolean;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
export const PERSISTENCE_NAMESPACE_VERSION =
|
|
26
|
+
export const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
28
27
|
|
|
29
28
|
function canonicalIdentity(
|
|
30
29
|
identity: PersistenceIdentity,
|
|
@@ -33,8 +32,7 @@ function canonicalIdentity(
|
|
|
33
32
|
return JSON.stringify([
|
|
34
33
|
PERSISTENCE_NAMESPACE_VERSION,
|
|
35
34
|
identity.projectId,
|
|
36
|
-
identity.
|
|
37
|
-
identity.sandboxId,
|
|
35
|
+
['branch', identity.branchId, identity.branchRoot],
|
|
38
36
|
identity.organizationId,
|
|
39
37
|
identity.participantKind,
|
|
40
38
|
identity.participantId,
|
|
@@ -77,7 +75,7 @@ export function persistenceIdentityMatches(
|
|
|
77
75
|
info.workspaceId === identity.organizationId &&
|
|
78
76
|
info.participantKind === identity.participantKind &&
|
|
79
77
|
(info.projectId ?? null) === identity.projectId &&
|
|
80
|
-
|
|
81
|
-
(info.
|
|
78
|
+
info.branchId === identity.branchId &&
|
|
79
|
+
(info.branchRoot ?? false) === identity.branchRoot
|
|
82
80
|
);
|
|
83
81
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { getContext } from '../context.js';
|
|
14
14
|
import { clientSyncDeltaSchema, type ClientSyncDelta } from '@abloatai/transaction/wire/delta';
|
|
15
|
+
import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
|
|
15
16
|
import {
|
|
16
17
|
WsTransport,
|
|
17
18
|
type WsTransportOptions,
|
|
@@ -212,7 +213,23 @@ export class SyncWebSocket<
|
|
|
212
213
|
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
213
214
|
* delta — callers must not re-parse.
|
|
214
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Wire validation runs once per delta, so at drain scale it is a per-delta
|
|
218
|
+
* fixed cost rather than a payload-proportional one. The guard keeps the
|
|
219
|
+
* normal path free: when profiling is off this is a boolean test and a
|
|
220
|
+
* direct call, with no closure allocated per delta.
|
|
221
|
+
*/
|
|
215
222
|
private normalizeWireDelta(raw: unknown): SyncDelta | null {
|
|
223
|
+
if (!drainProfilingEnabled()) return this.parseWireDelta(raw);
|
|
224
|
+
const startedAt = performance.now();
|
|
225
|
+
try {
|
|
226
|
+
return this.parseWireDelta(raw);
|
|
227
|
+
} finally {
|
|
228
|
+
observeDrainStage('parse', performance.now() - startedAt);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private parseWireDelta(raw: unknown): SyncDelta | null {
|
|
216
233
|
let candidate: unknown = raw;
|
|
217
234
|
if (isRecord(raw)) {
|
|
218
235
|
const normalized: Record<string, unknown> = { ...raw };
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type AbloPlugin,
|
|
25
25
|
type AppliedChange,
|
|
26
26
|
} from '../../plugin.js';
|
|
27
|
+
import { observeDrainBatch, timeDrainStage, timeDrainStageAsync } from './drainProfile.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* What the pipeline needs back from the surrounding store: the shared mutable
|
|
@@ -409,7 +410,8 @@ async function flushDeltaBatch(
|
|
|
409
410
|
queuedDeltas: SyncDelta[],
|
|
410
411
|
): Promise<void> {
|
|
411
412
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
412
|
-
const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
|
|
413
|
+
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
414
|
+
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
413
415
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
414
416
|
|
|
415
417
|
// Custom entities → apply straight to the pool, skipping the local store.
|
|
@@ -444,17 +446,19 @@ async function flushDeltaBatch(
|
|
|
444
446
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
445
447
|
// signature accepts them defensively.
|
|
446
448
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
447
|
-
const batch = await
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
449
|
+
const batch = await timeDrainStageAsync('persist', () =>
|
|
450
|
+
ctx.processDeltaBatch(
|
|
451
|
+
regularDeltas.map((d) => ({
|
|
452
|
+
syncId: d.id,
|
|
453
|
+
actionType: d.actionType,
|
|
454
|
+
modelName: d.modelName,
|
|
455
|
+
modelId: d.modelId,
|
|
456
|
+
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
457
|
+
// Thread `transactionId` through so the receive layer can recognize
|
|
458
|
+
// echoes of locally-applied transactions and skip the pool mutation.
|
|
459
|
+
transactionId: d.transactionId,
|
|
460
|
+
}))
|
|
461
|
+
)
|
|
458
462
|
);
|
|
459
463
|
const dbResults = batch.results;
|
|
460
464
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
@@ -464,11 +468,13 @@ async function flushDeltaBatch(
|
|
|
464
468
|
// materialiser attached where it said it would. The direct call is the
|
|
465
469
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
466
470
|
// whose own apply is the whole pipeline.
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
471
|
+
timeDrainStage('apply', () => {
|
|
472
|
+
if (pluginsForStage(stagePlugins, 'apply').length > 0) {
|
|
473
|
+
runStage(stagePlugins, 'apply', { changes: dbResults });
|
|
474
|
+
} else {
|
|
475
|
+
ctx.applyDeltaBatchToPool(dbResults);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
472
478
|
|
|
473
479
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
474
480
|
//
|
|
@@ -480,11 +486,15 @@ async function flushDeltaBatch(
|
|
|
480
486
|
// be lost. The cursor and the persisted state must move together.
|
|
481
487
|
const persistedSyncId = batch.persistedSyncId;
|
|
482
488
|
if (persistedSyncId > ctx.lastAckedId) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
489
|
+
timeDrainStage('acknowledge', () => {
|
|
490
|
+
ctx.acknowledge(persistedSyncId);
|
|
491
|
+
ctx.advancePersisted(persistedSyncId);
|
|
492
|
+
runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
|
|
493
|
+
});
|
|
486
494
|
}
|
|
487
495
|
|
|
488
496
|
// Cache invalidation happens automatically via the 'models:changed' event.
|
|
489
|
-
|
|
497
|
+
timeDrainStage('notify', () => {
|
|
498
|
+
runStage(stagePlugins, 'notify', { changes: dbResults });
|
|
499
|
+
});
|
|
490
500
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the drain's seconds go.
|
|
3
|
+
*
|
|
4
|
+
* A commit's receipt is confirmed the moment its PostgreSQL transaction
|
|
5
|
+
* commits, but an observer is not caught up until it has applied the last
|
|
6
|
+
* delta. Server publication p95 is single-digit milliseconds while the final
|
|
7
|
+
* observer takes seconds, so the gap is client-side and has never been
|
|
8
|
+
* attributed to a stage. Two fixes aimed at wire bytes (project filtering,
|
|
9
|
+
* patch-only UPDATE delivery) each returned well under a third, which is
|
|
10
|
+
* evidence the dominant term is a per-delta or per-batch fixed cost rather
|
|
11
|
+
* than payload size.
|
|
12
|
+
*
|
|
13
|
+
* This times the stages a delta actually passes through so a benchmark run can
|
|
14
|
+
* state the attribution instead of inferring it. It is off unless
|
|
15
|
+
* `ABLO_PROFILE_DRAIN=true`, and every entry point returns before doing work
|
|
16
|
+
* when off, mirroring the server's commit profiler.
|
|
17
|
+
*
|
|
18
|
+
* The stage vocabulary derives from {@link PipelineStage}; `parse` is the one
|
|
19
|
+
* addition, because wire validation happens in the transport before a delta
|
|
20
|
+
* reaches the pipeline at all.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PipelineStage } from '../../plugin.js';
|
|
24
|
+
|
|
25
|
+
/** The pipeline's own stages plus the transport-level wire validation ahead of them. */
|
|
26
|
+
export type DrainStage = 'parse' | PipelineStage;
|
|
27
|
+
|
|
28
|
+
export interface DrainStageTotals {
|
|
29
|
+
/** Accumulated wall time attributed to this stage. */
|
|
30
|
+
readonly totalMs: number;
|
|
31
|
+
/** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
|
|
32
|
+
readonly calls: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DrainProfile {
|
|
36
|
+
/** Flush batches drained. The per-batch fixed cost multiplies by this. */
|
|
37
|
+
readonly batches: number;
|
|
38
|
+
/** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
|
|
39
|
+
readonly deltas: number;
|
|
40
|
+
/** Deltas dropped by the dedupe stage before persistence. */
|
|
41
|
+
readonly deduplicated: number;
|
|
42
|
+
/** Wall time from the first observed stage to the last. */
|
|
43
|
+
readonly spanMs: number;
|
|
44
|
+
readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DRAIN_STAGES: readonly DrainStage[] = [
|
|
48
|
+
'parse',
|
|
49
|
+
'receive',
|
|
50
|
+
'dedupe',
|
|
51
|
+
'persist',
|
|
52
|
+
'apply',
|
|
53
|
+
'acknowledge',
|
|
54
|
+
'notify',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
interface MutableTotals {
|
|
58
|
+
totalMs: number;
|
|
59
|
+
calls: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function emptyTotals(): Record<DrainStage, MutableTotals> {
|
|
63
|
+
const totals = {} as Record<DrainStage, MutableTotals>;
|
|
64
|
+
for (const stage of DRAIN_STAGES) totals[stage] = { totalMs: 0, calls: 0 };
|
|
65
|
+
return totals;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let totals = emptyTotals();
|
|
69
|
+
let batches = 0;
|
|
70
|
+
let deltas = 0;
|
|
71
|
+
let deduplicated = 0;
|
|
72
|
+
let firstMark: number | undefined;
|
|
73
|
+
let lastMark = 0;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read once. A profiler that consults the environment on every delta would
|
|
77
|
+
* itself become a per-delta cost in the path it is measuring.
|
|
78
|
+
*/
|
|
79
|
+
const enabled: boolean = (() => {
|
|
80
|
+
const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
|
|
81
|
+
return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
85
|
+
export function drainProfilingEnabled(): boolean {
|
|
86
|
+
return enabled;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function mark(elapsedMs: number): void {
|
|
90
|
+
const now = performance.now();
|
|
91
|
+
firstMark ??= now - elapsedMs;
|
|
92
|
+
lastMark = now;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Attribute already-measured wall time to a stage. */
|
|
96
|
+
export function observeDrainStage(stage: DrainStage, elapsedMs: number): void {
|
|
97
|
+
if (!enabled) return;
|
|
98
|
+
const entry = totals[stage];
|
|
99
|
+
entry.totalMs += elapsedMs;
|
|
100
|
+
entry.calls += 1;
|
|
101
|
+
mark(elapsedMs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
105
|
+
export function timeDrainStage<T>(stage: DrainStage, run: () => T): T {
|
|
106
|
+
if (!enabled) return run();
|
|
107
|
+
const startedAt = performance.now();
|
|
108
|
+
try {
|
|
109
|
+
return run();
|
|
110
|
+
} finally {
|
|
111
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
116
|
+
export async function timeDrainStageAsync<T>(
|
|
117
|
+
stage: DrainStage,
|
|
118
|
+
run: () => Promise<T>,
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
if (!enabled) return run();
|
|
121
|
+
const startedAt = performance.now();
|
|
122
|
+
try {
|
|
123
|
+
return await run();
|
|
124
|
+
} finally {
|
|
125
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
131
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
132
|
+
* is reported alongside the timings rather than derived from them.
|
|
133
|
+
*/
|
|
134
|
+
export function observeDrainBatch(received: number, survived: number): void {
|
|
135
|
+
if (!enabled) return;
|
|
136
|
+
batches += 1;
|
|
137
|
+
deltas += received;
|
|
138
|
+
deduplicated += Math.max(0, received - survived);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The totals accumulated since the last reset. */
|
|
142
|
+
export function drainProfileSnapshot(): DrainProfile {
|
|
143
|
+
const stages = {} as Record<DrainStage, DrainStageTotals>;
|
|
144
|
+
for (const stage of DRAIN_STAGES) {
|
|
145
|
+
stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
batches,
|
|
149
|
+
deltas,
|
|
150
|
+
deduplicated,
|
|
151
|
+
spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
|
|
152
|
+
stages,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
157
|
+
export function resetDrainProfile(): void {
|
|
158
|
+
totals = emptyTotals();
|
|
159
|
+
batches = 0;
|
|
160
|
+
deltas = 0;
|
|
161
|
+
deduplicated = 0;
|
|
162
|
+
firstMark = undefined;
|
|
163
|
+
lastMark = 0;
|
|
164
|
+
}
|
|
@@ -54,8 +54,8 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
54
54
|
organizationId: context.organizationId,
|
|
55
55
|
participantKind: context.kind ?? 'user',
|
|
56
56
|
projectId: context.projectId ?? context.organizationId,
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
branchId: context.branchId,
|
|
58
|
+
branchRoot: context.branchRoot ?? false,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// Propagate identity only after storage is ready, then restore sealed
|
|
@@ -1222,17 +1222,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
1222
1222
|
* transaction.
|
|
1223
1223
|
*/
|
|
1224
1224
|
confirmationFor(modelName: string, modelId: string): Promise<void> {
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1225
|
+
const transactions = this.store.getAll();
|
|
1226
|
+
for (let index = transactions.length - 1; index >= 0; index--) {
|
|
1227
|
+
const transaction = transactions[index];
|
|
1228
|
+
if (
|
|
1229
|
+
transaction?.modelName === modelName &&
|
|
1230
|
+
transaction.modelId === modelId &&
|
|
1231
|
+
(transaction.status === 'pending' ||
|
|
1232
|
+
transaction.status === 'executing' ||
|
|
1233
|
+
transaction.status === 'awaiting_delta')
|
|
1234
|
+
) {
|
|
1235
|
+
return transaction.confirmation ?? Promise.resolve();
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return Promise.resolve();
|
|
1236
1239
|
}
|
|
1237
1240
|
|
|
1238
1241
|
/**
|
|
@@ -1425,7 +1428,23 @@ export class MutationQueue extends EventEmitter {
|
|
|
1425
1428
|
}
|
|
1426
1429
|
|
|
1427
1430
|
private async drainPendingInternal(): Promise<void> {
|
|
1428
|
-
|
|
1431
|
+
// The normal batch scheduler and the explicit/reconnect drain are two
|
|
1432
|
+
// ways to drive the same durable queue. They must never seal the same
|
|
1433
|
+
// staged source records concurrently: the first seal consumes those
|
|
1434
|
+
// records, so the second would correctly reject them as already claimed.
|
|
1435
|
+
//
|
|
1436
|
+
// `isProcessing` is acquired synchronously before either path awaits,
|
|
1437
|
+
// making it the queue-wide execution lock. If the normal lane already
|
|
1438
|
+
// owns it, that lane will finish the pending work; callers waiting on a
|
|
1439
|
+
// specific confirmation remain attached to the exact transaction.
|
|
1440
|
+
if (this.isProcessing) return;
|
|
1441
|
+
this.isProcessing = true;
|
|
1442
|
+
try {
|
|
1443
|
+
await drainPendingSettlements(this.pendingDrainContext);
|
|
1444
|
+
} finally {
|
|
1445
|
+
this.isProcessing = false;
|
|
1446
|
+
if (this.executionQueue.length > 0) this.scheduleProcessing(true);
|
|
1447
|
+
}
|
|
1429
1448
|
}
|
|
1430
1449
|
async create(
|
|
1431
1450
|
model: LocalModel,
|
|
@@ -10,7 +10,7 @@ export interface PendingDrainContext {
|
|
|
10
10
|
readonly runtime: RuntimeContext;
|
|
11
11
|
readonly config: { deltaConfirmationTimeout: number };
|
|
12
12
|
readonly store: MutationStore;
|
|
13
|
-
executionQueue: QueuedMutation[];
|
|
13
|
+
readonly executionQueue: QueuedMutation[];
|
|
14
14
|
readonly optimisticUpdates: Map<string, OptimisticUpdateEntry>;
|
|
15
15
|
readonly assertDurableReplayOpen: () => void;
|
|
16
16
|
readonly processCommitLane: () => Promise<void>;
|
|
@@ -45,9 +45,14 @@ export async function drainPendingSettlements(ctx: PendingDrainContext): Promise
|
|
|
45
45
|
// These rows may already be waiting behind the normal batch timer. The
|
|
46
46
|
// reconnect fast path takes ownership of them for this attempt so the same
|
|
47
47
|
// transaction cannot dispatch concurrently through both paths.
|
|
48
|
-
|
|
48
|
+
const retainedQueue = ctx.executionQueue.filter(
|
|
49
49
|
(tx) => !pendingIds.has(tx.id),
|
|
50
50
|
);
|
|
51
|
+
ctx.executionQueue.splice(
|
|
52
|
+
0,
|
|
53
|
+
ctx.executionQueue.length,
|
|
54
|
+
...retainedQueue,
|
|
55
|
+
);
|
|
51
56
|
|
|
52
57
|
const remaining = [...pending];
|
|
53
58
|
while (remaining.length > 0) {
|