@tanstack/offline-transactions
Version:
Offline-first transaction capabilities for TanStack DB
392 lines (325 loc) • 11.4 kB
text/typescript
import { createTransaction } from '@tanstack/db'
import { DefaultRetryPolicy } from '../retry/RetryPolicy'
import { NonRetriableError } from '../types'
import { withNestedSpan } from '../telemetry/tracer'
import type { KeyScheduler } from './KeyScheduler'
import type { OutboxManager } from '../outbox/OutboxManager'
import type {
OfflineConfig,
OfflineTransaction,
TransactionSignaler,
} from '../types'
const HANDLED_EXECUTION_ERROR = Symbol(`HandledExecutionError`)
export class TransactionExecutor {
private scheduler: KeyScheduler
private outbox: OutboxManager
private config: OfflineConfig
private retryPolicy: DefaultRetryPolicy
private isExecuting = false
private executionPromise: Promise<void> | null = null
private offlineExecutor: TransactionSignaler
private retryTimer: ReturnType<typeof setTimeout> | null = null
constructor(
scheduler: KeyScheduler,
outbox: OutboxManager,
config: OfflineConfig,
offlineExecutor: TransactionSignaler,
) {
this.scheduler = scheduler
this.outbox = outbox
this.config = config
this.retryPolicy = new DefaultRetryPolicy(
Number.POSITIVE_INFINITY,
config.jitter ?? true,
)
this.offlineExecutor = offlineExecutor
}
async execute(transaction: OfflineTransaction): Promise<void> {
this.scheduler.schedule(transaction)
await this.executeAll()
}
async executeAll(): Promise<void> {
if (this.isExecuting) {
return this.executionPromise!
}
this.isExecuting = true
this.executionPromise = this.runExecution()
try {
await this.executionPromise
} finally {
this.isExecuting = false
this.executionPromise = null
}
}
private async runExecution(): Promise<void> {
while (this.scheduler.getPendingCount() > 0) {
if (!this.isOnline()) {
break
}
const transaction = this.scheduler.getNext()
if (!transaction) {
break
}
await this.executeTransaction(transaction)
}
// Schedule next retry after execution completes
this.scheduleNextRetry()
}
private async executeTransaction(
transaction: OfflineTransaction,
): Promise<void> {
try {
await withNestedSpan(
`transaction.execute`,
{
'transaction.id': transaction.id,
'transaction.mutationFnName': transaction.mutationFnName,
'transaction.retryCount': transaction.retryCount,
'transaction.keyCount': transaction.keys.length,
},
async (span) => {
this.scheduler.markStarted(transaction)
if (transaction.retryCount > 0) {
span.setAttribute(`retry.attempt`, transaction.retryCount)
}
try {
const result = await this.runMutationFn(transaction)
this.scheduler.markCompleted(transaction)
await this.outbox.remove(transaction.id)
span.setAttribute(`result`, `success`)
this.offlineExecutor.resolveTransaction(transaction.id, result)
} catch (error) {
const err =
error instanceof Error ? error : new Error(String(error))
span.setAttribute(`result`, `error`)
await this.handleError(transaction, err)
;(err as any)[HANDLED_EXECUTION_ERROR] = true
throw err
}
},
)
} catch (error) {
if (
error instanceof Error &&
(error as any)[HANDLED_EXECUTION_ERROR] === true
) {
return
}
throw error
}
}
private async runMutationFn(transaction: OfflineTransaction): Promise<void> {
const mutationFn = this.config.mutationFns[transaction.mutationFnName]
if (!mutationFn) {
const errorMessage = `Unknown mutation function: ${transaction.mutationFnName}`
if (this.config.onUnknownMutationFn) {
this.config.onUnknownMutationFn(transaction.mutationFnName, transaction)
}
throw new NonRetriableError(errorMessage)
}
// Mutations are already PendingMutation objects with collections attached
// from the deserializer, so we can use them directly
const transactionWithMutations = {
id: transaction.id,
mutations: transaction.mutations,
metadata: transaction.metadata ?? {},
}
await mutationFn({
transaction: transactionWithMutations as any,
idempotencyKey: transaction.idempotencyKey,
})
}
private async handleError(
transaction: OfflineTransaction,
error: Error,
): Promise<void> {
return withNestedSpan(
`transaction.handleError`,
{
'transaction.id': transaction.id,
'error.name': error.name,
'error.message': error.message,
},
async (span) => {
const shouldRetry = this.retryPolicy.shouldRetry(
error,
transaction.retryCount,
)
span.setAttribute(`shouldRetry`, shouldRetry)
if (!shouldRetry) {
this.scheduler.markCompleted(transaction)
await this.outbox.remove(transaction.id)
console.warn(
`Transaction ${transaction.id} failed permanently:`,
error,
)
span.setAttribute(`result`, `permanent_failure`)
// Signal permanent failure to the waiting transaction
this.offlineExecutor.rejectTransaction(transaction.id, error)
return
}
const delay = Math.max(
0,
this.retryPolicy.calculateDelay(transaction.retryCount),
)
const updatedTransaction: OfflineTransaction = {
...transaction,
retryCount: transaction.retryCount + 1,
nextAttemptAt: Date.now() + delay,
lastError: {
name: error.name,
message: error.message,
stack: error.stack,
},
}
span.setAttribute(`retryDelay`, delay)
span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
this.scheduler.markFailed(transaction)
this.scheduler.updateTransaction(updatedTransaction)
try {
await this.outbox.update(transaction.id, updatedTransaction)
span.setAttribute(`result`, `scheduled_retry`)
} catch (persistError) {
span.recordException(persistError as Error)
span.setAttribute(`result`, `persist_failed`)
throw persistError
}
// Schedule retry timer
this.scheduleNextRetry()
},
)
}
async loadPendingTransactions(): Promise<void> {
const transactions = await this.outbox.getAll()
let filteredTransactions = transactions
if (this.config.beforeRetry) {
filteredTransactions = this.config.beforeRetry(transactions)
}
for (const transaction of filteredTransactions) {
this.scheduler.schedule(transaction)
}
// Restore optimistic state for loaded transactions
// This ensures the UI shows the optimistic data while transactions are pending
this.restoreOptimisticState(filteredTransactions)
// Reset retry delays for all loaded transactions so they can run immediately
this.resetRetryDelays()
// Schedule retry timer for loaded transactions
this.scheduleNextRetry()
const removedTransactions = transactions.filter(
(tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id),
)
if (removedTransactions.length > 0) {
await this.outbox.removeMany(removedTransactions.map((tx) => tx.id))
}
}
/**
* Restore optimistic state from loaded transactions.
* Creates internal transactions to hold the mutations so the collection's
* state manager can show optimistic data while waiting for sync.
*/
private restoreOptimisticState(
transactions: Array<OfflineTransaction>,
): void {
for (const offlineTx of transactions) {
if (offlineTx.mutations.length === 0) {
continue
}
try {
// Create a restoration transaction that holds mutations for optimistic state display.
// It will never commit - the real mutation is handled by the offline executor.
const restorationTx = createTransaction({
id: offlineTx.id,
autoCommit: false,
mutationFn: async () => {},
})
// Prevent unhandled promise rejection when cleanup calls rollback()
// We don't care about this promise - it's just for holding mutations
restorationTx.isPersisted.promise.catch(() => {
// Intentionally ignored - restoration transactions are cleaned up
// via cleanupRestorationTransaction, not through normal commit flow
})
restorationTx.applyMutations(offlineTx.mutations)
// Register with each affected collection's state manager
const touchedCollections = new Set<string>()
for (const mutation of offlineTx.mutations) {
// Defensive check for corrupted deserialized data
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!mutation.collection) {
continue
}
const collectionId = mutation.collection.id
if (touchedCollections.has(collectionId)) {
continue
}
touchedCollections.add(collectionId)
mutation.collection._state.transactions.set(
restorationTx.id,
restorationTx,
)
mutation.collection._state.recomputeOptimisticState(true)
}
this.offlineExecutor.registerRestorationTransaction(
offlineTx.id,
restorationTx,
)
} catch (error) {
console.warn(
`Failed to restore optimistic state for transaction ${offlineTx.id}:`,
error,
)
}
}
}
clear(): void {
this.scheduler.clear()
this.clearRetryTimer()
}
getPendingCount(): number {
return this.scheduler.getPendingCount()
}
private scheduleNextRetry(): void {
// Clear existing timer
this.clearRetryTimer()
if (!this.isOnline()) {
return
}
// Find the earliest retry time among pending transactions
const earliestRetryTime = this.getEarliestRetryTime()
if (earliestRetryTime === null) {
return // No transactions pending retry
}
const delay = Math.max(0, earliestRetryTime - Date.now())
this.retryTimer = setTimeout(() => {
this.executeAll().catch((error) => {
console.warn(`Failed to execute retry batch:`, error)
})
}, delay)
}
private getEarliestRetryTime(): number | null {
const allTransactions = this.scheduler.getAllPendingTransactions()
if (allTransactions.length === 0) {
return null
}
return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt))
}
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer)
this.retryTimer = null
}
}
private isOnline(): boolean {
return this.offlineExecutor.isOnline()
}
getRunningCount(): number {
return this.scheduler.getRunningCount()
}
resetRetryDelays(): void {
const allTransactions = this.scheduler.getAllPendingTransactions()
const updatedTransactions = allTransactions.map((transaction) => ({
...transaction,
nextAttemptAt: Date.now(),
}))
this.scheduler.updateTransactions(updatedTransactions)
}
}