Skip to content

Commit fc85004

Browse files
fabioh8010TMisiukiewicz
authored andcommitted
Load all storage data into cache during init
1 parent 434871e commit fc85004

10 files changed

Lines changed: 201 additions & 17 deletions

File tree

lib/Onyx.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,13 @@ function init({
8282

8383
OnyxUtils.initStoreValues(keys, initialKeyStates, evictableKeys);
8484

85-
// Initialize all of our keys with data provided then give green light to any pending connections
86-
Promise.all([cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys), OnyxUtils.initializeWithDefaultKeyStates()]).then(
87-
OnyxUtils.getDeferredInitTask().resolve,
88-
);
85+
// Initialize all of our keys with data provided then give green light to any pending connections.
86+
// addEvictableKeysToRecentlyAccessedList must run after initializeWithDefaultKeyStates because
87+
// eager cache loading populates the key index (cache.setAllKeys) inside initializeWithDefaultKeyStates,
88+
// and the evictable keys list depends on that index being populated.
89+
OnyxUtils.initializeWithDefaultKeyStates()
90+
.then(() => cache.addEvictableKeysToRecentlyAccessedList(OnyxUtils.isCollectionKey, OnyxUtils.getAllKeys))
91+
.then(OnyxUtils.getDeferredInitTask().resolve);
8992
}
9093

9194
/**

lib/OnyxUtils.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,19 +1075,72 @@ function mergeInternal<TValue extends OnyxInput<OnyxKey> | undefined, TChange ex
10751075
* Merge user provided default key value pairs.
10761076
*/
10771077
function initializeWithDefaultKeyStates(): Promise<void> {
1078-
// Filter out RAM-only keys from storage reads as they may have stale persisted data
1079-
// from before the key was migrated to RAM-only.
1080-
const keysToFetch = Object.keys(defaultKeyStates).filter((key) => !isRamOnlyKey(key));
1081-
return Storage.multiGet(keysToFetch).then((pairs) => {
1082-
const existingDataAsObject = Object.fromEntries(pairs) as Record<string, unknown>;
1083-
1084-
const merged = utils.fastMerge(existingDataAsObject, defaultKeyStates, {
1085-
shouldRemoveNestedNulls: true,
1086-
}).result;
1087-
cache.merge(merged ?? {});
1088-
1089-
for (const [key, value] of Object.entries(merged ?? {})) keyChanged(key, value);
1090-
});
1078+
// Eagerly load the entire database into cache in a single batch read.
1079+
// This is faster than lazy-loading individual keys because:
1080+
// 1. One DB transaction instead of hundreds
1081+
// 2. All subsequent reads are synchronous cache hits
1082+
return Storage.getAll()
1083+
.then((pairs) => {
1084+
const allDataFromStorage: Record<string, unknown> = {};
1085+
for (const [key, value] of pairs) {
1086+
// RAM-only keys should never be loaded from storage as they may have stale persisted data
1087+
// from before the key was migrated to RAM-only.
1088+
if (isRamOnlyKey(key)) {
1089+
continue;
1090+
}
1091+
1092+
// Skip collection members that are marked as skippable
1093+
if (skippableCollectionMemberIDs.size && getCollectionKey(key)) {
1094+
const [, collectionMemberID] = splitCollectionMemberKey(key);
1095+
1096+
if (skippableCollectionMemberIDs.has(collectionMemberID)) {
1097+
continue;
1098+
}
1099+
}
1100+
1101+
allDataFromStorage[key] = value;
1102+
}
1103+
1104+
// Load all storage data into cache silently (no subscriber notifications)
1105+
cache.setAllKeys(Object.keys(allDataFromStorage));
1106+
cache.merge(allDataFromStorage);
1107+
1108+
// For keys that have a developer-defined default (via `initialKeyStates`), merge the
1109+
// persisted value with the default so new properties added in code updates are applied
1110+
// without wiping user data that already exists in storage.
1111+
const defaultKeysFromStorage = Object.keys(defaultKeyStates).reduce((obj: Record<string, unknown>, key) => {
1112+
if (key in allDataFromStorage) {
1113+
// eslint-disable-next-line no-param-reassign
1114+
obj[key] = allDataFromStorage[key];
1115+
}
1116+
return obj;
1117+
}, {});
1118+
1119+
const merged = utils.fastMerge(defaultKeysFromStorage, defaultKeyStates, {
1120+
shouldRemoveNestedNulls: true,
1121+
}).result;
1122+
cache.merge(merged ?? {});
1123+
1124+
// Notify subscribers about default key states so that any subscriber that connected
1125+
// before init (e.g. during module load) receives the merged default values immediately
1126+
for (const [key, value] of Object.entries(merged ?? {})) keyChanged(key, value);
1127+
})
1128+
.catch((error) => {
1129+
Logger.logAlert(`Failed to load data from storage during init. The app will boot with default key states only. Error: ${error}`);
1130+
1131+
// Populate the key index so getAllKeys() returns correct results for default keys.
1132+
// Without this, subscribers that check getAllKeys() would see an empty set even
1133+
// though we have default values in cache.
1134+
cache.setAllKeys(Object.keys(defaultKeyStates));
1135+
1136+
// Boot with defaults so the app renders instead of deadlocking.
1137+
// Users will get a fresh-install experience but the app won't be bricked.
1138+
cache.merge(defaultKeyStates);
1139+
1140+
// Notify subscribers about default key states so that any subscriber that connected
1141+
// before init (e.g. during module load) receives the merged default values immediately
1142+
for (const [key, value] of Object.entries(defaultKeyStates)) keyChanged(key, value);
1143+
});
10911144
}
10921145

