IBM/node-celery-ts

View on GitHub
src/containers/promise_map.ts

Summary

Maintainability
B
4 hrs
Test Coverage
// BSD 3-Clause License
//
// Copyright (c) 2018, IBM Corporation
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice, this
//   list of conditions and the following disclaimer.
//
// * Redistributions in binary form must reproduce the above copyright notice,
//   this list of conditions and the following disclaimer in the documentation
//   and/or other materials provided with the distribution.
//
// * Neither the name of the copyright holder nor the names of its
//   contributors may be used to endorse or promote products derived from
//   this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.

import { PromiseFunctions } from "../promise_functions";
import { isUndefined } from "../utility";

/**
 * `PromiseMap` is a key-value store where the values are `Promise`s.
 * It allows for inspection of the state of owned `Promise`s - whether pending,
 * fulfilled, or rejected - as well as the ability to resolve or reject owned
 * `Promise`s that are pending.
 */
export class PromiseMap<K, V> {
    private promises: Map<K, Promise<V>>;
    private data: Map<K, MapData<V>>;
    private readonly timeout?: number;

    /**
     * @param timeout The duration, in milliseconds, to wait before deleting
     *                settled `Promise`s.
     * @returns An empty `PromiseMap`.
     */
    public constructor(timeout?: number) {
        this.promises = new Map<K, Promise<V>>();
        this.data = new Map<K, MapData<V>>();
        this.timeout = timeout;
    }

    /**
     * @param key The key to check for membership in the owned set.
     * @returns True if this contains the requested key.
     */
    public has(key: K): boolean {
        return this.promises.has(key);
    }

    /**
     * @param key The key of the Promise whose status is to be queried.
     * @returns True if the key is in the owned set and the matching `Promise`
     *          is pending.
     */
    public isPending(key: K): boolean {
        const entry = this.data.get(key);

        return !isUndefined(entry) && entry.status === State.Pending;
    }

    /**
     * @param key The key of the Promise whose status is to be queried.
     * @returns True if the key is in the owned set and the matching `Promise`
     *          is fulfilled.
     */
    public isFulfilled(key: K): boolean {
        const entry = this.data.get(key);

        return !isUndefined(entry) && entry.status === State.Fulfilled;
    }

    /**
     * @param key The key of the `Promise` whose status is to be queried.
     * @returns True if the key is in the owned set and the matching `Promise`
     *          is rejected.
     */
    public isRejected(key: K): boolean {
        const entry = this.data.get(key);

        return !isUndefined(entry) && entry.status === State.Rejected;
    }

    /**
     * The matching `Promise` to `key` will remain pending until `value`
     * resolves. If `value` is a `Promise`-like type, the corresponding
     * `Promise` will follow `value`. If not, the owned `Promise` will be
     * fulfilled.
     *
     * @param key The key of the `Promise` to be settled. Must be pending or
     *            not in the owned set.
     * @param value The value to settle the `Promise` to.
     * @returns True if a new `Promise` was inserted.
     */
    public resolve(key: K, value: V | PromiseLike<V>): boolean {
        return this.settle({
            key,
            onExisting: (data) => this.resolveExisting({ key, data, value }),
            onNew: () => this.resolveNew(key, value),
        });
    }

    /**
     * @param key The key of the `Promise` to be rejected. Must be pending or
     *            not in the owned set.
     * @param reason The reason to reject the `Promise` with.
     * @returns True if a new `Promise` was inserted.
     */
    public reject(key: K, reason?: any): boolean {
        return this.settle({
            key,
            onExisting: (data) => this.rejectExisting({ key, data, reason }),
            onNew: () => this.rejectNew(reason),
        });
    }

    /**
     * Rejects all pending `Promise`s that are not following another `Promise`.
     *
     * @param reason The reason to reject any pending `Promise`s with.
     * @returns The number of rejected `Promise`s.
     */
    public rejectAll(reason?: any): number {
        let numRejected = 0;

        for (const [key, data] of this.data.entries()) {
            if (data.status !== State.Pending || isUndefined(data.functions)) {
                continue;
            }

            this.reject(key, reason);
            ++numRejected;
        }

        return numRejected;
    }

    /**
     * If `key` is not in the owned set, a new `Promise` will be created.
     *
     * @param key The key of the `Promise` to get.
     * @returns The matching `Promise` to the key.
     */
    public async get(key: K): Promise<V> {
        const maybePromise = this.promises.get(key);

        if (!isUndefined(maybePromise)) {
            return maybePromise;
        }

        const promise = new Promise<V>((resolve, reject) => this.data.set(key, {
            functions: { resolve, reject },
            status: State.Pending,
        }));

        this.promises.set(key, promise);

        return promise;
    }

    /**
     * @param key The key of the `Promise` to delete.
     * @returns True if `key` and the matching `Promise` were in the owned set.
     */
    public delete(key: K): boolean {
        return this.doDelete(key, new Error("deleted"));
    }

    /**
     * @returns The number of `Promise`s that were deleted.
     */
    public clear(): number {
        const error = new Error("cleared");

        for (const controlBlock of this.data.values()) {
            if (isUndefined(controlBlock.functions)) {
                continue;
            }

            controlBlock.functions.reject(error);
        }

        const numDeleted = this.promises.size;
        this.promises.clear();
        this.data.clear();

        return numDeleted;
    }

