Skip to content

Commit b73bfd3

Browse files
fix: preserve offline runtime correctness across replay and restart (#1837)
* fix(offline-transactions): preserve runtime correctness Co-authored-by: Colton Demetriou <cdemetriou@valinor.co> * fix(offline-transactions): reject unrestorable scalars * fix(offline-transactions): address runtime review gaps * fix(offline-transactions): preserve metadata JSON semantics * test(offline-transactions): validate Temporal tags once * fix(offline-transactions): settle successful replay removals * docs: clarify offline retry liveness scope * fix(offline-transactions): isolate cleanup failures * fix(offline-transactions): await initial RN connectivity --------- Co-authored-by: Colton Demetriou <cdemetriou@valinor.co>
1 parent 9cbc885 commit b73bfd3

15 files changed

Lines changed: 2266 additions & 148 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/offline-transactions': patch
3+
---
4+
5+
Fix offline replay filtering so it preserves concurrently admitted and issued transactions, wait for React Native's subscribed connectivity snapshot before reporting online, and preserve Temporal scalar identity through storage and restart when the runtime provides `globalThis.Temporal`. Filtered replay work now settles and rolls back after each successful durable removal even when a sibling removal fails, while a failed filtered-work cleanup no longer aborts unrelated startup replay. Successful and permanently failed provider work settles its own caller even when durable cleanup also fails, retry scheduling remains live when a retry update or permanent-failure removal fails, and metadata keeps standard `toJSON(key)` replacement semantics. Recognized native scalars now fail before storage when the matching global constructor is unavailable.
6+
7+
Offline storage compatibility: new records use `valueEncoding: 3`. Older clients cannot read these records, so do not run old and new clients against the same pending outbox or downgrade while new records remain pending.

docs/contributing/oracle-coverage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ does not award oracle credit for a filename alone.
8282
| [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, hostile lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. |
8383
| [#1834](https://github.com/TanStack/db/pull/1834) | Explicit oracle | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` owns join/predicate equality equivalence across the established value domains, binary/string and nullish controls, replacement histories, raw lazy demand, and scan/auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. |
8484
| [#1835](https://github.com/TanStack/db/pull/1835) | Explicit oracle | The existing `packages/db/tests/collection-state-retention-oracle.property.test.ts` and `packages/db/tests/optimistic-transaction-oracle.property.test.ts` owners cover separate collection-state and transaction-history laws. Both were already registered in `@tanstack/db`'s `test:oracles` campaign; focused storage/local-only tests remain collateral. |
85+
| [#1837](https://github.com/TanStack/db/pull/1837) | Explicit oracle | The offline scheduler, leadership replay, and serializer owners cover selective replay retirement, durable per-ID settlement, stale-read fencing, lifecycle recovery, native scalar encoding, and prior wire compatibility. They run in the package test campaign; generated owners expose `OFFLINE_ORACLE_{SEED,PATH,RUNS}` or the scheduler's `TANSTACK_DB_OFFLINE_ORACLE_*` replay interface. |
8586
| [#1842](https://github.com/TanStack/db/pull/1842) | No shipped-law case | The PR changed only focused observer tests and introduced no production behavior. `packages/db/tests/live-query-observer.test.ts` remains the correct evidence; no synthetic oracle or campaign claim is added. |
8687

8788
[PR #1816](https://github.com/TanStack/db/pull/1816) preserves existing witnesses,

packages/offline-transactions/src/OfflineExecutor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ export class OfflineExecutor {
101101
this.initResolve = resolve
102102
this.initReject = reject
103103
})
104-
104+
// Handle constructor-started rejection; waitForInit still observes it.
105+
void this.initPromise.catch(() => {})
105106
this.initialize()
106107
}
107108

packages/offline-transactions/src/connectivity/ReactNativeOnlineDetector.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class ReactNativeOnlineDetector implements OnlineDetector {
1414
private netInfoUnsubscribe: (() => void) | null = null
1515
private appStateSubscription: NativeEventSubscription | null = null
1616
private isListening = false
17-
private wasConnected = true
17+
private wasConnected = false
1818

1919
constructor() {
2020
this.startListening()
@@ -27,16 +27,6 @@ export class ReactNativeOnlineDetector implements OnlineDetector {
2727

2828
this.isListening = true
2929

30-
if (typeof NetInfo.fetch === `function`) {
31-
void NetInfo.fetch()
32-
.then((state) => {
33-
this.wasConnected = this.toConnectivityState(state)
34-
})
35-
.catch(() => {
36-
// Ignore initial fetch failures and rely on subscription updates.
37-
})
38-
}
39-
4030
// Subscribe to network state changes
4131
this.netInfoUnsubscribe = NetInfo.addEventListener((state) => {
4232
const isConnected = this.toConnectivityState(state)

packages/offline-transactions/src/executor/KeyScheduler.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { OfflineTransaction } from '../types'
33

44
export class KeyScheduler {
55
private pendingTransactions: Array<OfflineTransaction> = []
6-
private isRunning = false
6+
private activeTransactionId: string | undefined
77

88
schedule(transaction: OfflineTransaction): boolean {
99
return withSyncSpan(
@@ -35,7 +35,10 @@ export class KeyScheduler {
3535
`scheduler.getNext`,
3636
{ pendingCount: this.pendingTransactions.length },
3737
(span) => {
38-
if (this.isRunning || this.pendingTransactions.length === 0) {
38+
if (
39+
this.activeTransactionId !== undefined ||
40+
this.pendingTransactions.length === 0
41+
) {
3942
span.setAttribute(`result`, `empty`)
4043
return undefined
4144
}
@@ -59,17 +62,17 @@ export class KeyScheduler {
5962
return Date.now() >= transaction.nextAttemptAt
6063
}
6164

62-
markStarted(_transaction: OfflineTransaction): void {
63-
this.isRunning = true
65+
markStarted(transaction: OfflineTransaction): void {
66+
this.activeTransactionId = transaction.id
6467
}
6568

6669
markCompleted(transaction: OfflineTransaction): void {
6770
this.removeTransaction(transaction)
68-
this.isRunning = false
71+
this.activeTransactionId = undefined
6972
}
7073

7174
markFailed(_transaction: OfflineTransaction): void {
72-
this.isRunning = false
75+
this.activeTransactionId = undefined
7376
}
7477

7578
private removeTransaction(transaction: OfflineTransaction): void {
@@ -99,12 +102,23 @@ export class KeyScheduler {
99102
}
100103

101104
getRunningCount(): number {
102-
return this.isRunning ? 1 : 0
105+
return this.activeTransactionId === undefined ? 0 : 1
103106
}
104107

105108
clear(): void {
106109
this.pendingTransactions = []
107-
this.isRunning = false
110+
this.activeTransactionId = undefined
111+
}
112+
113+
/** @internal Reconcile one replay snapshot without canceling issued work. */
114+
removePendingTransactions(transactionIds: Iterable<string>): Array<string> {
115+
const ids = new Set(transactionIds)
116+
if (this.activeTransactionId !== undefined)
117+
ids.delete(this.activeTransactionId)
118+
this.pendingTransactions = this.pendingTransactions.filter(
119+
({ id }) => !ids.has(id),
120+
)
121+
return [...ids]
108122
}
109123

110124
getAllPendingTransactions(): Array<OfflineTransaction> {

packages/offline-transactions/src/executor/TransactionExecutor.ts

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export class TransactionExecutor {
5656
} finally {
5757
this.isExecuting = false
5858
this.executionPromise = null
59+
this.scheduleNextRetry()
5960
}
6061
}
6162

@@ -73,9 +74,6 @@ export class TransactionExecutor {
7374

7475
await this.executeTransaction(transaction)
7576
}
76-
77-
// Schedule next retry after execution completes
78-
this.scheduleNextRetry()
7977
}
8078

8179
private async executeTransaction(
@@ -97,18 +95,9 @@ export class TransactionExecutor {
9795
span.setAttribute(`retry.attempt`, transaction.retryCount)
9896
}
9997

98+
let result: void
10099
try {
101-
const result = await this.runMutationFn(transaction)
102-
103-
try {
104-
// Replay can still see this ID until durable deletion settles.
105-
await this.outbox.remove(transaction.id)
106-
} finally {
107-
this.scheduler.markCompleted(transaction)
108-
}
109-
110-
span.setAttribute(`result`, `success`)
111-
this.offlineExecutor.resolveTransaction(transaction.id, result)
100+
result = await this.runMutationFn(transaction)
112101
} catch (error) {
113102
const err =
114103
error instanceof Error ? error : new Error(String(error))
@@ -119,6 +108,20 @@ export class TransactionExecutor {
119108
;(err as any)[HANDLED_EXECUTION_ERROR] = true
120109
throw err
121110
}
111+
112+
let removalError: unknown
113+
try {
114+
// Replay can still see this ID until durable deletion settles.
115+
await this.outbox.remove(transaction.id)
116+
} catch (error) {
117+
removalError = error
118+
} finally {
119+
this.scheduler.markCompleted(transaction)
120+
}
121+
122+
span.setAttribute(`result`, `success`)
123+
this.offlineExecutor.resolveTransaction(transaction.id, result)
124+
if (removalError !== undefined) throw removalError
122125
},
123126
)
124127
} catch (error) {
@@ -180,8 +183,14 @@ export class TransactionExecutor {
180183
span.setAttribute(`shouldRetry`, shouldRetry)
181184

182185
if (!shouldRetry) {
183-
this.scheduler.markCompleted(transaction)
184-
await this.outbox.remove(transaction.id)
186+
let removalError: unknown
187+
try {
188+
await this.outbox.remove(transaction.id)
189+
} catch (cleanupError) {
190+
removalError = cleanupError
191+
} finally {
192+
this.scheduler.markCompleted(transaction)
193+
}
185194
console.warn(
186195
`Transaction ${transaction.id} failed permanently:`,
187196
error,
@@ -190,6 +199,7 @@ export class TransactionExecutor {
190199
span.setAttribute(`result`, `permanent_failure`)
191200
// Signal permanent failure to the waiting transaction
192201
this.offlineExecutor.rejectTransaction(transaction.id, error)
202+
if (removalError !== undefined) throw removalError
193203
return
194204
}
195205

@@ -211,7 +221,6 @@ export class TransactionExecutor {
211221
span.setAttribute(`retryDelay`, delay)
212222
span.setAttribute(`nextRetryCount`, updatedTransaction.retryCount)
213223

214-
this.scheduler.markFailed(transaction)
215224
this.scheduler.updateTransaction(updatedTransaction)
216225

217226
try {
@@ -221,10 +230,9 @@ export class TransactionExecutor {
221230
span.recordException(persistError as Error)
222231
span.setAttribute(`result`, `persist_failed`)
223232
throw persistError
233+
} finally {
234+
this.scheduler.markFailed(transaction)
224235
}
225-
226-
// Schedule retry timer
227-
this.scheduleNextRetry()
228236
},
229237
)
230238
}
@@ -240,13 +248,21 @@ export class TransactionExecutor {
240248
filteredTransactions = this.config.beforeRetry(transactions)
241249
}
242250

243-
// The outbox read or retry hook may outlive this owner's right to replay.
251+
// The retry hook is user code and may synchronously revoke replay rights.
244252
if (!this.offlineExecutor.isOfflineEnabled) return
245253

246254
const newlyLoaded = filteredTransactions.filter((transaction) =>
247255
this.scheduler.schedule(transaction),
248256
)
249257

258+
removedIds = transactions
259+
.filter(
260+
(tx) =>
261+
!filteredTransactions.some((filtered) => filtered.id === tx.id),
262+
)
263+
.map(({ id }) => id)
264+
removedIds = this.scheduler.removePendingTransactions(removedIds)
265+
250266
// Restore optimistic state for loaded transactions
251267
// This ensures the UI shows the optimistic data while transactions are pending
252268
this.restoreOptimisticState(newlyLoaded)
@@ -256,17 +272,24 @@ export class TransactionExecutor {
256272

257273
// Schedule retry timer for loaded transactions
258274
this.scheduleNextRetry()
259-
260-
removedIds = transactions
261-
.filter(
262-
(tx) =>
263-
!filteredTransactions.some((filtered) => filtered.id === tx.id),
264-
)
265-
.map(({ id }) => id)
266275
})
267276

268277
if (removedIds.length > 0) {
269-
await this.outbox.removeMany(removedIds)
278+
const error = new NonRetriableError(`Transaction excluded by beforeRetry`)
279+
await Promise.all(
280+
removedIds.map(async (id) => {
281+
try {
282+
await this.outbox.remove(id)
283+
this.offlineExecutor.rejectTransaction(id, error)
284+
} catch (cleanupError) {
285+
console.warn(
286+
`Failed to remove transaction excluded by beforeRetry:`,
287+
id,
288+
cleanupError,
289+
)
290+
}
291+
}),
292+
)
270293
}
271294
}
272295

packages/offline-transactions/src/outbox/OutboxManager.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { withSpan } from '../telemetry/tracer'
2-
import { TransactionSerializer } from './TransactionSerializer'
2+
import {
3+
MissingTemporalConstructorError,
4+
TransactionSerializer,
5+
} from './TransactionSerializer'
36
import type { OfflineTransaction, StorageAdapter } from '../types'
47
import type { Collection } from '@tanstack/db'
58

@@ -74,6 +77,10 @@ export class OutboxManager {
7477
span.setAttribute(`result`, `found`)
7578
return transaction
7679
} catch (error) {
80+
if (error instanceof MissingTemporalConstructorError) {
81+
error.message = `transaction ${id}: ${error.message}`
82+
throw error
83+
}
7784
console.warn(`Failed to deserialize transaction ${id}:`, error)
7885
span.setAttribute(`result`, `deserialize_error`)
7986
return null
@@ -108,6 +115,10 @@ export class OutboxManager {
108115
const transaction = this.serializer.deserialize(data)
109116
transactions.push(transaction)
110117
} catch (error) {
118+
if (error instanceof MissingTemporalConstructorError) {
119+
error.message = `transaction ${key.slice(this.keyPrefix.length)}: ${error.message}`
120+
throw error
121+
}
111122
console.warn(
112123
`Failed to deserialize transaction from key ${key}:`,
113124
error,

0 commit comments

Comments
 (0)