"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GlobalEndpointManager = void 0;
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
const index_js_1 = require("./common/index.js");
const constants_js_1 = require("./common/constants.js");
const CosmosDiagnostics_js_1 = require("./CosmosDiagnostics.js");
const diagnostics_js_1 = require("./utils/diagnostics.js");
const checkURL_js_1 = require("./utils/checkURL.js");
const helper_js_1 = require("./common/helper.js");
/**
 * @hidden
 * This internal class implements the logic for endpoint management for geo-replicated database accounts.
 */
class GlobalEndpointManager {
    readDatabaseAccount;
    /**
     * The endpoint used to create the client instance.
     */
    defaultEndpoint;
    /**
     * Flag to enable/disable automatic redirecting of requests based on read/write operations.
     */
    enableEndpointDiscovery;
    isRefreshing;
    options;
    /**
     * List of azure regions to be used as preferred locations for read requests.
     */
    preferredLocations;
    writeableLocations = [];
    readableLocations = [];
    unavailableReadableLocations = [];
    unavailableWriteableLocations = [];
    enableMultipleWriteLocations;
    preferredLocationsCount;
    /**
     * Flag to enable/disable the Per Partition Level Failover (PPAF). Contains the value from the Connection policy.
     * @internal
     */
    enablePartitionLevelFailover;
    /**
     * Flag to enable/disable the Per Partition Level Circuit Breaker (PPCB). Contains the value from the Connection policy.
     * @internal
     */
    enablePartitionLevelCircuitBreaker;
    /**
     * Cached PPAF enablement status from the last account refresh
     * @internal
     */
    lastKnownPPAFEnabled;
    /**
     * Cached circuit breaker timer enablement status
     * @internal
     */
    lastKnownPPCBEnabled;
    /**
     * Event that is raised when circuit breaker timer should start or stop based on PPAF/PPCB status changes
     * @internal
     */
    onEnablePartitionLevelFailoverConfigChanged;
    /**
     * @param options - The document client instance.
     * @internal
     */
    constructor(options, readDatabaseAccount) {
        this.readDatabaseAccount = readDatabaseAccount;
        this.options = options;
        this.defaultEndpoint = options.endpoint;
        this.enableEndpointDiscovery = options.connectionPolicy.enableEndpointDiscovery;
        this.isRefreshing = false;
        this.preferredLocations = this.options.connectionPolicy.preferredLocations;
        this.preferredLocationsCount = this.preferredLocations ? this.preferredLocations.length : 0;
        this.enablePartitionLevelFailover = options.connectionPolicy.enablePartitionLevelFailover;
        this.enablePartitionLevelCircuitBreaker =
            options.connectionPolicy.enablePartitionLevelCircuitBreaker;
        this.lastKnownPPCBEnabled = options.connectionPolicy.enablePartitionLevelCircuitBreaker;
        this.lastKnownPPAFEnabled = false;
    }
    /**
     * Gets the current read endpoint from the endpoint cache.
     */
    async getReadEndpoint(diagnosticNode) {
        return this.resolveServiceEndpoint(diagnosticNode, index_js_1.ResourceType.item, index_js_1.OperationType.Read);
    }
    /**
     * Gets the current write endpoint from the endpoint cache.
     */
    async getWriteEndpoint(diagnosticNode) {
        return this.resolveServiceEndpoint(diagnosticNode, index_js_1.ResourceType.item, index_js_1.OperationType.Replace);
    }
    async getReadEndpoints() {
        return this.readableLocations.map((loc) => loc.databaseAccountEndpoint);
    }
    async getWriteEndpoints() {
        return this.writeableLocations.map((loc) => loc.databaseAccountEndpoint);
    }
    /**
     * Gets the read locations from the endpoint cache.
     */
    async getReadLocations() {
        return this.readableLocations;
    }
    async markCurrentLocationUnavailableForRead(diagnosticNode, endpoint) {
        await this.refreshEndpointList(diagnosticNode);
        const location = this.readableLocations.find((loc) => loc.databaseAccountEndpoint === endpoint);
        if (location) {
            location.unavailable = true;
            location.lastUnavailabilityTimestampInMs = Date.now();
            this.unavailableReadableLocations.push(location);
        }
    }
    async markCurrentLocationUnavailableForWrite(diagnosticNode, endpoint) {
        await this.refreshEndpointList(diagnosticNode);
        const location = this.writeableLocations.find((loc) => loc.databaseAccountEndpoint === endpoint);
        if (location) {
            location.unavailable = true;
            location.lastUnavailabilityTimestampInMs = Date.now();
            this.unavailableWriteableLocations.push(location);
        }
    }
    canUseMultipleWriteLocations(resourceType, operationType) {
        let canUse = this.options.connectionPolicy.useMultipleWriteLocations && this.enableMultipleWriteLocations;
        if (resourceType) {
            canUse =
                canUse &&
                    (resourceType === index_js_1.ResourceType.item ||
                        (resourceType === index_js_1.ResourceType.sproc && operationType === index_js_1.OperationType.Execute));
        }
        return canUse;
    }
    getEffectiveExcludedLocations(excludedLocations = [], resourceType) {
        if (!(0, helper_js_1.canApplyExcludedLocations)(resourceType)) {
            return new Set();
        }
        return excludedLocations.length ? new Set(excludedLocations.map(checkURL_js_1.normalizeEndpoint)) : new Set();
    }
    filterExcludedLocations(preferredLocations, excludedLocations) {
        if (!excludedLocations || excludedLocations.size === 0) {
            return preferredLocations;
        }
        const filteredLocations = preferredLocations.filter((location) => {
            return !excludedLocations.has((0, checkURL_js_1.normalizeEndpoint)(location));
        });
        return filteredLocations;
    }
    async resolveServiceEndpoint(diagnosticNode, resourceType, operationType, startServiceEndpointIndex = 0) {
        return this.resolveServiceEndpointInternal({
            diagnosticNode: diagnosticNode,
            resourceType: resourceType,
            operationType: operationType,
            startServiceEndpointIndex: startServiceEndpointIndex,
        });
    }
    /**
     * @internal
     */
    async resolveServiceEndpointInternal(resolveServiceEndpointOptions) {
        // Extract all fields from ResolveServiceEndpointOptions
        const { diagnosticNode, resourceType, operationType, startServiceEndpointIndex, excludedLocations = [], } = resolveServiceEndpointOptions;
        // If endpoint discovery is disabled, always use the user provided endpoint
        if (!this.options.connectionPolicy.enableEndpointDiscovery) {
            diagnosticNode.addData({ readFromCache: true }, "default_endpoint");
            diagnosticNode.recordEndpointResolution(this.defaultEndpoint);
            return this.defaultEndpoint;
        }
        // If getting the database account, always use the user provided endpoint
        if (resourceType === index_js_1.ResourceType.none) {
            diagnosticNode.addData({ readFromCache: true }, "none_resource");
            diagnosticNode.recordEndpointResolution(this.defaultEndpoint);
            return this.defaultEndpoint;
        }
        if (this.readableLocations.length === 0 || this.writeableLocations.length === 0) {
            const resourceResponse = await (0, diagnostics_js_1.withMetadataDiagnostics)(async (metadataNode) => {
                return this.readDatabaseAccount(metadataNode, {
                    urlConnection: this.defaultEndpoint,
                });
            }, diagnosticNode, CosmosDiagnostics_js_1.MetadataLookUpType.DatabaseAccountLookUp);
            this.writeableLocations = resourceResponse.resource.writableLocations;
            this.readableLocations = resourceResponse.resource.readableLocations;
            this.enableMultipleWriteLocations = resourceResponse.resource.enableMultipleWritableLocations;
            if (this.enablePartitionLevelFailover) {
                this.refreshPPAFFeatureFlag(resourceResponse.resource);
            }
        }
        const locations = (0, index_js_1.isReadRequest)(operationType)
            ? this.readableLocations
            : this.writeableLocations;
        const effectiveExcludedLocations = this.getEffectiveExcludedLocations(excludedLocations, resourceType);
        diagnosticNode.addData({ excludedLocations: Array.from(effectiveExcludedLocations) }, "excluded_locations");
        // Filter locations based on exclusions
        const availableLocations = this.filterExcludedLocations(this.preferredLocations, effectiveExcludedLocations);
        let location;
        // If we have preferred locations, try each one in order and use the first available one
        if (availableLocations &&
            availableLocations.length > 0 &&
            startServiceEndpointIndex < availableLocations.length) {
            this.preferredLocationsCount = availableLocations.length;
            for (let i = startServiceEndpointIndex; i < availableLocations.length; i++) {
                const preferredLocation = availableLocations[i];
                location = locations.find((loc) => loc.unavailable !== true &&
                    (0, checkURL_js_1.normalizeEndpoint)(loc.name) === (0, checkURL_js_1.normalizeEndpoint)(preferredLocation));
                if (location) {
                    break;
                }
            }
        }
        // If no preferred locations or one did not match, just grab the first one that is available
        if (!location) {
            const startIndexValid = startServiceEndpointIndex >= 0 && startServiceEndpointIndex < locations.length;
            const locationsToSearch = startIndexValid
                ? locations.slice(startServiceEndpointIndex)
                : locations;
            location = locationsToSearch.find((loc) => {
                return (loc.unavailable !== true && !effectiveExcludedLocations.has((0, checkURL_js_1.normalizeEndpoint)(loc.name)));
            });
        }
        location = location ? location : { name: "", databaseAccountEndpoint: this.defaultEndpoint };
        diagnosticNode.recordEndpointResolution(location.databaseAccountEndpoint);
        return location.databaseAccountEndpoint;
    }
    /**
     * Refreshes the endpoint list by clearning stale unavailability and then
     *  retrieving the writable and readable locations from the geo-replicated database account
     *  and then updating the locations cache.
     *  We skip the refreshing if enableEndpointDiscovery is set to False
     */
    async refreshEndpointList(diagnosticNode) {
        if (!this.isRefreshing && this.enableEndpointDiscovery) {
            this.isRefreshing = true;
            const databaseAccount = await this.getDatabaseAccountFromAnyEndpoint(diagnosticNode);
            if (databaseAccount) {
                if (this.enablePartitionLevelFailover) {
                    this.refreshPPAFFeatureFlag(databaseAccount);
                }
                this.refreshStaleUnavailableLocations();
                this.refreshEndpoints(databaseAccount);
            }
            this.isRefreshing = false;
        }
    }
    refreshEndpoints(databaseAccount) {
        const oldWritableLocations = this.writeableLocations;
        const oldReadableLocations = this.readableLocations;
        function merge(loc, oldList) {
            const prev = oldList.find((o) => o.name === loc.name);
            if (prev) {
                loc.unavailable = prev.unavailable;
                loc.lastUnavailabilityTimestampInMs = prev.lastUnavailabilityTimestampInMs;
            }
            return loc;
        }
        this.writeableLocations = databaseAccount.writableLocations.map((loc) => merge({ ...loc }, oldWritableLocations));
        this.readableLocations = databaseAccount.readableLocations.map((loc) => merge({ ...loc }, oldReadableLocations));
    }
    refreshStaleUnavailableLocations() {
        const now = Date.now();
        this.updateLocation(now, this.unavailableReadableLocations, this.readableLocations);
        this.unavailableReadableLocations = this.cleanUnavailableLocationList(now, this.unavailableReadableLocations);
        this.updateLocation(now, this.unavailableWriteableLocations, this.writeableLocations);
        this.unavailableWriteableLocations = this.cleanUnavailableLocationList(now, this.unavailableWriteableLocations);
    }
    /**
     * update the locationUnavailability to undefined if the location is available again
     * @param now - current time
     * @param unavailableLocations - list of unavailable locations
     * @param allLocations - list of all locations
     */
    updateLocation(now, unavailableLocations, allLocations) {
        for (const location of unavailableLocations) {
            const unavailableLocation = allLocations.find((loc) => loc.name === location.name);
            if (unavailableLocation &&
                now - unavailableLocation.lastUnavailabilityTimestampInMs >
                    constants_js_1.Constants.LocationUnavailableExpirationTimeInMs) {
                unavailableLocation.unavailable = false;
            }
        }
    }
    cleanUnavailableLocationList(now, unavailableLocations) {
        return unavailableLocations.filter((loc) => {
            return (now - loc.lastUnavailabilityTimestampInMs < constants_js_1.Constants.LocationUnavailableExpirationTimeInMs);
        });
    }
    /**
     * Gets the database account first by using the default endpoint, and if that doesn't returns
     * use the endpoints for the preferred locations in the order they are specified to get
     * the database account.
     */
    async getDatabaseAccountFromAnyEndpoint(diagnosticNode) {
        try {
            const options = { urlConnection: this.defaultEndpoint };
            const { resource: databaseAccount } = await this.readDatabaseAccount(diagnosticNode, options);
            return databaseAccount;
            // If for any reason(non - globaldb related), we are not able to get the database
            // account from the above call to readDatabaseAccount,
            // we would try to get this information from any of the preferred locations that the user
            // might have specified (by creating a locational endpoint)
            // and keeping eating the exception until we get the database account and return None at the end,
            // if we are not able to get that info from any endpoints
        }
        catch (err) {
            // TODO: Tracing
        }
        if (this.preferredLocations) {
            for (const location of this.preferredLocations) {
                try {
                    const locationalEndpoint = GlobalEndpointManager.getLocationalEndpoint(this.defaultEndpoint, location);
                    const options = { urlConnection: locationalEndpoint };
                    const { resource: databaseAccount } = await this.readDatabaseAccount(diagnosticNode, options);
                    if (databaseAccount) {
                        return databaseAccount;
                    }
                }
                catch (err) {
                    // TODO: Tracing
                }
            }
        }
    }
    /**
     * Gets the locational endpoint using the location name passed to it using the default endpoint.
     *
     * @param defaultEndpoint - The default endpoint to use for the endpoint.
     * @param locationName    - The location name for the azure region like "East US".
     */
    static getLocationalEndpoint(defaultEndpoint, locationName) {
        // For defaultEndpoint like 'https://contoso.documents.azure.com:443/' parse it to generate URL format
        // This defaultEndpoint should be global endpoint(and cannot be a locational endpoint)
        // and we agreed to document that
        const endpointUrl = new URL(defaultEndpoint);
        // hostname attribute in endpointUrl will return 'contoso.documents.azure.com'
        if (endpointUrl.hostname) {
            const hostnameParts = endpointUrl.hostname.toString().toLowerCase().split(".");
            if (hostnameParts) {
                // globalDatabaseAccountName will return 'contoso'
                const globalDatabaseAccountName = hostnameParts[0];
                // Prepare the locationalDatabaseAccountName as contoso-EastUS for location_name 'East US'
                const locationalDatabaseAccountName = globalDatabaseAccountName + "-" + locationName.replace(" ", "");
                // Replace 'contoso' with 'contoso-EastUS' and
                // return locationalEndpoint as https://contoso-EastUS.documents.azure.com:443/
                const locationalEndpoint = defaultEndpoint
                    .toLowerCase()
                    .replace(globalDatabaseAccountName, locationalDatabaseAccountName);
                return locationalEndpoint;
            }
        }
        return null;
    }
    /**
     * Checks for changes in PPAF enablement status and raises events if they have changed.
     * It also manages circuit breaker timer state.
     * @internal
     */
    refreshPPAFFeatureFlag(databaseAccount) {
        let shouldEnableCircuitBreakerTimer = false;
        if (this.enablePartitionLevelCircuitBreaker) {
            // If PPCB is enabled in connection policy, always run circuit breaker
            shouldEnableCircuitBreakerTimer = true;
        }
        else {
            // If PPCB is disabled, circuit breaker timer depends on PPAF flags
            if (!this.enablePartitionLevelFailover) {
                // If PPAF is disabled in connection policy, don't run circuit breaker ever.
                shouldEnableCircuitBreakerTimer = false;
            }
            else {
                shouldEnableCircuitBreakerTimer =
                    databaseAccount.enablePerPartitionFailover ?? this.lastKnownPPAFEnabled ?? false;
            }
        }
        this.lastKnownPPAFEnabled = databaseAccount.enablePerPartitionFailover;
        // Only trigger callback if the circuit breaker timer state has changed
        if (this.lastKnownPPCBEnabled !== shouldEnableCircuitBreakerTimer) {
            this.lastKnownPPCBEnabled = shouldEnableCircuitBreakerTimer;
            this.onEnablePartitionLevelFailoverConfigChanged?.(shouldEnableCircuitBreakerTimer);
        }
    }
}
exports.GlobalEndpointManager = GlobalEndpointManager;
//# sourceMappingURL=globalEndpointManager.js.map