@statezero/core 0.2.9 → 0.2.10
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.
|
@@ -16,3 +16,29 @@ export function ModelAdaptor(modelInstance: Object, reactivityFn?: Function): an
|
|
|
16
16
|
*/
|
|
17
17
|
export function QuerySetAdaptor(liveQuerySet: Object, reactivityFn?: Function): any | import("vue").Ref;
|
|
18
18
|
export function MetricAdaptor(metric: any): any;
|
|
19
|
+
export const modelEventBatcher: ModelEventBatcher;
|
|
20
|
+
export const BATCH_THRESHOLD: 50;
|
|
21
|
+
/**
|
|
22
|
+
* Batches model events and decides whether to use fine-grained (touch) or
|
|
23
|
+
* coarse-grained (queryset refresh) reactivity based on batch size.
|
|
24
|
+
*/
|
|
25
|
+
declare class ModelEventBatcher {
|
|
26
|
+
pendingTouches: Map<any, any>;
|
|
27
|
+
debounceTimers: Map<any, any>;
|
|
28
|
+
maxWaitTimers: Map<any, any>;
|
|
29
|
+
processQueue: PQueue<import("p-queue/dist/priority-queue.js").default, import("p-queue").QueueAddOptions>;
|
|
30
|
+
/**
|
|
31
|
+
* Queue a touch call for batching
|
|
32
|
+
*/
|
|
33
|
+
queueTouch(configKey: any, modelName: any, pk: any, wrapper: any): void;
|
|
34
|
+
_resetDebounce(modelKey: any): void;
|
|
35
|
+
_ensureMaxWait(modelKey: any): void;
|
|
36
|
+
_clearTimers(modelKey: any): void;
|
|
37
|
+
_flush(modelKey: any): void;
|
|
38
|
+
_processBatch(modelKey: any, touchBatch: any): void;
|
|
39
|
+
_touchModels(touchBatch: any): void;
|
|
40
|
+
_refreshQuerysets(modelKey: any): void;
|
|
41
|
+
clear(): void;
|
|
42
|
+
}
|
|
43
|
+
import PQueue from "p-queue";
|
|
44
|
+
export {};
|
|
@@ -4,9 +4,154 @@ import { initEventHandler } from "../../syncEngine/stores/operationEventHandlers
|
|
|
4
4
|
import { isEqual, isNil } from 'lodash-es';
|
|
5
5
|
import hash from 'object-hash';
|
|
6
6
|
import { registerAdapterReset } from "../../reset.js";
|
|
7
|
+
import PQueue from "p-queue";
|
|
7
8
|
initEventHandler();
|
|
8
9
|
const wrappedQuerysetCache = new Map();
|
|
9
10
|
const wrappedMetricCache = new Map();
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Model Event Batcher - Batches rapid model events to prevent watcher overload
|
|
13
|
+
// =============================================================================
|
|
14
|
+
const BATCH_THRESHOLD = 50; // Above this, use queryset refresh instead of individual touch
|
|
15
|
+
const DEBOUNCE_MS = 16; // ~1 frame, gather rapid events
|
|
16
|
+
const MAX_WAIT_MS = 100; // Don't wait longer than this to flush
|
|
17
|
+
/**
|
|
18
|
+
* Batches model events and decides whether to use fine-grained (touch) or
|
|
19
|
+
* coarse-grained (queryset refresh) reactivity based on batch size.
|
|
20
|
+
*/
|
|
21
|
+
class ModelEventBatcher {
|
|
22
|
+
constructor() {
|
|
23
|
+
// Queue of pending touch calls: Map<modelKey, Map<pk, Set<wrapper>>>
|
|
24
|
+
this.pendingTouches = new Map();
|
|
25
|
+
// Debounce timers per model
|
|
26
|
+
this.debounceTimers = new Map();
|
|
27
|
+
// Max wait timers per model
|
|
28
|
+
this.maxWaitTimers = new Map();
|
|
29
|
+
// Rate-limited queue for processing
|
|
30
|
+
this.processQueue = new PQueue({
|
|
31
|
+
concurrency: 1,
|
|
32
|
+
interval: 16, // ~1 frame
|
|
33
|
+
intervalCap: 10 // max 10 flushes per frame
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Queue a touch call for batching
|
|
38
|
+
*/
|
|
39
|
+
queueTouch(configKey, modelName, pk, wrapper) {
|
|
40
|
+
const modelKey = `${configKey}::${modelName}`;
|
|
41
|
+
// Initialize maps if needed
|
|
42
|
+
if (!this.pendingTouches.has(modelKey)) {
|
|
43
|
+
this.pendingTouches.set(modelKey, new Map());
|
|
44
|
+
}
|
|
45
|
+
const pkMap = this.pendingTouches.get(modelKey);
|
|
46
|
+
if (!pkMap.has(pk)) {
|
|
47
|
+
pkMap.set(pk, new Set());
|
|
48
|
+
}
|
|
49
|
+
pkMap.get(pk).add(wrapper);
|
|
50
|
+
// Reset debounce timer
|
|
51
|
+
this._resetDebounce(modelKey);
|
|
52
|
+
// Start max wait timer if not already running
|
|
53
|
+
this._ensureMaxWait(modelKey);
|
|
54
|
+
}
|
|
55
|
+
_resetDebounce(modelKey) {
|
|
56
|
+
// Clear existing debounce
|
|
57
|
+
if (this.debounceTimers.has(modelKey)) {
|
|
58
|
+
clearTimeout(this.debounceTimers.get(modelKey));
|
|
59
|
+
}
|
|
60
|
+
// Set new debounce
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
this._flush(modelKey);
|
|
63
|
+
}, DEBOUNCE_MS);
|
|
64
|
+
this.debounceTimers.set(modelKey, timer);
|
|
65
|
+
}
|
|
66
|
+
_ensureMaxWait(modelKey) {
|
|
67
|
+
if (this.maxWaitTimers.has(modelKey)) {
|
|
68
|
+
return; // Already waiting
|
|
69
|
+
}
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
this._flush(modelKey);
|
|
72
|
+
}, MAX_WAIT_MS);
|
|
73
|
+
this.maxWaitTimers.set(modelKey, timer);
|
|
74
|
+
}
|
|
75
|
+
_clearTimers(modelKey) {
|
|
76
|
+
if (this.debounceTimers.has(modelKey)) {
|
|
77
|
+
clearTimeout(this.debounceTimers.get(modelKey));
|
|
78
|
+
this.debounceTimers.delete(modelKey);
|
|
79
|
+
}
|
|
80
|
+
if (this.maxWaitTimers.has(modelKey)) {
|
|
81
|
+
clearTimeout(this.maxWaitTimers.get(modelKey));
|
|
82
|
+
this.maxWaitTimers.delete(modelKey);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
_flush(modelKey) {
|
|
86
|
+
this._clearTimers(modelKey);
|
|
87
|
+
const pkMap = this.pendingTouches.get(modelKey);
|
|
88
|
+
if (!pkMap || pkMap.size === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Take the pending touches and clear
|
|
92
|
+
const touchBatch = new Map(pkMap);
|
|
93
|
+
pkMap.clear();
|
|
94
|
+
// Queue for rate-limited processing
|
|
95
|
+
this.processQueue.add(() => this._processBatch(modelKey, touchBatch));
|
|
96
|
+
}
|
|
97
|
+
_processBatch(modelKey, touchBatch) {
|
|
98
|
+
const batchSize = touchBatch.size;
|
|
99
|
+
if (batchSize < BATCH_THRESHOLD) {
|
|
100
|
+
// Small batch: fine-grained touch for each model
|
|
101
|
+
this._touchModels(touchBatch);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// Large batch: refresh all querysets for this model instead
|
|
105
|
+
this._refreshQuerysets(modelKey);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
_touchModels(touchBatch) {
|
|
109
|
+
// Call touch() on each wrapper
|
|
110
|
+
for (const [pk, wrappers] of touchBatch) {
|
|
111
|
+
for (const wrapper of wrappers) {
|
|
112
|
+
try {
|
|
113
|
+
wrapper.touch();
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
console.warn('[ModelEventBatcher] Error touching model:', e);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
_refreshQuerysets(modelKey) {
|
|
122
|
+
// Find all cached querysets for this model and refresh them
|
|
123
|
+
// modelKey is "configKey::modelName"
|
|
124
|
+
const [configKey, modelName] = modelKey.split('::');
|
|
125
|
+
for (const [semanticKey, wrapper] of wrappedQuerysetCache) {
|
|
126
|
+
const liveQuerySet = wrapper.original;
|
|
127
|
+
if (!liveQuerySet)
|
|
128
|
+
continue;
|
|
129
|
+
const qs = liveQuerySet.queryset;
|
|
130
|
+
if (qs?.ModelClass?.configKey === configKey &&
|
|
131
|
+
qs?.ModelClass?.modelName === modelName) {
|
|
132
|
+
// Refresh this queryset's reactive wrapper
|
|
133
|
+
try {
|
|
134
|
+
wrapper.splice(0, wrapper.length);
|
|
135
|
+
wrapper.push(...liveQuerySet);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
console.warn('[ModelEventBatcher] Error refreshing queryset:', e);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
clear() {
|
|
144
|
+
// Clear all pending state
|
|
145
|
+
for (const modelKey of this.pendingTouches.keys()) {
|
|
146
|
+
this._clearTimers(modelKey);
|
|
147
|
+
}
|
|
148
|
+
this.pendingTouches.clear();
|
|
149
|
+
this.processQueue.clear();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const modelEventBatcher = new ModelEventBatcher();
|
|
153
|
+
// Export for testing
|
|
154
|
+
export { modelEventBatcher, BATCH_THRESHOLD };
|
|
10
155
|
/**
|
|
11
156
|
* Adapts a model instance to a Vue reactive object by directly wrapping
|
|
12
157
|
* the instance and incrementing an internal version on relevant events.
|
|
@@ -23,17 +168,15 @@ export function ModelAdaptor(modelInstance, reactivityFn = reactive) {
|
|
|
23
168
|
// Make the model instance reactive using the specified function
|
|
24
169
|
const wrapper = reactivityFn(modelInstance);
|
|
25
170
|
const eventName = `${configKey}::${modelName}::render`;
|
|
26
|
-
// Handler
|
|
171
|
+
// Handler queues touch calls through the batcher for optimal batching
|
|
27
172
|
const renderHandler = (eventData) => {
|
|
28
173
|
const isRef = reactivityFn === ref;
|
|
29
174
|
const model = isRef ? wrapper.value : wrapper;
|
|
30
175
|
if (eventData.pk === model[pkField]) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
wrapper.touch();
|
|
36
|
-
}
|
|
176
|
+
// Queue the touch through the batcher instead of calling directly
|
|
177
|
+
// This allows batching of rapid events and smart routing to queryset refresh for bulk ops
|
|
178
|
+
const targetWrapper = isRef ? wrapper.value : wrapper;
|
|
179
|
+
modelEventBatcher.queueTouch(configKey, modelName, eventData.pk, targetWrapper);
|
|
37
180
|
}
|
|
38
181
|
};
|
|
39
182
|
// Subscribe to model events indefinitely
|
|
@@ -127,4 +270,5 @@ export function MetricAdaptor(metric) {
|
|
|
127
270
|
registerAdapterReset(() => {
|
|
128
271
|
wrappedQuerysetCache.clear();
|
|
129
272
|
wrappedMetricCache.clear();
|
|
273
|
+
modelEventBatcher.clear();
|
|
130
274
|
});
|
package/package.json
CHANGED