Skip to content

Commit 4069c24

Browse files
Merge pull request #380 from splitio/cache_expiration_validateExpiration_method
[Cache expiration] Update `validateCache` function with `expirationDays` and `clearOnInit` options
2 parents edb8995 + 760e2d0 commit 4069c24

File tree

12 files changed

+225
-32
lines changed

12 files changed

+225
-32
lines changed

CHANGES.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
2.0.3 (December 29, 2024)
1+
2.1.0 (January XX, 2025)
2+
- Added two new configuration options for the SDK storage in browsers when using storage type `LOCALSTORAGE`:
3+
- `storage.expirationDays` to specify the validity period of the rollout cache.
4+
- `storage.clearOnInit` to clear the rollout cache on SDK initialization.
25
- Bugfixing - Properly handle rejected promises when using targeting rules with segment matchers in consumer modes (e.g., Redis and Pluggable storages).
36

47
2.0.2 (December 3, 2024)

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.0.2",
3+
"version": "2.1.0-rc.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",

src/storages/KeyBuilderCS.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export class KeyBuilderCS extends KeyBuilder implements MySegmentsKeyBuilder {
4343
buildTillKey() {
4444
return `${this.prefix}.${this.matchingKey}.segments.till`;
4545
}
46+
47+
buildLastClear() {
48+
return `${this.prefix}.lastClear`;
49+
}
4650
}
4751

