Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/fix-keyscheduler-strict-fifo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/offline-transactions': patch
---

Fix KeyScheduler to enforce strict FIFO ordering on retries. Previously, `getNextBatch` could skip past a transaction waiting for retry and execute a later one, causing dependent UPDATE-before-INSERT failures.
18 changes: 8 additions & 10 deletions packages/offline-transactions/src/executor/KeyScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,17 @@ export class KeyScheduler {
return []
}

// Find the first transaction that's ready to run
const readyTransaction = this.pendingTransactions.find((tx) =>
this.isReadyToRun(tx),
)
const firstTransaction = this.pendingTransactions[0]!

if (readyTransaction) {
span.setAttribute(`result`, `found`)
span.setAttribute(`transaction.id`, readyTransaction.id)
} else {
span.setAttribute(`result`, `none_ready`)
if (!this.isReadyToRun(firstTransaction)) {
span.setAttribute(`result`, `waiting_for_first`)
span.setAttribute(`transaction.id`, firstTransaction.id)
return []
}

return readyTransaction ? [readyTransaction] : []
span.setAttribute(`result`, `found`)
span.setAttribute(`transaction.id`, firstTransaction.id)
return [firstTransaction]
},
)
}
Expand Down
291 changes: 291 additions & 0 deletions packages/offline-transactions/tests/KeyScheduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { KeyScheduler } from '../src/executor/KeyScheduler'
import type { OfflineTransaction } from '../src/types'

function createTransaction({
id,
createdAt,
nextAttemptAt,
retryCount = 0,
}: {
id: string
createdAt: Date
nextAttemptAt: number
retryCount?: number
}): OfflineTransaction {
return {
id,
mutationFnName: `syncData`,
mutations: [],
keys: [],
idempotencyKey: `idempotency-${id}`,
createdAt,
retryCount,
nextAttemptAt,
metadata: {},
version: 1,
}
}

describe(`KeyScheduler`, () => {
afterEach(() => {
vi.useRealTimers()
})

it(`does not execute a later ready transaction while an earlier retry is pending`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

const firstBatch = scheduler.getNextBatch(1)
expect(firstBatch.map((tx) => tx.id)).toEqual([`first`])

scheduler.markStarted(first)
scheduler.markFailed(first)
scheduler.updateTransaction({
...first,
retryCount: 1,
nextAttemptAt: now.getTime() + 5000,
lastError: {
name: `Error`,
message: `network timeout`,
},
})

const secondBatch = scheduler.getNextBatch(1)
expect(secondBatch).toEqual([])
})

it(`executes the first transaction once its retry delay has elapsed`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime() + 5000,
retryCount: 1,
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

expect(scheduler.getNextBatch(1)).toEqual([])

vi.advanceTimersByTime(5000)

const batch = scheduler.getNextBatch(1)
expect(batch.map((tx) => tx.id)).toEqual([`first`])
})

it(`processes the second transaction after the first completes`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

const batch1 = scheduler.getNextBatch(1)
expect(batch1.map((tx) => tx.id)).toEqual([`first`])

scheduler.markStarted(first)
scheduler.markCompleted(first)

const batch2 = scheduler.getNextBatch(1)
expect(batch2.map((tx) => tx.id)).toEqual([`second`])
})

it(`returns empty batch while a transaction is running`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const tx = createTransaction({
id: `tx1`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(tx)
scheduler.markStarted(tx)

expect(scheduler.getNextBatch(1)).toEqual([])
})

it(`processes second transaction after first retries and succeeds`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

scheduler.markStarted(first)
scheduler.markFailed(first)
scheduler.updateTransaction({
...first,
retryCount: 1,
nextAttemptAt: now.getTime() + 5000,
})

expect(scheduler.getNextBatch(1)).toEqual([])

vi.advanceTimersByTime(5000)

const retryBatch = scheduler.getNextBatch(1)
expect(retryBatch.map((tx) => tx.id)).toEqual([`first`])

scheduler.markStarted(retryBatch[0]!)
scheduler.markCompleted(retryBatch[0]!)

const finalBatch = scheduler.getNextBatch(1)
expect(finalBatch.map((tx) => tx.id)).toEqual([`second`])
})

it(`maintains FIFO order regardless of scheduling order`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const older = createTransaction({
id: `older`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const newer = createTransaction({
id: `newer`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(newer)
scheduler.schedule(older)

const batch = scheduler.getNextBatch(1)
expect(batch.map((tx) => tx.id)).toEqual([`older`])
})

it(`preserves FIFO order after updateTransaction`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime() + 1),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

scheduler.updateTransaction({
...first,
retryCount: 1,
nextAttemptAt: now.getTime() + 5000,
})

vi.advanceTimersByTime(5000)

const batch = scheduler.getNextBatch(1)
expect(batch.map((tx) => tx.id)).toEqual([`first`])
})

it(`returns empty batch when no transactions are scheduled`, () => {
const scheduler = new KeyScheduler()
expect(scheduler.getNextBatch(1)).toEqual([])
})

it(`processes transactions with identical createdAt in scheduling order`, () => {
vi.useFakeTimers()

const now = new Date(`2026-01-01T00:00:00.000Z`)
vi.setSystemTime(now)

const scheduler = new KeyScheduler()
const first = createTransaction({
id: `first`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})
const second = createTransaction({
id: `second`,
createdAt: new Date(now.getTime()),
nextAttemptAt: now.getTime(),
})

scheduler.schedule(first)
scheduler.schedule(second)

const batch1 = scheduler.getNextBatch(1)
expect(batch1.map((tx) => tx.id)).toEqual([`first`])

scheduler.markStarted(first)
scheduler.markCompleted(first)

const batch2 = scheduler.getNextBatch(1)
expect(batch2.map((tx) => tx.id)).toEqual([`second`])
})
})
Loading