UNPKG

@aws-amplify/core

Version:
284 lines (281 loc) • 10.9 kB
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