Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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: 2 additions & 0 deletions constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,10 @@ export const XEC_TX_EXPLORER_URL = 'https://explorer.e.cash/tx/'
export const BCH_TX_EXPLORER_URL = 'https://blockchair.com/bitcoin-cash/transaction/'

export const MAX_MEMPOOL_TXS_TO_PROCESS_AT_A_TIME = 2
export const MAX_CONFIRMED_TXS_TO_PROCESS_AT_A_TIME = 6

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.

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

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

export const CHRONIK_INITIALIZATION_DELAY = 2000
export const MEMPOOL_PROCESS_DELAY = 100
export const CONFIRMED_TX_PROCESS_DELAY = 100

/* WIP RENAME ALL THOSE */
// When fetching some address transactions, number of transactions to fetch at a time.
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:
12 changes: 2 additions & 10 deletions pages/api/payments/count/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { CacheGet } from 'redis/index'
import { fetchUserProfileFromId } from 'services/userService'
import { setSession } from 'utils/setSession'
import { getFilteredTransactionCount } from 'services/transactionService'
Expand Down Expand Up @@ -28,15 +27,8 @@ export default async (req: any, res: any): Promise<void> => {
if (typeof req.query.endDate === 'string' && req.query.endDate !== '') {
endDate = req.query.endDate as string
}
if (((buttonIds !== undefined) && buttonIds.length > 0) ||
((years !== undefined) && years.length > 0) ||
(startDate !== undefined && endDate !== undefined && startDate !== '' && endDate !== '')) {
const totalCount = await getFilteredTransactionCount(userId, buttonIds, years, timezone, startDate, endDate)
res.status(200).json(totalCount)
} else {
const totalCount = await CacheGet.paymentsCount(userId, timezone)
res.status(200).json(totalCount)
}
const totalCount = await getFilteredTransactionCount(userId, buttonIds, years, timezone, startDate, endDate)
res.status(200).json(totalCount)
} else {
res.status(405).json({ error: 'Method not allowed' })
}
Expand Down
10 changes: 10 additions & 0 deletions pages/dashboard/dashboard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,14 @@ body[data-theme='dark'] .show_filters_button img {
display: flex;
align-items: center;
gap: 10px;
}

.cache_banner {
background-color: #fff3cd;
color: #856404;
padding: 10px 16px;
border-radius: 6px;
margin-top: 12px;
font-size: 14px;
font-weight: 500;
}
20 changes: 20 additions & 0 deletions pages/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,21 @@ export default function Dashboard ({ user }: PaybuttonsProps): React.ReactElemen
}
}, [selectedButtonIds])

useEffect(() => {
if (dashboardData?.cacheRebuilding !== true) return
const timer = setTimeout(() => {
let url = 'api/dashboard'
if (selectedButtonIds.length > 0) {
url += `?buttonIds=${selectedButtonIds.join(',')}`
}
fetch(url, { headers: { Timezone: moment.tz.guess() } })
.then(async res => await res.json())
.then(json => { setDashboardData(json) })
.catch(console.error)
}, 15000)
return () => clearTimeout(timer)
}, [dashboardData?.cacheRebuilding, selectedButtonIds])
Comment on lines +143 to +156

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

Polling stops after the first refresh.

This effect only depends on the boolean dashboardData?.cacheRebuilding. If the first refresh still returns cacheRebuilding: true, the dependencies do not change, so no second timer is scheduled. Long-running rebuilds will therefore update once and then stall.

Suggested fix
   useEffect(() => {
     if (dashboardData?.cacheRebuilding !== true) return
-    const timer = setTimeout(() => {
-      let url = 'api/dashboard'
-      if (selectedButtonIds.length > 0) {
-        url += `?buttonIds=${selectedButtonIds.join(',')}`
-      }
-      fetch(url, { headers: { Timezone: moment.tz.guess() } })
-        .then(async res => await res.json())
-        .then(json => { setDashboardData(json) })
-        .catch(console.error)
-    }, 15000)
-    return () => clearTimeout(timer)
-  }, [dashboardData?.cacheRebuilding, selectedButtonIds])
+    const interval = setInterval(() => {
+      let url = 'api/dashboard'
+      if (selectedButtonIds.length > 0) {
+        url += `?buttonIds=${selectedButtonIds.join(',')}`
+      }
+      fetch(url, { headers: { Timezone: moment.tz.guess() } })
+        .then(async res => await res.json())
+        .then(json => { setDashboardData(json) })
+        .catch(console.error)
+    }, 15000)
+    return () => clearInterval(interval)
+  }, [dashboardData, selectedButtonIds])
🤖 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 `@pages/dashboard/index.tsx` around lines 143 - 156, The current useEffect uses
a single setTimeout based on dashboardData?.cacheRebuilding and
selectedButtonIds, but if the fetched response still has cacheRebuilding: true
the effect’s dependency value doesn’t change and no further poll is scheduled;
change to an explicit polling loop: implement a local async function (e.g.,
pollDashboard) inside the effect that fetches via the same URL logic (using
selectedButtonIds and moment.tz.guess()), calls setDashboardData(json), and if
json.cacheRebuilding === true schedules itself with setTimeout to run again (or
immediately re-invoke after await) so polling continues until cacheRebuilding
becomes false; ensure you clear the pending timeout on cleanup and reference the
same symbols (useEffect, selectedButtonIds, pollDashboard, setDashboardData,
dashboardData?.cacheRebuilding, timer) so the loop respects selection changes
and is cancelled properly.


