@abloatai/humans 0.39.0 → 0.41.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/local/BaseSyncedStore.d.ts +9 -0
- package/dist/local/BaseSyncedStore.js +21 -0
- package/dist/local/InstanceCache.d.ts +22 -0
- package/dist/local/InstanceCache.js +82 -9
- package/dist/local/Model.d.ts +10 -0
- package/dist/local/Model.js +19 -3
- package/dist/local/SyncClient.js +8 -2
- package/dist/local/sync/deltaPipeline.d.ts +32 -1
- package/dist/local/sync/deltaPipeline.js +105 -7
- 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/local/BaseSyncedStore.ts +30 -0
- package/src/local/InstanceCache.ts +85 -9
- package/src/local/Model.ts +19 -5
- package/src/local/SyncClient.ts +9 -2
- package/src/local/sync/deltaPipeline.ts +114 -8
- package/src/local/transactions/mutations/MutationQueue.ts +14 -4
- package/src/local/utils/mobxSetup.ts +5 -0
|
@@ -126,6 +126,15 @@ export interface SmartSyncOptions {
|
|
|
126
126
|
maxBootstrapSize?: number;
|
|
127
127
|
batchingDelay?: number;
|
|
128
128
|
maxBatchSize?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Upper bound on deltas revealed per apply slice. A large flush batch is
|
|
131
|
+
* split at TRANSACTION boundaries into slices of at most this many deltas,
|
|
132
|
+
* with the event loop yielded between slices, so a catch-up wave never
|
|
133
|
+
* holds the thread for one long synchronous apply. A transaction larger
|
|
134
|
+
* than the bound still applies whole — the commit stays the atomic unit of
|
|
135
|
+
* visibility. `Infinity` restores single-slice behavior.
|
|
136
|
+
*/
|
|
137
|
+
applySliceDeltas?: number;
|
|
129
138
|
}
|
|
130
139
|
export type { RehydrationStats } from './sync/bootstrapApply.js';
|
|
131
140
|
/**
|
|
@@ -36,6 +36,15 @@ import * as groupChange from './sync/groupChange.js';
|
|
|
36
36
|
import * as bootstrapApply from './sync/bootstrapApply.js';
|
|
37
37
|
import * as deltaPipeline from './sync/deltaPipeline.js';
|
|
38
38
|
import { queryByClass as runQueryByClass, countModels } from './store/queryApi.js';
|
|
39
|
+
/** Bench-diagnostic slice-bound override; absent everywhere but the bench. */
|
|
40
|
+
function benchApplySliceOverride() {
|
|
41
|
+
const host = globalThis;
|
|
42
|
+
const raw = host.process?.env?.ABLO_APPLY_SLICE_DELTAS;
|
|
43
|
+
if (!raw)
|
|
44
|
+
return undefined;
|
|
45
|
+
const value = Number(raw);
|
|
46
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
47
|
+
}
|
|
39
48
|
/**
|
|
40
49
|
* Bootstrap retry configuration.
|
|
41
50
|
*
|
|
@@ -378,6 +387,18 @@ export class BaseSyncedStore {
|
|
|
378
387
|
// trickle just as well and keeps burst tails inside the drain budget.
|
|
379
388
|
batchingDelay: 10,
|
|
380
389
|
maxBatchSize: 50,
|
|
390
|
+
// ~600 deltas ≈ 9 to 14 ms of apply — inside a no-visible-stall
|
|
391
|
+
// budget, and a full 500-op commit reveals in one slice. The yield
|
|
392
|
+
// itself is TIME-budgeted in the pipeline (one or two yields per
|
|
393
|
+
// batch), because a host yield costs milliseconds under load.
|
|
394
|
+
// History: the "sliced-apply wedge" that briefly held this at
|
|
395
|
+
// Infinity was kernel memory limits against the bench's many-isolate
|
|
396
|
+
// process (semispace commits refused at stock max_map_count /
|
|
397
|
+
// CommitLimit), not this pipeline — with the limits raised, the
|
|
398
|
+
// sliced path ran the full certification load with zero errors and
|
|
399
|
+
// cut writer ack latency threefold. `ABLO_APPLY_SLICE_DELTAS` remains
|
|
400
|
+
// the bench-diagnostic override.
|
|
401
|
+
applySliceDeltas: benchApplySliceOverride() ?? 600,
|
|
381
402
|
};
|
|
382
403
|
// Create internal helpers
|
|
383
404
|
this.queryProcessor = new QueryProcessor({
|
|
@@ -60,6 +60,18 @@ export declare class InstanceCache {
|
|
|
60
60
|
constructor(config?: PoolConfig, modelRegistry?: ModelRegistry);
|
|
61
61
|
private resolveModel;
|
|
62
62
|
get<T extends Model = Model>(id: string): T | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
|
|
65
|
+
* boundary. Same resolution semantics as {@link get} — weak-ref revival,
|
|
66
|
+
* disposed rows filtered, access recency stamped — but the model is NOT
|
|
67
|
+
* activated. The delta-apply loop reads every row it updates, and reading
|
|
68
|
+
* through `get()` made the stream itself install per-field MobX
|
|
69
|
+
* instrumentation on rows no consumer ever observes; that activation is
|
|
70
|
+
* the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
|
|
71
|
+
* `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
|
|
72
|
+
* which is what activates a deferred model.
|
|
73
|
+
*/
|
|
74
|
+
peek<T extends Model = Model>(id: string): T | undefined;
|
|
63
75
|
/**
|
|
64
76
|
* Look a row up **within one model**.
|
|
65
77
|
*
|
|
@@ -226,6 +238,16 @@ export declare class InstanceCache {
|
|
|
226
238
|
* on later evictions until it is no longer observed.
|
|
227
239
|
*/
|
|
228
240
|
private evictOldestBatch;
|
|
241
|
+
/**
|
|
242
|
+
* Data-field keys to probe per model, resolved once per model name. Only
|
|
243
|
+
* stored-value fields can carry bulk; walking the whole instance also
|
|
244
|
+
* visited the base class's bookkeeping (`_mobxProperties`,
|
|
245
|
+
* `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
|
|
246
|
+
* those empty containers on EVERY add — a first-order term of wire-ingest
|
|
247
|
+
* create apply. Reference/computed keys are deliberately excluded: reading
|
|
248
|
+
* them would execute their getters.
|
|
249
|
+
*/
|
|
250
|
+
private sizeProbeKeys;
|
|
229
251
|
private isLargeModel;
|
|
230
252
|
/**
|
|
231
253
|
* Register a foreign key field for indexing on a model type.
|
|
@@ -11,7 +11,7 @@ import { DEFER_MODEL_OBSERVABILITY, Model } from './Model.js';
|
|
|
11
11
|
import { ModelRegistry } from './ModelRegistry.js';
|
|
12
12
|
import { globalRuntime } from './context.js';
|
|
13
13
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
14
|
-
import { ModelScope } from '@abloatai/transaction/types';
|
|
14
|
+
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
15
15
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
16
16
|
import { QueryView } from './views/QueryView.js';
|
|
17
17
|
// Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
|
|
@@ -21,9 +21,18 @@ export { ModelScope };
|
|
|
21
21
|
* entity resolves to a single instance.
|
|
22
22
|
*/
|
|
23
23
|
export class InstanceCache {
|
|
24
|
-
// Single source of truth for all models (observable for reactivity)
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Single source of truth for all models (observable for reactivity).
|
|
25
|
+
// Shallow on purpose, and the reactivity contract depends on it: readers
|
|
26
|
+
// track MEMBERSHIP (keys), and every entry-level change re-sets the whole
|
|
27
|
+
// entry (see `updateScope`) — in-place field writes on an entry do not
|
|
28
|
+
// notify. The deep default additionally converted every stored ModelEntry
|
|
29
|
+
// into an observable object, a per-add extendObservable/defineProperty
|
|
30
|
+
// pass that profiled as the single largest term of create-apply on the
|
|
31
|
+
// wire-ingestion path, bought nothing the contract uses, and meant
|
|
32
|
+
// `entries.get()` returned a converted wrapper rather than the object
|
|
33
|
+
// the insert stored.
|
|
34
|
+
entries = observable.map({}, { deep: false });
|
|
35
|
+
typeIndex = observable.map({}, { deep: false });
|
|
27
36
|
// Non-observable access time tracking — kept outside observable.map so that
|
|
28
37
|
// updating timestamps in get() during React render does NOT trigger MobX
|
|
29
38
|
// reactions (which would cause infinite re-render loops).
|
|
@@ -205,6 +214,37 @@ export class InstanceCache {
|
|
|
205
214
|
model?.ensureObservable();
|
|
206
215
|
return model ?? undefined;
|
|
207
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
|
|
219
|
+
* boundary. Same resolution semantics as {@link get} — weak-ref revival,
|
|
220
|
+
* disposed rows filtered, access recency stamped — but the model is NOT
|
|
221
|
+
* activated. The delta-apply loop reads every row it updates, and reading
|
|
222
|
+
* through `get()` made the stream itself install per-field MobX
|
|
223
|
+
* instrumentation on rows no consumer ever observes; that activation is
|
|
224
|
+
* the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
|
|
225
|
+
* `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
|
|
226
|
+
* which is what activates a deferred model.
|
|
227
|
+
*/
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
229
|
+
peek(id) {
|
|
230
|
+
const entry = this.entries.get(id);
|
|
231
|
+
if (!entry)
|
|
232
|
+
return undefined;
|
|
233
|
+
let model = entry.model;
|
|
234
|
+
if (!model && entry.weakRef) {
|
|
235
|
+
const restoredModel = entry.weakRef.deref();
|
|
236
|
+
if (!restoredModel)
|
|
237
|
+
return undefined;
|
|
238
|
+
model = restoredModel;
|
|
239
|
+
runInAction(() => {
|
|
240
|
+
entry.model = restoredModel;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (model?.disposed)
|
|
244
|
+
return undefined;
|
|
245
|
+
this.touchAccess(id, Date.now());
|
|
246
|
+
return model ?? undefined;
|
|
247
|
+
}
|
|
208
248
|
/**
|
|
209
249
|
* Look a row up **within one model**.
|
|
210
250
|
*
|
|
@@ -588,6 +628,11 @@ export class InstanceCache {
|
|
|
588
628
|
continue;
|
|
589
629
|
const model = this.resolveModel(entry, id);
|
|
590
630
|
if (model && !model.disposed) {
|
|
631
|
+
// Bulk reads are a consumer boundary: rows land cold from the wire
|
|
632
|
+
// (delta ingestion applies with `peek` and never activates), so
|
|
633
|
+
// every read that hands models out must activate them, exactly as
|
|
634
|
+
// `get()` does. Idempotent — an active row pays one boolean check.
|
|
635
|
+
model.ensureObservable();
|
|
591
636
|
result.push(model);
|
|
592
637
|
}
|
|
593
638
|
}
|
|
@@ -613,6 +658,8 @@ export class InstanceCache {
|
|
|
613
658
|
continue;
|
|
614
659
|
const model = this.resolveModel(entry, id);
|
|
615
660
|
if (model && !model.disposed) {
|
|
661
|
+
// Consumer boundary — activate, as in getByType.
|
|
662
|
+
model.ensureObservable();
|
|
616
663
|
result.push(model);
|
|
617
664
|
}
|
|
618
665
|
}
|
|
@@ -1030,14 +1077,37 @@ export class InstanceCache {
|
|
|
1030
1077
|
}
|
|
1031
1078
|
});
|
|
1032
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Data-field keys to probe per model, resolved once per model name. Only
|
|
1082
|
+
* stored-value fields can carry bulk; walking the whole instance also
|
|
1083
|
+
* visited the base class's bookkeeping (`_mobxProperties`,
|
|
1084
|
+
* `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
|
|
1085
|
+
* those empty containers on EVERY add — a first-order term of wire-ingest
|
|
1086
|
+
* create apply. Reference/computed keys are deliberately excluded: reading
|
|
1087
|
+
* them would execute their getters.
|
|
1088
|
+
*/
|
|
1089
|
+
sizeProbeKeys = new Map();
|
|
1033
1090
|
isLargeModel(model) {
|
|
1034
1091
|
try {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1092
|
+
const modelName = model.getModelName();
|
|
1093
|
+
let keys = this.sizeProbeKeys.get(modelName);
|
|
1094
|
+
if (!keys) {
|
|
1095
|
+
const props = this.registry.getProperties(modelName);
|
|
1096
|
+
keys = [...props.entries()]
|
|
1097
|
+
.filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
|
|
1098
|
+
.map(([key]) => key);
|
|
1099
|
+
this.sizeProbeKeys.set(modelName, keys);
|
|
1100
|
+
}
|
|
1101
|
+
// A model with no registered stored fields (e.g. a custom entity) keeps
|
|
1102
|
+
// the whole-instance walk, since its payload lives outside the schema.
|
|
1103
|
+
const values = keys.length > 0
|
|
1104
|
+
? keys.map((key) => Reflect.get(model, key))
|
|
1105
|
+
: Object.values(model);
|
|
1106
|
+
// Most synchronized rows contain only short scalars. Accumulate cheap
|
|
1107
|
+
// scalar sizes and serialize only nested payloads that can plausibly
|
|
1108
|
+
// cross the WeakRef threshold.
|
|
1039
1109
|
let size = 0;
|
|
1040
|
-
for (const value of
|
|
1110
|
+
for (const value of values) {
|
|
1041
1111
|
if (typeof value === 'string') {
|
|
1042
1112
|
size += value.length;
|
|
1043
1113
|
}
|
|
@@ -1134,6 +1204,9 @@ export class InstanceCache {
|
|
|
1134
1204
|
}
|
|
1135
1205
|
const model = this.resolveModel(entry, id);
|
|
1136
1206
|
if (model && !model.disposed) {
|
|
1207
|
+
// Consumer boundary — activate, as in getByType. `hasMany` reads
|
|
1208
|
+
// (`section.blocks`) observe fields off these rows directly.
|
|
1209
|
+
model.ensureObservable();
|
|
1137
1210
|
result.push(model);
|
|
1138
1211
|
}
|
|
1139
1212
|
else if (model?.disposed) {
|
package/dist/local/Model.d.ts
CHANGED
|
@@ -81,6 +81,16 @@ export declare abstract class Model {
|
|
|
81
81
|
clientId: string;
|
|
82
82
|
/** MobX observable properties storage */
|
|
83
83
|
_mobxProperties: ModelData;
|
|
84
|
+
/**
|
|
85
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
86
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
87
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
88
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
89
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
90
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
91
|
+
* correctness backstop; this flag removes the cost.
|
|
92
|
+
*/
|
|
93
|
+
_isHydrating: boolean;
|
|
84
94
|
/** Referenced models cache */
|
|
85
95
|
_referencedModels: Record<string, Model | null>;
|
|
86
96
|
/** Track property changes */
|
package/dist/local/Model.js
CHANGED
|
@@ -47,6 +47,16 @@ export class Model {
|
|
|
47
47
|
clientId;
|
|
48
48
|
/** MobX observable properties storage */
|
|
49
49
|
_mobxProperties = {};
|
|
50
|
+
/**
|
|
51
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
52
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
53
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
54
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
55
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
56
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
57
|
+
* correctness backstop; this flag removes the cost.
|
|
58
|
+
*/
|
|
59
|
+
_isHydrating = false;
|
|
50
60
|
/** Referenced models cache */
|
|
51
61
|
_referencedModels = {};
|
|
52
62
|
/** Track property changes */
|
|
@@ -594,9 +604,15 @@ export class Model {
|
|
|
594
604
|
runInAction(() => {
|
|
595
605
|
const originalTracking = this.modifiedProperties;
|
|
596
606
|
this.modifiedProperties = new Map();
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
607
|
+
this._isHydrating = true;
|
|
608
|
+
try {
|
|
609
|
+
// No `onWrite` → this call records nothing itself.
|
|
610
|
+
this.assignFieldsFromData(data);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
this._isHydrating = false;
|
|
614
|
+
this.modifiedProperties = originalTracking;
|
|
615
|
+
}
|
|
600
616
|
});
|
|
601
617
|
// Mark as persisted if updating existing model
|
|
602
618
|
if (!this._isNew) {
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -1579,7 +1579,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1579
1579
|
}
|
|
1580
1580
|
switch (action) {
|
|
1581
1581
|
case 'add': {
|
|
1582
|
-
|
|
1582
|
+
// `peek`, not `get`: this loop is ingestion, not a consumer read.
|
|
1583
|
+
// `get()` activates deferred MobX instrumentation, so reading
|
|
1584
|
+
// through it here made the delta stream itself instrument every
|
|
1585
|
+
// row it touched — the dominant term of apply cost for rows no
|
|
1586
|
+
// consumer observes. Activation belongs to the consumer-facing
|
|
1587
|
+
// reads (`get`, views, subscribers), which are unchanged.
|
|
1588
|
+
const existing = this.objectPool.peek(modelId);
|
|
1583
1589
|
if (existing) {
|
|
1584
1590
|
existing.markAsSynced();
|
|
1585
1591
|
}
|
|
@@ -1594,7 +1600,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1594
1600
|
break;
|
|
1595
1601
|
}
|
|
1596
1602
|
case 'update': {
|
|
1597
|
-
const existing = this.objectPool.
|
|
1603
|
+
const existing = this.objectPool.peek(modelId);
|
|
1598
1604
|
if (existing && !existing.disposed && result.data) {
|
|
1599
1605
|
enrichRelations(modelName, result.data);
|
|
1600
1606
|
const resolved = this.resolveConflicts(existing, result.data);
|
|
@@ -38,6 +38,7 @@ export interface DeltaPipelineContext {
|
|
|
38
38
|
readonly smartSyncOptions: {
|
|
39
39
|
readonly batchingDelay: number;
|
|
40
40
|
readonly maxBatchSize: number;
|
|
41
|
+
readonly applySliceDeltas: number;
|
|
41
42
|
};
|
|
42
43
|
/** Pool-applied cursor (`syncClient.position.applied`). */
|
|
43
44
|
readonly highestProcessedSyncId: number;
|
|
@@ -62,7 +63,8 @@ export interface DeltaPipelineContext {
|
|
|
62
63
|
/** Acknowledges a sync id back to the server; a no-op when the socket is down. */
|
|
63
64
|
acknowledge(syncId: number): void;
|
|
64
65
|
readonly objectPool: {
|
|
65
|
-
|
|
66
|
+
/** Ingestion-side lookup — resolves without activating observability. */
|
|
67
|
+
peek(id: string): Model | undefined;
|
|
66
68
|
add(model: Model, scope: ModelScope): void;
|
|
67
69
|
remove(id: string): boolean;
|
|
68
70
|
/** Full in-memory clear — the revocation-failure fallback (see
|
|
@@ -114,3 +116,32 @@ export declare function applyDeltaFrame(ctx: DeltaPipelineContext, deltas: SyncD
|
|
|
114
116
|
* and advances the acknowledgement cursor once the store write has committed.
|
|
115
117
|
*/
|
|
116
118
|
export declare function flushPendingDeltas(ctx: DeltaPipelineContext): Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Wedge forensics: where the pipeline currently is, updated synchronously at
|
|
121
|
+
* every stage boundary. A hang diagnoses itself by which counter pair
|
|
122
|
+
* diverged and which phase the active flush froze in. Mirrored onto
|
|
123
|
+
* `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
|
|
124
|
+
* can read it without an import path into SDK internals — diagnostics only,
|
|
125
|
+
* a handful of numbers, no payload data.
|
|
126
|
+
*/
|
|
127
|
+
export declare const pipelineDebug: {
|
|
128
|
+
flushesStarted: number;
|
|
129
|
+
flushesSettled: number;
|
|
130
|
+
persistsStarted: number;
|
|
131
|
+
persistsSettled: number;
|
|
132
|
+
applySlices: number;
|
|
133
|
+
applyYields: number;
|
|
134
|
+
enqueued: number;
|
|
135
|
+
phase: string;
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Split applied changes into slices of at most `maxDeltas`, never splitting a
|
|
139
|
+
* transaction: consecutive changes sharing a `transactionId` form one
|
|
140
|
+
* indivisible group (a commit reveals whole), while changes without one are
|
|
141
|
+
* individually splittable. A single transaction larger than the bound forms
|
|
142
|
+
* its own oversized slice, so apply always advances rather than stalling on
|
|
143
|
+
* an oversized commit — the same rule the server's publication chunking uses.
|
|
144
|
+
*/
|
|
145
|
+
export declare function sliceApplyChanges<T extends {
|
|
146
|
+
readonly transactionId?: string;
|
|
147
|
+
}>(changes: readonly T[], maxDeltas: number): readonly T[][];
|
|
@@ -199,6 +199,7 @@ export function enqueueDelta(ctx, delta, options = {}) {
|
|
|
199
199
|
// The delta is accepted and queued — the `receive` stage boundary.
|
|
200
200
|
runStage(ctx.stagePlugins ?? [], 'receive', { delta });
|
|
201
201
|
ctx.pendingDeltas.push(delta);
|
|
202
|
+
pipelineDebug.enqueued += 1;
|
|
202
203
|
return true;
|
|
203
204
|
}
|
|
204
205
|
/** Debounce a flush for live single-delta traffic. */
|
|
@@ -285,6 +286,68 @@ async function drainPendingDeltas(ctx) {
|
|
|
285
286
|
ctx.batchTimer = null;
|
|
286
287
|
}
|
|
287
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Uninterrupted apply time allowed before the sliced loop yields — the
|
|
291
|
+
* "no visible stall" bound. Yields are amortized against it because one host
|
|
292
|
+
* yield costs milliseconds under load; at the measured per-delta apply cost
|
|
293
|
+
* this works out to roughly one yield per one to two 600-delta slices.
|
|
294
|
+
*/
|
|
295
|
+
const APPLY_YIELD_BUDGET_MS = 12;
|
|
296
|
+
/**
|
|
297
|
+
* Wedge forensics: where the pipeline currently is, updated synchronously at
|
|
298
|
+
* every stage boundary. A hang diagnoses itself by which counter pair
|
|
299
|
+
* diverged and which phase the active flush froze in. Mirrored onto
|
|
300
|
+
* `globalThis.__abloPipelineDebug` so a bench watchdog in the same thread
|
|
301
|
+
* can read it without an import path into SDK internals — diagnostics only,
|
|
302
|
+
* a handful of numbers, no payload data.
|
|
303
|
+
*/
|
|
304
|
+
export const pipelineDebug = {
|
|
305
|
+
flushesStarted: 0,
|
|
306
|
+
flushesSettled: 0,
|
|
307
|
+
persistsStarted: 0,
|
|
308
|
+
persistsSettled: 0,
|
|
309
|
+
applySlices: 0,
|
|
310
|
+
applyYields: 0,
|
|
311
|
+
enqueued: 0,
|
|
312
|
+
phase: 'idle',
|
|
313
|
+
};
|
|
314
|
+
globalThis.__abloPipelineDebug =
|
|
315
|
+
pipelineDebug;
|
|
316
|
+
/**
|
|
317
|
+
* Split applied changes into slices of at most `maxDeltas`, never splitting a
|
|
318
|
+
* transaction: consecutive changes sharing a `transactionId` form one
|
|
319
|
+
* indivisible group (a commit reveals whole), while changes without one are
|
|
320
|
+
* individually splittable. A single transaction larger than the bound forms
|
|
321
|
+
* its own oversized slice, so apply always advances rather than stalling on
|
|
322
|
+
* an oversized commit — the same rule the server's publication chunking uses.
|
|
323
|
+
*/
|
|
324
|
+
export function sliceApplyChanges(changes, maxDeltas) {
|
|
325
|
+
if (changes.length <= maxDeltas)
|
|
326
|
+
return changes.length > 0 ? [[...changes]] : [];
|
|
327
|
+
const slices = [];
|
|
328
|
+
let current = [];
|
|
329
|
+
let index = 0;
|
|
330
|
+
while (index < changes.length) {
|
|
331
|
+
// The indivisible unit starting here: one transaction's run, or a single
|
|
332
|
+
// untransacted change.
|
|
333
|
+
const transactionId = changes[index].transactionId;
|
|
334
|
+
let end = index + 1;
|
|
335
|
+
if (transactionId !== undefined) {
|
|
336
|
+
while (end < changes.length && changes[end].transactionId === transactionId)
|
|
337
|
+
end += 1;
|
|
338
|
+
}
|
|
339
|
+
const groupSize = end - index;
|
|
340
|
+
if (current.length > 0 && current.length + groupSize > maxDeltas) {
|
|
341
|
+
slices.push(current);
|
|
342
|
+
current = [];
|
|
343
|
+
}
|
|
344
|
+
current.push(...changes.slice(index, end));
|
|
345
|
+
index = end;
|
|
346
|
+
}
|
|
347
|
+
if (current.length > 0)
|
|
348
|
+
slices.push(current);
|
|
349
|
+
return slices;
|
|
350
|
+
}
|
|
288
351
|
function yieldToHost() {
|
|
289
352
|
const immediate = globalThis.setImmediate;
|
|
290
353
|
return new Promise((resolve) => {
|
|
@@ -296,15 +359,19 @@ function yieldToHost() {
|
|
|
296
359
|
}
|
|
297
360
|
async function flushDeltaBatch(ctx, queuedDeltas) {
|
|
298
361
|
openDrainBatchRow(queuedDeltas.length);
|
|
362
|
+
pipelineDebug.flushesStarted += 1;
|
|
299
363
|
try {
|
|
300
364
|
await flushDeltaBatchInner(ctx, queuedDeltas);
|
|
301
365
|
}
|
|
302
366
|
finally {
|
|
367
|
+
pipelineDebug.flushesSettled += 1;
|
|
368
|
+
pipelineDebug.phase = 'idle';
|
|
303
369
|
closeDrainBatchRow();
|
|
304
370
|
}
|
|
305
371
|
}
|
|
306
372
|
async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
307
373
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
374
|
+
pipelineDebug.phase = 'dedupe';
|
|
308
375
|
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
309
376
|
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
310
377
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
@@ -320,7 +387,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
320
387
|
// gained permission to see the entity, so we insert it into the
|
|
321
388
|
// pool as if newly created.
|
|
322
389
|
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
323
|
-
const existing = ctx.objectPool.
|
|
390
|
+
const existing = ctx.objectPool.peek(delta.modelId);
|
|
324
391
|
if (existing) {
|
|
325
392
|
existing.updateFromData(data);
|
|
326
393
|
}
|
|
@@ -343,6 +410,8 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
343
410
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
344
411
|
// signature accepts them defensively.
|
|
345
412
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
413
|
+
pipelineDebug.phase = 'persist';
|
|
414
|
+
pipelineDebug.persistsStarted += 1;
|
|
346
415
|
const batch = await timeDrainStageAsync('persist', () => ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
347
416
|
syncId: d.id,
|
|
348
417
|
actionType: d.actionType,
|
|
@@ -353,6 +422,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
353
422
|
// echoes of locally-applied transactions and skip the pool mutation.
|
|
354
423
|
transactionId: d.transactionId,
|
|
355
424
|
}))));
|
|
425
|
+
pipelineDebug.persistsSettled += 1;
|
|
356
426
|
const dbResults = batch.results;
|
|
357
427
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
358
428
|
// Apply the batch results to the in-memory graph. When a plugin has
|
|
@@ -360,14 +430,42 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
360
430
|
// materialiser attached where it said it would. The direct call is the
|
|
361
431
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
362
432
|
// whose own apply is the whole pipeline.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
433
|
+
//
|
|
434
|
+
// Large batches apply in TIME SLICES: split at transaction boundaries into
|
|
435
|
+
// bounded chunks with the event loop yielded between them, so a catch-up
|
|
436
|
+
// wave reveals commit-by-commit instead of holding the thread for one long
|
|
437
|
+
// synchronous block. Each slice is still one MobX action (reactions fire
|
|
438
|
+
// once per slice), and a transaction never splits across slices — the
|
|
439
|
+
// commit remains the atomic unit of visibility.
|
|
440
|
+
const slices = sliceApplyChanges(dbResults, ctx.smartSyncOptions.applySliceDeltas);
|
|
441
|
+
await timeDrainStageAsync('apply', async () => {
|
|
442
|
+
const hasApplyPlugins = pluginsForStage(stagePlugins, 'apply').length > 0;
|
|
443
|
+
// Yield on a TIME budget, not per slice: a host yield costs milliseconds
|
|
444
|
+
// under load (measured ~7x throughput collapse when yielding every few
|
|
445
|
+
// deltas), so the yield decision amortizes it — only after the budget of
|
|
446
|
+
// uninterrupted apply work has been spent, and never before the first
|
|
447
|
+
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
448
|
+
// the loop breathes.
|
|
449
|
+
let sliceStartedAt = performance.now();
|
|
450
|
+
for (let index = 0; index < slices.length; index++) {
|
|
451
|
+
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
452
|
+
pipelineDebug.phase = `apply-yield-${index}`;
|
|
453
|
+
pipelineDebug.applyYields += 1;
|
|
454
|
+
await yieldToHost();
|
|
455
|
+
sliceStartedAt = performance.now();
|
|
456
|
+
}
|
|
457
|
+
pipelineDebug.phase = `apply-slice-${index}`;
|
|
458
|
+
pipelineDebug.applySlices += 1;
|
|
459
|
+
const slice = slices[index];
|
|
460
|
+
if (hasApplyPlugins) {
|
|
461
|
+
runStage(stagePlugins, 'apply', { changes: slice });
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
ctx.applyDeltaBatchToPool(slice);
|
|
465
|
+
}
|
|
369
466
|
}
|
|
370
467
|
});
|
|
468
|
+
pipelineDebug.phase = 'acknowledge';
|
|
371
469
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
372
470
|
//
|
|
373
471
|
// We must acknowledge `persistedSyncId` — the high-water mark of deltas whose
|
|
@@ -419,11 +419,19 @@ export class MutationQueue extends EventEmitter {
|
|
|
419
419
|
const notificationsByTarget = new Map();
|
|
420
420
|
let holdsEntireBatch = false;
|
|
421
421
|
for (const notification of notifications) {
|
|
422
|
-
|
|
423
|
-
|
|
422
|
+
// Scope is decided before any target matching. A group premise fires over
|
|
423
|
+
// the WHOLE batch by convention, and its `target` now names the row that
|
|
424
|
+
// actually moved — which may well be a row this batch is writing, so
|
|
425
|
+
// matching first would misread a batch-wide hold as a per-row one.
|
|
426
|
+
if (notification.scope === 'group') {
|
|
427
|
+
holdsEntireBatch = true;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const candidates = targets.filter((target) => target.id === notification.target.id);
|
|
431
|
+
const notificationKey = this.receiptTargetKey(notification.target.model, notification.target.id);
|
|
424
432
|
const exactTargets = candidates.filter((target) => target.key === notificationKey);
|
|
425
433
|
const candidateKeys = new Set((exactTargets.length > 0 ? exactTargets : candidates).map((target) => target.key));
|
|
426
|
-
if (
|
|
434
|
+
if (candidates.length === 0) {
|
|
427
435
|
holdsEntireBatch = true;
|
|
428
436
|
continue;
|
|
429
437
|
}
|
|
@@ -16,6 +16,7 @@ import { type PropertyMetadata, type ReferenceMetadata } from '@abloatai/transac
|
|
|
16
16
|
interface M1Target {
|
|
17
17
|
_hasCustomObservability?: boolean;
|
|
18
18
|
_isConstructing?: boolean;
|
|
19
|
+
_isHydrating?: boolean;
|
|
19
20
|
_extraMobxAnnotations?: Record<string, AnnotationMapEntry>;
|
|
20
21
|
setupObservability?(): void;
|
|
21
22
|
propertyChanged?(name: string, oldValue: unknown, newValue: unknown): void;
|
|
@@ -245,6 +245,11 @@ export function M1(target, propertyMetadata, referenceMetadata) {
|
|
|
245
245
|
// would otherwise spuriously fill `modifiedProperties`.
|
|
246
246
|
if (target._isConstructing)
|
|
247
247
|
return;
|
|
248
|
+
// Hydration writes (`updateFromData`) are inbound wire data,
|
|
249
|
+
// not user edits: the forward's result is discarded by the
|
|
250
|
+
// `modifiedProperties` swap anyway, so skip the work.
|
|
251
|
+
if (target._isHydrating)
|
|
252
|
+
return;
|
|
248
253
|
if (typeof target.propertyChanged === 'function') {
|
|
249
254
|
target.propertyChanged(propName, change.oldValue, change.newValue);
|
|
250
255
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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.41.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
|
@@ -187,12 +187,30 @@ export interface SmartSyncOptions {
|
|
|
187
187
|
maxBootstrapSize?: number;
|
|
188
188
|
batchingDelay?: number;
|
|
189
189
|
maxBatchSize?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Upper bound on deltas revealed per apply slice. A large flush batch is
|
|
192
|
+
* split at TRANSACTION boundaries into slices of at most this many deltas,
|
|
193
|
+
* with the event loop yielded between slices, so a catch-up wave never
|
|
194
|
+
* holds the thread for one long synchronous apply. A transaction larger
|
|
195
|
+
* than the bound still applies whole — the commit stays the atomic unit of
|
|
196
|
+
* visibility. `Infinity` restores single-slice behavior.
|
|
197
|
+
*/
|
|
198
|
+
applySliceDeltas?: number;
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
// RehydrationStats is defined alongside the bootstrap-apply path and is
|
|
193
202
|
// re-exported here.
|
|
194
203
|
export type { RehydrationStats } from './sync/bootstrapApply.js';
|
|
195
204
|
|
|
205
|
+
/** Bench-diagnostic slice-bound override; absent everywhere but the bench. */
|
|
206
|
+
function benchApplySliceOverride(): number | undefined {
|
|
207
|
+
const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
|
|
208
|
+
const raw = host.process?.env?.ABLO_APPLY_SLICE_DELTAS;
|
|
209
|
+
if (!raw) return undefined;
|
|
210
|
+
const value = Number(raw);
|
|
211
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
/**
|
|
197
215
|
* Bootstrap retry configuration.
|
|
198
216
|
*
|
|
@@ -652,6 +670,18 @@ export class BaseSyncedStore<
|
|
|
652
670
|
// trickle just as well and keeps burst tails inside the drain budget.
|
|
653
671
|
batchingDelay: 10,
|
|
654
672
|
maxBatchSize: 50,
|
|
673
|
+
// ~600 deltas ≈ 9 to 14 ms of apply — inside a no-visible-stall
|
|
674
|
+
// budget, and a full 500-op commit reveals in one slice. The yield
|
|
675
|
+
// itself is TIME-budgeted in the pipeline (one or two yields per
|
|
676
|
+
// batch), because a host yield costs milliseconds under load.
|
|
677
|
+
// History: the "sliced-apply wedge" that briefly held this at
|
|
678
|
+
// Infinity was kernel memory limits against the bench's many-isolate
|
|
679
|
+
// process (semispace commits refused at stock max_map_count /
|
|
680
|
+
// CommitLimit), not this pipeline — with the limits raised, the
|
|
681
|
+
// sliced path ran the full certification load with zero errors and
|
|
682
|
+
// cut writer ack latency threefold. `ABLO_APPLY_SLICE_DELTAS` remains
|
|
683
|
+
// the bench-diagnostic override.
|
|
684
|
+
applySliceDeltas: benchApplySliceOverride() ?? 600,
|
|
655
685
|
};
|
|
656
686
|
|
|
657
687
|
// Create internal helpers
|
|
@@ -13,7 +13,7 @@ import { ModelRegistry } from './ModelRegistry.js';
|
|
|
13
13
|
import { globalRuntime } from './context.js';
|
|
14
14
|
import type { RuntimeContext } from './RuntimeContext.js';
|
|
15
15
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
16
|
-
import { ModelScope } from '@abloatai/transaction/types';
|
|
16
|
+
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
17
17
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
18
18
|
import { QueryView, type QueryViewOptions } from './views/QueryView.js';
|
|
19
19
|
|
|
@@ -48,9 +48,18 @@ interface DeltaInfo {
|
|
|
48
48
|
* entity resolves to a single instance.
|
|
49
49
|
*/
|
|
50
50
|
export class InstanceCache {
|
|
51
|
-
// Single source of truth for all models (observable for reactivity)
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Single source of truth for all models (observable for reactivity).
|
|
52
|
+
// Shallow on purpose, and the reactivity contract depends on it: readers
|
|
53
|
+
// track MEMBERSHIP (keys), and every entry-level change re-sets the whole
|
|
54
|
+
// entry (see `updateScope`) — in-place field writes on an entry do not
|
|
55
|
+
// notify. The deep default additionally converted every stored ModelEntry
|
|
56
|
+
// into an observable object, a per-add extendObservable/defineProperty
|
|
57
|
+
// pass that profiled as the single largest term of create-apply on the
|
|
58
|
+
// wire-ingestion path, bought nothing the contract uses, and meant
|
|
59
|
+
// `entries.get()` returned a converted wrapper rather than the object
|
|
60
|
+
// the insert stored.
|
|
61
|
+
private entries = observable.map<string, ModelEntry>({}, { deep: false });
|
|
62
|
+
private typeIndex = observable.map<string, Set<string>>({}, { deep: false });
|
|
54
63
|
|
|
55
64
|
// Non-observable access time tracking — kept outside observable.map so that
|
|
56
65
|
// updating timestamps in get() during React render does NOT trigger MobX
|
|
@@ -272,6 +281,38 @@ export class InstanceCache {
|
|
|
272
281
|
return model ?? undefined;
|
|
273
282
|
}
|
|
274
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
|
|
286
|
+
* boundary. Same resolution semantics as {@link get} — weak-ref revival,
|
|
287
|
+
* disposed rows filtered, access recency stamped — but the model is NOT
|
|
288
|
+
* activated. The delta-apply loop reads every row it updates, and reading
|
|
289
|
+
* through `get()` made the stream itself install per-field MobX
|
|
290
|
+
* instrumentation on rows no consumer ever observes; that activation is
|
|
291
|
+
* the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
|
|
292
|
+
* `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
|
|
293
|
+
* which is what activates a deferred model.
|
|
294
|
+
*/
|
|
295
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
296
|
+
peek<T extends Model = Model>(id: string): T | undefined {
|
|
297
|
+
const entry = this.entries.get(id);
|
|
298
|
+
if (!entry) return undefined;
|
|
299
|
+
|
|
300
|
+
let model = entry.model as T | undefined;
|
|
301
|
+
if (!model && entry.weakRef) {
|
|
302
|
+
const restoredModel = entry.weakRef.deref();
|
|
303
|
+
if (!restoredModel) return undefined;
|
|
304
|
+
model = restoredModel as T;
|
|
305
|
+
runInAction(() => {
|
|
306
|
+
entry.model = restoredModel;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (model?.disposed) return undefined;
|
|
311
|
+
|
|
312
|
+
this.touchAccess(id, Date.now());
|
|
313
|
+
return model ?? undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
275
316
|
/**
|
|
276
317
|
* Look a row up **within one model**.
|
|
277
318
|
*
|
|
@@ -720,6 +761,11 @@ export class InstanceCache {
|
|
|
720
761
|
|
|
721
762
|
const model = this.resolveModel(entry, id);
|
|
722
763
|
if (model && !model.disposed) {
|
|
764
|
+
// Bulk reads are a consumer boundary: rows land cold from the wire
|
|
765
|
+
// (delta ingestion applies with `peek` and never activates), so
|
|
766
|
+
// every read that hands models out must activate them, exactly as
|
|
767
|
+
// `get()` does. Idempotent — an active row pays one boolean check.
|
|
768
|
+
model.ensureObservable();
|
|
723
769
|
result.push(model);
|
|
724
770
|
}
|
|
725
771
|
}
|
|
@@ -747,6 +793,8 @@ export class InstanceCache {
|
|
|
747
793
|
|
|
748
794
|
const model = this.resolveModel(entry, id);
|
|
749
795
|
if (model && !model.disposed) {
|
|
796
|
+
// Consumer boundary — activate, as in getByType.
|
|
797
|
+
model.ensureObservable();
|
|
750
798
|
result.push(model);
|
|
751
799
|
}
|
|
752
800
|
}
|
|
@@ -1272,14 +1320,39 @@ export class InstanceCache {
|
|
|
1272
1320
|
});
|
|
1273
1321
|
}
|
|
1274
1322
|
|
|
1323
|
+
/**
|
|
1324
|
+
* Data-field keys to probe per model, resolved once per model name. Only
|
|
1325
|
+
* stored-value fields can carry bulk; walking the whole instance also
|
|
1326
|
+
* visited the base class's bookkeeping (`_mobxProperties`,
|
|
1327
|
+
* `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
|
|
1328
|
+
* those empty containers on EVERY add — a first-order term of wire-ingest
|
|
1329
|
+
* create apply. Reference/computed keys are deliberately excluded: reading
|
|
1330
|
+
* them would execute their getters.
|
|
1331
|
+
*/
|
|
1332
|
+
private sizeProbeKeys = new Map<string, readonly string[]>();
|
|
1333
|
+
|
|
1275
1334
|
private isLargeModel(model: Model): boolean {
|
|
1276
1335
|
try {
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1336
|
+
const modelName = model.getModelName();
|
|
1337
|
+
let keys = this.sizeProbeKeys.get(modelName);
|
|
1338
|
+
if (!keys) {
|
|
1339
|
+
const props = this.registry.getProperties(modelName);
|
|
1340
|
+
keys = [...props.entries()]
|
|
1341
|
+
.filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
|
|
1342
|
+
.map(([key]) => key);
|
|
1343
|
+
this.sizeProbeKeys.set(modelName, keys);
|
|
1344
|
+
}
|
|
1345
|
+
// A model with no registered stored fields (e.g. a custom entity) keeps
|
|
1346
|
+
// the whole-instance walk, since its payload lives outside the schema.
|
|
1347
|
+
const values: Iterable<unknown> = keys.length > 0
|
|
1348
|
+
? keys.map((key): unknown => Reflect.get(model, key))
|
|
1349
|
+
: Object.values(model);
|
|
1350
|
+
|
|
1351
|
+
// Most synchronized rows contain only short scalars. Accumulate cheap
|
|
1352
|
+
// scalar sizes and serialize only nested payloads that can plausibly
|
|
1353
|
+
// cross the WeakRef threshold.
|
|
1281
1354
|
let size = 0;
|
|
1282
|
-
for (const value of
|
|
1355
|
+
for (const value of values) {
|
|
1283
1356
|
if (typeof value === 'string') {
|
|
1284
1357
|
size += value.length;
|
|
1285
1358
|
} else if (
|
|
@@ -1375,6 +1448,9 @@ export class InstanceCache {
|
|
|
1375
1448
|
if (!this.matchesScope(entry.scope, ModelScope.live)) { droppedScope++; continue; }
|
|
1376
1449
|
const model = this.resolveModel(entry, id);
|
|
1377
1450
|
if (model && !model.disposed) {
|
|
1451
|
+
// Consumer boundary — activate, as in getByType. `hasMany` reads
|
|
1452
|
+
// (`section.blocks`) observe fields off these rows directly.
|
|
1453
|
+
model.ensureObservable();
|
|
1378
1454
|
result.push(model);
|
|
1379
1455
|
} else if (model?.disposed) {
|
|
1380
1456
|
droppedDisposed++;
|
package/src/local/Model.ts
CHANGED
|
@@ -121,6 +121,17 @@ export abstract class Model {
|
|
|
121
121
|
/** MobX observable properties storage */
|
|
122
122
|
_mobxProperties: ModelData = {};
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
126
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
127
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
128
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
129
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
130
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
131
|
+
* correctness backstop; this flag removes the cost.
|
|
132
|
+
*/
|
|
133
|
+
_isHydrating = false;
|
|
134
|
+
|
|
124
135
|
/** Referenced models cache */
|
|
125
136
|
_referencedModels: Record<string, Model | null> = {};
|
|
126
137
|
|
|
@@ -721,11 +732,14 @@ export abstract class Model {
|
|
|
721
732
|
runInAction(() => {
|
|
722
733
|
const originalTracking = this.modifiedProperties;
|
|
723
734
|
this.modifiedProperties = new Map();
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
735
|
+
this._isHydrating = true;
|
|
736
|
+
try {
|
|
737
|
+
// No `onWrite` → this call records nothing itself.
|
|
738
|
+
this.assignFieldsFromData(data);
|
|
739
|
+
} finally {
|
|
740
|
+
this._isHydrating = false;
|
|
741
|
+
this.modifiedProperties = originalTracking;
|
|
742
|
+
}
|
|
729
743
|
});
|
|
730
744
|
|
|
731
745
|
// Mark as persisted if updating existing model
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -1812,6 +1812,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1812
1812
|
return this.mutationQueue.confirmationFor(modelName, modelId);
|
|
1813
1813
|
}
|
|
1814
1814
|
|
|
1815
|
+
|
|
1815
1816
|
/**
|
|
1816
1817
|
* Get detailed debug info for the sync debug page
|
|
1817
1818
|
*/
|
|
@@ -1950,7 +1951,13 @@ export class SyncClient extends EventEmitter {
|
|
|
1950
1951
|
|
|
1951
1952
|
switch (action) {
|
|
1952
1953
|
case 'add': {
|
|
1953
|
-
|
|
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);
|
|
1954
1961
|
if (existing) {
|
|
1955
1962
|
existing.markAsSynced();
|
|
1956
1963
|
} else if (result.data) {
|
|
@@ -1963,7 +1970,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1963
1970
|
break;
|
|
1964
1971
|
}
|
|
1965
1972
|
case 'update': {
|
|
1966
|
-
const existing = this.objectPool.
|
|
1973
|
+
const existing = this.objectPool.peek(modelId);
|
|
1967
1974
|
if (existing && !existing.disposed && result.data) {
|
|
1968
1975
|
enrichRelations(modelName, result.data);
|
|
1969
1976
|
const resolved = this.resolveConflicts(existing, result.data);
|
|
@@ -53,7 +53,11 @@ export interface DeltaPipelineContext {
|
|
|
53
53
|
batchTimer: ReturnType<typeof setTimeout> | null;
|
|
54
54
|
/** Queue for deltas arriving during an active bootstrap; null when none. */
|
|
55
55
|
readonly bootstrapDeltaQueue: SyncDelta[] | null;
|
|
56
|
-
readonly smartSyncOptions: {
|
|
56
|
+
readonly smartSyncOptions: {
|
|
57
|
+
readonly batchingDelay: number;
|
|
58
|
+
readonly maxBatchSize: number;
|
|
59
|
+
readonly applySliceDeltas: number;
|
|
60
|
+
};
|
|
57
61
|
/** Pool-applied cursor (`syncClient.position.applied`). */
|
|
58
62
|
readonly highestProcessedSyncId: number;
|
|
59
63
|
/** Resume/ack cursor (`syncClient.position.persisted`). */
|
|
@@ -86,7 +90,8 @@ export interface DeltaPipelineContext {
|
|
|
86
90
|
|
|
87
91
|
// ── Custom-entity pool ops (deltas that skip the local store) ──
|
|
88
92
|
readonly objectPool: {
|
|
89
|
-
|
|
93
|
+
/** Ingestion-side lookup — resolves without activating observability. */
|
|
94
|
+
peek(id: string): Model | undefined;
|
|
90
95
|
add(model: Model, scope: ModelScope): void;
|
|
91
96
|
remove(id: string): boolean;
|
|
92
97
|
/** Full in-memory clear — the revocation-failure fallback (see
|
|
@@ -312,6 +317,7 @@ export function enqueueDelta(
|
|
|
312
317
|
// The delta is accepted and queued — the `receive` stage boundary.
|
|
313
318
|
runStage(ctx.stagePlugins ?? [], 'receive', { delta });
|
|
314
319
|
ctx.pendingDeltas.push(delta);
|
|
320
|
+
pipelineDebug.enqueued += 1;
|
|
315
321
|
return true;
|
|
316
322
|
}
|
|
317
323
|
|
|
@@ -400,6 +406,71 @@ async function drainPendingDeltas(ctx: DeltaPipelineContext): Promise<void> {
|
|
|
400
406
|
}
|
|
401
407
|
}
|
|
402
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
|
+
|
|
403
474
|
function yieldToHost(): Promise<void> {
|
|
404
475
|
const immediate = (
|
|
405
476
|
globalThis as {
|
|
@@ -417,9 +488,12 @@ async function flushDeltaBatch(
|
|
|
417
488
|
queuedDeltas: SyncDelta[],
|
|
418
489
|
): Promise<void> {
|
|
419
490
|
openDrainBatchRow(queuedDeltas.length);
|
|
491
|
+
pipelineDebug.flushesStarted += 1;
|
|
420
492
|
try {
|
|
421
493
|
await flushDeltaBatchInner(ctx, queuedDeltas);
|
|
422
494
|
} finally {
|
|
495
|
+
pipelineDebug.flushesSettled += 1;
|
|
496
|
+
pipelineDebug.phase = 'idle';
|
|
423
497
|
closeDrainBatchRow();
|
|
424
498
|
}
|
|
425
499
|
}
|
|
@@ -429,6 +503,7 @@ async function flushDeltaBatchInner(
|
|
|
429
503
|
queuedDeltas: SyncDelta[],
|
|
430
504
|
): Promise<void> {
|
|
431
505
|
const stagePlugins = ctx.stagePlugins ?? [];
|
|
506
|
+
pipelineDebug.phase = 'dedupe';
|
|
432
507
|
const deduplicatedDeltas = timeDrainStage('dedupe', () => ctx.deduplicateDeltas(queuedDeltas));
|
|
433
508
|
observeDrainBatch(queuedDeltas.length, deduplicatedDeltas.length);
|
|
434
509
|
runStage(stagePlugins, 'dedupe', { deltas: deduplicatedDeltas });
|
|
@@ -446,7 +521,7 @@ async function flushDeltaBatchInner(
|
|
|
446
521
|
// gained permission to see the entity, so we insert it into the
|
|
447
522
|
// pool as if newly created.
|
|
448
523
|
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
449
|
-
const existing = ctx.objectPool.
|
|
524
|
+
const existing = ctx.objectPool.peek(delta.modelId);
|
|
450
525
|
if (existing) {
|
|
451
526
|
existing.updateFromData(data);
|
|
452
527
|
} else {
|
|
@@ -465,6 +540,8 @@ async function flushDeltaBatchInner(
|
|
|
465
540
|
// handleGroupRemoved) and never reach here, though the persistence
|
|
466
541
|
// signature accepts them defensively.
|
|
467
542
|
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
543
|
+
pipelineDebug.phase = 'persist';
|
|
544
|
+
pipelineDebug.persistsStarted += 1;
|
|
468
545
|
const batch = await timeDrainStageAsync('persist', () =>
|
|
469
546
|
ctx.processDeltaBatch(
|
|
470
547
|
regularDeltas.map((d) => ({
|
|
@@ -479,6 +556,7 @@ async function flushDeltaBatchInner(
|
|
|
479
556
|
}))
|
|
480
557
|
)
|
|
481
558
|
);
|
|
559
|
+
pipelineDebug.persistsSettled += 1;
|
|
482
560
|
const dbResults = batch.results;
|
|
483
561
|
runStage(stagePlugins, 'persist', { deltas: regularDeltas });
|
|
484
562
|
|
|
@@ -487,13 +565,41 @@ async function flushDeltaBatchInner(
|
|
|
487
565
|
// materialiser attached where it said it would. The direct call is the
|
|
488
566
|
// bridge for stores constructed without plugins (subclasses, tests),
|
|
489
567
|
// whose own apply is the whole pipeline.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
600
|
}
|
|
496
601
|
});
|
|
602
|
+
pipelineDebug.phase = 'acknowledge';
|
|
497
603
|
|
|
498
604
|
// Acknowledge and advance the sync cursor, gated on persistence.
|
|
499
605
|
//
|
|
@@ -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,
|