diff --git a/.changeset/fix-retired-replay-stale-rows.md b/.changeset/fix-retired-replay-stale-rows.md new file mode 100644 index 0000000000..058554c6f0 --- /dev/null +++ b/.changeset/fix-retired-replay-stale-rows.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +A subscriber no longer keeps stale rows after a truncate replay that loses its last demand. When the last requested subset is released while the replay is still loading, nothing can complete the replay. If its current attempt has not failed, the subscriber now receives the rows the source wrote, replaced or deleted during it, instead of keeping the values from before the truncate. A replay whose attempt failed still keeps the last complete snapshot, as before, and a later request updates any row it still shows at an older value. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 7258689d1a..f2f2c7f873 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -681,7 +681,19 @@ The following related oracles own distinct production paths or observation cuts: - The [Collection lifecycle publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts) owns exact public rows and change batches. The [replay oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts) - applies that law to competing replay schedules and same-key writes. + applies that law to competing replay schedules and same-key writes. Both + models publish a replay's rows when its last owner is released and its + current attempt has not failed, and keep a failed one private; the + publication oracle's named histories cover a value written, rewritten or + deleted during the replay, a row no demand names, a failed replay, a + later request that updates a row still shown at an older value, and a row + retained by a failed replay that the source deletes before a later replay + retires. The replay oracle's named witnesses fail each synchronous failure + site (a replay load, an old lease's unload, a new request) and then release + the last owner; removing that site's sticky failure fails its witness. + Limit: the generated grammars reach a failed retirement followed by another + retired replay only rarely (the replay grammar releases one demand, on the + last attempt), so the named histories own that sequence. Bounded three-row histories now compare a subscription's selected keys with callback key/type batches across full, explicit, filtered, and empty snapshots, two later ordered selections, both initial-state modes, and callback reentry. diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index cd5b62bfea..76fbbad68a 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -129,6 +129,8 @@ type TruncateReplayState = { pendingSetups: number currentAttempt: TruncateReplayAttempt failures: Map + /** The current attempt failed, even if its failed demand was released since. */ + failed: boolean completion: Deferred } @@ -408,6 +410,7 @@ export class CollectionSubscription // before a request returns its promise and joins the pending set. replayState.pendingSetups++ replayState.failures.clear() + replayState.failed = false replayState.currentAttempt = { pendingCount: 0, setupComplete: false } } else { // Every overlapping attempt shares one publication baseline and buffer. @@ -471,7 +474,10 @@ export class CollectionSubscription this.isSyncRunGenerationCurrent(replayState.syncRunGeneration) && this.isDemandActive(demand) const fail = (error: unknown) => { - if (isCurrent()) replayState.failures.set(demand, normalizeError(error)) + if (isCurrent()) { + replayState.failures.set(demand, normalizeError(error)) + replayState.failed = true + } } if (demand.initialResult) { void replayState.completion.promise.then( @@ -587,6 +593,7 @@ export class CollectionSubscription ) { const normalized = this.normalizeLoadSubsetPromiseError(result, error) replayState.failures.set(demand, normalized) + replayState.failed = true } this.settleTruncateReplay(replayState, pending) }, @@ -649,9 +656,19 @@ export class CollectionSubscription if (this.truncateReplayState !== replayState) return this.truncateReplayState = undefined this.truncateReplacementPending = false + try { + this.publishReplayRows(replayState.privateRows) + } finally { + replayState.completion.resolve() + this.options.truncateReplayPublication?.succeed() + } + } + /** Replace the public snapshot with a replay's private rows in one batch. */ + private publishReplayRows( + privateRows: Map | undefined, + ): void { // Retained rows the source never re-delivered leave the replacement. - const { privateRows } = replayState for (const key of this.stalePublishedRows.keys()) privateRows?.delete(key) this.stalePublishedRows.clear() try { @@ -666,8 +683,6 @@ export class CollectionSubscription } finally { // Restore tracking even when a subscriber rejects the replacement. this.restorePublishedSnapshotTracking() - replayState.completion.resolve() - this.options.truncateReplayPublication?.succeed() } } @@ -778,6 +793,7 @@ export class CollectionSubscription pendingSetups: 1, currentAttempt: { pendingCount: 0, setupComplete: false }, failures: new Map(), + failed: false, completion: createReplayCompletion(), } } @@ -1074,6 +1090,7 @@ export class CollectionSubscription replayState.currentAttempt === replayAttempt ) { replayState.failures.set(demand, normalizeError(error)) + replayState.failed = true } this.subsetDemands.splice(demandIndex, 1) } @@ -1358,9 +1375,19 @@ export class CollectionSubscription runAllCallbacks(releaseCallbacks) } - /** A replay with no remaining logical demand cannot establish more rows. */ + /** + * A replay with no remaining logical demand cannot establish more rows. One + * that has not failed publishes the rows it holds, so the subscriber does + * not keep values the source has replaced or deleted. A failed replay keeps + * the last public snapshot until an authoritative replay succeeds. + */ private retireEmptyReplay(): void { - if (this.subsetDemands.length !== 0 || !this.truncateReplayState) { + const replayState = this.truncateReplayState + if (this.subsetDemands.length !== 0 || !replayState) { + return + } + if (replayState.privateRows && !replayState.failed) { + this.flushTruncateReplay(replayState) return } this.discardTruncateReplay() diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 579e473c76..b68b934caf 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -1263,7 +1263,13 @@ replacement. Replay failure is scoped to the logical demand that failed. If that demand retires, its failure cannot poison a successful replacement for the remaining demand. If the last logical demand retires, the now-unreachable source replay rejects its completion with `AbortError` and stops gating the shared -graph; unrelated parent or sibling changes may then publish. If release +graph; unrelated parent or sibling changes may then publish. A direct +subscriber's replay whose current attempt has not failed instead publishes the +rows it holds at that retirement and resolves its completion, because nothing +can complete it and keeping the old snapshot would leave values the source +replaced or deleted. A replay whose +current attempt failed keeps the last public snapshot, even after its failed +owner was released; a later request reconciles a row it still shows. If release publication or adapter unload synchronously acquires new demand, core checks completion after that callback: the new demand joins the private replacement, while the retired transport can no longer gate it. A genuine replay failure is diff --git a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts index 7da2aaa6c3..775e413d04 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts @@ -32,8 +32,14 @@ import type { * file adds the row-publication node. A truncate opens a private replacement; * source writes accumulate there until every gating attempt succeeds. Replay * failure keeps the replacement and later source changes private while the - * last public snapshot remains visible. Retiring the final logical subset - * owner can retire that replacement barrier without publishing its rows. + * last public snapshot remains visible. Releasing the final logical subset + * owner retires the replacement, since nothing can complete it. A + * replacement whose current attempt has not failed then publishes the rows + * it holds, so the subscriber does not keep values the source replaced or + * deleted during it. One that failed stays private, as a failed replay does + * until an authoritative replay succeeds, even after its failed owner was + * released. A later request delivers its installed row, so a row that the + * subscriber still shows at an older value is updated then. * The `Collection.subscribeChanges` API contract in `../src/collection/index.ts` * is the authority for callback batches and their per-key causal order. The * public Collection lifecycle and shared lifecycle grammar specify cleanup, @@ -116,6 +122,10 @@ type Replacement = { replayGeneration: number rows: Map failed: boolean + // A current attempt failed, even if its owner has been released since. + // Only full retirement reads it: with owners left, a released owner's + // failure cannot stop a successful replacement. + everFailed: boolean } type PublicationModel = { source: Map @@ -313,6 +323,7 @@ function finishReplacement( replacement.failed = currentAttempts.some( ({ outcome }) => outcome === `reject`, ) + replacement.everFailed ||= replacement.failed if (lifecycle.publicationBarrierOpen) return if (replacement.failed) { replacement.failed = true @@ -328,6 +339,13 @@ function finishReplacement( publishIfChanged(publication, new Map(replacement.rows)) publication.retainedKeys.clear() publication.replacement = undefined + } else if (lifecycle.owners.length === 0 && !replacement.everFailed) { + // Releasing the final owner retires a replay that has not failed. Nothing + // can complete it, so it publishes the rows it holds rather than keep + // values the source has replaced or deleted. + publishIfChanged(publication, new Map(replacement.rows)) + publication.retainedKeys.clear() + publication.replacement = undefined } else { // Retire private publication work, not the source's independently applied state. publication.replacement = undefined @@ -362,6 +380,7 @@ function projectPublication( replayGeneration: lifecycle.replayGeneration, rows: new Map(publication.source), failed: false, + everFailed: false, } } else { publication.replacement = undefined @@ -403,6 +422,7 @@ function projectPublication( replayGeneration: lifecycle.replayGeneration, rows: new Map(), failed: false, + everFailed: false, } } else if ( command.type === `source` && @@ -445,8 +465,9 @@ function projectPublication( publication.source.clear() publication.replacement = undefined } else if (command.type === `release`) { - // Release changes demand, not source retention. Only source writes or a - // successful replacement can change the subscriber's rows. + // Release changes demand, not source retention. Releasing alone never + // changes the subscriber's rows; a source write, a successful + // replacement, or a later request does. finishReplacement(publication, lifecycle) } else if (command.type === `settle` && effect.attemptId !== undefined) { const attempt = lifecycle.attempts[effect.attemptId]! @@ -488,28 +509,41 @@ function projectPublication( index < lifecycle.publications; index++ ) { - const row = + const requested = command.type === `request` && lifecycle.active && lifecycle.owners.filter(({ demand }) => demand === command.demand) - .length === 1 && - !publication.sentKeys.has(command.demand) + .length === 1 ? publication.source.get(command.demand) : undefined + // A request delivers its installed row: an insert when the subscriber + // does not show it, an update when it shows an older value, such as a + // row a failed replay kept, and nothing when it shows the same value. + // Either way the row is no longer retained. + const shown = requested && publication.visible.get(requested.id) + const row = + requested && requested.value !== shown?.value ? requested : undefined publication.batches.push( row ? [ - { - type: `insert`, - key: row.id, - value: cloneRow(row), - }, + shown + ? { + type: `update`, + key: row.id, + value: cloneRow(row), + previousValue: cloneRow(shown), + } + : { + type: `insert`, + key: row.id, + value: cloneRow(row), + }, ] : [], ) + if (requested) publication.retainedKeys.delete(requested.id) if (row) { publication.visible.set(row.id, cloneRow(row)) - publication.retainedKeys.delete(row.id) publication.sentKeys.add(row.id) } } @@ -2805,6 +2839,157 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) + // Releasing the final owner retires a replay that has not failed; it + // publishes the rows it holds, so none of these rows stays stale. + it.each([ + [ + `a value written during the replay`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, + ], + ], + [ + `a value written again after the replay retired`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 2 }, + { type: `release`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 2 }, + ], + ], + [ + `a row no demand names`, + [ + { type: `source`, key: `c`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `c`, action: `upsert`, value: 2 }, + { type: `release`, demand: `a` }, + ], + ], + [ + `a row deleted during the replay`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `delete`, value: 0 }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, + ], + ], + [ + `a row retained by a failed replay and deleted before the next one`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `source`, key: `a`, action: `delete`, value: 0 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `release`, demand: `b` }, + ], + ], + [ + `an undemanded row retained by a failed replay and deleted before the next one`, + [ + { type: `source`, key: `c`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `source`, key: `c`, action: `delete`, value: 0 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `release`, demand: `b` }, + ], + ], + ] as Array<[string, Array]>)( + `leaves no stale row after a retired replay: %s`, + async (_, history) => { + await runPublicationHistory(history) + }, + ) + + // Law 9: a failed replay stays private until an authoritative replay + // succeeds, even after its failed owner is released and nothing remains. + it(`keeps a failed replay private when its final owner releases`, async () => { + await runPublicationHistory([ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `source`, key: `a`, action: `upsert`, value: 5 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `a` }, + ]) + }) + it(`retires failed private replacement rows when its final owner releases`, async () => { await runPublicationHistory( [ diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index f0702217e3..4e19378ac6 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -607,9 +607,20 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { pending: Set currentAttemptIndex: number publicationCount: number + // The current attempt failed, even if its demand was released since. + failed: boolean } | undefined + // A new attempt supersedes the previous one, and its failure with it. + const startModelAttempt = ( + state: NonNullable, + attemptIndex: number, + ): void => { + state.currentAttemptIndex = attemptIndex + state.failed = false + } + const writeReplayRows = ( pending: PendingReplay, isCurrent: boolean, @@ -654,6 +665,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { } else { if (isCurrent) { lastReportedError = pending.error + replayState.failed = true } else { expect(pending.signal?.aborted).toBe(true) } @@ -724,8 +736,9 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { pending: new Set(), currentAttemptIndex: attemptIndex, publicationCount: expectedPublicationCount, + failed: false, } - modelReplayState.currentAttemptIndex = attemptIndex + startModelAttempt(modelReplayState, attemptIndex) for (const load of attempt.loads) { queuedLoads.push({ attemptIndex, load }) @@ -760,8 +773,29 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { } } // A released request does not retract rows already applied by the - // source, nor change the retained baseline of an unfinished replay. + // source. Releasing the final demand retires the replay: one that has + // not failed publishes the rows the source applied, since nothing can + // complete it; a failed one keeps the retained baseline (law 9). if (modelReplayState.pending.size === 0 && activeDemandIds.size === 0) { + if (!modelReplayState.failed) { + const previousPublication = new Map(expectedPublished) + expectedPublished.clear() + for (const [id, row] of sourceRows) { + expectedPublished.set(id, { ...row }) + } + const expectedBatch = publicationDiff( + previousPublication, + expectedPublished, + ) + expect(publicationCount - modelReplayState.publicationCount).toBe( + Number(expectedBatch.length > 0), + ) + if (expectedBatch.length > 0) { + expect(sortedChanges(publicationBatches.at(-1)!)).toEqual( + sortedChanges(expectedBatch), + ) + } + } expectedPublicationCount = publicationCount modelReplayState = undefined } @@ -2785,6 +2819,103 @@ describe(`CollectionSubscription replay oracle`, () => { }) }) + // Law 9 at retirement: a failure in the current attempt keeps the replay + // private even after the last owner is released. Each site writes a row + // before it fails, so publishing the private rows would show that row. + it.each([ + `replay load throws`, + `old lease unload throws`, + `new request throws`, + ])( + `keeps a replay private at retirement after a synchronous failure: %s`, + async (site) => { + let begin!: () => void + let write!: (message: { + type: `insert` | `update` + value: ReplayRow + }) => void + let commit!: () => void + let truncate!: () => void + let loads = 0 + let unloads = 0 + const writeRow = (row: ReplayRow) => { + begin() + write({ type: `insert`, value: row }) + commit() + } + const collection = createCollection({ + id: `retired-replay-synchronous-failure-${site}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + truncate = operations.truncate + operations.markReady() + return { + loadSubset: () => { + loads++ + if (loads === 1) { + writeRow({ id: `one`, value: 1 }) + return true + } + if (site === `replay load throws` && loads === 2) { + writeRow({ id: `one`, value: 5 }) + throw new Error(`replay load failed`) + } + if (site === `new request throws` && loads === 3) { + writeRow({ id: `two`, value: 2 }) + throw new Error(`request failed`) + } + return createDeferred().promise + }, + unloadSubset: () => { + unloads++ + if (site === `old lease unload throws` && unloads === 1) { + writeRow({ id: `two`, value: 2 }) + throw new Error(`unload failed`) + } + }, + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges((changes) => { + recordPublishedChanges(visible, changes) + }) + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + try { + subscription.requestSnapshot({ where, optimizedOnly: false }) + await flushPromises() + expect(sortedRows(visible)).toEqual([{ id: `one`, value: 1 }]) + + begin() + truncate() + commit() + await flushPromises() + if (site === `new request throws`) { + expect(() => + subscription.requestSnapshot({ + where: new Func(`eq`, [new PropRef([`id`]), new Value(`two`)]), + optimizedOnly: false, + }), + ).toThrow(`request failed`) + } + subscription.releaseSnapshot(where) + await flushPromises() + + expect(subscription.pendingTruncateReplacement).toBeUndefined() + expect(sortedRows(visible)).toEqual([{ id: `one`, value: 1 }]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }, + ) + it(`replaces a retained snapshot with a later empty replay`, async () => { await runReplayScenario({ initialRows: [{ id: `one`, value: 1 }], @@ -4612,11 +4743,10 @@ describe(`CollectionSubscription replay oracle`, () => { reacquireOnReady = reacquireTiming === `during-ready` reacquireInUnload = reacquireTiming === `during-unload` subscription.releaseSnapshot(where) + // Releasing the final demand retires the replay, which publishes the + // row it holds, so the replacement resolves. if (reacquireTiming === `after-release`) { - await expect(settlement).resolves.toMatchObject({ - status: `rejected`, - error: { name: `AbortError` }, - }) + await expect(settlement).resolves.toEqual({ status: `resolved` }) subscription.requestSnapshot({ where }) } else if (reacquireTiming === `during-unload`) { let settled = false @@ -4640,10 +4770,7 @@ describe(`CollectionSubscription replay oracle`, () => { await expect(settlement).resolves.toEqual({ status: `resolved` }) } else { expect(subscription.pendingTruncateReplacement).toBeUndefined() - await expect(settlement).resolves.toMatchObject({ - status: `rejected`, - error: { name: `AbortError` }, - }) + await expect(settlement).resolves.toEqual({ status: `resolved` }) } expect(subscription.pendingTruncateReplacement).toBeUndefined() @@ -4651,7 +4778,11 @@ describe(`CollectionSubscription replay oracle`, () => { expect(sortedChanges(batches[0]!)).toEqual([ { type: `insert`, key: `one`, value: { id: `one`, value: 1 } }, ]) - expect(sortedChanges(batches.at(-1)!)).toEqual([ + // Retiring the replay publishes its applied row at the release; the + // reacquired demand then has nothing new to deliver. + expect( + sortedChanges(batches.filter((batch) => batch.length > 0).at(-1)!), + ).toEqual([ { type: `update`, key: `one`, diff --git a/packages/db/tests/replay-publication-storage.test.ts b/packages/db/tests/replay-publication-storage.test.ts index ca7392f365..c5215773c5 100644 --- a/packages/db/tests/replay-publication-storage.test.ts +++ b/packages/db/tests/replay-publication-storage.test.ts @@ -298,7 +298,7 @@ describe(`Replay publication storage`, () => { }) }) - it(`direct: releasing the last demand during replay retires it; re-acquisition reconciles`, async () => { + it(`direct: releasing the last demand during replay retires it and publishes its rows; re-acquisition reconciles`, async () => { return withReplayResources(async (resources) => { const s = makeSource(`probe-release`) resources.sources.push(() => s.source.cleanup()) @@ -333,20 +333,23 @@ describe(`Replay publication storage`, () => { sub.releaseSnapshot(where) const [outcome] = await settled - expect(outcome.status).toBe(`rejected`) - expect((outcome as PromiseRejectedResult).reason.name).toBe(`AbortError`) + // The retired replay published its rows, so its replacement resolves. + expect(outcome.status).toBe(`fulfilled`) expect(sub.status).toBe(`ready`) expect(sub.hasPendingTruncateReplacement).toBe(false) - expect(visible.get(1)).toBe(1) + // Nothing can complete the retired replay, and its attempt did not + // fail, so it publishes the row the source replaced during it. + expect(visible.get(1)).toBe(2) + expect(batches.at(-1)).toEqual([[`update`, 1, 2]]) expect(replayStates(s.source)).toEqual([]) // Late settlement of the released transport changes nothing. hold.resolve() await flushPromises() - expect(visible.get(1)).toBe(1) + expect(visible.get(1)).toBe(2) expect(sub.status).toBe(`ready`) - // Re-acquiring reconciles the retained row against the source. + // Re-acquiring reconciles the row against the source. s.setHold(undefined) s.setVersion(3) sub.requestSnapshot({ where, optimizedOnly: false })