From 3485cef772ce09f7c1b768a340cab689121740fb Mon Sep 17 00:00:00 2001 From: Blake Kaufman Date: Tue, 30 Jun 2026 07:38:13 -0400 Subject: [PATCH] deboucing incoming balances for high activity accounts --- context-store/sparkContext.js | 159 +++++++++++++++++++++++++++------- 1 file changed, 129 insertions(+), 30 deletions(-) diff --git a/context-store/sparkContext.js b/context-store/sparkContext.js index f2b10090..7b226689 100644 --- a/context-store/sparkContext.js +++ b/context-store/sparkContext.js @@ -379,6 +379,17 @@ const SparkWalletProvider = ({ children }) => { const latestIncomingBalanceRef = useRef(null); const pendingTransferIds = useRef(new Set()); + // Debounce refs for balance:update — a burst of inbound payments emits one + // balance:update each; we coalesce them into a single state write. + const balanceDebounceTimeoutRef = useRef(null); + const balanceDebounceMaxWaitRef = useRef(null); + const latestBalanceRef = useRef(null); + + // Debounce refs for token-balance:update — same coalescing for token events. + const tokenDebounceTimeoutRef = useRef(null); + const tokenDebounceMaxWaitRef = useRef(null); + const latestTokensRef = useRef(null); + const toggleIsSendingPayment = useCallback(isSending => { console.log('Setting is sending payment', isSending); if (isSending) { @@ -1236,11 +1247,11 @@ const SparkWalletProvider = ({ children }) => { if (debounceTimeoutRef.current) clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = setTimeout(flush, 500); - // …but cap the wait at 1.5s so a sustained burst (events arriving faster + // …but cap the wait at 10s so a sustained burst (events arriving faster // than every 500ms, which would perpetually reset the trailing timer and // never flush) still applies the balance periodically. if (!debounceMaxWaitRef.current) { - debounceMaxWaitRef.current = setTimeout(flush, 1500); + debounceMaxWaitRef.current = setTimeout(flush, 10000); } }, []); @@ -1257,23 +1268,55 @@ const SparkWalletProvider = ({ children }) => { // emitting balance:update) can't trigger a render / DB-write storm. if (available === sparkInfoRef.current.balance) return; - // Entry-stamped ordering guard, shared with applyConfirmedBalanceSnapshot - // and applyIncomingPaymentSnapshot, so a late-resolving snapshot can't - // overwrite a newer event's value. - const myVersion = ++balanceVersionRef.current; - const { identityPubKey } = sparkInfoRef.current; + // Always flush with the most recent value, even when the max-wait timer + // (set on the first event of the burst) fires. + latestBalanceRef.current = available; - saveAccountBalanceSnapshot( - identityPubKey, - available, - sparkInfoRef.current.tokens, - ); + const flush = () => { + if (balanceDebounceTimeoutRef.current) { + clearTimeout(balanceDebounceTimeoutRef.current); + balanceDebounceTimeoutRef.current = null; + } + if (balanceDebounceMaxWaitRef.current) { + clearTimeout(balanceDebounceMaxWaitRef.current); + balanceDebounceMaxWaitRef.current = null; + } - setSparkInformation(prev => { - if (myVersion < balanceVersionRef.current) return prev; - if (prev.balance === available) return prev; - return { ...prev, balance: available }; - }); + const nextBalance = latestBalanceRef.current; + if (!Number.isFinite(nextBalance)) return; + if (nextBalance === sparkInfoRef.current.balance) return; + + // Entry-stamped ordering guard, shared with applyConfirmedBalanceSnapshot + // and applyIncomingPaymentSnapshot, so a late-resolving snapshot can't + // overwrite a newer event's value. Stamped at flush time so it orders + // correctly against other appliers running during the debounce window. + const myVersion = ++balanceVersionRef.current; + const { identityPubKey } = sparkInfoRef.current; + + saveAccountBalanceSnapshot( + identityPubKey, + nextBalance, + sparkInfoRef.current.tokens, + ); + + setSparkInformation(prev => { + if (myVersion < balanceVersionRef.current) return prev; + if (prev.balance === nextBalance) return prev; + return { ...prev, balance: nextBalance }; + }); + }; + + // Trailing debounce: flush 500ms after the last event… + if (balanceDebounceTimeoutRef.current) + clearTimeout(balanceDebounceTimeoutRef.current); + balanceDebounceTimeoutRef.current = setTimeout(flush, 500); + + // …but cap the wait at 10s so a sustained burst (events arriving faster + // than every 500ms, which would perpetually reset the trailing timer and + // never flush) still applies the balance periodically. + if (!balanceDebounceMaxWaitRef.current) { + balanceDebounceMaxWaitRef.current = setTimeout(flush, 10000); + } }, []); // token-balance:update fires when a token tx finalizes and carries the full @@ -1282,19 +1325,47 @@ const SparkWalletProvider = ({ children }) => { // round-trip — same result, one fewer WebView read per event. The WebView // runtime delivers the already-normalized map; the native runtime delivers the // raw SDK Map and is normalized at registration (see addListeners). - const tokenBalanceUpdateHandler = useCallback(async tokensObject => { - const mnemonic = currentMnemonicRef.current; - if (!mnemonic) return; - const merged = await mergeAndCacheTokens(tokensObject ?? {}, mnemonic); - if (mnemonic !== currentMnemonicRef.current) return; - setSparkInformation(prev => ({ ...prev, tokens: merged })); - // Persist tokens so a token-only change survives a cold start, matching - // applyConfirmedBalanceSnapshot / applyIncomingPaymentSnapshot. - saveAccountBalanceSnapshot( - sparkInfoRef.current.identityPubKey, - sparkInfoRef.current.balance, - merged, - ); + const tokenBalanceUpdateHandler = useCallback(tokensObject => { + // Each event carries the full current token-balance map, so only the latest + // payload matters during a burst. + latestTokensRef.current = tokensObject ?? {}; + + const flush = async () => { + if (tokenDebounceTimeoutRef.current) { + clearTimeout(tokenDebounceTimeoutRef.current); + tokenDebounceTimeoutRef.current = null; + } + if (tokenDebounceMaxWaitRef.current) { + clearTimeout(tokenDebounceMaxWaitRef.current); + tokenDebounceMaxWaitRef.current = null; + } + + const mnemonic = currentMnemonicRef.current; + if (!mnemonic) return; + const merged = await mergeAndCacheTokens( + latestTokensRef.current, + mnemonic, + ); + if (mnemonic !== currentMnemonicRef.current) return; + setSparkInformation(prev => ({ ...prev, tokens: merged })); + // Persist tokens so a token-only change survives a cold start, matching + // applyConfirmedBalanceSnapshot / applyIncomingPaymentSnapshot. + saveAccountBalanceSnapshot( + sparkInfoRef.current.identityPubKey, + sparkInfoRef.current.balance, + merged, + ); + }; + + // Trailing debounce 500ms after the last event, capped at 10s so a + // sustained burst still flushes periodically (see balanceUpdateHandler). + if (tokenDebounceTimeoutRef.current) + clearTimeout(tokenDebounceTimeoutRef.current); + tokenDebounceTimeoutRef.current = setTimeout(flush, 500); + + if (!tokenDebounceMaxWaitRef.current) { + tokenDebounceMaxWaitRef.current = setTimeout(flush, 10000); + } }, []); // Stream lifecycle. A connect after a drop means events may have been missed @@ -1624,6 +1695,22 @@ const SparkWalletProvider = ({ children }) => { clearTimeout(debounceMaxWaitRef.current); debounceMaxWaitRef.current = null; } + if (balanceDebounceTimeoutRef.current) { + clearTimeout(balanceDebounceTimeoutRef.current); + balanceDebounceTimeoutRef.current = null; + } + if (balanceDebounceMaxWaitRef.current) { + clearTimeout(balanceDebounceMaxWaitRef.current); + balanceDebounceMaxWaitRef.current = null; + } + if (tokenDebounceTimeoutRef.current) { + clearTimeout(tokenDebounceTimeoutRef.current); + tokenDebounceTimeoutRef.current = null; + } + if (tokenDebounceMaxWaitRef.current) { + clearTimeout(tokenDebounceMaxWaitRef.current); + tokenDebounceMaxWaitRef.current = null; + } // Clear pending transfer IDs pendingTransferIds.current.clear(); @@ -2294,6 +2381,18 @@ const SparkWalletProvider = ({ children }) => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } + if (balanceDebounceTimeoutRef.current) { + clearTimeout(balanceDebounceTimeoutRef.current); + } + if (balanceDebounceMaxWaitRef.current) { + clearTimeout(balanceDebounceMaxWaitRef.current); + } + if (tokenDebounceTimeoutRef.current) { + clearTimeout(tokenDebounceTimeoutRef.current); + } + if (tokenDebounceMaxWaitRef.current) { + clearTimeout(tokenDebounceMaxWaitRef.current); + } pendingTransferIds.current.clear(); }; }, []);