10931146
/**

lib/storage/__mocks__/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const StorageMock = {
1616
removeItems: jest.fn(MemoryOnlyProvider.removeItems),
1717
clear: jest.fn(MemoryOnlyProvider.clear),
1818
getAllKeys: jest.fn(MemoryOnlyProvider.getAllKeys),
19+
getAll: jest.fn(MemoryOnlyProvider.getAll),
1920
getDatabaseSize: jest.fn(MemoryOnlyProvider.getDatabaseSize),
2021
keepInstancesSync: jest.fn(),
2122

lib/storage/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,11 @@ const storage: Storage = {
187187
*/
188188
getAllKeys: () => tryOrDegradePerformance(() => provider.getAllKeys()),
189189

190+
/**
191+
* Returns all key-value pairs from storage in a single batch operation
192+
*/
193+
getAll: () => tryOrDegradePerformance(() => provider.getAll()),
194+
190195
/**
191196
* Gets the total bytes of the store
192197
*/
@@ -220,6 +225,7 @@ GlobalSettings.addGlobalSettingsChangeListener(({enablePerformanceMetrics}) => {
220225
storage.removeItems = decorateWithMetrics(storage.removeItems, 'Storage.removeItems');
221226
storage.clear = decorateWithMetrics(storage.clear, 'Storage.clear');
222227
storage.getAllKeys = decorateWithMetrics(storage.getAllKeys, 'Storage.getAllKeys');
228+
storage.getAll = decorateWithMetrics(storage.getAll, 'Storage.getAll');
223229
});
224230

225231
export default storage;

lib/storage/providers/IDBKeyValProvider/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import utils from '../../../utils';
44
import type StorageProvider from '../types';
55
import type {OnyxKey, OnyxValue} from '../../../types';
66
import createStore from './createStore';
7+
import type {StorageKeyValuePair} from '../types';
78

89
const DB_NAME = 'OnyxDB';
910
const STORE_NAME = 'keyvaluepairs';
@@ -109,6 +110,13 @@ const provider: StorageProvider<UseStore | undefined> = {
109110

110111
return IDB.keys(provider.store);
111112
},
113+
getAll() {
114+
if (!provider.store) {
115+
throw new Error('Store not initialized!');
116+
}
117+
118+
return IDB.entries(provider.store) as Promise<StorageKeyValuePair[]>;
119+
},
112120
getItem(key) {
113121
if (!provider.store) {
114122
throw new Error('Store not initialized!');

lib/storage/providers/MemoryOnlyProvider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ const provider: StorageProvider<Store> = {
135135
return Promise.resolve(_.keys(provider.store));
136136
},
137137

138+
/**
139+
* Returns all key-value pairs from memory
140+
*/
141+
getAll() {
142+
return Promise.resolve(Object.entries(provider.store) as StorageKeyValuePair[]);
143+
},
144+
138145
/**
139146
* Gets the total bytes of the store.
140147
* `bytesRemaining` will always be `Number.POSITIVE_INFINITY` since we don't have a hard limit on memory.

lib/storage/providers/NoopProvider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ const provider: StorageProvider<unknown> = {
8787
return Promise.resolve([]);
8888
},
8989

90+
/**
91+
* Returns all key-value pairs from storage
92+
*/
93+
getAll() {
94+
return Promise.resolve([]);
95+
},
96+
9097
/**
9198
* Gets the total bytes of the store.
9299
* `bytesRemaining` will always be `Number.POSITIVE_INFINITY` since we don't have a hard limit on memory.

lib/storage/providers/SQLiteProvider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,17 @@ const provider: StorageProvider<NitroSQLiteConnection | undefined> = {
198198
return (result ?? []) as StorageKeyList;
199199
});
200200
},
201+
getAll() {
202+
if (!provider.store) {
203+
throw new Error('Store is not initialized!');
204+
}
205+
206+
return provider.store.executeAsync<OnyxSQLiteKeyValuePair>('SELECT record_key, valueJSON FROM keyvaluepairs;').then(({rows}) => {
207+
// eslint-disable-next-line no-underscore-dangle
208+
const result = rows?._array.map((row) => [row.record_key, JSON.parse(row.valueJSON)]);
209+
return (result ?? []) as StorageKeyValuePair[];
210+
});
211+
},
201212
removeItem(key) {
202213
if (!provider.store) {
203214
throw new Error('Store is not initialized!');

lib/storage/providers/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ type StorageProvider<TStore> = {
6060
*/
6161
getAllKeys: () => Promise<StorageKeyList>;
6262

63+
/**
64+
* Returns all key-value pairs from storage in a single batch operation.
65+
* More efficient than getAllKeys + multiGet for loading the entire database.
66+
*/
67+
getAll: () => Promise<StorageKeyValuePair[]>;
68+
6369
/**
6470
* Removes given key and its value from storage
6571
*/

tests/unit/onyxCacheTest.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,88 @@ describe('Onyx', () => {
662662
});
663663
});
664664

