@abloatai/humans 0.38.0 → 0.40.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 -1
- package/dist/core.js +1 -1
- package/dist/local/BaseSyncedStore.d.ts +9 -0
- package/dist/local/BaseSyncedStore.js +28 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +40 -8
- package/dist/local/InstanceCache.js +156 -83
- package/dist/local/Model.d.ts +28 -0
- package/dist/local/Model.js +102 -35
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +63 -62
- package/dist/local/client/reactiveEngine.js +18 -0
- package/dist/local/sync/SyncWebSocket.js +3 -6
- package/dist/local/sync/deltaPipeline.d.ts +32 -1
- package/dist/local/sync/deltaPipeline.js +116 -8
- package/dist/local/sync/drainProfile.d.ts +45 -0
- package/dist/local/sync/drainProfile.js +55 -0
- package/dist/local/transactions/mutations/MutationQueue.js +11 -3
- package/dist/local/utils/mobxSetup.d.ts +1 -0
- package/dist/local/utils/mobxSetup.js +5 -0
- package/package.json +2 -2
- package/src/core.ts +3 -0
- package/src/local/BaseSyncedStore.ts +37 -1
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +162 -80
- package/src/local/Model.ts +117 -42
- package/src/local/SyncClient.ts +66 -63
- package/src/local/client/reactiveEngine.ts +18 -0
- package/src/local/sync/SyncWebSocket.ts +3 -6
- package/src/local/sync/deltaPipeline.ts +135 -9
- package/src/local/sync/drainProfile.ts +93 -0
- package/src/local/transactions/mutations/MutationQueue.ts +14 -4
- package/src/local/utils/mobxSetup.ts +5 -0
package/src/local/SyncClient.ts
CHANGED
|
@@ -1290,34 +1290,25 @@ export class SyncClient extends EventEmitter {
|
|
|
1290
1290
|
* local model has unsynced changes, so the two sides stay consistent.
|
|
1291
1291
|
*/
|
|
1292
1292
|
resolveConflicts(localModel: Model, serverData: Record<string, unknown>): Model {
|
|
1293
|
-
|
|
1294
|
-
//
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
serverUpdatedAt: serverData.updatedAt,
|
|
1308
|
-
localChanges: localModel.getChanges(),
|
|
1309
|
-
serverState: this.extractCriticalState(serverData),
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
// PRIORITY 1: Check for critical server states that must be respected
|
|
1313
|
-
// These states override any local changes to maintain data consistency
|
|
1314
|
-
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1315
|
-
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1293
|
+
// No entry-point debug here: this runs once per incoming update delta,
|
|
1294
|
+
// and a debug call's payload is BUILT even when the logger discards it —
|
|
1295
|
+
// a per-delta model scan on the apply hot path. The outcome branches
|
|
1296
|
+
// below log the cases worth reading.
|
|
1297
|
+
|
|
1298
|
+
// PRIORITY 1: Check for critical server states that must be respected.
|
|
1299
|
+
// These states override any local changes to maintain data consistency.
|
|
1300
|
+
// Checked inline — the collected-object form (`extractCriticalState`)
|
|
1301
|
+
// only materializes on the rare force-accept branch, for its log line.
|
|
1302
|
+
const shouldForceAcceptServer =
|
|
1303
|
+
(serverData.deletedAt !== undefined && serverData.deletedAt !== null) ||
|
|
1304
|
+
(serverData.archivedAt !== undefined && serverData.archivedAt !== null) ||
|
|
1305
|
+
serverData.isActive === false ||
|
|
1306
|
+
(serverData.unassignedAt !== undefined && serverData.unassignedAt !== null);
|
|
1316
1307
|
|
|
1317
1308
|
if (shouldForceAcceptServer) {
|
|
1318
1309
|
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1319
1310
|
modelId: localModel.id,
|
|
1320
|
-
criticalStates:
|
|
1311
|
+
criticalStates: this.extractCriticalState(serverData),
|
|
1321
1312
|
});
|
|
1322
1313
|
|
|
1323
1314
|
// Force accept server state for critical changes
|
|
@@ -1329,7 +1320,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1329
1320
|
|
|
1330
1321
|
// Local-first: if we have local dirty fields, merge by field.
|
|
1331
1322
|
// Keep locally changed fields; apply server for the rest.
|
|
1332
|
-
if (
|
|
1323
|
+
if (localModel.hasChanges) {
|
|
1333
1324
|
const localChanges = localModel.getChanges();
|
|
1334
1325
|
this.runtime.logger.debug('Merging server update with local dirty fields', {
|
|
1335
1326
|
modelId: localModel.id,
|
|
@@ -1341,6 +1332,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1341
1332
|
|
|
1342
1333
|
// Preserve the most recent updatedAt without clearing dirty flags
|
|
1343
1334
|
if (serverData.updatedAt || localModel.updatedAt) {
|
|
1335
|
+
// Safely get timestamp, handling both Date objects and strings
|
|
1336
|
+
const localUpdatedAt = localModel.updatedAt
|
|
1337
|
+
? localModel.updatedAt instanceof Date
|
|
1338
|
+
? localModel.updatedAt.getTime()
|
|
1339
|
+
: new Date(localModel.updatedAt).getTime()
|
|
1340
|
+
: 0;
|
|
1341
|
+
const serverUpdatedAt = toEpochMs(serverData.updatedAt);
|
|
1344
1342
|
const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
|
|
1345
1343
|
// updateFromData accepts Date or ISO string for dates
|
|
1346
1344
|
merged.updatedAt = mergedUpdatedAt;
|
|
@@ -1351,10 +1349,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1351
1349
|
return localModel;
|
|
1352
1350
|
}
|
|
1353
1351
|
|
|
1354
|
-
// No local changes: fall back to LWW to converge
|
|
1355
|
-
//
|
|
1356
|
-
|
|
1357
|
-
this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1352
|
+
// No local changes: fall back to LWW to converge. Accept server
|
|
1353
|
+
// regardless of timestamp equality to stay in sync. Not logged — this is
|
|
1354
|
+
// the common path for every collaborator update a client receives.
|
|
1358
1355
|
localModel.updateFromData(serverData);
|
|
1359
1356
|
localModel.clearChanges();
|
|
1360
1357
|
localModel.markAsSynced();
|
|
@@ -1392,17 +1389,6 @@ export class SyncClient extends EventEmitter {
|
|
|
1392
1389
|
return critical;
|
|
1393
1390
|
}
|
|
1394
1391
|
|
|
1395
|
-
/**
|
|
1396
|
-
* Check if critical state changes exist that require forcing server state
|
|
1397
|
-
*/
|
|
1398
|
-
private hasCriticalStateChange(criticalStates: Record<string, unknown>): boolean {
|
|
1399
|
-
// Any critical state present means we should force accept server
|
|
1400
|
-
return (
|
|
1401
|
-
Object.keys(criticalStates).length > 0 &&
|
|
1402
|
-
Object.values(criticalStates).some((v) => v !== null && v !== undefined)
|
|
1403
|
-
);
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
1392
|
/**
|
|
1407
1393
|
* Handle network reconnection
|
|
1408
1394
|
*/
|
|
@@ -1826,6 +1812,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1826
1812
|
return this.mutationQueue.confirmationFor(modelName, modelId);
|
|
1827
1813
|
}
|
|
1828
1814
|
|
|
1815
|
+
|
|
1829
1816
|
/**
|
|
1830
1817
|
* Get detailed debug info for the sync debug page
|
|
1831
1818
|
*/
|
|
@@ -1901,6 +1888,22 @@ export class SyncClient extends EventEmitter {
|
|
|
1901
1888
|
applyDeltaBatchToPool(
|
|
1902
1889
|
dbResults: readonly AppliedChange[],
|
|
1903
1890
|
enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
|
|
1891
|
+
): void {
|
|
1892
|
+
// The WHOLE batch — conflict resolution and model mutation included, not
|
|
1893
|
+
// just the pool bookkeeping at the end — runs in one MobX action.
|
|
1894
|
+
// `resolveConflicts` writes model fields via `updateFromData`; when those
|
|
1895
|
+
// writes ran before the action, every delta opened its own top-level
|
|
1896
|
+
// action and flushed reactions at its boundary, so a large frame paid one
|
|
1897
|
+
// reaction pass per delta and an observer could see a partially applied
|
|
1898
|
+
// frame between them.
|
|
1899
|
+
runInAction(() => {
|
|
1900
|
+
this.applyDeltaBatchToPoolInAction(dbResults, enrichRelations);
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
private applyDeltaBatchToPoolInAction(
|
|
1905
|
+
dbResults: readonly AppliedChange[],
|
|
1906
|
+
enrichRelations: (modelName: string, data: Record<string, unknown>) => Record<string, unknown>,
|
|
1904
1907
|
): void {
|
|
1905
1908
|
const modelsToAdd: Model[] = [];
|
|
1906
1909
|
const modelsToUpsert: Model[] = [];
|
|
@@ -1948,7 +1951,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1948
1951
|
|
|
1949
1952
|
switch (action) {
|
|
1950
1953
|
case 'add': {
|
|
1951
|
-
|
|
1954
|
+
// `peek`, not `get`: this loop is ingestion, not a consumer read.
|
|
1955
|
+
// `get()` activates deferred MobX instrumentation, so reading
|
|
1956
|
+
// through it here made the delta stream itself instrument every
|
|
1957
|
+
// row it touched — the dominant term of apply cost for rows no
|
|
1958
|
+
// consumer observes. Activation belongs to the consumer-facing
|
|
1959
|
+
// reads (`get`, views, subscribers), which are unchanged.
|
|
1960
|
+
const existing = this.objectPool.peek(modelId);
|
|
1952
1961
|
if (existing) {
|
|
1953
1962
|
existing.markAsSynced();
|
|
1954
1963
|
} else if (result.data) {
|
|
@@ -1961,7 +1970,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1961
1970
|
break;
|
|
1962
1971
|
}
|
|
1963
1972
|
case 'update': {
|
|
1964
|
-
const existing = this.objectPool.
|
|
1973
|
+
const existing = this.objectPool.peek(modelId);
|
|
1965
1974
|
if (existing && !existing.disposed && result.data) {
|
|
1966
1975
|
enrichRelations(modelName, result.data);
|
|
1967
1976
|
const resolved = this.resolveConflicts(existing, result.data);
|
|
@@ -2002,27 +2011,21 @@ export class SyncClient extends EventEmitter {
|
|
|
2002
2011
|
}
|
|
2003
2012
|
}
|
|
2004
2013
|
|
|
2005
|
-
// Reveal the whole frame
|
|
2006
|
-
//
|
|
2007
|
-
//
|
|
2008
|
-
//
|
|
2009
|
-
//
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
2021
|
-
// Kept inside the action so any observable query-cache state it
|
|
2022
|
-
// flips is part of the same atomic reveal.
|
|
2023
|
-
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
2024
|
-
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2025
|
-
});
|
|
2014
|
+
// Reveal the whole frame at one reaction boundary: the caller's single
|
|
2015
|
+
// action covers the collection loop above and these batch pool writes, so
|
|
2016
|
+
// dependents recompute exactly once regardless of how many models or
|
|
2017
|
+
// operation kinds the frame touched, and the app never observes a
|
|
2018
|
+
// partially applied frame.
|
|
2019
|
+
if (modelsToAdd.length > 0) this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
2020
|
+
if (modelsToUpsert.length > 0) this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
|
|
2021
|
+
if (idsToRemove.length > 0) this.objectPool.removeBatch(idsToRemove);
|
|
2022
|
+
for (const id of idsToArchive) this.objectPool.updateScope(id, ModelScope.archived);
|
|
2023
|
+
|
|
2024
|
+
// Emit changed model types so QueryProcessor can auto-invalidate.
|
|
2025
|
+
// Kept inside the action so any observable query-cache state it
|
|
2026
|
+
// flips is part of the same atomic reveal.
|
|
2027
|
+
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
2028
|
+
if (changedTypes.size > 0) this.emit('models:changed', changedTypes);
|
|
2026
2029
|
}
|
|
2027
2030
|
|
|
2028
2031
|
/**
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
16
|
+
import { omittedModelError } from '@abloatai/transaction/schema/select';
|
|
16
17
|
import {
|
|
17
18
|
durableCommitOperationSchema,
|
|
18
19
|
type DurableCommitOperation,
|
|
@@ -934,5 +935,22 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
934
935
|
},
|
|
935
936
|
} as Ablo<S>;
|
|
936
937
|
|
|
938
|
+
// A model the schema projection left out answers with an error naming the
|
|
939
|
+
// model and the fix, not `undefined`. An app can compile against the full
|
|
940
|
+
// source schema while running a projection, so the type system never sees
|
|
941
|
+
// this gap; without the stub the caller crashes one property later with a
|
|
942
|
+
// bare TypeError ("reading 'local'") that names neither. Non-enumerable so
|
|
943
|
+
// spread, Object.keys, and JSON.stringify walk past the stubs untriggered.
|
|
944
|
+
for (const name of schema.omittedModels ?? []) {
|
|
945
|
+
if (name in engine) continue;
|
|
946
|
+
Object.defineProperty(engine, name, {
|
|
947
|
+
get() {
|
|
948
|
+
throw omittedModelError(name);
|
|
949
|
+
},
|
|
950
|
+
enumerable: false,
|
|
951
|
+
configurable: true,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
937
955
|
return engine;
|
|
938
956
|
}
|
|
@@ -272,12 +272,9 @@ export class SyncWebSocket<
|
|
|
272
272
|
protected override handleDelta(rawDelta: unknown): void {
|
|
273
273
|
const delta = this.normalizeWireDelta(rawDelta);
|
|
274
274
|
if (!delta) return;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
id: delta.modelId,
|
|
279
|
-
syncId: delta.id,
|
|
280
|
-
});
|
|
275
|
+
// No per-delta debug here: the payload object is built even when the
|
|
276
|
+
// logger discards it, and this runs at the full live wire rate. Dropped
|
|
277
|
+
// malformed deltas are still logged by `normalizeWireDelta`.
|
|
281
278
|
|
|
282
279
|
// Do not advance `this.cursor.lastSyncId` on receipt. The runtime cursor
|
|
283
280
|
// must stay consistent with what has been persisted locally; otherwise the
|
|
@@ -24,7 +24,14 @@ import {
|
|
|
24
24
|
type AbloPlugin,
|
|
25
25
|
type AppliedChange,
|
|
26
26
|
} from '../../plugin.js';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
observeDrainBatch,
|
|
29
|
+
observeDrainAcknowledge,
|
|
30
|
+
timeDrainStage,
|
|
31
|
+
timeDrainStageAsync,
|
|
32
|
+
openDrainBatchRow,
|
|
33
|
+
closeDrainBatchRow,
|
|
34
|
+
} from './drainProfile.js';
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
37
|
* What the pipeline needs back from the surrounding store: the shared mutable
|
|
@@ -46,7 +53,11 @@ export interface DeltaPipelineContext {
|
|
|
46
53
|
batchTimer: ReturnType<typeof setTimeout> | null;
|
|
47
54
|
/** Queue for deltas arriving during an active bootstrap; null when none. */
|
|
48
55
|
readonly bootstrapDeltaQueue: SyncDelta[] | null;
|
|
49
|
-
readonly smartSyncOptions: {
|
|
56
|
+
readonly smartSyncOptions: {
|
|
57
|
+
readonly batchingDelay: number;
|
|
58
|
+
readonly maxBatchSize: number;
|
|
59
|
+
readonly applySliceDeltas: number;
|
|
60
|
+
};
|
|
50
61
|
/** Pool-applied cursor (`syncClient.position.applied`). */
|
|
51
62
|
readonly highestProcessedSyncId: number;
|
|
52
63
|
/** Resume/ack cursor (`syncClient.position.persisted`). */
|
|
@@ -79,7 +90,8 @@ export interface DeltaPipelineContext {
|
|
|
79
90
|
|
|
80
91
|
// ── Custom-entity pool ops (deltas that skip the local store) ──
|
|
81
92
|
readonly objectPool: {
|
|
82
|
-
|
|
93
|
+
/** Ingestion-side lookup — resolves without activating observability. */
|
|
94
|
+
peek(id: string): Model | undefined;
|
|
83
95
|
add(model: Model, scope: ModelScope): void;
|
|
84
96
|
remove(id: string): boolean;
|
|
85
97
|
/** Full in-memory clear — the revocation-failure fallback (see
|
|
@@ -305,6 +317,7 @@ export function enqueueDelta(
|
|
|
305
317
|
// The delta is accepted and queued — the `receive` stage boundary.
|
|
306
318
|
runStage(ctx.stagePlugins ?? [], 'receive', { delta });
|
|
307
319
|
ctx.pendingDeltas.push(delta);
|
|
320
|
+
pipelineDebug.enqueued += 1;
|
|
308
321
|
return true;
|
|
309
322
|
}
|
|
310
323
|
|
|
@@ -393,6 +406,71 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
|
|
|
393
406
|
}
|
|
394
407
|
}
|
|
395
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Uninterrupted apply time allowed before the sliced loop yields — the
|
|
411
|
+
* "no visible stall" bound. Yields are amortized against it because one host
|
|
412
|
+
* yield costs milliseconds under load; at the measured per-delta apply cost
|
|
413
|
+
* this works out to roughly one yield per one to two 600-delta slices.
|
|
414
|
+
*/
|
|
415
|
+
const APPLY_YIELD_BUDGET_MS = 12;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Wedge forensics: where the pipeline currently is, updated synchronously at
|
|
419
|
+
* every stage boundary. A hang diagnoses itself by which counter pair
|
|
420
|
+
* diverged and which phase the active flush froze in. Mirrored onto
|
|
421
|
+
* `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
|
|
422
|
+
* can read it without an import path into SDK internals — diagnostics only,
|
|
423
|
+
* a handful of numbers, no payload data.
|
|
424
|
+
*/
|
|
425
|
+
export const pipelineDebug = {
|
|
426
|
+
flushesStarted: 0,
|
|
427
|
+
flushesSettled: 0,
|
|
428
|
+
persistsStarted: 0,
|
|
429
|
+
persistsSettled: 0,
|
|
430
|
+
applySlices: 0,
|
|
431
|
+
applyYields: 0,
|
|
432
|
+
enqueued: 0,
|
|
433
|
+
phase: 'idle' as string,
|
|
434
|
+
};
|
|
435
|
+
(globalThis as { __abloPipelineDebug?: typeof pipelineDebug }).__abloPipelineDebug =
|
|
436
|
+
pipelineDebug;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Split applied changes into slices of at most `maxDeltas`, never splitting a
|
|
440
|
+
* transaction: consecutive changes sharing a `transactionId` form one
|
|
441
|
+
* indivisible group (a commit reveals whole), while changes without one are
|
|
442
|
+
* individually splittable. A single transaction larger than the bound forms
|
|
443
|
+
* its own oversized slice, so apply always advances rather than stalling on
|
|
444
|
+
* an oversized commit — the same rule the server's publication chunking uses.
|
|
445
|
+
*/
|
|
446
|
+
export function sliceApplyChanges<T extends { readonly transactionId?: string }>(
|
|
447
|
+
changes: readonly T[],
|
|
448
|
+
maxDeltas: number,
|
|
449
|
+
): readonly T[][] {
|
|
450
|
+
if (changes.length <= maxDeltas) return changes.length > 0 ? [[...changes]] : [];
|
|
451
|
+
const slices: T[][] = [];
|
|
452
|
+
let current: T[] = [];
|
|
453
|
+
let index = 0;
|
|
454
|
+
while (index < changes.length) {
|
|
455
|
+
// The indivisible unit starting here: one transaction's run, or a single
|
|
456
|
+
// untransacted change.
|
|
457
|
+
const transactionId = changes[index]!.transactionId;
|
|
458
|
+
let end = index + 1;
|
|
459
|
+
if (transactionId !== undefined) {
|
|
460
|
+
while (end < changes.length && changes[end]!.transactionId === transactionId) end += 1;
|
|
461
|
+
}
|
|
462
|
+
const groupSize = end - index;
|
|
463
|
+
if (current.length > 0 && current.length + groupSize > maxDeltas) {
|
|
464
|
+
slices.push(current);
|
|
465
|
+
current = [];
|
|
466
|
+
}
|
|
467
|
+
current.push(...changes.slice(index, end));
|
|
468
|
+
index = end;
|
|
469
|
+
}
|
|
470
|
+
if (current.length > 0) slices.push(current);
|
|
471
|
+
return slices;
|
|
472
|
+
}
|
|
473
|
+
|
|
396
474
|
function yieldToHost(): Promise<void> {
|
|
397
475
|
const immediate = (
|
|
398
476
|
globalThis as {
|
|
@@ -408,8 +486,24 @@ function yieldToHost(): Promise<void> {
|
|
|
408
486
|
async function flushDeltaBatch(
|
|
409
487
|
ctx: DeltaPipelineContext,
|
|
410
488
|
queuedDeltas: SyncDelta[],
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
openDrainBatchRow(queuedDeltas.length);
|
|
491
|
+
pipelineDebug.flushesStarted += 1;
|
|
492
|
+
try {
|
|
493
|
+
await flushDeltaBatchInner(ctx, queuedDeltas);
|
|
494
|
+
} finally {
|
|
495
|
+
pipelineDebug.flushesSettled += 1;
|
|
496
|
+
pipelineDebug.phase = 'idle';
|
|
497
|
+
closeDrainBatchRow();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function flushDeltaBatchInner(
|
|
502
|
+
ctx: DeltaPipelineContext,
|
|
503
|
+
queuedDeltas: SyncDelta[],
|
|
411
504
|
): Promise<void> {
|
|
412
505
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
506
|
+
pipelineDebug.phase = 'dedupe';
|
|
413
507
|
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
414
508
|
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
415
509
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
@@ -427,7 +521,7 @@ async function flushDeltaBatch(
|
|
|
427
521
|
// gained permission to see the entity, so we insert it into the
|
|
428
522
|
// pool as if newly created.
|
|
429
523
|
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
430
|
-
const existing = ctx.objectPool.
|
|
524
|
+
const existing = ctx.objectPool.peek(delta.modelId);
|
|
431
525
|
if (existing) {
|
|
432
526
|
existing.updateFromData(data);
|
|
433
527
|
} else {
|
|
@@ -446,6 +540,8 @@ async function flushDeltaBatch(
|
|
|
446
540
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
447
541
|
// signature accepts them defensively.
|
|
448
542
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
543
|
+
pipelineDebug.phase = 'persist';
|
|
544
|
+
pipelineDebug.persistsStarted += 1;
|
|
449
545
|
const batch = await timeDrainStageAsync('persist', () =>
|
|
450
546
|
ctx.processDeltaBatch(
|
|
451
547
|
regularDeltas.map((d) => ({
|
|
@@ -460,6 +556,7 @@ async function flushDeltaBatch(
|
|
|
460
556
|
}))
|
|
461
557
|
)
|
|
462
558
|
);
|
|
559
|
+
pipelineDebug.persistsSettled += 1;
|
|
463
560
|
const dbResults = batch.results;
|
|
464
561
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
465
562
|
|
|
@@ -468,13 +565,41 @@ async function flushDeltaBatch(
|
|
|
468
565
|
// materialiser attached where it said it would. The direct call is the
|
|
469
566
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
470
567
|
// whose own apply is the whole pipeline.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
568
|
+
//
|
|
569
|
+
// Large batches apply in TIME SLICES: split at transaction boundaries into
|
|
570
|
+
// bounded chunks with the event loop yielded between them, so a catch-up
|
|
571
|
+
// wave reveals commit-by-commit instead of holding the thread for one long
|
|
572
|
+
// synchronous block. Each slice is still one MobX action (reactions fire
|
|
573
|
+
// once per slice), and a transaction never splits across slices — the
|
|
574
|
+
// commit remains the atomic unit of visibility.
|
|
575
|
+
const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
|
|
576
|
+
await timeDrainStageAsync('apply', async () => {
|
|
577
|
+
const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
|
|
578
|
+
// Yield on a TIME budget, not per slice: a host yield costs milliseconds
|
|
579
|
+
// under load (measured ~7x throughput collapse when yielding every few
|
|
580
|
+
// deltas), so the yield decision amortizes it — only after the budget of
|
|
581
|
+
// uninterrupted apply work has been spent, and never before the first
|
|
582
|
+
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
583
|
+
// the loop breathes.
|
|
584
|
+
let sliceStartedAt = performance.now();
|
|
585
|
+
for (let index = 0; index < slices.length; index++) {
|
|
586
|
+
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
587
|
+
pipelineDebug.phase = `apply-yield-${index}`;
|
|
588
|
+
pipelineDebug.applyYields += 1;
|
|
589
|
+
await yieldToHost();
|
|
590
|
+
sliceStartedAt = performance.now();
|
|
591
|
+
}
|
|
592
|
+
pipelineDebug.phase = `apply-slice-${index}`;
|
|
593
|
+
pipelineDebug.applySlices += 1;
|
|
594
|
+
const slice = slices[index]!;
|
|
595
|
+
if (hasApplyPlugins) {
|
|
596
|
+
runStage(stagePlugins, 'apply', { changes: slice });
|
|
597
|
+
} else {
|
|
598
|
+
ctx.applyDeltaBatchToPool(slice);
|
|
599
|
+
}
|
|
476
600
|
}
|
|
477
601
|
});
|
|
602
|
+
pipelineDebug.phase = 'acknowledge';
|
|
478
603
|
|
|
479
604
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
480
605
|
//
|
|
@@ -489,6 +614,7 @@ async function flushDeltaBatch(
|
|
|
489
614
|
timeDrainStage('acknowledge', () => {
|
|
490
615
|
ctx.acknowledge(persistedSyncId);
|
|
491
616
|
ctx.advancePersisted(persistedSyncId);
|
|
617
|
+
observeDrainAcknowledge(persistedSyncId);
|
|
492
618
|
runStage(stagePlugins, 'acknowledge', { syncId: persistedSyncId });
|
|
493
619
|
});
|
|
494
620
|
}
|
|
@@ -32,6 +32,19 @@ export interface DrainStageTotals {
|
|
|
32
32
|
readonly calls: number;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* One flush batch on the wall clock. Wall time (`Date.now`) rather than
|
|
37
|
+
* `performance.now`, because rows cross the worker boundary and each thread
|
|
38
|
+
* has its own `performance` origin — the drain-tail stamps learned the same
|
|
39
|
+
* lesson. Stage entries are the batch's own share of each pipeline stage.
|
|
40
|
+
*/
|
|
41
|
+
export interface DrainBatchRow {
|
|
42
|
+
readonly startedAtWallMs: number;
|
|
43
|
+
readonly endedAtWallMs: number;
|
|
44
|
+
readonly deltas: number;
|
|
45
|
+
readonly stages: Readonly<Partial<Record<DrainStage, number>>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
export interface DrainProfile {
|
|
36
49
|
/** Flush batches drained. The per-batch fixed cost multiplies by this. */
|
|
37
50
|
readonly batches: number;
|
|
@@ -42,6 +55,12 @@ export interface DrainProfile {
|
|
|
42
55
|
/** Wall time from the first observed stage to the last. */
|
|
43
56
|
readonly spanMs: number;
|
|
44
57
|
readonly stages: Readonly<Record<DrainStage, DrainStageTotals>>;
|
|
58
|
+
/**
|
|
59
|
+
* The most recent flush batches, oldest first, capped — enough to cover a
|
|
60
|
+
* drain tail. Optional because derived profiles (window subtraction, fleet
|
|
61
|
+
* merges) drop it; only a worker's own snapshot carries rows.
|
|
62
|
+
*/
|
|
63
|
+
readonly recentBatches?: readonly DrainBatchRow[];
|
|
45
64
|
}
|
|
46
65
|
|
|
47
66
|
const DRAIN_STAGES: readonly DrainStage[] = [
|
|
@@ -72,6 +91,73 @@ let deduplicated = 0;
|
|
|
72
91
|
let firstMark: number | undefined;
|
|
73
92
|
let lastMark = 0;
|
|
74
93
|
|
|
94
|
+
/** Ring of recent batch rows. ~50 batches/sec at benchmark rates, so this covers seconds of tail. */
|
|
95
|
+
const BATCH_ROW_CAP = 128;
|
|
96
|
+
let batchRows: DrainBatchRow[] = [];
|
|
97
|
+
interface OpenBatchRow {
|
|
98
|
+
startedAtWallMs: number;
|
|
99
|
+
deltas: number;
|
|
100
|
+
stages: Partial<Record<DrainStage, number>>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The batch currently being flushed. Module-global like the totals above, so
|
|
104
|
+
* an isolate hosting several stores attributes interleaved awaits to whichever
|
|
105
|
+
* batch is open — the same per-isolate approximation the totals already make.
|
|
106
|
+
*/
|
|
107
|
+
let currentRow: OpenBatchRow | null = null;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Wall-stamped persisted-cursor advances, oldest first. The benchmark's drain
|
|
111
|
+
* gate reads THESE rather than observing the cursor from a timer or a
|
|
112
|
+
* cross-thread poll: any observation that has to be scheduled onto the
|
|
113
|
+
* worker's event loop queues behind the very drain burst it is measuring and
|
|
114
|
+
* reports the queue's latency as drain. A stamp taken synchronously inside
|
|
115
|
+
* the acknowledge stage cannot be deferred by anything.
|
|
116
|
+
*/
|
|
117
|
+
export interface AcknowledgeStamp {
|
|
118
|
+
readonly syncId: number;
|
|
119
|
+
readonly atWallMs: number;
|
|
120
|
+
}
|
|
121
|
+
const ACK_STAMP_CAP = 512;
|
|
122
|
+
let ackStamps: AcknowledgeStamp[] = [];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Record a persisted-cursor advance. Called by the pipeline's acknowledge
|
|
126
|
+
* stage. Unlike every stage timer here, this is NOT gated on the profiler
|
|
127
|
+
* flag: it is one wall-clock read and one bounded push per flush batch —
|
|
128
|
+
* nothing against the batch's own work — and the certification benchmark
|
|
129
|
+
* runs unprofiled (the profiler costs ~15%), so the honest drain stamp must
|
|
130
|
+
* exist without it.
|
|
131
|
+
*/
|
|
132
|
+
export function observeDrainAcknowledge(syncId: number): void {
|
|
133
|
+
ackStamps.push({ syncId, atWallMs: Date.now() });
|
|
134
|
+
if (ackStamps.length > ACK_STAMP_CAP) ackStamps.shift();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The recorded persisted-advance stamps, oldest first. */
|
|
138
|
+
export function drainAcknowledgeStamps(): readonly AcknowledgeStamp[] {
|
|
139
|
+
return [...ackStamps];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Begin a batch row. Called by the pipeline at flush entry when profiling. */
|
|
143
|
+
export function openDrainBatchRow(deltaCount: number): void {
|
|
144
|
+
if (!enabled) return;
|
|
145
|
+
currentRow = { startedAtWallMs: Date.now(), deltas: deltaCount, stages: {} };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Close the open batch row and commit it to the ring. */
|
|
149
|
+
export function closeDrainBatchRow(): void {
|
|
150
|
+
if (!enabled || currentRow === null) return;
|
|
151
|
+
batchRows.push({
|
|
152
|
+
startedAtWallMs: currentRow.startedAtWallMs,
|
|
153
|
+
endedAtWallMs: Date.now(),
|
|
154
|
+
deltas: currentRow.deltas,
|
|
155
|
+
stages: currentRow.stages,
|
|
156
|
+
});
|
|
157
|
+
if (batchRows.length > BATCH_ROW_CAP) batchRows.shift();
|
|
158
|
+
currentRow = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
75
161
|
/**
|
|
76
162
|
* Read once. A profiler that consults the environment on every delta would
|
|
77
163
|
* itself become a per-delta cost in the path it is measuring.
|
|
@@ -98,6 +184,9 @@ export function observeDrainStage(stage: DrainStage, elapsedMs: number): void {
|
|
|
98
184
|
const entry = totals[stage];
|
|
99
185
|
entry.totalMs += elapsedMs;
|
|
100
186
|
entry.calls += 1;
|
|
187
|
+
if (currentRow !== null) {
|
|
188
|
+
currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
|
|
189
|
+
}
|
|
101
190
|
mark(elapsedMs);
|
|
102
191
|
}
|
|
103
192
|
|
|
@@ -150,6 +239,7 @@ export function drainProfileSnapshot(): DrainProfile {
|
|
|
150
239
|
deduplicated,
|
|
151
240
|
spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
|
|
152
241
|
stages,
|
|
242
|
+
recentBatches: [...batchRows],
|
|
153
243
|
};
|
|
154
244
|
}
|
|
155
245
|
|
|
@@ -161,4 +251,7 @@ export function resetDrainProfile(): void {
|
|
|
161
251
|
deduplicated = 0;
|
|
162
252
|
firstMark = undefined;
|
|
163
253
|
lastMark = 0;
|
|
254
|
+
batchRows = [];
|
|
255
|
+
currentRow = null;
|
|
256
|
+
ackStamps = [];
|
|
164
257
|
}
|
|
@@ -647,10 +647,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
647
647
|
let holdsEntireBatch = false;
|
|
648
648
|
|
|
649
649
|
for (const notification of notifications) {
|
|
650
|
-
|
|
650
|
+
// Scope is decided before any target matching. A group premise fires over
|
|
651
|
+
// the WHOLE batch by convention, and its `target` now names the row that
|
|
652
|
+
// actually moved — which may well be a row this batch is writing, so
|
|
653
|
+
// matching first would misread a batch-wide hold as a per-row one.
|
|
654
|
+
if (notification.scope === 'group') {
|
|
655
|
+
holdsEntireBatch = true;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
const candidates = targets.filter(
|
|
659
|
+
(target) => target.id === notification.target.id,
|
|
660
|
+
);
|
|
651
661
|
const notificationKey = this.receiptTargetKey(
|
|
652
|
-
notification.model,
|
|
653
|
-
notification.id,
|
|
662
|
+
notification.target.model,
|
|
663
|
+
notification.target.id,
|
|
654
664
|
);
|
|
655
665
|
const exactTargets = candidates.filter(
|
|
656
666
|
(target) => target.key === notificationKey,
|
|
@@ -661,7 +671,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
661
671
|
),
|
|
662
672
|
);
|
|
663
673
|
|
|
664
|
-
if (
|
|
674
|
+
if (candidates.length === 0) {
|
|
665
675
|
holdsEntireBatch = true;
|
|
666
676
|
continue;
|
|
667
677
|
}
|
|
@@ -27,6 +27,7 @@ import { getContext } from '../context.js';
|
|
|
27
27
|
interface M1Target {
|
|
28
28
|
_hasCustomObservability?: boolean;
|
|
29
29
|
_isConstructing?: boolean;
|
|
30
|
+
_isHydrating?: boolean;
|
|
30
31
|
_extraMobxAnnotations?: Record<string, AnnotationMapEntry>;
|
|
31
32
|
setupObservability?(): void;
|
|
32
33
|
propertyChanged?(name: string, oldValue: unknown, newValue: unknown): void;
|
|
@@ -282,6 +283,10 @@ export function M1<T extends M1Target>(
|
|
|
282
283
|
// pre-construct models with partial data then bulk-assign
|
|
283
284
|
// would otherwise spuriously fill `modifiedProperties`.
|
|
284
285
|
if (target._isConstructing) return;
|
|
286
|
+
// Hydration writes (`updateFromData`) are inbound wire data,
|
|
287
|
+
// not user edits: the forward's result is discarded by the
|
|
288
|
+
// `modifiedProperties` swap anyway, so skip the work.
|
|
289
|
+
if (target._isHydrating) return;
|
|
285
290
|
if (typeof target.propertyChanged === 'function') {
|
|
286
291
|
target.propertyChanged(
|
|
287
292
|
propName,
|