Skip to content

Commit 1289217

Browse files
authored
fix: optimize localstorage transactions (#1651)
* feat: avoid writing to reclaim fields continuously * feat: add timer scale factor option * fix: revert the localstorage clearing strategy * fix: optimize localstorage key access * test(analytics-js-plugins): improve test coverage for retryqueue * test(analytics-js): improve test coverage for all the storages * test(analytics-js): improve test coverage for all the storage modules
1 parent 03f1b15 commit 1289217

File tree

13 files changed

+155
-82
lines changed

13 files changed

+155
-82
lines changed

packages/analytics-js-common/src/types/LoadOptions.ts

+2
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export type QueueOpts = {
7878
maxItems?: number;
7979
// Options for batched requests
8080
batch?: BatchOpts;
81+
// The scale factor applied to the default timer values
82+
timerScaleFactor?: number;
8183
};
8284

8385
/**

packages/analytics-js-common/src/types/Store.ts

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface IStore {
5454

5555
export interface IStorage extends Storage {
5656
configure?(options: StorageOptions): void;
57+
keys(): string[];
5758
isEnabled?: boolean;
5859
}
5960

packages/analytics-js-plugins/__tests__/utilities/retryQueue/RetryQueue.test.ts

+31-20
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ describe('Queue', () => {
3737
schedule.now = () => +new window.Date();
3838

3939
// Have the default function be a spied success
40-
queue = new RetryQueue('test', {}, jest.fn(), defaultStoreManager);
40+
queue = new RetryQueue(
41+
'test',
42+
{
43+
timerScaleFactor: 2, // scales the timers by 2x. Not a necessity, but added this option to test the timer scaling
44+
},
45+
jest.fn(),
46+
defaultStoreManager,
47+
);
4148
queue.schedule = schedule;
4249
});
4350

@@ -188,13 +195,7 @@ describe('Queue', () => {
188195
});
189196

190197
it('should respect shouldRetry', () => {
191-
queue.shouldRetry = (_, attemptNumber) => {
192-
if (attemptNumber > 2) {
193-
return false;
194-
}
195-
196-
return true;
197-
};
198+
queue.shouldRetry = (_, attemptNumber) => !(attemptNumber > 2);
198199

199200
const mockProcessItemCb = jest.fn((_, cb) => cb(new Error('no')));
200201

@@ -205,21 +206,17 @@ describe('Queue', () => {
205206
// over maxattempts
206207
queue.requeue('a', 3);
207208
jest.advanceTimersByTime(queue.getDelay(3));
208-
expect(queue.processQueueCb).toBeCalledTimes(0);
209+
expect(queue.processQueueCb).toHaveBeenCalledTimes(0);
209210

210211
mockProcessItemCb.mockReset();
211212
queue.requeue('a', 2);
212213
jest.advanceTimersByTime(queue.getDelay(2));
213-
expect(queue.processQueueCb).toBeCalledTimes(1);
214-
215-
// logic based on item state (eg. could be msg timestamp field)
216-
queue.shouldRetry = item => !(item.shouldRetry === false);
214+
expect(queue.processQueueCb).toHaveBeenCalledTimes(1);
217215

218216
mockProcessItemCb.mockReset();
219-
queue.requeue({ shouldRetry: false }, 1);
217+
queue.requeue('a', 3);
220218
jest.advanceTimersByTime(queue.getDelay(1));
221-
222-
expect(queue.processQueueCb).toBeCalledTimes(0);
219+
expect(queue.processQueueCb).toHaveBeenCalledTimes(0);
223220
});
224221

225222
it('should respect maxItems', () => {
@@ -382,8 +379,22 @@ describe('Queue', () => {
382379
// wait long enough for the other queue to expire and be reclaimed
383380
jest.advanceTimersByTime(queue.timeouts.reclaimTimer + queue.timeouts.reclaimWait * 2);
384381

385-
expect(queue.processQueueCb).nthCalledWith(1, 'a', expect.any(Function), 0, Infinity, true);
386-
expect(queue.processQueueCb).nthCalledWith(2, 'b', expect.any(Function), 0, Infinity, true);
382+
expect(queue.processQueueCb).toHaveBeenNthCalledWith(
383+
1,
384+
'a',
385+
expect.any(Function),
386+
0,
387+
Infinity,
388+
true,
389+
);
390+
expect(queue.processQueueCb).toHaveBeenNthCalledWith(
391+
2,
392+
'b',
393+
expect.any(Function),
394+
0,
395+
Infinity,
396+
true,
397+
);
387398
});
388399

389400
it('should deduplicate ids when reclaiming abandoned queue tasks', () => {
@@ -813,7 +824,7 @@ describe('Queue', () => {
813824
done(new Error());
814825
};
815826

816-
for (let i = 0; i < calls.length; i = i + 1) {
827+
for (let i = 0; i < calls.length; i += 1) {
817828
queue.addItem({ index: i });
818829
}
819830

@@ -849,7 +860,7 @@ describe('Queue', () => {
849860
expect(size(queue).inProgress).toEqual(queue.maxItems);
850861

851862
// while the items are in progress let's add maxItems times two items
852-
for (i = 0; i < queue.maxItems * 2; i = i + 1) {
863+
for (i = 0; i < queue.maxItems * 2; i += 1) {
853864
queue.addItem({ index: i });
854865
}
855866

packages/analytics-js-plugins/src/utilities/retryQueue/RetryQueue.ts

+62-49
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { StorageType } from '@rudderstack/analytics-js-common/types/Storage
55
import type { Nullable } from '@rudderstack/analytics-js-common/types/Nullable';
66
import type { ILogger } from '@rudderstack/analytics-js-common/types/Logger';
77
import type { BatchOpts, QueueOpts } from '@rudderstack/analytics-js-common/types/LoadOptions';
8-
import { isDefined } from '@rudderstack/analytics-js-common/utilities/checks';
8+
import { isDefined, isNullOrUndefined } from '@rudderstack/analytics-js-common/utilities/checks';
99
import { LOCAL_STORAGE } from '@rudderstack/analytics-js-common/constants/storages';
1010
import { generateUUID } from '@rudderstack/analytics-js-common/utilities/uuId';
1111
import type {
@@ -32,6 +32,8 @@ import {
3232
DEFAULT_RECLAIM_TIMEOUT_MS,
3333
DEFAULT_RECLAIM_WAIT_MS,
3434
DEFAULT_BATCH_FLUSH_INTERVAL_MS,
35+
MIN_TIMER_SCALE_FACTOR,
36+
MAX_TIMER_SCALE_FACTOR,
3537
} from './constants';
3638

3739
const sortByTime = (a: QueueItem, b: QueueItem) => a.time - b.time;
@@ -64,6 +66,8 @@ class RetryQueue implements IQueue<QueueItemData> {
6466
flushBatchTaskId?: string;
6567
batchingInProgress?: boolean;
6668
batchSizeCalcCb?: QueueBatchItemsSizeCalculatorCallback<QueueItemData>;
69+
reclaimStartVal?: Nullable<string>;
70+
reclaimEndVal?: Nullable<string>;
6771

6872
constructor(
6973
name: string,
@@ -95,12 +99,21 @@ class RetryQueue implements IQueue<QueueItemData> {
9599
jitter: options.backoffJitter || DEFAULT_BACKOFF_JITTER,
96100
};
97101

102+
// Limit the timer scale factor to the minimum value
103+
let timerScaleFactor = Math.max(
104+
options.timerScaleFactor ?? MIN_TIMER_SCALE_FACTOR,
105+
MIN_TIMER_SCALE_FACTOR,
106+
);
107+
108+
// Limit the timer scale factor to the maximum value
109+
timerScaleFactor = Math.min(timerScaleFactor, MAX_TIMER_SCALE_FACTOR);
110+
98111
// painstakingly tuned. that's why they're not "easily" configurable
99112
this.timeouts = {
100-
ackTimer: DEFAULT_ACK_TIMER_MS,
101-
reclaimTimer: DEFAULT_RECLAIM_TIMER_MS,
102-
reclaimTimeout: DEFAULT_RECLAIM_TIMEOUT_MS,
103-
reclaimWait: DEFAULT_RECLAIM_WAIT_MS,
113+
ackTimer: Math.round(timerScaleFactor * DEFAULT_ACK_TIMER_MS),
114+
reclaimTimer: Math.round(timerScaleFactor * DEFAULT_RECLAIM_TIMER_MS),
115+
reclaimTimeout: Math.round(timerScaleFactor * DEFAULT_RECLAIM_TIMEOUT_MS),
116+
reclaimWait: Math.round(timerScaleFactor * DEFAULT_RECLAIM_WAIT_MS),
104117
};
105118

106119
this.schedule = new Schedule();
@@ -165,17 +178,21 @@ class RetryQueue implements IQueue<QueueItemData> {
165178
}
166179

167180
getStorageEntry(
168-
name?: string,
181+
name: string,
169182
): Nullable<QueueItem<QueueItemData>[] | Record<string, any> | number> {
170-
return this.store.get(name ?? this.name);
183+
return this.store.get(name);
171184
}
172185

173186
// TODO: fix the type of different queues to be the same if possible
174187
setStorageEntry(
175-
name?: string,
188+
name: string,
176189
value?: Nullable<QueueItem<QueueItemData>[] | Record<string, any>> | number,
177190
) {
178-
this.store.set(name ?? this.name, value ?? []);
191+
if (isNullOrUndefined(value)) {
192+
this.store.remove(name);
193+
} else {
194+
this.store.set(name, value);
195+
}
179196
}
180197

181198
/**
@@ -305,7 +322,7 @@ class RetryQueue implements IQueue<QueueItemData> {
305322
batchQueue = batchQueue.slice(-batchQueue.length);
306323
batchQueue.push(entry);
307324

308-
const batchDispatchInfo = this.getBatchDispInfo(batchQueue);
325+
const batchDispatchInfo = this.getBatchDispatchInfo(batchQueue);
309326
// if batch criteria is met, queue the batch events to the main queue and clear batch queue
310327
if (batchDispatchInfo.criteriaMet || batchDispatchInfo.criteriaExceeded) {
311328
let batchItems;
@@ -399,7 +416,7 @@ class RetryQueue implements IQueue<QueueItemData> {
399416
* @param batchItems Prospective batch items
400417
* @returns Batch dispatch info
401418
*/
402-
getBatchDispInfo(batchItems: QueueItem[]) {
419+
getBatchDispatchInfo(batchItems: QueueItem[]) {
403420
let lengthCriteriaMet = false;
404421
let lengthCriteriaExceeded = false;
405422
const configuredBatchMaxItems = this.batch?.maxItems as number;
@@ -520,8 +537,17 @@ class RetryQueue implements IQueue<QueueItemData> {
520537
// Ack continuously to prevent other tabs from claiming our queue
521538
ack() {
522539
this.setStorageEntry(QueueStatuses.ACK, this.schedule.now());
523-
this.setStorageEntry(QueueStatuses.RECLAIM_START, null);
524-
this.setStorageEntry(QueueStatuses.RECLAIM_END, null);
540+
541+
if (this.reclaimStartVal != null) {
542+
this.reclaimStartVal = null;
543+
this.setStorageEntry(QueueStatuses.RECLAIM_START, null);
544+
}
545+
546+
if (this.reclaimEndVal != null) {
547+
this.reclaimEndVal = null;
548+
this.setStorageEntry(QueueStatuses.RECLAIM_END, null);
549+
}
550+
525551
this.schedule.run(this.ack, this.timeouts.ackTimer, ScheduleModes.ASAP);
526552
}
527553

@@ -609,9 +635,10 @@ class RetryQueue implements IQueue<QueueItemData> {
609635

610636
removeStorageEntry(store: IStore, entryIdx: number, backoff: number, attempt = 1) {
611637
const maxAttempts = 2;
638+
const queueEntryKeys = Object.keys(QueueStatuses);
639+
const entry = QueueStatuses[queueEntryKeys[entryIdx] as keyof typeof QueueStatuses];
640+
612641
(globalThis as typeof window).setTimeout(() => {
613-
const queueEntryKeys = Object.keys(QueueStatuses);
614-
const entry = QueueStatuses[queueEntryKeys[entryIdx] as keyof typeof QueueStatuses];
615642
try {
616643
store.remove(entry);
617644

@@ -633,7 +660,7 @@ class RetryQueue implements IQueue<QueueItemData> {
633660
this.logger?.error(RETRY_QUEUE_ENTRY_REMOVE_ERROR(RETRY_QUEUE, entry, attempt), err);
634661
}
635662

636-
// clear the next entry
663+
// clear the next entry after we've exhausted our attempts
637664
if (attempt === maxAttempts && entryIdx + 1 < queueEntryKeys.length) {
638665
this.removeStorageEntry(store, entryIdx + 1, backoff);
639666
}
@@ -678,45 +705,31 @@ class RetryQueue implements IQueue<QueueItemData> {
678705
};
679706
const findOtherQueues = (name: string): IStore[] => {
680707
const res: IStore[] = [];
681-
const storage = this.store.getOriginalEngine();
682-
683-
for (let i = 0; i < storage.length; i++) {
684-
const k = storage.key(i);
685-
const parts: string[] = k ? k.split('.') : [];
686-
687-
if (parts.length !== 3) {
688-
// eslint-disable-next-line no-continue
689-
continue;
690-
}
691-
692-
if (parts[0] !== name) {
693-
// eslint-disable-next-line no-continue
694-
continue;
695-
}
696-
697-
if (parts[2] !== QueueStatuses.ACK) {
698-
// eslint-disable-next-line no-continue
699-
continue;
708+
const storageKeys = this.store.getOriginalEngine().keys();
709+
storageKeys.forEach((k: string) => {
710+
const keyParts: string[] = k ? k.split('.') : [];
711+
712+
if (
713+
keyParts.length >= 3 &&
714+
keyParts[0] === name &&
715+
keyParts[1] !== this.id &&
716+
keyParts[2] === QueueStatuses.ACK
717+
) {
718+
res.push(
719+
this.storeManager.setStore({
720+
id: keyParts[1] as string,
721+
name,
722+
validKeys: QueueStatuses,
723+
type: LOCAL_STORAGE,
724+
}),
725+
);
700726
}
701-
702-
res.push(
703-
this.storeManager.setStore({
704-
id: parts[1] as string,
705-
name,
706-
validKeys: QueueStatuses,
707-
type: LOCAL_STORAGE,
708-
}),
709-
);
710-
}
727+
});
711728

712729
return res;
713730
};
714731

715732
findOtherQueues(this.name).forEach(store => {
716-
if (store.id === this.id) {
717-
return;
718-
}
719-
720733
if (this.schedule.now() - store.get(QueueStatuses.ACK) < this.timeouts.reclaimTimeout) {
721734
return;
722735
}

packages/analytics-js-plugins/src/utilities/retryQueue/constants.ts

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const DEFAULT_ACK_TIMER_MS = 1000;
1010
const DEFAULT_RECLAIM_TIMER_MS = 3000;
1111
const DEFAULT_RECLAIM_TIMEOUT_MS = 10000;
1212
const DEFAULT_RECLAIM_WAIT_MS = 500;
13+
const MIN_TIMER_SCALE_FACTOR = 1;
14+
const MAX_TIMER_SCALE_FACTOR = 10;
1315

1416
const DEFAULT_MAX_BATCH_SIZE_BYTES = 512 * 1024; // 512 KB; this is also the max size of a batch
1517
const DEFAULT_MAX_BATCH_ITEMS = 100;
@@ -29,4 +31,6 @@ export {
2931
DEFAULT_MAX_BATCH_SIZE_BYTES,
3032
DEFAULT_MAX_BATCH_ITEMS,
3133
DEFAULT_BATCH_FLUSH_INTERVAL_MS,
34+
MIN_TIMER_SCALE_FACTOR,
35+
MAX_TIMER_SCALE_FACTOR,
3236
};

packages/analytics-js/__tests__/services/StoreManager/storages/CookieStorage.test.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IStorage } from '@rudderstack/analytics-js-common/types/Store';
1+
import type { IStorage } from '@rudderstack/analytics-js-common/types/Store';
22
import {
33
getStorageEngine,
44
configureCookieStorageEngine,
@@ -17,10 +17,13 @@ describe('CookieStorage', () => {
1717
expect(engine.getItem('test-key')).toStrictEqual('abc');
1818
expect(engine.length).toStrictEqual(1);
1919
expect(engine.key(0)).toStrictEqual('test-key');
20+
expect(engine.key(1)).toBeNull();
21+
expect(engine.keys()).toStrictEqual(['test-key']);
2022

2123
engine.removeItem('test-key');
2224
expect(engine.getItem('test-key')).toBeNull();
2325
expect(engine.length).toStrictEqual(0);
26+
expect(engine.keys()).toStrictEqual([]);
2427

2528
// clear not implemented intentionally, see source code comments
2629
// engine.setItem('test-key', 'abc');

packages/analytics-js/__tests__/services/StoreManager/storages/InMemoryStorage.test.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { IStorage } from '@rudderstack/analytics-js-common/types/Store';
1+
import type { IStorage } from '@rudderstack/analytics-js-common/types/Store';
22
import { getStorageEngine } from '../../../../src/services/StoreManager/storages/storageEngine';
33

44
describe('InMemoryStorage', () => {
@@ -14,13 +14,18 @@ describe('InMemoryStorage', () => {
1414
expect(engine.getItem('test-key')).toStrictEqual('abc');
1515
expect(engine.length).toStrictEqual(1);
1616
expect(engine.key(0)).toStrictEqual('test-key');
17+
expect(engine.key(1)).toBeNull();
18+
expect(engine.keys()).toStrictEqual(['test-key']);
1719

1820
engine.removeItem('test-key');
1921
expect(engine.getItem('test-key')).toBeNull();
2022
expect(engine.length).toStrictEqual(0);
23+
expect(engine.keys()).toStrictEqual([]);
2124

2225
engine.setItem('test-key', 'abc');
2326
engine.clear();
27+
expect(engine.getItem('test-key')).toBeNull();
28+
expect(engine.keys()).toStrictEqual([]);
2429
expect(engine.length).toStrictEqual(0);
2530
});
2631
});

0 commit comments

Comments
 (0)