"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Adapter = void 0;
const events_1 = __importDefault(require("events"));
const ioredis_1 = __importDefault(require("ioredis"));
const cache_1 = require("../cache");
const metrics_1 = require("../cache/metrics");
const config_1 = require("../config");
const metrics_2 = require("../metrics");
const rate_limiting_1 = require("../rate-limiting");
const factory_1 = require("../rate-limiting/factory");
const util_1 = require("../util");
const requester_1 = require("../util/requester");
const error_1 = require("../validation/error");
const logger = (0, util_1.makeLogger)('Adapter');
/**
 * Main class to represent an External Adapter
 */
class Adapter {
    constructor(params) {
        // After initialization
        this.initialized = false;
        /** Object containing alias translations for all endpoints */
        this.endpointsMap = {};
        // Copy over params
        this.name = params.name;
        this.defaultEndpoint = params.defaultEndpoint?.toLowerCase();
        this.endpoints = params.endpoints;
        this.rateLimiting = params.rateLimiting;
        this.bootstrap = params.bootstrap;
        this.config =
            params.config || new config_1.AdapterConfig({});
        this.config.initialize();
        this.normalizeEndpointNames();
        this.calculateRateLimitAllocations();
        this.shutdownNotifier = new events_1.default();
    }
    /**
     * Initializes all of the [[Transport]]s in the adapter, passing along any [[AdapterDependencies]] and [[AdapterConfig]].
     * Additionally, it builds a map out of all the endpoint names and aliases (checking for duplicates).
     */
    async initialize(dependencies) {
        // We initialize the logger first, separate from the rest of the dependency initialization
        // since we could have a custom logger to document the EA lifecycle since the beginning.
        util_1.LoggerFactoryProvider.set(dependencies?.loggerFactory);
        if (this.initialized) {
            throw new Error('This adapter has already been initialized!');
        }
        if (this.name !== this.name.toUpperCase()) {
            throw new Error('Adapter name must be uppercase');
        }
        // We do this after we have the logging factory provider initialized
        this.logRateLimitAllocations();
        // Initialize metrics to register them with the prom-client
        metrics_2.metrics.initialize();
        // Building configs during initialization to avoid validation errors during construction
        this.config.validate();
        // Log warnings for risks associated with certain configs and values
        this.logConfigWarnings();
        if (this.bootstrap) {
            await this.bootstrap(this);
        }
        this.dependencies = this.initializeDependencies(dependencies);
        if (this.config.settings.EA_MODE !== 'reader' && this.dependencies.cache.lock) {
            const [lockAcquiredPromise, lockAcquiredResolve, lockAcquiredReject] = (0, util_1.deferredPromise)();
            this.lockAcquiredPromise = lockAcquiredPromise;
            const cacheLockKey = this.config.settings.CACHE_PREFIX
                ? `${this.config.settings.CACHE_PREFIX}-${this.name}`
                : this.name;
            // We need to defer the locking to support rollout strategies where the old container for an
            // adapter is still running while the new one is starting up, and we don't want to block the
            // old container from serving requests while the new one is starting up.
            setTimeout(async () => {
                try {
                    await (this.dependencies.cache.lock &&
                        this.dependencies.cache.lock(cacheLockKey, this.config.settings.CACHE_LOCK_DURATION, this.config.settings.CACHE_LOCK_RETRIES, this.shutdownNotifier));
                }
                catch (error) {
                    lockAcquiredReject(error);
                    return;
                }
                lockAcquiredResolve(true);
            }, this.config.settings.CACHE_LOCK_DEFERRAL_MS);
        }
        for (const endpoint of this.endpoints) {
            // Add aliases to map to use in validation
            const aliases = [endpoint.name, ...(endpoint.aliases || [])];
            for (const alias of aliases) {
                if (this.endpointsMap[alias]) {
                    throw new Error(`Duplicate endpoint / alias: "${alias}"`);
                }
                this.endpointsMap[alias] = endpoint;
            }
            logger.debug(`Initializing endpoint "${endpoint.name}"...`);
            await endpoint.initialize(this.name, this.dependencies, this.config.settings);
        }
        // Build list of key/values that need to be redacted in logs
        // Populates the static array in CensorList to use in censor-transport
        this.config.buildCensorList();
        logger.debug('Adapter initialization complete.');
        this.initialized = true;
    }
    /**
     * Takes an adapter and normalizes all endpoint names and aliases, as well as the default endpoint.
     * i.e. makes them lowercase for now
     */
    normalizeEndpointNames() {
        for (const endpoint of this.endpoints) {
            endpoint.name = endpoint.name.toLowerCase();
            endpoint.aliases = endpoint.aliases?.map((a) => a.toLowerCase());
        }
    }
    /**
     * This function will take an adapter structure and go through each endpoint, calculating
     * each one's allocation of the total rate limits that are set for the adapter as a whole.
     *
     */
    calculateRateLimitAllocations() {
        const numberOfEndpoints = this.endpoints.length;
        const endpointsWithExplicitAllocations = this.endpoints.filter((e) => e.rateLimiting);
        const totalExplicitAllocation = endpointsWithExplicitAllocations
            .map((e) => e.rateLimiting?.allocationPercentage || 0)
            .reduce((sum, next) => sum + next, 0);
        if (totalExplicitAllocation > 100) {
            throw new Error('The total allocation set for all endpoints summed cannot exceed 100%');
        }
        if (totalExplicitAllocation === 100 &&
            numberOfEndpoints - endpointsWithExplicitAllocations.length > 0) {
            throw new Error('The explicit allocation is at 100% but there are endpoints with implicit allocation');
        }
        const implicitAllocation = 100 - totalExplicitAllocation;
        for (const endpoint of this.endpoints) {
            if (!endpoint.rateLimiting) {
                endpoint.rateLimiting = {
                    allocationPercentage: implicitAllocation / (numberOfEndpoints - endpointsWithExplicitAllocations.length),
                };
            }
        }
    }
    logRateLimitAllocations() {
        logger.debug('Adapter rate limit allocations:');
        for (const endpoint of this.endpoints) {
            logger.debug(`Endpoint [${endpoint.name}] - ${endpoint.rateLimiting?.allocationPercentage}%`);
        }
    }
    /**
     * Logs a warning for certain configs if set to particular values.
     * Used to warn stakeholders of potential risks.
     */
    logConfigWarnings() {
        if (this.config.settings.LOG_LEVEL.toUpperCase() === 'DEBUG' ||
            this.config.settings.LOG_LEVEL.toUpperCase() === 'TRACE') {
            logger.warn(`LOG_LEVEL has been set to ${this.config.settings.LOG_LEVEL.toUpperCase()}. Setting higher log levels results in increased memory usage and potentially slower performance.`);
        }
        if (this.config.settings.DEBUG === true) {
            logger.warn(`The adapter is running with DEBUG mode on.`);
        }
        if (this.config.settings.METRICS_ENABLED === false) {
            logger.warn(`METRICS_ENABLED has been set to false. Metrics should not be disabled in a production environment.`);
        }
        if (this.config.settings.MAX_PAYLOAD_SIZE_LIMIT !==
            config_1.BaseSettingsDefinition.MAX_PAYLOAD_SIZE_LIMIT.default) {
            logger.warn(`MAX_PAYLOAD_SIZE_LIMIT has been set to ${this.config.settings.MAX_PAYLOAD_SIZE_LIMIT}. This setting should only be set when absolutely necessary.`);
        }
    }
    /**
     * This function will process dependencies for an adapter, such as caches or rate limiters,
     * in order to inject them into transports and other relevant places later in the lifecycle.
     *
     * @param inputDependencies - a partial obj of initialized dependencies to override the created ones
     * @returns a set of AdapterDependencies all initialized
     */
    initializeDependencies(inputDependencies) {
        const dependencies = inputDependencies || {};
        if (this.config.settings.EA_MODE !== 'reader-writer' &&
            this.config.settings.CACHE_TYPE === 'local') {
            throw new Error(`EA mode cannot be ${this.config.settings.EA_MODE} while cache type is local`);
        }
        if (this.config.settings.CACHE_TYPE === 'redis' && !dependencies.redisClient) {
            const maxCooldown = this.config.settings.CACHE_REDIS_MAX_RECONNECT_COOLDOWN;
            const redisOptions = {
                enableAutoPipelining: true, // This will make multiple commands be batch automatically
                host: this.config.settings.CACHE_REDIS_HOST,
                port: this.config.settings.CACHE_REDIS_PORT,
                password: this.config.settings.CACHE_REDIS_PASSWORD,
                path: this.config.settings.CACHE_REDIS_PATH, // If set, port and host are ignored
                timeout: this.config.settings.CACHE_REDIS_TIMEOUT,
                retryStrategy(times) {
                    metrics_2.metrics.get('redisRetriesCount').inc();
                    logger.warn(`Redis reconnect attempt #${times}`);
                    return Math.min(times * 100, maxCooldown); // Next reconnect attempt time
                },
                connectTimeout: this.config.settings.CACHE_REDIS_CONNECTION_TIMEOUT,
                maxRetriesPerRequest: 30, // Limits the number of retries before the adapter shuts down
            };
            if (this.config.settings.CACHE_REDIS_URL) {
                dependencies.redisClient = new ioredis_1.default(this.config.settings.CACHE_REDIS_URL, redisOptions);
            }
            else {
                dependencies.redisClient = new ioredis_1.default(redisOptions);
            }
            dependencies.redisClient.on('connect', () => {
                metrics_2.metrics.get('redisConnectionsOpen').inc();
            });
        }
        if (!dependencies.cache) {
            dependencies.cache = cache_1.CacheFactory.buildCache({
                cacheType: this.config.settings.CACHE_TYPE,
                maxSizeForLocalCache: this.config.settings.CACHE_MAX_ITEMS,
            }, dependencies.redisClient);
        }
        const rateLimitingTier = (0, rate_limiting_1.getRateLimitingTier)(this.config.settings, this.rateLimiting?.tiers);
        if (rateLimitingTier) {
            for (const limit of Object.values(rateLimitingTier)) {
                if (limit && limit < 0) {
                    throw new Error('Rate limit must be a positive number');
                }
            }
        }
        const highestTierValue = (0, rate_limiting_1.highestRateLimitTiers)(this.rateLimiting?.tiers);
        const rateLimitTierFromConfig = (0, rate_limiting_1.buildRateLimitTiersFromConfig)(this.config.settings);
        const perSecRateLimit = rateLimitTierFromConfig?.rateLimit1s || 0;
        const perMinuteRateLimit = (rateLimitTierFromConfig?.rateLimit1m || 0) / 60;
        if (perSecRateLimit > highestTierValue) {
            logger.warn(`The configured RATE_LIMIT_CAPACITY_SECOND value is higher than the highest tier value in the adapter rate limiting configurations ${highestTierValue}`);
        }
        if (perMinuteRateLimit > highestTierValue) {
            logger.warn(`The configured ${this.config.settings.RATE_LIMIT_CAPACITY_MINUTE
                ? 'RATE_LIMIT_CAPACITY_MINUTE'
                : 'RATE_LIMIT_CAPACITY'} value (${perMinuteRateLimit}) is higher than the highest tier value the adapter rate limiting configurations (${highestTierValue * 60})`);
        }
        if (!dependencies.rateLimiter) {
            dependencies.rateLimiter = factory_1.RateLimiterFactory.buildRateLimiter(this.config.settings.RATE_LIMITING_STRATEGY).initialize(this.endpoints, rateLimitingTier);
        }
        if (!dependencies.subscriptionSetFactory) {
            dependencies.subscriptionSetFactory = new util_1.SubscriptionSetFactory(this.config.settings, this.name, dependencies.redisClient);
        }
        if (!dependencies.requester) {
            dependencies.requester = new requester_1.Requester(dependencies.rateLimiter, this.config.settings);
        }
        return dependencies;
    }
    /**
     * Attempts to find a value from the Cache and return that if found.
     *
     * @param req - the incoming request to this adapter
     * @returns the cached value if exists
     */
    async findResponseInCache(req) {
        const response = await this.dependencies.cache.get(req.requestContext.cacheKey);
        if (response) {
            if (this.config.settings.METRICS_ENABLED &&
                this.config.settings.EXPERIMENTAL_METRICS_ENABLED) {
                const label = (0, metrics_1.cacheMetricsLabel)(req.requestContext.cacheKey, req.requestContext.meta?.metrics?.feedId || 'N/A', this.config.settings.CACHE_TYPE);
                // Record cache staleness and cache get count and value
                const now = Date.now();
                (0, metrics_1.cacheGet)(label, response.result, {
                    cache: now - response.timestamps.providerDataReceivedUnixMs,
                    total: response.timestamps.providerIndicatedTimeUnixMs
                        ? now - response.timestamps.providerIndicatedTimeUnixMs
                        : null,
                });
                req.requestContext.meta = {
                    ...req.requestContext.meta,
                    metrics: { ...req.requestContext.meta?.metrics, cacheHit: true },
                };
            }
            return response;
        }
    }
    validateOutput(req, output) {
        const endpoint = this.endpointsMap[req.requestContext.endpointName];
        if (endpoint.customOutputValidation) {
            return endpoint.customOutputValidation(output);
        }
        return undefined;
    }
    async handleRequestWithValidation(req, replySent) {
        const response = await this.handleRequest(req, replySent);
        if (!response.errorMessage) {
            this.validateOutput(req, response);
        }
        return response;
    }
    /**
     * Function to serve as middleware to pass along the AdapterRequest to the appropriate Transport (acc. to the endpoint in the req.)
     *
     * @param req - the incoming request to this adapter
     * @param replySent - a promise that resolves when the reply has already been sent
     * @returns a simple Promise when it's done
     */
    async handleRequest(req, replySent) {
        // Get transport, must be here because it's already checked in the validator
        const endpoint = this.endpointsMap[req.requestContext.endpointName];
        const transport = endpoint.transportRoutes.get(req.requestContext.transportName);
        // First try to find the response in our cache, keep it ready
        const cachedResponse = await this.findResponseInCache(req);
        // Next we fire off the transport's registration of the request if defined, regardless of if we already have a cached response.
        // This is necessary to ensure things like subscription sets are updated each time we get a request
        let requestRegistrationPromise;
        let requestRegistrationError;
        if (transport.registerRequest) {
            const handler = async () => {
                // If we already have a cached response, wait for it to be sent back before continuing with registration
                // This way we respond to incoming requests from the cache as fast as possible
                if (cachedResponse) {
                    await replySent;
                }
                try {
                    // `await` is required to catch the error, you'll get an unhandled promise rejection otherwise
                    // Disable non-null assertion operator because we already checked for the existence of registerRequest
                    return await transport.registerRequest(req, this.config.settings);
                }
                catch (err) {
                    (0, util_1.censorLogs)(() => logger.error(`Error registering request: ${err}`));
                    requestRegistrationError = err;
                }
            };
            // Execute the registration handler without blocking
            logger.debug(`Firing request registration handler${cachedResponse ? ' (cached response already sent)' : ''}`);
            requestRegistrationPromise = handler();
        }
        // Now that we have dealt with request registration, can return the cached response if present
        if (cachedResponse) {
            logger.debug('Found response from cache, sending that');
            return cachedResponse;
        }
        // If there was no cached response, execute the foregroundExecute if defined
        const immediateResponse = transport.foregroundExecute && (await transport.foregroundExecute(req, this.config.settings));
        if (immediateResponse) {
            logger.debug('Got immediate response from transport, sending as response');
            return immediateResponse;
        }
        // Finally, either because there was no synchronous execute or because it returned an empty response,
        // we wait for the cache to be filled (either from background work started in the sync execute, or the backgroundExecute).
        // We can wait for the request registration to have finished here, since we're going to be sleeping for the cache anyways,
        // and it's useful in case the registration throws a promise so that it doesn't go unhandled.
        await requestRegistrationPromise;
        if (requestRegistrationError) {
            throw requestRegistrationError;
        }
        // Observe the idle time taken for polling response
        const metricsTimer = metrics_2.metrics
            .get('transportPollingDurationSeconds')
            .labels({ adapter_endpoint: req.requestContext.endpointName })
            .startTimer();
        logger.debug('Transport is set up, polling cache for response...');
        const response = await (0, cache_1.pollResponseFromCache)(this.dependencies.cache, req.requestContext.cacheKey, {
            maxRetries: this.config.settings.CACHE_POLLING_MAX_RETRIES,
            sleep: this.config.settings.CACHE_POLLING_SLEEP_MS,
        });
        metricsTimer({ succeeded: String(!!response) });
        if (response) {
            logger.debug('Got a response from polling the cache, sending that back');
            return response;
        }
        // Record polling mechanism failure to return response
        metrics_2.metrics
            .get('transportPollingFailureCount')
            .labels({ adapter_endpoint: req.requestContext.endpointName })
            .inc();
        logger.debug('Ran out of polling attempts, returning timeout');
        throw new error_1.AdapterTimeoutError({
            message: 'The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.',
            statusCode: 504,
        });
    }
}
exports.Adapter = Adapter;
//# sourceMappingURL=basic.js.map