@aws-amplify/core
Version:
Core category of aws-amplify
284 lines (281 loc) • 10.9 kB
JavaScript
import { BackgroundManagerNotOpenError } from './BackgroundManagerNotOpenError.mjs';
import { BackgroundProcessManagerState } from './types.mjs';
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/**
* @private For internal Amplify use.
*
* Creates a new scope for promises, observables, and other types of work or
* processes that may be running in the background. This manager provides
* an singular entrypoint to request termination and await completion.
*
* As work completes on its own prior to close, the manager removes them
* from the registry to avoid holding references to completed jobs.
*/
class BackgroundProcessManager {
constructor() {
/**
* A string indicating whether the manager is accepting new work ("Open"),
* waiting for work to complete ("Closing"), or fully done with all
* submitted work and *not* accepting new jobs ("Closed").
*/
this._state = BackgroundProcessManagerState.Open;
/**
* The list of outstanding jobs we'll need to wait for upon `close()`
*/
this.jobs = new Set();
}
add(jobOrDescription, optionalDescription) {
let job;
let description;
if (typeof jobOrDescription === 'string') {
job = undefined;
description = jobOrDescription;
}
else {
job = jobOrDescription;
description = optionalDescription;
}
const error = this.closedFailure(description);
if (error)
return error;
if (job === undefined) {
return this.addHook(description);
}
else if (typeof job === 'function') {
return this.addFunction(job, description);
}
else if (job instanceof BackgroundProcessManager) {
this.addManager(job, description);
}
else {
throw new Error('If `job` is provided, it must be an Observable, Function, or BackgroundProcessManager.');
}
}
/**
* Adds a **cleaner** function that doesn't immediately get executed.
* Instead, the caller gets a **terminate** function back. The *cleaner* is
* invoked only once the mananger *closes* or the returned **terminate**
* function is called.
*
* @param clean The cleanup function.
* @param description Optional description to help identify pending jobs.
* @returns A terminate function.
*/
addCleaner(clean, description) {
const { resolve, onTerminate } = this.addHook(description);
const proxy = async () => {
await clean();
resolve();
};
onTerminate.then(proxy);
return proxy;
}
addFunction(job, description) {
// the function we call when we want to try to terminate this job.
let terminate;
// the promise the job can opt into listening to for termination.
const onTerminate = new Promise(resolve => {
terminate = resolve;
});
// finally! start the job.
const jobResult = job(onTerminate);
// depending on what the job gives back, register the result
// so we can monitor for completion.
if (typeof jobResult?.then === 'function') {
this.registerPromise(jobResult, terminate, description);
}
// At the end of the day, or you know, method call, it doesn't matter
// what the return value is at all; we just pass it through to the
// caller.
return jobResult;
}
addManager(manager, description) {
this.addCleaner(async () => manager.close(), description);
}
/**
* Creates and registers a fabricated job for processes that need to operate
* with callbacks/hooks. The returned `resolve` and `reject`
* functions can be used to signal the job is done successfully or not.
* The returned `onTerminate` is a promise that will resolve when the
* manager is requesting the termination of the job.
*
* @param description Optional description to help identify pending jobs.
* @returns `{ resolve, reject, onTerminate }`
*/
addHook(description) {
// the resolve/reject functions we'll provide to the caller to signal
// the state of the job.
let promiseResolve;
let promiseReject;
// the underlying promise we'll use to manage it, pretty much like
// any other promise.
const promise = new Promise((resolve, reject) => {
promiseResolve = resolve;
promiseReject = reject;
});
// the function we call when we want to try to terminate this job.
let terminate;
// the promise the job can opt into listening to for termination.
const onTerminate = new Promise(resolve => {
terminate = resolve;
});
this.registerPromise(promise, terminate, description);
return {
resolve: promiseResolve,
reject: promiseReject,
onTerminate,
};
}
/**
* Adds a Promise based job to the list of jobs for monitoring and listens
* for either a success or failure, upon which the job is considered "done"
* and removed from the registry.
*
* @param promise A promise that is on its way to being returned to a
* caller, which needs to be tracked as a background job.
* @param terminate The termination function to register, which can be
* invoked to request the job stop.
* @param description Optional description to help identify pending jobs.
*/
registerPromise(promise, terminate, description) {
const jobEntry = { promise, terminate, description };
this.jobs.add(jobEntry);
// in all of my testing, it is safe to multi-subscribe to a promise.
// so, rather than create another layer of promising, we're just going
// to hook into the promise we already have, and when it's done
// (successfully or not), we no longer need to wait for it upon close.
//
// sorry this is a bit hand-wavy:
//
// i believe we use `.then` and `.catch` instead of `.finally` because
// `.finally` is invoked in a different order in the sequence, and this
// breaks assumptions throughout and causes failures.
promise
.then(() => {
this.jobs.delete(jobEntry);
})
.catch(() => {
this.jobs.delete(jobEntry);
});
}
/**
* The number of jobs being waited on.
*
* We don't use this for anything. It's just informational for the caller,
* and can be used in logging and testing.
*
* @returns the number of jobs.
*/
get length() {
return this.jobs.size;
}
/**
* The execution state of the manager. One of:
*
* 1. "Open" -> Accepting new jobs
* 1. "Closing" -> Not accepting new work. Waiting for jobs to complete.
* 1. "Closed" -> Not accepting new work. All submitted jobs are complete.
*/
get state() {
return this._state;
}
/**
* The registered `description` of all still-pending jobs.
*
* @returns descriptions as an array.
*/
get pending() {
return Array.from(this.jobs).map(job => job.description);
}
/**
* Whether the manager is accepting new jobs.
*/
get isOpen() {
return this._state === BackgroundProcessManagerState.Open;
}
/**
* Whether the manager is rejecting new work, but still waiting for
* submitted work to complete.
*/
get isClosing() {
return this._state === BackgroundProcessManagerState.Closing;
}
/**
* Whether the manager is rejecting work and done waiting for submitted
* work to complete.
*/
get isClosed() {
return this._state === BackgroundProcessManagerState.Closed;
}
closedFailure(description) {
if (!this.isOpen) {
return Promise.reject(new BackgroundManagerNotOpenError([
`The manager is ${this.state}.`,
`You tried to add "${description}".`,
`Pending jobs: [\n${this.pending
.map(t => ' ' + t)
.join(',\n')}\n]`,
].join('\n')));
}
}
/**
* Signals jobs to stop (for those that accept interruptions) and waits
* for confirmation that jobs have stopped.
*
* This immediately puts the manager into a closing state and just begins
* to reject new work. After all work in the manager is complete, the
* manager goes into a `Completed` state and `close()` returns.
*
* This call is idempotent.
*
* If the manager is already closing or closed, `finalCleaup` is not executed.
*
* @param onClosed
* @returns The settled results of each still-running job's promise. If the
* manager is already closed, this will contain the results as of when the
* manager's `close()` was called in an `Open` state.
*/
async close() {
if (this.isOpen) {
this._state = BackgroundProcessManagerState.Closing;
for (const job of Array.from(this.jobs)) {
try {
job.terminate();
}
catch (error) {
// Due to potential races with a job's natural completion, it's
// reasonable to expect the termination call to fail. Hence,
// not logging as an error.
// eslint-disable-next-line no-console
console.warn(`Failed to send termination signal to job. Error: ${error.message}`, job);
}
}
// Use `allSettled()` because we want to wait for all to finish. We do
// not want to stop waiting if there is a failure.
this._closingPromise = Promise.allSettled(Array.from(this.jobs).map(j => j.promise));
await this._closingPromise;
this._state = BackgroundProcessManagerState.Closed;
}
return this._closingPromise;
}
/**
* Signals the manager to start accepting work (again) and returns once
* the manager is ready to do so.
*
* If the state is already `Open`, this call is a no-op.
*
* If the state is `Closed`, this call simply updates state and returns.
*
* If the state is `Closing`, this call waits for completion before it
* updates the state and returns.
*/
async open() {
if (this.isClosing) {
await this.close();
}
this._state = BackgroundProcessManagerState.Open;
}
}
export { BackgroundProcessManager };
//# sourceMappingURL=BackgroundProcessManager.mjs.map