@abloatai/humans 0.37.1 → 0.39.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/BaseSyncedStore.js +7 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +18 -8
- package/dist/local/InstanceCache.js +74 -74
- package/dist/local/Model.d.ts +18 -0
- package/dist/local/Model.js +83 -32
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +55 -60
- package/dist/local/client/createModelProxy.js +14 -12
- package/dist/local/client/options.d.ts +7 -0
- package/dist/local/client/reactiveEngine.js +23 -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 +21 -6
- package/dist/local/sync/deltaPipeline.js +31 -13
- package/dist/local/sync/drainProfile.d.ts +104 -0
- package/dist/local/sync/drainProfile.js +182 -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 +15 -0
- package/src/local/BaseSyncedStore.ts +11 -3
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +77 -71
- package/src/local/Model.ts +98 -37
- package/src/local/SyncClient.ts +57 -61
- package/src/local/client/createModelProxy.ts +14 -12
- package/src/local/client/options.ts +9 -0
- package/src/local/client/reactiveEngine.ts +23 -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 +20 -6
- package/src/local/sync/deltaPipeline.ts +51 -21
- package/src/local/sync/drainProfile.ts +257 -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
|
@@ -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 };
|
|
@@ -160,12 +178,9 @@ export class SyncWebSocket extends WsTransport {
|
|
|
160
178
|
const delta = this.normalizeWireDelta(rawDelta);
|
|
161
179
|
if (!delta)
|
|
162
180
|
return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
id: delta.modelId,
|
|
167
|
-
syncId: delta.id,
|
|
168
|
-
});
|
|
181
|
+
// No per-delta debug here: the payload object is built even when the
|
|
182
|
+
// logger discards it, and this runs at the full live wire rate. Dropped
|
|
183
|
+
// malformed deltas are still logged by `normalizeWireDelta`.
|
|
169
184
|
// Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
|
|
170
185
|
// must stay consistent with what has been persisted locally; otherwise the
|
|
171
186
|
// next `requestIncrementalSync()` (and the connect-time handshake) would
|
|
@@ -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, observeDrainAcknowledge, timeDrainStage, timeDrainStageAsync, openDrainBatchRow, closeDrainBatchRow, } 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
|
|
@@ -294,8 +295,18 @@ function yieldToHost() {
|
|
|
294
295
|
});
|
|
295
296
|
}
|
|
296
297
|
async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
298
|
+
openDrainBatchRow(queuedDeltas.length);
|
|
299
|
+
try {
|
|
300
|
+
await flushDeltaBatchInner(ctx, queuedDeltas);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
closeDrainBatchRow();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
297
307
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
298
|
-
const deduplicatedDeltas = ctx.deduplicateDeltas(queuedDeltas);
|
|
308
|
+
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
309
|
+
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
299
310
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
300
311
|
// Custom entities → apply straight to the pool, skipping the local store.
|
|
301
312
|
const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
|
|
@@ -332,7 +343,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
332
343
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
333
344
|
// signature accepts them defensively.
|
|
334
345
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
335
|
-
const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
346
|
+
const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
336
347
|
syncId: d.id,
|
|
337
348
|
actionType: d.actionType,
|
|
338
349
|
modelName: d.modelName,
|
|
@@ -341,7 +352,7 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
341
352
|
// Thread `transactionId` through so the receive layer can recognize
|
|
342
353
|
// echoes of locally-applied transactions and skip the pool mutation.
|
|
343
354
|
transactionId: d.transactionId,
|
|
344
|
-
})));
|
|
355
|
+
}))));
|
|
345
356
|
const dbResults = batch.results;
|
|
346
357
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
347
358
|
// Apply the batch results to the in-memory graph. When a plugin has
|
|
@@ -349,12 +360,14 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
349
360
|
// materialiser attached where it said it would. The direct call is the
|
|
350
361
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
351
362
|
// whose own apply is the whole pipeline.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
timeDrainStage('apply', () => {
|
|
364
|
+
if (pluginsForStage(stagePlugins, 'apply').length > 0) {
|
|
365
|
+
runStage(stagePlugins, 'apply', { changes: dbResults });
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
ctx.applyDeltaBatchToPool(dbResults);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
358
371
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
359
372
|
//
|
|
360
373
|
// We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
|
|
@@ -365,10 +378,15 @@ async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
|
365
378
|
// be lost. The cursor and the persisted state must move together.
|
|
366
379
|
const persistedSyncId = batch.persistedSyncId;
|
|
367
380
|
if (persistedSyncId > ctx.lastAckedId) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
381
|
+
timeDrainStage('acknowledge', () => {
|
|
382
|
+
ctx.acknowledge(persistedSyncId);
|
|
383
|
+
ctx.advancePersisted(persistedSyncId);
|
|
384
|
+
observeDrainAcknowledge(persistedSyncId);
|
|
385
|
+
runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
|
|
386
|
+
});
|
|
371
387
|
}
|
|
372
388
|
// Cache invalidation happens automatically via the 'models:changed' event.
|
|
373
|
-
|
|
389
|
+
timeDrainStage('notify', () => {
|
|
390
|
+
runStage(stagePlugins, 'notify', { changes: dbResults });
|
|
391
|
+
});
|
|
374
392
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
/**
|
|
32
|
+
* One flush batch on the wall clock. Wall time (`Date.now`) rather than
|
|
33
|
+
* `performance.now`, because rows cross the worker boundary and each thread
|
|
34
|
+
* has its own `performance` origin — the drain-tail stamps learned the same
|
|
35
|
+
* lesson. Stage entries are the batch's own share of each pipeline stage.
|
|
36
|
+
*/
|
|
37
|
+
export interface DrainBatchRow {
|
|
38
|
+
readonly startedAtWallMs: number;
|
|
39
|
+
readonly endedAtWallMs: number;
|
|
40
|
+
readonly deltas: number;
|
|
41
|
+
readonly stages: Readonly<Partial<Record<DrainStage, number>>>;
|
|
42
|
+
}
|
|
43
|
+
export interface DrainProfile {
|
|
44
|
+
/** Flush batches drained. The per-batch fixed cost multiplies by this. */
|
|
45
|
+
readonly batches: number;
|
|
46
|
+
/** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
|
|
47
|
+
readonly deltas: number;
|
|
48
|
+
/** Deltas dropped by the dedupe stage before persistence. */
|
|
49
|
+
readonly deduplicated: number;
|
|
50
|
+
/** Wall time from the first observed stage to the last. */
|
|
51
|
+
readonly spanMs: number;
|
|
52
|
+
readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
|
|
53
|
+
/**
|
|
54
|
+
* The most recent flush batches, oldest first, capped — enough to cover a
|
|
55
|
+
* drain tail. Optional because derived profiles (window subtraction, fleet
|
|
56
|
+
* merges) drop it; only a worker's own snapshot carries rows.
|
|
57
|
+
*/
|
|
58
|
+
readonly recentBatches?: readonly DrainBatchRow[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Wall-stamped persisted-cursor advances, oldest first. The benchmark's drain
|
|
62
|
+
* gate reads THESE rather than observing the cursor from a timer or a
|
|
63
|
+
* cross-thread poll: any observation that has to be scheduled onto the
|
|
64
|
+
* worker's event loop queues behind the very drain burst it is measuring and
|
|
65
|
+
* reports the queue's latency as drain. A stamp taken synchronously inside
|
|
66
|
+
* the acknowledge stage cannot be deferred by anything.
|
|
67
|
+
*/
|
|
68
|
+
export interface AcknowledgeStamp {
|
|
69
|
+
readonly syncId: number;
|
|
70
|
+
readonly atWallMs: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Record a persisted-cursor advance. Called by the pipeline's acknowledge
|
|
74
|
+
* stage. Unlike every stage timer here, this is NOT gated on the profiler
|
|
75
|
+
* flag: it is one wall-clock read and one bounded push per flush batch —
|
|
76
|
+
* nothing against the batch's own work — and the certification benchmark
|
|
77
|
+
* runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
|
|
78
|
+
* exist without it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function observeDrainAcknowledge(syncId: number): void;
|
|
81
|
+
/** The recorded persisted-advance stamps, oldest first. */
|
|
82
|
+
export declare function drainAcknowledgeStamps(): readonly AcknowledgeStamp[];
|
|
83
|
+
/** Begin a batch row. Called by the pipeline at flush entry when profiling. */
|
|
84
|
+
export declare function openDrainBatchRow(deltaCount: number): void;
|
|
85
|
+
/** Close the open batch row and commit it to the ring. */
|
|
86
|
+
export declare function closeDrainBatchRow(): void;
|
|
87
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
88
|
+
export declare function drainProfilingEnabled(): boolean;
|
|
89
|
+
/** Attribute already-measured wall time to a stage. */
|
|
90
|
+
export declare function observeDrainStage(stage: DrainStage, elapsedMs: number): void;
|
|
91
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
92
|
+
export declare function timeDrainStage<T>(stage: DrainStage, run: () => T): T;
|
|
93
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
94
|
+
export declare function timeDrainStageAsync<T>(stage: DrainStage, run: () => Promise<T>): Promise<T>;
|
|
95
|
+
/**
|
|
96
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
97
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
98
|
+
* is reported alongside the timings rather than derived from them.
|
|
99
|
+
*/
|
|
100
|
+
export declare function observeDrainBatch(received: number, survived: number): void;
|
|
101
|
+
/** The totals accumulated since the last reset. */
|
|
102
|
+
export declare function drainProfileSnapshot(): DrainProfile;
|
|
103
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
104
|
+
export declare function resetDrainProfile(): void;
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
/** Ring of recent batch rows. ~50 batches/sec at benchmark rates, so this covers seconds of tail. */
|
|
44
|
+
const BATCH_ROW_CAP = 128;
|
|
45
|
+
let batchRows = [];
|
|
46
|
+
/**
|
|
47
|
+
* The batch currently being flushed. Module-global like the totals above, so
|
|
48
|
+
* an isolate hosting several stores attributes interleaved awaits to whichever
|
|
49
|
+
* batch is open — the same per-isolate approximation the totals already make.
|
|
50
|
+
*/
|
|
51
|
+
let currentRow = null;
|
|
52
|
+
const ACK_STAMP_CAP = 512;
|
|
53
|
+
let ackStamps = [];
|
|
54
|
+
/**
|
|
55
|
+
* Record a persisted-cursor advance. Called by the pipeline's acknowledge
|
|
56
|
+
* stage. Unlike every stage timer here, this is NOT gated on the profiler
|
|
57
|
+
* flag: it is one wall-clock read and one bounded push per flush batch —
|
|
58
|
+
* nothing against the batch's own work — and the certification benchmark
|
|
59
|
+
* runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
|
|
60
|
+
* exist without it.
|
|
61
|
+
*/
|
|
62
|
+
export function observeDrainAcknowledge(syncId) {
|
|
63
|
+
ackStamps.push({ syncId, atWallMs: Date.now() });
|
|
64
|
+
if (ackStamps.length > ACK_STAMP_CAP)
|
|
65
|
+
ackStamps.shift();
|
|
66
|
+
}
|
|
67
|
+
/** The recorded persisted-advance stamps, oldest first. */
|
|
68
|
+
export function drainAcknowledgeStamps() {
|
|
69
|
+
return [...ackStamps];
|
|
70
|
+
}
|
|
71
|
+
/** Begin a batch row. Called by the pipeline at flush entry when profiling. */
|
|
72
|
+
export function openDrainBatchRow(deltaCount) {
|
|
73
|
+
if (!enabled)
|
|
74
|
+
return;
|
|
75
|
+
currentRow = { startedAtWallMs: Date.now(), deltas: deltaCount, stages: {} };
|
|
76
|
+
}
|
|
77
|
+
/** Close the open batch row and commit it to the ring. */
|
|
78
|
+
export function closeDrainBatchRow() {
|
|
79
|
+
if (!enabled || currentRow === null)
|
|
80
|
+
return;
|
|
81
|
+
batchRows.push({
|
|
82
|
+
startedAtWallMs: currentRow.startedAtWallMs,
|
|
83
|
+
endedAtWallMs: Date.now(),
|
|
84
|
+
deltas: currentRow.deltas,
|
|
85
|
+
stages: currentRow.stages,
|
|
86
|
+
});
|
|
87
|
+
if (batchRows.length > BATCH_ROW_CAP)
|
|
88
|
+
batchRows.shift();
|
|
89
|
+
currentRow = null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read once. A profiler that consults the environment on every delta would
|
|
93
|
+
* itself become a per-delta cost in the path it is measuring.
|
|
94
|
+
*/
|
|
95
|
+
const enabled = (() => {
|
|
96
|
+
const host = globalThis;
|
|
97
|
+
return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
|
|
98
|
+
})();
|
|
99
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
100
|
+
export function drainProfilingEnabled() {
|
|
101
|
+
return enabled;
|
|
102
|
+
}
|
|
103
|
+
function mark(elapsedMs) {
|
|
104
|
+
const now = performance.now();
|
|
105
|
+
firstMark ??= now - elapsedMs;
|
|
106
|
+
lastMark = now;
|
|
107
|
+
}
|
|
108
|
+
/** Attribute already-measured wall time to a stage. */
|
|
109
|
+
export function observeDrainStage(stage, elapsedMs) {
|
|
110
|
+
if (!enabled)
|
|
111
|
+
return;
|
|
112
|
+
const entry = totals[stage];
|
|
113
|
+
entry.totalMs += elapsedMs;
|
|
114
|
+
entry.calls += 1;
|
|
115
|
+
if (currentRow !== null) {
|
|
116
|
+
currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
|
|
117
|
+
}
|
|
118
|
+
mark(elapsedMs);
|
|
119
|
+
}
|
|
120
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
121
|
+
export function timeDrainStage(stage, run) {
|
|
122
|
+
if (!enabled)
|
|
123
|
+
return run();
|
|
124
|
+
const startedAt = performance.now();
|
|
125
|
+
try {
|
|
126
|
+
return run();
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
133
|
+
export async function timeDrainStageAsync(stage, run) {
|
|
134
|
+
if (!enabled)
|
|
135
|
+
return run();
|
|
136
|
+
const startedAt = performance.now();
|
|
137
|
+
try {
|
|
138
|
+
return await run();
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
146
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
147
|
+
* is reported alongside the timings rather than derived from them.
|
|
148
|
+
*/
|
|
149
|
+
export function observeDrainBatch(received, survived) {
|
|
150
|
+
if (!enabled)
|
|
151
|
+
return;
|
|
152
|
+
batches += 1;
|
|
153
|
+
deltas += received;
|
|
154
|
+
deduplicated += Math.max(0, received - survived);
|
|
155
|
+
}
|
|
156
|
+
/** The totals accumulated since the last reset. */
|
|
157
|
+
export function drainProfileSnapshot() {
|
|
158
|
+
const stages = {};
|
|
159
|
+
for (const stage of DRAIN_STAGES) {
|
|
160
|
+
stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
batches,
|
|
164
|
+
deltas,
|
|
165
|
+
deduplicated,
|
|
166
|
+
spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
|
|
167
|
+
stages,
|
|
168
|
+
recentBatches: [...batchRows],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
172
|
+
export function resetDrainProfile() {
|
|
173
|
+
totals = emptyTotals();
|
|
174
|
+
batches = 0;
|
|
175
|
+
deltas = 0;
|
|
176
|
+
deduplicated = 0;
|
|
177
|
+
firstMark = undefined;
|
|
178
|
+
lastMark = 0;
|
|
179
|
+
batchRows = [];
|
|
180
|
+
currentRow = null;
|
|
181
|
+
ackStamps = [];
|
|
182
|
+
}
|
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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.39.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,18 @@ 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
|
+
drainAcknowledgeStamps,
|
|
114
|
+
type AcknowledgeStamp,
|
|
115
|
+
type DrainProfile,
|
|
116
|
+
type DrainBatchRow,
|
|
117
|
+
type DrainStage,
|
|
118
|
+
type DrainStageTotals,
|
|
119
|
+
} 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
|
|
@@ -642,7 +644,13 @@ export class BaseSyncedStore<
|
|
|
642
644
|
this.smartSyncOptions = {
|
|
643
645
|
maxDeltasBeforeBootstrap: 1000,
|
|
644
646
|
maxBootstrapSize: 10 * 1024 * 1024,
|
|
645
|
-
|
|
647
|
+
// The inbound-delta flush debounce. Under sustained traffic the
|
|
648
|
+
// `maxBatchSize` force-flush governs batching, so this timer decides
|
|
649
|
+
// exactly one thing: how long the FINAL partial batch of a burst sits
|
|
650
|
+
// before it materializes. At 100 ms it was the largest single term in
|
|
651
|
+
// the observer's drain tail on the throughput bench; 10 ms coalesces a
|
|
652
|
+
// trickle just as well and keeps burst tails inside the drain budget.
|
|
653
|
+
batchingDelay: 10,
|
|
646
654
|
maxBatchSize: 50,
|
|
647
655
|
};
|
|
648
656
|
|