Skip to content

Commit 12dbc5e

Browse files
committed
Modify Firestore AsyncQueue to be more robust
Firestore was becoming stuck because whatever operation was on `tail` seemed to never complete. Once in this state, all future writes would simply queue up in memory and never process, losing user data. My theory is that disk errors (or hangs) are transient, and that trying again will probably work. This refactors AsyncQueue to add a few things: 1. Timeouts for all async operations 2. Automatic retry for each operation (up to a limit) 3. Logging when an operation fails This is pretty hacky because attempting to link `@firebase/firestore` ended up including multiple versions of firebase, and I tweaked rollup to be faster and emit more readable code.
1 parent ea49051 commit 12dbc5e

File tree

4 files changed

+97
-122
lines changed

4 files changed

+97
-122
lines changed

packages/firestore/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18-
import firebase from '@firebase/app';
18+
// import firebase from '@firebase/app';
1919
import { FirebaseNamespace } from '@firebase/app-types';
2020

2121
import { Firestore } from './src/api/database';
@@ -38,4 +38,5 @@ export function registerFirestore(instance: FirebaseNamespace): void {
3838
instance.registerVersion(name, version);
3939
}
4040

41-
registerFirestore(firebase);
41+
// @ts-ignore
42+
window.FIRESTORE_REGISTER_FN = registerFirestore;

packages/firestore/rollup.config.js

Lines changed: 1 addition & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,9 @@ export function resolveMemoryExterns(deps, externsId, referencedBy) {
102102
const es5BuildPlugins = [
103103
typescriptPlugin({
104104
typescript,
105-
transformers: appendPrivatePrefixTransformers,
106105
cacheRoot: `./.cache/es5.mangled/`
107106
}),
108-
json(),
109-
terser(manglePrivatePropertiesOptions)
107+
json()
110108
];
111109

112110
const es2017BuildPlugins = [
@@ -132,41 +130,6 @@ const browserBuilds = [
132130
plugins: es5BuildPlugins,
133131
external: resolveBrowserExterns
134132
},
135-
// ES5 ESM Build (memory-only)
136-
{
137-
input: 'index.memory.ts',
138-
output: {
139-
file: path.resolve('./memory', memoryPkg.module),
140-
format: 'es',
141-
sourcemap: true
142-
},
143-
plugins: es5BuildPlugins,
144-
external: (id, referencedBy) =>
145-
resolveMemoryExterns(browserDeps, id, referencedBy)
146-
},
147-
// ES2017 ESM build (with persistence)
148-
{
149-
input: 'index.ts',
150-
output: {
151-
file: pkg.esm2017,
152-
format: 'es',
153-
sourcemap: true
154-
},
155-
plugins: es5BuildPlugins,
156-
external: resolveBrowserExterns
157-
},
158-
// ES2017 ESM build (memory-only)
159-
{
160-
input: 'index.memory.ts',
161-
output: {
162-
file: path.resolve('./memory', memoryPkg.esm2017),
163-
format: 'es',
164-
sourcemap: true
165-
},
166-
plugins: es2017BuildPlugins,
167-
external: (id, referencedBy) =>
168-
resolveMemoryExterns(browserDeps, id, referencedBy)
169-
},
170133
// ES5 CJS Build (with persistence)
171134
//
172135
// This build is based on the mangling in the ESM build above, since
@@ -175,19 +138,6 @@ const browserBuilds = [
175138
input: pkg.module,
176139
output: { file: pkg.browser, format: 'cjs', sourcemap: true },
177140
plugins: [sourcemaps()]
178-
},
179-
// ES5 CJS Build (memory-only)
180-
//
181-
// This build is based on the mangling in the ESM build above, since
182-
// Terser's Property name mangling doesn't work well with CJS's syntax.
183-
{
184-
input: path.resolve('./memory', memoryPkg.module),
185-
output: {
186-
file: path.resolve('./memory', memoryPkg.browser),
187-
format: 'cjs',
188-
sourcemap: true
189-
},
190-
plugins: [sourcemaps()]
191141
}
192142
];
193143

@@ -205,27 +155,6 @@ const nodeBuildPlugins = [
205155
];
206156

207157
const nodeBuilds = [
208-
// ES5 CJS build (with persistence)
209-
{
210-
input: 'index.node.ts',
211-
output: [{ file: pkg.main, format: 'cjs', sourcemap: true }],
212-
plugins: nodeBuildPlugins,
213-
external: resolveNodeExterns
214-
},
215-
// ES5 CJS build (memory-only)
216-
{
217-
input: 'index.node.memory.ts',
218-
output: [
219-
{
220-
file: path.resolve('./memory', memoryPkg.main),
221-
format: 'cjs',
222-
sourcemap: true
223-
}
224-
],
225-
plugins: nodeBuildPlugins,
226-
external: (id, referencedBy) =>
227-
resolveMemoryExterns(nodeDeps, id, referencedBy)
228-
}
229158
];
230159

231160
export default [...browserBuilds, ...nodeBuilds];

packages/firestore/src/core/firestore_client.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,11 +433,6 @@ export class FirestoreClient {
433433
});
434434
}
435435

