UNPKG

@azure/cosmos

Version:
377 lines • 18.3 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { OperationType, ResourceType, isReadRequest } from "./common/index.js"; import { Constants } from "./common/constants.js"; import { MetadataLookUpType } from "./CosmosDiagnostics.js"; import { withMetadataDiagnostics } from "./utils/diagnostics.js"; import { normalizeEndpoint } from "./utils/checkURL.js"; import { canApplyExcludedLocations } from "./common/helper.js"; /** * @hidden * This internal class implements the logic for endpoint management for geo-replicated database accounts. */ export 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, ResourceType.item, OperationType.Read); } /** * Gets the current write endpoint from the endpoint cache. */ async getWriteEndpoint(diagnosticNode) { return this.resolveServiceEndpoint(diagnosticNode, ResourceType.item, 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 === ResourceType.item || (resourceType === ResourceType.sproc && operationType === OperationType.Execute)); } return canUse; } getEffectiveExcludedLocations(excludedLocations = [], resourceType) { if (!canApplyExcludedLocations(resourceType)) { return new Set(); } return excludedLocations.length ? new Set(excludedLocations.map(normalizeEndpoint)) : new Set(); } filterExcludedLocations(preferredLocations, excludedLocations) { if (!excludedLocations || excludedLocations.size === 0) { return preferredLocations; } const filteredLocations = preferredLocations.filter((location) => { return !excludedLocations.has(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 === 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 withMetadataDiagnostics(async (metadataNode) => { return this.readDatabaseAccount(metadataNode, { urlConnection: this.defaultEndpoint, }); }, diagnosticNode, 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 = 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 && normalizeEndpoint(loc.name) === 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(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.LocationUnavailableExpirationTimeInMs) { unavailableLocation.unavailable = false; } } } cleanUnavailableLocationList(now, unavailableLocations) { return unavailableLocations.filter((loc) => { return (now - loc.lastUnavailabilityTimestampInMs < 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); } } } //# sourceMappingURL=globalEndpointManager.js.map