Skip to content

Commit 8846f61

Browse files
Don't delay retryable operations (#3270)
1 parent c436bcc commit 8846f61

File tree

4 files changed

+98
-27
lines changed

4 files changed

+98
-27
lines changed

packages/firestore/src/util/async_queue.ts

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,9 @@ export class AsyncQueue {
207207
// The last promise in the queue.
208208
private tail: Promise<unknown> = Promise.resolve();
209209

210-
// The last retryable operation. Retryable operation are run in order and
210+
// A list of retryable operations. Retryable operations are run in order and
211211
// retried with backoff.
212-
private retryableTail: Promise<void> = Promise.resolve();
212+
private retryableOps: Array<() => Promise<void>> = [];
213213

214214
// Is this AsyncQueue being shut down? Once it is set to true, it will not
215215
// be changed again.
@@ -323,32 +323,44 @@ export class AsyncQueue {
323323
* operations were retried successfully.
324324
*/
325325
enqueueRetryable(op: () => Promise<void>): void {
326-
this.verifyNotFailed();
326+
this.retryableOps.push(op);
327+
this.enqueueAndForget(() => this.retryNextOp());
328+
}
327329

328-
if (this._isShuttingDown) {
330+
/**
331+
* Runs the next operation from the retryable queue. If the operation fails,
332+
* reschedules with backoff.
333+
*/
334+
private async retryNextOp(): Promise<void> {
335+
if (this.retryableOps.length === 0) {
329336
return;
330337
}
331338

332-
this.retryableTail = this.retryableTail.then(() => {
333-
const deferred = new Deferred<void>();
334-
const retryingOp = async (): Promise<void> => {
335-
try {
336-
await op();
337-
deferred.resolve();
338-
this.backoff.reset();
339-
} catch (e) {
340-
if (isIndexedDbTransactionError(e)) {
341-
logDebug(LOG_TAG, 'Operation failed with retryable error: ' + e);
342-
this.backoff.backoffAndRun(retryingOp);
343-
} else {
344-
deferred.resolve();
345-
throw e; // Failure will be handled by AsyncQueue
346-
}
347-
}
348-
};
349-
this.enqueueAndForget(retryingOp);
350-
return deferred.promise;
351-
});
339+
try {
340+
await this.retryableOps[0]();
341+
this.retryableOps.shift();
342+
this.backoff.reset();
343+
} catch (e) {
344+
if (isIndexedDbTransactionError(e)) {
345+
logDebug(LOG_TAG, 'Operation failed with retryable error: ' + e);
346+
} else {
347+
throw e; // Failure will be handled by AsyncQueue
348+
}
349+
}
350+
351+
if (this.retryableOps.length > 0) {
352+
// If there are additional operations, we re-schedule `retryNextOp()`.
353+
// This is necessary to run retryable operations that failed during
354+
// their initial attempt since we don't know whether they are already
355+
// enqueued. If, for example, `op1`, `op2`, `op3` are enqueued and `op1`
356+
// needs to be re-run, we will run `op1`, `op1`, `op2` using the
357+
// already enqueued calls to `retryNextOp()`. `op3()` will then run in the
358+
// call scheduled here.
359+
// Since `backoffAndRun()` cancels an existing backoff and schedules a
360+
// new backoff on every call, there is only ever a single additional
361+
// operation in the queue.
362+
this.backoff.backoffAndRun(() => this.retryNextOp());
363+
}
352364
}
353365

354366
private enqueueInternal<T extends unknown>(op: () => Promise<T>): Promise<T> {

packages/firestore/test/unit/specs/recovery_spec.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,7 @@ describeSpec('Persistence Recovery', ['no-ios', 'no-android'], () => {
787787
.expectActiveTargets({ query })
788788
// We are now user 2
789789
.expectEvents(query, { removed: [doc1], fromCache: true })
790+
.runTimer(TimerId.AsyncQueueRetry)
790791
// We are now user 1
791792
.expectEvents(query, {
792793
added: [doc1],

packages/firestore/test/unit/specs/spec_test_runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ abstract class TestRunner {
277277
}
278278

279279
async shutdown(): Promise<void> {
280-
await this.queue.enqueue(async () => {
280+
await this.queue.enqueueAndInitiateShutdown(async () => {
281281
if (this.started) {
282282
await this.doShutdown();
283283
}

packages/firestore/test/unit/util/async_queue.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,7 @@ describe('AsyncQueue', () => {
297297

298298
queue.enqueueRetryable(async () => {
299299
doStep(1);
300-
if (completedSteps.length > 1) {
301-
} else {
300+
if (completedSteps.length === 1) {
302301
throw new IndexedDbTransactionError(
303302
new Error('Simulated retryable error')
304303
);
@@ -313,6 +312,65 @@ describe('AsyncQueue', () => {
313312
expect(completedSteps).to.deep.equal([1, 1, 2]);
314313
});
315314

315+
it('Does not delay retryable operations that succeed', async () => {
316+
const queue = new AsyncQueue();
317+
const completedSteps: number[] = [];
318+
const doStep = (n: number): void => {
319+
completedSteps.push(n);
320+
};
321+
322+
queue.enqueueRetryable(async () => {
323+
doStep(1);
324+
});
325+
queue.enqueueAndForget(async () => {
326+
doStep(2);
327+
});
328+
await queue.enqueue(async () => {
329+
doStep(3);
330+
});
331+
332+
expect(completedSteps).to.deep.equal([1, 2, 3]);
333+
});
334+
335+
it('Catches up when retryable operation fails', async () => {
336+
const queue = new AsyncQueue();
337+
const completedSteps: number[] = [];
338+
const doStep = (n: number): void => {
339+
completedSteps.push(n);
340+
};
341+
342+
const blockingPromise = new Deferred<void>();
343+
344+
queue.enqueueRetryable(async () => {
345+
doStep(1);
346+
if (completedSteps.length === 1) {
347+
throw new IndexedDbTransactionError(
348+
new Error('Simulated retryable error')
349+
);
350+
}
351+
});
352+
queue.enqueueAndForget(async () => {
353+
doStep(2);
354+
});
355+
queue.enqueueRetryable(async () => {
356+
doStep(3);
357+
blockingPromise.resolve();
358+
});
359+
await blockingPromise.promise;
360+
361+
// Once all existing retryable operations succeeded, they are scheduled
362+
// in the order they are enqueued.
363+
queue.enqueueAndForget(async () => {
364+
doStep(4);
365+
});
366+
await queue.enqueue(async () => {
367+
doStep(5);
368+
});
369+
370+
await blockingPromise.promise;
371+
expect(completedSteps).to.deep.equal([1, 2, 1, 3, 4, 5]);
372+
});
373+
316374
it('Can drain (non-delayed) operations', async () => {
317375
const queue = new AsyncQueue();
318376
const completedSteps: number[] = [];

0 commit comments

Comments
 (0)