From 8f6e468361c8d6d328a2b2722dab6e8c8f517cf2 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 9 Jun 2026 15:27:48 +0200 Subject: [PATCH] perf(persistence): dedup MMKV snapshot writes + make derivation counter volatile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root onSnapshot listener wrote the whole MST snapshot to MMKV on every action, even when postProcessSnapshot stripped the only changed field (proofs, transactions, counters, tokens) to a byte-identical payload — MST still fires onSnapshot and re-serializes regardless. During wallet transactions this produced a storm of redundant whole-tree writes. Tier A: guard the write by comparing the serialized payload against the last one and skipping identical writes (stringify once, persist the raw string via saveString to avoid a second serialize). Measured ~77% of writes eliminated in steady-state operation at ~0.25ms/fire overhead. Behind a toggleable PROFILE_SNAPSHOT_PERSISTENCE constant: cumulative invocations/writes/skipped/ totalMs trace, off by default. Tier B: move Mint derivation `counter` from a persisted prop (stripped to 0 in postProcessSnapshot) to volatile state, so per-derivation bumps never invalidate the root snapshot at all. The counter is already SQLite-mastered (mint_counters) and hydrated on startup. Reconciled the paths that fed counters through a snapshot — backup export re-injects live.counter (unchanged), import and the v<33 migration now seed SQLite from the raw snapshot then re-hydrate the cache (applySnapshot no longer loads the volatile value). Removed the now-dead seedCountersToDatabase. No model version bump required. Co-Authored-By: Claude Opus 4.8 --- src/models/Mint.ts | 27 +++++---- src/models/MintsStore.ts | 28 --------- src/models/helpers/setupRootStore.ts | 88 ++++++++++++++++++++++++---- src/screens/ImportBackupScreen.tsx | 27 +++++++-- 4 files changed, 115 insertions(+), 55 deletions(-) diff --git a/src/models/Mint.ts b/src/models/Mint.ts index 09dfd214..f6deb190 100644 --- a/src/models/Mint.ts +++ b/src/models/Mint.ts @@ -50,7 +50,9 @@ export type InFlightRequest = { // {keyset, unit, counter}. const migrateSnapshot = (snapshot: any): any => { if (!snapshot) return snapshot - const {inFlightRequests, meltCounterValues, ...rest} = snapshot + // `counter` is now VOLATILE (mastered in SQLite, see model below) — drop it + // from any incoming snapshot so applySnapshot never carries a stale value. + const {inFlightRequests, meltCounterValues, counter, ...rest} = snapshot return rest } @@ -107,8 +109,19 @@ export const MintProofsCounterModel = types .model('MintProofsCounter', { keyset: types.string, unit: types.optional(types.frozen(), 'sat'), - counter: types.optional(types.number, 0), }) + // The derivation counter is mastered in SQLite (mint_counters) and kept here + // only as an in-memory cache, hydrated from the authority on startup/resume. + // It is VOLATILE — deliberately NOT part of the MST snapshot — so the many + // per-derivation bumps during a wallet transaction never invalidate the root + // snapshot, and therefore never trigger a serialize + MMKV write. It used to + // be a persisted prop stripped to 0 in postProcessSnapshot, but MST still + // fired onSnapshot (and thus a redundant whole-tree write) on every bump. + // Persistence of the real value happens through the SQLite write-through in + // the counter actions below; this field is a cache only. + .volatile(() => ({ + counter: 0, + })) .preProcessSnapshot(migrateSnapshot) .actions(self => ({ // === Counter mutations (write through to the SQLite authority) === @@ -140,16 +153,6 @@ export const MintProofsCounterModel = types } }, })) - // The derivation counter is mastered in SQLite (mint_counters), hydrated - // into this model as an in-memory cache on startup/resume. Strip it from - // every persisted snapshot so the MMKV whole-tree save can never write a - // stale value back over the SQLite authority — exactly as ProofsStore strips - // `proofs`. Consumers that legitimately need the value (backup export, - // counter backups) re-inject it from the live model / SQLite. - .postProcessSnapshot(snapshot => ({ - ...snapshot, - counter: 0, - })) export type MintProofsCounter = Instance diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index 1aef2ea2..c939320d 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -11,7 +11,6 @@ import { import {MintModel, Mint} from './Mint' import {log} from '../services/logService' import {Database} from '../services' - import type {CounterSeed} from '../services/db' import AppError, { Err } from '../utils/AppError' import { Mint as CashuMint, @@ -64,33 +63,6 @@ export const MintsStoreModel = types const mint = self.mints.find(m => m.mintUrl.replace(/\/$/, '') === normalized) if(mint) {return true} else {return false} }, - /** - * One-time copy of the in-memory (MMKV-loaded) derivation counters into - * SQLite. Run from _runMigrations on upgrade, and on backup import. - * - * Two safeguards make this safe even on a device that has ALREADY - * migrated (SQLite populated, MMKV stripped to 0, model not yet - * re-hydrated this launch): - * 1. only counters that have actually advanced (> 0) are seeded, so a - * stripped/zero value is never written; and - * 2. the repo upsert is monotonic (MAX), so a seed can never lower an - * existing SQLite counter. - */ - seedCountersToDatabase() { - const seeds: CounterSeed[] = [] - - for (const mint of self.mints) { - for (const c of mint.proofsCounters) { - if (c.counter > 0) { - seeds.push({mintUrl: mint.mintUrl, keysetId: c.keyset, unit: c.unit, counter: c.counter}) - } - } - } - - if (seeds.length > 0) { - Database.seedCounters(seeds) - } - }, /** * Load the authoritative counter values from SQLite into the in-memory * cache (startup / foreground resume). Monotonic per counter, so a value diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts index ae894121..75d838bf 100644 --- a/src/models/helpers/setupRootStore.ts +++ b/src/models/helpers/setupRootStore.ts @@ -152,8 +152,61 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO } if (snapshotApplied) { + // Skip MMKV writes whose serialized payload is identical to the last one. + // postProcessSnapshot strips ephemeral, SQLite/KeyChain-mastered data + // (proofs, transactions, derivation counters, tokens…) from the saved + // snapshot, but MST still fires onSnapshot on every action that touches + // those fields — each producing a byte-identical payload. Comparing the + // serialized string here collapses that storm of redundant writes during + // wallet transactions into a single write when something persisted + // actually changes. We stringify once and persist the raw string so the + // write path doesn't serialize a second time. + let lastSerialized = MMKVStorage.loadString(ROOT_STORAGE_KEY) + + // Flip to true to trace snapshot-persistence profiling. When off, the + // callback is just the lean equality-guarded write (the counters and + // performance.now() calls below never run). Typed `boolean` so the + // disabled profiling block isn't flagged as unreachable. + const PROFILE_SNAPSHOT_PERSISTENCE: boolean = false + + // Cumulative session counters, flushed at most once per PROF_FLUSH_MS so + // the trace adds no per-fire logging overhead (used only when profiling): + // `invocations` is every onSnapshot fire (≈ one per persisted-or-stripped + // MST action), `writes` are the MMKV writes actually performed, `skipped` + // are the redundant byte-identical payloads the equality guard avoided, + // and `totalMs` is the wall time spent serializing (+ writing, when not + // skipped). + const prof = {invocations: 0, writes: 0, skipped: 0, totalMs: 0} + const PROF_FLUSH_MS = 5000 + let profFlushAt = performance.now() + _disposer = onSnapshot(rootStore, snapshot => { - MMKVStorage.save(ROOT_STORAGE_KEY, snapshot) + const t0 = PROFILE_SNAPSHOT_PERSISTENCE ? performance.now() : 0 + + const serialized = JSON.stringify(snapshot) + const changed = serialized !== lastSerialized + if (changed) { + lastSerialized = serialized + MMKVStorage.saveString(ROOT_STORAGE_KEY, serialized) + } + + if (!PROFILE_SNAPSHOT_PERSISTENCE) return + + prof.invocations++ + prof.totalMs += performance.now() - t0 + if (changed) { prof.writes++ } else { prof.skipped++ } + + if (t0 - profFlushAt >= PROF_FLUSH_MS) { + profFlushAt = t0 + log.trace('[setupRootStore] snapshot persistence profile (cumulative)', { + invocations: prof.invocations, + writes: prof.writes, + skipped: prof.skipped, + totalMs: Math.round(prof.totalMs), + avgWriteMs: prof.writes ? +(prof.totalMs / prof.writes).toFixed(3) : 0, + caller: 'setupRootStore', + }) + } }) } else { log.error('[setupRootStore]', 'State restore failed — skipping onSnapshot to preserve MMKV data', {caller: 'setupRootStore'}) @@ -186,7 +239,6 @@ export async function setupRootStore(rootStore: RootStore, opts: SetupRootStoreO async function _runMigrations(rootStore: RootStore, restoredState: any) { const { - mintsStore, transactionsStore, } = rootStore @@ -201,14 +253,30 @@ async function _runMigrations(rootStore: RootStore, restoredState: any) { if(currentVersion < 33) { // One-time copy of the MMKV-resident derivation counters into SQLite - // (the new authority for counters). Reads the LIVE MST counters, - // which still hold the real values loaded from the pre-upgrade MMKV - // snapshot — postProcessSnapshot strips `counter` only from saves, not - // from the in-memory model — and persists them via a monotonic, - // idempotent upsert (incl. counterBackups). After this, mint_counters - // is authoritative and the every-launch hydrate in setupRootStore - // fills the in-memory cache from it. - mintsStore.seedCountersToDatabase() + // (the new authority for counters). `counter` is VOLATILE in the + // model, so applySnapshot no longer loads the pre-upgrade values into + // the live tree — read them straight from the RAW pre-upgrade snapshot + // (same pattern as the v34/35/36 seeds below). Monotonic + only > 0, + // so a stripped/zero value is never written and an existing SQLite + // counter is never lowered. After this, mint_counters is authoritative + // and the every-launch hydrate fills the in-memory cache from it. + const seeds: CounterSeed[] = [] + for (const mint of restoredState?.mintsStore?.mints ?? []) { + for (const counter of mint?.proofsCounters ?? []) { + if (counter?.keyset && typeof counter.counter === 'number' && counter.counter > 0) { + seeds.push({mintUrl: mint.mintUrl, keysetId: counter.keyset, unit: counter.unit, counter: counter.counter}) + } + } + } + if (seeds.length > 0) { + Database.seedCounters(seeds) + // The earlier startup hydrate (setupRootStore) ran against an + // empty mint_counters and left the volatile in-memory counters at + // 0. Now that SQLite is seeded, refresh the cache so a transaction + // in THIS first post-upgrade session reads the real index rather + // than 0 (which would risk blinded-secret reuse). Monotonic. + rootStore.mintsStore.hydrateCountersFromDatabase() + } } if(currentVersion < 34) { diff --git a/src/screens/ImportBackupScreen.tsx b/src/screens/ImportBackupScreen.tsx index 6729a3ed..0a6a6148 100644 --- a/src/screens/ImportBackupScreen.tsx +++ b/src/screens/ImportBackupScreen.tsx @@ -29,6 +29,7 @@ import { applySnapshot} from 'mobx-state-tree' import { verticalScale } from '@gocodingnow/rn-size-matters' import { translate } from '../i18n' import { MintsStoreSnapshot } from '../models/MintsStore' +import { CounterSeed } from '../services/db' import { ContactsStoreSnapshot } from '../models/ContactsStore' import { Mint as CashuMint, GetKeysResponse } from '@cashu/cashu-ts' import { StaticScreenProps, useNavigation } from '@react-navigation/native' @@ -203,11 +204,27 @@ export const ImportBackupScreen = observer(function ImportBackupScreen({ route } applySnapshot(mintsStore, walletSnapshot.mintsStore) applySnapshot(contactsStore, walletSnapshot.contactsStore) - // The backup carries real derivation counters in the MST snapshot; the - // counter is mastered in SQLite, so copy the just-imported values into - // the mint_counters table immediately (monotonic, never lowers). Without - // this they would only reach SQLite on the next startup seed. - mintsStore.seedCountersToDatabase() + // The backup carries real derivation counters in its raw MST snapshot. + // `counter` is VOLATILE in the model (mastered in SQLite), so the + // applySnapshot above does NOT load it — read the values straight from + // the backup snapshot, seed SQLite (monotonic, never lowers), then + // hydrate the in-memory cache from the authority. Seeding 0 from the + // live (just-reset) model would risk blinded-secret reuse on restore. + const counterSeeds: CounterSeed[] = [] + for (const mint of walletSnapshot.mintsStore?.mints ?? []) { + for (const pc of mint?.proofsCounters ?? []) { + // `counter` is no longer on the typed snapshot (it's volatile); the + // backup JSON still carries it, so read it off the raw value. + const counter = (pc as any).counter + if (pc?.keyset && typeof counter === 'number' && counter > 0) { + counterSeeds.push({mintUrl: mint.mintUrl, keysetId: pc.keyset, unit: pc.unit, counter}) + } + } + } + if (counterSeeds.length > 0) { + Database.seedCounters(counterSeeds) + } + mintsStore.hydrateCountersFromDatabase() log.trace('After import and mint keys hydration', {mintsStore})