@memberjunction/core 6.2.0-edge.3 → 6.2.0-edge.5
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/generic/BaseEntitySaveQueue.d.ts +7 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +22 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +33 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +44 -0
- package/dist/generic/RegisterForStartup.d.ts.map +1 -1
- package/dist/generic/RegisterForStartup.js +124 -48
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +222 -7
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +555 -37
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineSweeper.d.ts +35 -0
- package/dist/generic/baseEngineSweeper.d.ts.map +1 -0
- package/dist/generic/baseEngineSweeper.js +97 -0
- package/dist/generic/baseEngineSweeper.js.map +1 -0
- package/dist/generic/baseEntity.d.ts +45 -1
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +308 -187
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +57 -19
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +203 -80
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityEventBatch.d.ts +124 -0
- package/dist/generic/entityEventBatch.d.ts.map +1 -0
- package/dist/generic/entityEventBatch.js +194 -0
- package/dist/generic/entityEventBatch.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +103 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +376 -60
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +143 -3
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +505 -56
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +1185 -175
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +15 -0
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +17 -0
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +1 -1
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/providerBase.d.ts +301 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +750 -168
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +10 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +60 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +4 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +27 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +58 -5
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
|
|
2
2
|
import { IsMaterializedDataSource } from "../views/runView.js";
|
|
3
|
-
import { LogError, LogStatusEx } from "./logging.js";
|
|
3
|
+
import { LogError, LogStatusEx, LogWarning } from "./logging.js";
|
|
4
4
|
import { BaseEntity } from "./baseEntity.js";
|
|
5
5
|
import { Metadata } from "./metadata.js";
|
|
6
6
|
import { CompositeKey, KeyValuePair } from "./compositeKey.js";
|
|
7
|
+
import { EntityEventBatchSet } from "./entityEventBatch.js";
|
|
7
8
|
/** Verbose-only status logging — hidden unless verbose logging is enabled */
|
|
8
9
|
function LogStatusVerbose(message) {
|
|
9
10
|
LogStatusEx({ message, verboseOnly: true });
|
|
@@ -55,13 +56,35 @@ function deepFreezeCacheValue(value, visited = new WeakSet()) {
|
|
|
55
56
|
// ============================================================================
|
|
56
57
|
// DEFAULT CONFIGURATION
|
|
57
58
|
// ============================================================================
|
|
59
|
+
/**
|
|
60
|
+
* Minimum percentage of the cache budget that eviction frees in one pass, so a run of
|
|
61
|
+
* stores near the budget does not trigger an eviction each time. Also the floor under the
|
|
62
|
+
* space any single entry must leave behind it — see {@link HARD_MAX_ENTRY_PERCENT}.
|
|
63
|
+
*/
|
|
64
|
+
const EVICTION_THRASH_FLOOR_PERCENT = 10;
|
|
65
|
+
/**
|
|
66
|
+
* Percentage of the budget held back from any one entry when the per-entry ceiling is
|
|
67
|
+
* derived (`maxEntryPercentOfCache: 'auto'`). The reserve is what keeps the rest of the
|
|
68
|
+
* cache alive alongside one large entry: with it, a store that arrives after the large
|
|
69
|
+
* entry can be satisfied by evicting entries out of the reserve rather than the large
|
|
70
|
+
* entry itself. It MUST be at least {@link EVICTION_THRASH_FLOOR_PERCENT}, or a single
|
|
71
|
+
* eviction pass could not be satisfied without evicting the large entry that eviction
|
|
72
|
+
* was making room around.
|
|
73
|
+
*/
|
|
74
|
+
const SINGLE_ENTRY_RESERVE_PERCENT = 25;
|
|
75
|
+
/**
|
|
76
|
+
* Hard upper bound on the per-entry ceiling, whatever the configuration says. One entry
|
|
77
|
+
* may never occupy so much of the budget that less than one eviction pass' worth of space
|
|
78
|
+
* is left — that is the state in which every store wipes everything else.
|
|
79
|
+
*/
|
|
80
|
+
const HARD_MAX_ENTRY_PERCENT = 100 - EVICTION_THRASH_FLOOR_PERCENT;
|
|
58
81
|
const DEFAULT_CONFIG = {
|
|
59
82
|
enabled: true,
|
|
60
83
|
maxSizeBytes: 150 * 1024 * 1024, // 150MB
|
|
61
84
|
defaultTTLMs: 0, // No TTL — event-based invalidation is the primary mechanism
|
|
62
85
|
evictionPolicy: 'lru',
|
|
63
86
|
maxPercentOfCachePerEntity: 50,
|
|
64
|
-
maxEntryPercentOfCache:
|
|
87
|
+
maxEntryPercentOfCache: 'auto',
|
|
65
88
|
evictionSweepIntervalMs: 300000, // 5 minutes
|
|
66
89
|
verboseLogging: false,
|
|
67
90
|
};
|
|
@@ -118,6 +141,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
118
141
|
static get Instance() {
|
|
119
142
|
return super.getInstance();
|
|
120
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* How often, at most, a process writes an entity's shared slots while it keeps finding that
|
|
146
|
+
* entity's slots under a schema different from its own. See {@link handleSchemaMismatch}.
|
|
147
|
+
*/
|
|
148
|
+
static { this.SCHEMA_MISMATCH_WRITE_INTERVAL_MS = 60_000; }
|
|
121
149
|
constructor() {
|
|
122
150
|
super();
|
|
123
151
|
this._storageProvider = null;
|
|
@@ -139,6 +167,20 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
139
167
|
* (e.g., TransactionGroup batch deletes).
|
|
140
168
|
*/
|
|
141
169
|
this._fingerprintLocks = new Map();
|
|
170
|
+
/** Logged once when a local TTL is configured against a shared store. See {@link runEvictionSweep}. */
|
|
171
|
+
this._sharedTTLWarned = false;
|
|
172
|
+
/** Open entity-event batches. See {@link BeginEntityEventBatch}. */
|
|
173
|
+
this._entityEventBatches = new EntityEventBatchSet();
|
|
174
|
+
/** Save/delete events a batch took when they were raised. See {@link WasTakenByEntityEventBatch}. */
|
|
175
|
+
this._eventsTakenByBatch = new WeakSet();
|
|
176
|
+
/**
|
|
177
|
+
* Write holds on entities whose shared slots were found under a different schema, keyed by
|
|
178
|
+
* lowercased entity name. While a hold applies, {@link SetRunViewResult} skips or rate-limits
|
|
179
|
+
* writes of that entity's slots. See {@link handleSchemaMismatch} and {@link mayWriteUnderSchemaHold}.
|
|
180
|
+
*/
|
|
181
|
+
this._schemaWriteHolds = new Map();
|
|
182
|
+
/** Schema mismatches already reported, so each entity/hash pair is logged once per process. */
|
|
183
|
+
this._reportedSchemaMismatches = new Set();
|
|
142
184
|
this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
|
|
143
185
|
/**
|
|
144
186
|
* Reverse index from entity name to the set of RunView cache fingerprints
|
|
@@ -146,6 +188,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
146
188
|
* event fires so we can update all relevant cached results.
|
|
147
189
|
*/
|
|
148
190
|
this._entityFingerprintIndex = new Map();
|
|
191
|
+
this._initializedListeners = [];
|
|
149
192
|
// ========================================================================
|
|
150
193
|
// CROSS-SERVER CACHE CHANGE CALLBACKS
|
|
151
194
|
// ========================================================================
|
|
@@ -168,22 +211,29 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
168
211
|
* Initialize the cache manager with a storage provider.
|
|
169
212
|
* This should be called during app startup after the storage provider is available.
|
|
170
213
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
214
|
+
* Safe to call repeatedly: the cache is initialized once, but **a `config` passed by a later
|
|
215
|
+
* caller is still applied**. That matters because a database provider initializes this manager
|
|
216
|
+
* from inside its own `Config()` — before the host has a chance to pass anything — so a host
|
|
217
|
+
* that hands its settings to `StartupManager` would otherwise have them silently dropped, and
|
|
218
|
+
* every `cacheSettings` knob would be inert. The host's explicit settings win
|
|
219
|
+
* over the provider's implicit initialization, whichever runs first.
|
|
173
220
|
*
|
|
174
221
|
* @param storageProvider - The local storage provider to use for persistence
|
|
175
|
-
* @param config - Optional configuration overrides
|
|
222
|
+
* @param config - Optional configuration overrides, applied even when already initialized
|
|
176
223
|
* @returns A promise that resolves when initialization is complete
|
|
177
224
|
*/
|
|
178
225
|
Initialize(storageProvider, config) {
|
|
179
|
-
// If already initialized, return immediately
|
|
180
226
|
if (this._initialized) {
|
|
227
|
+
if (config) {
|
|
228
|
+
this.UpdateConfig(config);
|
|
229
|
+
}
|
|
181
230
|
return Promise.resolve();
|
|
182
231
|
}
|
|
183
|
-
//
|
|
184
|
-
// so all callers await the same initialization
|
|
232
|
+
// Initialization in flight: share it, then apply this caller's settings on top.
|
|
185
233
|
if (this._initializePromise) {
|
|
186
|
-
return
|
|
234
|
+
return config
|
|
235
|
+
? this._initializePromise.then(() => { this.UpdateConfig(config); })
|
|
236
|
+
: this._initializePromise;
|
|
187
237
|
}
|
|
188
238
|
// First caller - start initialization and store the promise
|
|
189
239
|
this._initializePromise = this.doInitialize(storageProvider, config);
|
|
@@ -254,6 +304,36 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
254
304
|
// Subscribe to BaseEntity events for universal cache invalidation.
|
|
255
305
|
// When any entity is saved/deleted, update all cached RunView results for that entity.
|
|
256
306
|
this.subscribeToBaseEntityEvents();
|
|
307
|
+
this.notifyInitialized();
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Runs `listener` once the cache manager is initialized — immediately if it already is.
|
|
311
|
+
* Lets code that ran too early (an engine loaded before startup initialized the cache) finish
|
|
312
|
+
* its cache wiring later instead of never.
|
|
313
|
+
*
|
|
314
|
+
* @param listener - Called once; errors are logged and do not affect other listeners
|
|
315
|
+
*/
|
|
316
|
+
WhenInitialized(listener) {
|
|
317
|
+
if (this._initialized) {
|
|
318
|
+
this.runInitializedListener(listener);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this._initializedListeners.push(listener);
|
|
322
|
+
}
|
|
323
|
+
runInitializedListener(listener) {
|
|
324
|
+
try {
|
|
325
|
+
listener();
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
LogError(`LocalCacheManager.WhenInitialized listener failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
notifyInitialized() {
|
|
332
|
+
const listeners = this._initializedListeners;
|
|
333
|
+
this._initializedListeners = [];
|
|
334
|
+
for (const listener of listeners) {
|
|
335
|
+
this.runInitializedListener(listener);
|
|
336
|
+
}
|
|
257
337
|
}
|
|
258
338
|
/**
|
|
259
339
|
* Returns whether the cache manager has been initialized
|
|
@@ -268,10 +348,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
268
348
|
return { ...this._config };
|
|
269
349
|
}
|
|
270
350
|
/**
|
|
271
|
-
* Updates the configuration at runtime
|
|
351
|
+
* Updates the configuration at runtime, and re-arms the eviction sweep so a changed interval
|
|
352
|
+
* (or a disabled cache) takes effect immediately instead of at the next restart.
|
|
272
353
|
*/
|
|
273
354
|
UpdateConfig(config) {
|
|
355
|
+
const previousInterval = this._config.evictionSweepIntervalMs;
|
|
356
|
+
const wasEnabled = this._config.enabled;
|
|
274
357
|
this._config = { ...this._config, ...config };
|
|
358
|
+
if (!this._initialized) {
|
|
359
|
+
return; // doInitialize starts the sweep with the merged config
|
|
360
|
+
}
|
|
361
|
+
if (this._config.evictionSweepIntervalMs !== previousInterval || this._config.enabled !== wasEnabled) {
|
|
362
|
+
if (this._config.enabled) {
|
|
363
|
+
this.startEvictionSweep();
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
this.stopEvictionSweep();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
275
369
|
}
|
|
276
370
|
/**
|
|
277
371
|
* Checks whether caching is enabled for a given entity. Returns the entity's
|
|
@@ -294,9 +388,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
294
388
|
* @param newProvider - The new storage provider to use
|
|
295
389
|
*/
|
|
296
390
|
async SetStorageProvider(newProvider) {
|
|
391
|
+
if (newProvider === this._storageProvider) {
|
|
392
|
+
// Already in use (a host that installed its shared store before startup): migrating
|
|
393
|
+
// every entry onto itself would rewrite — and republish — all of them.
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
297
396
|
// The freeze decision belongs to the ACTIVE provider, not to whichever one happened to be
|
|
298
|
-
// installed at Initialize.
|
|
299
|
-
//
|
|
397
|
+
// installed at Initialize. A host may still swap stores after initialization (and MJAPI did,
|
|
398
|
+
// before it began installing Redis ahead of metadata), and the two have OPPOSITE reference
|
|
399
|
+
// semantics on
|
|
300
400
|
// every Redis deployment — carrying the old answer forward means freezing rows Redis has
|
|
301
401
|
// already isolated (all of the hazard, none of the protection), or, on the reverse swap,
|
|
302
402
|
// silently dropping the protection.
|
|
@@ -321,7 +421,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
321
421
|
const category = this.getCategoryForType(entry.type);
|
|
322
422
|
const data = await oldProvider?.GetItem(entry.key, category);
|
|
323
423
|
if (data) {
|
|
324
|
-
await newProvider.SetItem(entry.key, data, category);
|
|
424
|
+
await newProvider.SetItem(entry.key, data, category, this.writeOptionsForEntry(entry));
|
|
325
425
|
migratedCount++;
|
|
326
426
|
}
|
|
327
427
|
}
|
|
@@ -381,6 +481,23 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
381
481
|
* in-place mutation (`canUseImmediateMutation`); this closes the same gap in the raw
|
|
382
482
|
* provider cache. (B42)
|
|
383
483
|
*
|
|
484
|
+
* Maintaining the order in place was built and then withdrawn, so the payoff is on record rather
|
|
485
|
+
* than left to be rediscovered. It is possible for a narrow subset — plain column terms over
|
|
486
|
+
* numeric/date/boolean fields with no NULLs present, where JavaScript and SQL provably agree —
|
|
487
|
+
* but the subset is where the value dies, not the implementation. Of 918 RunView param literals
|
|
488
|
+
* in this repo, 289 carry an ORDER BY, 112 are also unfiltered and uncapped (the only ones a slot
|
|
489
|
+
* could maintain), and roughly 26 fall in that type subset, several of which order on a
|
|
490
|
+
* uniqueidentifier and are refused anyway. The survivors are about a dozen configuration and
|
|
491
|
+
* admin lists — API keys, credentials, view types, schema info — saved rarely, so the saving is a
|
|
492
|
+
* handful of small indexed reads per day. High-write entities gain nothing: every ordered read of
|
|
493
|
+
* MJ: AI Agent Runs, MJ: Conversation Details and MJ: AI Prompt Runs is FILTERED, so their slots
|
|
494
|
+
* land in the invalidate branch regardless. Against that, the cost is a comparator that must keep
|
|
495
|
+
* agreeing with SQL collation and NULL placement across dialects forever, whose failure mode is a
|
|
496
|
+
* silently reordered list in a slot other readers treat as authoritative. Widening it to strings —
|
|
497
|
+
* by far the largest bucket, `Name` — means owning collation per dialect and per column.
|
|
498
|
+
*
|
|
499
|
+
* If you are about to attempt this, the measurement above is the argument not to.
|
|
500
|
+
*
|
|
384
501
|
* @param parts - the fingerprint already split on '|'
|
|
385
502
|
*/
|
|
386
503
|
hasOrderBy(parts) {
|
|
@@ -540,30 +657,227 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
540
657
|
return Number.isFinite(startRow) && startRow > 0;
|
|
541
658
|
}
|
|
542
659
|
/**
|
|
543
|
-
*
|
|
544
|
-
* (
|
|
545
|
-
*
|
|
660
|
+
* Compares a cached RunView entry's schema hash against this process's current field list for
|
|
661
|
+
* the entity (a mismatch follows a migration + CodeGen that added, removed or reordered
|
|
662
|
+
* columns). Entries without a hash, and entities this process cannot resolve, never mismatch.
|
|
546
663
|
* @param fingerprint - The cache fingerprint
|
|
547
664
|
* @param data - The cached data to validate
|
|
548
|
-
* @returns
|
|
665
|
+
* @returns The mismatch, or null when the entry may be served
|
|
549
666
|
*/
|
|
550
|
-
|
|
667
|
+
findSchemaMismatch(fingerprint, data) {
|
|
551
668
|
if (!data.schemaHash)
|
|
552
|
-
return
|
|
669
|
+
return null;
|
|
553
670
|
const entityName = this.extractEntityFromFingerprint(fingerprint);
|
|
554
671
|
if (!entityName)
|
|
672
|
+
return null;
|
|
673
|
+
const current = this.computeSchemaSignature(undefined, entityName);
|
|
674
|
+
if (!current || current.Hash === data.schemaHash)
|
|
675
|
+
return null;
|
|
676
|
+
return {
|
|
677
|
+
Subject: LocalCacheManager.runViewSubject(entityName),
|
|
678
|
+
CachedHash: data.schemaHash,
|
|
679
|
+
Current: current,
|
|
680
|
+
EntryIsNewer: LocalCacheManager.isNewerSchemaStamp(data.schemaStamp, current.Stamp),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** The mismatch subject for an entity's RunView slots. */
|
|
684
|
+
static runViewSubject(entityName) {
|
|
685
|
+
return { Kind: 'runview', Name: entityName, Key: entityName.trim().toLowerCase() };
|
|
686
|
+
}
|
|
687
|
+
/** The mismatch subject for a query's RunQuery slots. */
|
|
688
|
+
static runQuerySubject(definition, queryName) {
|
|
689
|
+
return { Kind: 'runquery', Name: queryName || definition.QueryID, Key: definition.QueryID.trim().toLowerCase() };
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* True only when both stamps are present and the cached entry's is strictly newer than this
|
|
693
|
+
* process's. Equal or missing stamps leave the order unknown, which counts as not newer.
|
|
694
|
+
*/
|
|
695
|
+
static isNewerSchemaStamp(entryStamp, currentStamp) {
|
|
696
|
+
if (!entryStamp || !currentStamp) {
|
|
555
697
|
return false;
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
698
|
+
}
|
|
699
|
+
return LocalCacheManager.timestampMs(entryStamp) > LocalCacheManager.timestampMs(currentStamp);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Decides what a signature mismatch does to the slot: an entity schema mismatch on a RunView
|
|
703
|
+
* slot, or a query definition mismatch on a RunQuery slot. The entry is never served; the
|
|
704
|
+
* question is whether the slot is deleted, which on a shared store may also tell every peer to
|
|
705
|
+
* reload.
|
|
706
|
+
*
|
|
707
|
+
* - Process-local store, or a slot-maintenance read: delete it (`drop`). No other process reads a
|
|
708
|
+
* local store. A maintenance read happens because a row changed, and a slot that cannot take
|
|
709
|
+
* the change in place must go, whichever process wrote it.
|
|
710
|
+
* - Shared store, serving read: leave it, and hold back this process's writes of the subject's
|
|
711
|
+
* slots. Deleting a RunView slot publishes `removed`, every peer engine holding the slot
|
|
712
|
+
* reloads and rewrites it under its own hash, and processes on either side of a migration keep
|
|
713
|
+
* doing that to each other. The caller reads the database instead.
|
|
714
|
+
* - When the slot is known to come from newer metadata, this process is the one out of date.
|
|
715
|
+
* It stops writing the subject's slots until its own hash changes.
|
|
716
|
+
* - Otherwise it writes the subject's slots at most once per
|
|
717
|
+
* {@link SCHEMA_MISMATCH_WRITE_INTERVAL_MS} while mismatches keep appearing. That still
|
|
718
|
+
* replaces a slot left by older metadata, but a peer running code that deletes mismatched
|
|
719
|
+
* slots (any release before this check stopped deleting them) gets one slot to delete per
|
|
720
|
+
* interval instead of one per read. The hold lapses after an interval with no mismatch.
|
|
721
|
+
* For RunView slots the first replacement waits one interval. For RunQuery slots it is
|
|
722
|
+
* immediate: no release ever deleted a RunQuery slot over its definition and the category
|
|
723
|
+
* is not published, so an immediate replacement cannot feed a loop, and waiting would make
|
|
724
|
+
* every server run an edited query against the database for a full interval.
|
|
725
|
+
*/
|
|
726
|
+
handleSchemaMismatch(mismatch, purpose, drop) {
|
|
727
|
+
const shared = this._storageProvider?.SharedAcrossProcesses === true;
|
|
728
|
+
if (!shared || purpose === 'maintain') {
|
|
729
|
+
this.reportSchemaMismatch(mismatch, 'dropping the slot');
|
|
730
|
+
drop().catch(() => { });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
this.holdSchemaWrites(mismatch);
|
|
734
|
+
this.reportSchemaMismatch(mismatch, LocalCacheManager.describeHeldMismatch(mismatch));
|
|
735
|
+
}
|
|
736
|
+
/** The log text for a mismatch that left the slot in place and took a write hold. */
|
|
737
|
+
static describeHeldMismatch(mismatch) {
|
|
738
|
+
const seconds = LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS / 1000;
|
|
739
|
+
if (mismatch.Subject.Kind === 'runquery') {
|
|
740
|
+
return mismatch.EntryIsNewer
|
|
741
|
+
? 'another process wrote the slot under a newer query definition. This process runs the query against ' +
|
|
742
|
+
'the database and stops writing its cache slots until its own definition catches up.'
|
|
743
|
+
: `running the query against the database. While slots under other definitions keep appearing, this ` +
|
|
744
|
+
`process writes the query's slots at most once every ${seconds}s.`;
|
|
745
|
+
}
|
|
746
|
+
return mismatch.EntryIsNewer
|
|
747
|
+
? 'another process wrote the slot under newer metadata. This process reads the entity from the database ' +
|
|
748
|
+
'and stops writing its cache slots until its metadata changes. If it started before a migration, restart it.'
|
|
749
|
+
: `reading from the database. While slots under other metadata keep appearing, this process writes the entity's ` +
|
|
750
|
+
`slots at most once every ${seconds}s.`;
|
|
751
|
+
}
|
|
752
|
+
/** The key of a subject's write hold. RunView and RunQuery holds share one map. */
|
|
753
|
+
static holdKey(subject) {
|
|
754
|
+
return `${subject.Kind}:${subject.Key}`;
|
|
755
|
+
}
|
|
756
|
+
/** Records or extends the write hold for a mismatch seen on a shared store. */
|
|
757
|
+
holdSchemaWrites(mismatch) {
|
|
758
|
+
const key = LocalCacheManager.holdKey(mismatch.Subject);
|
|
759
|
+
const now = Date.now();
|
|
760
|
+
const existing = this._schemaWriteHolds.get(key);
|
|
761
|
+
if (mismatch.EntryIsNewer) {
|
|
762
|
+
this._schemaWriteHolds.set(key, { OwnHash: mismatch.Current.Hash, NextWriteAt: Number.POSITIVE_INFINITY, LastMismatchAt: now });
|
|
763
|
+
}
|
|
764
|
+
else if (existing && existing.OwnHash === mismatch.Current.Hash) {
|
|
765
|
+
existing.LastMismatchAt = now;
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
const firstWriteDelay = mismatch.Subject.Kind === 'runquery' ? 0 : LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS;
|
|
769
|
+
this._schemaWriteHolds.set(key, {
|
|
770
|
+
OwnHash: mismatch.Current.Hash,
|
|
771
|
+
NextWriteAt: now + firstWriteDelay,
|
|
772
|
+
LastMismatchAt: now,
|
|
563
773
|
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/** Logs a mismatch once per subject and hash pair per process. */
|
|
777
|
+
reportSchemaMismatch(mismatch, outcome) {
|
|
778
|
+
const key = `${LocalCacheManager.holdKey(mismatch.Subject)}|${mismatch.CachedHash}|${mismatch.Current.Hash}`;
|
|
779
|
+
if (this._reportedSchemaMismatches.has(key)) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
this._reportedSchemaMismatches.add(key);
|
|
783
|
+
const label = mismatch.Subject.Kind === 'runquery'
|
|
784
|
+
? `[CACHE-QUERY-DEFINITION-STALE] Query "${mismatch.Subject.Name}" definition`
|
|
785
|
+
: `[CACHE-SCHEMA-STALE] Entity "${mismatch.Subject.Name}" schema`;
|
|
786
|
+
LogWarning(`${label} differs from a cached slot ` +
|
|
787
|
+
`(cached=${mismatch.CachedHash || '(none)'}, current=${mismatch.Current.Hash}) — ${outcome}`, 'Cache');
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Whether a slot of `subject` may be written now, given its write hold (see
|
|
791
|
+
* {@link handleSchemaMismatch}). A permitted write under a rate-limiting hold uses up the
|
|
792
|
+
* interval. The hold is dropped once this process's hash changes (a metadata refresh, or a
|
|
793
|
+
* query edit reaching this process) or, for a rate-limiting hold, once an interval passes with
|
|
794
|
+
* no mismatch.
|
|
795
|
+
*/
|
|
796
|
+
mayWriteUnderSchemaHold(subject, currentHash) {
|
|
797
|
+
if (this._storageProvider?.SharedAcrossProcesses !== true) {
|
|
564
798
|
return true;
|
|
565
799
|
}
|
|
566
|
-
|
|
800
|
+
const key = LocalCacheManager.holdKey(subject);
|
|
801
|
+
const hold = this._schemaWriteHolds.get(key);
|
|
802
|
+
if (!hold) {
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
const now = Date.now();
|
|
806
|
+
const interval = LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS;
|
|
807
|
+
const lapsed = hold.NextWriteAt !== Number.POSITIVE_INFINITY && now - hold.LastMismatchAt > interval;
|
|
808
|
+
if (hold.OwnHash !== currentHash || lapsed) {
|
|
809
|
+
this._schemaWriteHolds.delete(key);
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
if (now < hold.NextWriteAt) {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
hold.NextWriteAt = now + interval;
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* The newest `__mj_UpdatedAt` among `rows` as an ISO string, `''` for an empty set, or
|
|
820
|
+
* `undefined` when the rows carry no such column (so the caller can fall back to its own stamp).
|
|
821
|
+
* Mirrors how the RunView fill path stamps a slot.
|
|
822
|
+
*/
|
|
823
|
+
static MaxUpdatedAtOfRows(rows) {
|
|
824
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
825
|
+
let sawColumn = false;
|
|
826
|
+
for (const row of rows) {
|
|
827
|
+
if (!row || typeof row !== 'object') {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
// `UpdatedAt` is the fallback an entity whose view predates the `__mj_` prefix carries.
|
|
831
|
+
// Both cache-write funnels must agree on the stamp they compute, or the same rows get
|
|
832
|
+
// different slot timestamps depending on which path wrote them and every currency check
|
|
833
|
+
// between them misfires.
|
|
834
|
+
const record = row;
|
|
835
|
+
const raw = record['__mj_UpdatedAt'] !== undefined ? record['__mj_UpdatedAt'] : record['UpdatedAt'];
|
|
836
|
+
if (raw === undefined) {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
sawColumn = true;
|
|
840
|
+
if (raw === null) {
|
|
841
|
+
continue; // new Date(null) is the epoch, not "no timestamp"
|
|
842
|
+
}
|
|
843
|
+
const ms = raw instanceof Date ? raw.getTime() : new Date(raw).getTime();
|
|
844
|
+
if (!Number.isNaN(ms) && ms > max) {
|
|
845
|
+
max = ms;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (rows.length === 0) {
|
|
849
|
+
return '';
|
|
850
|
+
}
|
|
851
|
+
if (!sawColumn) {
|
|
852
|
+
return undefined;
|
|
853
|
+
}
|
|
854
|
+
return max === Number.NEGATIVE_INFINITY ? '' : new Date(max).toISOString();
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Storage write options for a RunView slot: the entity name as index group, so a shared
|
|
858
|
+
* provider can find the slot from any process, and the time left before the slot's own expiry
|
|
859
|
+
* (external-data-source entities), so a rewrite does not extend it.
|
|
860
|
+
*
|
|
861
|
+
* @param fingerprint - The slot key
|
|
862
|
+
* @param expiresAt - Absolute expiry (ms since epoch), when the slot has one
|
|
863
|
+
*/
|
|
864
|
+
runViewWriteOptions(fingerprint, expiresAt) {
|
|
865
|
+
const options = {};
|
|
866
|
+
const entity = this.extractEntityFromFingerprint(fingerprint);
|
|
867
|
+
if (entity) {
|
|
868
|
+
options.IndexGroup = entity;
|
|
869
|
+
}
|
|
870
|
+
if (expiresAt) {
|
|
871
|
+
options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
|
872
|
+
}
|
|
873
|
+
return options;
|
|
874
|
+
}
|
|
875
|
+
/** Storage write options for any registry entry; only RunView slots are indexed. */
|
|
876
|
+
writeOptionsForEntry(entry) {
|
|
877
|
+
if (entry.type === 'runview') {
|
|
878
|
+
return this.runViewWriteOptions(entry.key, entry.expiresAt);
|
|
879
|
+
}
|
|
880
|
+
return entry.expiresAt ? { TTLSeconds: Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000)) } : undefined;
|
|
567
881
|
}
|
|
568
882
|
/**
|
|
569
883
|
* Adds a fingerprint to the entity→fingerprint reverse index.
|
|
@@ -602,13 +916,31 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
602
916
|
return this._entityFingerprintIndex.get(entityName) ?? new Set();
|
|
603
917
|
}
|
|
604
918
|
/**
|
|
605
|
-
* Resolves cached fingerprints for an entity,
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
919
|
+
* Resolves cached fingerprints for an entity: this process's own index, plus — on a storage
|
|
920
|
+
* provider shared between processes — the slots other processes wrote.
|
|
921
|
+
*
|
|
922
|
+
* A save on server A has to reach slots server B wrote after A booted, because nothing else
|
|
923
|
+
* will: B only hears about A's save through the slots A rewrites. A shared provider therefore
|
|
924
|
+
* answers from its per-entity index ({@link ILocalStorageProvider.GetIndexGroupKeys}) on every
|
|
925
|
+
* call. A provider without that index is process-local or cannot enumerate cheaply, and keeps
|
|
926
|
+
* the older behaviour: the category is listed only when the local index knows nothing.
|
|
610
927
|
*/
|
|
611
928
|
async resolveFingerprintsForEntity(entityName) {
|
|
929
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
930
|
+
const shared = await this._storageProvider.GetIndexGroupKeys(CacheCategory.RunViewCache, entityName);
|
|
931
|
+
// The local index is deliberately NOT pruned against the shared group here. A slot this
|
|
932
|
+
// process wrote that the group no longer lists is one that expired or was cleared
|
|
933
|
+
// elsewhere — and peers may still be holding its rows in memory. Keeping it means the
|
|
934
|
+
// next save invalidates it, which is what publishes the `removed` those peers reload
|
|
935
|
+
// on (pinned by localCacheManager.sharedIndex.test.ts). The invalidation then
|
|
936
|
+
// drops it from the index, so that costs one notice per slot, once — not per save.
|
|
937
|
+
// Reviewed as and rejected on that evidence
|
|
938
|
+
for (const fp of shared) {
|
|
939
|
+
this.addToEntityIndex(fp);
|
|
940
|
+
}
|
|
941
|
+
const merged = this._entityFingerprintIndex.get(entityName);
|
|
942
|
+
return merged && merged.size > 0 ? merged : undefined;
|
|
943
|
+
}
|
|
612
944
|
const local = this._entityFingerprintIndex.get(entityName);
|
|
613
945
|
if (local && local.size > 0)
|
|
614
946
|
return local;
|
|
@@ -656,6 +988,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
656
988
|
// Only react to completed save and delete events
|
|
657
989
|
if (entityEvent.type !== 'save' && entityEvent.type !== 'delete')
|
|
658
990
|
return;
|
|
991
|
+
// Inside an open batch the change is recorded now and applied when the batch closes.
|
|
992
|
+
if (this.bufferIfBatching(entityEvent))
|
|
993
|
+
return;
|
|
659
994
|
// Fire-and-forget to avoid blocking the save/delete operation
|
|
660
995
|
this.HandleBaseEntityEvent(entityEvent).catch((err) => {
|
|
661
996
|
LogError(`LocalCacheManager.HandleBaseEntityEvent error: ${err.message}`);
|
|
@@ -721,6 +1056,306 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
721
1056
|
}));
|
|
722
1057
|
}
|
|
723
1058
|
}
|
|
1059
|
+
// ========================================================================
|
|
1060
|
+
// ENTITY-EVENT BATCHES
|
|
1061
|
+
// ========================================================================
|
|
1062
|
+
/**
|
|
1063
|
+
* Opens a batch for `owner` (normally a provider), or enters the one already open. Until the
|
|
1064
|
+
* matching {@link EndEntityEventBatch}, saves and deletes raised by entities that use `owner`
|
|
1065
|
+
* as their provider are recorded instead of applied; closing the batch rewrites each affected
|
|
1066
|
+
* slot once. `DatabaseProviderBase.BeginEntityTransaction` opens one for every transaction, so
|
|
1067
|
+
* callers rarely need this directly — {@link RunInEntityEventBatch} is the wrapper for bulk
|
|
1068
|
+
* writers that do not use a transaction.
|
|
1069
|
+
*
|
|
1070
|
+
* Every call must be paired with exactly one {@link EndEntityEventBatch}.
|
|
1071
|
+
*/
|
|
1072
|
+
BeginEntityEventBatch(owner) {
|
|
1073
|
+
this._entityEventBatches.Open(owner);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Replaces a RunView slot wholesale while holding the cross-process lock for it.
|
|
1077
|
+
*
|
|
1078
|
+
* The event-driven path already reads-modifies-writes under that lock; a writer that replaces
|
|
1079
|
+
* the same slot outside it can land between a peer's read and its write, and whichever `SET`
|
|
1080
|
+
* lands second wins — the engine sweep's fresh rows lost, or the peer's delta lost, and either
|
|
1081
|
+
* way the losing state is published to the fleet.
|
|
1082
|
+
*/
|
|
1083
|
+
async ReplaceRunViewResultLocked(fingerprint, params, results, maxUpdatedAt, totalRowCount, provider) {
|
|
1084
|
+
if (!this._storageProvider || !this._config.enabled) {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
// Carry the slot's existing expiry across the replace. An external-data-source slot has one,
|
|
1088
|
+
// and a rewrite that dropped it would turn a bounded cache entry into a permanent one
|
|
1089
|
+
//.
|
|
1090
|
+
const ttlMs = this.remainingTTLForSlot(fingerprint);
|
|
1091
|
+
return this.maintainSlotLocked(fingerprint, async () => {
|
|
1092
|
+
await this.SetRunViewResult(fingerprint, params, results, maxUpdatedAt, undefined, totalRowCount, provider, ttlMs);
|
|
1093
|
+
return true;
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
/** Milliseconds left on a slot's expiry, or undefined when it has none. @internal */
|
|
1097
|
+
remainingTTLForSlot(fingerprint) {
|
|
1098
|
+
const expiresAt = this._registry.get(fingerprint)?.expiresAt;
|
|
1099
|
+
if (!expiresAt) {
|
|
1100
|
+
return undefined;
|
|
1101
|
+
}
|
|
1102
|
+
return Math.max(1, expiresAt - Date.now());
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Abandons `owner`'s batch at any depth and invalidates every slot it touched.
|
|
1106
|
+
*
|
|
1107
|
+
* For an owner that cannot settle its batch normally: an independent provider instance being
|
|
1108
|
+
* released with a transaction still open, or a transaction handle reset after a failure. The
|
|
1109
|
+
* buffered rows may or may not have reached the database, so the affected slots are invalidated
|
|
1110
|
+
* rather than written, and the entities stop counting as pending — a batch left open makes
|
|
1111
|
+
* every cached read of its entities miss, for the life of the process.
|
|
1112
|
+
*
|
|
1113
|
+
* Never throws. Safe when no batch is open.
|
|
1114
|
+
*/
|
|
1115
|
+
async AbandonEntityEventBatch(owner) {
|
|
1116
|
+
const batch = this._entityEventBatches.Abandon(owner);
|
|
1117
|
+
if (!batch) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
LogWarning(`LocalCacheManager: an entity-event batch was abandoned with ${batch.Changes.length} buffered change(s) across ${batch.TouchedEntities.size} entit${batch.TouchedEntities.size === 1 ? 'y' : 'ies'} — invalidating them, because whether those rows reached the database is unknown`, 'Cache');
|
|
1121
|
+
try {
|
|
1122
|
+
await this.invalidateBatchEntities(batch);
|
|
1123
|
+
}
|
|
1124
|
+
catch (e) {
|
|
1125
|
+
LogError(`LocalCacheManager.AbandonEntityEventBatch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Leaves one level of `owner`'s batch. Closing the outermost level applies the batch: with
|
|
1130
|
+
* every level successful, each affected slot is rewritten once with all its changes; if any
|
|
1131
|
+
* level failed (a rollback), the affected slots are invalidated instead, so every server reloads
|
|
1132
|
+
* them from the database. Never throws.
|
|
1133
|
+
*
|
|
1134
|
+
* @param owner - The object passed to {@link BeginEntityEventBatch}
|
|
1135
|
+
* @param succeeded - False when the work this level covered was rolled back or failed
|
|
1136
|
+
*/
|
|
1137
|
+
async EndEntityEventBatch(owner, succeeded) {
|
|
1138
|
+
const batch = this._entityEventBatches.Close(owner, succeeded);
|
|
1139
|
+
if (!batch) {
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
try {
|
|
1143
|
+
await this.applyEntityEventBatch(batch);
|
|
1144
|
+
}
|
|
1145
|
+
catch (e) {
|
|
1146
|
+
LogError(`LocalCacheManager: applying an entity-event batch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Claims a named lease on the storage provider for `ttlMs`: on a shared store only one process
|
|
1151
|
+
* gets it per period; on a private store the caller always does. A lease that cannot be
|
|
1152
|
+
* checked is reported as not claimed.
|
|
1153
|
+
*/
|
|
1154
|
+
async TryAcquireSharedLease(name, ttlMs) {
|
|
1155
|
+
return (await this.claimLease(name, ttlMs)) === 'claimed';
|
|
1156
|
+
}
|
|
1157
|
+
/** One lease attempt: claimed, held by another process, or the store could not be asked. */
|
|
1158
|
+
async claimLease(name, ttlMs) {
|
|
1159
|
+
const provider = this._storageProvider;
|
|
1160
|
+
if (!provider?.TryAcquireLease) {
|
|
1161
|
+
return 'claimed';
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
return (await provider.TryAcquireLease(name, ttlMs)) ? 'claimed' : 'held';
|
|
1165
|
+
}
|
|
1166
|
+
catch (e) {
|
|
1167
|
+
LogWarning(`LocalCacheManager: could not claim lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
1168
|
+
return 'error';
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Extends a lease this process already holds, so long-running work keeps its turn. Falls back
|
|
1173
|
+
* to re-claiming when the store has no dedicated renewal, which is a no-op for anyone else's
|
|
1174
|
+
* lease because the claim is `SET NX`. Never throws.
|
|
1175
|
+
*
|
|
1176
|
+
* @returns true when the lease is still (or again) held by this process.
|
|
1177
|
+
*/
|
|
1178
|
+
async RenewSharedLease(name, ttlMs) {
|
|
1179
|
+
const provider = this._storageProvider;
|
|
1180
|
+
if (!provider?.TryAcquireLease) {
|
|
1181
|
+
return true; // no shared store: this process is the only one, and holds it by default
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
const renew = provider.RenewLease;
|
|
1185
|
+
if (renew) {
|
|
1186
|
+
return await renew.call(provider, name, ttlMs);
|
|
1187
|
+
}
|
|
1188
|
+
return await provider.TryAcquireLease(name, ttlMs);
|
|
1189
|
+
}
|
|
1190
|
+
catch (e) {
|
|
1191
|
+
LogWarning(`LocalCacheManager: could not renew lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
/** Ends a lease claimed with {@link TryAcquireSharedLease}. Never throws. */
|
|
1196
|
+
async ReleaseSharedLease(name) {
|
|
1197
|
+
try {
|
|
1198
|
+
await this._storageProvider?.ReleaseLease?.(name);
|
|
1199
|
+
}
|
|
1200
|
+
catch (e) {
|
|
1201
|
+
LogWarning(`LocalCacheManager: could not release lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Waits until this process claims the lease, polling every `pollMs`, for at most `maxWaitMs`.
|
|
1206
|
+
* @returns Whether the lease was claimed, and how long the wait took.
|
|
1207
|
+
*/
|
|
1208
|
+
async WaitForSharedLease(name, ttlMs, maxWaitMs, pollMs = 100) {
|
|
1209
|
+
const started = Date.now();
|
|
1210
|
+
for (;;) {
|
|
1211
|
+
const outcome = await this.claimLease(name, ttlMs);
|
|
1212
|
+
if (outcome !== 'held') {
|
|
1213
|
+
// An unreachable store coordinates nothing, so waiting on it would only delay the caller.
|
|
1214
|
+
return { Acquired: outcome === 'claimed', WaitedMs: Date.now() - started };
|
|
1215
|
+
}
|
|
1216
|
+
if (Date.now() - started >= maxWaitMs) {
|
|
1217
|
+
return { Acquired: false, WaitedMs: Date.now() - started };
|
|
1218
|
+
}
|
|
1219
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/** True while a batch is open for `owner`. */
|
|
1223
|
+
IsBatchingEntityEvents(owner) {
|
|
1224
|
+
return this._entityEventBatches.Find(owner) !== undefined;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* True when a batch took `entityEvent` as it was raised, so closing that batch maintains every
|
|
1228
|
+
* slot indexed for the entity. A listener that reaches its cache write only after an `await`
|
|
1229
|
+
* asks this instead of {@link IsBatchingEntityEvents}: by then the batch may have closed, and
|
|
1230
|
+
* a write of its own would rewrite the slot once per event.
|
|
1231
|
+
*/
|
|
1232
|
+
WasTakenByEntityEventBatch(entityEvent) {
|
|
1233
|
+
return this._eventsTakenByBatch.has(entityEvent);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Runs `work` inside an entity-event batch for `owner` and applies the batch when it finishes
|
|
1237
|
+
* (as a failure when `work` throws). Use for bulk writes that do not already run in a
|
|
1238
|
+
* transaction.
|
|
1239
|
+
*/
|
|
1240
|
+
async RunInEntityEventBatch(owner, work) {
|
|
1241
|
+
this.BeginEntityEventBatch(owner);
|
|
1242
|
+
let succeeded = false;
|
|
1243
|
+
try {
|
|
1244
|
+
const result = await work();
|
|
1245
|
+
succeeded = true;
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
finally {
|
|
1249
|
+
await this.EndEntityEventBatch(owner, succeeded);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Records a save/delete in the batch open for the entity's provider.
|
|
1254
|
+
* @returns True when the event was taken by a batch (or needs no cache work at all).
|
|
1255
|
+
*/
|
|
1256
|
+
bufferIfBatching(entityEvent) {
|
|
1257
|
+
const owner = entityEvent.baseEntity?.ProviderToUse;
|
|
1258
|
+
if (!this._entityEventBatches.Find(owner)) {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
const taken = this._entityEventBatches.Record(owner, this.captureEntityChange(entityEvent));
|
|
1262
|
+
if (taken) {
|
|
1263
|
+
this._eventsTakenByBatch.add(entityEvent);
|
|
1264
|
+
}
|
|
1265
|
+
return taken;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* True when `fingerprint`'s entity has changes waiting in an open batch. Such a slot is behind
|
|
1269
|
+
* what the unit of work has written, so reads treat it as a miss (the database answers,
|
|
1270
|
+
* including the uncommitted rows for a reader inside the transaction) and fills are not stored.
|
|
1271
|
+
*/
|
|
1272
|
+
isPendingInBatch(fingerprint) {
|
|
1273
|
+
const entityName = this.extractEntityFromFingerprint(fingerprint);
|
|
1274
|
+
return entityName !== null && this._entityEventBatches.HasPendingChanges(entityName);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* The change a save/delete event describes, captured now — the entity object can change (or,
|
|
1278
|
+
* after a delete, be reset) before a batch closes. Null when the event needs no cache work.
|
|
1279
|
+
*/
|
|
1280
|
+
captureEntityChange(entityEvent) {
|
|
1281
|
+
const entityInfo = entityEvent.baseEntity?.EntityInfo;
|
|
1282
|
+
if (!entityInfo?.Name || !this.IsCachingEnabledForEntity(entityInfo) || !entityInfo.PrimaryKeys?.length) {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
if (entityEvent.type !== 'save' && entityEvent.type !== 'delete') {
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
// A delete resets the entity right after raising the event; its payload keeps the old values.
|
|
1289
|
+
const payload = entityEvent.payload;
|
|
1290
|
+
const record = (entityEvent.type === 'delete' && payload?.OldValues)
|
|
1291
|
+
? payload.OldValues
|
|
1292
|
+
: entityEvent.baseEntity.GetAll();
|
|
1293
|
+
const key = new CompositeKey();
|
|
1294
|
+
key.LoadFromEntityInfoAndRecord(entityInfo, record);
|
|
1295
|
+
if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
return { EntityInfo: entityInfo, Type: entityEvent.type, Key: key, Record: record };
|
|
1299
|
+
}
|
|
1300
|
+
/** Applies a closed batch; see {@link EndEntityEventBatch}. */
|
|
1301
|
+
async applyEntityEventBatch(batch) {
|
|
1302
|
+
if (!batch.CanApplyRows) {
|
|
1303
|
+
await this.invalidateBatchEntities(batch);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
const byEntity = new Map();
|
|
1307
|
+
for (const change of batch.Changes) {
|
|
1308
|
+
const list = byEntity.get(change.EntityInfo.Name) ?? [];
|
|
1309
|
+
list.push(change);
|
|
1310
|
+
byEntity.set(change.EntityInfo.Name, list);
|
|
1311
|
+
}
|
|
1312
|
+
for (const [entityName, changes] of byEntity) {
|
|
1313
|
+
await this.applyEntityChanges(entityName, changes);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
/** Invalidates every entity a batch touched — for a batch that failed or was abandoned. */
|
|
1317
|
+
async invalidateBatchEntities(batch) {
|
|
1318
|
+
for (const entityName of batch.TouchedEntities.keys()) {
|
|
1319
|
+
await this.InvalidateEntityCaches(entityName);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/** Applies one entity's buffered changes to every slot indexed for it, each slot once. */
|
|
1323
|
+
async applyEntityChanges(entityName, changes) {
|
|
1324
|
+
const fingerprints = await this.resolveFingerprintsForEntity(entityName);
|
|
1325
|
+
if (!fingerprints || fingerprints.size === 0) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const snapshot = [...fingerprints];
|
|
1329
|
+
const nowISO = new Date().toISOString();
|
|
1330
|
+
const BATCH_SIZE = 8;
|
|
1331
|
+
for (let i = 0; i < snapshot.length; i += BATCH_SIZE) {
|
|
1332
|
+
await Promise.all(snapshot.slice(i, i + BATCH_SIZE).map(async (fingerprint) => {
|
|
1333
|
+
try {
|
|
1334
|
+
await this.applyEntityChangesToSlot(fingerprint, changes, nowISO);
|
|
1335
|
+
}
|
|
1336
|
+
catch (err) {
|
|
1337
|
+
LogError(`LocalCacheManager: failed to apply batched changes to "${fingerprint}": ${err.message}`);
|
|
1338
|
+
}
|
|
1339
|
+
}));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* One slot, several changes: the same decisions {@link processEntityEventForFingerprint} makes
|
|
1344
|
+
* per event, made once for the whole list.
|
|
1345
|
+
*/
|
|
1346
|
+
async applyEntityChangesToSlot(fingerprint, changes, nowISO) {
|
|
1347
|
+
const hasSave = changes.some(c => c.Type === 'save');
|
|
1348
|
+
// Subset and aggregate slots cannot be maintained in place; filtered slots cannot take an
|
|
1349
|
+
// upsert (the new row may not match the filter) but can drop deleted rows.
|
|
1350
|
+
if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fingerprint.split('|'))
|
|
1351
|
+
|| (hasSave && this.isFilteredFingerprint(fingerprint))) {
|
|
1352
|
+
await this.InvalidateRunViewResult(fingerprint);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const rowChanges = changes.map(c => ({ Key: c.Key, Row: c.Type === 'save' ? c.Record : null }));
|
|
1356
|
+
const maintained = await this.ApplyRowChanges(fingerprint, rowChanges, nowISO);
|
|
1357
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
1358
|
+
}
|
|
724
1359
|
/**
|
|
725
1360
|
* Handles remote-invalidate events that include recordData (the saved entity as JSON).
|
|
726
1361
|
* Updates all cached RunView results for the entity without a server round-trip.
|
|
@@ -928,13 +1563,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
928
1563
|
}
|
|
929
1564
|
else if (eventType === 'delete') {
|
|
930
1565
|
LogStatusVerbose(`LocalCacheManager: Removing entity ${keyStr} from cache "${fingerprint.substring(0, 60)}"`);
|
|
931
|
-
await this.RemoveSingleEntity(fingerprint, key, nowISO);
|
|
1566
|
+
const maintained = await this.RemoveSingleEntity(fingerprint, key, nowISO);
|
|
1567
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
932
1568
|
}
|
|
933
1569
|
else if (!this.isFilteredFingerprint(fingerprint)) {
|
|
934
1570
|
// Unfiltered cache: update the record in place
|
|
935
1571
|
LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
|
|
936
1572
|
const entityData = baseEntity.GetAll();
|
|
937
|
-
await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
|
|
1573
|
+
const maintained = await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
|
|
1574
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
938
1575
|
}
|
|
939
1576
|
else {
|
|
940
1577
|
// Filtered cache: conservatively invalidate (can't verify filter match)
|
|
@@ -942,6 +1579,26 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
942
1579
|
await this.InvalidateRunViewResult(fingerprint);
|
|
943
1580
|
}
|
|
944
1581
|
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Handles a save/delete that could not be applied to a slot this process indexed — in practice
|
|
1584
|
+
* because the slot expired or was removed by someone else.
|
|
1585
|
+
*
|
|
1586
|
+
* Rewriting the slot is how peer servers hear about the change: their engines hold the rows in
|
|
1587
|
+
* memory and never read the slot again, so a slot that expired while they were running leaves
|
|
1588
|
+
* nothing to rewrite and the change would never reach them. Invalidating instead publishes a
|
|
1589
|
+
* `removed` event on a shared provider, which makes every peer engine tracking the slot reload
|
|
1590
|
+
* it from the database. It also drops the dead fingerprint from this process's index.
|
|
1591
|
+
*
|
|
1592
|
+
* @param fingerprint - The slot the event was applied to
|
|
1593
|
+
* @param maintained - What the in-place upsert/remove reported
|
|
1594
|
+
*/
|
|
1595
|
+
async invalidateIfNotMaintained(fingerprint, maintained) {
|
|
1596
|
+
if (maintained) {
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
LogStatusVerbose(`LocalCacheManager: slot "${fingerprint.substring(0, 60)}" could not be maintained in place — invalidating so peers reload`);
|
|
1600
|
+
await this.InvalidateRunViewResult(fingerprint);
|
|
1601
|
+
}
|
|
945
1602
|
/**
|
|
946
1603
|
* Registers a callback that fires when a specific cache fingerprint is updated
|
|
947
1604
|
* by another server instance. Returns an unsubscribe function to remove the callback.
|
|
@@ -988,9 +1645,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
988
1645
|
* fingerprint. Called by infrastructure code (e.g., {@link RedisLocalStorageProvider})
|
|
989
1646
|
* when another server modifies a cached entry.
|
|
990
1647
|
*
|
|
991
|
-
* For `category_cleared` events, dispatches to ALL registered
|
|
992
|
-
*
|
|
993
|
-
* which contains the category name).
|
|
1648
|
+
* For `category_cleared` events on the RunView category, dispatches to ALL registered
|
|
1649
|
+
* callbacks (they all watch RunView slots); a clear of any other category reaches none.
|
|
994
1650
|
*
|
|
995
1651
|
* Errors in individual callbacks are caught and logged via {@link LogError}
|
|
996
1652
|
* to prevent one bad callback from blocking others.
|
|
@@ -1001,9 +1657,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1001
1657
|
const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
|
|
1002
1658
|
LogStatusVerbose(`LocalCacheManager: DispatchCacheChange received — action="${event.Action}", key="${event.CacheKey}", source="${sourceShort}"`);
|
|
1003
1659
|
if (event.Action === 'category_cleared') {
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1660
|
+
// Every registered callback watches a RunView slot (engine configs and RunView
|
|
1661
|
+
// OnDataChanged both key on RunView fingerprints), so only clearing that category
|
|
1662
|
+
// affects them. Notifying them for a Metadata or Dataset clear as well would make a
|
|
1663
|
+
// tool that clears several categories reload every engine once per category.
|
|
1664
|
+
if (event.Category !== CacheCategory.RunViewCache) {
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1007
1667
|
for (const [, callbacks] of this._changeCallbacks) {
|
|
1008
1668
|
for (const cb of callbacks) {
|
|
1009
1669
|
try {
|
|
@@ -1053,9 +1713,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1053
1713
|
if (!this._storageProvider || !this._config.enabled)
|
|
1054
1714
|
return;
|
|
1055
1715
|
const key = this.buildDatasetKey(name, itemFilters, keyPrefix);
|
|
1056
|
-
// Estimate size
|
|
1057
|
-
|
|
1058
|
-
|
|
1716
|
+
// Estimate size by sampling item result arrays instead of stringifying the entire dataset
|
|
1717
|
+
const sizeBytes = Array.isArray(dataset?.Results)
|
|
1718
|
+
? dataset.Results.reduce((sum, item) => sum + this.estimateResultsSize(item.Results), 128)
|
|
1719
|
+
: 128;
|
|
1059
1720
|
// Fourth write funnel, held to the same contract as SetRunViewResult /
|
|
1060
1721
|
// SetRunQueryResult / storeCachedResults. `GetDataset` hands this object straight back
|
|
1061
1722
|
// out, so on a reference-sharing provider every reader shares it — the same exposure the
|
|
@@ -1455,6 +2116,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1455
2116
|
* @returns The schema hash string, or undefined if the entity can't be resolved
|
|
1456
2117
|
*/
|
|
1457
2118
|
ComputeSchemaHash(provider, entityName) {
|
|
2119
|
+
return this.computeSchemaSignature(provider, entityName)?.Hash;
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* The entity's schema hash (see {@link ComputeSchemaHash}) together with its schema stamp: the
|
|
2123
|
+
* newest `__mj_UpdatedAt` across the entity row and its field rows, as an ISO string, or
|
|
2124
|
+
* undefined when the metadata carries no timestamps.
|
|
2125
|
+
*/
|
|
2126
|
+
computeSchemaSignature(provider, entityName) {
|
|
1458
2127
|
try {
|
|
1459
2128
|
const md = provider ?? new Metadata();
|
|
1460
2129
|
const entity = md.EntityByName(entityName);
|
|
@@ -1463,12 +2132,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1463
2132
|
// Use natural sequence order (EntityInfo.Fields is sorted by Sequence).
|
|
1464
2133
|
// This detects field additions, removals, AND reorderings.
|
|
1465
2134
|
const fieldNames = entity.Fields.map(f => f.Name).join('|');
|
|
1466
|
-
|
|
2135
|
+
let newest = LocalCacheManager.timestampMs(entity.__mj_UpdatedAt);
|
|
2136
|
+
for (const field of entity.Fields) {
|
|
2137
|
+
newest = Math.max(newest, LocalCacheManager.timestampMs(field.__mj_UpdatedAt));
|
|
2138
|
+
}
|
|
2139
|
+
return {
|
|
2140
|
+
Hash: this.simpleHash(fieldNames),
|
|
2141
|
+
Stamp: newest > 0 ? new Date(newest).toISOString() : undefined,
|
|
2142
|
+
};
|
|
1467
2143
|
}
|
|
1468
2144
|
catch {
|
|
1469
2145
|
return undefined;
|
|
1470
2146
|
}
|
|
1471
2147
|
}
|
|
2148
|
+
/** Milliseconds since the epoch for a metadata timestamp (a Date or a string), or 0 when unreadable. */
|
|
2149
|
+
static timestampMs(value) {
|
|
2150
|
+
if (value == null) {
|
|
2151
|
+
return 0;
|
|
2152
|
+
}
|
|
2153
|
+
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
2154
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
2155
|
+
}
|
|
1472
2156
|
/**
|
|
1473
2157
|
* Deep-freezes an about-to-be-stored cache payload when — and only when — the active
|
|
1474
2158
|
* storage provider hands out live object references
|
|
@@ -1557,6 +2241,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1557
2241
|
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for keyset (AfterKey) query on "${params.EntityName}"`, verboseOnly: true });
|
|
1558
2242
|
return;
|
|
1559
2243
|
}
|
|
2244
|
+
// Rows read while a unit of work has unapplied changes for this entity may include its
|
|
2245
|
+
// uncommitted writes (or miss them); either way they must not become the shared slot.
|
|
2246
|
+
if (this.isPendingInBatch(fingerprint)) {
|
|
2247
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — an open batch has changes for it`, verboseOnly: true });
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
1560
2250
|
// Short-circuit: if the entity has AllowCaching = false, do not write to the cache.
|
|
1561
2251
|
// The invalidation path (HandleBaseEntityEvent line 552) already short-circuits for
|
|
1562
2252
|
// these entities, so any entry we write here would never be invalidated and would
|
|
@@ -1618,26 +2308,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1618
2308
|
}
|
|
1619
2309
|
// Compute and store schema hash for upgrade detection
|
|
1620
2310
|
if (params.EntityName) {
|
|
1621
|
-
const
|
|
1622
|
-
if (
|
|
1623
|
-
|
|
2311
|
+
const signature = this.computeSchemaSignature(provider, params.EntityName);
|
|
2312
|
+
if (signature) {
|
|
2313
|
+
if (!this.mayWriteUnderSchemaHold(LocalCacheManager.runViewSubject(params.EntityName), signature.Hash)) {
|
|
2314
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — its shared slots were found under different metadata`, verboseOnly: true });
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
data.schemaHash = signature.Hash;
|
|
2318
|
+
if (signature.Stamp) {
|
|
2319
|
+
data.schemaStamp = signature.Stamp;
|
|
2320
|
+
}
|
|
1624
2321
|
}
|
|
1625
2322
|
}
|
|
1626
2323
|
// Estimate size by sampling rows (eviction accounting only); the actual stored
|
|
1627
2324
|
// value is the native object — no full JSON.stringify on the hot path.
|
|
1628
2325
|
const sizeBytes = this.estimateResultsSize(data.results);
|
|
1629
|
-
// Oversized-entry gate: an entry
|
|
1630
|
-
// never cached
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
//
|
|
2326
|
+
// Oversized-entry gate: an entry too large to be RETAINED within the budget is
|
|
2327
|
+
// never cached — storing it would evict other entries for something the next store
|
|
2328
|
+
// evicts again. An entry that fits under MaxEntrySizeBytes is cached however large
|
|
2329
|
+
// it is. Always logged (not verbose-gated): a query too big to cache is a cost the
|
|
2330
|
+
// operator is paying on every call and must be able to see.
|
|
1634
2331
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
1635
|
-
|
|
2332
|
+
LogWarning(this.describeOversizedEntry(params.EntityName || fingerprint.substring(0, 60), sizeBytes), 'Cache');
|
|
1636
2333
|
return;
|
|
1637
2334
|
}
|
|
1638
|
-
// The oversized gate above
|
|
1639
|
-
// synchronous — so by here the row set is definitely becoming shared
|
|
1640
|
-
// frozen with no intervening yield point. That ordering is load-bearing: the eviction
|
|
2335
|
+
// The schema gate and the oversized gate above are the ONLY steps that can decline this
|
|
2336
|
+
// write, and they are synchronous — so by here the row set is definitely becoming shared
|
|
2337
|
+
// state, and it is frozen with no intervening yield point. That ordering is load-bearing: the eviction
|
|
1641
2338
|
// steps below are awaited, and callers hold this very array while they run (the
|
|
1642
2339
|
// smart-cache stale leg does not await this method at all). Freezing after them leaves
|
|
1643
2340
|
// a window in which shared rows are handed out still mutable — and `BaseEntity`
|
|
@@ -1655,7 +2352,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1655
2352
|
await this.evictIfNeeded(sizeBytes);
|
|
1656
2353
|
try {
|
|
1657
2354
|
// Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
|
|
1658
|
-
|
|
2355
|
+
const expiresAt = ttlMs ? Date.now() + ttlMs : undefined;
|
|
2356
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, expiresAt));
|
|
1659
2357
|
this.registerEntry({
|
|
1660
2358
|
key: fingerprint,
|
|
1661
2359
|
type: 'runview',
|
|
@@ -1675,7 +2373,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1675
2373
|
sizeBytes,
|
|
1676
2374
|
maxUpdatedAt,
|
|
1677
2375
|
rowCount: results.length, // Registry still tracks this for display/stats, derived from actual results
|
|
1678
|
-
expiresAt
|
|
2376
|
+
expiresAt // time-based expiry (external entities); undefined => event-invalidated (MJ-DB entities)
|
|
1679
2377
|
});
|
|
1680
2378
|
// Maintain entity→fingerprint reverse index for universal cache invalidation
|
|
1681
2379
|
this.addToEntityIndex(fingerprint);
|
|
@@ -1694,12 +2392,20 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1694
2392
|
* @returns The cached results, maxUpdatedAt, rowCount (derived), and aggregateResults, or null if not found
|
|
1695
2393
|
*/
|
|
1696
2394
|
async GetRunViewResult(fingerprint) {
|
|
2395
|
+
if (this.isPendingInBatch(fingerprint)) {
|
|
2396
|
+
this._stats.misses++;
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
return this.readRunViewSlot(fingerprint, 'serve');
|
|
2400
|
+
}
|
|
2401
|
+
/** {@link GetRunViewResult} without the open-batch check; slot maintenance reads through this. */
|
|
2402
|
+
async readRunViewSlot(fingerprint, purpose) {
|
|
1697
2403
|
if (!this._storageProvider || !this._config.enabled)
|
|
1698
2404
|
return null;
|
|
1699
2405
|
try {
|
|
1700
2406
|
// Native object read — IDB structured-clones the result back, no JSON.parse needed.
|
|
1701
2407
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunViewCache);
|
|
1702
|
-
return this.materializeCachedRunViewResult(fingerprint, parsed);
|
|
2408
|
+
return this.materializeCachedRunViewResult(fingerprint, parsed, purpose);
|
|
1703
2409
|
}
|
|
1704
2410
|
catch (e) {
|
|
1705
2411
|
LogError(`LocalCacheManager.GetRunViewResult failed: ${e}`);
|
|
@@ -1739,7 +2445,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1739
2445
|
try {
|
|
1740
2446
|
const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
|
|
1741
2447
|
for (const [fp, parsed] of raw) {
|
|
1742
|
-
out.set(fp, this.materializeCachedRunViewResult(fp, parsed));
|
|
2448
|
+
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed, 'serve'));
|
|
1743
2449
|
}
|
|
1744
2450
|
return out;
|
|
1745
2451
|
}
|
|
@@ -1759,10 +2465,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1759
2465
|
* recording the appropriate hit/miss + access-tracking side effects.
|
|
1760
2466
|
*
|
|
1761
2467
|
* Also validates the schema hash (if present) to detect structurally stale
|
|
1762
|
-
* cache entries after schema migrations. If the entity's field list
|
|
1763
|
-
*
|
|
2468
|
+
* cache entries after schema migrations. If the entity's field list differs
|
|
2469
|
+
* from the one the entry was cached under, null is returned; whether the slot
|
|
2470
|
+
* is also deleted depends on `purpose` (see {@link handleSchemaMismatch}).
|
|
1764
2471
|
*/
|
|
1765
|
-
materializeCachedRunViewResult(fingerprint, parsed) {
|
|
2472
|
+
materializeCachedRunViewResult(fingerprint, parsed, purpose) {
|
|
1766
2473
|
if (!parsed) {
|
|
1767
2474
|
this._stats.misses++;
|
|
1768
2475
|
return null;
|
|
@@ -1776,12 +2483,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1776
2483
|
this._stats.misses++;
|
|
1777
2484
|
return null;
|
|
1778
2485
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
2486
|
+
const schemaMismatch = this.findSchemaMismatch(fingerprint, parsed);
|
|
2487
|
+
if (schemaMismatch) {
|
|
2488
|
+
this.handleSchemaMismatch(schemaMismatch, purpose, () => this.InvalidateRunViewResult(fingerprint));
|
|
1781
2489
|
this._stats.misses++;
|
|
1782
2490
|
return null;
|
|
1783
2491
|
}
|
|
1784
2492
|
this.recordAccess(fingerprint);
|
|
2493
|
+
this.indexSlotThisProcessDidNotWrite(fingerprint);
|
|
1785
2494
|
this._stats.hits++;
|
|
1786
2495
|
const results = parsed.results || [];
|
|
1787
2496
|
const result = {
|
|
@@ -1791,6 +2500,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1791
2500
|
totalRowCount: parsed.totalRowCount,
|
|
1792
2501
|
// Surfaced so in-place maintenance can carry it forward on rewrite (B38).
|
|
1793
2502
|
schemaHash: parsed.schemaHash,
|
|
2503
|
+
schemaStamp: parsed.schemaStamp,
|
|
1794
2504
|
// Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
|
|
1795
2505
|
providerInternalScaffolding: parsed.providerInternalScaffolding,
|
|
1796
2506
|
};
|
|
@@ -1799,6 +2509,34 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1799
2509
|
}
|
|
1800
2510
|
return result;
|
|
1801
2511
|
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Records a slot this process READ, so a later save can still find it once it has expired.
|
|
2514
|
+
*
|
|
2515
|
+
* An engine loads its rows once and never reads its slot again — that is the design, not a
|
|
2516
|
+
* defect: the slot is a boot shortcut and a propagation channel, and correctness comes from
|
|
2517
|
+
* events. Giving slots a finite expiry opened a window those events could not cross. When a
|
|
2518
|
+
* slot expires under running replicas, a save finds nothing to rewrite and publishes nothing,
|
|
2519
|
+
* so every peer engine keeps serving what it loaded.
|
|
2520
|
+
*
|
|
2521
|
+
* The fix — invalidate a slot that cannot be maintained, which publishes `removed` — only
|
|
2522
|
+
* fires if the saving process still holds the fingerprint, and only the process that WROTE the
|
|
2523
|
+
* slot did. A server that booted warm read it instead, and a read indexed nothing; behind a
|
|
2524
|
+
* load balancer, which server takes the save is arbitrary. A warm read is the only trace such a
|
|
2525
|
+
* process has of that slot, so it is indexed too.
|
|
2526
|
+
*
|
|
2527
|
+
* Gated on the provider exposing a shared per-entity index, which is exactly the case
|
|
2528
|
+
* {@link resolveFingerprintsForEntity} answers from that index — with no category-scan
|
|
2529
|
+
* fallback. On a process-local store there is nothing to gain (no peers to notify, and every
|
|
2530
|
+
* slot in it was written by this process, so it is already indexed) and something to lose: a
|
|
2531
|
+
* non-empty index SUPPRESSES that fallback scan, which is what finds persisted slots the index
|
|
2532
|
+
* does not know about.
|
|
2533
|
+
* @internal
|
|
2534
|
+
*/
|
|
2535
|
+
indexSlotThisProcessDidNotWrite(fingerprint) {
|
|
2536
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
2537
|
+
this.addToEntityIndex(fingerprint);
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
1802
2540
|
/**
|
|
1803
2541
|
* Invalidates a cached RunView result.
|
|
1804
2542
|
*
|
|
@@ -1948,16 +2686,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1948
2686
|
}
|
|
1949
2687
|
}
|
|
1950
2688
|
/**
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
1954
|
-
*
|
|
1955
|
-
* Serializes async operations on the same cache fingerprint to prevent
|
|
1956
|
-
* lost-update races. When multiple entity events fire simultaneously
|
|
1957
|
-
* (e.g., 3 deletes from a TransactionGroup), each read-modify-write cycle
|
|
1958
|
-
* must complete before the next one starts for the same fingerprint.
|
|
1959
|
-
* Different fingerprints run concurrently with no contention.
|
|
2689
|
+
* Runs an in-place slot rewrite under {@link withFingerprintLock}. When the lock itself fails
|
|
2690
|
+
* (a shared lock another server held too long), reports the rewrite as not done rather than
|
|
2691
|
+
* throwing, so the caller's fallback — invalidate the slot, making every server reload —
|
|
2692
|
+
* applies.
|
|
1960
2693
|
*/
|
|
2694
|
+
async maintainSlotLocked(fingerprint, fn) {
|
|
2695
|
+
try {
|
|
2696
|
+
return await this.withFingerprintLock(fingerprint, fn);
|
|
2697
|
+
}
|
|
2698
|
+
catch (e) {
|
|
2699
|
+
// Distinguish "another process holds this slot" (expected contention — the caller
|
|
2700
|
+
// invalidates instead of writing) from a fault inside the work itself, which is a bug
|
|
2701
|
+
// and must be reported as one rather than filed under lock contention. The
|
|
2702
|
+
// storage provider owns those error types, and it depends on this package, so they are
|
|
2703
|
+
// recognised by name rather than imported.
|
|
2704
|
+
const name = e instanceof Error ? e.name : '';
|
|
2705
|
+
const isLockProblem = name === 'KeyLockTimeoutError' || name === 'KeyLockLostError';
|
|
2706
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
2707
|
+
if (isLockProblem) {
|
|
2708
|
+
LogWarning(`LocalCacheManager: could not hold the lock on "${fingerprint.substring(0, 60)}" for an in-place update (${detail}) — invalidating instead`, 'Cache');
|
|
2709
|
+
}
|
|
2710
|
+
else {
|
|
2711
|
+
LogError(`LocalCacheManager: the in-place update of "${fingerprint.substring(0, 60)}" failed (${detail}) — invalidating instead`);
|
|
2712
|
+
}
|
|
2713
|
+
return false;
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
1961
2716
|
async withFingerprintLock(fingerprint, fn) {
|
|
1962
2717
|
const existing = this._fingerprintLocks.get(fingerprint) ?? Promise.resolve();
|
|
1963
2718
|
let releaseLock;
|
|
@@ -1965,6 +2720,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1965
2720
|
this._fingerprintLocks.set(fingerprint, lockPromise);
|
|
1966
2721
|
try {
|
|
1967
2722
|
await existing; // Wait for any previous operation on this fingerprint
|
|
2723
|
+
// Another server may be rewriting the same shared entry right now; its write and ours
|
|
2724
|
+
// would each start from the same old array, and the later SET would drop the other's
|
|
2725
|
+
// row. A shared provider serializes the whole read-modify-write across processes.
|
|
2726
|
+
const provider = this._storageProvider;
|
|
2727
|
+
if (provider?.WithKeyLock) {
|
|
2728
|
+
return await provider.WithKeyLock(fingerprint, CacheCategory.RunViewCache, fn);
|
|
2729
|
+
}
|
|
1968
2730
|
return await fn();
|
|
1969
2731
|
}
|
|
1970
2732
|
finally {
|
|
@@ -1976,46 +2738,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1976
2738
|
}
|
|
1977
2739
|
}
|
|
1978
2740
|
/**
|
|
2741
|
+
* Upserts a single entity in a cached RunView result.
|
|
2742
|
+
* Used by BaseEngine for immediate cache sync when an entity is saved.
|
|
2743
|
+
* If the entity exists (by primary key), it is replaced; otherwise it is added.
|
|
2744
|
+
*
|
|
2745
|
+
* Serializes async operations on the same cache fingerprint to prevent
|
|
2746
|
+
* lost-update races. When multiple entity events fire simultaneously
|
|
2747
|
+
* (e.g., 3 deletes from a TransactionGroup), each read-modify-write cycle
|
|
2748
|
+
* must complete before the next one starts for the same fingerprint.
|
|
2749
|
+
* Different fingerprints run concurrently with no contention.
|
|
2750
|
+
*
|
|
1979
2751
|
* @param fingerprint - The cache fingerprint to update
|
|
1980
2752
|
* @param entityData - The entity data as a plain object (use entity.GetAll())
|
|
1981
|
-
* @param
|
|
2753
|
+
* @param key - The record's primary key
|
|
1982
2754
|
* @param newMaxUpdatedAt - New maxUpdatedAt timestamp (from entity's __mj_UpdatedAt)
|
|
1983
2755
|
* @returns true if cache was updated, false if cache not found or update failed
|
|
1984
2756
|
*/
|
|
1985
2757
|
async UpsertSingleEntity(fingerprint, entityData, key, newMaxUpdatedAt) {
|
|
1986
|
-
|
|
1987
|
-
return false;
|
|
1988
|
-
return this.withFingerprintLock(fingerprint, async () => {
|
|
1989
|
-
try {
|
|
1990
|
-
const cached = await this.GetRunViewResult(fingerprint);
|
|
1991
|
-
if (!cached) {
|
|
1992
|
-
LogStatusVerbose(`LocalCacheManager.UpsertSingleEntity: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
|
|
1993
|
-
return false;
|
|
1994
|
-
}
|
|
1995
|
-
LogStatusVerbose(`LocalCacheManager.UpsertSingleEntity: Found cached data with ${cached.results.length} rows, updating...`);
|
|
1996
|
-
const pkFieldNames = key.KeyValuePairs.map(kv => kv.FieldName);
|
|
1997
|
-
const keyStr = this.cheapKeyFromCompositeKey(key);
|
|
1998
|
-
// Build a map of existing records by composite key string. Uses a cheap
|
|
1999
|
-
// delimiter-joined PK string (no per-row CompositeKey/KeyValuePair allocation);
|
|
2000
|
-
// matching is consistent because keyStr above uses the same format.
|
|
2001
|
-
const resultMap = new Map();
|
|
2002
|
-
for (const row of cached.results) {
|
|
2003
|
-
const rowObj = row;
|
|
2004
|
-
if (pkFieldNames.some(fn => rowObj[fn] == null))
|
|
2005
|
-
continue; // Skip rows with missing PK fields
|
|
2006
|
-
resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
|
|
2007
|
-
}
|
|
2008
|
-
// Upsert the entity (add or replace)
|
|
2009
|
-
resultMap.set(keyStr, entityData);
|
|
2010
|
-
const updatedResults = Array.from(resultMap.values());
|
|
2011
|
-
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2012
|
-
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2013
|
-
}
|
|
2014
|
-
catch (e) {
|
|
2015
|
-
LogError(`LocalCacheManager.UpsertSingleEntity failed: ${e}`);
|
|
2016
|
-
return false;
|
|
2017
|
-
}
|
|
2018
|
-
});
|
|
2758
|
+
return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: entityData }], newMaxUpdatedAt);
|
|
2019
2759
|
}
|
|
2020
2760
|
/**
|
|
2021
2761
|
* Removes a single entity from a cached RunView result.
|
|
@@ -2027,39 +2767,70 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2027
2767
|
* @returns true if cache was updated, false if cache not found or update failed
|
|
2028
2768
|
*/
|
|
2029
2769
|
async RemoveSingleEntity(fingerprint, key, newMaxUpdatedAt) {
|
|
2030
|
-
|
|
2770
|
+
return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: null }], newMaxUpdatedAt);
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* Applies several row changes to one cached RunView result in a single read-modify-write:
|
|
2774
|
+
* each change with a `Row` adds or replaces that row, each with `Row: null` removes it, in
|
|
2775
|
+
* order. The slot is written once, and not at all when only removals of absent rows were asked.
|
|
2776
|
+
* All keys must name the same primary-key fields.
|
|
2777
|
+
*
|
|
2778
|
+
* @returns true if the slot now reflects the changes, false if it was not cached or the update failed
|
|
2779
|
+
*/
|
|
2780
|
+
async ApplyRowChanges(fingerprint, changes, newMaxUpdatedAt) {
|
|
2781
|
+
if (!this._storageProvider || !this._config.enabled || changes.length === 0)
|
|
2031
2782
|
return false;
|
|
2032
|
-
return this.
|
|
2783
|
+
return this.maintainSlotLocked(fingerprint, async () => {
|
|
2033
2784
|
try {
|
|
2034
|
-
const cached = await this.
|
|
2785
|
+
const cached = await this.readRunViewSlot(fingerprint, 'maintain');
|
|
2035
2786
|
if (!cached) {
|
|
2787
|
+
LogStatusVerbose(`LocalCacheManager.ApplyRowChanges: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
|
|
2036
2788
|
return false;
|
|
2037
2789
|
}
|
|
2038
|
-
const pkFieldNames =
|
|
2039
|
-
const
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
const resultMap = new Map();
|
|
2043
|
-
for (const row of cached.results) {
|
|
2044
|
-
const rowObj = row;
|
|
2045
|
-
if (pkFieldNames.some(fn => rowObj[fn] == null))
|
|
2046
|
-
continue; // Skip rows with missing PK fields
|
|
2047
|
-
resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
|
|
2790
|
+
const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
|
|
2791
|
+
const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
|
|
2792
|
+
if (!this.applyChangesToRowMap(resultMap, changes)) {
|
|
2793
|
+
return true; // only removals of rows the slot does not hold
|
|
2048
2794
|
}
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
}
|
|
2052
|
-
resultMap.delete(keyStr);
|
|
2053
|
-
const updatedResults = Array.from(resultMap.values());
|
|
2054
|
-
return await this.storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2055
|
-
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2795
|
+
return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2796
|
+
schemaStamp: cached.schemaStamp, providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2056
2797
|
}
|
|
2057
2798
|
catch (e) {
|
|
2058
|
-
LogError(`LocalCacheManager.
|
|
2799
|
+
LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
|
|
2059
2800
|
return false;
|
|
2060
2801
|
}
|
|
2061
2802
|
});
|
|
2062
2803
|
}
|
|
2804
|
+
/**
|
|
2805
|
+
* Rows keyed by a cheap delimiter-joined PK string (no per-row CompositeKey/KeyValuePair
|
|
2806
|
+
* allocation); matching is consistent because {@link cheapKeyFromCompositeKey} uses the same
|
|
2807
|
+
* format. Rows missing a PK value are dropped.
|
|
2808
|
+
*/
|
|
2809
|
+
indexRowsByKey(rows, pkFieldNames) {
|
|
2810
|
+
const resultMap = new Map();
|
|
2811
|
+
for (const row of rows) {
|
|
2812
|
+
const rowObj = row;
|
|
2813
|
+
if (pkFieldNames.some(fn => rowObj[fn] == null))
|
|
2814
|
+
continue;
|
|
2815
|
+
resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
|
|
2816
|
+
}
|
|
2817
|
+
return resultMap;
|
|
2818
|
+
}
|
|
2819
|
+
/** Applies `changes` to `rows` in order. @returns True when anything changed. */
|
|
2820
|
+
applyChangesToRowMap(rows, changes) {
|
|
2821
|
+
let changed = false;
|
|
2822
|
+
for (const change of changes) {
|
|
2823
|
+
const keyStr = this.cheapKeyFromCompositeKey(change.Key);
|
|
2824
|
+
if (change.Row) {
|
|
2825
|
+
rows.set(keyStr, change.Row);
|
|
2826
|
+
changed = true;
|
|
2827
|
+
}
|
|
2828
|
+
else if (rows.delete(keyStr)) {
|
|
2829
|
+
changed = true;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
return changed;
|
|
2833
|
+
}
|
|
2063
2834
|
/**
|
|
2064
2835
|
* Stores updated results array back to the cache and updates the registry.
|
|
2065
2836
|
* Shared by UpsertSingleEntity and RemoveSingleEntity to avoid duplication.
|
|
@@ -2075,9 +2846,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2075
2846
|
* correct total.
|
|
2076
2847
|
*/
|
|
2077
2848
|
async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
|
|
2849
|
+
// Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
|
|
2850
|
+
// stamp means the same thing whichever funnel wrote the slot and matches what the database
|
|
2851
|
+
// reports for the same rows (the client smart-cache check compares the two). The caller's
|
|
2852
|
+
// value (the event time) is used only when the rows carry no timestamp column.
|
|
2853
|
+
const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(updatedResults) ?? newMaxUpdatedAt;
|
|
2078
2854
|
const data = {
|
|
2079
2855
|
results: updatedResults,
|
|
2080
|
-
maxUpdatedAt
|
|
2856
|
+
maxUpdatedAt
|
|
2081
2857
|
};
|
|
2082
2858
|
// Carry the schemaHash FORWARD — never recompute it here (B38).
|
|
2083
2859
|
//
|
|
@@ -2093,6 +2869,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2093
2869
|
if (prior?.schemaHash) {
|
|
2094
2870
|
data.schemaHash = prior.schemaHash;
|
|
2095
2871
|
}
|
|
2872
|
+
if (prior?.schemaStamp) {
|
|
2873
|
+
data.schemaStamp = prior.schemaStamp;
|
|
2874
|
+
}
|
|
2096
2875
|
// Carry the freeze exemption forward too — a scaffolding slot that gets maintained in
|
|
2097
2876
|
// place must not silently become frozen, or the owner that mutates those rows breaks on
|
|
2098
2877
|
// the next read (for the metadata dataset that means booting with no metadata).
|
|
@@ -2116,10 +2895,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2116
2895
|
if (!prior?.providerInternalScaffolding) {
|
|
2117
2896
|
this.freezeRowDataIfProviderSharesReferences(data);
|
|
2118
2897
|
}
|
|
2119
|
-
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache);
|
|
2120
2898
|
const existingEntry = this._registry.get(fingerprint);
|
|
2899
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
|
|
2121
2900
|
if (existingEntry) {
|
|
2122
|
-
existingEntry.maxUpdatedAt =
|
|
2901
|
+
existingEntry.maxUpdatedAt = maxUpdatedAt;
|
|
2123
2902
|
existingEntry.rowCount = updatedResults.length;
|
|
2124
2903
|
existingEntry.sizeBytes = sizeBytes;
|
|
2125
2904
|
existingEntry.lastAccessedAt = Date.now();
|
|
@@ -2198,8 +2977,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2198
2977
|
* @param rowCount - Optional row count (defaults to results.length if not provided)
|
|
2199
2978
|
* @param queryId - Optional query ID for reference
|
|
2200
2979
|
* @param ttlMs - Optional TTL in milliseconds (for cache expiry tracking)
|
|
2201
|
-
|
|
2202
|
-
|
|
2980
|
+
* @param warmedForUserID - The user whose authorized execution produced the rows
|
|
2981
|
+
* @param definition - The query definition the rows were computed under. Stored with the slot so
|
|
2982
|
+
* a read under a different definition misses (see {@link GetRunQueryResult}), and used to index
|
|
2983
|
+
* the slot by query ID (see {@link InvalidateRunQueryResultsForQuery}). On a shared store the
|
|
2984
|
+
* write is skipped while the query's slots are under a write hold (see
|
|
2985
|
+
* `handleSchemaMismatch`).
|
|
2986
|
+
*/
|
|
2987
|
+
async SetRunQueryResult(fingerprint, queryName, results, maxUpdatedAt, rowCount, queryId, ttlMs, warmedForUserID, definition) {
|
|
2203
2988
|
if (!this._storageProvider || !this._config.enabled)
|
|
2204
2989
|
return;
|
|
2205
2990
|
const actualRowCount = rowCount ?? results.length;
|
|
@@ -2209,17 +2994,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2209
2994
|
// does not refresh in-process): the warmer proved their permission by executing; anyone
|
|
2210
2995
|
// ELSE falls through to an authorized execution rather than being served unchecked.
|
|
2211
2996
|
const data = { results, maxUpdatedAt, rowCount: actualRowCount, queryId, warmedForUserID };
|
|
2997
|
+
if (definition) {
|
|
2998
|
+
if (!this.mayWriteUnderSchemaHold(LocalCacheManager.runQuerySubject(definition, queryName), definition.Hash)) {
|
|
2999
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — its shared slots were found under a different definition`, verboseOnly: true });
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
data.definitionHash = definition.Hash;
|
|
3003
|
+
if (definition.Stamp) {
|
|
3004
|
+
data.definitionStamp = definition.Stamp;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
2212
3007
|
// Estimate size by sampling rows (eviction accounting only).
|
|
2213
3008
|
const sizeBytes = this.estimateResultsSize(results);
|
|
2214
3009
|
// Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
|
|
2215
3010
|
// cache to make room for an entry that can't be retained within budget.
|
|
2216
3011
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2217
|
-
|
|
3012
|
+
LogWarning(this.describeOversizedEntry(`query ${queryName}`, sizeBytes), 'Cache');
|
|
2218
3013
|
return;
|
|
2219
3014
|
}
|
|
2220
3015
|
// Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
|
|
2221
3016
|
// `results` straight back out) — closed here rather than waiting for a mutator to
|
|
2222
|
-
// find it. Placed immediately after the only declining
|
|
3017
|
+
// find it. Placed immediately after the only declining gates and BEFORE the awaited
|
|
2223
3018
|
// eviction below, for the same reason as SetRunViewResult: callers of this method do
|
|
2224
3019
|
// not always await it, so any yield point before the freeze is a window in which the
|
|
2225
3020
|
// caller can still mutate the rows the cache is about to store.
|
|
@@ -2227,10 +3022,17 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2227
3022
|
// Check if we need to evict entries. Evicts OTHER entries only — cannot cancel this write.
|
|
2228
3023
|
await this.evictIfNeeded(sizeBytes);
|
|
2229
3024
|
const now = Date.now();
|
|
2230
|
-
|
|
3025
|
+
// Keep the expiry a rewrite found, exactly as the RunView path does: recomputing it from
|
|
3026
|
+
// `now` on every write pushes a bounded entry's expiry forward for as long as anything keeps
|
|
3027
|
+
// rewriting it, so it never expires at all.
|
|
3028
|
+
const existingExpiry = this._registry.get(fingerprint)?.expiresAt;
|
|
3029
|
+
const expiresAt = existingExpiry ?? (ttlMs ? now + ttlMs : undefined);
|
|
2231
3030
|
try {
|
|
2232
|
-
//
|
|
2233
|
-
|
|
3031
|
+
// The expiry must reach the STORE, not just this process's registry: another server
|
|
3032
|
+
// reading this slot has no registry entry for it, so a store-side TTL is the only thing
|
|
3033
|
+
// that bounds it there. Without it an external-data-source query result was served
|
|
3034
|
+
// forever by every server except the one that wrote it.
|
|
3035
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache, LocalCacheManager.runQueryWriteOptions(expiresAt, now, definition?.QueryID ?? queryId));
|
|
2234
3036
|
this.registerEntry({
|
|
2235
3037
|
key: fingerprint,
|
|
2236
3038
|
type: 'runquery',
|
|
@@ -2249,13 +3051,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2249
3051
|
LogError(`LocalCacheManager.SetRunQueryResult failed: ${e}`);
|
|
2250
3052
|
}
|
|
2251
3053
|
}
|
|
3054
|
+
/**
|
|
3055
|
+
* Store options for a RunQuery slot: its expiry, and the query ID as the index group, so a
|
|
3056
|
+
* shared store can list every slot of one query whichever process wrote it.
|
|
3057
|
+
*/
|
|
3058
|
+
static runQueryWriteOptions(expiresAt, now, queryId) {
|
|
3059
|
+
const options = {};
|
|
3060
|
+
if (expiresAt) {
|
|
3061
|
+
options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - now) / 1000));
|
|
3062
|
+
}
|
|
3063
|
+
if (queryId) {
|
|
3064
|
+
options.IndexGroup = queryId.trim().toLowerCase();
|
|
3065
|
+
}
|
|
3066
|
+
return options.TTLSeconds !== undefined || options.IndexGroup !== undefined ? options : undefined;
|
|
3067
|
+
}
|
|
2252
3068
|
/**
|
|
2253
3069
|
* Retrieves a cached RunQuery result.
|
|
2254
3070
|
*
|
|
2255
3071
|
* @param fingerprint - The cache fingerprint
|
|
2256
|
-
* @
|
|
2257
|
-
|
|
2258
|
-
|
|
3072
|
+
* @param expectedDefinition - The query definition the caller would execute on a miss. When
|
|
3073
|
+
* given, a slot written under a different definition (or one that records none) is a miss.
|
|
3074
|
+
* On a process-local store the slot is also deleted; on a shared store it is left in place and
|
|
3075
|
+
* this process's writes of the query's slots are held (see `handleSchemaMismatch`). Omit it
|
|
3076
|
+
* where the caller cannot compute a definition (a client, whose slots the server validates).
|
|
3077
|
+
* @returns The cached results, maxUpdatedAt, rowCount, queryId and definition hash, or null if
|
|
3078
|
+
* not found, expired, or written under another definition
|
|
3079
|
+
*/
|
|
3080
|
+
async GetRunQueryResult(fingerprint, expectedDefinition) {
|
|
2259
3081
|
if (!this._storageProvider || !this._config.enabled)
|
|
2260
3082
|
return null;
|
|
2261
3083
|
// Check if entry has expired
|
|
@@ -2270,6 +3092,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2270
3092
|
// Native object read — no JSON.parse needed.
|
|
2271
3093
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunQueryCache);
|
|
2272
3094
|
if (parsed) {
|
|
3095
|
+
const mismatch = expectedDefinition
|
|
3096
|
+
? LocalCacheManager.findDefinitionMismatch(parsed, expectedDefinition, entry?.name)
|
|
3097
|
+
: null;
|
|
3098
|
+
if (mismatch) {
|
|
3099
|
+
this.handleSchemaMismatch(mismatch, 'serve', () => this.InvalidateRunQueryResult(fingerprint));
|
|
3100
|
+
this._stats.misses++;
|
|
3101
|
+
return null;
|
|
3102
|
+
}
|
|
2273
3103
|
this.recordAccess(fingerprint);
|
|
2274
3104
|
this._stats.hits++;
|
|
2275
3105
|
// Handle legacy entries that may not have rowCount
|
|
@@ -2283,7 +3113,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2283
3113
|
// (the B38 omission pattern, which this session exists to stamp out — and
|
|
2284
3114
|
// which the first version of this very fix repeated) silently disabled that
|
|
2285
3115
|
// tie-break and with it TTL caching for runtime-created queries.
|
|
2286
|
-
warmedForUserID: parsed.warmedForUserID
|
|
3116
|
+
warmedForUserID: parsed.warmedForUserID,
|
|
3117
|
+
// Same pass-through rule: a client sends it back to the server, which answers
|
|
3118
|
+
// `stale` when its own definition differs.
|
|
3119
|
+
definitionHash: parsed.definitionHash
|
|
2287
3120
|
};
|
|
2288
3121
|
}
|
|
2289
3122
|
}
|
|
@@ -2293,6 +3126,22 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2293
3126
|
this._stats.misses++;
|
|
2294
3127
|
return null;
|
|
2295
3128
|
}
|
|
3129
|
+
/**
|
|
3130
|
+
* Compares a RunQuery slot's definition hash with the definition the caller expects. A slot
|
|
3131
|
+
* that records no hash was written before slots carried one, so nothing says which SQL produced
|
|
3132
|
+
* its rows; it counts as a mismatch with an unknown order.
|
|
3133
|
+
*/
|
|
3134
|
+
static findDefinitionMismatch(data, expected, queryName) {
|
|
3135
|
+
if (data.definitionHash === expected.Hash) {
|
|
3136
|
+
return null;
|
|
3137
|
+
}
|
|
3138
|
+
return {
|
|
3139
|
+
Subject: LocalCacheManager.runQuerySubject(expected, queryName),
|
|
3140
|
+
CachedHash: data.definitionHash ?? '',
|
|
3141
|
+
Current: { Hash: expected.Hash, Stamp: expected.Stamp },
|
|
3142
|
+
EntryIsNewer: LocalCacheManager.isNewerSchemaStamp(data.definitionStamp, expected.Stamp),
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
2296
3145
|
/**
|
|
2297
3146
|
* Invalidates a cached RunQuery result.
|
|
2298
3147
|
*
|
|
@@ -2313,7 +3162,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2313
3162
|
* Invalidates all cached RunQuery results for a specific query.
|
|
2314
3163
|
* Useful when a query's underlying data changes and all related caches should be cleared.
|
|
2315
3164
|
*
|
|
3165
|
+
* Matches this process's registry by name only, so it misses slots written by other processes
|
|
3166
|
+
* on a shared store and slots written under a former name.
|
|
3167
|
+
*
|
|
2316
3168
|
* @param queryName - The query name to invalidate
|
|
3169
|
+
* @deprecated Use {@link InvalidateRunQueryResultsForQuery}, which finds a query's slots by ID
|
|
3170
|
+
* in every process that shares the store.
|
|
2317
3171
|
*/
|
|
2318
3172
|
async InvalidateQueryCaches(queryName) {
|
|
2319
3173
|
if (!this._storageProvider)
|
|
@@ -2336,12 +3190,66 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2336
3190
|
}
|
|
2337
3191
|
await this.persistRegistry();
|
|
2338
3192
|
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Deletes every cached RunQuery result of one query: the slots this process wrote, and on a
|
|
3195
|
+
* shared store every slot any process wrote with the query's ID (see {@link SetRunQueryResult}).
|
|
3196
|
+
* Slots keyed by the query's name alone, or written before slots were indexed, are not found;
|
|
3197
|
+
* a definition check on read (see {@link GetRunQueryResult}) keeps them from being served.
|
|
3198
|
+
* Never throws.
|
|
3199
|
+
*
|
|
3200
|
+
* @param queryId - The query whose slots to delete
|
|
3201
|
+
* @returns The number of slots deleted
|
|
3202
|
+
*/
|
|
3203
|
+
async InvalidateRunQueryResultsForQuery(queryId) {
|
|
3204
|
+
if (!this._storageProvider || !queryId)
|
|
3205
|
+
return 0;
|
|
3206
|
+
const keys = await this.findRunQueryKeysForQuery(queryId);
|
|
3207
|
+
let removed = 0;
|
|
3208
|
+
for (const key of keys) {
|
|
3209
|
+
try {
|
|
3210
|
+
await this._storageProvider.Remove(key, CacheCategory.RunQueryCache);
|
|
3211
|
+
this._registry.delete(key);
|
|
3212
|
+
removed++;
|
|
3213
|
+
}
|
|
3214
|
+
catch (e) {
|
|
3215
|
+
LogError(`LocalCacheManager.InvalidateRunQueryResultsForQuery failed for key ${key}: ${e}`);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
if (removed > 0) {
|
|
3219
|
+
await this.persistRegistry();
|
|
3220
|
+
}
|
|
3221
|
+
return removed;
|
|
3222
|
+
}
|
|
3223
|
+
/** The keys of one query's RunQuery slots: this process's registry, plus the shared index. */
|
|
3224
|
+
async findRunQueryKeysForQuery(queryId) {
|
|
3225
|
+
const id = queryId.trim().toLowerCase();
|
|
3226
|
+
const keys = new Set();
|
|
3227
|
+
for (const [key, entry] of this._registry.entries()) {
|
|
3228
|
+
// A fingerprint is `Name|ID|Category|Params[|Connection]`; matching any segment keeps a
|
|
3229
|
+
// name that itself contains '|' from hiding the ID.
|
|
3230
|
+
if (entry.type === 'runquery' && key.toLowerCase().split('|').includes(id)) {
|
|
3231
|
+
keys.add(key);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
3235
|
+
try {
|
|
3236
|
+
for (const key of await this._storageProvider.GetIndexGroupKeys(CacheCategory.RunQueryCache, id)) {
|
|
3237
|
+
keys.add(key);
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
catch (e) {
|
|
3241
|
+
LogError(`LocalCacheManager: listing RunQuery slots of query ${queryId} failed: ${e}`);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
return keys;
|
|
3245
|
+
}
|
|
2339
3246
|
/**
|
|
2340
3247
|
* Gets the cache status (fingerprint data) for a RunQuery result.
|
|
2341
3248
|
* Used for smart cache validation with the server.
|
|
2342
3249
|
*
|
|
2343
3250
|
* @param fingerprint - The cache fingerprint
|
|
2344
|
-
* @returns The cache status with maxUpdatedAt and
|
|
3251
|
+
* @returns The cache status with maxUpdatedAt, rowCount and the definition hash the slot was
|
|
3252
|
+
* written under, or null if not found/expired
|
|
2345
3253
|
*/
|
|
2346
3254
|
async GetRunQueryCacheStatus(fingerprint) {
|
|
2347
3255
|
const cached = await this.GetRunQueryResult(fingerprint);
|
|
@@ -2349,7 +3257,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2349
3257
|
return null;
|
|
2350
3258
|
return {
|
|
2351
3259
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
2352
|
-
rowCount: cached.rowCount
|
|
3260
|
+
rowCount: cached.rowCount,
|
|
3261
|
+
definitionHash: cached.definitionHash
|
|
2353
3262
|
};
|
|
2354
3263
|
}
|
|
2355
3264
|
// ========================================================================
|
|
@@ -2379,17 +3288,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2379
3288
|
runview: { count: 0, sizeBytes: 0 },
|
|
2380
3289
|
runquery: { count: 0, sizeBytes: 0 }
|
|
2381
3290
|
};
|
|
3291
|
+
let totalSizeBytes = 0;
|
|
3292
|
+
let oldestEntry = Infinity;
|
|
3293
|
+
let newestEntry = 0;
|
|
2382
3294
|
for (const entry of entries) {
|
|
2383
3295
|
byType[entry.type].count++;
|
|
2384
3296
|
byType[entry.type].sizeBytes += entry.sizeBytes;
|
|
3297
|
+
totalSizeBytes += entry.sizeBytes;
|
|
3298
|
+
if (entry.cachedAt < oldestEntry)
|
|
3299
|
+
oldestEntry = entry.cachedAt;
|
|
3300
|
+
if (entry.cachedAt > newestEntry)
|
|
3301
|
+
newestEntry = entry.cachedAt;
|
|
2385
3302
|
}
|
|
2386
|
-
const timestamps = entries.map(e => e.cachedAt);
|
|
2387
3303
|
return {
|
|
2388
3304
|
totalEntries: entries.length,
|
|
2389
|
-
totalSizeBytes
|
|
3305
|
+
totalSizeBytes,
|
|
2390
3306
|
byType,
|
|
2391
|
-
oldestEntry:
|
|
2392
|
-
newestEntry
|
|
3307
|
+
oldestEntry: entries.length > 0 ? oldestEntry : 0,
|
|
3308
|
+
newestEntry,
|
|
2393
3309
|
hits: this._stats.hits,
|
|
2394
3310
|
misses: this._stats.misses
|
|
2395
3311
|
};
|
|
@@ -2515,11 +3431,21 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2515
3431
|
// Don't persist on every access - too expensive
|
|
2516
3432
|
}
|
|
2517
3433
|
}
|
|
3434
|
+
/**
|
|
3435
|
+
* True when the registry must stay in this process: the storage is shared with other
|
|
3436
|
+
* processes, so a persisted registry would be one key every server overwrites with its own
|
|
3437
|
+
* view (and publishes to every peer on each write), and a booting server would adopt another
|
|
3438
|
+
* server's accounting. Entries written by other processes are found through the provider's
|
|
3439
|
+
* per-entity index instead ({@link resolveFingerprintsForEntity}).
|
|
3440
|
+
*/
|
|
3441
|
+
registryIsProcessLocalOnly() {
|
|
3442
|
+
return this._storageProvider?.SharedAcrossProcesses === true;
|
|
3443
|
+
}
|
|
2518
3444
|
/**
|
|
2519
3445
|
* Loads the registry from storage.
|
|
2520
3446
|
*/
|
|
2521
3447
|
async loadRegistry() {
|
|
2522
|
-
if (!this._storageProvider)
|
|
3448
|
+
if (!this._storageProvider || this.registryIsProcessLocalOnly())
|
|
2523
3449
|
return;
|
|
2524
3450
|
try {
|
|
2525
3451
|
// Native object read — registry is a plain CacheEntryInfo[] array.
|
|
@@ -2554,7 +3480,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2554
3480
|
* Persists the registry to storage.
|
|
2555
3481
|
*/
|
|
2556
3482
|
async persistRegistry() {
|
|
2557
|
-
if (!this._storageProvider)
|
|
3483
|
+
if (!this._storageProvider || this.registryIsProcessLocalOnly())
|
|
2558
3484
|
return;
|
|
2559
3485
|
try {
|
|
2560
3486
|
// Native object storage — store the entries array directly.
|
|
@@ -2599,7 +3525,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2599
3525
|
return 0;
|
|
2600
3526
|
}
|
|
2601
3527
|
};
|
|
2602
|
-
const sampleCount = Math.min(
|
|
3528
|
+
const sampleCount = rowCount <= 3 ? rowCount : Math.min(10, Math.max(3, Math.ceil(rowCount * 0.10)));
|
|
2603
3529
|
if (sampleCount >= rowCount) {
|
|
2604
3530
|
// Small result set — measure every row (no point sampling).
|
|
2605
3531
|
let total = 0;
|
|
@@ -2621,32 +3547,89 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2621
3547
|
return Math.ceil(avgLen * rowCount) * 2;
|
|
2622
3548
|
}
|
|
2623
3549
|
/**
|
|
2624
|
-
*
|
|
2625
|
-
*
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
3550
|
+
* The largest single entry this cache will store, in bytes, or 0 when no ceiling
|
|
3551
|
+
* applies (`maxEntryPercentOfCache: 0`).
|
|
3552
|
+
*
|
|
3553
|
+
* `'auto'` derives it from the budget — the whole budget less
|
|
3554
|
+
* {@link SINGLE_ENTRY_RESERVE_PERCENT} — so the ceiling grows with the memory the
|
|
3555
|
+
* operator gave the cache instead of being a small fixed fraction that the biggest
|
|
3556
|
+
* entity can never fit under. An explicit percentage is honoured as a stricter
|
|
3557
|
+
* ceiling, clamped to {@link HARD_MAX_ENTRY_PERCENT} so no configuration can admit an
|
|
3558
|
+
* entry that leaves the cache with less free space than one eviction pass needs.
|
|
2630
3559
|
*/
|
|
2631
|
-
|
|
2632
|
-
const pct = this.
|
|
3560
|
+
get MaxEntrySizeBytes() {
|
|
3561
|
+
const pct = this.effectiveMaxEntryPercent;
|
|
2633
3562
|
if (pct <= 0)
|
|
3563
|
+
return 0;
|
|
3564
|
+
return Math.floor(this._config.maxSizeBytes * pct / 100);
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* The per-entry ceiling as a percentage of the budget, with `'auto'` resolved and an
|
|
3568
|
+
* explicit configuration clamped. 0 or below means "no ceiling".
|
|
3569
|
+
*/
|
|
3570
|
+
get effectiveMaxEntryPercent() {
|
|
3571
|
+
const pct = this._config.maxEntryPercentOfCache;
|
|
3572
|
+
if (pct === 'auto') {
|
|
3573
|
+
return 100 - Math.max(SINGLE_ENTRY_RESERVE_PERCENT, EVICTION_THRASH_FLOOR_PERCENT);
|
|
3574
|
+
}
|
|
3575
|
+
return Math.min(pct, HARD_MAX_ENTRY_PERCENT);
|
|
3576
|
+
}
|
|
3577
|
+
/**
|
|
3578
|
+
* Returns true when a single entry of the given estimated size exceeds
|
|
3579
|
+
* {@link MaxEntrySizeBytes} and must not be cached. See the config property's doc
|
|
3580
|
+
* comment for the rationale — in short, an entry that cannot be RETAINED within the
|
|
3581
|
+
* budget would be evicted again on the next store, so caching it is churn. An entry
|
|
3582
|
+
* that fits is cached however large it is: the cases that most need caching are the
|
|
3583
|
+
* large ones.
|
|
3584
|
+
*/
|
|
3585
|
+
exceedsMaxEntrySize(sizeBytes) {
|
|
3586
|
+
const ceiling = this.MaxEntrySizeBytes;
|
|
3587
|
+
if (ceiling <= 0)
|
|
2634
3588
|
return false;
|
|
2635
|
-
return sizeBytes >
|
|
3589
|
+
return sizeBytes > ceiling;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Renders the decline of an oversized entry as something an operator can act on: what
|
|
3593
|
+
* the ceiling is, and the `cacheSettings.maxMemoryMB` that would admit this entry.
|
|
3594
|
+
* The old message reported raw byte counts and a percentage, which named the symptom
|
|
3595
|
+
* but not the lever — and the entries it declines are exactly the expensive queries
|
|
3596
|
+
* whose absence from the cache costs the most.
|
|
3597
|
+
*/
|
|
3598
|
+
describeOversizedEntry(label, sizeBytes) {
|
|
3599
|
+
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
|
3600
|
+
const pct = this.effectiveMaxEntryPercent;
|
|
3601
|
+
const head = `[CACHE-WRITE-GATE] Not caching "${label}" — ${mb(sizeBytes)}MB exceeds the largest single entry this cache can retain (${mb(this.MaxEntrySizeBytes)}MB of a ${mb(this._config.maxSizeBytes)}MB budget). Every read of it goes to the database.`;
|
|
3602
|
+
// pct can only be <= 0 with the ceiling disabled, in which case nothing is declined
|
|
3603
|
+
// and this message is never produced — but the arithmetic below must not divide by it.
|
|
3604
|
+
if (pct <= 0)
|
|
3605
|
+
return head;
|
|
3606
|
+
const requiredBudgetMB = Math.ceil((sizeBytes * 100 / pct) / 1024 / 1024);
|
|
3607
|
+
return `${head} To cache it, raise cacheSettings.maxMemoryMB to ${requiredBudgetMB} or more.`;
|
|
2636
3608
|
}
|
|
2637
3609
|
/**
|
|
2638
3610
|
* Evicts entries if needed to make room for new data.
|
|
3611
|
+
*
|
|
3612
|
+
* Frees the DEFICIT — how far over budget the cache would be with the incoming entry —
|
|
3613
|
+
* rounded up to {@link EVICTION_THRASH_FLOOR_PERCENT} of the budget so a run of stores
|
|
3614
|
+
* near the budget does not evict on every one. It deliberately does not free the
|
|
3615
|
+
* incoming entry's whole size: for a large entry arriving into a mostly empty cache the
|
|
3616
|
+
* deficit is small, and freeing the entry's full size instead emptied the cache to make
|
|
3617
|
+
* room that was already there. That over-eviction is what made a large entry look
|
|
3618
|
+
* inherently destructive, and it is why the per-entry ceiling used to be set low enough
|
|
3619
|
+
* to exclude every large entry.
|
|
2639
3620
|
*/
|
|
2640
3621
|
async evictIfNeeded(neededBytes) {
|
|
2641
3622
|
if (!this._storageProvider)
|
|
2642
3623
|
return;
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
3624
|
+
let currentSizeBytes = 0;
|
|
3625
|
+
for (const entry of this._registry.values()) {
|
|
3626
|
+
currentSizeBytes += entry.sizeBytes;
|
|
3627
|
+
}
|
|
3628
|
+
const deficitBytes = (currentSizeBytes + neededBytes) - this._config.maxSizeBytes;
|
|
3629
|
+
if (deficitBytes <= 0)
|
|
2646
3630
|
return;
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
const targetFreeBytes = Math.max(neededBytes, this._config.maxSizeBytes * 0.1);
|
|
3631
|
+
const thrashFloorBytes = Math.floor(this._config.maxSizeBytes * EVICTION_THRASH_FLOOR_PERCENT / 100);
|
|
3632
|
+
const targetFreeBytes = Math.max(deficitBytes, thrashFloorBytes);
|
|
2650
3633
|
await this.evict(targetFreeBytes);
|
|
2651
3634
|
}
|
|
2652
3635
|
/**
|
|
@@ -2788,8 +3771,26 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2788
3771
|
if (!this._storageProvider || !this._config.enabled)
|
|
2789
3772
|
return;
|
|
2790
3773
|
const now = Date.now();
|
|
2791
|
-
|
|
3774
|
+
// On a shared store this process must not age out entries by its OWN clock: every server
|
|
3775
|
+
// would delete the same keys on its own schedule and publish a `removed` for each, and the
|
|
3776
|
+
// peers would reload every affected engine — a fleet-wide deletion and reload storm for
|
|
3777
|
+
// entries the store already expires itself (the Redis provider sets a per-key TTL). The
|
|
3778
|
+
// local TTL stays in force for process-private stores, where nothing else can expire them.
|
|
3779
|
+
const sharedStore = this._storageProvider.SharedAcrossProcesses === true;
|
|
3780
|
+
const ttlMs = sharedStore ? 0 : this._config.defaultTTLMs;
|
|
3781
|
+
if (sharedStore && this._config.defaultTTLMs > 0 && !this._sharedTTLWarned) {
|
|
3782
|
+
this._sharedTTLWarned = true;
|
|
3783
|
+
LogWarning(`LocalCacheManager: defaultTTLSeconds is set but the cache store is shared across processes — expiry is left to the store itself (set the store's own TTL, e.g. REDIS_TTL_SECONDS / cacheSettings.sharedCacheTTLSeconds)`, 'Cache');
|
|
3784
|
+
}
|
|
2792
3785
|
const toDelete = [];
|
|
3786
|
+
// Entries to drop from this process's bookkeeping WITHOUT touching the store. An entry that
|
|
3787
|
+
// carries its own `expiresAt` always handed the store the matching TTL when it was written,
|
|
3788
|
+
// so a shared store expires the key itself. Deleting it here as well means every server
|
|
3789
|
+
// removes the same key on its own clock and publishes a `removed` for it, and every peer
|
|
3790
|
+
// reloads the affected engine — the storm the TTL branch above already avoids, in miniature.
|
|
3791
|
+
// Forgetting it locally still matters: otherwise this registry records slots the store no
|
|
3792
|
+
// longer has.
|
|
3793
|
+
const toForget = [];
|
|
2793
3794
|
for (const [key, entry] of this._registry) {
|
|
2794
3795
|
// TTL expiry check
|
|
2795
3796
|
if (ttlMs > 0 && entry.cachedAt + ttlMs < now) {
|
|
@@ -2798,29 +3799,38 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2798
3799
|
}
|
|
2799
3800
|
// expiresAt check (if set individually)
|
|
2800
3801
|
if (entry.expiresAt && entry.expiresAt < now) {
|
|
2801
|
-
toDelete.push(key);
|
|
3802
|
+
(sharedStore ? toForget : toDelete).push(key);
|
|
2802
3803
|
}
|
|
2803
3804
|
}
|
|
2804
|
-
if (toDelete.length > 0) {
|
|
3805
|
+
if (toDelete.length > 0 || toForget.length > 0) {
|
|
2805
3806
|
if (this._config.verboseLogging) {
|
|
2806
|
-
LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries
|
|
3807
|
+
LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries` +
|
|
3808
|
+
(toForget.length > 0 ? `, forgetting ${toForget.length} the shared store expires itself` : ''), verboseOnly: true });
|
|
2807
3809
|
}
|
|
2808
3810
|
for (const key of toDelete) {
|
|
2809
3811
|
try {
|
|
2810
3812
|
const entry = this._registry.get(key);
|
|
2811
3813
|
const category = this.getCategoryForType(entry?.type);
|
|
2812
3814
|
await this._storageProvider.Remove(key, category);
|
|
2813
|
-
|
|
2814
|
-
this.removeFromEntityIndex(entry.fingerprint);
|
|
2815
|
-
}
|
|
2816
|
-
this._registry.delete(key);
|
|
3815
|
+
this.forgetRegistryEntry(key);
|
|
2817
3816
|
}
|
|
2818
3817
|
catch {
|
|
2819
3818
|
// Continue
|
|
2820
3819
|
}
|
|
2821
3820
|
}
|
|
3821
|
+
for (const key of toForget) {
|
|
3822
|
+
this.forgetRegistryEntry(key);
|
|
3823
|
+
}
|
|
2822
3824
|
await this.persistRegistry();
|
|
2823
3825
|
}
|
|
2824
3826
|
}
|
|
3827
|
+
/** Drops one entry from the registry and the fingerprint index, leaving the store alone. @internal */
|
|
3828
|
+
forgetRegistryEntry(key) {
|
|
3829
|
+
const entry = this._registry.get(key);
|
|
3830
|
+
if (entry?.fingerprint) {
|
|
3831
|
+
this.removeFromEntityIndex(entry.fingerprint);
|
|
3832
|
+
}
|
|
3833
|
+
this._registry.delete(key);
|
|
3834
|
+
}
|
|
2825
3835
|
}
|
|
2826
3836
|
//# sourceMappingURL=localCacheManager.js.map
|