@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6
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 +22 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +43 -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/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +82 -14
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +311 -46
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +194 -14
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +638 -218
- 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/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -13
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +321 -77
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.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 +306 -12
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +894 -92
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- 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 +39 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +318 -51
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +675 -109
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +370 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1085 -284
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +23 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +73 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.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 +17 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +25 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { BaseSingleton, MJGlobal, MJEventType } from "@memberjunction/global";
|
|
2
|
-
import { IsMaterializedDataSource } from "../views/runView.js";
|
|
2
|
+
import { IsMaterializedDataSource, NormalizeAlternateBaseViewName } from "../views/runView.js";
|
|
3
|
+
import { GetEffectiveAlternateBaseViewName } from "./alternateBaseViews.js";
|
|
4
|
+
import { ViewPropertyValuesKey } from "../views/viewProperties.js";
|
|
3
5
|
import { LogError, LogStatusEx, LogWarning } from "./logging.js";
|
|
4
6
|
import { BaseEntity } from "./baseEntity.js";
|
|
5
7
|
import { Metadata } from "./metadata.js";
|
|
@@ -56,13 +58,35 @@ function deepFreezeCacheValue(value, visited = new WeakSet()) {
|
|
|
56
58
|
// ============================================================================
|
|
57
59
|
// DEFAULT CONFIGURATION
|
|
58
60
|
// ============================================================================
|
|
61
|
+
/**
|
|
62
|
+
* Minimum percentage of the cache budget that eviction frees in one pass, so a run of
|
|
63
|
+
* stores near the budget does not trigger an eviction each time. Also the floor under the
|
|
64
|
+
* space any single entry must leave behind it — see {@link HARD_MAX_ENTRY_PERCENT}.
|
|
65
|
+
*/
|
|
66
|
+
const EVICTION_THRASH_FLOOR_PERCENT = 10;
|
|
67
|
+
/**
|
|
68
|
+
* Percentage of the budget held back from any one entry when the per-entry ceiling is
|
|
69
|
+
* derived (`maxEntryPercentOfCache: 'auto'`). The reserve is what keeps the rest of the
|
|
70
|
+
* cache alive alongside one large entry: with it, a store that arrives after the large
|
|
71
|
+
* entry can be satisfied by evicting entries out of the reserve rather than the large
|
|
72
|
+
* entry itself. It MUST be at least {@link EVICTION_THRASH_FLOOR_PERCENT}, or a single
|
|
73
|
+
* eviction pass could not be satisfied without evicting the large entry that eviction
|
|
74
|
+
* was making room around.
|
|
75
|
+
*/
|
|
76
|
+
const SINGLE_ENTRY_RESERVE_PERCENT = 25;
|
|
77
|
+
/**
|
|
78
|
+
* Hard upper bound on the per-entry ceiling, whatever the configuration says. One entry
|
|
79
|
+
* may never occupy so much of the budget that less than one eviction pass' worth of space
|
|
80
|
+
* is left — that is the state in which every store wipes everything else.
|
|
81
|
+
*/
|
|
82
|
+
const HARD_MAX_ENTRY_PERCENT = 100 - EVICTION_THRASH_FLOOR_PERCENT;
|
|
59
83
|
const DEFAULT_CONFIG = {
|
|
60
84
|
enabled: true,
|
|
61
85
|
maxSizeBytes: 150 * 1024 * 1024, // 150MB
|
|
62
86
|
defaultTTLMs: 0, // No TTL — event-based invalidation is the primary mechanism
|
|
63
87
|
evictionPolicy: 'lru',
|
|
64
88
|
maxPercentOfCachePerEntity: 50,
|
|
65
|
-
maxEntryPercentOfCache:
|
|
89
|
+
maxEntryPercentOfCache: 'auto',
|
|
66
90
|
evictionSweepIntervalMs: 300000, // 5 minutes
|
|
67
91
|
verboseLogging: false,
|
|
68
92
|
};
|
|
@@ -119,6 +143,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
119
143
|
static get Instance() {
|
|
120
144
|
return super.getInstance();
|
|
121
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* How often, at most, a process writes an entity's shared slots while it keeps finding that
|
|
148
|
+
* entity's slots under a schema different from its own. See {@link handleSchemaMismatch}.
|
|
149
|
+
*/
|
|
150
|
+
static { this.SCHEMA_MISMATCH_WRITE_INTERVAL_MS = 60_000; }
|
|
122
151
|
constructor() {
|
|
123
152
|
super();
|
|
124
153
|
this._storageProvider = null;
|
|
@@ -129,6 +158,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
129
158
|
*/
|
|
130
159
|
this._sharesReferences = false;
|
|
131
160
|
this._registry = new Map();
|
|
161
|
+
this._lastVerifiedAt = new Map();
|
|
132
162
|
this._initialized = false;
|
|
133
163
|
this._initializePromise = null;
|
|
134
164
|
this._stats = { hits: 0, misses: 0 };
|
|
@@ -146,6 +176,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
146
176
|
this._entityEventBatches = new EntityEventBatchSet();
|
|
147
177
|
/** Save/delete events a batch took when they were raised. See {@link WasTakenByEntityEventBatch}. */
|
|
148
178
|
this._eventsTakenByBatch = new WeakSet();
|
|
179
|
+
/**
|
|
180
|
+
* Write holds on entities whose shared slots were found under a different schema, keyed by
|
|
181
|
+
* lowercased entity name. While a hold applies, {@link SetRunViewResult} skips or rate-limits
|
|
182
|
+
* writes of that entity's slots. See {@link handleSchemaMismatch} and {@link mayWriteUnderSchemaHold}.
|
|
183
|
+
*/
|
|
184
|
+
this._schemaWriteHolds = new Map();
|
|
185
|
+
/** Schema mismatches already reported, so each entity/hash pair is logged once per process. */
|
|
186
|
+
this._reportedSchemaMismatches = new Set();
|
|
149
187
|
this.REGISTRY_KEY = '__MJ_CACHE_REGISTRY__';
|
|
150
188
|
/**
|
|
151
189
|
* Reverse index from entity name to the set of RunView cache fingerprints
|
|
@@ -532,6 +570,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
532
570
|
* segment is built. Same misclassification, worse consequence: a save by
|
|
533
571
|
* user A was upserted into user B's RLS-scoped slot, injecting a row B's
|
|
534
572
|
* predicate excludes. That is an RLS bypass.
|
|
573
|
+
* - `fls:<h>` — the slot holds only the columns one permission class may read. Upserting a
|
|
574
|
+
* saver's row would put columns into it that its readers may not see (or
|
|
575
|
+
* take away ones they may), so it is invalidated like the two above.
|
|
535
576
|
*
|
|
536
577
|
* ## Why it is written as a DENY-by-default allowlist
|
|
537
578
|
* Enumerating the narrowing segments would repeat the original mistake: the next segment
|
|
@@ -555,6 +596,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
555
596
|
if (!seg) {
|
|
556
597
|
continue;
|
|
557
598
|
}
|
|
599
|
+
if (seg.startsWith('abv:')) {
|
|
600
|
+
return true; // alternate base view applies a canonical WHERE/JOIN — never maintain in place on save
|
|
601
|
+
}
|
|
558
602
|
if (seg.startsWith('imr:')) {
|
|
559
603
|
continue; // widens the set — safe to maintain
|
|
560
604
|
}
|
|
@@ -622,27 +666,163 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
622
666
|
return Number.isFinite(startRow) && startRow > 0;
|
|
623
667
|
}
|
|
624
668
|
/**
|
|
625
|
-
*
|
|
626
|
-
* (
|
|
627
|
-
*
|
|
669
|
+
* Compares a cached RunView entry's schema hash against this process's current field list for
|
|
670
|
+
* the entity (a mismatch follows a migration + CodeGen that added, removed or reordered
|
|
671
|
+
* columns). Entries without a hash, and entities this process cannot resolve, never mismatch.
|
|
628
672
|
* @param fingerprint - The cache fingerprint
|
|
629
673
|
* @param data - The cached data to validate
|
|
630
|
-
* @returns
|
|
674
|
+
* @returns The mismatch, or null when the entry may be served
|
|
631
675
|
*/
|
|
632
|
-
|
|
676
|
+
findSchemaMismatch(fingerprint, data) {
|
|
633
677
|
if (!data.schemaHash)
|
|
634
|
-
return
|
|
678
|
+
return null;
|
|
635
679
|
const entityName = this.extractEntityFromFingerprint(fingerprint);
|
|
636
680
|
if (!entityName)
|
|
681
|
+
return null;
|
|
682
|
+
const current = this.computeSchemaSignature(undefined, entityName);
|
|
683
|
+
if (!current || current.Hash === data.schemaHash)
|
|
684
|
+
return null;
|
|
685
|
+
return {
|
|
686
|
+
Subject: LocalCacheManager.runViewSubject(entityName),
|
|
687
|
+
CachedHash: data.schemaHash,
|
|
688
|
+
Current: current,
|
|
689
|
+
EntryIsNewer: LocalCacheManager.isNewerSchemaStamp(data.schemaStamp, current.Stamp),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** The mismatch subject for an entity's RunView slots. */
|
|
693
|
+
static runViewSubject(entityName) {
|
|
694
|
+
return { Kind: 'runview', Name: entityName, Key: entityName.trim().toLowerCase() };
|
|
695
|
+
}
|
|
696
|
+
/** The mismatch subject for a query's RunQuery slots. */
|
|
697
|
+
static runQuerySubject(definition, queryName) {
|
|
698
|
+
return { Kind: 'runquery', Name: queryName || definition.QueryID, Key: definition.QueryID.trim().toLowerCase() };
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* True only when both stamps are present and the cached entry's is strictly newer than this
|
|
702
|
+
* process's. Equal or missing stamps leave the order unknown, which counts as not newer.
|
|
703
|
+
*/
|
|
704
|
+
static isNewerSchemaStamp(entryStamp, currentStamp) {
|
|
705
|
+
if (!entryStamp || !currentStamp) {
|
|
637
706
|
return false;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
707
|
+
}
|
|
708
|
+
return LocalCacheManager.timestampMs(entryStamp) > LocalCacheManager.timestampMs(currentStamp);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Decides what a signature mismatch does to the slot: an entity schema mismatch on a RunView
|
|
712
|
+
* slot, or a query definition mismatch on a RunQuery slot. The entry is never served; the
|
|
713
|
+
* question is whether the slot is deleted, which on a shared store may also tell every peer to
|
|
714
|
+
* reload.
|
|
715
|
+
*
|
|
716
|
+
* - Process-local store, or a slot-maintenance read: delete it (`drop`). No other process reads a
|
|
717
|
+
* local store. A maintenance read happens because a row changed, and a slot that cannot take
|
|
718
|
+
* the change in place must go, whichever process wrote it.
|
|
719
|
+
* - Shared store, serving read: leave it, and hold back this process's writes of the subject's
|
|
720
|
+
* slots. Deleting a RunView slot publishes `removed`, every peer engine holding the slot
|
|
721
|
+
* reloads and rewrites it under its own hash, and processes on either side of a migration keep
|
|
722
|
+
* doing that to each other. The caller reads the database instead.
|
|
723
|
+
* - When the slot is known to come from newer metadata, this process is the one out of date.
|
|
724
|
+
* It stops writing the subject's slots until its own hash changes.
|
|
725
|
+
* - Otherwise it writes the subject's slots at most once per
|
|
726
|
+
* {@link SCHEMA_MISMATCH_WRITE_INTERVAL_MS} while mismatches keep appearing. That still
|
|
727
|
+
* replaces a slot left by older metadata, but a peer running code that deletes mismatched
|
|
728
|
+
* slots (any release before this check stopped deleting them) gets one slot to delete per
|
|
729
|
+
* interval instead of one per read. The hold lapses after an interval with no mismatch.
|
|
730
|
+
* For RunView slots the first replacement waits one interval. For RunQuery slots it is
|
|
731
|
+
* immediate: no release ever deleted a RunQuery slot over its definition and the category
|
|
732
|
+
* is not published, so an immediate replacement cannot feed a loop, and waiting would make
|
|
733
|
+
* every server run an edited query against the database for a full interval.
|
|
734
|
+
*/
|
|
735
|
+
handleSchemaMismatch(mismatch, purpose, drop) {
|
|
736
|
+
const shared = this._storageProvider?.SharedAcrossProcesses === true;
|
|
737
|
+
if (!shared || purpose === 'maintain') {
|
|
738
|
+
this.reportSchemaMismatch(mismatch, 'dropping the slot');
|
|
739
|
+
drop().catch(() => { });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.holdSchemaWrites(mismatch);
|
|
743
|
+
this.reportSchemaMismatch(mismatch, LocalCacheManager.describeHeldMismatch(mismatch));
|
|
744
|
+
}
|
|
745
|
+
/** The log text for a mismatch that left the slot in place and took a write hold. */
|
|
746
|
+
static describeHeldMismatch(mismatch) {
|
|
747
|
+
const seconds = LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS / 1000;
|
|
748
|
+
if (mismatch.Subject.Kind === 'runquery') {
|
|
749
|
+
return mismatch.EntryIsNewer
|
|
750
|
+
? 'another process wrote the slot under a newer query definition. This process runs the query against ' +
|
|
751
|
+
'the database and stops writing its cache slots until its own definition catches up.'
|
|
752
|
+
: `running the query against the database. While slots under other definitions keep appearing, this ` +
|
|
753
|
+
`process writes the query's slots at most once every ${seconds}s.`;
|
|
754
|
+
}
|
|
755
|
+
return mismatch.EntryIsNewer
|
|
756
|
+
? 'another process wrote the slot under newer metadata. This process reads the entity from the database ' +
|
|
757
|
+
'and stops writing its cache slots until its metadata changes. If it started before a migration, restart it.'
|
|
758
|
+
: `reading from the database. While slots under other metadata keep appearing, this process writes the entity's ` +
|
|
759
|
+
`slots at most once every ${seconds}s.`;
|
|
760
|
+
}
|
|
761
|
+
/** The key of a subject's write hold. RunView and RunQuery holds share one map. */
|
|
762
|
+
static holdKey(subject) {
|
|
763
|
+
return `${subject.Kind}:${subject.Key}`;
|
|
764
|
+
}
|
|
765
|
+
/** Records or extends the write hold for a mismatch seen on a shared store. */
|
|
766
|
+
holdSchemaWrites(mismatch) {
|
|
767
|
+
const key = LocalCacheManager.holdKey(mismatch.Subject);
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
const existing = this._schemaWriteHolds.get(key);
|
|
770
|
+
if (mismatch.EntryIsNewer) {
|
|
771
|
+
this._schemaWriteHolds.set(key, { OwnHash: mismatch.Current.Hash, NextWriteAt: Number.POSITIVE_INFINITY, LastMismatchAt: now });
|
|
772
|
+
}
|
|
773
|
+
else if (existing && existing.OwnHash === mismatch.Current.Hash) {
|
|
774
|
+
existing.LastMismatchAt = now;
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
const firstWriteDelay = mismatch.Subject.Kind === 'runquery' ? 0 : LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS;
|
|
778
|
+
this._schemaWriteHolds.set(key, {
|
|
779
|
+
OwnHash: mismatch.Current.Hash,
|
|
780
|
+
NextWriteAt: now + firstWriteDelay,
|
|
781
|
+
LastMismatchAt: now,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/** Logs a mismatch once per subject and hash pair per process. */
|
|
786
|
+
reportSchemaMismatch(mismatch, outcome) {
|
|
787
|
+
const key = `${LocalCacheManager.holdKey(mismatch.Subject)}|${mismatch.CachedHash}|${mismatch.Current.Hash}`;
|
|
788
|
+
if (this._reportedSchemaMismatches.has(key)) {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
this._reportedSchemaMismatches.add(key);
|
|
792
|
+
const label = mismatch.Subject.Kind === 'runquery'
|
|
793
|
+
? `[CACHE-QUERY-DEFINITION-STALE] Query "${mismatch.Subject.Name}" definition`
|
|
794
|
+
: `[CACHE-SCHEMA-STALE] Entity "${mismatch.Subject.Name}" schema`;
|
|
795
|
+
LogWarning(`${label} differs from a cached slot ` +
|
|
796
|
+
`(cached=${mismatch.CachedHash || '(none)'}, current=${mismatch.Current.Hash}) — ${outcome}`, 'Cache');
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Whether a slot of `subject` may be written now, given its write hold (see
|
|
800
|
+
* {@link handleSchemaMismatch}). A permitted write under a rate-limiting hold uses up the
|
|
801
|
+
* interval. The hold is dropped once this process's hash changes (a metadata refresh, or a
|
|
802
|
+
* query edit reaching this process) or, for a rate-limiting hold, once an interval passes with
|
|
803
|
+
* no mismatch.
|
|
804
|
+
*/
|
|
805
|
+
mayWriteUnderSchemaHold(subject, currentHash) {
|
|
806
|
+
if (this._storageProvider?.SharedAcrossProcesses !== true) {
|
|
643
807
|
return true;
|
|
644
808
|
}
|
|
645
|
-
|
|
809
|
+
const key = LocalCacheManager.holdKey(subject);
|
|
810
|
+
const hold = this._schemaWriteHolds.get(key);
|
|
811
|
+
if (!hold) {
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
const now = Date.now();
|
|
815
|
+
const interval = LocalCacheManager.SCHEMA_MISMATCH_WRITE_INTERVAL_MS;
|
|
816
|
+
const lapsed = hold.NextWriteAt !== Number.POSITIVE_INFINITY && now - hold.LastMismatchAt > interval;
|
|
817
|
+
if (hold.OwnHash !== currentHash || lapsed) {
|
|
818
|
+
this._schemaWriteHolds.delete(key);
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
if (now < hold.NextWriteAt) {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
hold.NextWriteAt = now + interval;
|
|
825
|
+
return true;
|
|
646
826
|
}
|
|
647
827
|
/**
|
|
648
828
|
* The newest `__mj_UpdatedAt` among `rows` as an ISO string, `''` for an empty set, or
|
|
@@ -1156,11 +1336,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1156
1336
|
}
|
|
1157
1337
|
const snapshot = [...fingerprints];
|
|
1158
1338
|
const nowISO = new Date().toISOString();
|
|
1339
|
+
const savesComplete = LocalCacheManager.everySavedRowComplete(changes); // per change, not per slot
|
|
1159
1340
|
const BATCH_SIZE = 8;
|
|
1160
1341
|
for (let i = 0; i < snapshot.length; i += BATCH_SIZE) {
|
|
1161
1342
|
await Promise.all(snapshot.slice(i, i + BATCH_SIZE).map(async (fingerprint) => {
|
|
1162
1343
|
try {
|
|
1163
|
-
await this.applyEntityChangesToSlot(fingerprint, changes, nowISO);
|
|
1344
|
+
await this.applyEntityChangesToSlot(fingerprint, changes, nowISO, savesComplete);
|
|
1164
1345
|
}
|
|
1165
1346
|
catch (err) {
|
|
1166
1347
|
LogError(`LocalCacheManager: failed to apply batched changes to "${fingerprint}": ${err.message}`);
|
|
@@ -1172,12 +1353,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1172
1353
|
* One slot, several changes: the same decisions {@link processEntityEventForFingerprint} makes
|
|
1173
1354
|
* per event, made once for the whole list.
|
|
1174
1355
|
*/
|
|
1175
|
-
async applyEntityChangesToSlot(fingerprint, changes, nowISO) {
|
|
1356
|
+
async applyEntityChangesToSlot(fingerprint, changes, nowISO, savesComplete = LocalCacheManager.everySavedRowComplete(changes)) {
|
|
1176
1357
|
const hasSave = changes.some(c => c.Type === 'save');
|
|
1177
1358
|
// Subset and aggregate slots cannot be maintained in place; filtered slots cannot take an
|
|
1178
1359
|
// upsert (the new row may not match the filter) but can drop deleted rows.
|
|
1360
|
+
// A saved row missing columns cannot replace a full-width row either (see RowHasEveryField).
|
|
1179
1361
|
if (this.isSubsetFingerprint(fingerprint) || this.hasAggregates(fingerprint.split('|'))
|
|
1180
|
-
|| (hasSave && this.isFilteredFingerprint(fingerprint))
|
|
1362
|
+
|| (hasSave && this.isFilteredFingerprint(fingerprint))
|
|
1363
|
+
|| !savesComplete) {
|
|
1181
1364
|
await this.InvalidateRunViewResult(fingerprint);
|
|
1182
1365
|
return;
|
|
1183
1366
|
}
|
|
@@ -1274,11 +1457,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1274
1457
|
if (key.KeyValuePairs.some(kv => kv.Value == null))
|
|
1275
1458
|
return;
|
|
1276
1459
|
LogStatusVerbose(`LocalCacheManager: remote-invalidate (save) for "${entityName}" PK=${key.ToConcatenatedString()}, updating ${fingerprints.size} cached fingerprint(s)`);
|
|
1460
|
+
const rowComplete = LocalCacheManager.RowHasEveryField(entityInfo, recordData); // per row, not per slot
|
|
1277
1461
|
for (const fingerprint of fingerprintSnapshot) {
|
|
1278
1462
|
try {
|
|
1279
1463
|
// Subset slot (MaxRows/StartRow): upserting would grow the slot past the
|
|
1280
1464
|
// caller's own row limit. Invalidate, same as a filtered slot.
|
|
1281
|
-
if (!this.isFilteredFingerprint(fingerprint) && !this.isSubsetFingerprint(fingerprint)) {
|
|
1465
|
+
if (rowComplete && !this.isFilteredFingerprint(fingerprint) && !this.isSubsetFingerprint(fingerprint)) {
|
|
1282
1466
|
await this.UpsertSingleEntity(fingerprint, recordData, key, nowISO);
|
|
1283
1467
|
}
|
|
1284
1468
|
else {
|
|
@@ -1396,9 +1580,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1396
1580
|
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
1397
1581
|
}
|
|
1398
1582
|
else if (!this.isFilteredFingerprint(fingerprint)) {
|
|
1399
|
-
// Unfiltered cache: update the record in place
|
|
1400
|
-
LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
|
|
1583
|
+
// Unfiltered cache: update the record in place, unless the saved row is missing columns
|
|
1401
1584
|
const entityData = baseEntity.GetAll();
|
|
1585
|
+
if (!LocalCacheManager.RowHasEveryField(baseEntity.EntityInfo, entityData)) {
|
|
1586
|
+
LogStatusVerbose(`LocalCacheManager: Invalidating "${fingerprint.substring(0, 60)}" — the saved row is missing columns`);
|
|
1587
|
+
await this.InvalidateRunViewResult(fingerprint);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
LogStatusVerbose(`LocalCacheManager: Upserting entity ${keyStr} in unfiltered cache "${fingerprint.substring(0, 60)}"`);
|
|
1402
1591
|
const maintained = await this.UpsertSingleEntity(fingerprint, entityData, key, nowISO);
|
|
1403
1592
|
await this.invalidateIfNotMaintained(fingerprint, maintained);
|
|
1404
1593
|
}
|
|
@@ -1408,6 +1597,58 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1408
1597
|
await this.InvalidateRunViewResult(fingerprint);
|
|
1409
1598
|
}
|
|
1410
1599
|
}
|
|
1600
|
+
/**
|
|
1601
|
+
* True when `row` has a key for every non-binary field of `entity`, so it may replace the
|
|
1602
|
+
* cached copy of its record in place.
|
|
1603
|
+
*
|
|
1604
|
+
* A save event carries the saver's own values (`GetAll()`), and an object leaves out every
|
|
1605
|
+
* field it does not hold: one its user may not read, or one that was never loaded. Upserting
|
|
1606
|
+
* such a row into a full-width slot would replace a complete cached row with a partial one,
|
|
1607
|
+
* and every later reader of that slot, including users entitled to the missing columns, would
|
|
1608
|
+
* be served the row without them. A partial row therefore clears the slot instead, and the
|
|
1609
|
+
* next read refills it from the database. Binary fields are not required, because slots
|
|
1610
|
+
* leave them out unless asked for.
|
|
1611
|
+
*
|
|
1612
|
+
* A row is judged against the entity's field list, so without one (no entity metadata on
|
|
1613
|
+
* the event, which a real entity always carries) there is nothing to compare and the row is
|
|
1614
|
+
* accepted, as before this check existed.
|
|
1615
|
+
*
|
|
1616
|
+
* @param entity - The saved record's entity
|
|
1617
|
+
* @param row - The values the event carries
|
|
1618
|
+
*/
|
|
1619
|
+
static RowHasEveryField(entity, row) {
|
|
1620
|
+
if (!row || typeof row !== 'object') {
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
const fields = entity?.Fields;
|
|
1624
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
for (const field of fields) {
|
|
1628
|
+
if (!field.IsBinaryFieldType && !Object.prototype.hasOwnProperty.call(row, field.Name)) {
|
|
1629
|
+
return false;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
return true;
|
|
1633
|
+
}
|
|
1634
|
+
/** Whether every saved row among `changes` carries every column ({@link RowHasEveryField}). */
|
|
1635
|
+
static everySavedRowComplete(changes) {
|
|
1636
|
+
return changes.every(c => c.Type !== 'save' || LocalCacheManager.RowHasEveryField(c.EntityInfo, c.Record));
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* True when a saved row may be written into `fingerprint`'s slot in place: the slot is neither
|
|
1640
|
+
* filtered (including row-security and field-security segments) nor a subset, and the row
|
|
1641
|
+
* carries every column ({@link RowHasEveryField}). Otherwise the slot must be invalidated.
|
|
1642
|
+
*
|
|
1643
|
+
* @param fingerprint - The slot to maintain
|
|
1644
|
+
* @param entity - The saved record's entity
|
|
1645
|
+
* @param row - The values the save carries
|
|
1646
|
+
*/
|
|
1647
|
+
CanUpsertRowIntoSlot(fingerprint, entity, row) {
|
|
1648
|
+
return !this.isFilteredFingerprint(fingerprint)
|
|
1649
|
+
&& !this.isSubsetFingerprint(fingerprint)
|
|
1650
|
+
&& LocalCacheManager.RowHasEveryField(entity, row);
|
|
1651
|
+
}
|
|
1411
1652
|
/**
|
|
1412
1653
|
* Handles a save/delete that could not be applied to a slot this process indexed — in practice
|
|
1413
1654
|
* because the slot expired or was removed by someone else.
|
|
@@ -1542,9 +1783,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1542
1783
|
if (!this._storageProvider || !this._config.enabled)
|
|
1543
1784
|
return;
|
|
1544
1785
|
const key = this.buildDatasetKey(name, itemFilters, keyPrefix);
|
|
1545
|
-
// Estimate size
|
|
1546
|
-
|
|
1547
|
-
|
|
1786
|
+
// Estimate size by sampling item result arrays instead of stringifying the entire dataset
|
|
1787
|
+
const sizeBytes = Array.isArray(dataset?.Results)
|
|
1788
|
+
? dataset.Results.reduce((sum, item) => sum + this.estimateResultsSize(item.Results), 128)
|
|
1789
|
+
: 128;
|
|
1548
1790
|
// Fourth write funnel, held to the same contract as SetRunViewResult /
|
|
1549
1791
|
// SetRunQueryResult / storeCachedResults. `GetDataset` hands this object straight back
|
|
1550
1792
|
// out, so on a reference-sharing provider every reader shares it — the same exposure the
|
|
@@ -1695,16 +1937,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1695
1937
|
* what goes in it, and `ProviderBase.ComputeRunViewFLSFingerprintKey` is the single place
|
|
1696
1938
|
* that decision lives so the two tiers cannot drift.
|
|
1697
1939
|
*
|
|
1698
|
-
* **
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
1703
|
-
*
|
|
1704
|
-
* never projected on read, so the field set has to be part of slot identity: without it a
|
|
1705
|
-
* user whose access is tightened keeps being served their persisted IndexedDB slot, because
|
|
1706
|
-
* the currency check compares only `maxUpdatedAt` and `rowCount` and neither notices a
|
|
1707
|
-
* column.
|
|
1940
|
+
* **Both tiers pass it.** Each slot holds the rows of one permission class: the provider
|
|
1941
|
+
* fetches only the columns the reading user may see, so the field set has to be part of slot
|
|
1942
|
+
* identity on the server exactly as it is in the browser, where rows are stored as the server
|
|
1943
|
+
* returned them and never projected on read. Users with the same allowed set share a slot;
|
|
1944
|
+
* users with different sets never do. That also keeps a user search's row set apart, since
|
|
1945
|
+
* which rows match depends on which columns the reader may search.
|
|
1708
1946
|
*
|
|
1709
1947
|
* Keyed on the ALLOWED set rather than the denied one deliberately: once client metadata is
|
|
1710
1948
|
* filtered for restricted users ([#3485](https://github.com/MemberJunction/MJ/issues/3485))
|
|
@@ -1715,7 +1953,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1715
1953
|
* Empty for unrestricted users and non-FLS entities, which appends no segment at all — so
|
|
1716
1954
|
* their fingerprints stay byte-identical and keep sharing slots (the `rls:` rule). A
|
|
1717
1955
|
* permission change produces a new hash → fresh slot; slots keyed to the old hash strand
|
|
1718
|
-
* until eviction (memory cost, not a leak).
|
|
1956
|
+
* until eviction (memory cost, not a leak). Hashed with {@link HashSecuritySegment}.
|
|
1719
1957
|
* @param datasetSegment - Namespace for dataset-item slots (see the `ds:` append below): keeps
|
|
1720
1958
|
* `GetDatasetByName` item caching from colliding with a plain unfiltered read of the same
|
|
1721
1959
|
* entity. Appended only when supplied, so ordinary reads keep their pre-existing key.
|
|
@@ -1780,6 +2018,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1780
2018
|
if (params.IncludeBinaryFields === true) {
|
|
1781
2019
|
parts.push('bin:1');
|
|
1782
2020
|
}
|
|
2021
|
+
// SkipTotalRowCount segment. A call that skips the fallback COUNT stores a result whose
|
|
2022
|
+
// TotalRowCount was never counted, so it must not share a slot with the otherwise
|
|
2023
|
+
// identical counted call (each would be served the other's TotalRowCount). Appended only
|
|
2024
|
+
// when true, so every existing fingerprint stays byte-for-byte identical. Not allowlisted
|
|
2025
|
+
// in hasNarrowingSegment, so such a slot is invalidated on save rather than maintained.
|
|
2026
|
+
if (params.SkipTotalRowCount === true) {
|
|
2027
|
+
parts.push('strc:1');
|
|
2028
|
+
}
|
|
1783
2029
|
// DataSource segment. RunViewParams.DataSource:'Materialized' routes the read to the entity's
|
|
1784
2030
|
// materialized snapshot view (GetEffectiveBaseView), a DIFFERENT physical source than the default
|
|
1785
2031
|
// live base view — so a Live read and a Materialized read of the same entity/filter/orderBy MUST
|
|
@@ -1789,6 +2035,15 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1789
2035
|
if (IsMaterializedDataSource(params.DataSource)) {
|
|
1790
2036
|
parts.push('ds:materialized');
|
|
1791
2037
|
}
|
|
2038
|
+
// AlternateBaseView segment. Routing a read through an alternate base view (explicitly via
|
|
2039
|
+
// params.AlternateBaseView or pinned inside a loaded params.ViewEntity's DisplayState JSON)
|
|
2040
|
+
// targets a different physical SQL view with its own canonical WHERE/JOIN, so it MUST NOT
|
|
2041
|
+
// share a cache slot with the entity's canonical BaseView or another alternate view.
|
|
2042
|
+
// Appended only when non-empty so canonical reads keep their exact pre-existing fingerprint.
|
|
2043
|
+
const effectiveAbv = NormalizeAlternateBaseViewName(GetEffectiveAlternateBaseViewName(params));
|
|
2044
|
+
if (effectiveAbv.length > 0) {
|
|
2045
|
+
parts.push(`abv:${effectiveAbv}`);
|
|
2046
|
+
}
|
|
1792
2047
|
// Keyset (AfterKey) seek cursor MUST be part of the fingerprint. Each keyset page
|
|
1793
2048
|
// sends a different AfterKey but otherwise-identical params; without this, sequential
|
|
1794
2049
|
// pages collide on the same fingerprint and the dedup/linger layer hands page N+1 the
|
|
@@ -1806,25 +2061,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1806
2061
|
// substitutions) hash differently and therefore never collide.
|
|
1807
2062
|
const rls = (rlsWhereClause ?? '').trim();
|
|
1808
2063
|
if (rls.length > 0) {
|
|
1809
|
-
parts.push(`rls:${this.
|
|
2064
|
+
parts.push(`rls:${this.HashSecuritySegment(rls)}`);
|
|
1810
2065
|
}
|
|
1811
|
-
// Field-Level-Security segment — the column counterpart of `rls
|
|
1812
|
-
//
|
|
1813
|
-
// A browser stores rows exactly as the server returned them (already narrowed to the
|
|
1814
|
-
// user's allowed columns) and never projects on read, so the field set has to be part
|
|
1815
|
-
// of slot identity. Without it, a user whose access is tightened keeps being served
|
|
1816
|
-
// their persisted IndexedDB slot: the currency check compares maxUpdatedAt and rowCount
|
|
1817
|
-
// only, neither of which notices a column.
|
|
2066
|
+
// Field-Level-Security segment — the column counterpart of `rls:`.
|
|
1818
2067
|
//
|
|
1819
|
-
//
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
2068
|
+
// A slot holds the columns one permission class may read, on both tiers: the server
|
|
2069
|
+
// fetches only the reader's allowed columns, and a browser stores rows exactly as the
|
|
2070
|
+
// server returned them. So the field set is part of slot identity, and a user is only
|
|
2071
|
+
// ever served a slot filled for the same allowed set. Without it, a user whose access is
|
|
2072
|
+
// tightened keeps being served a slot with the column in it (the currency check compares
|
|
2073
|
+
// maxUpdatedAt and rowCount only, neither of which notices a column), and a restricted
|
|
2074
|
+
// reader's narrow rows would be served to an unrestricted one.
|
|
2075
|
+
// ProviderBase.ComputeRunViewFLSFingerprintKey decides what the key contains.
|
|
1822
2076
|
//
|
|
1823
2077
|
// Appended ONLY when non-empty, so unrestricted users and non-FLS entities keep
|
|
1824
2078
|
// byte-identical fingerprints and shared slots (the rls: rule).
|
|
1825
2079
|
const fls = (flsFieldsKey ?? '').trim();
|
|
1826
2080
|
if (fls.length > 0) {
|
|
1827
|
-
parts.push(`fls:${this.
|
|
2081
|
+
parts.push(`fls:${this.HashSecuritySegment(fls)}`);
|
|
1828
2082
|
}
|
|
1829
2083
|
// Stored-view identity. A saved view's WhereClause/OrderBy live on the view, not in
|
|
1830
2084
|
// params.ExtraFilter, so a view run and a plain entity read (or a different view) can
|
|
@@ -1837,6 +2091,17 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1837
2091
|
if (viewKey.length > 0) {
|
|
1838
2092
|
parts.push(`vw:${viewKey}`);
|
|
1839
2093
|
}
|
|
2094
|
+
// View-property segment. A stored view's property values (RunViewParams.Properties and
|
|
2095
|
+
// ServerProperties) each add a predicate the provider builds, so one chapter's run must
|
|
2096
|
+
// never be served another chapter's slot. On the server the provider has already replaced
|
|
2097
|
+
// both with the resolved values (token defaults included) before this runs. The values go
|
|
2098
|
+
// in as canonical JSON rather than a hash: a 32-bit hash of values a client partly chooses
|
|
2099
|
+
// could be steered onto another run's slot. Appended ONLY when a value is present, so every
|
|
2100
|
+
// run without properties keeps its exact fingerprint.
|
|
2101
|
+
const propertyKey = ViewPropertyValuesKey(params.Properties, params.ServerProperties);
|
|
2102
|
+
if (propertyKey.length > 0) {
|
|
2103
|
+
parts.push(`vp:${propertyKey}`);
|
|
2104
|
+
}
|
|
1840
2105
|
// Dataset namespace. `GetDatasetByName` caches each dataset ITEM's rows through this same
|
|
1841
2106
|
// builder, supplying only entity + the item's WhereClause — and every shipped item has a
|
|
1842
2107
|
// NULL WhereClause, so without this segment a dataset item emits the identical fingerprint
|
|
@@ -1919,6 +2184,39 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1919
2184
|
}
|
|
1920
2185
|
return reordered;
|
|
1921
2186
|
}
|
|
2187
|
+
/**
|
|
2188
|
+
* A 128-bit hash, as 32 hex characters, of a security key that becomes part of a slot's
|
|
2189
|
+
* identity: the `rls:` and `fls:` fingerprint segments, and the field-security key a browser
|
|
2190
|
+
* sends with its cache status.
|
|
2191
|
+
*
|
|
2192
|
+
* These keys decide which users may share a slot, so two different keys must not land on the
|
|
2193
|
+
* same value. The 32-bit {@link simpleHash} used for the other segments is too short for that:
|
|
2194
|
+
* at a few tens of thousands of distinct row filters a collision becomes likely, and a
|
|
2195
|
+
* collision here serves one user's rows to another. Four independent 32-bit multiply-xor lanes
|
|
2196
|
+
* keep it synchronous, deterministic and identical in Node and the browser.
|
|
2197
|
+
*
|
|
2198
|
+
* @param str - The security key
|
|
2199
|
+
* @returns 32 lowercase hex characters
|
|
2200
|
+
*/
|
|
2201
|
+
HashSecuritySegment(str) {
|
|
2202
|
+
let h1 = 1779033703, h2 = 3144134277, h3 = 1013904242, h4 = 2773480762;
|
|
2203
|
+
for (let i = 0; i < str.length; i++) {
|
|
2204
|
+
const k = str.charCodeAt(i);
|
|
2205
|
+
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
|
2206
|
+
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
|
2207
|
+
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
|
2208
|
+
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
|
2209
|
+
}
|
|
2210
|
+
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
|
2211
|
+
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
|
2212
|
+
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
|
2213
|
+
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
|
2214
|
+
h1 ^= (h2 ^ h3 ^ h4);
|
|
2215
|
+
h2 ^= h1;
|
|
2216
|
+
h3 ^= h1;
|
|
2217
|
+
h4 ^= h1;
|
|
2218
|
+
return [h1, h2, h3, h4].map(h => (h >>> 0).toString(16).padStart(8, '0')).join('');
|
|
2219
|
+
}
|
|
1922
2220
|
/**
|
|
1923
2221
|
* Simple hash function for creating short fingerprints from strings.
|
|
1924
2222
|
* Not cryptographic, just for deduplication/fingerprinting purposes.
|
|
@@ -1944,6 +2242,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1944
2242
|
* @returns The schema hash string, or undefined if the entity can't be resolved
|
|
1945
2243
|
*/
|
|
1946
2244
|
ComputeSchemaHash(provider, entityName) {
|
|
2245
|
+
return this.computeSchemaSignature(provider, entityName)?.Hash;
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* The entity's schema hash (see {@link ComputeSchemaHash}) together with its schema stamp: the
|
|
2249
|
+
* newest `__mj_UpdatedAt` across the entity row and its field rows, as an ISO string, or
|
|
2250
|
+
* undefined when the metadata carries no timestamps.
|
|
2251
|
+
*/
|
|
2252
|
+
computeSchemaSignature(provider, entityName) {
|
|
1947
2253
|
try {
|
|
1948
2254
|
const md = provider ?? new Metadata();
|
|
1949
2255
|
const entity = md.EntityByName(entityName);
|
|
@@ -1952,12 +2258,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
1952
2258
|
// Use natural sequence order (EntityInfo.Fields is sorted by Sequence).
|
|
1953
2259
|
// This detects field additions, removals, AND reorderings.
|
|
1954
2260
|
const fieldNames = entity.Fields.map(f => f.Name).join('|');
|
|
1955
|
-
|
|
2261
|
+
let newest = LocalCacheManager.timestampMs(entity.__mj_UpdatedAt);
|
|
2262
|
+
for (const field of entity.Fields) {
|
|
2263
|
+
newest = Math.max(newest, LocalCacheManager.timestampMs(field.__mj_UpdatedAt));
|
|
2264
|
+
}
|
|
2265
|
+
return {
|
|
2266
|
+
Hash: this.simpleHash(fieldNames),
|
|
2267
|
+
Stamp: newest > 0 ? new Date(newest).toISOString() : undefined,
|
|
2268
|
+
};
|
|
1956
2269
|
}
|
|
1957
2270
|
catch {
|
|
1958
2271
|
return undefined;
|
|
1959
2272
|
}
|
|
1960
2273
|
}
|
|
2274
|
+
/** Milliseconds since the epoch for a metadata timestamp (a Date or a string), or 0 when unreadable. */
|
|
2275
|
+
static timestampMs(value) {
|
|
2276
|
+
if (value == null) {
|
|
2277
|
+
return 0;
|
|
2278
|
+
}
|
|
2279
|
+
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
2280
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
2281
|
+
}
|
|
1961
2282
|
/**
|
|
1962
2283
|
* Deep-freezes an about-to-be-stored cache payload when — and only when — the active
|
|
1963
2284
|
* storage provider hands out live object references
|
|
@@ -2100,7 +2421,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2100
2421
|
maxUpdatedAt = coerced;
|
|
2101
2422
|
}
|
|
2102
2423
|
// Persist results, maxUpdatedAt, aggregateResults, totalRowCount, and schemaHash
|
|
2103
|
-
const
|
|
2424
|
+
const initialNow = Date.now();
|
|
2425
|
+
const data = { results, maxUpdatedAt, cachedAt: initialNow, lastVerifiedAt: initialNow };
|
|
2104
2426
|
if (options?.ProviderInternalScaffolding) {
|
|
2105
2427
|
// Persisted so event-driven in-place maintenance carries the exemption forward.
|
|
2106
2428
|
data.providerInternalScaffolding = true;
|
|
@@ -2113,26 +2435,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2113
2435
|
}
|
|
2114
2436
|
// Compute and store schema hash for upgrade detection
|
|
2115
2437
|
if (params.EntityName) {
|
|
2116
|
-
const
|
|
2117
|
-
if (
|
|
2118
|
-
|
|
2438
|
+
const signature = this.computeSchemaSignature(provider, params.EntityName);
|
|
2439
|
+
if (signature) {
|
|
2440
|
+
if (!this.mayWriteUnderSchemaHold(LocalCacheManager.runViewSubject(params.EntityName), signature.Hash)) {
|
|
2441
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for "${params.EntityName}" — its shared slots were found under different metadata`, verboseOnly: true });
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
data.schemaHash = signature.Hash;
|
|
2445
|
+
if (signature.Stamp) {
|
|
2446
|
+
data.schemaStamp = signature.Stamp;
|
|
2447
|
+
}
|
|
2119
2448
|
}
|
|
2120
2449
|
}
|
|
2121
2450
|
// Estimate size by sampling rows (eviction accounting only); the actual stored
|
|
2122
2451
|
// value is the native object — no full JSON.stringify on the hot path.
|
|
2123
2452
|
const sizeBytes = this.estimateResultsSize(data.results);
|
|
2124
|
-
// Oversized-entry gate: an entry
|
|
2125
|
-
// never cached
|
|
2126
|
-
//
|
|
2127
|
-
//
|
|
2128
|
-
//
|
|
2453
|
+
// Oversized-entry gate: an entry too large to be RETAINED within the budget is
|
|
2454
|
+
// never cached — storing it would evict other entries for something the next store
|
|
2455
|
+
// evicts again. An entry that fits under MaxEntrySizeBytes is cached however large
|
|
2456
|
+
// it is. Always logged (not verbose-gated): a query too big to cache is a cost the
|
|
2457
|
+
// operator is paying on every call and must be able to see.
|
|
2129
2458
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2130
|
-
LogWarning(
|
|
2459
|
+
LogWarning(this.describeOversizedEntry(params.EntityName || fingerprint.substring(0, 60), sizeBytes), 'Cache');
|
|
2131
2460
|
return;
|
|
2132
2461
|
}
|
|
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
|
|
2462
|
+
// The schema gate and the oversized gate above are the ONLY steps that can decline this
|
|
2463
|
+
// write, and they are synchronous — so by here the row set is definitely becoming shared
|
|
2464
|
+
// state, and it is frozen with no intervening yield point. That ordering is load-bearing: the eviction
|
|
2136
2465
|
// steps below are awaited, and callers hold this very array while they run (the
|
|
2137
2466
|
// smart-cache stale leg does not await this method at all). Freezing after them leaves
|
|
2138
2467
|
// a window in which shared rows are handed out still mutable — and `BaseEntity`
|
|
@@ -2150,8 +2479,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2150
2479
|
await this.evictIfNeeded(sizeBytes);
|
|
2151
2480
|
try {
|
|
2152
2481
|
// Native object storage — IDB structured-clones, localStorage / Redis serialize internally.
|
|
2153
|
-
const
|
|
2482
|
+
const writeNow = Date.now();
|
|
2483
|
+
const expiresAt = ttlMs ? writeNow + ttlMs : undefined;
|
|
2154
2484
|
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, expiresAt));
|
|
2485
|
+
this._lastVerifiedAt.set(fingerprint, writeNow);
|
|
2155
2486
|
this.registerEntry({
|
|
2156
2487
|
key: fingerprint,
|
|
2157
2488
|
type: 'runview',
|
|
@@ -2165,8 +2496,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2165
2496
|
MaxRows: params.MaxRows,
|
|
2166
2497
|
HasAggregates: (params.Aggregates?.length ?? 0) > 0
|
|
2167
2498
|
},
|
|
2168
|
-
cachedAt:
|
|
2169
|
-
lastAccessedAt:
|
|
2499
|
+
cachedAt: writeNow,
|
|
2500
|
+
lastAccessedAt: writeNow,
|
|
2501
|
+
lastVerifiedAt: writeNow,
|
|
2170
2502
|
accessCount: 1,
|
|
2171
2503
|
sizeBytes,
|
|
2172
2504
|
maxUpdatedAt,
|
|
@@ -2194,16 +2526,16 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2194
2526
|
this._stats.misses++;
|
|
2195
2527
|
return null;
|
|
2196
2528
|
}
|
|
2197
|
-
return this.readRunViewSlot(fingerprint);
|
|
2529
|
+
return this.readRunViewSlot(fingerprint, 'serve');
|
|
2198
2530
|
}
|
|
2199
2531
|
/** {@link GetRunViewResult} without the open-batch check; slot maintenance reads through this. */
|
|
2200
|
-
async readRunViewSlot(fingerprint) {
|
|
2532
|
+
async readRunViewSlot(fingerprint, purpose) {
|
|
2201
2533
|
if (!this._storageProvider || !this._config.enabled)
|
|
2202
2534
|
return null;
|
|
2203
2535
|
try {
|
|
2204
2536
|
// Native object read — IDB structured-clones the result back, no JSON.parse needed.
|
|
2205
2537
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunViewCache);
|
|
2206
|
-
return this.materializeCachedRunViewResult(fingerprint, parsed);
|
|
2538
|
+
return this.materializeCachedRunViewResult(fingerprint, parsed, purpose);
|
|
2207
2539
|
}
|
|
2208
2540
|
catch (e) {
|
|
2209
2541
|
LogError(`LocalCacheManager.GetRunViewResult failed: ${e}`);
|
|
@@ -2243,7 +2575,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2243
2575
|
try {
|
|
2244
2576
|
const raw = await this._storageProvider.GetItems(fingerprints, CacheCategory.RunViewCache);
|
|
2245
2577
|
for (const [fp, parsed] of raw) {
|
|
2246
|
-
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed));
|
|
2578
|
+
out.set(fp, this.isPendingInBatch(fp) ? null : this.materializeCachedRunViewResult(fp, parsed, 'serve'));
|
|
2247
2579
|
}
|
|
2248
2580
|
return out;
|
|
2249
2581
|
}
|
|
@@ -2263,10 +2595,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2263
2595
|
* recording the appropriate hit/miss + access-tracking side effects.
|
|
2264
2596
|
*
|
|
2265
2597
|
* Also validates the schema hash (if present) to detect structurally stale
|
|
2266
|
-
* cache entries after schema migrations. If the entity's field list
|
|
2267
|
-
*
|
|
2598
|
+
* cache entries after schema migrations. If the entity's field list differs
|
|
2599
|
+
* from the one the entry was cached under, null is returned; whether the slot
|
|
2600
|
+
* is also deleted depends on `purpose` (see {@link handleSchemaMismatch}).
|
|
2268
2601
|
*/
|
|
2269
|
-
materializeCachedRunViewResult(fingerprint, parsed) {
|
|
2602
|
+
materializeCachedRunViewResult(fingerprint, parsed, purpose) {
|
|
2270
2603
|
if (!parsed) {
|
|
2271
2604
|
this._stats.misses++;
|
|
2272
2605
|
return null;
|
|
@@ -2280,8 +2613,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2280
2613
|
this._stats.misses++;
|
|
2281
2614
|
return null;
|
|
2282
2615
|
}
|
|
2283
|
-
|
|
2284
|
-
|
|
2616
|
+
const schemaMismatch = this.findSchemaMismatch(fingerprint, parsed);
|
|
2617
|
+
if (schemaMismatch) {
|
|
2618
|
+
this.handleSchemaMismatch(schemaMismatch, purpose, () => this.InvalidateRunViewResult(fingerprint));
|
|
2285
2619
|
this._stats.misses++;
|
|
2286
2620
|
return null;
|
|
2287
2621
|
}
|
|
@@ -2289,6 +2623,11 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2289
2623
|
this.indexSlotThisProcessDidNotWrite(fingerprint);
|
|
2290
2624
|
this._stats.hits++;
|
|
2291
2625
|
const results = parsed.results || [];
|
|
2626
|
+
const cachedAt = registryEntry?.cachedAt ?? parsed.cachedAt;
|
|
2627
|
+
const lastVerifiedAt = this._lastVerifiedAt.get(fingerprint)
|
|
2628
|
+
?? registryEntry?.lastVerifiedAt
|
|
2629
|
+
?? parsed.lastVerifiedAt
|
|
2630
|
+
?? cachedAt;
|
|
2292
2631
|
const result = {
|
|
2293
2632
|
results,
|
|
2294
2633
|
maxUpdatedAt: parsed.maxUpdatedAt,
|
|
@@ -2296,14 +2635,34 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2296
2635
|
totalRowCount: parsed.totalRowCount,
|
|
2297
2636
|
// Surfaced so in-place maintenance can carry it forward on rewrite (B38).
|
|
2298
2637
|
schemaHash: parsed.schemaHash,
|
|
2638
|
+
schemaStamp: parsed.schemaStamp,
|
|
2299
2639
|
// Same carry-forward rationale: a maintained scaffolding slot must stay exempt.
|
|
2300
2640
|
providerInternalScaffolding: parsed.providerInternalScaffolding,
|
|
2641
|
+
cachedAt,
|
|
2642
|
+
lastVerifiedAt,
|
|
2301
2643
|
};
|
|
2302
2644
|
if (parsed.aggregateResults) {
|
|
2303
2645
|
result.aggregateResults = parsed.aggregateResults;
|
|
2304
2646
|
}
|
|
2305
2647
|
return result;
|
|
2306
2648
|
}
|
|
2649
|
+
/**
|
|
2650
|
+
* Updates the `lastVerifiedAt` timestamp for a cached RunView slot in O(1) time
|
|
2651
|
+
* without re-cloning, re-freezing, or rewriting its stored row array.
|
|
2652
|
+
*
|
|
2653
|
+
* Used by server-side smart-cache verification after a lightweight SQL status probe
|
|
2654
|
+
* (`MAX(__mj_UpdatedAt)`, `COUNT(*)`) confirms that a cached slot is still current.
|
|
2655
|
+
*
|
|
2656
|
+
* @param fingerprint - The cache fingerprint to touch
|
|
2657
|
+
* @param timestamp - Verification timestamp in epoch ms (defaults to `Date.now()`)
|
|
2658
|
+
*/
|
|
2659
|
+
TouchRunViewResultVerifiedAt(fingerprint, timestamp = Date.now()) {
|
|
2660
|
+
this._lastVerifiedAt.set(fingerprint, timestamp);
|
|
2661
|
+
const entry = this._registry.get(fingerprint);
|
|
2662
|
+
if (entry) {
|
|
2663
|
+
entry.lastVerifiedAt = timestamp;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2307
2666
|
/**
|
|
2308
2667
|
* Records a slot this process READ, so a later save can still find it once it has expired.
|
|
2309
2668
|
*
|
|
@@ -2464,11 +2823,16 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2464
2823
|
// Note: If aggregateResults not provided, cached aggregates are cleared (they'd be stale)
|
|
2465
2824
|
await this.SetRunViewResult(fingerprint, params, mergedResults, newMaxUpdatedAt, aggregateResults, mergedTotalRowCount, provider);
|
|
2466
2825
|
// Return with rowCount derived from merged results and aggregates if provided
|
|
2826
|
+
const verifiedNow = this._lastVerifiedAt.get(fingerprint) ?? Date.now();
|
|
2827
|
+
this._lastVerifiedAt.set(fingerprint, verifiedNow);
|
|
2828
|
+
const registryEntry = this._registry.get(fingerprint);
|
|
2467
2829
|
const result = {
|
|
2468
2830
|
results: mergedResults,
|
|
2469
2831
|
maxUpdatedAt: newMaxUpdatedAt,
|
|
2470
2832
|
rowCount: mergedResults.length,
|
|
2471
|
-
totalRowCount: mergedTotalRowCount
|
|
2833
|
+
totalRowCount: mergedTotalRowCount,
|
|
2834
|
+
cachedAt: registryEntry?.cachedAt ?? verifiedNow,
|
|
2835
|
+
lastVerifiedAt: verifiedNow
|
|
2472
2836
|
};
|
|
2473
2837
|
if (aggregateResults) {
|
|
2474
2838
|
result.aggregateResults = aggregateResults;
|
|
@@ -2577,7 +2941,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2577
2941
|
return false;
|
|
2578
2942
|
return this.maintainSlotLocked(fingerprint, async () => {
|
|
2579
2943
|
try {
|
|
2580
|
-
const cached = await this.readRunViewSlot(fingerprint);
|
|
2944
|
+
const cached = await this.readRunViewSlot(fingerprint, 'maintain');
|
|
2581
2945
|
if (!cached) {
|
|
2582
2946
|
LogStatusVerbose(`LocalCacheManager.ApplyRowChanges: No cached data found for fingerprint "${fingerprint.substring(0, 60)}" — skipping (cache will be populated on next RunView)`);
|
|
2583
2947
|
return false;
|
|
@@ -2588,7 +2952,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2588
2952
|
return true; // only removals of rows the slot does not hold
|
|
2589
2953
|
}
|
|
2590
2954
|
return await this.storeCachedResults(fingerprint, Array.from(resultMap.values()), newMaxUpdatedAt, { totalRowCount: cached.totalRowCount, rowCount: cached.results.length, schemaHash: cached.schemaHash,
|
|
2591
|
-
providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2955
|
+
schemaStamp: cached.schemaStamp, providerInternalScaffolding: cached.providerInternalScaffolding });
|
|
2592
2956
|
}
|
|
2593
2957
|
catch (e) {
|
|
2594
2958
|
LogError(`LocalCacheManager.ApplyRowChanges failed: ${e}`);
|
|
@@ -2646,9 +3010,13 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2646
3010
|
// reports for the same rows (the client smart-cache check compares the two). The caller's
|
|
2647
3011
|
// value (the event time) is used only when the rows carry no timestamp column.
|
|
2648
3012
|
const maxUpdatedAt = LocalCacheManager.MaxUpdatedAtOfRows(updatedResults) ?? newMaxUpdatedAt;
|
|
3013
|
+
const now = Date.now();
|
|
3014
|
+
const existingEntry = this._registry.get(fingerprint);
|
|
2649
3015
|
const data = {
|
|
2650
3016
|
results: updatedResults,
|
|
2651
|
-
maxUpdatedAt
|
|
3017
|
+
maxUpdatedAt,
|
|
3018
|
+
cachedAt: existingEntry?.cachedAt ?? now,
|
|
3019
|
+
lastVerifiedAt: now
|
|
2652
3020
|
};
|
|
2653
3021
|
// Carry the schemaHash FORWARD — never recompute it here (B38).
|
|
2654
3022
|
//
|
|
@@ -2664,6 +3032,9 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2664
3032
|
if (prior?.schemaHash) {
|
|
2665
3033
|
data.schemaHash = prior.schemaHash;
|
|
2666
3034
|
}
|
|
3035
|
+
if (prior?.schemaStamp) {
|
|
3036
|
+
data.schemaStamp = prior.schemaStamp;
|
|
3037
|
+
}
|
|
2667
3038
|
// Carry the freeze exemption forward too — a scaffolding slot that gets maintained in
|
|
2668
3039
|
// place must not silently become frozen, or the owner that mutates those rows breaks on
|
|
2669
3040
|
// the next read (for the metadata dataset that means booting with no metadata).
|
|
@@ -2687,13 +3058,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2687
3058
|
if (!prior?.providerInternalScaffolding) {
|
|
2688
3059
|
this.freezeRowDataIfProviderSharesReferences(data);
|
|
2689
3060
|
}
|
|
2690
|
-
const existingEntry = this._registry.get(fingerprint);
|
|
2691
3061
|
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunViewCache, this.runViewWriteOptions(fingerprint, existingEntry?.expiresAt));
|
|
3062
|
+
this._lastVerifiedAt.set(fingerprint, now);
|
|
2692
3063
|
if (existingEntry) {
|
|
2693
3064
|
existingEntry.maxUpdatedAt = maxUpdatedAt;
|
|
2694
3065
|
existingEntry.rowCount = updatedResults.length;
|
|
2695
3066
|
existingEntry.sizeBytes = sizeBytes;
|
|
2696
|
-
existingEntry.lastAccessedAt =
|
|
3067
|
+
existingEntry.lastAccessedAt = now;
|
|
3068
|
+
existingEntry.lastVerifiedAt = now;
|
|
2697
3069
|
this.debouncedPersistRegistry();
|
|
2698
3070
|
}
|
|
2699
3071
|
return true;
|
|
@@ -2716,6 +3088,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2716
3088
|
try {
|
|
2717
3089
|
await this._storageProvider.Remove(key, CacheCategory.RunViewCache);
|
|
2718
3090
|
this._registry.delete(key);
|
|
3091
|
+
this._lastVerifiedAt.delete(key);
|
|
2719
3092
|
this.removeFromEntityIndex(key);
|
|
2720
3093
|
}
|
|
2721
3094
|
catch (e) {
|
|
@@ -2769,8 +3142,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2769
3142
|
* @param rowCount - Optional row count (defaults to results.length if not provided)
|
|
2770
3143
|
* @param queryId - Optional query ID for reference
|
|
2771
3144
|
* @param ttlMs - Optional TTL in milliseconds (for cache expiry tracking)
|
|
2772
|
-
|
|
2773
|
-
|
|
3145
|
+
* @param warmedForUserID - The user whose authorized execution produced the rows
|
|
3146
|
+
* @param definition - The query definition the rows were computed under. Stored with the slot so
|
|
3147
|
+
* a read under a different definition misses (see {@link GetRunQueryResult}), and used to index
|
|
3148
|
+
* the slot by query ID (see {@link InvalidateRunQueryResultsForQuery}). On a shared store the
|
|
3149
|
+
* write is skipped while the query's slots are under a write hold (see
|
|
3150
|
+
* `handleSchemaMismatch`).
|
|
3151
|
+
*/
|
|
3152
|
+
async SetRunQueryResult(fingerprint, queryName, results, maxUpdatedAt, rowCount, queryId, ttlMs, warmedForUserID, definition) {
|
|
2774
3153
|
if (!this._storageProvider || !this._config.enabled)
|
|
2775
3154
|
return;
|
|
2776
3155
|
const actualRowCount = rowCount ?? results.length;
|
|
@@ -2780,17 +3159,27 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2780
3159
|
// does not refresh in-process): the warmer proved their permission by executing; anyone
|
|
2781
3160
|
// ELSE falls through to an authorized execution rather than being served unchecked.
|
|
2782
3161
|
const data = { results, maxUpdatedAt, rowCount: actualRowCount, queryId, warmedForUserID };
|
|
3162
|
+
if (definition) {
|
|
3163
|
+
if (!this.mayWriteUnderSchemaHold(LocalCacheManager.runQuerySubject(definition, queryName), definition.Hash)) {
|
|
3164
|
+
LogStatusEx({ message: `[CACHE-WRITE-GATE] Skipping cache write for query "${queryName}" — its shared slots were found under a different definition`, verboseOnly: true });
|
|
3165
|
+
return;
|
|
3166
|
+
}
|
|
3167
|
+
data.definitionHash = definition.Hash;
|
|
3168
|
+
if (definition.Stamp) {
|
|
3169
|
+
data.definitionStamp = definition.Stamp;
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
2783
3172
|
// Estimate size by sampling rows (eviction accounting only).
|
|
2784
3173
|
const sizeBytes = this.estimateResultsSize(results);
|
|
2785
3174
|
// Oversized-entry gate — same rationale as SetRunViewResult: never wipe the
|
|
2786
3175
|
// cache to make room for an entry that can't be retained within budget.
|
|
2787
3176
|
if (this.exceedsMaxEntrySize(sizeBytes)) {
|
|
2788
|
-
LogWarning(`
|
|
3177
|
+
LogWarning(this.describeOversizedEntry(`query ${queryName}`, sizeBytes), 'Cache');
|
|
2789
3178
|
return;
|
|
2790
3179
|
}
|
|
2791
3180
|
// Same reference-sharing exposure as the RunView path (GetRunQueryResult hands
|
|
2792
3181
|
// `results` straight back out) — closed here rather than waiting for a mutator to
|
|
2793
|
-
// find it. Placed immediately after the only declining
|
|
3182
|
+
// find it. Placed immediately after the only declining gates and BEFORE the awaited
|
|
2794
3183
|
// eviction below, for the same reason as SetRunViewResult: callers of this method do
|
|
2795
3184
|
// not always await it, so any yield point before the freeze is a window in which the
|
|
2796
3185
|
// caller can still mutate the rows the cache is about to store.
|
|
@@ -2808,7 +3197,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2808
3197
|
// reading this slot has no registry entry for it, so a store-side TTL is the only thing
|
|
2809
3198
|
// that bounds it there. Without it an external-data-source query result was served
|
|
2810
3199
|
// forever by every server except the one that wrote it.
|
|
2811
|
-
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache,
|
|
3200
|
+
await this._storageProvider.SetItem(fingerprint, data, CacheCategory.RunQueryCache, LocalCacheManager.runQueryWriteOptions(expiresAt, now, definition?.QueryID ?? queryId));
|
|
2812
3201
|
this.registerEntry({
|
|
2813
3202
|
key: fingerprint,
|
|
2814
3203
|
type: 'runquery',
|
|
@@ -2827,13 +3216,33 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2827
3216
|
LogError(`LocalCacheManager.SetRunQueryResult failed: ${e}`);
|
|
2828
3217
|
}
|
|
2829
3218
|
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Store options for a RunQuery slot: its expiry, and the query ID as the index group, so a
|
|
3221
|
+
* shared store can list every slot of one query whichever process wrote it.
|
|
3222
|
+
*/
|
|
3223
|
+
static runQueryWriteOptions(expiresAt, now, queryId) {
|
|
3224
|
+
const options = {};
|
|
3225
|
+
if (expiresAt) {
|
|
3226
|
+
options.TTLSeconds = Math.max(1, Math.ceil((expiresAt - now) / 1000));
|
|
3227
|
+
}
|
|
3228
|
+
if (queryId) {
|
|
3229
|
+
options.IndexGroup = queryId.trim().toLowerCase();
|
|
3230
|
+
}
|
|
3231
|
+
return options.TTLSeconds !== undefined || options.IndexGroup !== undefined ? options : undefined;
|
|
3232
|
+
}
|
|
2830
3233
|
/**
|
|
2831
3234
|
* Retrieves a cached RunQuery result.
|
|
2832
3235
|
*
|
|
2833
3236
|
* @param fingerprint - The cache fingerprint
|
|
2834
|
-
* @
|
|
2835
|
-
|
|
2836
|
-
|
|
3237
|
+
* @param expectedDefinition - The query definition the caller would execute on a miss. When
|
|
3238
|
+
* given, a slot written under a different definition (or one that records none) is a miss.
|
|
3239
|
+
* On a process-local store the slot is also deleted; on a shared store it is left in place and
|
|
3240
|
+
* this process's writes of the query's slots are held (see `handleSchemaMismatch`). Omit it
|
|
3241
|
+
* where the caller cannot compute a definition (a client, whose slots the server validates).
|
|
3242
|
+
* @returns The cached results, maxUpdatedAt, rowCount, queryId and definition hash, or null if
|
|
3243
|
+
* not found, expired, or written under another definition
|
|
3244
|
+
*/
|
|
3245
|
+
async GetRunQueryResult(fingerprint, expectedDefinition) {
|
|
2837
3246
|
if (!this._storageProvider || !this._config.enabled)
|
|
2838
3247
|
return null;
|
|
2839
3248
|
// Check if entry has expired
|
|
@@ -2848,6 +3257,14 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2848
3257
|
// Native object read — no JSON.parse needed.
|
|
2849
3258
|
const parsed = await this._storageProvider.GetItem(fingerprint, CacheCategory.RunQueryCache);
|
|
2850
3259
|
if (parsed) {
|
|
3260
|
+
const mismatch = expectedDefinition
|
|
3261
|
+
? LocalCacheManager.findDefinitionMismatch(parsed, expectedDefinition, entry?.name)
|
|
3262
|
+
: null;
|
|
3263
|
+
if (mismatch) {
|
|
3264
|
+
this.handleSchemaMismatch(mismatch, 'serve', () => this.InvalidateRunQueryResult(fingerprint));
|
|
3265
|
+
this._stats.misses++;
|
|
3266
|
+
return null;
|
|
3267
|
+
}
|
|
2851
3268
|
this.recordAccess(fingerprint);
|
|
2852
3269
|
this._stats.hits++;
|
|
2853
3270
|
// Handle legacy entries that may not have rowCount
|
|
@@ -2861,7 +3278,10 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2861
3278
|
// (the B38 omission pattern, which this session exists to stamp out — and
|
|
2862
3279
|
// which the first version of this very fix repeated) silently disabled that
|
|
2863
3280
|
// tie-break and with it TTL caching for runtime-created queries.
|
|
2864
|
-
warmedForUserID: parsed.warmedForUserID
|
|
3281
|
+
warmedForUserID: parsed.warmedForUserID,
|
|
3282
|
+
// Same pass-through rule: a client sends it back to the server, which answers
|
|
3283
|
+
// `stale` when its own definition differs.
|
|
3284
|
+
definitionHash: parsed.definitionHash
|
|
2865
3285
|
};
|
|
2866
3286
|
}
|
|
2867
3287
|
}
|
|
@@ -2871,6 +3291,22 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2871
3291
|
this._stats.misses++;
|
|
2872
3292
|
return null;
|
|
2873
3293
|
}
|
|
3294
|
+
/**
|
|
3295
|
+
* Compares a RunQuery slot's definition hash with the definition the caller expects. A slot
|
|
3296
|
+
* that records no hash was written before slots carried one, so nothing says which SQL produced
|
|
3297
|
+
* its rows; it counts as a mismatch with an unknown order.
|
|
3298
|
+
*/
|
|
3299
|
+
static findDefinitionMismatch(data, expected, queryName) {
|
|
3300
|
+
if (data.definitionHash === expected.Hash) {
|
|
3301
|
+
return null;
|
|
3302
|
+
}
|
|
3303
|
+
return {
|
|
3304
|
+
Subject: LocalCacheManager.runQuerySubject(expected, queryName),
|
|
3305
|
+
CachedHash: data.definitionHash ?? '',
|
|
3306
|
+
Current: { Hash: expected.Hash, Stamp: expected.Stamp },
|
|
3307
|
+
EntryIsNewer: LocalCacheManager.isNewerSchemaStamp(data.definitionStamp, expected.Stamp),
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
2874
3310
|
/**
|
|
2875
3311
|
* Invalidates a cached RunQuery result.
|
|
2876
3312
|
*
|
|
@@ -2891,7 +3327,12 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2891
3327
|
* Invalidates all cached RunQuery results for a specific query.
|
|
2892
3328
|
* Useful when a query's underlying data changes and all related caches should be cleared.
|
|
2893
3329
|
*
|
|
3330
|
+
* Matches this process's registry by name only, so it misses slots written by other processes
|
|
3331
|
+
* on a shared store and slots written under a former name.
|
|
3332
|
+
*
|
|
2894
3333
|
* @param queryName - The query name to invalidate
|
|
3334
|
+
* @deprecated Use {@link InvalidateRunQueryResultsForQuery}, which finds a query's slots by ID
|
|
3335
|
+
* in every process that shares the store.
|
|
2895
3336
|
*/
|
|
2896
3337
|
async InvalidateQueryCaches(queryName) {
|
|
2897
3338
|
if (!this._storageProvider)
|
|
@@ -2914,12 +3355,66 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2914
3355
|
}
|
|
2915
3356
|
await this.persistRegistry();
|
|
2916
3357
|
}
|
|
3358
|
+
/**
|
|
3359
|
+
* Deletes every cached RunQuery result of one query: the slots this process wrote, and on a
|
|
3360
|
+
* shared store every slot any process wrote with the query's ID (see {@link SetRunQueryResult}).
|
|
3361
|
+
* Slots keyed by the query's name alone, or written before slots were indexed, are not found;
|
|
3362
|
+
* a definition check on read (see {@link GetRunQueryResult}) keeps them from being served.
|
|
3363
|
+
* Never throws.
|
|
3364
|
+
*
|
|
3365
|
+
* @param queryId - The query whose slots to delete
|
|
3366
|
+
* @returns The number of slots deleted
|
|
3367
|
+
*/
|
|
3368
|
+
async InvalidateRunQueryResultsForQuery(queryId) {
|
|
3369
|
+
if (!this._storageProvider || !queryId)
|
|
3370
|
+
return 0;
|
|
3371
|
+
const keys = await this.findRunQueryKeysForQuery(queryId);
|
|
3372
|
+
let removed = 0;
|
|
3373
|
+
for (const key of keys) {
|
|
3374
|
+
try {
|
|
3375
|
+
await this._storageProvider.Remove(key, CacheCategory.RunQueryCache);
|
|
3376
|
+
this._registry.delete(key);
|
|
3377
|
+
removed++;
|
|
3378
|
+
}
|
|
3379
|
+
catch (e) {
|
|
3380
|
+
LogError(`LocalCacheManager.InvalidateRunQueryResultsForQuery failed for key ${key}: ${e}`);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
if (removed > 0) {
|
|
3384
|
+
await this.persistRegistry();
|
|
3385
|
+
}
|
|
3386
|
+
return removed;
|
|
3387
|
+
}
|
|
3388
|
+
/** The keys of one query's RunQuery slots: this process's registry, plus the shared index. */
|
|
3389
|
+
async findRunQueryKeysForQuery(queryId) {
|
|
3390
|
+
const id = queryId.trim().toLowerCase();
|
|
3391
|
+
const keys = new Set();
|
|
3392
|
+
for (const [key, entry] of this._registry.entries()) {
|
|
3393
|
+
// A fingerprint is `Name|ID|Category|Params[|Connection]`; matching any segment keeps a
|
|
3394
|
+
// name that itself contains '|' from hiding the ID.
|
|
3395
|
+
if (entry.type === 'runquery' && key.toLowerCase().split('|').includes(id)) {
|
|
3396
|
+
keys.add(key);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
if (this._storageProvider?.GetIndexGroupKeys) {
|
|
3400
|
+
try {
|
|
3401
|
+
for (const key of await this._storageProvider.GetIndexGroupKeys(CacheCategory.RunQueryCache, id)) {
|
|
3402
|
+
keys.add(key);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
catch (e) {
|
|
3406
|
+
LogError(`LocalCacheManager: listing RunQuery slots of query ${queryId} failed: ${e}`);
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
return keys;
|
|
3410
|
+
}
|
|
2917
3411
|
/**
|
|
2918
3412
|
* Gets the cache status (fingerprint data) for a RunQuery result.
|
|
2919
3413
|
* Used for smart cache validation with the server.
|
|
2920
3414
|
*
|
|
2921
3415
|
* @param fingerprint - The cache fingerprint
|
|
2922
|
-
* @returns The cache status with maxUpdatedAt and
|
|
3416
|
+
* @returns The cache status with maxUpdatedAt, rowCount and the definition hash the slot was
|
|
3417
|
+
* written under, or null if not found/expired
|
|
2923
3418
|
*/
|
|
2924
3419
|
async GetRunQueryCacheStatus(fingerprint) {
|
|
2925
3420
|
const cached = await this.GetRunQueryResult(fingerprint);
|
|
@@ -2927,7 +3422,8 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2927
3422
|
return null;
|
|
2928
3423
|
return {
|
|
2929
3424
|
maxUpdatedAt: cached.maxUpdatedAt,
|
|
2930
|
-
rowCount: cached.rowCount
|
|
3425
|
+
rowCount: cached.rowCount,
|
|
3426
|
+
definitionHash: cached.definitionHash
|
|
2931
3427
|
};
|
|
2932
3428
|
}
|
|
2933
3429
|
// ========================================================================
|
|
@@ -2957,17 +3453,24 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
2957
3453
|
runview: { count: 0, sizeBytes: 0 },
|
|
2958
3454
|
runquery: { count: 0, sizeBytes: 0 }
|
|
2959
3455
|
};
|
|
3456
|
+
let totalSizeBytes = 0;
|
|
3457
|
+
let oldestEntry = Infinity;
|
|
3458
|
+
let newestEntry = 0;
|
|
2960
3459
|
for (const entry of entries) {
|
|
2961
3460
|
byType[entry.type].count++;
|
|
2962
3461
|
byType[entry.type].sizeBytes += entry.sizeBytes;
|
|
3462
|
+
totalSizeBytes += entry.sizeBytes;
|
|
3463
|
+
if (entry.cachedAt < oldestEntry)
|
|
3464
|
+
oldestEntry = entry.cachedAt;
|
|
3465
|
+
if (entry.cachedAt > newestEntry)
|
|
3466
|
+
newestEntry = entry.cachedAt;
|
|
2963
3467
|
}
|
|
2964
|
-
const timestamps = entries.map(e => e.cachedAt);
|
|
2965
3468
|
return {
|
|
2966
3469
|
totalEntries: entries.length,
|
|
2967
|
-
totalSizeBytes
|
|
3470
|
+
totalSizeBytes,
|
|
2968
3471
|
byType,
|
|
2969
|
-
oldestEntry:
|
|
2970
|
-
newestEntry
|
|
3472
|
+
oldestEntry: entries.length > 0 ? oldestEntry : 0,
|
|
3473
|
+
newestEntry,
|
|
2971
3474
|
hits: this._stats.hits,
|
|
2972
3475
|
misses: this._stats.misses
|
|
2973
3476
|
};
|
|
@@ -3000,6 +3503,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3000
3503
|
await this._storageProvider.Remove(entry.key + '_date', category);
|
|
3001
3504
|
}
|
|
3002
3505
|
this._registry.delete(entry.key);
|
|
3506
|
+
this._lastVerifiedAt.delete(entry.key);
|
|
3003
3507
|
}
|
|
3004
3508
|
catch (e) {
|
|
3005
3509
|
LogError(`LocalCacheManager.ClearByType failed for key ${entry.key}: ${e}`);
|
|
@@ -3030,6 +3534,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3030
3534
|
}
|
|
3031
3535
|
}
|
|
3032
3536
|
this._registry.clear();
|
|
3537
|
+
this._lastVerifiedAt.clear();
|
|
3033
3538
|
this._stats = { hits: 0, misses: 0 };
|
|
3034
3539
|
await this.persistRegistry();
|
|
3035
3540
|
return count;
|
|
@@ -3080,6 +3585,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3080
3585
|
*/
|
|
3081
3586
|
unregisterEntry(key) {
|
|
3082
3587
|
this._registry.delete(key);
|
|
3588
|
+
this._lastVerifiedAt.delete(key);
|
|
3083
3589
|
this.debouncedPersistRegistry();
|
|
3084
3590
|
}
|
|
3085
3591
|
/**
|
|
@@ -3187,7 +3693,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3187
3693
|
return 0;
|
|
3188
3694
|
}
|
|
3189
3695
|
};
|
|
3190
|
-
const sampleCount = Math.min(
|
|
3696
|
+
const sampleCount = rowCount <= 3 ? rowCount : Math.min(10, Math.max(3, Math.ceil(rowCount * 0.10)));
|
|
3191
3697
|
if (sampleCount >= rowCount) {
|
|
3192
3698
|
// Small result set — measure every row (no point sampling).
|
|
3193
3699
|
let total = 0;
|
|
@@ -3209,32 +3715,89 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3209
3715
|
return Math.ceil(avgLen * rowCount) * 2;
|
|
3210
3716
|
}
|
|
3211
3717
|
/**
|
|
3212
|
-
*
|
|
3213
|
-
*
|
|
3214
|
-
*
|
|
3215
|
-
*
|
|
3216
|
-
*
|
|
3217
|
-
*
|
|
3718
|
+
* The largest single entry this cache will store, in bytes, or 0 when no ceiling
|
|
3719
|
+
* applies (`maxEntryPercentOfCache: 0`).
|
|
3720
|
+
*
|
|
3721
|
+
* `'auto'` derives it from the budget — the whole budget less
|
|
3722
|
+
* {@link SINGLE_ENTRY_RESERVE_PERCENT} — so the ceiling grows with the memory the
|
|
3723
|
+
* operator gave the cache instead of being a small fixed fraction that the biggest
|
|
3724
|
+
* entity can never fit under. An explicit percentage is honoured as a stricter
|
|
3725
|
+
* ceiling, clamped to {@link HARD_MAX_ENTRY_PERCENT} so no configuration can admit an
|
|
3726
|
+
* entry that leaves the cache with less free space than one eviction pass needs.
|
|
3218
3727
|
*/
|
|
3219
|
-
|
|
3220
|
-
const pct = this.
|
|
3728
|
+
get MaxEntrySizeBytes() {
|
|
3729
|
+
const pct = this.effectiveMaxEntryPercent;
|
|
3221
3730
|
if (pct <= 0)
|
|
3731
|
+
return 0;
|
|
3732
|
+
return Math.floor(this._config.maxSizeBytes * pct / 100);
|
|
3733
|
+
}
|
|
3734
|
+
/**
|
|
3735
|
+
* The per-entry ceiling as a percentage of the budget, with `'auto'` resolved and an
|
|
3736
|
+
* explicit configuration clamped. 0 or below means "no ceiling".
|
|
3737
|
+
*/
|
|
3738
|
+
get effectiveMaxEntryPercent() {
|
|
3739
|
+
const pct = this._config.maxEntryPercentOfCache;
|
|
3740
|
+
if (pct === 'auto') {
|
|
3741
|
+
return 100 - Math.max(SINGLE_ENTRY_RESERVE_PERCENT, EVICTION_THRASH_FLOOR_PERCENT);
|
|
3742
|
+
}
|
|
3743
|
+
return Math.min(pct, HARD_MAX_ENTRY_PERCENT);
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Returns true when a single entry of the given estimated size exceeds
|
|
3747
|
+
* {@link MaxEntrySizeBytes} and must not be cached. See the config property's doc
|
|
3748
|
+
* comment for the rationale — in short, an entry that cannot be RETAINED within the
|
|
3749
|
+
* budget would be evicted again on the next store, so caching it is churn. An entry
|
|
3750
|
+
* that fits is cached however large it is: the cases that most need caching are the
|
|
3751
|
+
* large ones.
|
|
3752
|
+
*/
|
|
3753
|
+
exceedsMaxEntrySize(sizeBytes) {
|
|
3754
|
+
const ceiling = this.MaxEntrySizeBytes;
|
|
3755
|
+
if (ceiling <= 0)
|
|
3222
3756
|
return false;
|
|
3223
|
-
return sizeBytes >
|
|
3757
|
+
return sizeBytes > ceiling;
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* Renders the decline of an oversized entry as something an operator can act on: what
|
|
3761
|
+
* the ceiling is, and the `cacheSettings.maxMemoryMB` that would admit this entry.
|
|
3762
|
+
* The old message reported raw byte counts and a percentage, which named the symptom
|
|
3763
|
+
* but not the lever — and the entries it declines are exactly the expensive queries
|
|
3764
|
+
* whose absence from the cache costs the most.
|
|
3765
|
+
*/
|
|
3766
|
+
describeOversizedEntry(label, sizeBytes) {
|
|
3767
|
+
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
|
3768
|
+
const pct = this.effectiveMaxEntryPercent;
|
|
3769
|
+
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.`;
|
|
3770
|
+
// pct can only be <= 0 with the ceiling disabled, in which case nothing is declined
|
|
3771
|
+
// and this message is never produced — but the arithmetic below must not divide by it.
|
|
3772
|
+
if (pct <= 0)
|
|
3773
|
+
return head;
|
|
3774
|
+
const requiredBudgetMB = Math.ceil((sizeBytes * 100 / pct) / 1024 / 1024);
|
|
3775
|
+
return `${head} To cache it, raise cacheSettings.maxMemoryMB to ${requiredBudgetMB} or more.`;
|
|
3224
3776
|
}
|
|
3225
3777
|
/**
|
|
3226
3778
|
* Evicts entries if needed to make room for new data.
|
|
3779
|
+
*
|
|
3780
|
+
* Frees the DEFICIT — how far over budget the cache would be with the incoming entry —
|
|
3781
|
+
* rounded up to {@link EVICTION_THRASH_FLOOR_PERCENT} of the budget so a run of stores
|
|
3782
|
+
* near the budget does not evict on every one. It deliberately does not free the
|
|
3783
|
+
* incoming entry's whole size: for a large entry arriving into a mostly empty cache the
|
|
3784
|
+
* deficit is small, and freeing the entry's full size instead emptied the cache to make
|
|
3785
|
+
* room that was already there. That over-eviction is what made a large entry look
|
|
3786
|
+
* inherently destructive, and it is why the per-entry ceiling used to be set low enough
|
|
3787
|
+
* to exclude every large entry.
|
|
3227
3788
|
*/
|
|
3228
3789
|
async evictIfNeeded(neededBytes) {
|
|
3229
3790
|
if (!this._storageProvider)
|
|
3230
3791
|
return;
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3792
|
+
let currentSizeBytes = 0;
|
|
3793
|
+
for (const entry of this._registry.values()) {
|
|
3794
|
+
currentSizeBytes += entry.sizeBytes;
|
|
3795
|
+
}
|
|
3796
|
+
const deficitBytes = (currentSizeBytes + neededBytes) - this._config.maxSizeBytes;
|
|
3797
|
+
if (deficitBytes <= 0)
|
|
3234
3798
|
return;
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
const targetFreeBytes = Math.max(neededBytes, this._config.maxSizeBytes * 0.1);
|
|
3799
|
+
const thrashFloorBytes = Math.floor(this._config.maxSizeBytes * EVICTION_THRASH_FLOOR_PERCENT / 100);
|
|
3800
|
+
const targetFreeBytes = Math.max(deficitBytes, thrashFloorBytes);
|
|
3238
3801
|
await this.evict(targetFreeBytes);
|
|
3239
3802
|
}
|
|
3240
3803
|
/**
|
|
@@ -3280,6 +3843,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3280
3843
|
this.removeFromEntityIndex(entry.fingerprint);
|
|
3281
3844
|
}
|
|
3282
3845
|
this._registry.delete(key);
|
|
3846
|
+
this._lastVerifiedAt.delete(key);
|
|
3283
3847
|
}
|
|
3284
3848
|
catch (e) {
|
|
3285
3849
|
// Continue evicting other entries
|
|
@@ -3332,6 +3896,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3332
3896
|
this.removeFromEntityIndex(entry.key);
|
|
3333
3897
|
bytesToFree -= entry.sizeBytes;
|
|
3334
3898
|
this._registry.delete(entry.key);
|
|
3899
|
+
this._lastVerifiedAt.delete(entry.key);
|
|
3335
3900
|
}
|
|
3336
3901
|
catch {
|
|
3337
3902
|
// Continue evicting
|
|
@@ -3436,6 +4001,7 @@ export class LocalCacheManager extends BaseSingleton {
|
|
|
3436
4001
|
this.removeFromEntityIndex(entry.fingerprint);
|
|
3437
4002
|
}
|
|
3438
4003
|
this._registry.delete(key);
|
|
4004
|
+
this._lastVerifiedAt.delete(key);
|
|
3439
4005
|
}
|
|
3440
4006
|
}
|
|
3441
4007
|
//# sourceMappingURL=localCacheManager.js.map
|