react-native-onyx 3.0.87 → 3.0.89
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/API.md +0 -2
- package/README.md +2 -12
- package/dist/CircuitBreaker/AbstractCircuitBreaker.d.ts +91 -0
- package/dist/CircuitBreaker/AbstractCircuitBreaker.js +166 -0
- package/dist/CircuitBreaker/types.d.ts +38 -0
- package/dist/CircuitBreaker/types.js +24 -0
- package/dist/Onyx.d.ts +0 -2
- package/dist/Onyx.js +0 -2
- package/dist/OnyxConnectionManager.js +18 -15
- package/dist/OnyxUtils.d.ts +11 -5
- package/dist/OnyxUtils.js +92 -82
- package/dist/StateMachine.d.ts +45 -0
- package/dist/StateMachine.js +37 -0
- package/dist/StorageCircuitBreaker.d.ts +66 -0
- package/dist/StorageCircuitBreaker.js +177 -0
- package/dist/storage/__mocks__/index.d.ts +10 -0
- package/dist/storage/__mocks__/index.js +15 -0
- package/dist/storage/errors.d.ts +34 -0
- package/dist/storage/errors.js +41 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/providers/IDBKeyValProvider/classifyError.d.ts +9 -0
- package/dist/storage/providers/IDBKeyValProvider/classifyError.js +37 -0
- package/dist/storage/providers/IDBKeyValProvider/createStore.js +40 -57
- package/dist/storage/providers/IDBKeyValProvider/index.js +5 -0
- package/dist/storage/providers/MemoryOnlyProvider.js +5 -0
- package/dist/storage/providers/NoopProvider.js +5 -0
- package/dist/storage/providers/SQLiteProvider.js +5 -0
- package/dist/storage/providers/classifySQLiteError.d.ts +13 -0
- package/dist/storage/providers/classifySQLiteError.js +21 -0
- package/dist/storage/providers/types.d.ts +8 -0
- package/dist/types.d.ts +11 -27
- package/dist/useOnyx.js +0 -2
- package/package.json +1 -1
package/dist/OnyxUtils.js
CHANGED
|
@@ -43,8 +43,9 @@ const DevTools_1 = __importDefault(require("./DevTools"));
|
|
|
43
43
|
const Logger = __importStar(require("./Logger"));
|
|
44
44
|
const OnyxCache_1 = __importStar(require("./OnyxCache"));
|
|
45
45
|
const OnyxKeys_1 = __importDefault(require("./OnyxKeys"));
|
|
46
|
-
const
|
|
46
|
+
const StorageCircuitBreaker_1 = __importDefault(require("./StorageCircuitBreaker"));
|
|
47
47
|
const storage_1 = __importDefault(require("./storage"));
|
|
48
|
+
const errors_1 = require("./storage/errors");
|
|
48
49
|
const utils_1 = __importDefault(require("./utils"));
|
|
49
50
|
const createDeferredTask_1 = __importDefault(require("./createDeferredTask"));
|
|
50
51
|
const logMessages_1 = __importDefault(require("./logMessages"));
|
|
@@ -57,21 +58,6 @@ const METHOD = {
|
|
|
57
58
|
MULTI_SET: 'multiset',
|
|
58
59
|
CLEAR: 'clear',
|
|
59
60
|
};
|
|
60
|
-
// IndexedDB errors that indicate storage capacity issues where eviction can help
|
|
61
|
-
const IDB_STORAGE_ERRORS = [
|
|
62
|
-
'quotaexceedederror', // Browser storage quota exceeded
|
|
63
|
-
];
|
|
64
|
-
// SQLite errors that indicate storage capacity issues where eviction can help
|
|
65
|
-
const SQLITE_STORAGE_ERRORS = [
|
|
66
|
-
'database or disk is full', // Device storage is full
|
|
67
|
-
];
|
|
68
|
-
const STORAGE_ERRORS = [...IDB_STORAGE_ERRORS, ...SQLITE_STORAGE_ERRORS];
|
|
69
|
-
// IndexedDB errors where retrying is futile because the underlying connection/store is broken.
|
|
70
|
-
// The healing path (separate from retryOperation) is responsible for recovery.
|
|
71
|
-
const IDB_NON_RETRIABLE_ERRORS = [
|
|
72
|
-
'internal error opening backing store', // LevelDB backing store is broken at the filesystem level
|
|
73
|
-
];
|
|
74
|
-
const NON_RETRIABLE_ERRORS = [...IDB_NON_RETRIABLE_ERRORS];
|
|
75
61
|
// Max number of retries for failed storage operations
|
|
76
62
|
const MAX_STORAGE_OPERATION_RETRY_ATTEMPTS = 5;
|
|
77
63
|
// Key/value store of Onyx key and arrays of values to merge
|
|
@@ -497,25 +483,7 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
|
|
|
497
483
|
}
|
|
498
484
|
try {
|
|
499
485
|
lastConnectionCallbackData.set(subscriber.subscriptionID, { value: cachedCollection, matchedKey: subscriber.key });
|
|
500
|
-
|
|
501
|
-
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
// Not using waitForCollectionCallback — notify per changed key.
|
|
505
|
-
// Re-check the subscription on each iteration because the callback may
|
|
506
|
-
// synchronously disconnect itself (removing it from callbackToStateMapping),
|
|
507
|
-
// in which case we must stop firing further callbacks for this subscriber.
|
|
508
|
-
for (const dataKey of changedMemberKeys) {
|
|
509
|
-
const currentSubscriber = callbackToStateMapping[subID];
|
|
510
|
-
if (!currentSubscriber || typeof currentSubscriber.callback !== 'function') {
|
|
511
|
-
break;
|
|
512
|
-
}
|
|
513
|
-
if (cachedCollection[dataKey] === previousCollection[dataKey]) {
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
const currentSubscriberCallback = currentSubscriber.callback;
|
|
517
|
-
currentSubscriberCallback(cachedCollection[dataKey], dataKey);
|
|
518
|
-
}
|
|
486
|
+
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
|
|
519
487
|
}
|
|
520
488
|
catch (error) {
|
|
521
489
|
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
|
|
@@ -533,7 +501,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
|
|
|
533
501
|
try {
|
|
534
502
|
const subscriberCallback = subscriber.callback;
|
|
535
503
|
subscriberCallback(cachedCollection[subscriber.key], subscriber.key);
|
|
536
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
504
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
505
|
+
value: cachedCollection[subscriber.key],
|
|
506
|
+
matchedKey: subscriber.key,
|
|
507
|
+
});
|
|
537
508
|
}
|
|
538
509
|
catch (error) {
|
|
539
510
|
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
|
|
@@ -582,9 +553,9 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
|
|
|
582
553
|
if (lastData && lastData.matchedKey === key && lastData.value === value) {
|
|
583
554
|
continue;
|
|
584
555
|
}
|
|
585
|
-
if (OnyxKeys_1.default.isCollectionKey(subscriber.key)
|
|
586
|
-
// Skip individual key changes
|
|
587
|
-
//
|
|
556
|
+
if (OnyxKeys_1.default.isCollectionKey(subscriber.key)) {
|
|
557
|
+
// Skip individual key changes during collection updates to prevent duplicate
|
|
558
|
+
// callbacks - the collection update will handle this properly.
|
|
588
559
|
if (isProcessingCollectionUpdate) {
|
|
589
560
|
continue;
|
|
590
561
|
}
|
|
@@ -601,7 +572,10 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
|
|
|
601
572
|
}
|
|
602
573
|
const subscriberCallback = subscriber.callback;
|
|
603
574
|
subscriberCallback(value, key);
|
|
604
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
575
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
576
|
+
value,
|
|
577
|
+
matchedKey: key,
|
|
578
|
+
});
|
|
605
579
|
continue;
|
|
606
580
|
}
|
|
607
581
|
catch (error) {
|
|
@@ -616,17 +590,17 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
|
|
|
616
590
|
* Sends the data obtained from the keys to the connection.
|
|
617
591
|
*/
|
|
618
592
|
function sendDataToConnection(mapping, matchedKey) {
|
|
619
|
-
var _a
|
|
593
|
+
var _a;
|
|
620
594
|
// If the mapping no longer exists then we should not send any data.
|
|
621
595
|
// This means our subscriber was disconnected.
|
|
622
596
|
if (!callbackToStateMapping[mapping.subscriptionID]) {
|
|
623
597
|
return;
|
|
624
598
|
}
|
|
625
599
|
// Always read the latest value from cache to avoid stale or duplicate data.
|
|
626
|
-
// For collection subscribers
|
|
600
|
+
// For collection-root subscribers, read the full collection.
|
|
627
601
|
// For individual key subscribers, read just that key's value.
|
|
628
602
|
let value;
|
|
629
|
-
if (OnyxKeys_1.default.isCollectionKey(mapping.key)
|
|
603
|
+
if (OnyxKeys_1.default.isCollectionKey(mapping.key)) {
|
|
630
604
|
const collection = getCachedCollection(mapping.key);
|
|
631
605
|
value = Object.keys(collection).length > 0 ? collection : undefined;
|
|
632
606
|
}
|
|
@@ -642,7 +616,7 @@ function sendDataToConnection(mapping, matchedKey) {
|
|
|
642
616
|
if (lastData && lastData.matchedKey === matchedKey && (0, fast_equals_1.shallowEqual)(lastData.value, value)) {
|
|
643
617
|
return;
|
|
644
618
|
}
|
|
645
|
-
(
|
|
619
|
+
(_a = mapping.callback) === null || _a === void 0 ? void 0 : _a.call(mapping, value, matchedKey);
|
|
646
620
|
}
|
|
647
621
|
/**
|
|
648
622
|
* Gets the data for a given an array of matching keys, combines them into an object, and sends the result back to the subscriber.
|
|
@@ -666,44 +640,73 @@ function remove(key, isProcessingCollectionUpdate) {
|
|
|
666
640
|
function reportStorageQuota(error) {
|
|
667
641
|
return storage_1.default.getDatabaseSize()
|
|
668
642
|
.then(({ bytesUsed, bytesRemaining, usageDetails }) => {
|
|
669
|
-
|
|
643
|
+
// `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate,
|
|
644
|
+
// not headroom for this database. The browser allocates IndexedDB storage dynamically, so a
|
|
645
|
+
// QuotaExceededError can legitimately occur even when this number still looks large.
|
|
646
|
+
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
|
|
670
647
|
})
|
|
671
648
|
.catch((dbSizeError) => {
|
|
672
649
|
Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
|
|
673
650
|
});
|
|
674
651
|
}
|
|
675
652
|
/**
|
|
676
|
-
* Handles storage operation failures based on the error
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* -
|
|
680
|
-
* -
|
|
653
|
+
* Handles storage operation failures based on the error class (see lib/storage/errors.ts).
|
|
654
|
+
* The connection layer (createStore) owns connection/transport recovery; this operation layer owns
|
|
655
|
+
* capacity recovery (eviction) so that a given failure is retried by exactly one layer:
|
|
656
|
+
* - INVALID_DATA: logs an alert and throws (the same data will always fail).
|
|
657
|
+
* - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
|
|
658
|
+
* and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
|
|
659
|
+
* - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
|
|
660
|
+
* circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
|
|
661
|
+
* progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
|
|
662
|
+
* - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
|
|
663
|
+
* provider) once so it's visible, then bounded retry without eviction.
|
|
681
664
|
*/
|
|
682
665
|
function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) {
|
|
683
|
-
var _a, _b, _c, _d;
|
|
684
666
|
const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0;
|
|
685
667
|
const nextRetryAttempt = currentRetryAttempt + 1;
|
|
686
|
-
|
|
687
|
-
|
|
668
|
+
const errorClass = storage_1.default.classifyError(error);
|
|
669
|
+
// While open (or while a half-open probe is already in flight), drop capacity retries silently —
|
|
670
|
+
// the breaker already emitted its single alert, and logging per failed write is exactly the storm
|
|
671
|
+
// we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so
|
|
672
|
+
// the circuit reopens for another window. (We return before the log line below on purpose.)
|
|
673
|
+
if (errorClass === errors_1.StorageErrorClass.CAPACITY && !StorageCircuitBreaker_1.default.isAllowed()) {
|
|
674
|
+
StorageCircuitBreaker_1.default.recordProbeFailure();
|
|
675
|
+
return Promise.resolve();
|
|
676
|
+
}
|
|
677
|
+
Logger.logInfo(`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
|
|
678
|
+
if (errorClass === errors_1.StorageErrorClass.INVALID_DATA) {
|
|
688
679
|
Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
|
|
689
680
|
throw error;
|
|
690
681
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
const isStorageCapacityError = STORAGE_ERRORS.some((storageError) => (errorName === null || errorName === void 0 ? void 0 : errorName.includes(storageError)) || (errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.includes(storageError)));
|
|
694
|
-
const isNonRetriableError = NON_RETRIABLE_ERRORS.some((nonRetriableError) => (errorName === null || errorName === void 0 ? void 0 : errorName.includes(nonRetriableError)) || (errorMessage === null || errorMessage === void 0 ? void 0 : errorMessage.includes(nonRetriableError)));
|
|
695
|
-
if (isNonRetriableError) {
|
|
696
|
-
Logger.logAlert(`Storage operation skipped retry for non-retriable error. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
|
|
682
|
+
if (errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL) {
|
|
683
|
+
Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
|
|
697
684
|
return Promise.resolve();
|
|
698
685
|
}
|
|
699
686
|
if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) {
|
|
700
|
-
Logger.logAlert(`Storage operation failed after
|
|
687
|
+
Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
|
|
701
688
|
return Promise.resolve();
|
|
702
689
|
}
|
|
703
|
-
if (
|
|
690
|
+
if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
|
|
691
|
+
// UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so
|
|
692
|
+
// we cannot route it to a real recovery strategy. Log the full error shape (name + message +
|
|
693
|
+
// provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote
|
|
694
|
+
// recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the
|
|
695
|
+
// per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction.
|
|
696
|
+
if (currentRetryAttempt === 0) {
|
|
697
|
+
Logger.logAlert(`Unclassified storage error. provider: ${storage_1.default.getStorageProvider().name}. name: ${error === null || error === void 0 ? void 0 : error.name}. message: ${error === null || error === void 0 ? void 0 : error.message}. onyxMethod: ${onyxMethod.name}.`);
|
|
698
|
+
}
|
|
704
699
|
// @ts-expect-error No overload matches this call.
|
|
705
700
|
return onyxMethod(defaultParams, nextRetryAttempt);
|
|
706
701
|
}
|
|
702
|
+
// CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above
|
|
703
|
+
// cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns
|
|
704
|
+
// a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The
|
|
705
|
+
// already-open case returned silently at the top of this function.)
|
|
706
|
+
if (StorageCircuitBreaker_1.default.recordCapacityFailure()) {
|
|
707
|
+
// This failure tripped the breaker; it already emitted its single alert. Stop here.
|
|
708
|
+
return Promise.resolve();
|
|
709
|
+
}
|
|
707
710
|
// Find the least recently accessed evictable key that we can remove. Never evict an in-flight
|
|
708
711
|
// key — its cache value is the merge base this retry depends on, so dropping it would truncate
|
|
709
712
|
// the write to just the delta and diverge cache from storage.
|
|
@@ -718,8 +721,15 @@ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlight
|
|
|
718
721
|
// Remove the least recently accessed key and retry.
|
|
719
722
|
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
|
|
720
723
|
reportStorageQuota(error);
|
|
721
|
-
|
|
722
|
-
|
|
724
|
+
return remove(keyForRemoval).then(() => {
|
|
725
|
+
// Mark the eviction only once the deletion has actually completed, immediately before the
|
|
726
|
+
// retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume
|
|
727
|
+
// the marker as a no-progress cycle while this deletion is still pending and may yet free
|
|
728
|
+
// space — so the verdict belongs to the retry that follows the deletion, not the eviction call.
|
|
729
|
+
StorageCircuitBreaker_1.default.recordEviction();
|
|
730
|
+
// @ts-expect-error No overload matches this call.
|
|
731
|
+
return onyxMethod(defaultParams, nextRetryAttempt);
|
|
732
|
+
});
|
|
723
733
|
}
|
|
724
734
|
/**
|
|
725
735
|
* Notifies subscribers and writes current value to cache
|
|
@@ -948,27 +958,17 @@ function subscribeToKey(connectOptions) {
|
|
|
948
958
|
if (mapping.key) {
|
|
949
959
|
OnyxCache_1.default.addNullishStorageKey(mapping.key);
|
|
950
960
|
}
|
|
951
|
-
const matchedKey = OnyxKeys_1.default.isCollectionKey(mapping.key)
|
|
961
|
+
const matchedKey = OnyxKeys_1.default.isCollectionKey(mapping.key) ? mapping.key : undefined;
|
|
952
962
|
// Here we cannot use batching because the nullish value is expected to be set immediately for default props
|
|
953
963
|
// or they will be undefined.
|
|
954
964
|
sendDataToConnection(mapping, matchedKey);
|
|
955
965
|
return;
|
|
956
966
|
}
|
|
957
|
-
// When using a callback subscriber
|
|
958
|
-
//
|
|
959
|
-
// combined with a subscription to a collection key.
|
|
967
|
+
// When using a callback subscriber, a subscription to a collection key combines all matching
|
|
968
|
+
// member values into a single object and makes one call with the whole collection object.
|
|
960
969
|
if (typeof mapping.callback === 'function') {
|
|
961
970
|
if (OnyxKeys_1.default.isCollectionKey(mapping.key)) {
|
|
962
|
-
|
|
963
|
-
getCollectionDataAndSendAsObject(matchingKeys, mapping);
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
// We did not opt into using waitForCollectionCallback mode so the callback is called for every matching key.
|
|
967
|
-
multiGet(matchingKeys).then(() => {
|
|
968
|
-
for (const key of matchingKeys) {
|
|
969
|
-
sendDataToConnection(mapping, key);
|
|
970
|
-
}
|
|
971
|
-
});
|
|
971
|
+
getCollectionDataAndSendAsObject(matchingKeys, mapping);
|
|
972
972
|
return;
|
|
973
973
|
}
|
|
974
974
|
// If we are not subscribed to a collection key then there's only a single key to send an update for.
|
|
@@ -1117,6 +1117,7 @@ function setWithRetry({ key, value, options }, retryAttempt) {
|
|
|
1117
1117
|
return Promise.resolve();
|
|
1118
1118
|
}
|
|
1119
1119
|
return storage_1.default.setItem(key, valueWithoutNestedNullValues)
|
|
1120
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1120
1121
|
.catch((error) => OnyxUtils.retryOperation(error, setWithRetry, { key, value: valueWithoutNestedNullValues, options }, retryAttempt))
|
|
1121
1122
|
.then(() => {
|
|
1122
1123
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
|
|
@@ -1180,7 +1181,7 @@ function multiSetWithRetry(data, retryAttempt) {
|
|
|
1180
1181
|
// and subscriber state, matching the original per-key notification semantics.
|
|
1181
1182
|
OnyxCache_1.default.set(key, value);
|
|
1182
1183
|
// Skip subscriber notification on retry — already notified on attempt 0.
|
|
1183
|
-
//
|
|
1184
|
+
// Collection-root subscribers re-fire on every keyChanged by contract.
|
|
1184
1185
|
if (!retryAttempt) {
|
|
1185
1186
|
keyChanged(key, value);
|
|
1186
1187
|
}
|
|
@@ -1201,6 +1202,7 @@ function multiSetWithRetry(data, retryAttempt) {
|
|
|
1201
1202
|
});
|
|
1202
1203
|
const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
|
|
1203
1204
|
return storage_1.default.multiSet(keyValuePairsToStore)
|
|
1205
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1204
1206
|
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
|
|
1205
1207
|
.then(() => {
|
|
1206
1208
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
|
|
@@ -1258,7 +1260,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
|
|
|
1258
1260
|
for (const [key, value] of keyValuePairs)
|
|
1259
1261
|
OnyxCache_1.default.set(key, value);
|
|
1260
1262
|
// Skip subscriber notification on retry — already notified on attempt 0.
|
|
1261
|
-
//
|
|
1263
|
+
// Collection-root subscribers re-fire on every keysChanged by contract.
|
|
1262
1264
|
if (!retryAttempt) {
|
|
1263
1265
|
keysChanged(collectionKey, mutableCollection, previousCollection);
|
|
1264
1266
|
}
|
|
@@ -1269,6 +1271,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
|
|
|
1269
1271
|
}
|
|
1270
1272
|
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
|
|
1271
1273
|
return storage_1.default.multiSet(keyValuePairs)
|
|
1274
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1272
1275
|
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt, inFlightKeys))
|
|
1273
1276
|
.then(() => {
|
|
1274
1277
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
|
|
@@ -1380,7 +1383,7 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
|
|
|
1380
1383
|
const previousCollection = getCachedCollection(collectionKey, existingKeys);
|
|
1381
1384
|
OnyxCache_1.default.merge(finalMergedCollection);
|
|
1382
1385
|
// Skip subscriber notification on retry — already notified on attempt 0.
|
|
1383
|
-
//
|
|
1386
|
+
// Collection-root subscribers re-fire on every keysChanged by contract.
|
|
1384
1387
|
if (!retryAttempt) {
|
|
1385
1388
|
keysChanged(collectionKey, finalMergedCollection, previousCollection);
|
|
1386
1389
|
}
|
|
@@ -1398,7 +1401,13 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
|
|
|
1398
1401
|
}
|
|
1399
1402
|
const inFlightKeys = new Set(Object.keys(finalMergedCollection));
|
|
1400
1403
|
return Promise.all(promises)
|
|
1401
|
-
.
|
|
1404
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1405
|
+
.catch((error) => retryOperation(error, mergeCollectionWithPatches, {
|
|
1406
|
+
collectionKey,
|
|
1407
|
+
collection: resultCollection,
|
|
1408
|
+
mergeReplaceNullPatches,
|
|
1409
|
+
isProcessingCollectionUpdate,
|
|
1410
|
+
}, retryAttempt, inFlightKeys))
|
|
1402
1411
|
.then(() => {
|
|
1403
1412
|
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
|
|
1404
1413
|
});
|
|
@@ -1449,7 +1458,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
|
|
|
1449
1458
|
for (const [key, value] of keyValuePairs)
|
|
1450
1459
|
OnyxCache_1.default.set(key, value);
|
|
1451
1460
|
// Skip subscriber notification on retry — already notified on attempt 0.
|
|
1452
|
-
//
|
|
1461
|
+
// Collection-root subscribers re-fire on every keysChanged by contract.
|
|
1453
1462
|
if (!retryAttempt) {
|
|
1454
1463
|
keysChanged(collectionKey, mutableCollection, previousCollection);
|
|
1455
1464
|
}
|
|
@@ -1459,6 +1468,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
|
|
|
1459
1468
|
}
|
|
1460
1469
|
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
|
|
1461
1470
|
return storage_1.default.multiSet(keyValuePairs)
|
|
1471
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1462
1472
|
.catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt, inFlightKeys))
|
|
1463
1473
|
.then(() => {
|
|
1464
1474
|
sendActionToDevTools(METHOD.SET_COLLECTION, undefined, mutableCollection);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReadonlyDeep } from 'type-fest';
|
|
2
|
+
/**
|
|
3
|
+
* A directed transition graph keyed by state name.
|
|
4
|
+
* Use `as const` when defining a graph so illegal transitions are caught at compile time.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const transitions = {
|
|
8
|
+
* idle: ['loading'],
|
|
9
|
+
* loading: ['success', 'error'],
|
|
10
|
+
* success: [],
|
|
11
|
+
* error: ['idle'],
|
|
12
|
+
* } as const;
|
|
13
|
+
*/
|
|
14
|
+
type TransitionGraph = Readonly<Record<string, readonly string[]>>;
|
|
15
|
+
/** Target states reachable from `Current` according to `Graph`. */
|
|
16
|
+
type TransitionsFrom<Graph extends TransitionGraph, Current extends keyof Graph & string> = Graph[Current] extends ReadonlyArray<infer Target extends string> ? Target : never;
|
|
17
|
+
/**
|
|
18
|
+
* An immutable, type-safe finite state machine.
|
|
19
|
+
* Pass the transition graph with `as const` so `transition` only accepts legal target states.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const transitions = {
|
|
23
|
+
* idle: ['loading'],
|
|
24
|
+
* loading: ['success', 'error'],
|
|
25
|
+
* success: [],
|
|
26
|
+
* error: ['idle'],
|
|
27
|
+
* } as const;
|
|
28
|
+
*
|
|
29
|
+
* const idleMachine = new StateMachine('idle', transitions);
|
|
30
|
+
* const loadingMachine = idleMachine.transition('loading');
|
|
31
|
+
* loadingMachine.transition('success');
|
|
32
|
+
*/
|
|
33
|
+
declare class StateMachine<const Graph extends TransitionGraph, Current extends keyof Graph & string> {
|
|
34
|
+
/** The current state. Deeply readonly and owned by this state machine instance. */
|
|
35
|
+
readonly state: ReadonlyDeep<Current>;
|
|
36
|
+
private readonly transitions;
|
|
37
|
+
constructor(currentState: Current, transitions: Graph);
|
|
38
|
+
/**
|
|
39
|
+
* Transition to a new state, returning a new state machine instance.
|
|
40
|
+
* Only transitions declared in the graph for the current state are accepted.
|
|
41
|
+
*/
|
|
42
|
+
transition<Target extends TransitionsFrom<Graph, Current>>(target: Target): StateMachine<Graph, Target>;
|
|
43
|
+
}
|
|
44
|
+
export default StateMachine;
|
|
45
|
+
export type { TransitionGraph, TransitionsFrom };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* An immutable, type-safe finite state machine.
|
|
5
|
+
* Pass the transition graph with `as const` so `transition` only accepts legal target states.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const transitions = {
|
|
9
|
+
* idle: ['loading'],
|
|
10
|
+
* loading: ['success', 'error'],
|
|
11
|
+
* success: [],
|
|
12
|
+
* error: ['idle'],
|
|
13
|
+
* } as const;
|
|
14
|
+
*
|
|
15
|
+
* const idleMachine = new StateMachine('idle', transitions);
|
|
16
|
+
* const loadingMachine = idleMachine.transition('loading');
|
|
17
|
+
* loadingMachine.transition('success');
|
|
18
|
+
*/
|
|
19
|
+
class StateMachine {
|
|
20
|
+
constructor(currentState, transitions) {
|
|
21
|
+
this.state = currentState;
|
|
22
|
+
this.transitions = transitions;
|
|
23
|
+
Object.freeze(this);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Transition to a new state, returning a new state machine instance.
|
|
27
|
+
* Only transitions declared in the graph for the current state are accepted.
|
|
28
|
+
*/
|
|
29
|
+
transition(target) {
|
|
30
|
+
const validTargets = this.transitions[this.state];
|
|
31
|
+
if (!(validTargets === null || validTargets === void 0 ? void 0 : validTargets.includes(target))) {
|
|
32
|
+
throw new Error(`Illegal transition from "${String(this.state)}" to "${String(target)}"`);
|
|
33
|
+
}
|
|
34
|
+
return new StateMachine(target, this.transitions);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.default = StateMachine;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import AbstractCircuitBreaker from './CircuitBreaker/AbstractCircuitBreaker';
|
|
2
|
+
/**
|
|
3
|
+
* Process-scoped circuit breaker for storage CAPACITY failures.
|
|
4
|
+
*
|
|
5
|
+
* The per-operation retry budget in `OnyxUtils.retryOperation` cannot stop a session-level storm:
|
|
6
|
+
* each evict -> OnyxDerived recompute -> new write starts its own fresh budget, so a full disk or
|
|
7
|
+
* exhausted quota can drive tens of thousands of evict+retry cycles that never make progress and
|
|
8
|
+
* freeze the app. This breaker is the session-level brake — `retryOperation` consults it before
|
|
9
|
+
* every eviction.
|
|
10
|
+
*
|
|
11
|
+
* It is ONE circuit (closed/open/half-open) fed by TWO failure-counting policies, both evaluated in
|
|
12
|
+
* {@link recordFailureInClosed}. It trips when EITHER:
|
|
13
|
+
* - capacity failures within {@link ROLLING_WINDOW_MS} exceed {@link FAILURE_THRESHOLD}, or
|
|
14
|
+
* - {@link NO_PROGRESS_CAP} consecutive evictions are each immediately followed by another capacity
|
|
15
|
+
* failure (the eviction freed nothing the next write could use — a no-progress cycle). This is a
|
|
16
|
+
* cheap proxy for `getDatabaseSize()`, which is costly and only reports origin-wide usage.
|
|
17
|
+
*
|
|
18
|
+
* Keeping both policies inside a single state machine — rather than composing two independent breakers
|
|
19
|
+
* — is deliberate: two breakers each with their own open/half-open/probe latch cannot share one
|
|
20
|
+
* coherent circuit state without races (stranded half-open probes, storms uncounted while the other
|
|
21
|
+
* probes, cross-contaminated counters).
|
|
22
|
+
*
|
|
23
|
+
* On trip it emits exactly ONE alert per incident (across reopen cycles). After {@link ROLLING_WINDOW_MS}
|
|
24
|
+
* the circuit moves to half-open and admits a single eviction+retry probe; a successful probe closes
|
|
25
|
+
* the circuit, a failed probe reopens it for another window.
|
|
26
|
+
*/
|
|
27
|
+
declare class StorageCircuitBreaker extends AbstractCircuitBreaker {
|
|
28
|
+
/** Timestamps of capacity failures still inside the rolling window. */
|
|
29
|
+
private failureTimestamps;
|
|
30
|
+
/** Consecutive evictions that each failed to free usable space. */
|
|
31
|
+
private consecutiveNoProgress;
|
|
32
|
+
/** Set when an eviction's retry is pending, so the next capacity failure counts as no-progress. */
|
|
33
|
+
private evictionAwaitingResult;
|
|
34
|
+
/** Guards the single alert per incident (the open→half-open→open cycle must not re-alert). */
|
|
35
|
+
private hasTripped;
|
|
36
|
+
constructor();
|
|
37
|
+
/**
|
|
38
|
+
* Record a CAPACITY failure. Call once per capacity failure in `retryOperation`, BEFORE deciding
|
|
39
|
+
* whether to evict. Returns `true` when the breaker is open and eviction must not proceed.
|
|
40
|
+
*/
|
|
41
|
+
recordCapacityFailure(): boolean;
|
|
42
|
+
/** Record that `retryOperation` just evicted a key, so the next capacity failure counts as no-progress. */
|
|
43
|
+
recordEviction(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Record that a storage write succeeded. Fires on EVERY successful write, so it must only act on the
|
|
46
|
+
* one that carries capacity information: a write whose eviction was awaiting its verdict. Such a
|
|
47
|
+
* success means an eviction's retry actually landed — usable space was freed. In half-open that is
|
|
48
|
+
* the recovery probe succeeding (closes the circuit); in closed it clears the no-progress streak. A
|
|
49
|
+
* plain write that happens to succeed proves nothing about capacity and is a no-op (the common case).
|
|
50
|
+
*/
|
|
51
|
+
recordWriteSuccess(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Record that the half-open recovery probe failed. `retryOperation` calls this when a write is
|
|
54
|
+
* rejected while a probe is in flight — the storage is still full, so reopen for another window.
|
|
55
|
+
* No-op while fully open (recordFailure short-circuits) and harmless while closed.
|
|
56
|
+
*/
|
|
57
|
+
recordProbeFailure(): void;
|
|
58
|
+
/** Wipe all state back to a fresh closed circuit. Process-scoped, so reset between tests/sessions. */
|
|
59
|
+
reset(): void;
|
|
60
|
+
protected recordFailureInClosed(): string | null;
|
|
61
|
+
protected recordSuccessInClosed(): void;
|
|
62
|
+
protected resetFailureState(): void;
|
|
63
|
+
private handleTrip;
|
|
64
|
+
}
|
|
65
|
+
declare const _default: StorageCircuitBreaker;
|
|
66
|
+
export default _default;
|