4852
export function myLargeSegmentsKeyBuilder(prefix: string, matchingKey: string): MySegmentsKeyBuilder {

src/storages/dataLoader.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { PreloadedData } from '../types';
2-
import { DEFAULT_CACHE_EXPIRATION_IN_MILLIS } from '../utils/constants/browser';
32
import { DataLoader, ISegmentsCacheSync, ISplitsCacheSync } from './types';
43

4+
// This value might be eventually set via a config parameter
5+
const DEFAULT_CACHE_EXPIRATION_IN_MILLIS = 864000000; // 10 days
6+
57
/**
68
* Factory of client-side storage loader
79
*

src/storages/inLocalStorage/SplitsCacheInLocal.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ export class SplitsCacheInLocal extends AbstractSplitsCacheSync {
7171
* We cannot simply call `localStorage.clear()` since that implies removing user items from the storage.
7272
*/
7373
clear() {
74-
this.log.info(LOG_PREFIX + 'Flushing Splits data from localStorage');
75-
7674
// collect item keys
7775
const len = localStorage.length;
7876
const accum = [];
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { validateCache } from '../validateCache';
2+
3+
import { KeyBuilderCS } from '../../KeyBuilderCS';
4+
import { fullSettings } from '../../../utils/settingsValidation/__tests__/settings.mocks';
5+
import { SplitsCacheInLocal } from '../SplitsCacheInLocal';
6+
import { nearlyEqual } from '../../../__tests__/testUtils';
7+
import { MySegmentsCacheInLocal } from '../MySegmentsCacheInLocal';
8+
9+
const FULL_SETTINGS_HASH = '404832b3';
10+
11+
describe('validateCache', () => {
12+
const keys = new KeyBuilderCS('SPLITIO', 'user');
13+
const logSpy = jest.spyOn(fullSettings.log, 'info');
14+
const segments = new MySegmentsCacheInLocal(fullSettings.log, keys);
15+
const largeSegments = new MySegmentsCacheInLocal(fullSettings.log, keys);
16+
const splits = new SplitsCacheInLocal(fullSettings, keys);
17+
18+
jest.spyOn(splits, 'clear');
19+
jest.spyOn(splits, 'getChangeNumber');
20+
jest.spyOn(segments, 'clear');
21+
jest.spyOn(largeSegments, 'clear');
22+
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
localStorage.clear();
26+
});
27+
28+
test('if there is no cache, it should return false', () => {
29+
expect(validateCache({}, fullSettings, keys, splits, segments, largeSegments)).toBe(false);
30+
31+
expect(logSpy).not.toHaveBeenCalled();
32+
33+
expect(splits.clear).not.toHaveBeenCalled();
34+
expect(segments.clear).not.toHaveBeenCalled();
35+
expect(largeSegments.clear).not.toHaveBeenCalled();
36+
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);
37+
38+
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
39+
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
40+
});
41+
42+
test('if there is cache and it must not be cleared, it should return true', () => {
43+
localStorage.setItem(keys.buildSplitsTillKey(), '1');
44+
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
45+
46+
expect(validateCache({}, fullSettings, keys, splits, segments, largeSegments)).toBe(true);
47+
48+
expect(logSpy).not.toHaveBeenCalled();
49+
50+
expect(splits.clear).not.toHaveBeenCalled();
51+
expect(segments.clear).not.toHaveBeenCalled();
52+
expect(largeSegments.clear).not.toHaveBeenCalled();
53+
expect(splits.getChangeNumber).toHaveBeenCalledTimes(1);
54+
55+
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
56+
expect(localStorage.getItem(keys.buildLastClear())).toBeNull();
57+
});
58+
59+
test('if there is cache and it has expired, it should clear cache and return false', () => {
60+
localStorage.setItem(keys.buildSplitsTillKey(), '1');
61+
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
62+
localStorage.setItem(keys.buildLastUpdatedKey(), Date.now() - 1000 * 60 * 60 * 24 * 2 + ''); // 2 days ago
63+
64+
expect(validateCache({ expirationDays: 1 }, fullSettings, keys, splits, segments, largeSegments)).toBe(false);
65+
66+
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: Cache expired more than 1 days ago. Cleaning up cache');
67+
68+
expect(splits.clear).toHaveBeenCalledTimes(1);
69+
expect(segments.clear).toHaveBeenCalledTimes(1);
70+
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
71+
72+
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
73+
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
74+
});
75+
76+
test('if there is cache and its hash has changed, it should clear cache and return false', () => {
77+
localStorage.setItem(keys.buildSplitsTillKey(), '1');
78+
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
79+
80+
expect(validateCache({}, { ...fullSettings, core: { ...fullSettings.core, authorizationKey: 'another' } }, keys, splits, segments, largeSegments)).toBe(false);
81+
82+
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
83+
84+
expect(splits.clear).toHaveBeenCalledTimes(1);
85+
expect(segments.clear).toHaveBeenCalledTimes(1);
86+
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
87+
88+
expect(localStorage.getItem(keys.buildHashKey())).toBe('aa4877c2');
89+
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
90+
});
91+
92+
test('if there is cache and clearOnInit is true, it should clear cache and return false', () => {
93+
// Older cache version (without last clear)
94+
localStorage.setItem(keys.buildSplitsTillKey(), '1');
95+
localStorage.setItem(keys.buildHashKey(), FULL_SETTINGS_HASH);
96+
97+
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, segments, largeSegments)).toBe(false);
98+
99+
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
100+
101+
expect(splits.clear).toHaveBeenCalledTimes(1);
102+
expect(segments.clear).toHaveBeenCalledTimes(1);
103+
expect(largeSegments.clear).toHaveBeenCalledTimes(1);
104+
105+
expect(localStorage.getItem(keys.buildHashKey())).toBe(FULL_SETTINGS_HASH);
106+
const lastClear = localStorage.getItem(keys.buildLastClear());
107+
expect(nearlyEqual(parseInt(lastClear as string), Date.now())).toBe(true);
108+
109+
// If cache is cleared, it should not clear again until a day has passed
110+
logSpy.mockClear();
111+
localStorage.setItem(keys.buildSplitsTillKey(), '1');
112+
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, segments, largeSegments)).toBe(true);
113+
expect(logSpy).not.toHaveBeenCalled();
114+
expect(localStorage.getItem(keys.buildLastClear())).toBe(lastClear); // Last clear should not have changed
115+
116+
// If a day has passed, it should clear again
117+
localStorage.setItem(keys.buildLastClear(), (Date.now() - 1000 * 60 * 60 * 24 - 1) + '');
118+
expect(validateCache({ clearOnInit: true }, fullSettings, keys, splits, segments, largeSegments)).toBe(false);
119+
expect(logSpy).toHaveBeenCalledWith('storage:localstorage: clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
120+
expect(splits.clear).toHaveBeenCalledTimes(2);
121+
expect(segments.clear).toHaveBeenCalledTimes(2);
122+
expect(largeSegments.clear).toHaveBeenCalledTimes(2);
123+
expect(nearlyEqual(parseInt(localStorage.getItem(keys.buildLastClear()) as string), Date.now())).toBe(true);
124+
});
125+
});

