mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
Merge branch 'perf/snapshot-persistence-dedup'
Dedup redundant MMKV snapshot writes (serialized-payload equality guard) and make the Mint derivation counter volatile so per-derivation bumps no longer trigger whole-tree snapshot writes. SQLite remains the counter authority.
This commit is contained in:
+15
-12
@@ -50,7 +50,9 @@ export type InFlightRequest<TRequest = any> = {
|
||||
// {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<MintUnit>(), '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<typeof MintProofsCounterModel>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user