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:
minibits-cash
2026-06-09 15:31:12 +02:00
4 changed files with 115 additions and 55 deletions
+15 -12
View File
@@ -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>
-28
View File
@@ -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
+78 -10
View File
@@ -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) {
+22 -5
View File
@@ -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})