src/storages/inLocalStorage/index.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,12 @@ import { shouldRecordTelemetry, TelemetryCacheInMemory } from '../inMemory/Telem
1414
import { UniqueKeysCacheInMemoryCS } from '../inMemory/UniqueKeysCacheInMemoryCS';
1515
import { getMatching } from '../../utils/key';
1616
import { validateCache } from './validateCache';
17-
18-
export interface InLocalStorageOptions {
19-
prefix?: string
20-
}
17+
import SplitIO from '../../../types/splitio';
2118

2219
/**
2320
* InLocal storage factory for standalone client-side SplitFactory
2421
*/
25-
export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyncFactory {
22+
export function InLocalStorage(options: SplitIO.InLocalStorageOptions = {}): IStorageSyncFactory {
2623

2724
const prefix = validatePrefix(options.prefix);
2825

@@ -53,7 +50,7 @@ export function InLocalStorage(options: InLocalStorageOptions = {}): IStorageSyn
5350
uniqueKeys: impressionsMode === NONE ? new UniqueKeysCacheInMemoryCS() : undefined,
5451

5552
validateCache() {
56-
return validateCache(settings, keys, splits);
53+
return validateCache(options, settings, keys, splits, segments, largeSegments);
5754
},
5855

5956
destroy() { },
Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
import { ISettings } from '../../types';
2-
import { DEFAULT_CACHE_EXPIRATION_IN_MILLIS } from '../../utils/constants/browser';
3-
import { isNaNNumber } from '../../utils/lang';
2+
import { isFiniteNumber, isNaNNumber } from '../../utils/lang';
43
import { getStorageHash } from '../KeyBuilder';
54
import { LOG_PREFIX } from './constants';
65
import type { SplitsCacheInLocal } from './SplitsCacheInLocal';
6+
import type { MySegmentsCacheInLocal } from './MySegmentsCacheInLocal';
77
import { KeyBuilderCS } from '../KeyBuilderCS';
8+
import SplitIO from '../../../types/splitio';
89

9-
function validateExpiration(settings: ISettings, keys: KeyBuilderCS) {
10+
const DEFAULT_CACHE_EXPIRATION_IN_DAYS = 10;
11+
const MILLIS_IN_A_DAY = 86400000;
12+
13+
/**
14+
* Validates if cache should be cleared and sets the cache `hash` if needed.
15+
*
16+
* @returns `true` if cache should be cleared, `false` otherwise
17+
*/
18+
function validateExpiration(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, currentTimestamp: number, isThereCache: boolean) {
1019
const { log } = settings;
1120

1221
// Check expiration
13-
const expirationTimestamp = Date.now() - DEFAULT_CACHE_EXPIRATION_IN_MILLIS;
14-
let value: string | number | null = localStorage.getItem(keys.buildLastUpdatedKey());
15-
if (value !== null) {
16-
value = parseInt(value, 10);
17-
if (!isNaNNumber(value) && value < expirationTimestamp) return true;
22+
const lastUpdatedTimestamp = parseInt(localStorage.getItem(keys.buildLastUpdatedKey()) as string, 10);
23+
if (!isNaNNumber(lastUpdatedTimestamp)) {
24+
const cacheExpirationInDays = isFiniteNumber(options.expirationDays) && options.expirationDays >= 1 ? options.expirationDays : DEFAULT_CACHE_EXPIRATION_IN_DAYS;
25+
const expirationTimestamp = currentTimestamp - MILLIS_IN_A_DAY * cacheExpirationInDays;
26+
if (lastUpdatedTimestamp < expirationTimestamp) {
27+
log.info(LOG_PREFIX + 'Cache expired more than ' + cacheExpirationInDays + ' days ago. Cleaning up cache');
28+
return true;
29+
}
1830
}
1931

2032
// Check hash
@@ -23,27 +35,57 @@ function validateExpiration(settings: ISettings, keys: KeyBuilderCS) {
2335
const currentStorageHash = getStorageHash(settings);
2436

2537
if (storageHash !== currentStorageHash) {
26-
log.info(LOG_PREFIX + 'SDK key, flags filter criteria or flags spec version was modified. Updating cache');
2738
try {
2839
localStorage.setItem(storageHashKey, currentStorageHash);
2940
} catch (e) {
3041
log.error(LOG_PREFIX + e);
3142
}
32-
return true;
43+
if (isThereCache) {
44+
log.info(LOG_PREFIX + 'SDK key, flags filter criteria, or flags spec version has changed. Cleaning up cache');
45+
return true;
46+
}
47+
return false; // No cache to clear
48+
}
49+
50+
// Clear on init
51+
if (options.clearOnInit) {
52+
const lastClearTimestamp = parseInt(localStorage.getItem(keys.buildLastClear()) as string, 10);
53+
54+
if (isNaNNumber(lastClearTimestamp) || lastClearTimestamp < currentTimestamp - MILLIS_IN_A_DAY) {
55+
log.info(LOG_PREFIX + 'clearOnInit was set and cache was not cleared in the last 24 hours. Cleaning up cache');
56+
return true;
57+
}
3358
}
3459
}
3560

3661
/**
3762
* Clean cache if:
3863
* - it has expired, i.e., its `lastUpdated` timestamp is older than the given `expirationTimestamp`
39-
* - hash has changed, i.e., the SDK key, flags filter criteria or flags spec version was modified
64+
* - its hash has changed, i.e., the SDK key, flags filter criteria or flags spec version was modified
65+
* - `clearOnInit` was set and cache was not cleared in the last 24 hours
66+
*
67+
* @returns `true` if cache is ready to be used, `false` otherwise (cache was cleared or there is no cache)
4068
*/
41-
export function validateCache(settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal): boolean {
69+
export function validateCache(options: SplitIO.InLocalStorageOptions, settings: ISettings, keys: KeyBuilderCS, splits: SplitsCacheInLocal, segments: MySegmentsCacheInLocal, largeSegments: MySegmentsCacheInLocal): boolean {
70+
71+
const currentTimestamp = Date.now();
72+
const isThereCache = splits.getChangeNumber() > -1;
4273

43-
if (validateExpiration(settings, keys)) {
74+
if (validateExpiration(options, settings, keys, currentTimestamp, isThereCache)) {
4475
splits.clear();
76+
segments.clear();
77+
largeSegments.clear();
78+
79+
// Update last clear timestamp
80+
try {
81+
localStorage.setItem(keys.buildLastClear(), currentTimestamp + '');
82+
} catch (e) {
83+
settings.log.error(LOG_PREFIX + e);
84+
}
85+
86+
return false;
4587
}
4688

47-
// Check if the cache is ready
48-
return splits.getChangeNumber() > -1;
89+
// Check if ready from cache
90+
return isThereCache;
4991
}

src/utils/constants/browser.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

0 commit comments

Comments
 (0)