@memberjunction/core 6.2.0-edge.3 → 6.2.0-edge.4
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/JSONType-interfaces/IEntityConfiguration.d.ts +15 -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 +193 -2
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +445 -33
- 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/databaseProviderBase.d.ts +20 -11
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +41 -15
- package/dist/generic/databaseProviderBase.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 +25 -5
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +28 -5
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +118 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +267 -16
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +728 -113
- 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/providerBase.d.ts +184 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +353 -69
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +5 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +5 -0
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -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 });
|
|
@@ -139,6 +140,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
139
140
|
* (e.g., TransactionGroup batch deletes).
|
|
140
141
|
*/
|
|
141
142
|
this._fingerprintLocks = new Map();
|
|
143
|
+
/** Logged once when a local TTL is configured against a shared store. See {@link runEvictionSweep}. */
|
|
144
|
+
this._sharedTTLWarned = false;
|
|
145
|
+
/** Open entity-event batches. See {@link BeginEntityEventBatch}. */
|
|
146
|
+
this._entityEventBatches = new EntityEventBatchSet();
|
|
147
|
+
/** Save/delete events a batch took when they were raised. See {@link WasTakenByEntityEventBatch}. */
|
|
148
|
+
this._eventsTakenByBatch = new WeakSet();
|
|
142
149
|
this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
|
|
143
150
|
/**
|
|
144
151
|
* Reverse index from entity name to the set of RunView cache fingerprints
|
|
@@ -146,6 +153,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
146
153
|
* event fires so we can update all relevant cached results.
|
|
147
154
|
*/
|
|
148
155
|
this._entityFingerprintIndex = new Map();
|
|
156
|
+
this._initializedListeners = [];
|
|
149
157
|
// ========================================================================
|
|
150
158
|
// CROSS-SERVER CACHE CHANGE CALLBACKS
|
|
151
159
|
// ========================================================================
|
|
@@ -168,22 +176,29 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
168
176
|
* Initialize the cache manager with a storage provider.
|
|
169
177
|
* This should be called during app startup after the storage provider is available.
|
|
170
178
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
179
|
+
* Safe to call repeatedly: the cache is initialized once, but **a `config` passed by a later
|
|
180
|
+
* caller is still applied**. That matters because a database provider initializes this manager
|
|
181
|
+
* from inside its own `Config()` — before the host has a chance to pass anything — so a host
|
|
182
|
+
* that hands its settings to `StartupManager` would otherwise have them silently dropped, and
|
|
183
|
+
* every `cacheSettings` knob would be inert. The host's explicit settings win
|
|
184
|
+
* over the provider's implicit initialization, whichever runs first.
|
|
173
185
|
*
|
|
174
186
|
* @param storageProvider - The local storage provider to use for persistence
|
|
175
|
-
* @param config - Optional configuration overrides
|
|
187
|
+
* @param config - Optional configuration overrides, applied even when already initialized
|
|
176
188
|
* @returns A promise that resolves when initialization is complete
|
|
177
189
|
*/
|
|
178
190
|
Initialize(storageProvider, config) {
|
|
179
|
-
// If already initialized, return immediately
|
|
180
191
|
if (this._initialized) {
|
|
192
|
+
if (config) {
|
|
193
|
+
this.UpdateConfig(config);
|
|
194
|
+
}
|
|
181
195
|
return Promise.resolve();
|
|
182
196
|
}
|
|
183
|
-
//
|
|
184
|
-
// so all callers await the same initialization
|
|
197
|
+
// Initialization in flight: share it, then apply this caller's settings on top.
|
|
185
198
|
if (this._initializePromise) {
|
|
186
|
-
return
|
|
199
|
+
return config
|
|
200
|
+
? this._initializePromise.then(() => { this.UpdateConfig(config); })
|
|
201
|
+
: this._initializePromise;
|
|
187
202
|
}
|
|
188
203
|
// First caller - start initialization and store the promise
|
|
189
204
|
this._initializePromise = this.doInitialize(storageProvider, config);
|
|
@@ -254,6 +269,36 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
254
269
|
// Subscribe to BaseEntity events for universal cache invalidation.
|
|
255
270
|
// When any entity is saved/deleted, update all cached RunView results for that entity.
|
|
256
271
|
this.subscribeToBaseEntityEvents();
|
|
272
|
+
this.notifyInitialized();
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Runs `listener` once the cache manager is initialized — immediately if it already is.
|
|
276
|
+
* Lets code that ran too early (an engine loaded before startup initialized the cache) finish
|
|
277
|
+
* its cache wiring later instead of never.
|
|
278
|
+
*
|
|
279
|
+
* @param listener - Called once; errors are logged and do not affect other listeners
|
|
280
|
+
*/
|
|
281
|
+
WhenInitialized(listener) {
|
|
282
|
+
if (this._initialized) {
|
|
283
|
+
this.runInitializedListener(listener);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this._initializedListeners.push(listener);
|
|
287
|
+
}
|
|
288
|
+
runInitializedListener(listener) {
|
|
289
|
+
try {
|
|
290
|
+
listener();
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
LogError(`LocalCacheManager.WhenInitialized listener failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
notifyInitialized() {
|
|
297
|
+
const listeners = this._initializedListeners;
|
|
298
|
+
this._initializedListeners = [];
|
|
299
|
+
for (const listener of listeners) {
|
|
300
|
+
this.runInitializedListener(listener);
|
|
301
|
+
}
|
|
257
302
|
}
|
|
258
303
|
/**
|
|
259
304
|
* Returns whether the cache manager has been initialized
|
|
@@ -268,10 +313,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
268
313
|
return { ...this._config };
|
|
269
314
|
}
|
|
270
315
|
/**
|
|
271
|
-
* Updates the configuration at runtime
|
|
316
|
+
* Updates the configuration at runtime, and re-arms the eviction sweep so a changed interval
|
|
317
|
+
* (or a disabled cache) takes effect immediately instead of at the next restart.
|
|
272
318
|
*/
|
|
273
319
|
UpdateConfig(config) {
|
|
320
|
+
const previousInterval = this._config.evictionSweepIntervalMs;
|
|
321
|
+
const wasEnabled = this._config.enabled;
|
|
274
322
|
this._config = { ...this._config, ...config };
|
|
323
|
+
if (!this._initialized) {
|
|
324
|
+
return; // doInitialize starts the sweep with the merged config
|
|
325
|
+
}
|
|
326
|
+
if (this._config.evictionSweepIntervalMs !== previousInterval || this._config.enabled !== wasEnabled) {
|
|
327
|
+
if (this._config.enabled) {
|
|
328
|
+
this.startEvictionSweep();
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
this.stopEvictionSweep();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
275
334
|
}
|
|
276
335
|
/**
|
|
277
336
|
* Checks whether caching is enabled for a given entity. Returns the entity's
|
|
@@ -294,9 +353,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
294
353
|
* @param newProvider - The new storage provider to use
|
|
295
354
|
*/
|
|
296
355
|
async SetStorageProvider(newProvider) {
|
|
356
|
+
if (newProvider === this._storageProvider) {
|
|
357
|
+
// Already in use (a host that installed its shared store before startup): migrating
|
|
358
|
+
// every entry onto itself would rewrite — and republish — all of them.
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
297
361
|
// The freeze decision belongs to the ACTIVE provider, not to whichever one happened to be
|
|
298
|
-
// installed at Initialize.
|
|
299
|
-
//
|
|
362
|
+
// installed at Initialize. A host may still swap stores after initialization (and MJAPI did,
|
|
363
|
+
// before it began installing Redis ahead of metadata), and the two have OPPOSITE reference
|
|
364
|
+
// semantics on
|
|
300
365
|
// every Redis deployment — carrying the old answer forward means freezing rows Redis has
|
|
301
366
|
// already isolated (all of the hazard, none of the protection), or, on the reverse swap,
|
|
302
367
|
// silently dropping the protection.
|
|
@@ -321,7 +386,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
321
386
|
const category = this.getCategoryForType(entry.type);
|
|
322
387
|
const data = await oldProvider?.GetItem(entry.key, category);
|
|
323
388
|
if (data) {
|
|
324
|
-
await newProvider.SetItem(entry.key, data, category);
|
|
389
|
+
await newProvider.SetItem(entry.key, data, category, this.writeOptionsForEntry(entry));
|
|
325
390
|
migratedCount++;
|
|
326
391
|
}
|
|
327
392
|
}
|
|
@@ -381,6 +446,23 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
381
446
|
* in-place mutation (`canUseImmediateMutation`); this closes the same gap in the raw
|
|
382
447
|
* provider cache. (B42)
|
|
383
448
|
*
|
|
449
|
+
* Maintaining the order in place was built and then withdrawn, so the payoff is on record rather
|
|
450
|
+
* than left to be rediscovered. It is possible for a narrow subset — plain column terms over
|
|
451
|
+
* numeric/date/boolean fields with no NULLs present, where JavaScript and SQL provably agree —
|
|
452
|
+
* but the subset is where the value dies, not the implementation. Of 918 RunView param literals
|
|
453
|
+
* in this repo, 289 carry an ORDER BY, 112 are also unfiltered and uncapped (the only ones a slot
|
|
454
|
+
* could maintain), and roughly 26 fall in that type subset, several of which order on a
|
|
455
|
+
* uniqueidentifier and are refused anyway. The survivors are about a dozen configuration and
|
|
456
|
+
* admin lists — API keys, credentials, view types, schema info — saved rarely, so the saving is a
|
|
457
|
+
* handful of small indexed reads per day. High-write entities gain nothing: every ordered read of
|
|
458
|
+
* MJ: AI Agent Runs, MJ: Conversation Details and MJ: AI Prompt Runs is FILTERED, so their slots
|
|
459
|
+
* land in the invalidate branch regardless. Against that, the cost is a comparator that must keep
|
|
460
|
+
* agreeing with SQL collation and NULL placement across dialects forever, whose failure mode is a
|
|
461
|
+
* silently reordered list in a slot other readers treat as authoritative. Widening it to strings —
|
|
462
|
+
* by far the largest bucket, `Name` — means owning collation per dialect and per column.
|
|
463
|
+
*
|
|
464
|
+
* If you are about to attempt this, the measurement above is the argument not to.
|
|
465
|
+
*
|
|
384
466
|
* @param parts - the fingerprint already split on '|'
|
|
385
467
|
*/
|
|
386
468
|
hasOrderBy(parts) {
|
|
@@ -557,14 +639,75 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
557
639
|
if (!currentHash)
|
|
558
640
|
return false;
|
|
559
641
|
if (currentHash !== data.schemaHash) {
|
|
560
|
-
|
|
561
|
-
message: `[CACHE-SCHEMA-STALE] Entity "${entityName}" schema changed (cached=${data.schemaHash}, current=${currentHash})`,
|
|
562
|
-
verboseOnly: false
|
|
563
|
-
});
|
|
642
|
+
LogWarning(`[CACHE-SCHEMA-STALE] Entity "${entityName}" schema changed (cached=${data.schemaHash}, current=${currentHash}) — dropping the slot`, 'Cache');
|
|
564
643
|
return true;
|
|
565
644
|
}
|
|
566
645
|
return false;
|
|
567
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* The newest `__mj_UpdatedAt` among `rows` as an ISO string, `''` for an empty set, or
|
|
649
|
+
* `undefined` when the rows carry no such column (so the caller can fall back to its own stamp).
|
|
650
|
+
* Mirrors how the RunView fill path stamps a slot.
|
|
651
|
+
*/
|
|
652
|
+
static MaxUpdatedAtOfRows(rows) {
|
|
653
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
654
|
+
let sawColumn = false;
|
|
655
|
+
for (const row of rows) {
|
|
656
|
+
if (!row || typeof row !== 'object') {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// `UpdatedAt` is the fallback an entity whose view predates the `__mj_` prefix carries.
|
|
660
|
+
// Both cache-write funnels must agree on the stamp they compute, or the same rows get
|
|
661
|
+
// different slot timestamps depending on which path wrote them and every currency check
|
|
662
|
+
// between them misfires.
|
|
663
|
+
const record = row;
|
|
664
|
+
const raw = record['__mj_UpdatedAt'] !== undefined ? record['__mj_UpdatedAt'] : record['UpdatedAt'];
|
|
665
|
+
if (raw === undefined) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
sawColumn = true;
|
|
669
|
+
if (raw === null) {
|
|
670
|
+
continue; // new Date(null) is the epoch, not "no timestamp"
|
|
671
|
+
}
|
|
672
|
+
const ms = raw instanceof Date ? raw.getTime() : new Date(raw).getTime();
|
|
673
|
+
if (!Number.isNaN(ms) && ms > max) {
|
|
674
|
+
max = ms;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (rows.length === 0) {
|
|
678
|
+
return '';
|
|
679
|
+
}
|
|
680
|
+
if (!sawColumn) {
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
return max === Number.NEGATIVE_INFINITY ? '' : new Date(max).toISOString();
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Storage write options for a RunView slot: the entity name as index group, so a shared
|
|
687
|
+
* provider can find the slot from any process, and the time left before the slot's own expiry
|
|
688
|
+
* (external-data-source entities), so a rewrite does not extend it.
|
|
689
|
+
*
|
|
690
|
+
* @param fingerprint - The slot key
|
|
691
|
+
* @param expiresAt - Absolute expiry (ms since epoch), when the slot has one
|
|
692
|
+
*/
|
|
693
|
+
runViewWriteOptions(fingerprint, expiresAt) {
|
|
694
|
+
const options = {};
|
|
695
|
+
const entity = this.extractEntityFromFingerprint(fingerprint);
|
|
696
|
+
if (entity) {
|
|
697
|
+
options.IndexGroup = entity;
|
|
698
|
+
}
|
|
699
|
+
if (expiresAt) {
|
|
700
|
+
options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
|
|
701
|
+
}
|
|
702
|
+
return options;
|
|
703
|
+
}
|
|
704
|
+
/** Storage write options for any registry entry; only RunView slots are indexed. */
|
|
705
|
+
writeOptionsForEntry(entry) {
|
|
706
|
+
if (entry.type === 'runview') {
|
|
707
|
+
return this.runViewWriteOptions(entry.key, entry.expiresAt);
|
|
708
|
+
}
|
|
709
|
+
return entry.expiresAt ? { TTLSeconds: Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000)) } : undefined;
|
|
710
|
+
}
|
|
568
711
|
/**
|
|
569
712
|
* Adds a fingerprint to the entity→fingerprint reverse index.
|
|
570
713
|
* Called when a RunView result is cached.
|
|
@@ -602,13 +745,31 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
602
745
|
return this._entityFingerprintIndex.get(entityName) ?? new Set();
|
|
603
746
|
}
|
|
604
747
|
/**
|
|
605
|
-
* Resolves cached fingerprints for an entity,
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
748
|
+
* Resolves cached fingerprints for an entity: this process's own index, plus — on a storage
|
|
749
|
+
* provider shared between processes — the slots other processes wrote.
|
|
750
|
+
*
|
|
751
|
+
* A save on server A has to reach slots server B wrote after A booted, because nothing else
|
|
752
|
+
* will: B only hears about A's save through the slots A rewrites. A shared provider therefore
|
|
753
|
+
* answers from its per-entity index ({@link ILocalStorageProvider.GetIndexGroupKeys}) on every
|
|
754
|
+
* call. A provider without that index is process-local or cannot enumerate cheaply, and keeps
|
|
755
|
+
* the older behaviour: the category is listed only when the local index knows nothing.
|
|
610
756
|
*/
|
|
611
757
|
async resolveFingerprintsForEntity(entityName) {
|
|
758
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
759
|
+
const shared = await this._storageProvider.GetIndexGroupKeys(CacheCategory.RunViewCache, entityName);
|
|
760
|
+
// The local index is deliberately NOT pruned against the shared group here. A slot this
|
|
761
|
+
// process wrote that the group no longer lists is one that expired or was cleared
|
|
762
|
+
// elsewhere — and peers may still be holding its rows in memory. Keeping it means the
|
|
763
|
+
// next save invalidates it, which is what publishes the `removed` those peers reload
|
|
764
|
+
// on (pinned by localCacheManager.sharedIndex.test.ts). The invalidation then
|
|
765
|
+
// drops it from the index, so that costs one notice per slot, once — not per save.
|
|
766
|
+
// Reviewed as and rejected on that evidence
|
|
767
|
+
for (const fp of shared) {
|
|
768
|
+
this.addToEntityIndex(fp);
|
|
769
|
+
}
|
|
770
|
+
const merged = this._entityFingerprintIndex.get(entityName);
|
|
771
|
+
return merged && merged.size > 0 ? merged : undefined;
|
|
772
|
+
}
|
|
612
773
|
const local = this._entityFingerprintIndex.get(entityName);
|
|
613
774
|
if (local && local.size > 0)
|
|
614
775
|
return local;
|
|
@@ -656,6 +817,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
656
817
|
// Only react to completed save and delete events
|
|
657
818
|
if (entityEvent.type !== 'save' && entityEvent.type !== 'delete')
|
|
658
819
|
return;
|
|
820
|
+
// Inside an open batch the change is recorded now and applied when the batch closes.
|
|
821
|
+
if (this.bufferIfBatching(entityEvent))
|
|
822
|
+
return;
|
|
659
823
|
// Fire-and-forget to avoid blocking the save/delete operation
|
|
660
824
|
this.HandleBaseEntityEvent(entityEvent).catch((err) => {
|
|
661
825
|
LogError(`LocalCacheManager.HandleBaseEntityEvent error: ${err.message}`);
|
|
@@ -721,6 +885,306 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
721
885
|
}));
|
|
722
886
|
}
|
|
723
887
|
}
|
|
888
|
+
// ========================================================================
|
|
889
|
+
// ENTITY-EVENT BATCHES
|
|
890
|
+
// ========================================================================
|
|
891
|
+
/**
|
|
892
|
+
* Opens a batch for `owner` (normally a provider), or enters the one already open. Until the
|
|
893
|
+
* matching {@link EndEntityEventBatch}, saves and deletes raised by entities that use `owner`
|
|
894
|
+
* as their provider are recorded instead of applied; closing the batch rewrites each affected
|
|
895
|
+
* slot once. `DatabaseProviderBase.BeginEntityTransaction` opens one for every transaction, so
|
|
896
|
+
* callers rarely need this directly — {@link RunInEntityEventBatch} is the wrapper for bulk
|
|
897
|
+
* writers that do not use a transaction.
|
|
898
|
+
*
|
|
899
|
+
* Every call must be paired with exactly one {@link EndEntityEventBatch}.
|
|
900
|
+
*/
|
|
901
|
+
BeginEntityEventBatch(owner) {
|
|
902
|
+
this._entityEventBatches.Open(owner);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Replaces a RunView slot wholesale while holding the cross-process lock for it.
|
|
906
|
+
*
|
|
907
|
+
* The event-driven path already reads-modifies-writes under that lock; a writer that replaces
|
|
908
|
+
* the same slot outside it can land between a peer's read and its write, and whichever `SET`
|
|
909
|
+
* lands second wins — the engine sweep's fresh rows lost, or the peer's delta lost, and either
|
|
910
|
+
* way the losing state is published to the fleet.
|
|
911
|
+
*/
|
|
912
|
+
async ReplaceRunViewResultLocked(fingerprint, params, results, maxUpdatedAt, totalRowCount, provider) {
|
|
913
|
+
if (!this._storageProvider || !this._config.enabled) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
// Carry the slot's existing expiry across the replace. An external-data-source slot has one,
|
|
917
|
+
// and a rewrite that dropped it would turn a bounded cache entry into a permanent one
|
|
918
|
+
//.
|
|
919
|
+
const ttlMs = this.remainingTTLForSlot(fingerprint);
|
|
920
|
+
return this.maintainSlotLocked(fingerprint, async () => {
|
|
921
|
+
await this.SetRunViewResult(fingerprint, params, results, maxUpdatedAt, undefined, totalRowCount, provider, ttlMs);
|
|
922
|
+
return true;
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
/** Milliseconds left on a slot's expiry, or undefined when it has none. @internal */
|
|
926
|
+
remainingTTLForSlot(fingerprint) {
|
|
927
|
+
const expiresAt = this._registry.get(fingerprint)?.expiresAt;
|
|
928
|
+
if (!expiresAt) {
|
|
929
|
+
return undefined;
|
|
930
|
+
}
|
|
931
|
+
return Math.max(1, expiresAt - Date.now());
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Abandons `owner`'s batch at any depth and invalidates every slot it touched.
|
|
935
|
+
*
|
|
936
|
+
* For an owner that cannot settle its batch normally: an independent provider instance being
|
|
937
|
+
* released with a transaction still open, or a transaction handle reset after a failure. The
|
|
938
|
+
* buffered rows may or may not have reached the database, so the affected slots are invalidated
|
|
939
|
+
* rather than written, and the entities stop counting as pending — a batch left open makes
|
|
940
|
+
* every cached read of its entities miss, for the life of the process.
|
|
941
|
+
*
|
|
942
|
+
* Never throws. Safe when no batch is open.
|
|
943
|
+
*/
|
|
944
|
+
async AbandonEntityEventBatch(owner) {
|
|
945
|
+
const batch = this._entityEventBatches.Abandon(owner);
|
|
946
|
+
if (!batch) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
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');
|
|
950
|
+
try {
|
|
951
|
+
await this.invalidateBatchEntities(batch);
|
|
952
|
+
}
|
|
953
|
+
catch (e) {
|
|
954
|
+
LogError(`LocalCacheManager.AbandonEntityEventBatch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Leaves one level of `owner`'s batch. Closing the outermost level applies the batch: with
|
|
959
|
+
* every level successful, each affected slot is rewritten once with all its changes; if any
|
|
960
|
+
* level failed (a rollback), the affected slots are invalidated instead, so every server reloads
|
|
961
|
+
* them from the database. Never throws.
|
|
962
|
+
*
|
|
963
|
+
* @param owner - The object passed to {@link BeginEntityEventBatch}
|
|
964
|
+
* @param succeeded - False when the work this level covered was rolled back or failed
|
|
965
|
+
*/
|
|
966
|
+
async EndEntityEventBatch(owner, succeeded) {
|
|
967
|
+
const batch = this._entityEventBatches.Close(owner, succeeded);
|
|
968
|
+
if (!batch) {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
await this.applyEntityEventBatch(batch);
|
|
973
|
+
}
|
|
974
|
+
catch (e) {
|
|
975
|
+
LogError(`LocalCacheManager: applying an entity-event batch failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Claims a named lease on the storage provider for `ttlMs`: on a shared store only one process
|
|
980
|
+
* gets it per period; on a private store the caller always does. A lease that cannot be
|
|
981
|
+
* checked is reported as not claimed.
|
|
982
|
+
*/
|
|
983
|
+
async TryAcquireSharedLease(name, ttlMs) {
|
|
984
|
+
return (await this.claimLease(name, ttlMs)) === 'claimed';
|
|
985
|
+
}
|
|
986
|
+
/** One lease attempt: claimed, held by another process, or the store could not be asked. */
|
|
987
|
+
async claimLease(name, ttlMs) {
|
|
988
|
+
const provider = this._storageProvider;
|
|
989
|
+
if (!provider?.TryAcquireLease) {
|
|
990
|
+
return 'claimed';
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
return (await provider.TryAcquireLease(name, ttlMs)) ? 'claimed' : 'held';
|
|
994
|
+
}
|
|
995
|
+
catch (e) {
|
|
996
|
+
LogWarning(`LocalCacheManager: could not claim lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
997
|
+
return 'error';
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Extends a lease this process already holds, so long-running work keeps its turn. Falls back
|
|
1002
|
+
* to re-claiming when the store has no dedicated renewal, which is a no-op for anyone else's
|
|
1003
|
+
* lease because the claim is `SET NX`. Never throws.
|
|
1004
|
+
*
|
|
1005
|
+
* @returns true when the lease is still (or again) held by this process.
|
|
1006
|
+
*/
|
|
1007
|
+
async RenewSharedLease(name, ttlMs) {
|
|
1008
|
+
const provider = this._storageProvider;
|
|
1009
|
+
if (!provider?.TryAcquireLease) {
|
|
1010
|
+
return true; // no shared store: this process is the only one, and holds it by default
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const renew = provider.RenewLease;
|
|
1014
|
+
if (renew) {
|
|
1015
|
+
return await renew.call(provider, name, ttlMs);
|
|
1016
|
+
}
|
|
1017
|
+
return await provider.TryAcquireLease(name, ttlMs);
|
|
1018
|
+
}
|
|
1019
|
+
catch (e) {
|
|
1020
|
+
LogWarning(`LocalCacheManager: could not renew lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/** Ends a lease claimed with {@link TryAcquireSharedLease}. Never throws. */
|
|
1025
|
+
async ReleaseSharedLease(name) {
|
|
1026
|
+
try {
|
|
1027
|
+
await this._storageProvider?.ReleaseLease?.(name);
|
|
1028
|
+
}
|
|
1029
|
+
catch (e) {
|
|
1030
|
+
LogWarning(`LocalCacheManager: could not release lease "${name}" (${e instanceof Error ? e.message : String(e)})`, 'Cache');
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Waits until this process claims the lease, polling every `pollMs`, for at most `maxWaitMs`.
|
|
1035
|
+
* @returns Whether the lease was claimed, and how long the wait took.
|
|
1036
|
+
*/
|
|
1037
|
+
async WaitForSharedLease(name, ttlMs, maxWaitMs, pollMs = 100) {
|
|
1038
|
+
const started = Date.now();
|
|
1039
|
+
for (;;) {
|
|
1040
|
+
const outcome = await this.claimLease(name, ttlMs);
|
|
1041
|
+
if (outcome !== 'held') {
|
|
1042
|
+
// An unreachable store coordinates nothing, so waiting on it would only delay the caller.
|
|
1043
|
+
return { Acquired: outcome === 'claimed', WaitedMs: Date.now() - started };
|
|
1044
|
+
}
|
|
1045
|
+
if (Date.now() - started >= maxWaitMs) {
|
|
1046
|
+
return { Acquired: false, WaitedMs: Date.now() - started };
|
|
1047
|
+
}
|
|
1048
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/** True while a batch is open for `owner`. */
|
|
1052
|
+
IsBatchingEntityEvents(owner) {
|
|
1053
|
+
return this._entityEventBatches.Find(owner) !== undefined;
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* True when a batch took `entityEvent` as it was raised, so closing that batch maintains every
|
|
1057
|
+
* slot indexed for the entity. A listener that reaches its cache write only after an `await`
|
|
1058
|
+
* asks this instead of {@link IsBatchingEntityEvents}: by then the batch may have closed, and
|
|
1059
|
+
* a write of its own would rewrite the slot once per event.
|
|
1060
|
+
*/
|
|
1061
|
+
WasTakenByEntityEventBatch(entityEvent) {
|
|
1062
|
+
return this._eventsTakenByBatch.has(entityEvent);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Runs `work` inside an entity-event batch for `owner` and applies the batch when it finishes
|
|
1066
|
+
* (as a failure when `work` throws). Use for bulk writes that do not already run in a
|
|
1067
|
+
* transaction.
|
|
1068
|
+
*/
|
|
1069
|
+
async RunInEntityEventBatch(owner, work) {
|
|
1070
|
+
this.BeginEntityEventBatch(owner);
|
|
1071
|
+
let succeeded = false;
|
|
1072
|
+
try {
|
|
1073
|
+
const result = await work();
|
|
1074
|
+
succeeded = true;
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
await this.EndEntityEventBatch(owner, succeeded);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Records a save/delete in the batch open for the entity's provider.
|
|
1083
|
+
* @returns True when the event was taken by a batch (or needs no cache work at all).
|
|
1084
|
+
*/
|
|
1085
|
+
bufferIfBatching(entityEvent) {
|
|
1086
|
+
const owner = entityEvent.baseEntity?.ProviderToUse;
|
|
1087
|
+
if (!this._entityEventBatches.Find(owner)) {
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
const taken = this._entityEventBatches.Record(owner, this.captureEntityChange(entityEvent));
|
|
1091
|
+
if (taken) {
|
|
1092
|
+
this._eventsTakenByBatch.add(entityEvent);
|
|
1093
|
+
}
|
|
1094
|
+
return taken;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* True when `fingerprint`'s entity has changes waiting in an open batch. Such a slot is behind
|
|
1098
|
+
* what the unit of work has written, so reads treat it as a miss (the database answers,
|
|
1099
|
+
* including the uncommitted rows for a reader inside the transaction) and fills are not stored.
|
|
1100
|
+
*/
|
|
1101
|
+
isPendingInBatch(fingerprint) {
|
|
1102
|
+
const entityName = this.extractEntityFromFingerprint(fingerprint);
|
|
1103
|
+
return entityName !== null && this._entityEventBatches.HasPendingChanges(entityName);
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The change a save/delete event describes, captured now — the entity object can change (or,
|
|
1107
|
+
* after a delete, be reset) before a batch closes. Null when the event needs no cache work.
|
|
1108
|
+
*/
|
|
1109
|
+
captureEntityChange(entityEvent) {
|
|
1110
|
+
const entityInfo = entityEvent.baseEntity?.EntityInfo;
|
|
1111
|
+
if (!entityInfo?.Name || !this.IsCachingEnabledForEntity(entityInfo) || !entityInfo.PrimaryKeys?.length) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
if (entityEvent.type !== 'save' && entityEvent.type !== 'delete') {
|
|
1115
|
+
return null;
|
|
1116
|
+
}
|
|
1117
|
+
// A delete resets the entity right after raising the event; its payload keeps the old values.
|
|
1118
|
+
const payload = entityEvent.payload;
|
|
1119
|
+
const record = (entityEvent.type === 'delete' && payload?.OldValues)
|
|
1120
|
+
? payload.OldValues
|
|
1121
|
+
: entityEvent.baseEntity.GetAll();
|
|
1122
|
+
const key = new CompositeKey();
|
|
1123
|
+
key.LoadFromEntityInfoAndRecord(entityInfo, record);
|
|
1124
|
+
if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
return { EntityInfo: entityInfo, Type: entityEvent.type, Key: key, Record: record };
|
|
1128
|
+
}
|
|
1129
|
+
/** Applies a closed batch; see {@link EndEntityEventBatch}. */
|
|
1130
|
+
async applyEntityEventBatch(batch) {
|
|
1131
|
+
if (!batch.CanApplyRows) {
|
|
1132
|
+
await this.invalidateBatchEntities(batch);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const byEntity = new Map();
|
|
1136
|
+
for (const change of batch.Changes) {
|
|
1137
|
+
const list = byEntity.get(change.EntityInfo.Name) ?? [];
|
|
1138
|
+
list.push(change);
|
|
1139
|
+
byEntity.set(change.EntityInfo.Name, list);
|
|
1140
|
+
}
|
|
1141
|
+
for (const [entityName, changes] of byEntity) {
|
|
1142
|
+
await this.applyEntityChanges(entityName, changes);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
/** Invalidates every entity a batch touched — for a batch that failed or was abandoned. */
|
|
1146
|
+
async invalidateBatchEntities(batch) {
|
|
1147
|
+
for (const entityName of batch.TouchedEntities.keys()) {
|
|
1148
|
+
await this.InvalidateEntityCaches(entityName);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/** Applies one entity's buffered changes to every slot indexed for it, each slot once. */
|
|
1152
|
+
async applyEntityChanges(entityName, changes) {
|
|
1153
|
+
const fingerprints = await this.resolveFingerprintsForEntity(entityName);
|
|
1154
|
+
if (!fingerprints || fingerprints.size === 0) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const snapshot = [...fingerprints];
|
|
1158
|
+
const nowISO = new Date().toISOString();
|
|
1159
|
+
const BATCH_SIZE = 8;
|
|
1160
|
+
for (let i = 0; i < snapshot.length; i += BATCH_SIZE) {
|
|
1161
|
+
await Promise.all(snapshot.slice(i, i + BATCH_SIZE).map(async (fingerprint) => {
|
|
1162
|
+
try {
|
|
1163
|
+
await this.applyEntityChangesToSlot(fingerprint, changes, nowISO);
|
|
1164
|
+
}
|
|
1165
|
+
catch (err) {
|
|
1166
|
+
LogError(`LocalCacheManager: failed to apply batched changes to "${fingerprint}": ${err.message}`);
|
|
1167
|
+
}
|
|
1168
|
+
}));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* One slot, several changes: the same decisions {@link processEntityEventForFingerprint} makes
|
|
1173
|
+
* per event, made once for the whole list.
|
|
1174
|
+
*/
|
|
1175
|
+
async applyEntityChangesToSlot(fingerprint, changes, nowISO) {
|
|
1176
|
+
const hasSave = changes.some(c => c.Type === 'save');
|
|
1177
|
+
// Subset and aggregate slots cannot be maintained in place; filtered slots cannot take an
|
|
1178
|
+
// upsert (the new row may not match the filter) but can drop deleted rows.
|
|
1179
|
+
if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fingerprint.split('|'))
|
|
1180
|
+
|| (hasSave && this.isFilteredFingerprint(fingerprint))) {
|
|
1181
|
+
await this.InvalidateRunViewResult(fingerprint);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const rowChanges = changes.map(c => ({ Key: c.Key, Row: c.Type === 'save' ? c.Record : null }));
|
|
1185
|
+
const maintained = await this.ApplyRowChanges(fingerprint, rowChanges, nowISO);
|
|
1186
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
1187
|
+
}
|
|
724
1188
|
/**
|
|
725
1189
|
* Handles remote-invalidate events that include recordData (the saved entity as JSON).
|
|
726
1190
|
* Updates all cached RunView results for the entity without a server round-trip.
|
|
@@ -928,13 +1392,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
928
1392
|
}
|
|
929
1393
|
else if (eventType === 'delete') {
|
|
930
1394
|
LogStatusVerbose(`LocalCacheManager: Removing entity ${keyStr} from cache "${fingerprint.substring(0, 60)}"`);
|
|
931
|
-
await this.RemoveSingleEntity(fingerprint, key, nowISO);
|
|
1395
|
+
const maintained = await this.RemoveSingleEntity(fingerprint, key, nowISO);
|
|
1396
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
932
1397
|
}
|
|
933
1398
|
else if (!this.isFilteredFingerprint(fingerprint)) {
|
|
934
1399
|
// Unfiltered cache: update the record in place
|
|
935
1400
|
LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
|
|
936
1401
|
const entityData = baseEntity.GetAll();
|
|
937
|
-
await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
|
|
1402
|
+
const maintained = await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
|
|
1403
|
+
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
938
1404
|
}
|
|
939
1405
|
else {
|
|
940
1406
|
// Filtered cache: conservatively invalidate (can't verify filter match)
|
|
@@ -942,6 +1408,26 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
942
1408
|
await this.InvalidateRunViewResult(fingerprint);
|
|
943
1409
|
}
|
|
944
1410
|
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Handles a save/delete that could not be applied to a slot this process indexed — in practice
|
|
1413
|
+
* because the slot expired or was removed by someone else.
|
|
1414
|
+
*
|
|
1415
|
+
* Rewriting the slot is how peer servers hear about the change: their engines hold the rows in
|
|
1416
|
+
* memory and never read the slot again, so a slot that expired while they were running leaves
|
|
1417
|
+
* nothing to rewrite and the change would never reach them. Invalidating instead publishes a
|
|
1418
|
+
* `removed` event on a shared provider, which makes every peer engine tracking the slot reload
|
|
1419
|
+
* it from the database. It also drops the dead fingerprint from this process's index.
|
|
1420
|
+
*
|
|
1421
|
+
* @param fingerprint - The slot the event was applied to
|
|
1422
|
+
* @param maintained - What the in-place upsert/remove reported
|
|
1423
|
+
*/
|
|
1424
|
+
async invalidateIfNotMaintained(fingerprint, maintained) {
|
|
1425
|
+
if (maintained) {
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
LogStatusVerbose(`LocalCacheManager: slot "${fingerprint.substring(0, 60)}" could not be maintained in place — invalidating so peers reload`);
|
|
1429
|
+
await this.InvalidateRunViewResult(fingerprint);
|
|
1430
|
+
}
|
|
945
1431
|
/**
|
|
946
1432
|
* Registers a callback that fires when a specific cache fingerprint is updated
|
|
947
1433
|
* by another server instance. Returns an unsubscribe function to remove the callback.
|
|
@@ -988,9 +1474,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
988
1474
|
* fingerprint. Called by infrastructure code (e.g., {@link RedisLocalStorageProvider})
|
|
989
1475
|
* when another server modifies a cached entry.
|
|
990
1476
|
*
|
|
991
|
-
* For `category_cleared` events, dispatches to ALL registered
|
|
992
|
-
*
|
|
993
|
-
* which contains the category name).
|
|
1477
|
+
* For `category_cleared` events on the RunView category, dispatches to ALL registered
|
|
1478
|
+
* callbacks (they all watch RunView slots); a clear of any other category reaches none.
|
|
994
1479
|
*
|
|
995
1480
|
* Errors in individual callbacks are caught and logged via {@link LogError}
|
|
996
1481
|
* to prevent one bad callback from blocking others.
|
|
@@ -1001,9 +1486,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1001
1486
|
const sourceShort = event.SourceServerId ? event.SourceServerId.substring(0, 8) : 'unknown';
|
|
1002
1487
|
LogStatusVerbose(`LocalCacheManager: DispatchCacheChange received — action="${event.Action}", key="${event.CacheKey}", source="${sourceShort}"`);
|
|
1003
1488
|
if (event.Action === 'category_cleared') {
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1489
|
+
// Every registered callback watches a RunView slot (engine configs and RunView
|
|
1490
|
+
// OnDataChanged both key on RunView fingerprints), so only clearing that category
|
|
1491
|
+
// affects them. Notifying them for a Metadata or Dataset clear as well would make a
|
|
1492
|
+
// tool that clears several categories reload every engine once per category.
|
|
1493
|
+
if (event.Category !== CacheCategory.RunViewCache) {
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1007
1496
|
for (const [, callbacks] of this._changeCallbacks) {
|
|
1008
1497
|
for (const cb of callbacks) {
|
|
1009
1498
|
try {
|
|
@@ -1557,6 +2046,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1557
2046
|
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for keyset (AfterKey) query on "${params.EntityName}"`, verboseOnly: true });
|
|
1558
2047
|
return;
|
|
1559
2048
|
}
|
|
2049
|
+
// Rows read while a unit of work has unapplied changes for this entity may include its
|
|
2050
|
+
// uncommitted writes (or miss them); either way they must not become the shared slot.
|
|
2051
|
+
if (this.isPendingInBatch(fingerprint)) {
|
|
2052
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — an open batch has changes for it`, verboseOnly: true });
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
1560
2055
|
// Short-circuit: if the entity has AllowCaching = false, do not write to the cache.
|
|
1561
2056
|
// The invalidation path (HandleBaseEntityEvent line 552) already short-circuits for
|
|
1562
2057
|
// these entities, so any entry we write here would never be invalidated and would
|
|
@@ -1632,7 +2127,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1632
2127
|
// than serving this one query uncached. Always logged (not verbose-gated): an
|
|
1633
2128
|
// oversized result is a perf smell the operator should be able to see.
|
|
1634
2129
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
1635
|
-
|
|
2130
|
+
LogWarning(`[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName || fingerprint.substring(0, 60)}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)`, 'Cache');
|
|
1636
2131
|
return;
|
|
1637
2132
|
}
|
|
1638
2133
|
// The oversized gate above is the ONLY step that can decline this write, and it is
|
|
@@ -1655,7 +2150,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1655
2150
|
await this.evictIfNeeded(sizeBytes);
|
|
1656
2151
|
try {
|
|
1657
2152
|
// Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
|
|
1658
|
-
|
|
2153
|
+
const expiresAt = ttlMs ? Date.now() + ttlMs : undefined;
|
|
2154
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, expiresAt));
|
|
1659
2155
|
this.registerEntry({
|
|
1660
2156
|
key: fingerprint,
|
|
1661
2157
|
type: 'runview',
|
|
@@ -1675,7 +2171,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1675
2171
|
sizeBytes,
|
|
1676
2172
|
maxUpdatedAt,
|
|
1677
2173
|
rowCount: results.length, // Registry still tracks this for display/stats, derived from actual results
|
|
1678
|
-
expiresAt
|
|
2174
|
+
expiresAt // time-based expiry (external entities); undefined => event-invalidated (MJ-DB entities)
|
|
1679
2175
|
});
|
|
1680
2176
|
// Maintain entity→fingerprint reverse index for universal cache invalidation
|
|
1681
2177
|
this.addToEntityIndex(fingerprint);
|
|
@@ -1694,6 +2190,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1694
2190
|
* @returns The cached results, maxUpdatedAt, rowCount (derived), and aggregateResults, or null if not found
|
|
1695
2191
|
*/
|
|
1696
2192
|
async GetRunViewResult(fingerprint) {
|
|
2193
|
+
if (this.isPendingInBatch(fingerprint)) {
|
|
2194
|
+
this._stats.misses++;
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
return this.readRunViewSlot(fingerprint);
|
|
2198
|
+
}
|
|
2199
|
+
/** {@link GetRunViewResult} without the open-batch check; slot maintenance reads through this. */
|
|
2200
|
+
async readRunViewSlot(fingerprint) {
|
|
1697
2201
|
if (!this._storageProvider || !this._config.enabled)
|
|
1698
2202
|
return null;
|
|
1699
2203
|
try {
|
|
@@ -1739,7 +2243,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1739
2243
|
try {
|
|
1740
2244
|
const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
|
|
1741
2245
|
for (const [fp, parsed] of raw) {
|
|
1742
|
-
out.set(fp, this.materializeCachedRunViewResult(fp, parsed));
|
|
2246
|
+
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed));
|
|
1743
2247
|
}
|
|
1744
2248
|
return out;
|
|
1745
2249
|
}
|
|
@@ -1782,6 +2286,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1782
2286
|
return null;
|
|
1783
2287
|
}
|
|
1784
2288
|
this.recordAccess(fingerprint);
|
|
2289
|
+
this.indexSlotThisProcessDidNotWrite(fingerprint);
|
|
1785
2290
|
this._stats.hits++;
|
|
1786
2291
|
const results = parsed.results || [];
|
|
1787
2292
|
const result = {
|
|
@@ -1799,6 +2304,34 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1799
2304
|
}
|
|
1800
2305
|
return result;
|
|
1801
2306
|
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Records a slot this process READ, so a later save can still find it once it has expired.
|
|
2309
|
+
*
|
|
2310
|
+
* An engine loads its rows once and never reads its slot again — that is the design, not a
|
|
2311
|
+
* defect: the slot is a boot shortcut and a propagation channel, and correctness comes from
|
|
2312
|
+
* events. Giving slots a finite expiry opened a window those events could not cross. When a
|
|
2313
|
+
* slot expires under running replicas, a save finds nothing to rewrite and publishes nothing,
|
|
2314
|
+
* so every peer engine keeps serving what it loaded.
|
|
2315
|
+
*
|
|
2316
|
+
* The fix — invalidate a slot that cannot be maintained, which publishes `removed` — only
|
|
2317
|
+
* fires if the saving process still holds the fingerprint, and only the process that WROTE the
|
|
2318
|
+
* slot did. A server that booted warm read it instead, and a read indexed nothing; behind a
|
|
2319
|
+
* load balancer, which server takes the save is arbitrary. A warm read is the only trace such a
|
|
2320
|
+
* process has of that slot, so it is indexed too.
|
|
2321
|
+
*
|
|
2322
|
+
* Gated on the provider exposing a shared per-entity index, which is exactly the case
|
|
2323
|
+
* {@link resolveFingerprintsForEntity} answers from that index — with no category-scan
|
|
2324
|
+
* fallback. On a process-local store there is nothing to gain (no peers to notify, and every
|
|
2325
|
+
* slot in it was written by this process, so it is already indexed) and something to lose: a
|
|
2326
|
+
* non-empty index SUPPRESSES that fallback scan, which is what finds persisted slots the index
|
|
2327
|
+
* does not know about.
|
|
2328
|
+
* @internal
|
|
2329
|
+
*/
|
|
2330
|
+
indexSlotThisProcessDidNotWrite(fingerprint) {
|
|
2331
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
2332
|
+
this.addToEntityIndex(fingerprint);
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
1802
2335
|
/**
|
|
1803
2336
|
* Invalidates a cached RunView result.
|
|
1804
2337
|
*
|
|
@@ -1948,16 +2481,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1948
2481
|
}
|
|
1949
2482
|
}
|
|
1950
2483
|
/**
|
|
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.
|
|
2484
|
+
* Runs an in-place slot rewrite under {@link withFingerprintLock}. When the lock itself fails
|
|
2485
|
+
* (a shared lock another server held too long), reports the rewrite as not done rather than
|
|
2486
|
+
* throwing, so the caller's fallback — invalidate the slot, making every server reload —
|
|
2487
|
+
* applies.
|
|
1960
2488
|
*/
|
|
2489
|
+
async maintainSlotLocked(fingerprint, fn) {
|
|
2490
|
+
try {
|
|
2491
|
+
return await this.withFingerprintLock(fingerprint, fn);
|
|
2492
|
+
}
|
|
2493
|
+
catch (e) {
|
|
2494
|
+
// Distinguish "another process holds this slot" (expected contention — the caller
|
|
2495
|
+
// invalidates instead of writing) from a fault inside the work itself, which is a bug
|
|
2496
|
+
// and must be reported as one rather than filed under lock contention. The
|
|
2497
|
+
// storage provider owns those error types, and it depends on this package, so they are
|
|
2498
|
+
// recognised by name rather than imported.
|
|
2499
|
+
const name = e instanceof Error ? e.name : '';
|
|
2500
|
+
const isLockProblem = name === 'KeyLockTimeoutError' || name === 'KeyLockLostError';
|
|
2501
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
2502
|
+
if (isLockProblem) {
|
|
2503
|
+
LogWarning(`LocalCacheManager: could not hold the lock on "${fingerprint.substring(0, 60)}" for an in-place update (${detail}) — invalidating instead`, 'Cache');
|
|
2504
|
+
}
|
|
2505
|
+
else {
|
|
2506
|
+
LogError(`LocalCacheManager: the in-place update of "${fingerprint.substring(0, 60)}" failed (${detail}) — invalidating instead`);
|
|
2507
|
+
}
|
|
2508
|
+
return false;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
1961
2511
|
async withFingerprintLock(fingerprint, fn) {
|
|
1962
2512
|
const existing = this._fingerprintLocks.get(fingerprint) ?? Promise.resolve();
|
|
1963
2513
|
let releaseLock;
|
|
@@ -1965,6 +2515,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1965
2515
|
this._fingerprintLocks.set(fingerprint, lockPromise);
|
|
1966
2516
|
try {
|
|
1967
2517
|
await existing; // Wait for any previous operation on this fingerprint
|
|
2518
|
+
// Another server may be rewriting the same shared entry right now; its write and ours
|
|
2519
|
+
// would each start from the same old array, and the later SET would drop the other's
|
|
2520
|
+
// row. A shared provider serializes the whole read-modify-write across processes.
|
|
2521
|
+
const provider = this._storageProvider;
|
|
2522
|
+
if (provider?.WithKeyLock) {
|
|
2523
|
+
return await provider.WithKeyLock(fingerprint, CacheCategory.RunViewCache, fn);
|
|
2524
|
+
}
|
|
1968
2525
|
return await fn();
|
|
1969
2526
|
}
|
|
1970
2527
|
finally {
|
|
@@ -1976,46 +2533,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1976
2533
|
}
|
|
1977
2534
|
}
|
|
1978
2535
|
/**
|
|
2536
|
+
* Upserts a single entity in a cached RunView result.
|
|
2537
|
+
* Used by BaseEngine for immediate cache sync when an entity is saved.
|
|
2538
|
+
* If the entity exists (by primary key), it is replaced; otherwise it is added.
|
|
2539
|
+
*
|
|
2540
|
+
* Serializes async operations on the same cache fingerprint to prevent
|
|
2541
|
+
* lost-update races. When multiple entity events fire simultaneously
|
|
2542
|
+
* (e.g., 3 deletes from a TransactionGroup), each read-modify-write cycle
|
|
2543
|
+
* must complete before the next one starts for the same fingerprint.
|
|
2544
|
+
* Different fingerprints run concurrently with no contention.
|
|
2545
|
+
*
|
|
1979
2546
|
* @param fingerprint - The cache fingerprint to update
|
|
1980
2547
|
* @param entityData - The entity data as a plain object (use entity.GetAll())
|
|
1981
|
-
* @param
|
|
2548
|
+
* @param key - The record's primary key
|
|
1982
2549
|
* @param newMaxUpdatedAt - New maxUpdatedAt timestamp (from entity's __mj_UpdatedAt)
|
|
1983
2550
|
* @returns true if cache was updated, false if cache not found or update failed
|
|
1984
2551
|
*/
|
|
1985
2552
|
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
|
-
});
|
|
2553
|
+
return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: entityData }], newMaxUpdatedAt);
|
|
2019
2554
|
}
|
|
2020
2555
|
/**
|
|
2021
2556
|
* Removes a single entity from a cached RunView result.
|
|
@@ -2027,39 +2562,70 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2027
2562
|
* @returns true if cache was updated, false if cache not found or update failed
|
|
2028
2563
|
*/
|
|
2029
2564
|
async RemoveSingleEntity(fingerprint, key, newMaxUpdatedAt) {
|
|
2030
|
-
|
|
2565
|
+
return this.ApplyRowChanges(fingerprint, [{ Key: key, Row: null }], newMaxUpdatedAt);
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Applies several row changes to one cached RunView result in a single read-modify-write:
|
|
2569
|
+
* each change with a `Row` adds or replaces that row, each with `Row: null` removes it, in
|
|
2570
|
+
* order. The slot is written once, and not at all when only removals of absent rows were asked.
|
|
2571
|
+
* All keys must name the same primary-key fields.
|
|
2572
|
+
*
|
|
2573
|
+
* @returns true if the slot now reflects the changes, false if it was not cached or the update failed
|
|
2574
|
+
*/
|
|
2575
|
+
async ApplyRowChanges(fingerprint, changes, newMaxUpdatedAt) {
|
|
2576
|
+
if (!this._storageProvider || !this._config.enabled || changes.length === 0)
|
|
2031
2577
|
return false;
|
|
2032
|
-
return this.
|
|
2578
|
+
return this.maintainSlotLocked(fingerprint, async () => {
|
|
2033
2579
|
try {
|
|
2034
|
-
const cached = await this.
|
|
2580
|
+
const cached = await this.readRunViewSlot(fingerprint);
|
|
2035
2581
|
if (!cached) {
|
|
2582
|
+
LogStatusVerbose(`LocalCacheManager.ApplyRowChanges: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
|
|
2036
2583
|
return false;
|
|
2037
2584
|
}
|
|
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);
|
|
2585
|
+
const pkFieldNames = changes[0].Key.KeyValuePairs.map(kv => kv.FieldName);
|
|
2586
|
+
const resultMap = this.indexRowsByKey(cached.results, pkFieldNames);
|
|
2587
|
+
if (!this.applyChangesToRowMap(resultMap, changes)) {
|
|
2588
|
+
return true; // only removals of rows the slot does not hold
|
|
2048
2589
|
}
|
|
2049
|
-
|
|
2050
|
-
return true; // Not in cache, no-op
|
|
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,
|
|
2590
|
+
return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2055
2591
|
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2056
2592
|
}
|
|
2057
2593
|
catch (e) {
|
|
2058
|
-
LogError(`LocalCacheManager.
|
|
2594
|
+
LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
|
|
2059
2595
|
return false;
|
|
2060
2596
|
}
|
|
2061
2597
|
});
|
|
2062
2598
|
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Rows keyed by a cheap delimiter-joined PK string (no per-row CompositeKey/KeyValuePair
|
|
2601
|
+
* allocation); matching is consistent because {@link cheapKeyFromCompositeKey} uses the same
|
|
2602
|
+
* format. Rows missing a PK value are dropped.
|
|
2603
|
+
*/
|
|
2604
|
+
indexRowsByKey(rows, pkFieldNames) {
|
|
2605
|
+
const resultMap = new Map();
|
|
2606
|
+
for (const row of rows) {
|
|
2607
|
+
const rowObj = row;
|
|
2608
|
+
if (pkFieldNames.some(fn => rowObj[fn] == null))
|
|
2609
|
+
continue;
|
|
2610
|
+
resultMap.set(this.cheapRowKey(rowObj, pkFieldNames), row);
|
|
2611
|
+
}
|
|
2612
|
+
return resultMap;
|
|
2613
|
+
}
|
|
2614
|
+
/** Applies `changes` to `rows` in order. @returns True when anything changed. */
|
|
2615
|
+
applyChangesToRowMap(rows, changes) {
|
|
2616
|
+
let changed = false;
|
|
2617
|
+
for (const change of changes) {
|
|
2618
|
+
const keyStr = this.cheapKeyFromCompositeKey(change.Key);
|
|
2619
|
+
if (change.Row) {
|
|
2620
|
+
rows.set(keyStr, change.Row);
|
|
2621
|
+
changed = true;
|
|
2622
|
+
}
|
|
2623
|
+
else if (rows.delete(keyStr)) {
|
|
2624
|
+
changed = true;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return changed;
|
|
2628
|
+
}
|
|
2063
2629
|
/**
|
|
2064
2630
|
* Stores updated results array back to the cache and updates the registry.
|
|
2065
2631
|
* Shared by UpsertSingleEntity and RemoveSingleEntity to avoid duplication.
|
|
@@ -2075,9 +2641,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2075
2641
|
* correct total.
|
|
2076
2642
|
*/
|
|
2077
2643
|
async storeCachedResults(fingerprint, updatedResults, newMaxUpdatedAt, prior) {
|
|
2644
|
+
// Stamp the slot the way the fill path does — the newest row's __mj_UpdatedAt — so the
|
|
2645
|
+
// stamp means the same thing whichever funnel wrote the slot and matches what the database
|
|
2646
|
+
// reports for the same rows (the client smart-cache check compares the two). The caller's
|
|
2647
|
+
// value (the event time) is used only when the rows carry no timestamp column.
|
|
2648
|
+
const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(updatedResults) ?? newMaxUpdatedAt;
|
|
2078
2649
|
const data = {
|
|
2079
2650
|
results: updatedResults,
|
|
2080
|
-
maxUpdatedAt
|
|
2651
|
+
maxUpdatedAt
|
|
2081
2652
|
};
|
|
2082
2653
|
// Carry the schemaHash FORWARD — never recompute it here (B38).
|
|
2083
2654
|
//
|
|
@@ -2116,10 +2687,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2116
2687
|
if (!prior?.providerInternalScaffolding) {
|
|
2117
2688
|
this.freezeRowDataIfProviderSharesReferences(data);
|
|
2118
2689
|
}
|
|
2119
|
-
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache);
|
|
2120
2690
|
const existingEntry = this._registry.get(fingerprint);
|
|
2691
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
|
|
2121
2692
|
if (existingEntry) {
|
|
2122
|
-
existingEntry.maxUpdatedAt =
|
|
2693
|
+
existingEntry.maxUpdatedAt = maxUpdatedAt;
|
|
2123
2694
|
existingEntry.rowCount = updatedResults.length;
|
|
2124
2695
|
existingEntry.sizeBytes = sizeBytes;
|
|
2125
2696
|
existingEntry.lastAccessedAt = Date.now();
|
|
@@ -2214,7 +2785,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2214
2785
|
// Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
|
|
2215
2786
|
// cache to make room for an entry that can't be retained within budget.
|
|
2216
2787
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2217
|
-
|
|
2788
|
+
LogWarning(`[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — estimated entry size ${sizeBytes} bytes exceeds per-entry cap (${this._config.maxEntryPercentOfCache}% of ${this._config.maxSizeBytes} byte budget)`, 'Cache');
|
|
2218
2789
|
return;
|
|
2219
2790
|
}
|
|
2220
2791
|
// Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
|
|
@@ -2227,10 +2798,17 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2227
2798
|
// Check if we need to evict entries. Evicts OTHER entries only — cannot cancel this write.
|
|
2228
2799
|
await this.evictIfNeeded(sizeBytes);
|
|
2229
2800
|
const now = Date.now();
|
|
2230
|
-
|
|
2801
|
+
// Keep the expiry a rewrite found, exactly as the RunView path does: recomputing it from
|
|
2802
|
+
// `now` on every write pushes a bounded entry's expiry forward for as long as anything keeps
|
|
2803
|
+
// rewriting it, so it never expires at all.
|
|
2804
|
+
const existingExpiry = this._registry.get(fingerprint)?.expiresAt;
|
|
2805
|
+
const expiresAt = existingExpiry ?? (ttlMs ? now + ttlMs : undefined);
|
|
2231
2806
|
try {
|
|
2232
|
-
//
|
|
2233
|
-
|
|
2807
|
+
// The expiry must reach the STORE, not just this process's registry: another server
|
|
2808
|
+
// reading this slot has no registry entry for it, so a store-side TTL is the only thing
|
|
2809
|
+
// that bounds it there. Without it an external-data-source query result was served
|
|
2810
|
+
// forever by every server except the one that wrote it.
|
|
2811
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache, expiresAt ? { TTLSeconds: Math.max(1, Math.ceil((expiresAt - now) / 1000)) } : undefined);
|
|
2234
2812
|
this.registerEntry({
|
|
2235
2813
|
key: fingerprint,
|
|
2236
2814
|
type: 'runquery',
|
|
@@ -2515,11 +3093,21 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2515
3093
|
// Don't persist on every access - too expensive
|
|
2516
3094
|
}
|
|
2517
3095
|
}
|
|
3096
|
+
/**
|
|
3097
|
+
* True when the registry must stay in this process: the storage is shared with other
|
|
3098
|
+
* processes, so a persisted registry would be one key every server overwrites with its own
|
|
3099
|
+
* view (and publishes to every peer on each write), and a booting server would adopt another
|
|
3100
|
+
* server's accounting. Entries written by other processes are found through the provider's
|
|
3101
|
+
* per-entity index instead ({@link resolveFingerprintsForEntity}).
|
|
3102
|
+
*/
|
|
3103
|
+
registryIsProcessLocalOnly() {
|
|
3104
|
+
return this._storageProvider?.SharedAcrossProcesses === true;
|
|
3105
|
+
}
|
|
2518
3106
|
/**
|
|
2519
3107
|
* Loads the registry from storage.
|
|
2520
3108
|
*/
|
|
2521
3109
|
async loadRegistry() {
|
|
2522
|
-
if (!this._storageProvider)
|
|
3110
|
+
if (!this._storageProvider || this.registryIsProcessLocalOnly())
|
|
2523
3111
|
return;
|
|
2524
3112
|
try {
|
|
2525
3113
|
// Native object read — registry is a plain CacheEntryInfo[] array.
|
|
@@ -2554,7 +3142,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2554
3142
|
* Persists the registry to storage.
|
|
2555
3143
|
*/
|
|
2556
3144
|
async persistRegistry() {
|
|
2557
|
-
if (!this._storageProvider)
|
|
3145
|
+
if (!this._storageProvider || this.registryIsProcessLocalOnly())
|
|
2558
3146
|
return;
|
|
2559
3147
|
try {
|
|
2560
3148
|
// Native object storage — store the entries array directly.
|
|
@@ -2788,8 +3376,26 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2788
3376
|
if (!this._storageProvider || !this._config.enabled)
|
|
2789
3377
|
return;
|
|
2790
3378
|
const now = Date.now();
|
|
2791
|
-
|
|
3379
|
+
// On a shared store this process must not age out entries by its OWN clock: every server
|
|
3380
|
+
// would delete the same keys on its own schedule and publish a `removed` for each, and the
|
|
3381
|
+
// peers would reload every affected engine — a fleet-wide deletion and reload storm for
|
|
3382
|
+
// entries the store already expires itself (the Redis provider sets a per-key TTL). The
|
|
3383
|
+
// local TTL stays in force for process-private stores, where nothing else can expire them.
|
|
3384
|
+
const sharedStore = this._storageProvider.SharedAcrossProcesses === true;
|
|
3385
|
+
const ttlMs = sharedStore ? 0 : this._config.defaultTTLMs;
|
|
3386
|
+
if (sharedStore && this._config.defaultTTLMs > 0 && !this._sharedTTLWarned) {
|
|
3387
|
+
this._sharedTTLWarned = true;
|
|
3388
|
+
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');
|
|
3389
|
+
}
|
|
2792
3390
|
const toDelete = [];
|
|
3391
|
+
// Entries to drop from this process's bookkeeping WITHOUT touching the store. An entry that
|
|
3392
|
+
// carries its own `expiresAt` always handed the store the matching TTL when it was written,
|
|
3393
|
+
// so a shared store expires the key itself. Deleting it here as well means every server
|
|
3394
|
+
// removes the same key on its own clock and publishes a `removed` for it, and every peer
|
|
3395
|
+
// reloads the affected engine — the storm the TTL branch above already avoids, in miniature.
|
|
3396
|
+
// Forgetting it locally still matters: otherwise this registry records slots the store no
|
|
3397
|
+
// longer has.
|
|
3398
|
+
const toForget = [];
|
|
2793
3399
|
for (const [key, entry] of this._registry) {
|
|
2794
3400
|
// TTL expiry check
|
|
2795
3401
|
if (ttlMs > 0 && entry.cachedAt + ttlMs < now) {
|
|
@@ -2798,29 +3404,38 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2798
3404
|
}
|
|
2799
3405
|
// expiresAt check (if set individually)
|
|
2800
3406
|
if (entry.expiresAt && entry.expiresAt < now) {
|
|
2801
|
-
toDelete.push(key);
|
|
3407
|
+
(sharedStore ? toForget : toDelete).push(key);
|
|
2802
3408
|
}
|
|
2803
3409
|
}
|
|
2804
|
-
if (toDelete.length > 0) {
|
|
3410
|
+
if (toDelete.length > 0 || toForget.length > 0) {
|
|
2805
3411
|
if (this._config.verboseLogging) {
|
|
2806
|
-
LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries
|
|
3412
|
+
LogStatusEx({ message: ` 🗑️ [Cache SWEEP] Evicting ${toDelete.length} TTL-expired entries` +
|
|
3413
|
+
(toForget.length > 0 ? `, forgetting ${toForget.length} the shared store expires itself` : ''), verboseOnly: true });
|
|
2807
3414
|
}
|
|
2808
3415
|
for (const key of toDelete) {
|
|
2809
3416
|
try {
|
|
2810
3417
|
const entry = this._registry.get(key);
|
|
2811
3418
|
const category = this.getCategoryForType(entry?.type);
|
|
2812
3419
|
await this._storageProvider.Remove(key, category);
|
|
2813
|
-
|
|
2814
|
-
this.removeFromEntityIndex(entry.fingerprint);
|
|
2815
|
-
}
|
|
2816
|
-
this._registry.delete(key);
|
|
3420
|
+
this.forgetRegistryEntry(key);
|
|
2817
3421
|
}
|
|
2818
3422
|
catch {
|
|
2819
3423
|
// Continue
|
|
2820
3424
|
}
|
|
2821
3425
|
}
|
|
3426
|
+
for (const key of toForget) {
|
|
3427
|
+
this.forgetRegistryEntry(key);
|
|
3428
|
+
}
|
|
2822
3429
|
await this.persistRegistry();
|
|
2823
3430
|
}
|
|
2824
3431
|
}
|
|
3432
|
+
/** Drops one entry from the registry and the fingerprint index, leaving the store alone. @internal */
|
|
3433
|
+
forgetRegistryEntry(key) {
|
|
3434
|
+
const entry = this._registry.get(key);
|
|
3435
|
+
if (entry?.fingerprint) {
|
|
3436
|
+
this.removeFromEntityIndex(entry.fingerprint);
|
|
3437
|
+
}
|
|
3438
|
+
this._registry.delete(key);
|
|
3439
|
+
}
|
|
2825
3440
|
}
|
|
2826
3441
|
//# sourceMappingURL=localCacheManager.js.map
|