665+
describe('eager loading during initialisation', () => {
666+
beforeEach(() => {
667+
StorageMock = require('../../lib/storage').default;
668+
});
669+
670+
it('should load all storage data into cache during init', async () => {
671+
await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'storageValue');
672+
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, {id: 1, name: 'Item 1'});
673+
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}2`, {id: 2, name: 'Item 2'});
674+
await initOnyx();
675+
676+
expect(cache.getAllKeys().size).toBe(3);
677+
expect(cache.get(ONYX_KEYS.TEST_KEY)).toBe('storageValue');
678+
expect(cache.get(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`)).toEqual({id: 1, name: 'Item 1'});
679+
expect(cache.get(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}2`)).toEqual({id: 2, name: 'Item 2'});
680+
});
681+
682+
it('should not load RAM-only keys from storage during init', async () => {
683+
const testKeys = {
684+
...ONYX_KEYS,
685+
RAM_ONLY_KEY: 'ramOnlyKey',
686+
};
687+
688+
await StorageMock.setItem(testKeys.RAM_ONLY_KEY, 'staleValue');
689+
await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'normalValue');
690+
await initOnyx({keys: testKeys, ramOnlyKeys: [testKeys.RAM_ONLY_KEY]});
691+
692+
expect(cache.getAllKeys().size).toBe(1);
693+
expect(cache.get(testKeys.RAM_ONLY_KEY)).toBeUndefined();
694+
expect(cache.get(ONYX_KEYS.TEST_KEY)).toBe('normalValue');
695+
});
696+
697+
it('should merge default key states with storage data during init', async () => {
698+
await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, {fromStorage: true});
699+
await initOnyx({
700+
initialKeyStates: {
701+
[ONYX_KEYS.OTHER_TEST]: {fromDefault: true},
702+
},
703+
});
704+
705+
// Default key states are merged on top of storage data.
706+
expect(cache.get(ONYX_KEYS.OTHER_TEST)).toEqual({fromStorage: true, fromDefault: true});
707+
});
708+
709+
it('should use default key states when storage data is not available for a key', async () => {
710+
await StorageMock.clear();
711+
await initOnyx({
712+
initialKeyStates: {
713+
[ONYX_KEYS.OTHER_TEST]: 42,
714+
},
715+
});
716+
717+
expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBe(42);
718+
});
719+
720+
it('should gracefully handle Storage.getAll() failure and boot with defaults', async () => {
721+
(StorageMock.getAll as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('Database corrupted')));
722+
723+
await initOnyx({
724+
initialKeyStates: {
725+
[ONYX_KEYS.OTHER_TEST]: 42,
726+
},
727+
});
728+
729+
expect(cache.getAllKeys().size).toBe(1);
730+
expect(cache.get(ONYX_KEYS.OTHER_TEST)).toBe(42);
731+
});
732+
733+
it('should populate cache key index with all storage keys during init', async () => {
734+
await StorageMock.setItem(ONYX_KEYS.TEST_KEY, 'value1');
735+
await StorageMock.setItem(ONYX_KEYS.OTHER_TEST, 'value2');
736+
await StorageMock.setItem(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`, {id: 1});
737+
await initOnyx();
738+
739+
const allKeys = cache.getAllKeys();
740+
expect(allKeys.size).toBe(3);
741+
expect(allKeys.has(ONYX_KEYS.TEST_KEY)).toBe(true);
742+
expect(allKeys.has(ONYX_KEYS.OTHER_TEST)).toBe(true);
743+
expect(allKeys.has(`${ONYX_KEYS.COLLECTION.MOCK_COLLECTION}1`)).toBe(true);
744+
});
745+
});
746+
665747
it('should save RAM-only keys', () => {
666748
const testKeys = {
667749
...ONYX_KEYS,

0 commit comments

Comments
 (0)