Skip to content

fix: server down due to infinite pagination bug#1128

Open
chedieck wants to merge 5 commits into
masterfrom
fix/infinite-pagination-for-large-block
Open

fix: server down due to infinite pagination bug#1128
chedieck wants to merge 5 commits into
masterfrom
fix/infinite-pagination-for-large-block

Conversation

@chedieck
Copy link
Copy Markdown
Collaborator

@chedieck chedieck commented May 7, 2026

Fix 1 — Line 717

Added if (this.isAlreadyBeingProcessed(msg.txid, true)) return to the TX_CONFIRMED handler. The dedup mechanism already existed (and was already used for TX_ADDED_TO_MEMPOOL) but was never called for confirmed txs. Now when the same txid fires 253 times (one per subscribed address), only the first one actually runs. This is safe because getAddressesForTransaction already fetches ALL related addresses for the tx — you don't need N websocket events to cover N addresses.

Fix 2 — Lines 807-809

Changed syncBlockTransactions pagination to use a Set instead of the raw array:

  • new Set(this.confirmedTxsHashesFromLastBlock) deduplicates any remaining duplicates
  • .size instead of .length gives the correct unique count for the termination condition
  • < instead of !== prevents infinite looping if a tx is orphaned/missing from the block
  • .has() instead of .includes() is O(1) vs O(n) per lookup
    With this deployed, the DOIT airdrop scenario goes from 253 sequential Chronik fetches + 253 DB query rounds + broken pagination → 1 fetch + 1 query round + correct early termination.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented duplicate processing of confirmed transactions by skipping repeated confirmations and ensuring failed attempts clear their cached markers.
  • Refactor

    • Improved block sync efficiency by using unique transaction tracking to reduce redundant membership checks and limit queued work.
    • Rewrote historical price sync to detect missing daily prices per network and upsert only those gaps.
  • Chores

    • Removed stray/invalid constant tokens and cleaned up constant formatting.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Warning

Rate limit exceeded

@chedieck has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 37 minutes and 54 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c13bc3a-f861-45ba-8548-14537ac89b96

📥 Commits

Reviewing files that changed from the base of the PR and between ca8e93c and ca970eb.

📒 Files selected for processing (1)
  • services/priceService.ts
📝 Walkthrough

Walkthrough

Adds a confirmed-tx deduplication guard and cache cleanup in WebSocket handling, replaces block-sync linear checks with Set-based filtering, rewrites price sync to upsert only recent missing daily prices, and removes stray tokens in constants.

Changes

Processing & Price-sync Updates

Layer / File(s) Summary
Deduplication Guard & Error Cleanup
services/chronikService.ts
processWsMessage early-returns for TX_CONFIRMED when txid is already being processed and removes failed txid entries from lastProcessedMessages.confirmed after errors.
Block Sync Optimization
services/chronikService.ts
syncBlockTransactions uses a Set of confirmed tx hashes and caps queued txs by unique txid count instead of array includes()/length checks.
PriceService import & Sync Logic
services/priceService.ts
Adjusted imports; syncPastDaysNewerPrices() now queries a recent UTC window, identifies missing XEC/BCH daily timestamps, fetches daily prices per network only when gaps exist, and upserts only missing days.
Constants Cleanup
constants/index.ts
Removed stray numeric tokens and fixed spacing/ordering around MAX_TXS_PER_ADDRESS and N_DAYS_LOOK_FOR_PRICE_GAPS.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • Klakurka
  • lissavxo

Poem

🐰 I hopped through websockets, queues, and sets,
Found duplicate tracks and tidied up debts,
Prices I gathered for days we had missed,
Upserts applied—no more data amiss,
A rabbit applauds the code that now rests.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description clearly explains both fixes with technical details, but the template requires 'Related to #' issue reference and 'Test plan' section which are missing. Add the related issue number after 'Related to #' and include a 'Test plan' section describing how the fixes were validated.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: server down due to infinite pagination bug' directly matches the main changes: fixing infinite pagination in syncBlockTransactions and deduplicating TX_CONFIRMED processing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/infinite-pagination-for-large-block

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/chronikService.ts (1)

717-737: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Transient failures can permanently suppress confirmed-tx reprocessing (Line 717).

isAlreadyBeingProcessed(msg.txid, true) marks the txid before the try. If fetching/processing fails (non-404), the dedup flag remains set, so repeated TX_CONFIRMED messages are ignored and the tx may never be added to confirmedTxsHashesFromLastBlock for block sync.