436-
stillWorking(): boolean {
437-
const ts = this.asyncQueue.operationStart;
438-
return ts === 0 || ts > Date.now() - (1000 * 60);
439-
}
440-
441436
/**
442437
* Returns a Promise that resolves when all writes that were pending at the time this
443438
* method was called received server acknowledgement. An acknowledgement can be either acceptance

packages/firestore/src/util/async_queue.ts

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,18 @@ class DelayedOperation<T extends unknown> implements CancelablePromise<T> {
191191
}
192192
}
193193

194+
interface PendingOp {
195+
op: Function
196+
signature: string
197+
attempts: number
198+
resolve: Function
199+
reject: Function
200+
promise: Promise<void>
201+
};
202+
194203
export class AsyncQueue {
195-
// The last promise in the queue.
196-
private tail: Promise<unknown> = Promise.resolve();
204+
// all operations waiting to be processed
205+
private pending: PendingOp[] = [];
197206

198207
// Is this AsyncQueue being shut down? Once it is set to true, it will not
199208
// be changed again.
@@ -209,9 +218,7 @@ export class AsyncQueue {
209218
// Flag set while there's an outstanding AsyncQueue operation, used for
210219
// assertion sanity-checks.
211220
private operationInProgress = false;
212-
213-
operationSignature = '<none>';
214-
operationStart = 0;
221+
private operationStart = 0;
215222

216223
// List of TimerIds to fast-forward delays for.
217224
private timerIdsToSkip: TimerId[] = [];
@@ -283,40 +290,85 @@ export class AsyncQueue {
283290
}
284291

285292
private enqueueInternal<T extends unknown>(op: () => Promise<T>): Promise<T> {
286-
const newTail = this.tail.then(() => {
287-
this.operationStart = Date.now();
288-
this.operationSignature = op.toString();
289-
this.operationInProgress = true;
290-
return op()
291-
.catch((error: FirestoreError) => {
292-
this.failure = error;
293-
this.operationInProgress = false;
294-
const message = error.stack || error.message || '';
295-
log.error('INTERNAL UNHANDLED ERROR: ', message);
296-
297-
// Escape the promise chain and throw the error globally so that
298-
// e.g. any global crash reporting library detects and reports it.
299-
// (but not for simulated errors in our tests since this breaks mocha)
300-
if (message.indexOf('Firestore Test Simulated Error') < 0) {
301-
setTimeout(() => {
302-
throw error;
303-
}, 0);
304-
}
305-
306-
// Re-throw the error so that this.tail becomes a rejected Promise and
307-
// all further attempts to chain (via .then) will just short-circuit
308-
// and return the rejected Promise.
309-
throw error;
310-
})
311-
.then(result => {
312-
this.operationStart = 0;
313-
this.operationSignature = '<none>';
314-
this.operationInProgress = false;
315-
return result;
316-
});
293+
let resolve: Function, reject: Function;
294+
const promise = new Promise((res, rej) => {
295+
resolve = res;
296+
reject = rej;
297+
});
298+
299+
// @ts-ignore
300+
this.pending.push({op, attempts: 0, resolve, reject, promise} as PendingOp);
301+
302+
if (!this.operationInProgress) {
303+
this.runNext();
304+
}
305+
306+
return promise as Promise<T>;
307+
}
308+
309+
private withTimeout<T extends unknown> (promise: Promise<T>, ms: number): Promise<T> {
310+
let hasFinished = false;
311+
312+
const timeout = new Promise((_, reject) => {
313+
setTimeout(() => {
314+
if (!hasFinished) {
315+
reject('Timeout');
316+
}
317+
}, ms);
318+
});
319+
320+
return Promise.race([promise.then(() => { hasFinished = true; }), timeout]) as Promise<T>;
321+
}
322+
323+
private runNext (): void {
324+
if (this.operationInProgress || this.pending.length === 0) { return; }
325+
326+
const toRun = this.pending[0];
327+
toRun.attempts++;
328+
this.operationStart = Date.now();
329+
this.operationInProgress = true;
330+
331+
let runningOp = toRun.op();
332+
333+
334+
// @ts-ignore
335+
if (window.TRIGGER_FAILED_FIRESTORE_OP) {
336+
runningOp = new Promise(() => {});
337+
// @ts-ignore
338+
window.TRIGGER_FAILED_FIRESTORE_OP = false;
339+
}
340+
341+
this.withTimeout(runningOp, 10 * 1000).then((res: unknown) => {
342+
this.operationInProgress = false;
343+
this.pending.shift();
344+
this.runNext();
345+
return res;
346+
})
347+
.catch((e: FirestoreError) => {
348+
this.operationInProgress = false;
349+
350+
// @ts-ignore
351+
window.Profile('log.firestoreError', {message: e.message, signature: toRun.op.toString(), attempt: toRun.attempts, duration: Date.now() - this.operationStart});
352+
353+
// @ts-ignore
354+
window.Bugsnag.notify(e);
355+
356+
// give up on the operation, throw an error, and continue on
357+
if (toRun.attempts >= 3) {
358+
this.pending.shift();
359+
360+
// Escape the promise chain and throw the error globally so that
361+
// any global crash reporting library detects and reports it.
362+
// (but not for simulated errors in our tests since this breaks mocha)
363+
if ((e.message || '').indexOf('Firestore Test Simulated Error') < 0) {
364+
setTimeout(() => { throw e; }, 0);
365+
}
366+
}
367+
368+
// Add a slight delay before trying again. If it was an I/O problem,
369+
// waiting often helps.
370+
setTimeout(() => this.runNext(), 5000);
317371
});
318-
this.tail = newTail;
319-
return newTail;
320372
}
321373

322374
/**
@@ -384,11 +436,9 @@ export class AsyncQueue {
384436
// operations. Keep draining the queue until the tail is no longer advanced,
385437
// which indicates that no more new operations were enqueued and that all
386438
// operations were executed.
387-
let currentTail: Promise<unknown>;
388-
do {
389-
currentTail = this.tail;
390-
await currentTail;
391-
} while (currentTail !== this.tail);
439+
while (this.pending.length > 0) {
440+
this.runNext();
441+
}
392442
}
393443

394444
/**

0 commit comments

Comments
 (0)