@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/dist/core.d.ts
CHANGED
|
@@ -33,3 +33,4 @@ export { createClaimStream, type AttachableClaimStream, type ClaimStreamConfig,
|
|
|
33
33
|
export { awaitClaimGrant, type GrantTransport, } from '@abloatai/transaction/coordination/awaitClaimGrant';
|
|
34
34
|
export { LoadStrategy } from '@abloatai/transaction/types';
|
|
35
35
|
export type { InternalAbloOptions } from './local/client/options.js';
|
|
36
|
+
export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, type DrainProfile, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.js';
|
package/dist/core.js
CHANGED
|
@@ -46,3 +46,7 @@ export { awaitClaimGrant, } from '@abloatai/transaction/coordination/awaitClaimG
|
|
|
46
46
|
// An enum naming the strategies for loading a model's data. Referenced when
|
|
47
47
|
// registering models in extension code.
|
|
48
48
|
export { LoadStrategy } from '@abloatai/transaction/types';
|
|
49
|
+
// Stage timings for the delta drain, so a benchmark harness can report where
|
|
50
|
+
// an observer's catch-up time went instead of inferring it. Inert unless
|
|
51
|
+
// `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
|
|
52
|
+
export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, } from './local/sync/drainProfile.js';
|
|
@@ -86,8 +86,10 @@ export interface UserContext {
|
|
|
86
86
|
organizationId: string;
|
|
87
87
|
/** Authenticated data-plane coordinates used to isolate local persistence. */
|
|
88
88
|
projectId?: string | null;
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/** Immutable branch target. Authoritative whenever present. */
|
|
90
|
+
branchId: string;
|
|
91
|
+
/** True only when branchId is the project's production root. */
|
|
92
|
+
branchRoot?: boolean;
|
|
91
93
|
role?: string;
|
|
92
94
|
teamIds?: string[];
|
|
93
95
|
/** Participant kind on the wire. Default 'user' for browser
|
|
@@ -142,15 +142,17 @@ defaultWait) {
|
|
|
142
142
|
typeof value.release === 'function';
|
|
143
143
|
const mutationOptions = (params) => {
|
|
144
144
|
const rest = {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
...(params.idempotencyKey !== undefined
|
|
146
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
147
|
+
: {}),
|
|
148
|
+
...(params.label !== undefined ? { label: params.label } : {}),
|
|
149
|
+
...(params.wait !== undefined ? { wait: params.wait } : {}),
|
|
150
|
+
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
151
|
+
...(params.onStale !== undefined ? { onStale: params.onStale } : {}),
|
|
152
|
+
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
153
|
+
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
154
|
+
...(params.reads !== undefined ? { reads: params.reads } : {}),
|
|
155
|
+
...(params.track !== undefined ? { track: params.track } : {}),
|
|
154
156
|
};
|
|
155
157
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
156
158
|
// Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
|
|
@@ -312,7 +314,7 @@ defaultWait) {
|
|
|
312
314
|
return {
|
|
313
315
|
object: 'claim',
|
|
314
316
|
id: lease.id,
|
|
315
|
-
readAt: snapshot.stamp,
|
|
317
|
+
readAt: lease.readAt ?? snapshot.stamp,
|
|
316
318
|
// The fencing token the server minted for this grant, forwarded from the
|
|
317
319
|
// lease so writes taken under this handle carry it (Option B).
|
|
318
320
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -746,7 +748,7 @@ defaultWait) {
|
|
|
746
748
|
const effective = claimed
|
|
747
749
|
? {
|
|
748
750
|
wait: 'confirmed',
|
|
749
|
-
readAt: claimed.snapshot.stamp,
|
|
751
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
750
752
|
onStale: 'reject',
|
|
751
753
|
claimRef: { id: claimed.lease.id },
|
|
752
754
|
...opts,
|
|
@@ -816,7 +818,7 @@ defaultWait) {
|
|
|
816
818
|
const effective = claimed
|
|
817
819
|
? {
|
|
818
820
|
wait: 'confirmed',
|
|
819
|
-
readAt: claimed.snapshot.stamp,
|
|
821
|
+
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
820
822
|
onStale: 'reject',
|
|
821
823
|
claimRef: { id: claimed.lease.id },
|
|
822
824
|
...(claimed.lease.fenceToken !== undefined
|
|
@@ -434,6 +434,13 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
434
434
|
* identity from the token through the identity endpoint instead.
|
|
435
435
|
*/
|
|
436
436
|
organizationId?: string;
|
|
437
|
+
/**
|
|
438
|
+
* Immutable branch selected by a self-hosted credential. Hosted clients
|
|
439
|
+
* receive this from the credential exchange.
|
|
440
|
+
*/
|
|
441
|
+
branchId?: string;
|
|
442
|
+
/** Whether the selected self-hosted branch is the project's root branch. */
|
|
443
|
+
branchRoot?: boolean;
|
|
437
444
|
/** The client-wide write default — see {@link AbloOptions.wait}. Projected
|
|
438
445
|
* from the public option rather than restated, so the two cannot diverge. */
|
|
439
446
|
wait?: AbloOptions['wait'];
|
|
@@ -250,7 +250,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
}
|
|
253
|
-
function wrapClaimHandle(claim, waited = false, fenceToken) {
|
|
253
|
+
function wrapClaimHandle(claim, waited = false, fenceToken, readAt) {
|
|
254
254
|
const release = () => {
|
|
255
255
|
claim.revoke?.();
|
|
256
256
|
return Promise.resolve();
|
|
@@ -265,6 +265,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
265
265
|
description: claim.description,
|
|
266
266
|
target: claim.target,
|
|
267
267
|
waited,
|
|
268
|
+
...(readAt !== undefined ? { readAt } : {}),
|
|
268
269
|
...(resolvedFenceToken !== undefined ? { fenceToken: resolvedFenceToken } : {}),
|
|
269
270
|
release,
|
|
270
271
|
revoke: claim.revoke,
|
|
@@ -293,9 +294,10 @@ export function buildReactiveEngine(inputs) {
|
|
|
293
294
|
// holds the lease, never a half-claimed one racing the queue.
|
|
294
295
|
let waited = false;
|
|
295
296
|
let fenceToken;
|
|
297
|
+
let readAt;
|
|
296
298
|
if (claimOptions.queue) {
|
|
297
299
|
try {
|
|
298
|
-
({ waited, fenceToken } = await awaitClaimGrant(transport, claim.id, {
|
|
300
|
+
({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
|
|
299
301
|
timeoutMs: claimOptions.waitTimeoutMs,
|
|
300
302
|
maxQueueDepth: claimOptions.maxQueueDepth,
|
|
301
303
|
signal: claimOptions.signal,
|
|
@@ -310,7 +312,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
310
312
|
throw err;
|
|
311
313
|
}
|
|
312
314
|
}
|
|
313
|
-
return wrapClaimHandle(claim, waited, fenceToken);
|
|
315
|
+
return wrapClaimHandle(claim, waited, fenceToken, readAt);
|
|
314
316
|
},
|
|
315
317
|
list(target) {
|
|
316
318
|
return listModelClaims(target);
|
|
@@ -107,7 +107,7 @@ export function startStoreLifecycle(deps) {
|
|
|
107
107
|
auth: authCredentials,
|
|
108
108
|
logger,
|
|
109
109
|
});
|
|
110
|
-
const { userId, accountScope, projectId,
|
|
110
|
+
const { userId, accountScope, projectId, branchId, branchRoot, teamIds, capabilityToken, syncGroups, participantKind, } = resolved;
|
|
111
111
|
// Fail-loud guard: detect the degenerate "no real sync groups
|
|
112
112
|
// resolved" state before opening the socket. It is the same class of bug as
|
|
113
113
|
// a sensible-looking default that's functionally broken: the
|
|
@@ -155,12 +155,15 @@ export function startStoreLifecycle(deps) {
|
|
|
155
155
|
// agents default to 'none' (transactional participant — see
|
|
156
156
|
// option doc) and everyone else defaults to 'full'.
|
|
157
157
|
const resolvedBootstrapMode = internalOptions.bootstrapMode ?? (participantKind === 'agent' ? 'none' : 'full');
|
|
158
|
+
if (!branchId) {
|
|
159
|
+
throw new AbloConnectionError('The server did not resolve an Ablo branch for this credential.', { code: 'invalid_request' });
|
|
160
|
+
}
|
|
158
161
|
const gen = store.initialize({
|
|
159
162
|
userId,
|
|
160
163
|
organizationId: accountScope,
|
|
161
164
|
projectId,
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
branchId,
|
|
166
|
+
branchRoot,
|
|
164
167
|
teamIds,
|
|
165
168
|
kind: participantKind,
|
|
166
169
|
capabilityToken,
|
|
@@ -15,8 +15,8 @@ export interface DatabaseInfo {
|
|
|
15
15
|
workspaceId: string;
|
|
16
16
|
participantKind: string;
|
|
17
17
|
projectId: string | null;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
branchId: string;
|
|
19
|
+
branchRoot: boolean;
|
|
20
20
|
schemaHash: string;
|
|
21
21
|
schemaVersion: number;
|
|
22
22
|
userVersion?: number;
|
|
@@ -114,8 +114,8 @@ export class DatabaseManager {
|
|
|
114
114
|
workspaceId: identity.organizationId,
|
|
115
115
|
participantKind: identity.participantKind,
|
|
116
116
|
projectId: identity.projectId,
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
branchId: identity.branchId,
|
|
118
|
+
branchRoot: identity.branchRoot,
|
|
119
119
|
schemaHash,
|
|
120
120
|
schemaVersion,
|
|
121
121
|
userVersion,
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The complete authenticated
|
|
3
|
-
*
|
|
4
|
-
* those replicas must never share a namespace.
|
|
2
|
+
* The complete authenticated branch that owns one local replica. A branch id
|
|
3
|
+
* is authoritative.
|
|
5
4
|
*/
|
|
6
5
|
export interface PersistenceIdentity {
|
|
7
6
|
readonly participantId: string;
|
|
8
7
|
readonly participantKind: string;
|
|
9
8
|
readonly organizationId: string;
|
|
10
9
|
readonly projectId: string | null;
|
|
11
|
-
readonly
|
|
12
|
-
readonly
|
|
10
|
+
readonly branchId: string;
|
|
11
|
+
readonly branchRoot: boolean;
|
|
13
12
|
}
|
|
14
13
|
export interface PersistedIdentityMetadata {
|
|
15
14
|
readonly namespaceVersion?: number;
|
|
@@ -17,10 +16,10 @@ export interface PersistedIdentityMetadata {
|
|
|
17
16
|
readonly workspaceId: string;
|
|
18
17
|
readonly participantKind?: string;
|
|
19
18
|
readonly projectId?: string | null;
|
|
20
|
-
readonly
|
|
21
|
-
readonly
|
|
19
|
+
readonly branchId?: string;
|
|
20
|
+
readonly branchRoot?: boolean;
|
|
22
21
|
}
|
|
23
|
-
export declare const PERSISTENCE_NAMESPACE_VERSION =
|
|
22
|
+
export declare const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
24
23
|
/** Collision-resistant IndexedDB name for one authenticated data plane. */
|
|
25
24
|
export declare function persistenceDatabaseName(identity: PersistenceIdentity, userVersion?: number): Promise<string>;
|
|
26
25
|
/** Defense-in-depth check after namespace lookup and before persisted reads. */
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { AbloConnectionError } from '@abloatai/transaction/errors';
|
|
2
|
-
export const PERSISTENCE_NAMESPACE_VERSION =
|
|
2
|
+
export const PERSISTENCE_NAMESPACE_VERSION = 4;
|
|
3
3
|
function canonicalIdentity(identity, userVersion) {
|
|
4
4
|
return JSON.stringify([
|
|
5
5
|
PERSISTENCE_NAMESPACE_VERSION,
|
|
6
6
|
identity.projectId,
|
|
7
|
-
identity.
|
|
8
|
-
identity.sandboxId,
|
|
7
|
+
['branch', identity.branchId, identity.branchRoot],
|
|
9
8
|
identity.organizationId,
|
|
10
9
|
identity.participantKind,
|
|
11
10
|
identity.participantId,
|
|
@@ -33,6 +32,6 @@ export function persistenceIdentityMatches(info, identity) {
|
|
|
33
32
|
info.workspaceId === identity.organizationId &&
|
|
34
33
|
info.participantKind === identity.participantKind &&
|
|
35
34
|
(info.projectId ?? null) === identity.projectId &&
|
|
36
|
-
|
|
37
|
-
(info.
|
|
35
|
+
info.branchId === identity.branchId &&
|
|
36
|
+
(info.branchRoot ?? false) === identity.branchRoot);
|
|
38
37
|
}
|
|
@@ -94,7 +94,14 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
94
94
|
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
95
95
|
* delta — callers must not re-parse.
|
|
96
96
|
*/
|
|
97
|
+
/**
|
|
98
|
+
* Wire validation runs once per delta, so at drain scale it is a per-delta
|
|
99
|
+
* fixed cost rather than a payload-proportional one. The guard keeps the
|
|
100
|
+
* normal path free: when profiling is off this is a boolean test and a
|
|
101
|
+
* direct call, with no closure allocated per delta.
|
|
102
|
+
*/
|
|
97
103
|
private normalizeWireDelta;
|
|
104
|
+
private parseWireDelta;
|
|
98
105
|
/**
|
|
99
106
|
* Handle incoming sync delta (untrusted wire input — validated and
|
|
100
107
|
* normalized by {@link normalizeWireDelta}; malformed deltas are dropped).
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { getContext } from '../context.js';
|
|
13
13
|
import { clientSyncDeltaSchema } from '@abloatai/transaction/wire/delta';
|
|
14
|
+
import { drainProfilingEnabled, observeDrainStage } from './drainProfile.js';
|
|
14
15
|
import { WsTransport, } from '@abloatai/transaction/transport/wsTransport';
|
|
15
16
|
import { isRecord } from './wsFrameHandlers.js';
|
|
16
17
|
// Sync-position state (lastSyncId watermark, version vector, server cursor).
|
|
@@ -123,7 +124,24 @@ export class SyncWebSocket extends WsTransport {
|
|
|
123
124
|
* and an observability breadcrumb; it is never applied. There is one parse per
|
|
124
125
|
* delta — callers must not re-parse.
|
|
125
126
|
*/
|
|
127
|
+
/**
|
|
128
|
+
* Wire validation runs once per delta, so at drain scale it is a per-delta
|
|
129
|
+
* fixed cost rather than a payload-proportional one. The guard keeps the
|
|
130
|
+
* normal path free: when profiling is off this is a boolean test and a
|
|
131
|
+
* direct call, with no closure allocated per delta.
|
|
132
|
+
*/
|
|
126
133
|
normalizeWireDelta(raw) {
|
|
134
|
+
if (!drainProfilingEnabled())
|
|
135
|
+
return this.parseWireDelta(raw);
|
|
136
|
+
const startedAt = performance.now();
|
|
137
|
+
try {
|
|
138
|
+
return this.parseWireDelta(raw);
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
observeDrainStage('parse', performance.now() - startedAt);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
parseWireDelta(raw) {
|
|
127
145
|
let candidate = raw;
|
|
128
146
|
if (isRecord(raw)) {
|
|
129
147
|
const normalized = { ...raw };
|
|
@@ -14,6 +14,7 @@ import { runInAction } from 'mobx';
|
|
|
14
14
|
import { globalRuntime } from '../context.js';
|
|
15
15
|
import { ModelScope } from '../InstanceCache.js';
|
|
16
16
|
import { runStage, pluginsForStage, } from '../../plugin.js';
|
|
17
|
+
import { observeDrainBatch, timeDrainStage, timeDrainStageAsync } from './drainProfile.js';
|
|
17
18
|
/**
|
|
18
19
|
* One drain per store. Incoming WebSocket frames may arrive while persistence
|
|
19
20
|
* and pool application are awaiting. Without a single-flight guard every
|
|
@@ -295,7 +296,8 @@ function yieldToHost() {
|
|
|
295
296
|
}
|
|
296
297
|
async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
297
298
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
298
|
-
const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
|
|
299
|
+
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
300
|
+
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
299
301
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
300
302
|
// Custom entities → apply straight to the pool, skipping the local store.
|
|
301
303
|
const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
|
|
@@ -332,7 +334,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
332
334
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
333
335
|
// signature accepts them defensively.
|
|
334
336
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
335
|
-
const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
337
|
+
const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
336
338
|
syncId: d.id,
|
|
337
339
|
actionType: d.actionType,
|
|
338
340
|
modelName: d.modelName,
|
|
@@ -341,7 +343,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
341
343
|
// Thread `transactionId` through so the receive layer can recognize
|
|
342
344
|
// echoes of locally-applied transactions and skip the pool mutation.
|
|
343
345
|
transactionId: d.transactionId,
|
|
344
|
-
})));
|
|
346
|
+
}))));
|
|
345
347
|
const dbResults = batch.results;
|
|
346
348
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
347
349
|
// Apply the batch results to the in-memory graph. When a plugin has
|
|
@@ -349,12 +351,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
349
351
|
// materialiser attached where it said it would. The direct call is the
|
|
350
352
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
351
353
|
// whose own apply is the whole pipeline.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
354
|
+
timeDrainStage('apply', () => {
|
|
355
|
+
if (pluginsForStage(stagePlugins, 'apply').length > 0) {
|
|
356
|
+
runStage(stagePlugins, 'apply', { changes: dbResults });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
ctx.applyDeltaBatchToPool(dbResults);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
358
362
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
359
363
|
//
|
|
360
364
|
// We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
|
|
@@ -365,10 +369,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
365
369
|
// be lost. The cursor and the persisted state must move together.
|
|
366
370
|
const persistedSyncId = batch.persistedSyncId;
|
|
367
371
|
if (persistedSyncId > ctx.lastAckedId) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
372
|
+
timeDrainStage('acknowledge', () => {
|
|
373
|
+
ctx.acknowledge(persistedSyncId);
|
|
374
|
+
ctx.advancePersisted(persistedSyncId);
|
|
375
|
+
runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
|
|
376
|
+
});
|
|
371
377
|
}
|
|
372
378
|
// Cache invalidation happens automatically via the 'models:changed' event.
|
|
373
|
-
|
|
379
|
+
timeDrainStage('notify', () => {
|
|
380
|
+
runStage(stagePlugins, 'notify', { changes: dbResults });
|
|
381
|
+
});
|
|
374
382
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
import type { PipelineStage } from '../../plugin.js';
|
|
23
|
+
/** The pipeline's own stages plus the transport-level wire validation ahead of them. */
|
|
24
|
+
export type DrainStage = 'parse' | PipelineStage;
|
|
25
|
+
export interface DrainStageTotals {
|
|
26
|
+
/** Accumulated wall time attributed to this stage. */
|
|
27
|
+
readonly totalMs: number;
|
|
28
|
+
/** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
|
|
29
|
+
readonly calls: number;
|
|
30
|
+
}
|
|
31
|
+
export interface DrainProfile {
|
|
32
|
+
/** Flush batches drained. The per-batch fixed cost multiplies by this. */
|
|
33
|
+
readonly batches: number;
|
|
34
|
+
/** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
|
|
35
|
+
readonly deltas: number;
|
|
36
|
+
/** Deltas dropped by the dedupe stage before persistence. */
|
|
37
|
+
readonly deduplicated: number;
|
|
38
|
+
/** Wall time from the first observed stage to the last. */
|
|
39
|
+
readonly spanMs: number;
|
|
40
|
+
readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
|
|
41
|
+
}
|
|
42
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
43
|
+
export declare function drainProfilingEnabled(): boolean;
|
|
44
|
+
/** Attribute already-measured wall time to a stage. */
|
|
45
|
+
export declare function observeDrainStage(stage: DrainStage, elapsedMs: number): void;
|
|
46
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
47
|
+
export declare function timeDrainStage<T>(stage: DrainStage, run: () => T): T;
|
|
48
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
49
|
+
export declare function timeDrainStageAsync<T>(stage: DrainStage, run: () => Promise<T>): Promise<T>;
|
|
50
|
+
/**
|
|
51
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
52
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
53
|
+
* is reported alongside the timings rather than derived from them.
|
|
54
|
+
*/
|
|
55
|
+
export declare function observeDrainBatch(received: number, survived: number): void;
|
|
56
|
+
/** The totals accumulated since the last reset. */
|
|
57
|
+
export declare function drainProfileSnapshot(): DrainProfile;
|
|
58
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
59
|
+
export declare function resetDrainProfile(): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
const DRAIN_STAGES = [
|
|
23
|
+
'parse',
|
|
24
|
+
'receive',
|
|
25
|
+
'dedupe',
|
|
26
|
+
'persist',
|
|
27
|
+
'apply',
|
|
28
|
+
'acknowledge',
|
|
29
|
+
'notify',
|
|
30
|
+
];
|
|
31
|
+
function emptyTotals() {
|
|
32
|
+
const totals = {};
|
|
33
|
+
for (const stage of DRAIN_STAGES)
|
|
34
|
+
totals[stage] = { totalMs: 0, calls: 0 };
|
|
35
|
+
return totals;
|
|
36
|
+
}
|
|
37
|
+
let totals = emptyTotals();
|
|
38
|
+
let batches = 0;
|
|
39
|
+
let deltas = 0;
|
|
40
|
+
let deduplicated = 0;
|
|
41
|
+
let firstMark;
|
|
42
|
+
let lastMark = 0;
|
|
43
|
+
/**
|
|
44
|
+
* Read once. A profiler that consults the environment on every delta would
|
|
45
|
+
* itself become a per-delta cost in the path it is measuring.
|
|
46
|
+
*/
|
|
47
|
+
const enabled = (() => {
|
|
48
|
+
const host = globalThis;
|
|
49
|
+
return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
|
|
50
|
+
})();
|
|
51
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
52
|
+
export function drainProfilingEnabled() {
|
|
53
|
+
return enabled;
|
|
54
|
+
}
|
|
55
|
+
function mark(elapsedMs) {
|
|
56
|
+
const now = performance.now();
|
|
57
|
+
firstMark ??= now - elapsedMs;
|
|
58
|
+
lastMark = now;
|
|
59
|
+
}
|
|
60
|
+
/** Attribute already-measured wall time to a stage. */
|
|
61
|
+
export function observeDrainStage(stage, elapsedMs) {
|
|
62
|
+
if (!enabled)
|
|
63
|
+
return;
|
|
64
|
+
const entry = totals[stage];
|
|
65
|
+
entry.totalMs += elapsedMs;
|
|
66
|
+
entry.calls += 1;
|
|
67
|
+
mark(elapsedMs);
|
|
68
|
+
}
|
|
69
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
70
|
+
export function timeDrainStage(stage, run) {
|
|
71
|
+
if (!enabled)
|
|
72
|
+
return run();
|
|
73
|
+
const startedAt = performance.now();
|
|
74
|
+
try {
|
|
75
|
+
return run();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
82
|
+
export async function timeDrainStageAsync(stage, run) {
|
|
83
|
+
if (!enabled)
|
|
84
|
+
return run();
|
|
85
|
+
const startedAt = performance.now();
|
|
86
|
+
try {
|
|
87
|
+
return await run();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
95
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
96
|
+
* is reported alongside the timings rather than derived from them.
|
|
97
|
+
*/
|
|
98
|
+
export function observeDrainBatch(received, survived) {
|
|
99
|
+
if (!enabled)
|
|
100
|
+
return;
|
|
101
|
+
batches += 1;
|
|
102
|
+
deltas += received;
|
|
103
|
+
deduplicated += Math.max(0, received - survived);
|
|
104
|
+
}
|
|
105
|
+
/** The totals accumulated since the last reset. */
|
|
106
|
+
export function drainProfileSnapshot() {
|
|
107
|
+
const stages = {};
|
|
108
|
+
for (const stage of DRAIN_STAGES) {
|
|
109
|
+
stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
batches,
|
|
113
|
+
deltas,
|
|
114
|
+
deduplicated,
|
|
115
|
+
spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
|
|
116
|
+
stages,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
120
|
+
export function resetDrainProfile() {
|
|
121
|
+
totals = emptyTotals();
|
|
122
|
+
batches = 0;
|
|
123
|
+
deltas = 0;
|
|
124
|
+
deduplicated = 0;
|
|
125
|
+
firstMark = undefined;
|
|
126
|
+
lastMark = 0;
|
|
127
|
+
}
|
|
@@ -13,8 +13,8 @@ export function* initialize(host, context, signal) {
|
|
|
13
13
|
organizationId: context.organizationId,
|
|
14
14
|
participantKind: context.kind ?? 'user',
|
|
15
15
|
projectId: context.projectId ?? context.organizationId,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
branchId: context.branchId,
|
|
17
|
+
branchRoot: context.branchRoot ?? false,
|
|
18
18
|
});
|
|
19
19
|
// Propagate identity only after storage is ready, then restore sealed
|
|
20
20
|
// requests before accepting fresh mutations.
|
|
@@ -861,17 +861,18 @@ export class MutationQueue extends EventEmitter {
|
|
|
861
861
|
* transaction.
|
|
862
862
|
*/
|
|
863
863
|
confirmationFor(modelName, modelId) {
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
864
|
+
const transactions = this.store.getAll();
|
|
865
|
+
for (let index = transactions.length - 1; index >= 0; index--) {
|
|
866
|
+
const transaction = transactions[index];
|
|
867
|
+
if (transaction?.modelName === modelName &&
|
|
868
|
+
transaction.modelId === modelId &&
|
|
869
|
+
(transaction.status === 'pending' ||
|
|
870
|
+
transaction.status === 'executing' ||
|
|
871
|
+
transaction.status === 'awaiting_delta')) {
|
|
872
|
+
return transaction.confirmation ?? Promise.resolve();
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return Promise.resolve();
|
|
875
876
|
}
|
|
876
877
|
/**
|
|
877
878
|
* Attaches a `confirmation` promise to a newly created transaction. Call this
|
|
@@ -1046,7 +1047,26 @@ export class MutationQueue extends EventEmitter {
|
|
|
1046
1047
|
return this.pendingDrainPromise;
|
|
1047
1048
|
}
|
|
1048
1049
|
async drainPendingInternal() {
|
|
1049
|
-
|
|
1050
|
+
// The normal batch scheduler and the explicit/reconnect drain are two
|
|
1051
|
+
// ways to drive the same durable queue. They must never seal the same
|
|
1052
|
+
// staged source records concurrently: the first seal consumes those
|
|
1053
|
+
// records, so the second would correctly reject them as already claimed.
|
|
1054
|
+
//
|
|
1055
|
+
// `isProcessing` is acquired synchronously before either path awaits,
|
|
1056
|
+
// making it the queue-wide execution lock. If the normal lane already
|
|
1057
|
+
// owns it, that lane will finish the pending work; callers waiting on a
|
|
1058
|
+
// specific confirmation remain attached to the exact transaction.
|
|
1059
|
+
if (this.isProcessing)
|
|
1060
|
+
return;
|
|
1061
|
+
this.isProcessing = true;
|
|
1062
|
+
try {
|
|
1063
|
+
await drainPendingSettlements(this.pendingDrainContext);
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
this.isProcessing = false;
|
|
1067
|
+
if (this.executionQueue.length > 0)
|
|
1068
|
+
this.scheduleProcessing(true);
|
|
1069
|
+
}
|
|
1050
1070
|
}
|
|
1051
1071
|
async create(model, context, writeOptions, sourceMutationId) {
|
|
1052
1072
|
return createModel(this.modelMutationContext, model, context, writeOptions, sourceMutationId);
|
|
@@ -10,7 +10,7 @@ export interface PendingDrainContext {
|
|
|
10
10
|
deltaConfirmationTimeout: number;
|
|
11
11
|
};
|
|
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>;
|
|
@@ -13,7 +13,8 @@ export async function drainPendingSettlements(ctx) {
|
|
|
13
13
|
// These rows may already be waiting behind the normal batch timer. The
|
|
14
14
|
// reconnect fast path takes ownership of them for this attempt so the same
|
|
15
15
|
// transaction cannot dispatch concurrently through both paths.
|
|
16
|
-
|
|
16
|
+
const retainedQueue = ctx.executionQueue.filter((tx) => !pendingIds.has(tx.id));
|
|
17
|
+
ctx.executionQueue.splice(0, ctx.executionQueue.length, ...retainedQueue);
|
|
17
18
|
const remaining = [...pending];
|
|
18
19
|
while (remaining.length > 0) {
|
|
19
20
|
const batch = ctx.takePendingDrainBatch(remaining);
|