Suggested fix
       } else if (msg.msgType === 'TX_CONFIRMED') {
-        if (this.isAlreadyBeingProcessed(msg.txid, true)) return
+        if (this.isAlreadyBeingProcessed(msg.txid, true)) return
         try {
           const transaction = await this.fetchTxWithRetry(msg.txid)
           const addressesWithTransactions = await this.getAddressesForTransaction(transaction)
           console.log(`${this.CHRONIK_MSG_PREFIX}: [${msg.msgType}] ${msg.txid}`)
           this.confirmedTxsHashesFromLastBlock = [...this.confirmedTxsHashesFromLastBlock, msg.txid]
           for (const addressWithTransaction of addressesWithTransactions) {
             const { amount, opReturn } = addressWithTransaction.transaction
             await this.handleUpdateClientPaymentStatus(amount, opReturn, 'CONFIRMED' as ClientPaymentStatus, addressWithTransaction.address.address)
           }
         } catch (e: any) {
           const msg404 = String(e?.message ?? e)
           const is404 = /not found in the index|404/.test(msg404)
           if (is404) {
             console.log(`${this.CHRONIK_MSG_PREFIX}: [${msg.msgType}] tx ${msg.txid} not found in chronik, marking as orphaned`)
             await markTransactionsOrphaned(msg.txid)
           } else {
+            delete this.lastProcessedMessages.confirmed[msg.txid]
             console.error(`${this.CHRONIK_MSG_PREFIX}: confirmed tx handler failed for ${msg.txid}`, e)
           }
         }
       }
🤖 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 717 - 737, The code marks a tx as
"being processed" by calling isAlreadyBeingProcessed(msg.txid, true) before the
try block, which leaves the dedup flag set on transient failures so future
TX_CONFIRMED messages are ignored; change the flow so the tx is only
marked-as-processing after a successful fetch/start or ensure the flag is
cleared on non-404 failures: either move the isAlreadyBeingProcessed(..., true)
call to just before/inside successful processing (e.g., after fetchTxWithRetry
or just before awaiting handleUpdateClientPaymentStatus), or add a finally block
that calls the corresponding clear/unmark operation for the dedup flag when an
exception occurs (but keep 404/orphan handling intact and do not clear for true
orphan case if desired); reference functions/fields: isAlreadyBeingProcessed,
fetchTxWithRetry, handleUpdateClientPaymentStatus,
confirmedTxsHashesFromLastBlock, and markTransactionsOrphaned to locate the code
to change.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In `@services/chronikService.ts`:
- Around line 717-737: The code marks a tx as "being processed" by calling
isAlreadyBeingProcessed(msg.txid, true) before the try block, which leaves the
dedup flag set on transient failures so future TX_CONFIRMED messages are
ignored; change the flow so the tx is only marked-as-processing after a
successful fetch/start or ensure the flag is cleared on non-404 failures: either
move the isAlreadyBeingProcessed(..., true) call to just before/inside
successful processing (e.g., after fetchTxWithRetry or just before awaiting
handleUpdateClientPaymentStatus), or add a finally block that calls the
corresponding clear/unmark operation for the dedup flag when an exception occurs
(but keep 404/orphan handling intact and do not clear for true orphan case if
desired); reference functions/fields: isAlreadyBeingProcessed, fetchTxWithRetry,
handleUpdateClientPaymentStatus, confirmedTxsHashesFromLastBlock, and
markTransactionsOrphaned to locate the code to change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69b72758-0ad5-4d5a-a295-957551e438ad

📥 Commits

Reviewing files that changed from the base of the PR and between f1cc9f2 and 3498f84.

📒 Files selected for processing (1)
  • services/chronikService.ts

Fabcien
Fabcien previously approved these changes May 7, 2026
@Klakurka
Copy link
Copy Markdown
Member

Klakurka commented May 7, 2026

Failing test? @chedieck

@Klakurka Klakurka added the bug Something isn't working label May 7, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
services/priceService.ts (1)

156-220: 💤 Low value

Otherwise the rewrite reads well.

Per-network gating of the API fetch (lines 198–199), Set-based missing-day lookup (lines 202, 211), and using moment.utc(price.day).unix() to align the inserted timestamp with the windowed startOf('day') keys are all correct and a clear improvement over the prior implementation.

Two small, optional notes (skip if intentional):

  • The window loop on line 182 is inclusive of both endpoints, so N_DAYS_LOOK_FOR_PRICE_GAPS = 30 actually checks 31 days. The log on line 157/192 says "last 30 days". Either change isSameOrAfter to isAfter, or update the log wording.
  • getAllPricesByNetworkTicker(..., false) swallows errors and returns null; the resulting "no rows upserted" path is silent. Consider an explicit warning log when allXECPrices/allBCHPrices is null so an API outage is distinguishable from "no gaps" in operations.
