UNPKG

react-native-onyx

Version:

State management for React Native

178 lines (177 loc) 8.84 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const AbstractCircuitBreaker_1 = __importDefault(require("./CircuitBreaker/AbstractCircuitBreaker")); const Logger = __importStar(require("./Logger")); /** Rolling window over which capacity failures are counted, and how long a trip stays open. */ const ROLLING_WINDOW_MS = 60 * 1000; /** Capacity failures within the window above which the breaker trips (storm backstop). */ const FAILURE_THRESHOLD = 50; /** Consecutive no-progress evictions (evict -> still capacity failure) above which the breaker trips. */ const NO_PROGRESS_CAP = 5; /** * Process-scoped circuit breaker for storage CAPACITY failures. * * The per-operation retry budget in `OnyxUtils.retryOperation` cannot stop a session-level storm: * each evict -> OnyxDerived recompute -> new write starts its own fresh budget, so a full disk or * exhausted quota can drive tens of thousands of evict+retry cycles that never make progress and * freeze the app. This breaker is the session-level brake — `retryOperation` consults it before * every eviction. * * It is ONE circuit (closed/open/half-open) fed by TWO failure-counting policies, both evaluated in * {@link recordFailureInClosed}. It trips when EITHER: * - capacity failures within {@link ROLLING_WINDOW_MS} exceed {@link FAILURE_THRESHOLD}, or * - {@link NO_PROGRESS_CAP} consecutive evictions are each immediately followed by another capacity * failure (the eviction freed nothing the next write could use — a no-progress cycle). This is a * cheap proxy for `getDatabaseSize()`, which is costly and only reports origin-wide usage. * * Keeping both policies inside a single state machine — rather than composing two independent breakers * — is deliberate: two breakers each with their own open/half-open/probe latch cannot share one * coherent circuit state without races (stranded half-open probes, storms uncounted while the other * probes, cross-contaminated counters). * * On trip it emits exactly ONE alert per incident (across reopen cycles). After {@link ROLLING_WINDOW_MS} * the circuit moves to half-open and admits a single eviction+retry probe; a successful probe closes * the circuit, a failed probe reopens it for another window. */ class StorageCircuitBreaker extends AbstractCircuitBreaker_1.default { constructor() { super({ resetTimeoutMs: ROLLING_WINDOW_MS, onTrip: (reason) => this.handleTrip(reason), onClose: () => { this.hasTripped = false; }, }); /** Timestamps of capacity failures still inside the rolling window. */ this.failureTimestamps = []; /** Consecutive evictions that each failed to free usable space. */ this.consecutiveNoProgress = 0; /** Set when an eviction's retry is pending, so the next capacity failure counts as no-progress. */ this.evictionAwaitingResult = false; /** Guards the single alert per incident (the open→half-open→open cycle must not re-alert). */ this.hasTripped = false; } /** * Record a CAPACITY failure. Call once per capacity failure in `retryOperation`, BEFORE deciding * whether to evict. Returns `true` when the breaker is open and eviction must not proceed. */ recordCapacityFailure() { // We only get here when isAllowed() admitted this caller. In half-open that means THIS is the // single recovery probe: the eviction+retry that follows is the actual test, so the capacity // failure that triggered it must not re-trip the circuit. Let it proceed; the probe's outcome // is the verdict — recordWriteSuccess (retry landed) closes, recordProbeFailure (retry failed, // re-entering retryOperation) reopens. if (this.peekState() === 'half-open') { return false; } return this.recordFailure(); } /** Record that `retryOperation` just evicted a key, so the next capacity failure counts as no-progress. */ recordEviction() { this.evictionAwaitingResult = true; } /** * Record that a storage write succeeded. Fires on EVERY successful write, so it must only act on the * one that carries capacity information: a write whose eviction was awaiting its verdict. Such a * success means an eviction's retry actually landed — usable space was freed. In half-open that is * the recovery probe succeeding (closes the circuit); in closed it clears the no-progress streak. A * plain write that happens to succeed proves nothing about capacity and is a no-op (the common case). */ recordWriteSuccess() { if (!this.evictionAwaitingResult) { return; } this.recordSuccess(); } /** * Record that the half-open recovery probe failed. `retryOperation` calls this when a write is * rejected while a probe is in flight — the storage is still full, so reopen for another window. * No-op while fully open (recordFailure short-circuits) and harmless while closed. */ recordProbeFailure() { this.recordFailure(); } /** Wipe all state back to a fresh closed circuit. Process-scoped, so reset between tests/sessions. */ reset() { this.hardReset(); this.hasTripped = false; } recordFailureInClosed() { const now = Date.now(); this.failureTimestamps = this.failureTimestamps.filter((timestamp) => now - timestamp < ROLLING_WINDOW_MS); // A fresh storm (nothing left in the window) resets the no-progress tracking so a stale eviction // from an earlier, unrelated incident can't be miscounted as no-progress for this one. if (this.failureTimestamps.length === 0) { this.consecutiveNoProgress = 0; this.evictionAwaitingResult = false; } // We evicted on the previous cycle and we're back here with another capacity failure, so that // eviction freed no usable space. if (this.evictionAwaitingResult) { this.consecutiveNoProgress += 1; this.evictionAwaitingResult = false; } this.failureTimestamps.push(now); if (this.failureTimestamps.length > FAILURE_THRESHOLD) { return `${this.failureTimestamps.length} capacity failures within ${ROLLING_WINDOW_MS / 1000}s`; } if (this.consecutiveNoProgress >= NO_PROGRESS_CAP) { return `${this.consecutiveNoProgress} consecutive evictions freed no usable space`; } return null; } recordSuccessInClosed() { // An eviction's retry succeeded: the eviction made progress, so break the no-progress streak. this.consecutiveNoProgress = 0; this.evictionAwaitingResult = false; } resetFailureState() { this.failureTimestamps = []; this.consecutiveNoProgress = 0; this.evictionAwaitingResult = false; } handleTrip(reason) { if (this.hasTripped) { return; } this.hasTripped = true; Logger.logAlert(`Storage circuit breaker tripped: ${reason}. Halting eviction/retry for ${ROLLING_WINDOW_MS / 1000}s to stop a storage failure storm.`); } } exports.default = new StorageCircuitBreaker();