@azure/cosmos
Version:
Microsoft Azure Cosmos DB Service Node.js SDK for NOSQL API
377 lines • 18.3 kB
JavaScript
// 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