🤖 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/priceService.ts` around lines 156 - 220, The date-window loop in
syncPastDaysNewerPrices uses cursor.isSameOrAfter(windowStart) which makes the
range inclusive of both endpoints (so N_DAYS_LOOK_FOR_PRICE_GAPS = 30 yields 31
days) — change the loop to use cursor.isAfter(windowStart) or update the console
messages to reflect the inclusive count; additionally, detect when
getAllPricesByNetworkTicker(...) returns null for allXECPrices or allBCHPrices
and emit a warning (use processLogger or console.warn) naming the variables
allXECPrices/allBCHPrices and the network (NETWORK_TICKERS.ecash /
NETWORK_TICKERS.bitcoincash) so API outages are visible instead of silently
doing nothing.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@services/priceService.ts`:
- Around line 162-188: The gap detection is only querying rows with quoteId:
USD_QUOTE_ID so days where USD upsert succeeded but CAD failed will be treated
as present; fix by making the dual-quote upsert atomic in
upsertPricesForNetworkId: wrap the two Prisma upserts (USD and CAD) inside a
single prisma.$transaction(...) so both succeed or both rollback (use
CAD_QUOTE_ID and USD_QUOTE_ID inside the transaction), and keep the
gap-detection logic (existingPrices / xecTimestamps / bchTimestamps) unchanged;
alternatively, if you prefer detection-side fix, change the query that builds
existingPrices to group by timestamp+networkId and require count === N_OF_QUOTES
so only fully-populated (both quotes) days are considered present.

---

Nitpick comments:
In `@services/priceService.ts`:
- Around line 156-220: The date-window loop in syncPastDaysNewerPrices uses
cursor.isSameOrAfter(windowStart) which makes the range inclusive of both
endpoints (so N_DAYS_LOOK_FOR_PRICE_GAPS = 30 yields 31 days) — change the loop
to use cursor.isAfter(windowStart) or update the console messages to reflect the
inclusive count; additionally, detect when getAllPricesByNetworkTicker(...)
returns null for allXECPrices or allBCHPrices and emit a warning (use
processLogger or console.warn) naming the variables allXECPrices/allBCHPrices
and the network (NETWORK_TICKERS.ecash / NETWORK_TICKERS.bitcoincash) so API
outages are visible instead of silently doing nothing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f0db9bd-d81d-4dad-ace9-47811a4197c9

📥 Commits

Reviewing files that changed from the base of the PR and between 3498f84 and ca8e93c.

📒 Files selected for processing (3)
  • constants/index.ts
  • services/chronikService.ts
  • services/priceService.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • services/chronikService.ts

Comment thread services/priceService.ts
Comment on lines +162 to +188
const existingPrices = await prisma.price.findMany({
where: {
timestamp: {
gte: windowStart.unix(),
lte: today.unix()
},
quoteId: USD_QUOTE_ID
},
select: { timestamp: true, networkId: true }
})
if (lastPrice === null) throw new Error('No prices found, initial database seed did not complete successfully')

const lastDateInDB = moment.unix(lastPrice.timestamp)
const date = moment().startOf('day')
const daysToRetrieve: string[] = []
const xecTimestamps = new Set(
existingPrices.filter(p => p.networkId === XEC_NETWORK_ID).map(p => p.timestamp)
)
const bchTimestamps = new Set(
existingPrices.filter(p => p.networkId === BCH_NETWORK_ID).map(p => p.timestamp)
)

const expectedDays: Array<{ formatted: string, timestamp: number }> = []
const cursor = today.clone()
while (cursor.isSameOrAfter(windowStart)) {
expectedDays.push({ formatted: cursor.format(PRICE_API_DATE_FORMAT), timestamp: cursor.unix() })
cursor.subtract(1, 'day')
}

const missingXECDays = expectedDays.filter(d => !xecTimestamps.has(d.timestamp))
const missingBCHDays = expectedDays.filter(d => !bchTimestamps.has(d.timestamp))
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 8, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Gap detection only checks USD; CAD gaps from a partial prior upsert will be missed.

upsertPricesForNetworkId upserts USD and CAD as two separate, non-transactional Prisma calls (see lines 32–68). If the USD upsert succeeds but CAD throws (network blip, timeout, etc.), the row set ends up with USD-only for that day. This new gap-filler queries existing rows with quoteId: USD_QUOTE_ID only, so any USD-without-CAD day will be considered "already present" and will never be backfilled.

