Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3ebc4a0
fix: don't sed dump
chedieck Jun 1, 2026
03e107c
chore: improve testing from dump
chedieck Jun 1, 2026
c275f8e
chore: improve testing from dump 2
chedieck Jun 1, 2026
054ccda
fix: dev-from-dump setup (named volume, prisma deploy, pv progress)
chedieck Jun 2, 2026
a7c0814
feat: SKIP_CACHE_REBUILD to prevent I/O saturation with prod data
chedieck Jun 2, 2026
18879a6
chore: ignore .forever
chedieck Jun 2, 2026
25b5016
fix: skip txs with missing prices instead of failing entire batch
chedieck May 29, 2026
8ce9149
fix: resilient chronik WebSocket with retry + bump sync concurrency t…
chedieck May 29, 2026
6fdb0f6
fix: undo 16 -> 64 bump
chedieck Jun 1, 2026
b72ce29
fix: throttle TX_CONFIRMED handler to max 6 concurrent (prevents DB c…
chedieck Jun 1, 2026
72333f3
perf: non-blocking payment cache rebuild with background backfill and…
chedieck Jun 1, 2026
4a93f89
fix: non-blocking cache rebuild with progressive UX
chedieck Jun 1, 2026
5bdf53f
perf: fix payments page blocking on cache rebuild
chedieck Jun 2, 2026
59373c9
perf: fix page loads blocking on cache rebuild I/O saturation
chedieck Jun 2, 2026
a4b6f5c
fix: revert backgroundPrisma - was starving websocket handlers
chedieck Jun 2, 2026
5392e6c
chore: remove changes from other PR
chedieck Jun 2, 2026
68ad97b
fix: revert 30s TTL cache during rebuild - don't cache partial data
chedieck Jun 2, 2026
3b198d0
test: update tests for generateAddressPaymentInfo aggregate refactor
chedieck Jun 2, 2026
678f9f4
test: expect cacheRebuilding in dashboard response during integration…
chedieck Jun 2, 2026
f83885d
refactor: extract WS retry constants (CHRONIK_WS_MAX_RETRIES, CHRONIK…
chedieck Jun 2, 2026
ff1a38f
Merge branch 'master' into fix/tx-prices-linking
chedieck Jun 2, 2026
e5eafb3
refactor: rename chronik retry constants to CHRONIK_HTTP_* and CHRONI…
chedieck Jun 2, 2026
3d9c4bd
fix: constants names
chedieck Jun 8, 2026
c77cd75
fix: resolve merge conflicts in paymentCache.ts (keep background rebu…
chedieck Jun 22, 2026
2e78112
fix: optmize search iterations
chedieck Jun 30, 2026
89d64e0
fix: deduplicate cache rebuild
chedieck Jun 22, 2026
1231945
fix: retry -> attempt
chedieck Jun 23, 2026
c616877
fix: do not block chronik connection end
chedieck Jun 30, 2026
4f38145
refactor: bump max confirmed txs to process from 6 to 20
chedieck Jun 30, 2026
f076ec9
fix: improve comment
chedieck Jun 30, 2026
9799f62
Merge branch 'master' into fix/tx-prices-linking
Klakurka Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.from-dump
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ prisma-local/seeds/productionTxs.csv
paybutton-config.json

dump.sql*
.forever
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ dev-from-dump:
$(touch_local_env)
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump up --build -d

stop-dev-from-dump:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump down

reset-dev-from-dump:
make stop-dev-from-dump && make dev-from-dump

reset-dev-from-dump-keep-db:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump up --build -d --force-recreate --no-deps paybutton

nuke-dev-from-dump:
docker compose -f docker-compose-from-dump.yml --env-file .env --env-file .env.local --env-file .env.from-dump down -v

logs-dev:
docker logs -f paybutton-dev

Expand Down
2 changes: 1 addition & 1 deletion constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export const MEMPOOL_PROCESS_DELAY = 100
// On chronik, the max allowed is 200
export const CHRONIK_FETCH_N_TXS_PER_PAGE = 200

export const INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY = 16
export const INITIAL_ADDRESS_SYNC_FETCH_CONCURRENTLY = 64
Comment thread
chedieck marked this conversation as resolved.
Outdated
export const TX_EMIT_BATCH_SIZE = 200 // for our generator, not chronik
export const DB_COMMIT_BATCH_SIZE = 200 // tamanho dos lotes para commit no DB
export const TX_BATCH_POLLING_DELAY = 500 // delay (ms) between polling for new batches of txs to commit to the DB
Expand Down
4 changes: 4 additions & 0 deletions docker-compose-from-dump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ services:
- ./scripts/db/init-supertokens-db.sql:/home/mysql/raw_entrypoint/3-init-supertokens-db.sql
- ./scripts/db/prisma-shadow-db-fix.sql:/home/mysql/raw_entrypoint/4-prisma-shadow-db-fix.sql
- ./dump.sql:/home/mysql/raw_entrypoint/5-dump.sql
- paybutton-dump-db-data:/var/lib/mysql

users-service:
container_name: paybutton-users-service
Expand Down Expand Up @@ -69,3 +70,6 @@ services:
- ./redis:/data/redis:z
- ./scripts:/data/scripts
command: sh -c "sed -i 's/\\r//g' ./scripts/redis-start.sh && sh ./scripts/redis-start.sh"

volumes:
paybutton-dump-db-data:
25 changes: 17 additions & 8 deletions redis/paymentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ 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'
Expand All @@ -31,9 +31,11 @@ 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)
if (process.env.SKIP_CACHE_REBUILD === undefined) {
const uncachedAddressStream = getUserUncachedAddresses(userId)
for await (const address of uncachedAddressStream) {
void await CacheSet.addressCreation(address)
}
}
return await getCachedPaymentsForUser(userId)
}
Expand Down Expand Up @@ -153,6 +155,10 @@ export const generateAndCacheGroupedPaymentsAndInfoForAddress = async (address:
for (const tx of batch) {
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++
Expand Down Expand Up @@ -304,6 +310,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)
Expand All @@ -313,10 +320,12 @@ export const initPaymentCache = async (address: Address): Promise<boolean> => {
}

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)
if (process.env.SKIP_CACHE_REBUILD === undefined) {
const uncachedAddressStream = getUserUncachedAddresses(userId)
for await (const address of uncachedAddressStream) {
console.log('[CACHE]: Creating cache for address', address.address)
await CacheSet.addressCreation(address)
}
}
const userButtonIds: string[] = (await fetchPaybuttonArrayByUserId(userId))
.map(p => p.id)
Expand Down
18 changes: 16 additions & 2 deletions scripts/db-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,25 @@ sed_vars () {
cd /home/mysql/raw_entrypoint || exit

for file in *.sql; do
sed_vars "$file"
if [[ "$file" == *dump* ]]; then
echo "Linking dump file: $file"
ln -s /home/mysql/raw_entrypoint/"$file" /home/mysql/entrypoint/"$file"
else
echo "Copying file $file after seding vars"
sed_vars "$file"
fi
done

cd ../entrypoint/ || exit

for file in *.sql; do
mariadb -u root -p"$MYSQL_ROOT_PASSWORD" < "$file"
if [[ "$file" == *dump* ]]; then
filesize=$(stat -Lc%s "$file" 2>/dev/null || stat -Lf%z "$file" 2>/dev/null)
echo "Importing $file ($(numfmt --to=iec $filesize)) ..."
pv "$file" | mariadb -u root -p"$MYSQL_ROOT_PASSWORD"
else
echo "Importing $file ..."
mariadb -u root -p"$MYSQL_ROOT_PASSWORD" < "$file"
fi
echo "Done importing $file"
done
2 changes: 1 addition & 1 deletion scripts/db/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM mariadb:latest
RUN mkhomedir_helper mysql

RUN apt-get update || echo && \
apt-get install -y gettext || echo
apt-get install -y gettext pv || echo

RUN mkdir -p /home/mysql/raw_entrypoint && \
mkdir -p /home/mysql/entrypoint
Expand Down
14 changes: 8 additions & 6 deletions scripts/paybutton-server-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ logtime=$(date +%Y-%m-%d@%H:%M)
[ -e logs/jobs.log ] && mv logs/jobs.log logs/history/jobs_"$logtime".log
[ -e logs/ws-server.log ] && mv logs/ws-server.log logs/history/ws-server_"$logtime".log

if [ "$ENVIRONMENT" = "production" ]; then
if [ "$FROM_DUMP" = "true" ] || [ "$ENVIRONMENT" = "production" ]; then
yarn prisma migrate deploy || exit 1
yarn prisma generate || exit 1
pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- prod || exit 1
else
yarn prisma migrate dev || exit 1
yarn prisma db seed || exit 1
pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
fi

pm2 start yarn --time --interpreter ash --name jobs --output logs/jobs.log --error logs/jobs.log -- initJobs || exit 1
pm2 start yarn --time --interpreter ash --name WSServer --output logs/ws-server.log --error logs/ws-server.log -- initWSServer || exit 1
if [ "$ENVIRONMENT" = "production" ]; then
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- prod || exit 1
else
pm2 start yarn --time --interpreter ash --name next --output logs/next.log --error logs/next.log -- dev || exit 1
fi
pm2 logs next
51 changes: 47 additions & 4 deletions services/chronikService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export class ChronikBlockchainClient {
mempoolTxsBeingProcessed!: number

private latencyTestFinished: boolean
private wsReconnecting = false

constructor (networkSlug: string) {
this.latencyTestFinished = false
Expand All @@ -156,8 +157,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`, {
Expand All @@ -181,6 +182,42 @@ export class ChronikBlockchainClient {
}
}

private async connectWsWithRetry (maxRetries = 10, baseDelay = 5000): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is off by one

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed maxRetries to maxAttempts and CHRONIK_WS_MAX_TRIES to CHRONIK_WS_MAX_CONNECTION_ATTEMPTS. Now it should be good

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent reconnects during intentional shutdown.

destroy() closes chronikWSEndpoint at Lines 1460-1465, and this new onEnd handler immediately calls reconnectWs(). Without a shutdown flag, teardown now reopens the Chronik socket you are trying to close, which leaks background connections during process shutdown and test cleanup.

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 shutdown() method on ChronikBlockchainClient that sets wsShuttingDown = true before closing sockets, and call that from MultiBlockchainClient.destroy().

Also applies to: 705-714

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/chronikService.ts` around lines 204 - 220, The reconnect logic
reopens Chronik during an intentional teardown; add a wsShuttingDown boolean
flag on ChronikBlockchainClient, set it to true in a new shutdown() method (call
shutdown() from MultiBlockchainClient.destroy() before closing
chronikWSEndpoint), and guard the onEnd handler and reconnectWs() (which
currently uses wsReconnecting and reconnectWs) to return immediately if
wsShuttingDown is true so the socket isn't reopened during shutdown; apply the
same guard for the other onEnd/reconnect path around the code at the 705-714
area.


public getUrls (): string[] {
return this.chronik.proxyInterface().getEndpointArray().map(e => e.url)
}
Expand Down Expand Up @@ -664,10 +701,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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I think the reconnection logic only at onReconnect should fix the issue.

},
autoReconnect: false
}
}

Expand Down
28 changes: 21 additions & 7 deletions services/transactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,21 +519,22 @@ export async function connectTransactionsListToPrices (
grouped.set(p.timestamp, g)
}

// Throw on missing price pairs
for (const ts of tsArray) {
const allPrices = grouped.get(ts)
const formattedDate = moment.unix(ts).format(HUMAN_READABLE_DATE_FORMAT)

if (allPrices == null) {
throw new Error(
`[PRICES] No price record found for networkId=${networkId} at ${formattedDate}.`
console.warn(
`[PRICES] No price record found for networkId=${networkId} at ${formattedDate} — skipping affected txs.`
)
continue
}

if ((allPrices.cad == null) || (allPrices.usd == null)) {
throw new Error(
`[PRICES] Incomplete price data for networkId=${networkId} at ${formattedDate}. Partial data: ${JSON.stringify(allPrices, null, 2)}`
console.warn(
`[PRICES] Incomplete price data for networkId=${networkId} at ${formattedDate} — skipping affected txs.`
)
continue
}

priceByNetworkTs.set(`${networkId}:${ts}`, allPrices as AllPrices)
Expand All @@ -544,19 +545,32 @@ export async function connectTransactionsListToPrices (

// Build all join rows (2 per tx: USD + CAD)
const rows: Prisma.PricesOnTransactionsCreateManyInput[] = []
const skippedTxIds: string[] = []
for (const t of txList) {
const ts = flattenTimestamp(t.timestamp)
const allPrices = priceByNetworkTs.get(`${t.address.networkId}:${ts}`)
if (allPrices == null) {
throw new Error(`[PRICES] Missing price pair for networkId ${t.address.networkId} at ${moment.unix(ts).format(HUMAN_READABLE_DATE_FORMAT)}.`)
skippedTxIds.push(t.id)
continue
}
rows.push(...buildPriceTxConnectionInput(t, allPrices))
}

if (skippedTxIds.length > 0) {
console.warn(`[PRICES] Skipped ${skippedTxIds.length} txs due to missing price data.`)
}

const txIdsToConnect = txList.filter(t => !skippedTxIds.includes(t.id)).map(t => t.id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very inefficient, you can build txIdsToConnect on the fly (instead of the skipped ones) and check if txList.length > txIdsToConnect.length to determine if there is any skipped tx. This avoids the O(n²) search and map building + saves some memory from the temporary filter vector.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, fixed.

if (txIdsToConnect.length === 0) {
console.warn('[PRICES] No txs to connect after filtering — all had missing prices.')
return
}

console.log(`[PRICES] Built ${rows.length} price links (2 per tx).`)

await prisma.$transaction(
async (tx) => {
await deletePriceTxConnectionsInChunks(tx, txList.map((t) => t.id))
await deletePriceTxConnectionsInChunks(tx, txIdsToConnect)
await createPriceTxConnectionInChunks(tx, rows)
},
{ timeout: PRICES_CONNECTION_TIMEOUT }
Expand Down