    /**
     * @param key The key of the `Promise` to settle.
     * @param onExisting The function to call if `key` exists.
     * @param onNew The function to call if `key` doesn't exist.
     * @returns True if `key` didn't exist and `onNew` was called.
     */
    private settle({ key, onExisting, onNew }: {
        key: K;
        onExisting(data: MapData<V>): MapData<V>;
        onNew(): [Promise<V>, MapData<V>];
    }): boolean {
        const maybeData = this.data.get(key);
        const hasKey = !isUndefined(maybeData);

        if (hasKey) {
            const data = maybeData!;
            const newData = onExisting(data);

            this.data.set(key, newData);
        } else {
            const [promise, data] = onNew();

            this.promises.set(key, promise);
            this.data.set(key, data);
        }

        this.setTimeout(key);

        return !hasKey;
    }

    /**
     * @param key The key to resolve.
     * @param data The data pertaining to this existing key.
     * @param value The value to resolve with.
     */
    private resolveExisting({ key, data, value }: {
        key: K;
        data: MapData<V>;
        value: V | PromiseLike<V>;
    }): MapData<V> {
        if (!isUndefined(data.functions)) {
            data.functions.resolve(this.doResolve(key, value));
        } else {
            this.promises.set(key, this.doResolve(key, value));
        }

        return {
            ...removeFunctions(data),
            status: State.Pending,
        };
    }

    /**
     * @param key The key to create.
     * @param value The value to resolve with.
     *
     * @returns The newly created `Promise` and its control data.
     */
    private resolveNew(
        key: K,
        value: V | PromiseLike<V>
    ): [Promise<V>, MapData<V>] {
        return [this.doResolve(key, value), { status: State.Pending }];
    }

    /**
     * @param key The key to reject.
     * @param data The data of the key to reject.
     * @param reason The (optional) reason to reject with.
     */
    private rejectExisting({ key, data, reason }: {
        key: K;
        data: MapData<V>;
        reason?: any;
    }): MapData<V> {
        if (!isUndefined(data.functions)) {
            data.functions.reject(reason);
        } else {
            this.promises.set(key, Promise.reject(reason));
        }

        return {
            ...removeFunctions(data),
            status: State.Rejected,
        };
    }

    /**
     * @param reason The (optional) reason to reject with.
     * @returns The newly rejected `Promise` and its control data.
     */
    private rejectNew(reason?: any): [Promise<V>, MapData<V>] {
        return [Promise.reject(reason), { status: State.Rejected }];
    }

    private async doResolve(key: K, value: V | PromiseLike<V>): Promise<V> {
        try {
            const resolved = await value;
            this.setStatus(key, State.Fulfilled);

            return resolved;
        } catch (error) {
            this.setStatus(key, State.Rejected);

            throw error;
        }
    }

    /**
     * @param key The key to set the status of.
     */
    private setStatus(key: K, status: State): void {
        const maybeExisting = this.data.get(key);

        const toSet = (() => {
            if (typeof maybeExisting === "undefined") {
                return { status };
            }

            return {
                ...maybeExisting,
                status,
            };
        })();

        this.data.set(key, toSet);
    }

    /**
     * Sets a timeout for a key. After at least `this.timeout` milliseconds,
     * will call `PromiseMap#delete` on the specified key.
     *
     * @param key The key to set a timeout on.
     * @returns True if a timeout was set.
     */
    private setTimeout(key: K): boolean {
        const maybeData = this.data.get(key);

        if (isUndefined(maybeData) || isUndefined(this.timeout)) {
            return false;
        }

        if (!isUndefined(maybeData.timer)) {
            clearTimeout(maybeData.timer);
        }

        const doDelete = () => this.doDelete(key, new Error("timed out"));
        const withTimer = {
            ...maybeData,
            timer: setTimeout(doDelete, this.timeout),
        };
        this.data.set(key, withTimer);

        return true;
    }

    /**
     * @param key The key of the `Promise` to delete.
     * @param reason The reason to reject with if `key` is pending.
     * @returns Whether `key` was deleted.
     */
    private doDelete(key: K, reason: Error): boolean {
        const maybeData = this.data.get(key);

        if (!isUndefined(maybeData)) {
            if (!isUndefined(maybeData.functions)) {
                maybeData.functions.reject(reason);
            }

            if (!isUndefined(maybeData.timer)) {
                clearTimeout(maybeData.timer);
            }
        }

        this.data.delete(key);

        return this.promises.delete(key);
    }
}

/**
 * Potential states for a Promise to be in. `Fulfilled` and `Rejected` are both
 * settled states.
 */
enum State {
    Pending,
    Fulfilled,
    Rejected,
}

/**
 * Control block data for a `PromiseMap` entry.
 */
interface MapData<T> {
    readonly functions?: PromiseFunctions<T>;
    readonly status: State;
    readonly timer?: NodeJS.Timer;
}

/**
 * @param data The data that we want to ensure has no `functions` member.
 * @returns A copy of `data` sans `functions`.
 */
const removeFunctions = <T>(data: MapData<T>): MapData<T> => {
    const { functions: _, ...toReturn } = data;

    return toReturn;
};