-
Notifications
You must be signed in to change notification settings - Fork 3
fix: tx prices linking, dashboard load, initial sync errors #1132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 11 commits
3ebc4a0
03e107c
c275f8e
054ccda
a7c0814
18879a6
25b5016
8ce9149
6fdb0f6
b72ce29
72333f3
4a93f89
5bdf53f
59373c9
a4b6f5c
5392e6c
68ad97b
3b198d0
678f9f4
f83885d
ff1a38f
e5eafb3
3d9c4bd
c77cd75
2e78112
89d64e0
1231945
c616877
4f38145
f076ec9
9799f62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,4 @@ | ||
| IGNORE_SEEDING=true | ||
| DONT_EXECUTE_TRIGGERS=true | ||
| FROM_DUMP=true | ||
| SKIP_CACHE_REBUILD=true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,3 +39,4 @@ prisma-local/seeds/productionTxs.csv | |
| paybutton-config.json | ||
|
|
||
| dump.sql* | ||
| .forever | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,11 +10,11 @@ import { | |
| import { fetchAllUserAddresses, AddressPaymentInfo } from 'services/addressService' | ||
| import { fetchPaybuttonArrayByUserId } from 'services/paybuttonService' | ||
|
|
||
| import { RESPONSE_MESSAGES, PAYMENT_WEEK_KEY_FORMAT, KeyValueT } from 'constants/index' | ||
| import { RESPONSE_MESSAGES, PAYMENT_WEEK_KEY_FORMAT, KeyValueT, N_OF_QUOTES } from 'constants/index' | ||
| import moment from 'moment-timezone' | ||
| import { CacheSet } from 'redis/index' | ||
| import { ButtonDisplayData, Payment } from './types' | ||
| import { getUserDashboardData } from './dashboardCache' | ||
| import { getUserDashboardData, clearDashboardCache } from './dashboardCache' | ||
| // ADDRESS:payments:YYYY:MM | ||
| const getPaymentsWeekKey = (addressString: string, timestamp: number): string => { | ||
| return `${addressString}:payments:${moment.unix(timestamp).format(PAYMENT_WEEK_KEY_FORMAT)}` | ||
|
|
@@ -31,10 +31,20 @@ export async function * getUserUncachedAddresses (userId: string): AsyncGenerato | |
| } | ||
|
|
||
| export const getPaymentList = async (userId: string): Promise<Payment[]> => { | ||
| const uncachedAddressStream = getUserUncachedAddresses(userId) | ||
| for await (const address of uncachedAddressStream) { | ||
| void await CacheSet.addressCreation(address) | ||
| const allAddresses = await fetchAllUserAddresses(userId, false, false) as Address[] | ||
| const uncachedAddresses: Address[] = [] | ||
| for (const address of allAddresses) { | ||
| const keys = await getCachedWeekKeysForAddress(address.address) | ||
| if (keys.length === 0) { | ||
| uncachedAddresses.push(address) | ||
| } | ||
| } | ||
|
|
||
| if (uncachedAddresses.length > 0 && process.env.SKIP_CACHE_REBUILD === undefined) { | ||
| console.log(`[CACHE] getPaymentList: ${uncachedAddresses.length} uncached addresses for user ${userId}, starting background rebuild`) | ||
| void cacheAddressesInBackground(uncachedAddresses, userId) | ||
| } | ||
|
|
||
| return await getCachedPaymentsForUser(userId) | ||
| } | ||
|
|
||
|
|
@@ -145,20 +155,29 @@ export const generatePaymentFromTxWithInvoices = (tx: TransactionWithAddressAndP | |
| } | ||
|
|
||
| export const generateAndCacheGroupedPaymentsAndInfoForAddress = async (address: Address): Promise<GroupedPaymentsAndInfoObject> => { | ||
| const startTime = Date.now() | ||
| let paymentList: Payment[] = [] | ||
| let balance = new Prisma.Decimal(0) | ||
| let paymentCount = 0 | ||
| let txCount = 0 | ||
| const txsWithPaybuttonsGenerator = generateTransactionsWithPaybuttonsAndPricesForAddress(address.id) | ||
| for await (const batch of txsWithPaybuttonsGenerator) { | ||
| for (const tx of batch) { | ||
| txCount++ | ||
| balance = balance.plus(tx.amount) | ||
| if (tx.amount.gt(0)) { | ||
| if (tx.prices.length !== N_OF_QUOTES) { | ||
| console.warn(`[CACHE] Skipping tx ${tx.hash} — missing price links (${tx.prices.length}/${N_OF_QUOTES})`) | ||
| continue | ||
| } | ||
| const payment = generatePaymentFromTx(tx) | ||
| paymentList.push(payment) | ||
| paymentCount++ | ||
| } | ||
| } | ||
| } | ||
| const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) | ||
| console.log(`[CACHE] Cached address ${address.address.slice(0, 20)}...: ${paymentCount} payments, ${txCount} txs in ${elapsed}s`) | ||
| const info: AddressPaymentInfo = { | ||
| balance, | ||
| paymentCount | ||
|
|
@@ -304,6 +323,7 @@ export const clearPaymentCacheForAddress = async (addressString: string): Promis | |
| } | ||
|
|
||
| export const initPaymentCache = async (address: Address): Promise<boolean> => { | ||
| if (process.env.SKIP_CACHE_REBUILD !== undefined) return false | ||
| const cachedKeys = await getCachedWeekKeysForAddress(address.address) | ||
| if (cachedKeys.length === 0) { | ||
| await CacheSet.addressCreation(address) | ||
|
|
@@ -312,12 +332,40 @@ export const initPaymentCache = async (address: Address): Promise<boolean> => { | |
| return false | ||
| } | ||
|
|
||
| const cacheAddressesInBackground = async (addresses: Address[], userId: string): Promise<void> => { | ||
| const startTime = Date.now() | ||
| let cached = 0 | ||
| for (const address of addresses) { | ||
| try { | ||
| await CacheSet.addressCreation(address) | ||
| cached++ | ||
| if (cached % 10 === 0 || cached === addresses.length) { | ||
| console.log(`[CACHE] Background: cached ${cached}/${addresses.length} addresses for user ${userId}`) | ||
| } | ||
| } catch (err: any) { | ||
| console.error(`[CACHE] Background: failed to cache ${address.address}: ${err.message as string}`) | ||
| } | ||
| } | ||
| const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) | ||
| console.log(`[CACHE] Background: completed ${cached}/${addresses.length} addresses for user ${userId} in ${elapsed}s`) | ||
| await clearDashboardCache(userId) | ||
| } | ||
|
|
||
| export async function * getPaymentStream (userId: string): AsyncGenerator<Payment> { | ||
| const uncachedAddressStream = getUserUncachedAddresses(userId) | ||
| for await (const address of uncachedAddressStream) { | ||
| console.log('[CACHE]: Creating cache for address', address.address) | ||
| await CacheSet.addressCreation(address) | ||
| const allAddresses = await fetchAllUserAddresses(userId, false, false) as Address[] | ||
| const uncachedAddresses: Address[] = [] | ||
| for (const address of allAddresses) { | ||
| const keys = await getCachedWeekKeysForAddress(address.address) | ||
| if (keys.length === 0) { | ||
| uncachedAddresses.push(address) | ||
| } | ||
| } | ||
|
|
||
| if (uncachedAddresses.length > 0 && process.env.SKIP_CACHE_REBUILD === undefined) { | ||
| console.log(`[CACHE] ${uncachedAddresses.length}/${allAddresses.length} uncached addresses for user ${userId}, starting background rebuild`) | ||
| void cacheAddressesInBackground(uncachedAddresses, userId) | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is duplicated from getPaymentList, you should call that instead
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refactored both to use same auxiliary function. |
||
|
|
||
| const userButtonIds: string[] = (await fetchPaybuttonArrayByUserId(userId)) | ||
| .map(p => p.id) | ||
| const weekKeys = await getCachedWeekKeysForUser(userId) | ||
|
|
@@ -336,7 +384,7 @@ export async function * getPaymentStream (userId: string): AsyncGenerator<Paymen | |
| }) | ||
|
|
||
| for (const payment of weekPayments) { | ||
| yield payment // Yield one payment at a time | ||
| yield payment | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| import { BlockInfo, ChronikClient, ConnectionStrategy, ScriptUtxo, Tx, WsConfig, WsEndpoint, WsMsgClient, WsSubScriptClient } from 'chronik-client' | ||
| import { encodeCashAddress, decodeCashAddress } from 'ecashaddrjs' | ||
| import { AddressWithTransaction, BlockchainInfo, TransactionDetails, ProcessedMessages, SubbedAddressesLog, SyncAndSubscriptionReturn, SubscriptionReturn, SimpleBlockInfo } from 'types/chronikTypes' | ||
| import { CHRONIK_MESSAGE_CACHE_DELAY, RESPONSE_MESSAGES, XEC_TIMESTAMP_THRESHOLD, XEC_NETWORK_ID, BCH_NETWORK_ID, BCH_TIMESTAMP_THRESHOLD, CHRONIK_FETCH_N_TXS_PER_PAGE, KeyValueT, NETWORK_IDS_FROM_SLUGS, SOCKET_MESSAGES, NETWORK_IDS, NETWORK_TICKERS, MainNetworkSlugsType, MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME, MEMPOOL_PROCESS_DELAY, CHRONIK_INITIALIZATION_DELAY, LATENCY_TEST_CHECK_DELAY, INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY, TX_EMIT_BATCH_SIZE, DB_COMMIT_BATCH_SIZE, MAX_TXS_PER_ADDRESS, TX_BATCH_POLLING_DELAY } from 'constants/index' | ||
| import { CHRONIK_MESSAGE_CACHE_DELAY, RESPONSE_MESSAGES, XEC_TIMESTAMP_THRESHOLD, XEC_NETWORK_ID, BCH_NETWORK_ID, BCH_TIMESTAMP_THRESHOLD, CHRONIK_FETCH_N_TXS_PER_PAGE, KeyValueT, NETWORK_IDS_FROM_SLUGS, SOCKET_MESSAGES, NETWORK_IDS, NETWORK_TICKERS, MainNetworkSlugsType, MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME, MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME, MEMPOOL_PROCESS_DELAY, CONFIRMED_TX_PROCESS_DELAY, CHRONIK_INITIALIZATION_DELAY, LATENCY_TEST_CHECK_DELAY, INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY, TX_EMIT_BATCH_SIZE, DB_COMMIT_BATCH_SIZE, MAX_TXS_PER_ADDRESS, TX_BATCH_POLLING_DELAY } from 'constants/index' | ||
| import { productionAddresses } from 'prisma-local/seeds/addresses' | ||
| import prisma from 'prisma-local/clientInstance' | ||
| import { | ||
|
|
@@ -137,6 +137,8 @@ export class ChronikBlockchainClient { | |
| mempoolTxsBeingProcessed!: number | ||
|
|
||
| private latencyTestFinished: boolean | ||
| private wsReconnecting = false | ||
| private confirmedTxsBeingProcessed = 0 | ||
|
|
||
| constructor (networkSlug: string) { | ||
| this.latencyTestFinished = false | ||
|
|
@@ -156,8 +158,8 @@ export class ChronikBlockchainClient { | |
| this.latencyTestFinished = true | ||
| this.chronikWSEndpoint = this.chronik.ws(this.getWsConfig()) | ||
| this.confirmedTxsHashesFromLastBlock = [] | ||
| void this.chronikWSEndpoint.waitForOpen() | ||
| this.chronikWSEndpoint.subscribeToBlocks() | ||
| void this.connectWsWithRetry() | ||
| this.lastProcessedMessages = { confirmed: {}, unconfirmed: {} } | ||
| this.CHRONIK_MSG_PREFIX = `[CHRONIK — ${networkSlug}]` | ||
| this.wsEndpoint = io(`${config.wsBaseURL}/broadcast`, { | ||
|
|
@@ -181,6 +183,42 @@ export class ChronikBlockchainClient { | |
| } | ||
| } | ||
|
|
||
| private async connectWsWithRetry (maxRetries = 10, baseDelay = 5000): Promise<void> { | ||
| for (let attempt = 1; attempt <= maxRetries; attempt++) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is off by one
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I renamed |
||
| try { | ||
| await this.chronikWSEndpoint.waitForOpen() | ||
| console.log(`${this.CHRONIK_MSG_PREFIX}: WebSocket connected.`) | ||
| return | ||
| } catch (err: any) { | ||
| console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket connection attempt ${attempt}/${maxRetries} failed: ${err.message as string}`) | ||
| if (attempt < maxRetries) { | ||
| const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60000) | ||
| console.log(`${this.CHRONIK_MSG_PREFIX}: Retrying WebSocket in ${delay / 1000}s...`) | ||
| await new Promise(resolve => setTimeout(resolve, delay)) | ||
| } | ||
| } | ||
| } | ||
| console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket failed after ${maxRetries} attempts. Continuing without real-time updates.`) | ||
| } | ||
|
|
||
| private async reconnectWs (): Promise<void> { | ||
| if (this.wsReconnecting) return | ||
| this.wsReconnecting = true | ||
| try { | ||
| const addresses = this.getSubscribedAddresses() | ||
| this.chronikWSEndpoint = this.chronik.ws(this.getWsConfig()) | ||
| this.chronikWSEndpoint.subscribeToBlocks() | ||
| for (const addr of addresses) { | ||
| this.chronikWSEndpoint.subscribeToAddress(addr) | ||
| } | ||
| await this.connectWsWithRetry() | ||
| } catch (err: any) { | ||
| console.error(`${this.CHRONIK_MSG_PREFIX}: WebSocket reconnection error: ${err.message as string}`) | ||
| } finally { | ||
| this.wsReconnecting = false | ||
| } | ||
| } | ||
|
Comment on lines
+204
to
+220
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent reconnects during intentional shutdown.
Proposed fix export class ChronikBlockchainClient {
chronik!: ChronikClient
@@
private latencyTestFinished: boolean
private wsReconnecting = false
private confirmedTxsBeingProcessed = 0
+ private wsShuttingDown = false
@@
private async reconnectWs (): Promise<void> {
- if (this.wsReconnecting) return
+ if (this.wsReconnecting || this.wsShuttingDown) return
this.wsReconnecting = true
@@
onReconnect: (_: ws.Event) => {
+ if (this.wsShuttingDown) return
console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed. Attempting reconnection...`)
void this.reconnectWs()
},
@@
onEnd: (e: ws.Event) => {
+ if (this.wsShuttingDown) return
console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}. Attempting reconnection...`)
void this.reconnectWs()
},Add a small Also applies to: 705-714 🤖 Prompt for AI Agents |
||
|
|
||
| public getUrls (): string[] { | ||
| return this.chronik.proxyInterface().getEndpointArray().map(e => e.url) | ||
| } | ||
|
|
@@ -664,10 +702,16 @@ export class ChronikBlockchainClient { | |
| return { | ||
| onMessage: (msg: WsMsgClient) => { void this.processWsMessage(msg) }, | ||
| onError: (e: ws.ErrorEvent) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket error, type: ${e.type} | message: ${e.message} | error: ${e.error as string}`) }, | ||
| onReconnect: (_: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed.`) }, | ||
| onReconnect: (_: ws.Event) => { | ||
| console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket unexpectedly closed. Attempting reconnection...`) | ||
| void this.reconnectWs() | ||
| }, | ||
| onConnect: (_: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik webSocket connection (re)established.`) }, | ||
| onEnd: (e: ws.Event) => { console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}.`) }, | ||
| autoReconnect: true | ||
| onEnd: (e: ws.Event) => { | ||
| console.log(`${this.CHRONIK_MSG_PREFIX}: Chronik WebSocket ended, type: ${e.type}. Attempting reconnection...`) | ||
| void this.reconnectWs() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure that's what you want, this could prevent you from stopping the app as it will keep trying to reconnect ?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Indeed, I think the reconnection logic only at |
||
| }, | ||
| autoReconnect: false | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -788,6 +832,12 @@ export class ChronikBlockchainClient { | |
| } | ||
| } else if (msg.msgType === 'TX_CONFIRMED') { | ||
| if (this.isAlreadyBeingProcessed(msg.txid, true)) return | ||
|
|
||
| while (this.confirmedTxsBeingProcessed >= MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is very hacky. What about a proper message queue ? |
||
| await new Promise(resolve => setTimeout(resolve, CONFIRMED_TX_PROCESS_DELAY)) | ||
| } | ||
|
|
||
| this.confirmedTxsBeingProcessed += 1 | ||
| try { | ||
| const transaction = await this.fetchTxWithRetry(msg.txid) | ||
| const addressesWithTransactions = await this.getAddressesForTransaction(transaction) | ||
|
|
@@ -808,6 +858,8 @@ export class ChronikBlockchainClient { | |
| const { [msg.txid]: _, ...rest } = this.lastProcessedMessages.confirmed | ||
| this.lastProcessedMessages.confirmed = rest | ||
| } | ||
| } finally { | ||
| this.confirmedTxsBeingProcessed = Math.max(0, this.confirmedTxsBeingProcessed - 1) | ||
| } | ||
| } else if (msg.msgType === 'TX_ADDED_TO_MEMPOOL') { | ||
| if (this.isAlreadyBeingProcessed(msg.txid, false)) return | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OTOH this default seems very low to me. A block full will take ~10 minutes of total delay, not accounting for the tx processing itself
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I bumped it to 20, but in the end we have to try: Since we have to connect new txs to address & prices, this would sometimes try to do too much at once and end up timing out the DB connection