react-native-onyx 3.0.86 → 3.0.88
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/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/OnyxUtils.d.ts +11 -5
- package/dist/OnyxUtils.js +87 -41
- 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 +85 -52
- 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/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CircuitBreakerOptions, CircuitBreakerState } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Generic circuit breaker built on {@link StateMachine}.
|
|
4
|
+
*
|
|
5
|
+
* - **closed**: requests are allowed; failures are counted.
|
|
6
|
+
* - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
|
|
7
|
+
* - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE
|
|
8
|
+
* probe request: success means the dependency recovered, so the circuit closes. Failure means it's
|
|
9
|
+
* still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where
|
|
10
|
+
* every caller fails loudly when the service hasn't recovered yet.
|
|
11
|
+
*
|
|
12
|
+
* Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
|
|
13
|
+
* friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
|
|
14
|
+
* combination of those.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* class MyBreaker extends AbstractCircuitBreaker {
|
|
18
|
+
* private failures = 0;
|
|
19
|
+
* protected recordFailureInClosed() {
|
|
20
|
+
* this.failures += 1;
|
|
21
|
+
* return this.failures >= 3 ? `${this.failures} failures` : null;
|
|
22
|
+
* }
|
|
23
|
+
* protected recordSuccessInClosed() { this.failures = 0; }
|
|
24
|
+
* protected resetFailureState() { this.failures = 0; }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* const breaker = new MyBreaker({resetTimeoutMs: 30_000});
|
|
28
|
+
* if (breaker.isAllowed()) {
|
|
29
|
+
* try {
|
|
30
|
+
* doWork();
|
|
31
|
+
* breaker.recordSuccess();
|
|
32
|
+
* } catch {
|
|
33
|
+
* breaker.recordFailure();
|
|
34
|
+
* }
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
declare abstract class AbstractCircuitBreaker {
|
|
38
|
+
private machine;
|
|
39
|
+
private openedAt;
|
|
40
|
+
private isProbeInFlight;
|
|
41
|
+
private readonly resetTimeoutMs;
|
|
42
|
+
private readonly onTrip?;
|
|
43
|
+
private readonly onClose?;
|
|
44
|
+
constructor(options?: CircuitBreakerOptions);
|
|
45
|
+
/** Record a failure while the circuit is closed. Returns a trip reason when the threshold is exceeded. */
|
|
46
|
+
protected abstract recordFailureInClosed(): string | null;
|
|
47
|
+
/** Update failure state after a successful request while the circuit is closed. */
|
|
48
|
+
protected abstract recordSuccessInClosed(): void;
|
|
49
|
+
/** Clear accumulated failure state without changing circuit state. */
|
|
50
|
+
protected abstract resetFailureState(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a request may proceed.
|
|
53
|
+
*
|
|
54
|
+
* Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
|
|
55
|
+
* `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
|
|
56
|
+
* (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
|
|
57
|
+
* the whole point of half-open: it tests recovery with one request instead of letting a herd of
|
|
58
|
+
* waiting callers stampede a dependency that may still be down.
|
|
59
|
+
*/
|
|
60
|
+
isAllowed(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Record a failed request. May open the circuit from closed or half-open.
|
|
63
|
+
* @returns `true` when the circuit is open after recording (the request must not proceed).
|
|
64
|
+
*/
|
|
65
|
+
recordFailure(): boolean;
|
|
66
|
+
/** Record a successful request. Closes the circuit from half-open and clears failure counts. */
|
|
67
|
+
recordSuccess(): void;
|
|
68
|
+
/**
|
|
69
|
+
* The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
|
|
70
|
+
* The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
|
|
71
|
+
* time a caller queries state after being admitted, that transition has already happened.
|
|
72
|
+
*/
|
|
73
|
+
peekState(): CircuitBreakerState;
|
|
74
|
+
/**
|
|
75
|
+
* Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
|
|
76
|
+
* deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
|
|
77
|
+
* all state — e.g. between tests or sessions.
|
|
78
|
+
*/
|
|
79
|
+
protected hardReset(): void;
|
|
80
|
+
private getCurrentState;
|
|
81
|
+
private trip;
|
|
82
|
+
private close;
|
|
83
|
+
/**
|
|
84
|
+
* Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
|
|
85
|
+
* (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
|
|
86
|
+
* the transition simply becomes visible to the next caller after the window. Entering half-open
|
|
87
|
+
* clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
|
|
88
|
+
*/
|
|
89
|
+
private maybeRecover;
|
|
90
|
+
}
|
|
91
|
+
export default AbstractCircuitBreaker;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const StateMachine_1 = __importDefault(require("../StateMachine"));
|
|
7
|
+
const types_1 = require("./types");
|
|
8
|
+
/**
|
|
9
|
+
* Generic circuit breaker built on {@link StateMachine}.
|
|
10
|
+
*
|
|
11
|
+
* - **closed**: requests are allowed; failures are counted.
|
|
12
|
+
* - **open**: requests are rejected until {@link resetTimeoutMs} elapses.
|
|
13
|
+
* - **half-open**: the recovery-probe state. After the open timeout, the breaker admits exactly ONE
|
|
14
|
+
* probe request: success means the dependency recovered, so the circuit closes. Failure means it's
|
|
15
|
+
* still down, so the circuit reopens. This single-request probe prevents a "thundering herd" where
|
|
16
|
+
* every caller fails loudly when the service hasn't recovered yet.
|
|
17
|
+
*
|
|
18
|
+
* Subclasses implement the failure-counting policy by overriding {@link recordFailureInClosed} (and
|
|
19
|
+
* friends) — e.g. counting consecutive failures, or failures within a rolling time window, or any
|
|
20
|
+
* combination of those.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* class MyBreaker extends AbstractCircuitBreaker {
|
|
24
|
+
* private failures = 0;
|
|
25
|
+
* protected recordFailureInClosed() {
|
|
26
|
+
* this.failures += 1;
|
|
27
|
+
* return this.failures >= 3 ? `${this.failures} failures` : null;
|
|
28
|
+
* }
|
|
29
|
+
* protected recordSuccessInClosed() { this.failures = 0; }
|
|
30
|
+
* protected resetFailureState() { this.failures = 0; }
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* const breaker = new MyBreaker({resetTimeoutMs: 30_000});
|
|
34
|
+
* if (breaker.isAllowed()) {
|
|
35
|
+
* try {
|
|
36
|
+
* doWork();
|
|
37
|
+
* breaker.recordSuccess();
|
|
38
|
+
* } catch {
|
|
39
|
+
* breaker.recordFailure();
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
*/
|
|
43
|
+
class AbstractCircuitBreaker {
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
var _a;
|
|
46
|
+
this.openedAt = 0;
|
|
47
|
+
this.isProbeInFlight = false;
|
|
48
|
+
this.resetTimeoutMs = (_a = options.resetTimeoutMs) !== null && _a !== void 0 ? _a : 60000;
|
|
49
|
+
this.onTrip = options.onTrip;
|
|
50
|
+
this.onClose = options.onClose;
|
|
51
|
+
this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a request may proceed.
|
|
55
|
+
*
|
|
56
|
+
* Returns `false` while open. In half-open, the FIRST caller is admitted as the recovery probe and
|
|
57
|
+
* `isProbeInFlight` is latched so every subsequent caller is rejected until that probe resolves
|
|
58
|
+
* (via {@link recordSuccess} → close, or {@link recordFailure} → reopen). That single-probe gate is
|
|
59
|
+
* the whole point of half-open: it tests recovery with one request instead of letting a herd of
|
|
60
|
+
* waiting callers stampede a dependency that may still be down.
|
|
61
|
+
*/
|
|
62
|
+
isAllowed() {
|
|
63
|
+
const currentState = this.getCurrentState();
|
|
64
|
+
if (currentState === 'open') {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (currentState === 'half-open') {
|
|
68
|
+
if (this.isProbeInFlight) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
this.isProbeInFlight = true;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Record a failed request. May open the circuit from closed or half-open.
|
|
77
|
+
* @returns `true` when the circuit is open after recording (the request must not proceed).
|
|
78
|
+
*/
|
|
79
|
+
recordFailure() {
|
|
80
|
+
if (this.machine.state === 'open') {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (this.machine.state === 'half-open') {
|
|
84
|
+
this.trip();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const reason = this.recordFailureInClosed();
|
|
88
|
+
if (reason) {
|
|
89
|
+
this.trip(reason);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/** Record a successful request. Closes the circuit from half-open and clears failure counts. */
|
|
95
|
+
recordSuccess() {
|
|
96
|
+
if (this.machine.state === 'half-open') {
|
|
97
|
+
this.close();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (this.machine.state === 'closed') {
|
|
101
|
+
this.recordSuccessInClosed();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The current state WITHOUT advancing recovery — a pure query, safe to call without side effects.
|
|
106
|
+
* The open→half-open transition is applied only at the admission point ({@link isAllowed}); by the
|
|
107
|
+
* time a caller queries state after being admitted, that transition has already happened.
|
|
108
|
+
*/
|
|
109
|
+
peekState() {
|
|
110
|
+
return this.machine.state;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Force the circuit back to closed from ANY state. This is a reset, not a transition, so it
|
|
114
|
+
* deliberately bypasses the transition graph (and does not fire {@link onClose}). Use only to wipe
|
|
115
|
+
* all state — e.g. between tests or sessions.
|
|
116
|
+
*/
|
|
117
|
+
hardReset() {
|
|
118
|
+
this.machine = new StateMachine_1.default('closed', types_1.CIRCUIT_BREAKER_TRANSITIONS);
|
|
119
|
+
this.openedAt = 0;
|
|
120
|
+
this.isProbeInFlight = false;
|
|
121
|
+
this.resetFailureState();
|
|
122
|
+
}
|
|
123
|
+
getCurrentState() {
|
|
124
|
+
this.maybeRecover();
|
|
125
|
+
return this.machine.state;
|
|
126
|
+
}
|
|
127
|
+
trip(reason = '') {
|
|
128
|
+
var _a;
|
|
129
|
+
if (this.machine.state === 'open') {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.machine = this.machine.transition('open');
|
|
133
|
+
this.openedAt = Date.now();
|
|
134
|
+
this.isProbeInFlight = false;
|
|
135
|
+
this.resetFailureState();
|
|
136
|
+
(_a = this.onTrip) === null || _a === void 0 ? void 0 : _a.call(this, reason);
|
|
137
|
+
}
|
|
138
|
+
close() {
|
|
139
|
+
var _a;
|
|
140
|
+
// close() only ever runs from half-open (see recordSuccess), and half-open → closed is the one
|
|
141
|
+
// legal closing transition — so go through transition() to keep the illegal open → closed jump
|
|
142
|
+
// an error rather than silently constructing a fresh closed machine.
|
|
143
|
+
this.machine = this.machine.transition('closed');
|
|
144
|
+
this.openedAt = 0;
|
|
145
|
+
this.isProbeInFlight = false;
|
|
146
|
+
this.resetFailureState();
|
|
147
|
+
(_a = this.onClose) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Lazily advance open → half-open once the reset timeout has elapsed. This is checked on read
|
|
151
|
+
* (via {@link getCurrentState}) rather than on a timer, so there's nothing to schedule or clean up:
|
|
152
|
+
* the transition simply becomes visible to the next caller after the window. Entering half-open
|
|
153
|
+
* clears `isProbeInFlight` so the next admitted request becomes the recovery probe.
|
|
154
|
+
*/
|
|
155
|
+
maybeRecover() {
|
|
156
|
+
if (this.machine.state !== 'open') {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() - this.openedAt < this.resetTimeoutMs) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.machine = this.machine.transition('half-open');
|
|
163
|
+
this.isProbeInFlight = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
exports.default = AbstractCircuitBreaker;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* States of the circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* - **closed**: normal operation; requests flow and failures are counted.
|
|
5
|
+
* - **open**: tripped; requests are rejected outright so a known-bad dependency isn't hammered.
|
|
6
|
+
* - **half-open**: a trial state entered after the open timeout — see {@link CIRCUIT_BREAKER_TRANSITIONS}.
|
|
7
|
+
*/
|
|
8
|
+
type CircuitBreakerState = 'closed' | 'open' | 'half-open';
|
|
9
|
+
/**
|
|
10
|
+
* Legal state transitions. The flow is closed → open → half-open → (closed | open).
|
|
11
|
+
*
|
|
12
|
+
* The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
|
|
13
|
+
* flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
|
|
14
|
+
* still down, immediately re-admit the full load and re-trip — flapping between open and closed every
|
|
15
|
+
* window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
|
|
16
|
+
* ("probe") request:
|
|
17
|
+
* - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
|
|
18
|
+
* - probe fails → still broken → transition back to **open** for another timeout window.
|
|
19
|
+
*
|
|
20
|
+
* Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
|
|
21
|
+
* "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
|
|
22
|
+
* dependency that was just starting to recover.
|
|
23
|
+
*/
|
|
24
|
+
declare const CIRCUIT_BREAKER_TRANSITIONS: {
|
|
25
|
+
readonly closed: readonly ["open"];
|
|
26
|
+
readonly open: readonly ["half-open"];
|
|
27
|
+
readonly 'half-open': readonly ["closed", "open"];
|
|
28
|
+
};
|
|
29
|
+
type CircuitBreakerOptions = {
|
|
30
|
+
/** Time in milliseconds the circuit stays open before moving to half-open. */
|
|
31
|
+
resetTimeoutMs?: number;
|
|
32
|
+
/** Called once each time the circuit opens. */
|
|
33
|
+
onTrip?: (reason: string) => void;
|
|
34
|
+
/** Called when the circuit closes. */
|
|
35
|
+
onClose?: () => void;
|
|
36
|
+
};
|
|
37
|
+
export type { CircuitBreakerOptions, CircuitBreakerState };
|
|
38
|
+
export { CIRCUIT_BREAKER_TRANSITIONS };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CIRCUIT_BREAKER_TRANSITIONS = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Legal state transitions. The flow is closed → open → half-open → (closed | open).
|
|
6
|
+
*
|
|
7
|
+
* The **half-open** state exists so the breaker can test whether the dependency has recovered WITHOUT
|
|
8
|
+
* flipping straight back to fully closed. Going open → closed blindly would, on a dependency that is
|
|
9
|
+
* still down, immediately re-admit the full load and re-trip — flapping between open and closed every
|
|
10
|
+
* window. Instead, after the open timeout the breaker moves to half-open and admits a single trial
|
|
11
|
+
* ("probe") request:
|
|
12
|
+
* - probe succeeds → the dependency is healthy again → transition to **closed** (resume normal flow).
|
|
13
|
+
* - probe fails → still broken → transition back to **open** for another timeout window.
|
|
14
|
+
*
|
|
15
|
+
* Admitting exactly one probe (rather than reopening the floodgates) is also what prevents the
|
|
16
|
+
* "thundering herd": many callers retrying at once the instant the timeout elapses, re-overwhelming a
|
|
17
|
+
* dependency that was just starting to recover.
|
|
18
|
+
*/
|
|
19
|
+
const CIRCUIT_BREAKER_TRANSITIONS = {
|
|
20
|
+
closed: ['open'],
|
|
21
|
+
open: ['half-open'],
|
|
22
|
+
'half-open': ['closed', 'open'],
|
|
23
|
+
};
|
|
24
|
+
exports.CIRCUIT_BREAKER_TRANSITIONS = CIRCUIT_BREAKER_TRANSITIONS;
|
package/dist/OnyxUtils.d.ts
CHANGED
|
@@ -134,11 +134,17 @@ declare function getCollectionDataAndSendAsObject<TKey extends OnyxKey>(matching
|
|
|
134
134
|
declare function remove<TKey extends OnyxKey>(key: TKey, isProcessingCollectionUpdate?: boolean): Promise<void>;
|
|
135
135
|
declare function reportStorageQuota(error?: Error): Promise<void>;
|
|
136
136
|
/**
|
|
137
|
-
* Handles storage operation failures based on the error
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* -
|
|
141
|
-
* -
|
|
137
|
+
* Handles storage operation failures based on the error class (see lib/storage/errors.ts).
|
|
138
|
+
* The connection layer (createStore) owns connection/transport recovery; this operation layer owns
|
|
139
|
+
* capacity recovery (eviction) so that a given failure is retried by exactly one layer:
|
|
140
|
+
* - INVALID_DATA: logs an alert and throws (the same data will always fail).
|
|
141
|
+
* - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
|
|
142
|
+
* and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
|
|
143
|
+
* - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
|
|
144
|
+
* circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
|
|
145
|
+
* progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
|
|
146
|
+
* - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
|
|
147
|
+
* provider) once so it's visible, then bounded retry without eviction.
|
|
142
148
|
*/
|
|
143
149
|
declare function retryOperation<TMethod extends RetriableOnyxOperation>(error: Error, onyxMethod: TMethod, defaultParams: Parameters<TMethod>[0], retryAttempt: number | undefined, inFlightKeys?: Set<OnyxKey>): Promise<void>;
|
|
144
150
|
/**
|
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
|
|
@@ -496,7 +482,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
|
|
|
496
482
|
continue;
|
|
497
483
|
}
|
|
498
484
|
try {
|
|
499
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
485
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
486
|
+
value: cachedCollection,
|
|
487
|
+
matchedKey: subscriber.key,
|
|
488
|
+
});
|
|
500
489
|
if (subscriber.waitForCollectionCallback) {
|
|
501
490
|
subscriber.callback(cachedCollection, subscriber.key, partialCollection);
|
|
502
491
|
continue;
|
|
@@ -533,7 +522,10 @@ function keysChanged(collectionKey, partialCollection, partialPreviousCollection
|
|
|
533
522
|
try {
|
|
534
523
|
const subscriberCallback = subscriber.callback;
|
|
535
524
|
subscriberCallback(cachedCollection[subscriber.key], subscriber.key);
|
|
536
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
525
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
526
|
+
value: cachedCollection[subscriber.key],
|
|
527
|
+
matchedKey: subscriber.key,
|
|
528
|
+
});
|
|
537
529
|
}
|
|
538
530
|
catch (error) {
|
|
539
531
|
Logger.logAlert(`[OnyxUtils.keysChanged] Subscriber callback threw an error for key '${collectionKey}': ${error}`);
|
|
@@ -595,13 +587,21 @@ function keyChanged(key, value, canUpdateSubscriber = () => true, isProcessingCo
|
|
|
595
587
|
cachedCollection = getCachedCollection(subscriber.key);
|
|
596
588
|
cachedCollections[subscriber.key] = cachedCollection;
|
|
597
589
|
}
|
|
598
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
599
|
-
|
|
590
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
591
|
+
value: cachedCollection,
|
|
592
|
+
matchedKey: subscriber.key,
|
|
593
|
+
});
|
|
594
|
+
subscriber.callback(cachedCollection, subscriber.key, {
|
|
595
|
+
[key]: value,
|
|
596
|
+
});
|
|
600
597
|
continue;
|
|
601
598
|
}
|
|
602
599
|
const subscriberCallback = subscriber.callback;
|
|
603
600
|
subscriberCallback(value, key);
|
|
604
|
-
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
601
|
+
lastConnectionCallbackData.set(subscriber.subscriptionID, {
|
|
602
|
+
value,
|
|
603
|
+
matchedKey: key,
|
|
604
|
+
});
|
|
605
605
|
continue;
|
|
606
606
|
}
|
|
607
607
|
catch (error) {
|
|
@@ -666,44 +666,73 @@ function remove(key, isProcessingCollectionUpdate) {
|
|
|
666
666
|
function reportStorageQuota(error) {
|
|
667
667
|
return storage_1.default.getDatabaseSize()
|
|
668
668
|
.then(({ bytesUsed, bytesRemaining, usageDetails }) => {
|
|
669
|
-
|
|
669
|
+
// `bytesRemaining` comes from navigator.storage.estimate() and is an ORIGIN-WIDE estimate,
|
|
670
|
+
// not headroom for this database. The browser allocates IndexedDB storage dynamically, so a
|
|
671
|
+
// QuotaExceededError can legitimately occur even when this number still looks large.
|
|
672
|
+
Logger.logInfo(`Storage Quota Check -- bytesUsed: ${bytesUsed} originWideBytesRemaining (estimate, not per-DB headroom): ${bytesRemaining}${usageDetails ? ` usageDetails: ${JSON.stringify(usageDetails)}` : ''}. Original error: ${error}`);
|
|
670
673
|
})
|
|
671
674
|
.catch((dbSizeError) => {
|
|
672
675
|
Logger.logAlert(`Unable to get database size. getDatabaseSize error: ${dbSizeError}. Original error: ${error}`);
|
|
673
676
|
});
|
|
674
677
|
}
|
|
675
678
|
/**
|
|
676
|
-
* Handles storage operation failures based on the error
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* -
|
|
680
|
-
* -
|
|
679
|
+
* Handles storage operation failures based on the error class (see lib/storage/errors.ts).
|
|
680
|
+
* The connection layer (createStore) owns connection/transport recovery; this operation layer owns
|
|
681
|
+
* capacity recovery (eviction) so that a given failure is retried by exactly one layer:
|
|
682
|
+
* - INVALID_DATA: logs an alert and throws (the same data will always fail).
|
|
683
|
+
* - TRANSIENT / FATAL: the connection layer already retried (transient) or exhausted its heal budget
|
|
684
|
+
* and alerted (fatal). Retrying here would only re-amplify, so we skip the write quietly.
|
|
685
|
+
* - CAPACITY: evicts the least recently accessed evictable key and retries, under a session-level
|
|
686
|
+
* circuit breaker (see lib/StorageCircuitBreaker.ts) that halts the loop once eviction stops making
|
|
687
|
+
* progress or failures storm — the per-operation budget alone cannot stop a session-wide storm.
|
|
688
|
+
* - UNKNOWN: the provider couldn't classify it — log the full error shape (name + message +
|
|
689
|
+
* provider) once so it's visible, then bounded retry without eviction.
|
|
681
690
|
*/
|
|
682
691
|
function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlightKeys) {
|
|
683
|
-
var _a, _b, _c, _d;
|
|
684
692
|
const currentRetryAttempt = retryAttempt !== null && retryAttempt !== void 0 ? retryAttempt : 0;
|
|
685
693
|
const nextRetryAttempt = currentRetryAttempt + 1;
|
|
686
|
-
|
|
687
|
-
|
|
694
|
+
const errorClass = storage_1.default.classifyError(error);
|
|
695
|
+
// While open (or while a half-open probe is already in flight), drop capacity retries silently —
|
|
696
|
+
// the breaker already emitted its single alert, and logging per failed write is exactly the storm
|
|
697
|
+
// we are suppressing. A rejected half-open caller is the in-flight probe failing; record that so
|
|
698
|
+
// the circuit reopens for another window. (We return before the log line below on purpose.)
|
|
699
|
+
if (errorClass === errors_1.StorageErrorClass.CAPACITY && !StorageCircuitBreaker_1.default.isAllowed()) {
|
|
700
|
+
StorageCircuitBreaker_1.default.recordProbeFailure();
|
|
701
|
+
return Promise.resolve();
|
|
702
|
+
}
|
|
703
|
+
Logger.logInfo(`Failed to save to storage. Error: ${error}. class: ${errorClass}. onyxMethod: ${onyxMethod.name}. retryAttempt: ${currentRetryAttempt}/${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS}`);
|
|
704
|
+
if (errorClass === errors_1.StorageErrorClass.INVALID_DATA) {
|
|
688
705
|
Logger.logAlert(`Attempted to set invalid data set in Onyx. Please ensure all data is serializable. Error: ${error}`);
|
|
689
706
|
throw error;
|
|
690
707
|
}
|
|
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}.`);
|
|
708
|
+
if (errorClass === errors_1.StorageErrorClass.TRANSIENT || errorClass === errors_1.StorageErrorClass.FATAL) {
|
|
709
|
+
Logger.logInfo(`Storage operation skipped retry; ${errorClass} errors are handled by the connection layer. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
|
|
697
710
|
return Promise.resolve();
|
|
698
711
|
}
|
|
699
712
|
if (nextRetryAttempt > MAX_STORAGE_OPERATION_RETRY_ATTEMPTS) {
|
|
700
|
-
Logger.logAlert(`Storage operation failed after
|
|
713
|
+
Logger.logAlert(`Storage operation failed after ${MAX_STORAGE_OPERATION_RETRY_ATTEMPTS} retries. Error: ${error}. onyxMethod: ${onyxMethod.name}.`);
|
|
701
714
|
return Promise.resolve();
|
|
702
715
|
}
|
|
703
|
-
if (
|
|
716
|
+
if (errorClass === errors_1.StorageErrorClass.UNKNOWN) {
|
|
717
|
+
// UNKNOWN is the blind spot: the active provider's classifier did not recognize this error, so
|
|
718
|
+
// we cannot route it to a real recovery strategy. Log the full error shape (name + message +
|
|
719
|
+
// provider) once per operation so telemetry can reveal what lives in UNKNOWN, letting us promote
|
|
720
|
+
// recurring cases into TRANSIENT/CAPACITY/FATAL. Logged on the first attempt only to avoid the
|
|
721
|
+
// per-retry amplification this mechanism is trying to kill. Then bounded retry without eviction.
|
|
722
|
+
if (currentRetryAttempt === 0) {
|
|
723
|
+
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}.`);
|
|
724
|
+
}
|
|
704
725
|
// @ts-expect-error No overload matches this call.
|
|
705
726
|
return onyxMethod(defaultParams, nextRetryAttempt);
|
|
706
727
|
}
|
|
728
|
+
// CAPACITY: feed the session-level circuit breaker before evicting. The per-operation budget above
|
|
729
|
+
// cannot stop a session-wide storm — each evicted key triggers an OnyxDerived recompute that spawns
|
|
730
|
+
// a fresh write with its own budget — so the breaker is what actually halts the meltdown. (The
|
|
731
|
+
// already-open case returned silently at the top of this function.)
|
|
732
|
+
if (StorageCircuitBreaker_1.default.recordCapacityFailure()) {
|
|
733
|
+
// This failure tripped the breaker; it already emitted its single alert. Stop here.
|
|
734
|
+
return Promise.resolve();
|
|
735
|
+
}
|
|
707
736
|
// Find the least recently accessed evictable key that we can remove. Never evict an in-flight
|
|
708
737
|
// key — its cache value is the merge base this retry depends on, so dropping it would truncate
|
|
709
738
|
// the write to just the delta and diverge cache from storage.
|
|
@@ -718,8 +747,15 @@ function retryOperation(error, onyxMethod, defaultParams, retryAttempt, inFlight
|
|
|
718
747
|
// Remove the least recently accessed key and retry.
|
|
719
748
|
Logger.logInfo(`Out of storage. Evicting least recently accessed key (${keyForRemoval}) and retrying. Error: ${error}`);
|
|
720
749
|
reportStorageQuota(error);
|
|
721
|
-
|
|
722
|
-
|
|
750
|
+
return remove(keyForRemoval).then(() => {
|
|
751
|
+
// Mark the eviction only once the deletion has actually completed, immediately before the
|
|
752
|
+
// retry it pairs with. Recording earlier lets a concurrent write's capacity failure consume
|
|
753
|
+
// the marker as a no-progress cycle while this deletion is still pending and may yet free
|
|
754
|
+
// space — so the verdict belongs to the retry that follows the deletion, not the eviction call.
|
|
755
|
+
StorageCircuitBreaker_1.default.recordEviction();
|
|
756
|
+
// @ts-expect-error No overload matches this call.
|
|
757
|
+
return onyxMethod(defaultParams, nextRetryAttempt);
|
|
758
|
+
});
|
|
723
759
|
}
|
|
724
760
|
/**
|
|
725
761
|
* Notifies subscribers and writes current value to cache
|
|
@@ -1117,6 +1153,7 @@ function setWithRetry({ key, value, options }, retryAttempt) {
|
|
|
1117
1153
|
return Promise.resolve();
|
|
1118
1154
|
}
|
|
1119
1155
|
return storage_1.default.setItem(key, valueWithoutNestedNullValues)
|
|
1156
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1120
1157
|
.catch((error) => OnyxUtils.retryOperation(error, setWithRetry, { key, value: valueWithoutNestedNullValues, options }, retryAttempt))
|
|
1121
1158
|
.then(() => {
|
|
1122
1159
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET, key, valueWithoutNestedNullValues);
|
|
@@ -1201,6 +1238,7 @@ function multiSetWithRetry(data, retryAttempt) {
|
|
|
1201
1238
|
});
|
|
1202
1239
|
const inFlightKeys = new Set(keyValuePairsToSet.map(([key]) => key));
|
|
1203
1240
|
return storage_1.default.multiSet(keyValuePairsToStore)
|
|
1241
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1204
1242
|
.catch((error) => OnyxUtils.retryOperation(error, multiSetWithRetry, newData, retryAttempt, inFlightKeys))
|
|
1205
1243
|
.then(() => {
|
|
1206
1244
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.MULTI_SET, undefined, newData);
|
|
@@ -1269,6 +1307,7 @@ function setCollectionWithRetry({ collectionKey, collection }, retryAttempt) {
|
|
|
1269
1307
|
}
|
|
1270
1308
|
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
|
|
1271
1309
|
return storage_1.default.multiSet(keyValuePairs)
|
|
1310
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1272
1311
|
.catch((error) => OnyxUtils.retryOperation(error, setCollectionWithRetry, { collectionKey, collection }, retryAttempt, inFlightKeys))
|
|
1273
1312
|
.then(() => {
|
|
1274
1313
|
OnyxUtils.sendActionToDevTools(OnyxUtils.METHOD.SET_COLLECTION, undefined, mutableCollection);
|
|
@@ -1398,7 +1437,13 @@ function mergeCollectionWithPatches({ collectionKey, collection, mergeReplaceNul
|
|
|
1398
1437
|
}
|
|
1399
1438
|
const inFlightKeys = new Set(Object.keys(finalMergedCollection));
|
|
1400
1439
|
return Promise.all(promises)
|
|
1401
|
-
.
|
|
1440
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1441
|
+
.catch((error) => retryOperation(error, mergeCollectionWithPatches, {
|
|
1442
|
+
collectionKey,
|
|
1443
|
+
collection: resultCollection,
|
|
1444
|
+
mergeReplaceNullPatches,
|
|
1445
|
+
isProcessingCollectionUpdate,
|
|
1446
|
+
}, retryAttempt, inFlightKeys))
|
|
1402
1447
|
.then(() => {
|
|
1403
1448
|
sendActionToDevTools(METHOD.MERGE_COLLECTION, undefined, resultCollection);
|
|
1404
1449
|
});
|
|
@@ -1459,6 +1504,7 @@ function partialSetCollection({ collectionKey, collection }, retryAttempt) {
|
|
|
1459
1504
|
}
|
|
1460
1505
|
const inFlightKeys = new Set(keyValuePairs.map(([key]) => key));
|
|
1461
1506
|
return storage_1.default.multiSet(keyValuePairs)
|
|
1507
|
+
.then(() => StorageCircuitBreaker_1.default.recordWriteSuccess())
|
|
1462
1508
|
.catch((error) => retryOperation(error, partialSetCollection, { collectionKey, collection }, retryAttempt, inFlightKeys))
|
|
1463
1509
|
.then(() => {
|
|
1464
1510
|
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 };
|