@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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the drain's seconds go.
|
|
3
|
+
*
|
|
4
|
+
* A commit's receipt is confirmed the moment its PostgreSQL transaction
|
|
5
|
+
* commits, but an observer is not caught up until it has applied the last
|
|
6
|
+
* delta. Server publication p95 is single-digit milliseconds while the final
|
|
7
|
+
* observer takes seconds, so the gap is client-side and has never been
|
|
8
|
+
* attributed to a stage. Two fixes aimed at wire bytes (project filtering,
|
|
9
|
+
* patch-only UPDATE delivery) each returned well under a third, which is
|
|
10
|
+
* evidence the dominant term is a per-delta or per-batch fixed cost rather
|
|
11
|
+
* than payload size.
|
|
12
|
+
*
|
|
13
|
+
* This times the stages a delta actually passes through so a benchmark run can
|
|
14
|
+
* state the attribution instead of inferring it. It is off unless
|
|
15
|
+
* `ABLO_PROFILE_DRAIN=true`, and every entry point returns before doing work
|
|
16
|
+
* when off, mirroring the server's commit profiler.
|
|
17
|
+
*
|
|
18
|
+
* The stage vocabulary derives from {@link PipelineStage}; `parse` is the one
|
|
19
|
+
* addition, because wire validation happens in the transport before a delta
|
|
20
|
+
* reaches the pipeline at all.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PipelineStage } from '../../plugin.js';
|
|
24
|
+
|
|
25
|
+
/** The pipeline's own stages plus the transport-level wire validation ahead of them. */
|
|
26
|
+
export type DrainStage = 'parse' | PipelineStage;
|
|
27
|
+
|
|
28
|
+
export interface DrainStageTotals {
|
|
29
|
+
/** Accumulated wall time attributed to this stage. */
|
|
30
|
+
readonly totalMs: number;
|
|
31
|
+
/** How many times the stage ran. Per-delta for `parse`, per-batch for the rest. */
|
|
32
|
+
readonly calls: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
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
|
+
|
|
48
|
+
export interface DrainProfile {
|
|
49
|
+
/** Flush batches drained. The per-batch fixed cost multiplies by this. */
|
|
50
|
+
readonly batches: number;
|
|
51
|
+
/** Deltas that reached the pipeline. The per-delta fixed cost multiplies by this. */
|
|
52
|
+
readonly deltas: number;
|
|
53
|
+
/** Deltas dropped by the dedupe stage before persistence. */
|
|
54
|
+
readonly deduplicated: number;
|
|
55
|
+
/** Wall time from the first observed stage to the last. */
|
|
56
|
+
readonly spanMs: number;
|
|
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[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DRAIN_STAGES: readonly DrainStage[] = [
|
|
67
|
+
'parse',
|
|
68
|
+
'receive',
|
|
69
|
+
'dedupe',
|
|
70
|
+
'persist',
|
|
71
|
+
'apply',
|
|
72
|
+
'acknowledge',
|
|
73
|
+
'notify',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
interface MutableTotals {
|
|
77
|
+
totalMs: number;
|
|
78
|
+
calls: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emptyTotals(): Record<DrainStage, MutableTotals> {
|
|
82
|
+
const totals = {} as Record<DrainStage, MutableTotals>;
|
|
83
|
+
for (const stage of DRAIN_STAGES) totals[stage] = { totalMs: 0, calls: 0 };
|
|
84
|
+
return totals;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let totals = emptyTotals();
|
|
88
|
+
let batches = 0;
|
|
89
|
+
let deltas = 0;
|
|
90
|
+
let deduplicated = 0;
|
|
91
|
+
let firstMark: number | undefined;
|
|
92
|
+
let lastMark = 0;
|
|
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
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Read once. A profiler that consults the environment on every delta would
|
|
163
|
+
* itself become a per-delta cost in the path it is measuring.
|
|
164
|
+
*/
|
|
165
|
+
const enabled: boolean = (() => {
|
|
166
|
+
const host = globalThis as { process?: { env?: Record<string, string | undefined> } };
|
|
167
|
+
return host.process?.env?.ABLO_PROFILE_DRAIN === 'true';
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
/** Whether drain profiling is on. Callers skip their own bookkeeping when it is not. */
|
|
171
|
+
export function drainProfilingEnabled(): boolean {
|
|
172
|
+
return enabled;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function mark(elapsedMs: number): void {
|
|
176
|
+
const now = performance.now();
|
|
177
|
+
firstMark ??= now - elapsedMs;
|
|
178
|
+
lastMark = now;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Attribute already-measured wall time to a stage. */
|
|
182
|
+
export function observeDrainStage(stage: DrainStage, elapsedMs: number): void {
|
|
183
|
+
if (!enabled) return;
|
|
184
|
+
const entry = totals[stage];
|
|
185
|
+
entry.totalMs += elapsedMs;
|
|
186
|
+
entry.calls += 1;
|
|
187
|
+
if (currentRow !== null) {
|
|
188
|
+
currentRow.stages[stage] = (currentRow.stages[stage] ?? 0) + elapsedMs;
|
|
189
|
+
}
|
|
190
|
+
mark(elapsedMs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Time a synchronous stage. Returns the callback's value untouched. */
|
|
194
|
+
export function timeDrainStage<T>(stage: DrainStage, run: () => T): T {
|
|
195
|
+
if (!enabled) return run();
|
|
196
|
+
const startedAt = performance.now();
|
|
197
|
+
try {
|
|
198
|
+
return run();
|
|
199
|
+
} finally {
|
|
200
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Time an asynchronous stage. Returns the callback's value untouched. */
|
|
205
|
+
export async function timeDrainStageAsync<T>(
|
|
206
|
+
stage: DrainStage,
|
|
207
|
+
run: () => Promise<T>,
|
|
208
|
+
): Promise<T> {
|
|
209
|
+
if (!enabled) return run();
|
|
210
|
+
const startedAt = performance.now();
|
|
211
|
+
try {
|
|
212
|
+
return await run();
|
|
213
|
+
} finally {
|
|
214
|
+
observeDrainStage(stage, performance.now() - startedAt);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Record one drained batch: how many deltas entered it and how many survived
|
|
220
|
+
* deduplication. Batch count is the multiplier on every per-batch cost, so it
|
|
221
|
+
* is reported alongside the timings rather than derived from them.
|
|
222
|
+
*/
|
|
223
|
+
export function observeDrainBatch(received: number, survived: number): void {
|
|
224
|
+
if (!enabled) return;
|
|
225
|
+
batches += 1;
|
|
226
|
+
deltas += received;
|
|
227
|
+
deduplicated += Math.max(0, received - survived);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The totals accumulated since the last reset. */
|
|
231
|
+
export function drainProfileSnapshot(): DrainProfile {
|
|
232
|
+
const stages = {} as Record<DrainStage, DrainStageTotals>;
|
|
233
|
+
for (const stage of DRAIN_STAGES) {
|
|
234
|
+
stages[stage] = { totalMs: totals[stage].totalMs, calls: totals[stage].calls };
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
batches,
|
|
238
|
+
deltas,
|
|
239
|
+
deduplicated,
|
|
240
|
+
spanMs: firstMark === undefined ? 0 : lastMark - firstMark,
|
|
241
|
+
stages,
|
|
242
|
+
recentBatches: [...batchRows],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Clear the totals so a phase measures only its own traffic. */
|
|
247
|
+
export function resetDrainProfile(): void {
|
|
248
|
+
totals = emptyTotals();
|
|
249
|
+
batches = 0;
|
|
250
|
+
deltas = 0;
|
|
251
|
+
deduplicated = 0;
|
|
252
|
+
firstMark = undefined;
|
|
253
|
+
lastMark = 0;
|
|
254
|
+
batchRows = [];
|
|
255
|
+
currentRow = null;
|
|
256
|
+
ackStamps = [];
|
|
257
|
+
}
|
|
@@ -54,8 +54,8 @@ export function* initialize<TCollaboration extends EventMap<TCollaboration>>(
|
|
|
54
54
|
organizationId: context.organizationId,
|
|
55
55
|
participantKind: context.kind ?? 'user',
|
|
56
56
|
projectId: context.projectId ?? context.organizationId,
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
branchId: context.branchId,
|
|
58
|
+
branchRoot: context.branchRoot ?? false,
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
// Propagate identity only after storage is ready, then restore sealed
|
|
@@ -1222,17 +1222,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
1222
1222
|
* transaction.
|
|
1223
1223
|
*/
|
|
1224
1224
|
confirmationFor(modelName: string, modelId: string): Promise<void> {
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1225
|
+
const transactions = this.store.getAll();
|
|
1226
|
+
for (let index = transactions.length - 1; index >= 0; index--) {
|
|
1227
|
+
const transaction = transactions[index];
|
|
1228
|
+
if (
|
|
1229
|
+
transaction?.modelName === modelName &&
|
|
1230
|
+
transaction.modelId === modelId &&
|
|
1231
|
+
(transaction.status === 'pending' ||
|
|
1232
|
+
transaction.status === 'executing' ||
|
|
1233
|
+
transaction.status === 'awaiting_delta')
|
|
1234
|
+
) {
|
|
1235
|
+
return transaction.confirmation ?? Promise.resolve();
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return Promise.resolve();
|
|
1236
1239
|
}
|
|
1237
1240
|
|
|
1238
1241
|
/**
|
|
@@ -1425,7 +1428,23 @@ export class MutationQueue extends EventEmitter {
|
|
|
1425
1428
|
}
|
|
1426
1429
|
|
|
1427
1430
|
private async drainPendingInternal(): Promise<void> {
|
|
1428
|
-
|
|
1431
|
+
// The normal batch scheduler and the explicit/reconnect drain are two
|
|
1432
|
+
// ways to drive the same durable queue. They must never seal the same
|
|
1433
|
+
// staged source records concurrently: the first seal consumes those
|
|
1434
|
+
// records, so the second would correctly reject them as already claimed.
|
|
1435
|
+
//
|
|
1436
|
+
// `isProcessing` is acquired synchronously before either path awaits,
|
|
1437
|
+
// making it the queue-wide execution lock. If the normal lane already
|
|
1438
|
+
// owns it, that lane will finish the pending work; callers waiting on a
|
|
1439
|
+
// specific confirmation remain attached to the exact transaction.
|
|
1440
|
+
if (this.isProcessing) return;
|
|
1441
|
+
this.isProcessing = true;
|
|
1442
|
+
try {
|
|
1443
|
+
await drainPendingSettlements(this.pendingDrainContext);
|
|
1444
|
+
} finally {
|
|
1445
|
+
this.isProcessing = false;
|
|
1446
|
+
if (this.executionQueue.length > 0) this.scheduleProcessing(true);
|
|
1447
|
+
}
|
|
1429
1448
|
}
|
|
1430
1449
|
async create(
|
|
1431
1450
|
model: LocalModel,
|
|
@@ -10,7 +10,7 @@ export interface PendingDrainContext {
|
|
|
10
10
|
readonly runtime: RuntimeContext;
|
|
11
11
|
readonly config: { deltaConfirmationTimeout: number };
|
|
12
12
|
readonly store: MutationStore;
|
|
13
|
-
executionQueue: QueuedMutation[];
|
|
13
|
+
readonly executionQueue: QueuedMutation[];
|
|
14
14
|
readonly optimisticUpdates: Map<string, OptimisticUpdateEntry>;
|
|
15
15
|
readonly assertDurableReplayOpen: () => void;
|
|
16
16
|
readonly processCommitLane: () => Promise<void>;
|
|
@@ -45,9 +45,14 @@ export async function drainPendingSettlements(ctx: PendingDrainContext): Promise
|
|
|
45
45
|
// These rows may already be waiting behind the normal batch timer. The
|
|
46
46
|
// reconnect fast path takes ownership of them for this attempt so the same
|
|
47
47
|
// transaction cannot dispatch concurrently through both paths.
|
|
48
|
-
|
|
48
|
+
const retainedQueue = ctx.executionQueue.filter(
|
|
49
49
|
(tx) => !pendingIds.has(tx.id),
|
|
50
50
|
);
|
|
51
|
+
ctx.executionQueue.splice(
|
|
52
|
+
0,
|
|
53
|
+
ctx.executionQueue.length,
|
|
54
|
+
...retainedQueue,
|
|
55
|
+
);
|
|
51
56
|
|
|
52
57
|
const remaining = [...pending];
|
|
53
58
|
while (remaining.length > 0) {
|