Files

449 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2017 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isSafari } from '@firebase/util';
import { expect, use } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { IndexedDbTransactionError } from '../../../src/local/simple_db';
import { fail } from '../../../src/util/assert';
import { TimerId } from '../../../src/util/async_queue';
import {
AsyncQueueImpl,
newAsyncQueue
} from '../../../src/util/async_queue_impl';
import { Code } from '../../../src/util/error';
import {
getLogLevel,
setLogLevel,
LogLevelString
} from '../../../src/util/log';
import { Deferred, Rejecter, Resolver } from '../../../src/util/promise';
use(chaiAsPromised);
describe('AsyncQueue', () => {
// We reuse these TimerIds for generic testing.
const timerId1 = TimerId.ListenStreamConnectionBackoff;
const timerId2 = TimerId.ListenStreamIdle;
const timerId3 = TimerId.WriteStreamConnectionBackoff;
it('schedules ops in right order', () => {
const queue = newAsyncQueue();
const results: string[] = [];
function pushResult(result: string): void {
results.push(result);
}
const op1 = queue.enqueue(() => {
return defer(() => 'Hello')
.then((result: string) => {
return defer(() => result + ' world!');
})
.then(pushResult);
});
const op2 = queue.enqueue(() => {
return defer(() => 'Bye bye.').then(pushResult);
});
const op4 = new Deferred<void>();
const op3 = queue.enqueue(() => {
queue.enqueueAndForget(() => {
return Promise.resolve('Bye for good.')
.then(pushResult)
.then(op4.resolve);
});
return Promise.resolve('Welcome back.').then(pushResult);
});
return Promise.all([op1, op2, op3])
.then(() => {
expect(results[0]).to.deep.equal('Hello world!');
expect(results[1]).to.deep.equal('Bye bye.');
expect(results[2]).to.deep.equal('Welcome back.');
return op4.promise;
})
.then(() => {
expect(results[3]).to.deep.equal('Bye for good.');
});
});
it('handles failures', () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const expected = new Error('Firestore Test Simulated Error');
// Disable logging for this test to avoid the assertion being logged
const oldLogLevel = getLogLevel();
setLogLevel('silent');
// Schedule a failing operation and make sure it's handled correctly.
const op1Promise = queue
.enqueue(() => {
// This promise represents something that is rejected
return defer(() => {
throw expected;
});
})
.then(
() => {
expect.fail('expected op1 to fail');
},
(err: unknown) => {
expect(err).to.equal(expected);
}
)
.then(() => {
expect(queue.failure).to.equal(expected);
});
// Schedule a second failing operation (before the first one has actually
// executed and failed). It should not be run.
const op2Promise = queue
.enqueue(() => {
return defer(() => {
expect.fail('op2 should not be executed.');
});
})
.then(
() => {
expect.fail('expected op2 to fail');
},
(err: unknown) => {
// should be the original failure still.
expect(err).to.equal(expected);
expect(queue.failure).to.equal(expected);
}
);
return Promise.all([op1Promise, op2Promise]).then(() => {
// Once the queue is failed, trying to queue new operations will
// synchronously throw with "already failed" error.
const dummyOp = (): Promise<never> =>
Promise.reject('dummyOp should not be run');
expect(() => {
queue.enqueueAndForget(dummyOp);
}).to.throw(/already failed.*Simulated Error/);
// Finally, restore log level.
setLogLevel(oldLogLevel as unknown as LogLevelString);
});
});
// Flaky on Safari.
// eslint-disable-next-line no-restricted-properties
(isSafari() ? it.skip : it)('can schedule ops in the future', async () => {
const queue = newAsyncQueue();
const completedSteps: number[] = [];
const doStep = (n: number): Promise<number> =>
defer(() => completedSteps.push(n));
queue.enqueueAndForget(() => doStep(1));
const last = queue.enqueueAfterDelay(timerId1, 5, () => doStep(4));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId2, 1, () => doStep(3));
queue.enqueueAndForget(() => doStep(2));
await last;
expect(completedSteps).to.deep.equal([1, 2, 3, 4]);
});
it('Can cancel delayed operations', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<number> =>
defer(() => completedSteps.push(n));
queue.enqueueAndForget(() => doStep(1));
const delayedPromise = queue.enqueueAfterDelay(timerId1, 1, () =>
doStep(2)
);
expect(queue.containsDelayedOperation(timerId1)).to.be.true;
delayedPromise.cancel();
expect(queue.containsDelayedOperation(timerId1)).to.be.false;
await delayedPromise.then(
() => expect.fail('resolved promise', 'rejected promise'),
err => expect(err.code === Code.CANCELLED)
);
await queue.runAllDelayedOperationsUntil(TimerId.All);
expect(completedSteps).to.deep.equal([1]);
});
it('Can run all delayed operations early', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<number> =>
defer(() => completedSteps.push(n));
queue.enqueueAndForget(() => doStep(1));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId1, 20000, () => doStep(4));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId2, 10000, () => doStep(3));
queue.enqueueAndForget(() => doStep(2));
await queue.runAllDelayedOperationsUntil(TimerId.All);
expect(completedSteps).to.deep.equal([1, 2, 3, 4]);
});
it('Can run some delayed operations early', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<number> =>
defer(() => completedSteps.push(n));
queue.enqueueAndForget(() => doStep(1));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId1, 20000, () => doStep(5));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId2, 10000, () => doStep(3));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId3, 15000, () => doStep(4));
queue.enqueueAndForget(() => doStep(2));
await queue.runAllDelayedOperationsUntil(timerId3);
expect(completedSteps).to.deep.equal([1, 2, 3, 4]);
});
it('Retries retryable operations', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
queue.enqueueRetryable(async () => {
doStep(1);
if (completedSteps.length === 1) {
throw new IndexedDbTransactionError(
'Simulated error',
new Error('Simulated retryable error')
);
}
});
await queue.runAllDelayedOperationsUntil(TimerId.AsyncQueueRetry);
expect(completedSteps).to.deep.equal([1, 1]);
});
it("Doesn't retry internal exceptions", async () => {
const queue = newAsyncQueue();
// We use a deferred Promise as retryable operations are scheduled only
// when Promise chains are resolved, which can happen after the
// `queue.enqueue()` call below.
const deferred = new Deferred<void>();
queue.enqueueRetryable(async () => {
deferred.resolve();
throw fail(0x1576, 'Simulated test failure');
});
await deferred.promise;
await expect(
queue.enqueue(() => Promise.resolve())
).to.eventually.be.rejectedWith('Simulated test failure');
});
it('Schedules first retryable attempt with no delay', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
queue.enqueueRetryable(async () => {
doStep(1);
});
expect(queue.containsDelayedOperation(TimerId.AsyncQueueRetry)).to.be.false;
// Use `drain()` instead of runDelayedOperationsEarly since the
// operation was scheduled directly on the queue.
await queue.drain();
expect(completedSteps).to.deep.equal([1]);
});
it('Retries retryable operations with backoff', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
queue.enqueueRetryable(async () => {
doStep(1);
if (completedSteps.length === 1) {
throw new IndexedDbTransactionError(
'Simulated error',
new Error('Simulated retryable error')
);
}
});
// Verify that only one attempt has been made
await queue.drain();
expect(completedSteps).to.deep.equal([1]);
// Fast forward all operations
await queue.runAllDelayedOperationsUntil(TimerId.AsyncQueueRetry);
expect(completedSteps).to.deep.equal([1, 1]);
});
it('Retries retryable operations in order', async () => {
const queue = newAsyncQueue();
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
const blockingPromise = new Deferred<void>();
queue.enqueueRetryable(async () => {
doStep(1);
if (completedSteps.length === 1) {
throw new IndexedDbTransactionError(
'Simulated error',
new Error('Simulated retryable error')
);
}
});
queue.enqueueRetryable(async () => {
doStep(2);
blockingPromise.resolve();
});
await blockingPromise.promise;
expect(completedSteps).to.deep.equal([1, 1, 2]);
});
it('Does not delay retryable operations that succeed', async () => {
const queue = newAsyncQueue();
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
queue.enqueueRetryable(async () => {
doStep(1);
});
queue.enqueueAndForget(async () => {
doStep(2);
});
await queue.enqueue(async () => {
doStep(3);
});
expect(completedSteps).to.deep.equal([1, 2, 3]);
});
it('Catches up when retryable operation fails', async () => {
const queue = newAsyncQueue();
const completedSteps: number[] = [];
const doStep = (n: number): void => {
completedSteps.push(n);
};
const blockingPromise = new Deferred<void>();
queue.enqueueRetryable(async () => {
doStep(1);
if (completedSteps.length === 1) {
throw new IndexedDbTransactionError(
'Simulated error',
new Error('Simulated retryable error')
);
}
});
queue.enqueueAndForget(async () => {
doStep(2);
});
queue.enqueueRetryable(async () => {
doStep(3);
blockingPromise.resolve();
});
await blockingPromise.promise;
// Once all existing retryable operations succeeded, they are scheduled
// in the order they are enqueued.
queue.enqueueAndForget(async () => {
doStep(4);
});
await queue.enqueue(async () => {
doStep(5);
});
await blockingPromise.promise;
expect(completedSteps).to.deep.equal([1, 2, 1, 3, 4, 5]);
});
it('Can drain (non-delayed) operations', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<number> =>
defer(() => completedSteps.push(n));
queue.enqueueAndForget(() => doStep(1));
// eslint-disable-next-line @typescript-eslint/no-floating-promises
queue.enqueueAfterDelay(timerId1, 10000, () => doStep(5));
queue.enqueueAndForget(() => doStep(2));
await queue.drain();
expect(completedSteps).to.deep.equal([1, 2]);
});
it('Schedules operations with respect to shut down', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<void> =>
defer(() => {
completedSteps.push(n);
});
queue.enqueueAndForget(() => doStep(1));
// After this call, only operations requested via
// `enqueueAndForgetEvenWhileRestricted` gets executed.
queue.enterRestrictedMode();
queue.enqueueAndForgetEvenWhileRestricted(() => doStep(2));
queue.enqueueAndForget(() => doStep(3));
queue.enqueueAndForgetEvenWhileRestricted(() => doStep(4));
await queue.drain();
expect(completedSteps).to.deep.equal([1, 2, 4]);
});
it('Does not run existing operations if opted out', async () => {
const queue = newAsyncQueue() as AsyncQueueImpl;
const completedSteps: number[] = [];
const doStep = (n: number): Promise<void> =>
defer(() => {
completedSteps.push(n);
});
queue.enqueueAndForget(() => doStep(1));
queue.enqueueAndForgetEvenWhileRestricted(() => doStep(2));
queue.enterRestrictedMode(/* purgeExistingTasks =*/ true);
await queue.drain();
expect(completedSteps).to.deep.equal([2]);
});
});
function defer<T>(op: () => T): Promise<T> {
return new Promise((resolve: Resolver<T>, reject: Rejecter) => {
setTimeout(() => {
try {
resolve(op());
} catch (e) {
reject(e as Error);
}
}, 0);
});
}