Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions API-INTERNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ that this internal function allows passing an additional <code>mergeReplaceNullP
Any existing collection members not included in the new data will not be removed.
Retries on failure.</p>
</dd>
<dt><a href="#getCallbackToStateMapping">getCallbackToStateMapping()</a></dt>
<dd><p>Getter - returns the callback to state mapping, useful in test environments.</p>
</dd>
<dt><a href="#clearOnyxUtilsInternals">clearOnyxUtilsInternals()</a></dt>
<dd><p>Clear internal variables used in this file, useful in test environments.</p>
</dd>
Expand Down Expand Up @@ -519,6 +522,12 @@ Retries on failure.
| params.collection | Object collection keyed by individual collection member keys and values |
| retryAttempt | retry attempt |

<a name="getCallbackToStateMapping"></a>

## getCallbackToStateMapping()
Getter - returns the callback to state mapping, useful in test environments.

**Kind**: global function
<a name="clearOnyxUtilsInternals"></a>

## clearOnyxUtilsInternals()
Expand Down
89 changes: 80 additions & 9 deletions lib/OnyxUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ const STORAGE_ERRORS = [...IDB_STORAGE_ERRORS, ...SQLITE_STORAGE_ERRORS];
// Max number of retries for failed storage operations
const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5;

// Connection/state errors where the DB needs time to recover — backoff helps, eviction does not
const IDB_CONNECTION_ERRORS = [
'internal error opening backing store', // Chrome/Edge: corrupted IDB state
'connection to indexed database server lost', // Safari: IDB connection dropped
'the database connection is closing', // Cross-browser: DB closing during write
] as const;

const SQLITE_CONNECTION_ERRORS = [
'disk i/o error', // Native: filesystem/device stress
'database is locked', // Native: concurrent access contention
] as const;

const CONNECTION_ERRORS = [...IDB_CONNECTION_ERRORS, ...SQLITE_CONNECTION_ERRORS];

// Retry backoff configuration
const RETRY_BASE_DELAY_MS = 100;
const RETRY_JITTER_FACTOR = 0.25;

type OnyxMethod = ValueOf<typeof METHOD>;

// Key/value store of Onyx key and arrays of values to merge
Expand Down Expand Up @@ -763,13 +781,33 @@ function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?:
return Storage.removeItem(key).then(() => undefined);
}

function reportStorageQuota(): Promise<void> {
/**
* Returns a promise that resolves after the given number of milliseconds.
*/
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

/**
* Calculates exponential backoff delay with jitter for a given retry attempt.
* Formula: baseDelay * 2^attempt ± jitter
* Attempt 0: ~100ms, Attempt 1: ~200ms, ..., Attempt 4: ~1600ms
*/
function getRetryDelay(attempt: number): number {
const baseDelay = RETRY_BASE_DELAY_MS * 2 ** attempt;
const jitter = baseDelay * RETRY_JITTER_FACTOR * (2 * Math.random() - 1);
return Math.max(0, Math.round(baseDelay + jitter));
}

function reportStorageQuota(error?: Error): Promise<void> {
return Storage.getDatabaseSize()
.then(({bytesUsed, bytesRemaining}) => {
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}`);
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} bytesRemaining: ${bytesRemaining}. Original error: ${error}`);
})
.catch((dbSizeError) => {
Logger.logAlert(`Unable to get database size. Error: ${dbSizeError}`);
Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
});
}

Expand All @@ -786,7 +824,7 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
Logger.logInfo(`Failed to save to storage. Error: ${error}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);

if (error && Str.startsWith(error.message, "Failed to execute 'put' on 'IDBObjectStore'")) {
Logger.logAlert('Attempted to set invalid data set in Onyx. Please ensure all data is serializable.');
Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
throw error;
}

Expand All @@ -800,8 +838,15 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
}

if (!isStorageCapacityError) {
const delay = getRetryDelay(currentRetryAttempt);
const isConnectionError = CONNECTION_ERRORS.some((connError) => errorName?.includes(connError) || errorMessage?.includes(connError));

if (isConnectionError) {
Logger.logInfo(`Connection error detected, retrying with backoff (${delay}ms). Error: ${error}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${nextRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
}

// @ts-expect-error No overload matches this call.
return onyxMethod(defaultParams, nextRetryAttempt);
return wait(delay).then(() => onyxMethod(defaultParams, nextRetryAttempt));
}

// Find the least recently accessed evictable key that we can remove
Expand All @@ -810,13 +855,13 @@ function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, on
// If we have no acceptable keys to remove then we are possibly trying to save mission critical data. If this is the case,
// then we should stop retrying as there is not much the user can do to fix this. Instead of getting them stuck in an infinite loop we
// will allow this write to be skipped.
Logger.logAlert('Out of storage. But found no acceptable keys to remove.');
return reportStorageQuota();
Logger.logAlert(`Out of storage. But found no acceptable keys to remove. Error: ${error}`);
return reportStorageQuota(error);
}

// Remove the least recently accessed key and retry.
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying.`);
reportStorageQuota();
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
reportStorageQuota(error);

// @ts-expect-error No overload matches this call.
return remove(keyForRemoval).then(() => onyxMethod(defaultParams, nextRetryAttempt));
Expand Down Expand Up @@ -1053,6 +1098,24 @@ function subscribeToKey<TKey extends OnyxKey>(connectOptions: ConnectOptions<TKe
callbackToStateMapping[subscriptionID] = mapping as CallbackToStateMapping<OnyxKey>;
callbackToStateMapping[subscriptionID].subscriptionID = subscriptionID;

// If the subscriber is attempting to connect to a collection member whose ID is skippable (e.g. "undefined", "null", etc.)
// we suppress wiring the subscription fully to avoid unnecessary callback emissions such as for "report_undefined".
// We still return a valid subscriptionID so callers can disconnect safely.
try {
const skippableIDs = getSkippableCollectionMemberIDs();
if (skippableIDs.size) {
const [, collectionMemberID] = OnyxKeys.splitCollectionMemberKey(mapping.key);
if (skippableIDs.has(collectionMemberID)) {
// Clean up the provisional mapping to avoid retaining unused subscribers.
cache.addNullishStorageKey(mapping.key);
delete callbackToStateMapping[subscriptionID];
return subscriptionID;
}
}
} catch (e) {
// Not a collection member key, proceed as usual.
}

// When keyChanged is called, a key is passed and the method looks through all the Subscribers in callbackToStateMapping for the matching key to get the subscriptionID
// to avoid having to loop through all the Subscribers all the time (even when just one connection belongs to one key),
// We create a mapping from key to lists of subscriptionIDs to access the specific list of subscriptionIDs.
Expand Down Expand Up @@ -1659,6 +1722,13 @@ function logKeyRemoved(onyxMethod: Extract<OnyxMethod, 'set' | 'merge'>, key: On
Logger.logInfo(`${onyxMethod} called for key: ${key} => null passed, so key was removed`);
}

/**
* Getter - returns the callback to state mapping, useful in test environments.
*/
function getCallbackToStateMapping(): Record<number, CallbackToStateMapping<OnyxKey>> {
return callbackToStateMapping;
}

/**
* Clear internal variables used in this file, useful in test environments.
*/
Expand Down Expand Up @@ -1718,6 +1788,7 @@ const OnyxUtils = {
setWithRetry,
multiSetWithRetry,
setCollectionWithRetry,
getCallbackToStateMapping,
};

export type {OnyxMethod};
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-onyx",
"version": "3.0.66",
"version": "3.0.68",
"author": "Expensify, Inc.",
"homepage": "https://expensify.com",
"description": "State management for React Native",
Expand Down
Loading
Loading