useEffect(() => {
setPeriodFromString(dashboardData, activePeriodString)
if (dashboardData !== undefined) {
Expand Down Expand Up @@ -203,6 +218,11 @@ export default function Dashboard ({ user }: PaybuttonsProps): React.ReactElemen
</div>
</div>
)}
{dashboardData.cacheRebuilding === true && (
<div className={style.cache_banner}>
Data still being calculated. Values will update automatically.
</div>
)}
<div className={style.number_ctn}>
<NumberBlock value={'$'.concat(formatQuoteValue(activePeriod.totalRevenue, user.userProfile.preferredCurrencyId)) } text='Revenue' />
<NumberBlock value={activePeriod.totalPayments} text='Payments' />
Expand Down
17 changes: 17 additions & 0 deletions prisma-local/clientInstance.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient
let backgroundPrisma: PrismaClient

interface CustomNodeJsGlobal extends NodeJS.Global {
prisma: PrismaClient
backgroundPrisma: PrismaClient
}
declare const global: CustomNodeJsGlobal

const backgroundDbUrl = (process.env.DATABASE_URL ?? '').replace(
/connection_limit=\d+/,
'connection_limit=3'

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.

Maybe make it a constant?

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 took it out, we can change that in .env already

)

if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
backgroundPrisma = new PrismaClient({
datasources: { db: { url: backgroundDbUrl } }
})
} else {
if (global.prisma === undefined) {
global.prisma = new PrismaClient()
}
if (global.backgroundPrisma === undefined) {
global.backgroundPrisma = new PrismaClient({
datasources: { db: { url: backgroundDbUrl } }
})
}

prisma = global.prisma
backgroundPrisma = global.backgroundPrisma
}

export default prisma
export { backgroundPrisma }

// https://github.com/prisma/prisma/issues/1983#issuecomment-620621213
19 changes: 17 additions & 2 deletions redis/dashboardCache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { redis } from './clientInstance'
import { getPaymentStream } from 'redis/paymentCache'
import { getPaymentStream, isBackgroundRebuildActive } from 'redis/paymentCache'
import { ChartData, DashboardData, Payment, ButtonData, PaymentDataByButton, ChartColor, PeriodData, ButtonDisplayData } from './types'
import { Prisma } from '@prisma/client'
import moment, { DurationInputArg2 } from 'moment'
Expand Down Expand Up @@ -379,7 +379,14 @@ export const getUserDashboardData = async function (userId: string, timezone: st
timezone,
paybuttonIds
)
await cacheDashboardData(userId, dashboardData)

const rebuilding = isBackgroundRebuildActive(userId)
if (rebuilding) {
dashboardData.cacheRebuilding = true
await cacheDashboardDataWithTTL(userId, dashboardData, 30)
} else {
await cacheDashboardData(userId, dashboardData)
}
return dashboardData
}
return dashboardData
Expand All @@ -393,6 +400,14 @@ export const cacheDashboardData = async (userId: string, dashboardData: Dashboar
await redis.set(key, JSON.stringify(cachable))
}

const cacheDashboardDataWithTTL = async (userId: string, dashboardData: DashboardData, ttlSeconds: number): Promise<void> => {
const key = getDashboardSummaryKey(userId)
const {
...cachable
} = dashboardData
await redis.set(key, JSON.stringify(cachable), 'EX', ttlSeconds)
}

export const getCachedDashboardData = async (userId: string): Promise<DashboardData | null> => {
const key = getDashboardSummaryKey(userId)
const dashboardString = await redis.get(key)
Expand Down
28 changes: 14 additions & 14 deletions redis/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,34 +76,34 @@ export const CacheSet = {

type MethodName = 'dashboardData' | 'paymentList' | 'addressBalance' | 'paymentsCount'

interface PendingCalls {
[userId: string]: Set<MethodName>
}

export class CacheGet {
private static pendingCalls: PendingCalls = {}
private static readonly pendingPromises = new Map<string, Map<MethodName, Promise<any>>>()

private static async executeCall<T>(
userId: string,
methodName: MethodName,
fn: () => Promise<T>
): Promise<T> {
if (this.pendingCalls[userId] === undefined) {
this.pendingCalls[userId] = new Set()
let userMap = this.pendingPromises.get(userId)
if (userMap === undefined) {
userMap = new Map()
this.pendingPromises.set(userId, userMap)
}

if (this.pendingCalls[userId].has(methodName)) {
throw new Error(`Method "${methodName}" is already being executed for user "${userId}".`)
const existing = userMap.get(methodName)
if (existing !== undefined) {
return await existing as T
}

this.pendingCalls[userId].add(methodName)
const promise = fn()
userMap.set(methodName, promise)

try {
return await fn()
return await promise
} finally {
this.pendingCalls[userId].delete(methodName)
if (this.pendingCalls[userId].size === 0) {
this.pendingCalls[userId] = undefined as unknown as Set<MethodName>
userMap.delete(methodName)
if (userMap.size === 0) {
this.pendingPromises.delete(userId)
}
}
}
Expand Down
Loading