Repository navigation
fix(db): recover a live query's demand when its source recovers from error #2129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/db': patch | ||
| --- | ||
|
|
||
| A live query now recovers when a source Collection errors and then becomes ready again. Before, a subset load that rejected because its source entered `error` put a query with a join or include into a terminal error, and a direct query became ready on recovery without loading its subset again. The load is now requested again when the source recovers, and the query becomes ready when that load settles. A load that rejects while its source is healthy still puts the query in error. |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,6 +113,8 @@ type SubsetDemand = { | |
| acquisition: SubsetAcquisitionRecord | ||
| acquisitionState: `starting` | `active` | `detached` | ||
| initialResult?: Deferred<void> | ||
| /** Detached by a source error; recovery acquires it again. */ | ||
| awaitsErrorRecovery?: true | ||
| } | ||
|
|
||
| type TruncateReplayAttempt = { | ||
|
|
@@ -145,6 +147,26 @@ function cancelAcquisition(acquisition: SubsetAcquisitionRecord): void { | |
| acquisition.removeRequestAbortListener?.() | ||
| } | ||
|
|
||
| /** | ||
| * Wait for detached demand to be acquired again: the restart replay settles | ||
| * the wait, and release or abort rejects it with `AbortError`. | ||
| */ | ||
| function awaitRecovery(demand: SubsetDemand): Promise<void> { | ||
| if (demand.initialResult) return demand.initialResult.promise | ||
| const initialResult = createDeferred<void>() | ||
| demand.initialResult = initialResult | ||
| const signal = demand.requestOptions.signal | ||
| const abort = () => | ||
| initialResult.reject(new LoadSubsetOperationAbortedError()) | ||
| signal?.addEventListener(`abort`, abort, { once: true }) | ||
| const finish = () => { | ||
| signal?.removeEventListener(`abort`, abort) | ||
| demand.initialResult = undefined | ||
| } | ||
| void initialResult.promise.then(finish, finish) | ||
| return initialResult.promise | ||
| } | ||
|
|
||
| export class CollectionSubscription | ||
| extends EventEmitter<SubscriptionEvents> | ||
| implements Subscription | ||
|
|
@@ -371,6 +393,8 @@ export class CollectionSubscription | |
| ) | ||
| const attempt = replayState.currentAttempt | ||
| this.truncateReplayState = replayState | ||
| // From here the replay's own loading status holds dependent readiness. | ||
| for (const demand of demands) delete demand.awaitsErrorRecovery | ||
| this.setStatus(`loadingSubset`) | ||
| if (this.truncateReplayState !== replayState) return | ||
| this.startTruncateReplayAttempt(replayState, attempt, demands) | ||
|
|
@@ -895,6 +919,7 @@ export class CollectionSubscription | |
| if (!(syncResult instanceof Promise)) return | ||
|
|
||
| const syncRunGeneration = this.collection._sync.getSyncRunGeneration() | ||
| const acquisition = demand.acquisition | ||
| const participant = { demand, promise: syncResult } | ||
|
|
||
| if (trackStatus) { | ||
|
|
@@ -912,6 +937,10 @@ export class CollectionSubscription | |
| } | ||
|
|
||
| void syncResult.then(finish, (error: unknown) => { | ||
| if (this.detachForErrorRecovery(demand, acquisition)) { | ||
| finish() | ||
| return | ||
| } | ||
| if ( | ||
| this.isSyncRunGenerationCurrent(syncRunGeneration) && | ||
| shouldReportError() | ||
|
|
@@ -925,6 +954,53 @@ export class CollectionSubscription | |
| }) | ||
| } | ||
|
|
||
| /** | ||
| * A load that fails while its source Collection is in error belongs to that | ||
| * source error, not to the request. Its demand detaches, and recovery | ||
| * acquires it again, as for demand requested during the error. | ||
| */ | ||
| private detachForErrorRecovery( | ||
| demand: SubsetDemand, | ||
| acquisition: SubsetAcquisitionRecord, | ||
| ): boolean { | ||
| if ( | ||
| this.collection.status !== `error` || | ||
| demand.acquisition !== acquisition || | ||
| demand.acquisitionState !== `active` || | ||
| !this.isDemandActive(demand) || | ||
| !this.isSyncRunGenerationCurrent(acquisition.syncRunGeneration) || | ||
| demand.requestOptions.signal?.aborted | ||
| ) { | ||
| return false | ||
| } | ||
| cancelAcquisition(acquisition) | ||
| demand.acquisitionState = `detached` | ||
| demand.acquisition = { | ||
| options: demand.requestOptions, | ||
| syncRunGeneration: acquisition.syncRunGeneration, | ||
| } | ||
| demand.awaitsErrorRecovery = true | ||
| void awaitRecovery(demand) | ||
| return true | ||
|
Comment on lines
+982
to
+984
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Handle rejection of the internally created recovery wait. If an active root load rejects during a source error, this call creates a new 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Whether demand detached by a source error waits for its restart. A | ||
| * dependent's readiness check can run on the source's `ready` before this | ||
| * subscription starts the restart replay. | ||
| */ | ||
| public get awaitsErrorRecovery(): boolean { | ||
| return this.subsetDemands.some((demand) => demand.awaitsErrorRecovery) | ||
| } | ||
|
|
||
| /** Settles when every demand detached by a source error is acquired again. */ | ||
| public get pendingErrorRecovery(): Promise<void> | undefined { | ||
| const waits = this.subsetDemands.flatMap((demand) => | ||
| demand.awaitsErrorRecovery ? [awaitRecovery(demand)] : [], | ||
| ) | ||
| return waits.length > 0 ? Promise.all(waits).then(() => {}) : undefined | ||
| } | ||
|
|
||
| /** Give every logical observer of one transport rejection the same Error. */ | ||
| private normalizeLoadSubsetPromiseError( | ||
| promise: Promise<unknown>, | ||
|
|
@@ -1049,17 +1125,7 @@ export class CollectionSubscription | |
| ) { | ||
| demand.acquisitionState = `detached` | ||
| this.subsetDemands.push(demand) | ||
| const initialResult = createDeferred<void>() | ||
| demand.initialResult = initialResult | ||
| const abort = () => | ||
| initialResult.reject(new LoadSubsetOperationAbortedError()) | ||
| requestOptions.signal?.addEventListener(`abort`, abort, { once: true }) | ||
| const finish = () => { | ||
| requestOptions.signal?.removeEventListener(`abort`, abort) | ||
| demand.initialResult = undefined | ||
| } | ||
| void initialResult.promise.then(finish, finish) | ||
| return { demand, result: initialResult.promise, started: false } | ||
| return { demand, result: awaitRecovery(demand), started: false } | ||
| } | ||
| const acquisition = this.createSubsetAcquisitionRecord(demand) | ||
| demand.acquisition = acquisition | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Release the failed acquisition before replacing its record.
cancelAcquisitionaborts the signal but does not callunloadSubset. After this code marks the demand detached, replay and teardown can no longer release the old acquisition. An adapter that retains a lease untilunloadSubsetkeeps that lease across recovery and after demand release. Retire the old acquisition through the one-shot release path before replacing its record. Handle a release failure without losing the demand's state.🤖 Prompt for AI Agents