Two reasonable options:

  1. Wrap the USD+CAD upserts in prisma.$transaction(...) so they always succeed/fail together (preferred — also fixes the underlying invariant).
  2. Detect gaps based on (timestamp, networkId) having both quotes present, e.g. group by timestamp+networkId and require count = N_OF_QUOTES.
🛡️ Option 1: make the dual upsert atomic (root-cause fix)
 export async function upsertPricesForNetworkId (responseData: IResponseData, networkId: number, timestamp: number): Promise<void> {
   try {
-    await prisma.price.upsert({
-      where: { ... USD ... },
-      ...
-    })
-
-    await prisma.price.upsert({
-      where: { ... CAD ... },
-      ...
-    })
+    await prisma.$transaction([
+      prisma.price.upsert({
+        where: {
+          Price_timestamp_quoteId_networkId_unique_constraint: { quoteId: USD_QUOTE_ID, networkId, timestamp }
+        },
+        create: { quoteId: USD_QUOTE_ID, networkId, timestamp, value: new Prisma.Decimal(responseData.Price_in_USD) },
+        update: { value: new Prisma.Decimal(responseData.Price_in_USD) }
+      }),
+      prisma.price.upsert({
+        where: {
+          Price_timestamp_quoteId_networkId_unique_constraint: { quoteId: CAD_QUOTE_ID, networkId, timestamp }
+        },
+        create: { quoteId: CAD_QUOTE_ID, networkId, timestamp, value: new Prisma.Decimal(responseData.Price_in_CAD) },
+        update: { value: new Prisma.Decimal(responseData.Price_in_CAD) }
+      })
+    ])
🤖 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/priceService.ts` around lines 162 - 188, The gap detection is only
querying rows with quoteId: USD_QUOTE_ID so days where USD upsert succeeded but
CAD failed will be treated as present; fix by making the dual-quote upsert
atomic in upsertPricesForNetworkId: wrap the two Prisma upserts (USD and CAD)
inside a single prisma.$transaction(...) so both succeed or both rollback (use
CAD_QUOTE_ID and USD_QUOTE_ID inside the transaction), and keep the
gap-detection logic (existingPrices / xecTimestamps / bchTimestamps) unchanged;
alternatively, if you prefer detection-side fix, change the query that builds
existingPrices to group by timestamp+networkId and require count === N_OF_QUOTES
so only fully-populated (both quotes) days are considered present.

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.

The bot is correct on that one. You could run the same prisma query for both quote ids and intersect them for finding the gaps, so you will override any price with a missing quote as well.

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.

@Fabcien, great suggestion — querying both quote IDs and intersecting gives you the tightest gap definition: a day is only "complete" if both USD and CAD rows exist. Here is a concrete implementation:

// Fetch rows for both quote IDs in a single query
const existingPrices = await prisma.price.findMany({
  where: {
    timestamp: {
      gte: windowStart.unix(),
      lte: today.unix()
    },
    quoteId: { in: [USD_QUOTE_ID, CAD_QUOTE_ID] }   // ← both quotes
  },
  select: { timestamp: true, networkId: true, quoteId: true }
})

// Count distinct quotes per (networkId, timestamp)
const xecQuoteCounts = new Map<number, number>()
const bchQuoteCounts = new Map<number, number>()

for (const p of existingPrices) {
  if (p.networkId === XEC_NETWORK_ID) {
    xecQuoteCounts.set(p.timestamp, (xecQuoteCounts.get(p.timestamp) ?? 0) + 1)
  } else if (p.networkId === BCH_NETWORK_ID) {
    bchQuoteCounts.set(p.timestamp, (bchQuoteCounts.get(p.timestamp) ?? 0) + 1)
  }
}

// A day is only "present" when all N_OF_QUOTES quotes exist
const xecTimestamps = new Set(
  [...xecQuoteCounts.entries()]
    .filter(([, count]) => count >= N_OF_QUOTES)
    .map(([ts]) => ts)
)
const bchTimestamps = new Set(
  [...bchQuoteCounts.entries()]
    .filter(([, count]) => count >= N_OF_QUOTES)
    .map(([ts]) => ts)
)

The rest of the function (missingXECDays, missingBCHDays, fetch + upsert logic) stays unchanged. Any day where one quote succeeded and the other failed will have a count of 1, fall below N_OF_QUOTES, and be treated as a gap — triggering a full re-upsert of both quotes.

@Klakurka Klakurka requested a review from Fabcien May 8, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants