@memberjunction/core 6.2.0-edge.4 → 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 +18 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +29 -5
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +117 -11
- package/dist/generic/baseEngine.js.map +1 -1
- 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 +37 -8
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +162 -65
- 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/entityInfo.d.ts +78 -3
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +348 -55
- 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 +25 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +238 -40
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +466 -71
- package/dist/generic/localCacheManager.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 +118 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +398 -100
- 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 +6 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +10 -10
- package/dist/generic/securityInfo.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 +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
- package/readme.md +29 -4
|
@@ -56,13 +56,35 @@ function deepFreezeCacheValue(value, visited = new WeakSet()) {
|
|
|
56
56
|
// ============================================================================
|
|
57
57
|
// DEFAULT CONFIGURATION
|
|
58
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;
|
|
59
81
|
const DEFAULT_CONFIG = {
|
|
60
82
|
enabled: true,
|
|
61
83
|
maxSizeBytes: 150 * 1024 * 1024, // 150MB
|
|
62
84
|
defaultTTLMs: 0, // No TTL — event-based invalidation is the primary mechanism
|
|
63
85
|
evictionPolicy: 'lru',
|
|
64
86
|
maxPercentOfCachePerEntity: 50,
|
|
65
|
-
maxEntryPercentOfCache:
|
|
87
|
+
maxEntryPercentOfCache: 'auto',
|
|
66
88
|
evictionSweepIntervalMs: 300000, // 5 minutes
|
|
67
89
|
verboseLogging: false,
|
|
68
90
|
};
|
|
@@ -119,6 +141,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
119
141
|
static get Instance() {
|
|
120
142
|
return super.getInstance();
|
|
121
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; }
|
|
122
149
|
constructor() {
|
|
123
150
|
super();
|
|
124
151
|
this._storageProvider = null;
|
|
@@ -146,6 +173,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
146
173
|
this._entityEventBatches = new EntityEventBatchSet();
|
|
147
174
|
/** Save/delete events a batch took when they were raised. See {@link WasTakenByEntityEventBatch}. */
|
|
148
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();
|
|
149
184
|
this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
|
|
150
185
|
/**
|
|
151
186
|
* Reverse index from entity name to the set of RunView cache fingerprints
|
|
@@ -622,27 +657,163 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
622
657
|
return Number.isFinite(startRow) && startRow > 0;
|
|
623
658
|
}
|
|
624
659
|
/**
|
|
625
|
-
*
|
|
626
|
-
* (
|
|
627
|
-
*
|
|
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.
|
|
628
663
|
* @param fingerprint - The cache fingerprint
|
|
629
664
|
* @param data - The cached data to validate
|
|
630
|
-
* @returns
|
|
665
|
+
* @returns The mismatch, or null when the entry may be served
|
|
631
666
|
*/
|
|
632
|
-
|
|
667
|
+
findSchemaMismatch(fingerprint, data) {
|
|
633
668
|
if (!data.schemaHash)
|
|
634
|
-
return
|
|
669
|
+
return null;
|
|
635
670
|
const entityName = this.extractEntityFromFingerprint(fingerprint);
|
|
636
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) {
|
|
637
697
|
return false;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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,
|
|
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) {
|
|
643
798
|
return true;
|
|
644
799
|
}
|
|
645
|
-
|
|
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;
|
|
646
817
|
}
|
|
647
818
|
/**
|
|
648
819
|
* The newest `__mj_UpdatedAt` among `rows` as an ISO string, `''` for an empty set, or
|
|
@@ -1542,9 +1713,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1542
1713
|
if (!this._storageProvider || !this._config.enabled)
|
|
1543
1714
|
return;
|
|
1544
1715
|
const key = this.buildDatasetKey(name, itemFilters, keyPrefix);
|
|
1545
|
-
// Estimate size
|
|
1546
|
-
|
|
1547
|
-
|
|
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;
|
|
1548
1720
|
// Fourth write funnel, held to the same contract as SetRunViewResult /
|
|
1549
1721
|
// SetRunQueryResult / storeCachedResults. `GetDataset` hands this object straight back
|
|
1550
1722
|
// out, so on a reference-sharing provider every reader shares it — the same exposure the
|
|
@@ -1944,6 +2116,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1944
2116
|
* @returns The schema hash string, or undefined if the entity can't be resolved
|
|
1945
2117
|
*/
|
|
1946
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) {
|
|
1947
2127
|
try {
|
|
1948
2128
|
const md = provider ?? new Metadata();
|
|
1949
2129
|
const entity = md.EntityByName(entityName);
|
|
@@ -1952,12 +2132,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1952
2132
|
// Use natural sequence order (EntityInfo.Fields is sorted by Sequence).
|
|
1953
2133
|
// This detects field additions, removals, AND reorderings.
|
|
1954
2134
|
const fieldNames = entity.Fields.map(f => f.Name).join('|');
|
|
1955
|
-
|
|
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
|
+
};
|
|
1956
2143
|
}
|
|
1957
2144
|
catch {
|
|
1958
2145
|
return undefined;
|
|
1959
2146
|
}
|
|
1960
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
|
+
}
|
|
1961
2156
|
/**
|
|
1962
2157
|
* Deep-freezes an about-to-be-stored cache payload when — and only when — the active
|
|
1963
2158
|
* storage provider hands out live object references
|
|
@@ -2113,26 +2308,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2113
2308
|
}
|
|
2114
2309
|
// Compute and store schema hash for upgrade detection
|
|
2115
2310
|
if (params.EntityName) {
|
|
2116
|
-
const
|
|
2117
|
-
if (
|
|
2118
|
-
|
|
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
|
+
}
|
|
2119
2321
|
}
|
|
2120
2322
|
}
|
|
2121
2323
|
// Estimate size by sampling rows (eviction accounting only); the actual stored
|
|
2122
2324
|
// value is the native object — no full JSON.stringify on the hot path.
|
|
2123
2325
|
const sizeBytes = this.estimateResultsSize(data.results);
|
|
2124
|
-
// Oversized-entry gate: an entry
|
|
2125
|
-
// never cached
|
|
2126
|
-
//
|
|
2127
|
-
//
|
|
2128
|
-
//
|
|
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.
|
|
2129
2331
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2130
|
-
LogWarning(
|
|
2332
|
+
LogWarning(this.describeOversizedEntry(params.EntityName || fingerprint.substring(0, 60), sizeBytes), 'Cache');
|
|
2131
2333
|
return;
|
|
2132
2334
|
}
|
|
2133
|
-
// The oversized gate above
|
|
2134
|
-
// synchronous — so by here the row set is definitely becoming shared
|
|
2135
|
-
// 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
|
|
2136
2338
|
// steps below are awaited, and callers hold this very array while they run (the
|
|
2137
2339
|
// smart-cache stale leg does not await this method at all). Freezing after them leaves
|
|
2138
2340
|
// a window in which shared rows are handed out still mutable — and `BaseEntity`
|
|
@@ -2194,16 +2396,16 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2194
2396
|
this._stats.misses++;
|
|
2195
2397
|
return null;
|
|
2196
2398
|
}
|
|
2197
|
-
return this.readRunViewSlot(fingerprint);
|
|
2399
|
+
return this.readRunViewSlot(fingerprint, 'serve');
|
|
2198
2400
|
}
|
|
2199
2401
|
/** {@link GetRunViewResult} without the open-batch check; slot maintenance reads through this. */
|
|
2200
|
-
async readRunViewSlot(fingerprint) {
|
|
2402
|
+
async readRunViewSlot(fingerprint, purpose) {
|
|
2201
2403
|
if (!this._storageProvider || !this._config.enabled)
|
|
2202
2404
|
return null;
|
|
2203
2405
|
try {
|
|
2204
2406
|
// Native object read — IDB structured-clones the result back, no JSON.parse needed.
|
|
2205
2407
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunViewCache);
|
|
2206
|
-
return this.materializeCachedRunViewResult(fingerprint, parsed);
|
|
2408
|
+
return this.materializeCachedRunViewResult(fingerprint, parsed, purpose);
|
|
2207
2409
|
}
|
|
2208
2410
|
catch (e) {
|
|
2209
2411
|
LogError(`LocalCacheManager.GetRunViewResult failed: ${e}`);
|
|
@@ -2243,7 +2445,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2243
2445
|
try {
|
|
2244
2446
|
const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
|
|
2245
2447
|
for (const [fp, parsed] of raw) {
|
|
2246
|
-
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed));
|
|
2448
|
+
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed, 'serve'));
|
|
2247
2449
|
}
|
|
2248
2450
|
return out;
|
|
2249
2451
|
}
|
|
@@ -2263,10 +2465,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2263
2465
|
* recording the appropriate hit/miss + access-tracking side effects.
|
|
2264
2466
|
*
|
|
2265
2467
|
* Also validates the schema hash (if present) to detect structurally stale
|
|
2266
|
-
* cache entries after schema migrations. If the entity's field list
|
|
2267
|
-
*
|
|
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}).
|
|
2268
2471
|
*/
|
|
2269
|
-
materializeCachedRunViewResult(fingerprint, parsed) {
|
|
2472
|
+
materializeCachedRunViewResult(fingerprint, parsed, purpose) {
|
|
2270
2473
|
if (!parsed) {
|
|
2271
2474
|
this._stats.misses++;
|
|
2272
2475
|
return null;
|
|
@@ -2280,8 +2483,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2280
2483
|
this._stats.misses++;
|
|
2281
2484
|
return null;
|
|
2282
2485
|
}
|
|
2283
|
-
|
|
2284
|
-
|
|
2486
|
+
const schemaMismatch = this.findSchemaMismatch(fingerprint, parsed);
|
|
2487
|
+
if (schemaMismatch) {
|
|
2488
|
+
this.handleSchemaMismatch(schemaMismatch, purpose, () => this.InvalidateRunViewResult(fingerprint));
|
|
2285
2489
|
this._stats.misses++;
|
|
2286
2490
|
return null;
|
|
2287
2491
|
}
|
|
@@ -2296,6 +2500,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2296
2500
|
totalRowCount: parsed.totalRowCount,
|
|
2297
2501
|
// Surfaced so in-place maintenance can carry it forward on rewrite (B38).
|
|
2298
2502
|
schemaHash: parsed.schemaHash,
|
|
2503
|
+
schemaStamp: parsed.schemaStamp,
|
|
2299
2504
|
// Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
|
|
2300
2505
|
providerInternalScaffolding: parsed.providerInternalScaffolding,
|
|
2301
2506
|
};
|
|
@@ -2577,7 +2782,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2577
2782
|
return false;
|
|
2578
2783
|
return this.maintainSlotLocked(fingerprint, async () => {
|
|
2579
2784
|
try {
|
|
2580
|
-
const cached = await this.readRunViewSlot(fingerprint);
|
|
2785
|
+
const cached = await this.readRunViewSlot(fingerprint, 'maintain');
|
|
2581
2786
|
if (!cached) {
|
|
2582
2787
|
LogStatusVerbose(`LocalCacheManager.ApplyRowChanges: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
|
|
2583
2788
|
return false;
|
|
@@ -2588,7 +2793,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2588
2793
|
return true; // only removals of rows the slot does not hold
|
|
2589
2794
|
}
|
|
2590
2795
|
return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2591
|
-
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2796
|
+
schemaStamp: cached.schemaStamp, providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2592
2797
|
}
|
|
2593
2798
|
catch (e) {
|
|
2594
2799
|
LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
|
|
@@ -2664,6 +2869,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2664
2869
|
if (prior?.schemaHash) {
|
|
2665
2870
|
data.schemaHash = prior.schemaHash;
|
|
2666
2871
|
}
|
|
2872
|
+
if (prior?.schemaStamp) {
|
|
2873
|
+
data.schemaStamp = prior.schemaStamp;
|
|
2874
|
+
}
|
|
2667
2875
|
// Carry the freeze exemption forward too — a scaffolding slot that gets maintained in
|
|
2668
2876
|
// place must not silently become frozen, or the owner that mutates those rows breaks on
|
|
2669
2877
|
// the next read (for the metadata dataset that means booting with no metadata).
|
|
@@ -2769,8 +2977,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2769
2977
|
* @param rowCount - Optional row count (defaults to results.length if not provided)
|
|
2770
2978
|
* @param queryId - Optional query ID for reference
|
|
2771
2979
|
* @param ttlMs - Optional TTL in milliseconds (for cache expiry tracking)
|
|
2772
|
-
|
|
2773
|
-
|
|
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) {
|
|
2774
2988
|
if (!this._storageProvider || !this._config.enabled)
|
|
2775
2989
|
return;
|
|
2776
2990
|
const actualRowCount = rowCount ?? results.length;
|
|
@@ -2780,17 +2994,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2780
2994
|
// does not refresh in-process): the warmer proved their permission by executing; anyone
|
|
2781
2995
|
// ELSE falls through to an authorized execution rather than being served unchecked.
|
|
2782
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
|
+
}
|
|
2783
3007
|
// Estimate size by sampling rows (eviction accounting only).
|
|
2784
3008
|
const sizeBytes = this.estimateResultsSize(results);
|
|
2785
3009
|
// Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
|
|
2786
3010
|
// cache to make room for an entry that can't be retained within budget.
|
|
2787
3011
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2788
|
-
LogWarning(`
|
|
3012
|
+
LogWarning(this.describeOversizedEntry(`query ${queryName}`, sizeBytes), 'Cache');
|
|
2789
3013
|
return;
|
|
2790
3014
|
}
|
|
2791
3015
|
// Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
|
|
2792
3016
|
// `results` straight back out) — closed here rather than waiting for a mutator to
|
|
2793
|
-
// find it. Placed immediately after the only declining
|
|
3017
|
+
// find it. Placed immediately after the only declining gates and BEFORE the awaited
|
|
2794
3018
|
// eviction below, for the same reason as SetRunViewResult: callers of this method do
|
|
2795
3019
|
// not always await it, so any yield point before the freeze is a window in which the
|
|
2796
3020
|
// caller can still mutate the rows the cache is about to store.
|
|
@@ -2808,7 +3032,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2808
3032
|
// reading this slot has no registry entry for it, so a store-side TTL is the only thing
|
|
2809
3033
|
// that bounds it there. Without it an external-data-source query result was served
|
|
2810
3034
|
// forever by every server except the one that wrote it.
|
|
2811
|
-
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache,
|
|
3035
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache, LocalCacheManager.runQueryWriteOptions(expiresAt, now, definition?.QueryID ?? queryId));
|
|
2812
3036
|
this.registerEntry({
|
|
2813
3037
|
key: fingerprint,
|
|
2814
3038
|
type: 'runquery',
|
|
@@ -2827,13 +3051,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2827
3051
|
LogError(`LocalCacheManager.SetRunQueryResult failed: ${e}`);
|
|
2828
3052
|
}
|
|
2829
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
|
+
}
|
|
2830
3068
|
/**
|
|
2831
3069
|
* Retrieves a cached RunQuery result.
|
|
2832
3070
|
*
|
|
2833
3071
|
* @param fingerprint - The cache fingerprint
|
|
2834
|
-
* @
|
|
2835
|
-
|
|
2836
|
-
|
|
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) {
|
|
2837
3081
|
if (!this._storageProvider || !this._config.enabled)
|
|
2838
3082
|
return null;
|
|
2839
3083
|
// Check if entry has expired
|
|
@@ -2848,6 +3092,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2848
3092
|
// Native object read — no JSON.parse needed.
|
|
2849
3093
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunQueryCache);
|
|
2850
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
|
+
}
|
|
2851
3103
|
this.recordAccess(fingerprint);
|
|
2852
3104
|
this._stats.hits++;
|
|
2853
3105
|
// Handle legacy entries that may not have rowCount
|
|
@@ -2861,7 +3113,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2861
3113
|
// (the B38 omission pattern, which this session exists to stamp out — and
|
|
2862
3114
|
// which the first version of this very fix repeated) silently disabled that
|
|
2863
3115
|
// tie-break and with it TTL caching for runtime-created queries.
|
|
2864
|
-
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
|
|
2865
3120
|
};
|
|
2866
3121
|
}
|
|
2867
3122
|
}
|
|
@@ -2871,6 +3126,22 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2871
3126
|
this._stats.misses++;
|
|
2872
3127
|
return null;
|
|
2873
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
|
+
}
|
|
2874
3145
|
/**
|
|
2875
3146
|
* Invalidates a cached RunQuery result.
|
|
2876
3147
|
*
|
|
@@ -2891,7 +3162,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2891
3162
|
* Invalidates all cached RunQuery results for a specific query.
|
|
2892
3163
|
* Useful when a query's underlying data changes and all related caches should be cleared.
|
|
2893
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
|
+
*
|
|
2894
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.
|
|
2895
3171
|
*/
|
|
2896
3172
|
async InvalidateQueryCaches(queryName) {
|
|
2897
3173
|
if (!this._storageProvider)
|
|
@@ -2914,12 +3190,66 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2914
3190
|
}
|
|
2915
3191
|
await this.persistRegistry();
|
|
2916
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
|
+
}
|
|
2917
3246
|
/**
|
|
2918
3247
|
* Gets the cache status (fingerprint data) for a RunQuery result.
|
|
2919
3248
|
* Used for smart cache validation with the server.
|
|
2920
3249
|
*
|
|
2921
3250
|
* @param fingerprint - The cache fingerprint
|
|
2922
|
-
* @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
|
|
2923
3253
|
*/
|
|
2924
3254
|
async GetRunQueryCacheStatus(fingerprint) {
|
|
2925
3255
|
const cached = await this.GetRunQueryResult(fingerprint);
|
|
@@ -2927,7 +3257,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2927
3257
|
return null;
|
|
2928
3258
|
return {
|
|
2929
3259
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
2930
|
-
rowCount: cached.rowCount
|
|
3260
|
+
rowCount: cached.rowCount,
|
|
3261
|
+
definitionHash: cached.definitionHash
|
|
2931
3262
|
};
|
|
2932
3263
|
}
|
|
2933
3264
|
// ========================================================================
|
|
@@ -2957,17 +3288,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2957
3288
|
runview: { count: 0, sizeBytes: 0 },
|
|
2958
3289
|
runquery: { count: 0, sizeBytes: 0 }
|
|
2959
3290
|
};
|
|
3291
|
+
let totalSizeBytes = 0;
|
|
3292
|
+
let oldestEntry = Infinity;
|
|
3293
|
+
let newestEntry = 0;
|
|
2960
3294
|
for (const entry of entries) {
|
|
2961
3295
|
byType[entry.type].count++;
|
|
2962
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;
|
|
2963
3302
|
}
|
|
2964
|
-
const timestamps = entries.map(e => e.cachedAt);
|
|
2965
3303
|
return {
|
|
2966
3304
|
totalEntries: entries.length,
|
|
2967
|
-
totalSizeBytes
|
|
3305
|
+
totalSizeBytes,
|
|
2968
3306
|
byType,
|
|
2969
|
-
oldestEntry:
|
|
2970
|
-
newestEntry
|
|
3307
|
+
oldestEntry: entries.length > 0 ? oldestEntry : 0,
|
|
3308
|
+
newestEntry,
|
|
2971
3309
|
hits: this._stats.hits,
|
|
2972
3310
|
misses: this._stats.misses
|
|
2973
3311
|
};
|
|
@@ -3187,7 +3525,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3187
3525
|
return 0;
|
|
3188
3526
|
}
|
|
3189
3527
|
};
|
|
3190
|
-
const sampleCount = Math.min(
|
|
3528
|
+
const sampleCount = rowCount <= 3 ? rowCount : Math.min(10, Math.max(3, Math.ceil(rowCount * 0.10)));
|
|
3191
3529
|
if (sampleCount >= rowCount) {
|
|
3192
3530
|
// Small result set — measure every row (no point sampling).
|
|
3193
3531
|
let total = 0;
|
|
@@ -3209,32 +3547,89 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3209
3547
|
return Math.ceil(avgLen * rowCount) * 2;
|
|
3210
3548
|
}
|
|
3211
3549
|
/**
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3214
|
-
*
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
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.
|
|
3218
3559
|
*/
|
|
3219
|
-
|
|
3220
|
-
const pct = this.
|
|
3560
|
+
get MaxEntrySizeBytes() {
|
|
3561
|
+
const pct = this.effectiveMaxEntryPercent;
|
|
3221
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)
|
|
3222
3588
|
return false;
|
|
3223
|
-
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.`;
|
|
3224
3608
|
}
|
|
3225
3609
|
/**
|
|
3226
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.
|
|
3227
3620
|
*/
|
|
3228
3621
|
async evictIfNeeded(neededBytes) {
|
|
3229
3622
|
if (!this._storageProvider)
|
|
3230
3623
|
return;
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
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)
|
|
3234
3630
|
return;
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
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);
|
|
3238
3633
|
await this.evict(targetFreeBytes);
|
|
3239
3634
|
}
|
|
3240
3635
|
/**
|