Skip to content

Commit 0a6ba03

Browse files
committed
Advance Sideshift cursor past skipped orders to prevent stall
1 parent b308478 commit 0a6ba03

2 files changed

Lines changed: 68 additions & 2 deletions

File tree

src/partners/sideshift.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,18 @@ const defaultFetchSideshiftOrders: FetchSideshiftOrders = async (
309309
return asSideshiftResult(jsonObj)
310310
}
311311

312+
// Reads only the timestamp from a raw order. Used to advance an account's
313+
// cursor past an order that processOrder rejects (e.g. an unmapped coin or
314+
// network), so a page of only unprocessable orders can never stall the query
315+
// window. Returns undefined when the raw order has no usable createdAt.
316+
const asRawOrderDate = asMaybe(asObject({ createdAt: asString }))
317+
318+
const rawOrderIsoDate = (rawTx: unknown): string | undefined => {
319+
const parsed = asRawOrderDate(rawTx)
320+
if (parsed == null) return undefined
321+
return smartIsoDateFromTimestamp(parsed.createdAt).isoDate
322+
}
323+
312324
// Queries the completed orders for a single affiliate account, advancing its
313325
// own cursor. Mirrors the original per-account query/signature/retry/time-block
314326
// behavior exactly; only the fetch + per-order processing are abstracted so the
@@ -347,18 +359,28 @@ async function querySideshiftAccount(
347359
// the plugin maps) must not stall the account. Without skipping, the
348360
// throw aborts the page, the cursor never advances past it, and the
349361
// account retries-then-gives-up every cycle forever. Log the gap so
350-
// it can be mapped, then move on.
362+
// it can be mapped, then advance the cursor past this order's own
363+
// timestamp so the window still moves forward even when an entire
364+
// page is unprocessable.
351365
log.warn(
352366
`${account.affiliateId} skipping unprocessable order: ${String(e)}`
353367
)
368+
const skippedIsoDate = rawOrderIsoDate(rawTx)
369+
if (skippedIsoDate != null && skippedIsoDate > latestIsoDate) {
370+
latestIsoDate = skippedIsoDate
371+
}
354372
continue
355373
}
356374
standardTxs.push(standardTx)
357375
if (standardTx.isoDate > latestIsoDate) {
358376
latestIsoDate = standardTx.isoDate
359377
}
360378
}
361-
startTime = new Date(latestIsoDate).getTime()
379+
const advancedTime = new Date(latestIsoDate).getTime()
380+
// Guarantee forward progress even if every order in this block was skipped
381+
// without a usable timestamp, so the window can never spin on the same
382+
// startTime until the engine timeout.
383+
startTime = advancedTime > startTime ? advancedTime : endTime
362384
log(`${account.affiliateId} latestIsoDate ${latestIsoDate}`)
363385
if (endTime > now) {
364386
break

test/sideshift.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,48 @@ describe('querySideshift dual-account merge', function() {
299299
'2025-06-03T00:00:00.000Z'
300300
)
301301
})
302+
303+
it('advances past a page of only unprocessable orders instead of spinning forever', async function() {
304+
// Mirrors the live API re-serving the same window: a page containing ONLY
305+
// unprocessable orders keeps coming back until the cursor advances past
306+
// their timestamps. Without advancing the cursor on skip, the loop spins on
307+
// the same startTime until the engine timeout. makeFakeFetcher cannot model
308+
// this (it serves once then empty), so use a startTime-gated fetcher that
309+
// aborts loudly if the loop fails to terminate.
310+
const badDate = '2025-04-10T00:00:00.000Z'
311+
const badTime = new Date(badDate).getTime()
312+
let fetchCount = 0
313+
const fetchOrders = async (
314+
account: FakeAccount,
315+
startTime: number
316+
): Promise<unknown[]> => {
317+
fetchCount++
318+
if (fetchCount > 50) throw new Error('query loop did not terminate')
319+
if (startTime <= badTime) return [{ id: 'bad', createdAt: badDate }]
320+
return []
321+
}
322+
const alwaysThrows = async (): Promise<StandardTx> => {
323+
throw new Error('Unknown coin: FOO on network bar')
324+
}
325+
326+
const result = await querySideshift(
327+
{
328+
apiKeys: {
329+
sideshiftAffiliateId: 'PRIMARY',
330+
sideshiftAffiliateSecret: 'sprimary'
331+
},
332+
settings: {},
333+
log: noopLog
334+
},
335+
fetchOrders,
336+
alwaysThrows
337+
)
338+
339+
// No orders are reportable, but the account still advances its cursor past
340+
// the unprocessable window and the loop terminates.
341+
expect(result.transactions).to.deep.equal([])
342+
expect(new Date(result.settings.accounts.PRIMARY).getTime()).to.be.at.least(
343+
badTime
344+
)
345+
})
302346
})

0 commit comments

Comments
 (0)