|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { ChannelOptions } from './channel-options'; |
|
import { ConnectivityState } from './connectivity-state'; |
|
import { LogVerbosity, Status } from './constants'; |
|
import { durationToMs, isDuration, msToDuration } from './duration'; |
|
import { |
|
ChannelControlHelper, |
|
createChildChannelControlHelper, |
|
registerLoadBalancerType, |
|
} from './experimental'; |
|
import { |
|
getFirstUsableConfig, |
|
LoadBalancer, |
|
LoadBalancingConfig, |
|
validateLoadBalancingConfig, |
|
} from './load-balancer'; |
|
import { ChildLoadBalancerHandler } from './load-balancer-child-handler'; |
|
import { PickArgs, Picker, PickResult, PickResultType } from './picker'; |
|
import { |
|
SubchannelAddress, |
|
subchannelAddressToString, |
|
} from './subchannel-address'; |
|
import { |
|
BaseSubchannelWrapper, |
|
ConnectivityStateListener, |
|
SubchannelInterface, |
|
} from './subchannel-interface'; |
|
import * as logging from './logging'; |
|
|
|
const TRACER_NAME = 'outlier_detection'; |
|
|
|
function trace(text: string): void { |
|
logging.trace(LogVerbosity.DEBUG, TRACER_NAME, text); |
|
} |
|
|
|
const TYPE_NAME = 'outlier_detection'; |
|
|
|
const OUTLIER_DETECTION_ENABLED = |
|
(process.env.GRPC_EXPERIMENTAL_ENABLE_OUTLIER_DETECTION ?? 'true') === 'true'; |
|
|
|
export interface SuccessRateEjectionConfig { |
|
readonly stdev_factor: number; |
|
readonly enforcement_percentage: number; |
|
readonly minimum_hosts: number; |
|
readonly request_volume: number; |
|
} |
|
|
|
export interface FailurePercentageEjectionConfig { |
|
readonly threshold: number; |
|
readonly enforcement_percentage: number; |
|
readonly minimum_hosts: number; |
|
readonly request_volume: number; |
|
} |
|
|
|
const defaultSuccessRateEjectionConfig: SuccessRateEjectionConfig = { |
|
stdev_factor: 1900, |
|
enforcement_percentage: 100, |
|
minimum_hosts: 5, |
|
request_volume: 100, |
|
}; |
|
|
|
const defaultFailurePercentageEjectionConfig: FailurePercentageEjectionConfig = |
|
{ |
|
threshold: 85, |
|
enforcement_percentage: 100, |
|
minimum_hosts: 5, |
|
request_volume: 50, |
|
}; |
|
|
|
type TypeofValues = |
|
| 'object' |
|
| 'boolean' |
|
| 'function' |
|
| 'number' |
|
| 'string' |
|
| 'undefined'; |
|
|
|
function validateFieldType( |
|
obj: any, |
|
fieldName: string, |
|
expectedType: TypeofValues, |
|
objectName?: string |
|
) { |
|
if (fieldName in obj && typeof obj[fieldName] !== expectedType) { |
|
const fullFieldName = objectName ? `${objectName}.${fieldName}` : fieldName; |
|
throw new Error( |
|
`outlier detection config ${fullFieldName} parse error: expected ${expectedType}, got ${typeof obj[ |
|
fieldName |
|
]}` |
|
); |
|
} |
|
} |
|
|
|
function validatePositiveDuration( |
|
obj: any, |
|
fieldName: string, |
|
objectName?: string |
|
) { |
|
const fullFieldName = objectName ? `${objectName}.${fieldName}` : fieldName; |
|
if (fieldName in obj) { |
|
if (!isDuration(obj[fieldName])) { |
|
throw new Error( |
|
`outlier detection config ${fullFieldName} parse error: expected Duration, got ${typeof obj[ |
|
fieldName |
|
]}` |
|
); |
|
} |
|
if ( |
|
!( |
|
obj[fieldName].seconds >= 0 && |
|
obj[fieldName].seconds <= 315_576_000_000 && |
|
obj[fieldName].nanos >= 0 && |
|
obj[fieldName].nanos <= 999_999_999 |
|
) |
|
) { |
|
throw new Error( |
|
`outlier detection config ${fullFieldName} parse error: values out of range for non-negative Duaration` |
|
); |
|
} |
|
} |
|
} |
|
|
|
function validatePercentage(obj: any, fieldName: string, objectName?: string) { |
|
const fullFieldName = objectName ? `${objectName}.${fieldName}` : fieldName; |
|
validateFieldType(obj, fieldName, 'number', objectName); |
|
if (fieldName in obj && !(obj[fieldName] >= 0 && obj[fieldName] <= 100)) { |
|
throw new Error( |
|
`outlier detection config ${fullFieldName} parse error: value out of range for percentage (0-100)` |
|
); |
|
} |
|
} |
|
|
|
export class OutlierDetectionLoadBalancingConfig |
|
implements LoadBalancingConfig |
|
{ |
|
private readonly intervalMs: number; |
|
private readonly baseEjectionTimeMs: number; |
|
private readonly maxEjectionTimeMs: number; |
|
private readonly maxEjectionPercent: number; |
|
private readonly successRateEjection: SuccessRateEjectionConfig | null; |
|
private readonly failurePercentageEjection: FailurePercentageEjectionConfig | null; |
|
|
|
constructor( |
|
intervalMs: number | null, |
|
baseEjectionTimeMs: number | null, |
|
maxEjectionTimeMs: number | null, |
|
maxEjectionPercent: number | null, |
|
successRateEjection: Partial<SuccessRateEjectionConfig> | null, |
|
failurePercentageEjection: Partial<FailurePercentageEjectionConfig> | null, |
|
private readonly childPolicy: LoadBalancingConfig[] |
|
) { |
|
if ( |
|
childPolicy.length > 0 && |
|
childPolicy[0].getLoadBalancerName() === 'pick_first' |
|
) { |
|
throw new Error( |
|
'outlier_detection LB policy cannot have a pick_first child policy' |
|
); |
|
} |
|
this.intervalMs = intervalMs ?? 10_000; |
|
this.baseEjectionTimeMs = baseEjectionTimeMs ?? 30_000; |
|
this.maxEjectionTimeMs = maxEjectionTimeMs ?? 300_000; |
|
this.maxEjectionPercent = maxEjectionPercent ?? 10; |
|
this.successRateEjection = successRateEjection |
|
? { ...defaultSuccessRateEjectionConfig, ...successRateEjection } |
|
: null; |
|
this.failurePercentageEjection = failurePercentageEjection |
|
? { |
|
...defaultFailurePercentageEjectionConfig, |
|
...failurePercentageEjection, |
|
} |
|
: null; |
|
} |
|
getLoadBalancerName(): string { |
|
return TYPE_NAME; |
|
} |
|
toJsonObject(): object { |
|
return { |
|
interval: msToDuration(this.intervalMs), |
|
base_ejection_time: msToDuration(this.baseEjectionTimeMs), |
|
max_ejection_time: msToDuration(this.maxEjectionTimeMs), |
|
max_ejection_percent: this.maxEjectionPercent, |
|
success_rate_ejection: this.successRateEjection, |
|
failure_percentage_ejection: this.failurePercentageEjection, |
|
child_policy: this.childPolicy.map(policy => policy.toJsonObject()), |
|
}; |
|
} |
|
|
|
getIntervalMs(): number { |
|
return this.intervalMs; |
|
} |
|
getBaseEjectionTimeMs(): number { |
|
return this.baseEjectionTimeMs; |
|
} |
|
getMaxEjectionTimeMs(): number { |
|
return this.maxEjectionTimeMs; |
|
} |
|
getMaxEjectionPercent(): number { |
|
return this.maxEjectionPercent; |
|
} |
|
getSuccessRateEjectionConfig(): SuccessRateEjectionConfig | null { |
|
return this.successRateEjection; |
|
} |
|
getFailurePercentageEjectionConfig(): FailurePercentageEjectionConfig | null { |
|
return this.failurePercentageEjection; |
|
} |
|
getChildPolicy(): LoadBalancingConfig[] { |
|
return this.childPolicy; |
|
} |
|
|
|
copyWithChildPolicy( |
|
childPolicy: LoadBalancingConfig[] |
|
): OutlierDetectionLoadBalancingConfig { |
|
return new OutlierDetectionLoadBalancingConfig( |
|
this.intervalMs, |
|
this.baseEjectionTimeMs, |
|
this.maxEjectionTimeMs, |
|
this.maxEjectionPercent, |
|
this.successRateEjection, |
|
this.failurePercentageEjection, |
|
childPolicy |
|
); |
|
} |
|
|
|
static createFromJson(obj: any): OutlierDetectionLoadBalancingConfig { |
|
validatePositiveDuration(obj, 'interval'); |
|
validatePositiveDuration(obj, 'base_ejection_time'); |
|
validatePositiveDuration(obj, 'max_ejection_time'); |
|
validatePercentage(obj, 'max_ejection_percent'); |
|
if ('success_rate_ejection' in obj) { |
|
if (typeof obj.success_rate_ejection !== 'object') { |
|
throw new Error( |
|
'outlier detection config success_rate_ejection must be an object' |
|
); |
|
} |
|
validateFieldType( |
|
obj.success_rate_ejection, |
|
'stdev_factor', |
|
'number', |
|
'success_rate_ejection' |
|
); |
|
validatePercentage( |
|
obj.success_rate_ejection, |
|
'enforcement_percentage', |
|
'success_rate_ejection' |
|
); |
|
validateFieldType( |
|
obj.success_rate_ejection, |
|
'minimum_hosts', |
|
'number', |
|
'success_rate_ejection' |
|
); |
|
validateFieldType( |
|
obj.success_rate_ejection, |
|
'request_volume', |
|
'number', |
|
'success_rate_ejection' |
|
); |
|
} |
|
if ('failure_percentage_ejection' in obj) { |
|
if (typeof obj.failure_percentage_ejection !== 'object') { |
|
throw new Error( |
|
'outlier detection config failure_percentage_ejection must be an object' |
|
); |
|
} |
|
validatePercentage( |
|
obj.failure_percentage_ejection, |
|
'threshold', |
|
'failure_percentage_ejection' |
|
); |
|
validatePercentage( |
|
obj.failure_percentage_ejection, |
|
'enforcement_percentage', |
|
'failure_percentage_ejection' |
|
); |
|
validateFieldType( |
|
obj.failure_percentage_ejection, |
|
'minimum_hosts', |
|
'number', |
|
'failure_percentage_ejection' |
|
); |
|
validateFieldType( |
|
obj.failure_percentage_ejection, |
|
'request_volume', |
|
'number', |
|
'failure_percentage_ejection' |
|
); |
|
} |
|
|
|
return new OutlierDetectionLoadBalancingConfig( |
|
obj.interval ? durationToMs(obj.interval) : null, |
|
obj.base_ejection_time ? durationToMs(obj.base_ejection_time) : null, |
|
obj.max_ejection_time ? durationToMs(obj.max_ejection_time) : null, |
|
obj.max_ejection_percent ?? null, |
|
obj.success_rate_ejection, |
|
obj.failure_percentage_ejection, |
|
obj.child_policy.map(validateLoadBalancingConfig) |
|
); |
|
} |
|
} |
|
|
|
class OutlierDetectionSubchannelWrapper |
|
extends BaseSubchannelWrapper |
|
implements SubchannelInterface |
|
{ |
|
private childSubchannelState: ConnectivityState; |
|
private stateListeners: ConnectivityStateListener[] = []; |
|
private ejected = false; |
|
private refCount = 0; |
|
constructor( |
|
childSubchannel: SubchannelInterface, |
|
private mapEntry?: MapEntry |
|
) { |
|
super(childSubchannel); |
|
this.childSubchannelState = childSubchannel.getConnectivityState(); |
|
childSubchannel.addConnectivityStateListener( |
|
(subchannel, previousState, newState, keepaliveTime) => { |
|
this.childSubchannelState = newState; |
|
if (!this.ejected) { |
|
for (const listener of this.stateListeners) { |
|
listener(this, previousState, newState, keepaliveTime); |
|
} |
|
} |
|
} |
|
); |
|
} |
|
|
|
getConnectivityState(): ConnectivityState { |
|
if (this.ejected) { |
|
return ConnectivityState.TRANSIENT_FAILURE; |
|
} else { |
|
return this.childSubchannelState; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
addConnectivityStateListener(listener: ConnectivityStateListener) { |
|
this.stateListeners.push(listener); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
removeConnectivityStateListener(listener: ConnectivityStateListener) { |
|
const listenerIndex = this.stateListeners.indexOf(listener); |
|
if (listenerIndex > -1) { |
|
this.stateListeners.splice(listenerIndex, 1); |
|
} |
|
} |
|
|
|
ref() { |
|
this.child.ref(); |
|
this.refCount += 1; |
|
} |
|
|
|
unref() { |
|
this.child.unref(); |
|
this.refCount -= 1; |
|
if (this.refCount <= 0) { |
|
if (this.mapEntry) { |
|
const index = this.mapEntry.subchannelWrappers.indexOf(this); |
|
if (index >= 0) { |
|
this.mapEntry.subchannelWrappers.splice(index, 1); |
|
} |
|
} |
|
} |
|
} |
|
|
|
eject() { |
|
this.ejected = true; |
|
for (const listener of this.stateListeners) { |
|
listener( |
|
this, |
|
this.childSubchannelState, |
|
ConnectivityState.TRANSIENT_FAILURE, |
|
-1 |
|
); |
|
} |
|
} |
|
|
|
uneject() { |
|
this.ejected = false; |
|
for (const listener of this.stateListeners) { |
|
listener( |
|
this, |
|
ConnectivityState.TRANSIENT_FAILURE, |
|
this.childSubchannelState, |
|
-1 |
|
); |
|
} |
|
} |
|
|
|
getMapEntry(): MapEntry | undefined { |
|
return this.mapEntry; |
|
} |
|
|
|
getWrappedSubchannel(): SubchannelInterface { |
|
return this.child; |
|
} |
|
} |
|
|
|
interface CallCountBucket { |
|
success: number; |
|
failure: number; |
|
} |
|
|
|
function createEmptyBucket(): CallCountBucket { |
|
return { |
|
success: 0, |
|
failure: 0, |
|
}; |
|
} |
|
|
|
class CallCounter { |
|
private activeBucket: CallCountBucket = createEmptyBucket(); |
|
private inactiveBucket: CallCountBucket = createEmptyBucket(); |
|
addSuccess() { |
|
this.activeBucket.success += 1; |
|
} |
|
addFailure() { |
|
this.activeBucket.failure += 1; |
|
} |
|
switchBuckets() { |
|
this.inactiveBucket = this.activeBucket; |
|
this.activeBucket = createEmptyBucket(); |
|
} |
|
getLastSuccesses() { |
|
return this.inactiveBucket.success; |
|
} |
|
getLastFailures() { |
|
return this.inactiveBucket.failure; |
|
} |
|
} |
|
|
|
interface MapEntry { |
|
counter: CallCounter; |
|
currentEjectionTimestamp: Date | null; |
|
ejectionTimeMultiplier: number; |
|
subchannelWrappers: OutlierDetectionSubchannelWrapper[]; |
|
} |
|
|
|
class OutlierDetectionPicker implements Picker { |
|
constructor(private wrappedPicker: Picker, private countCalls: boolean) {} |
|
pick(pickArgs: PickArgs): PickResult { |
|
const wrappedPick = this.wrappedPicker.pick(pickArgs); |
|
if (wrappedPick.pickResultType === PickResultType.COMPLETE) { |
|
const subchannelWrapper = |
|
wrappedPick.subchannel as OutlierDetectionSubchannelWrapper; |
|
const mapEntry = subchannelWrapper.getMapEntry(); |
|
if (mapEntry) { |
|
let onCallEnded = wrappedPick.onCallEnded; |
|
if (this.countCalls) { |
|
onCallEnded = statusCode => { |
|
if (statusCode === Status.OK) { |
|
mapEntry.counter.addSuccess(); |
|
} else { |
|
mapEntry.counter.addFailure(); |
|
} |
|
wrappedPick.onCallEnded?.(statusCode); |
|
}; |
|
} |
|
return { |
|
...wrappedPick, |
|
subchannel: subchannelWrapper.getWrappedSubchannel(), |
|
onCallEnded: onCallEnded, |
|
}; |
|
} else { |
|
return { |
|
...wrappedPick, |
|
subchannel: subchannelWrapper.getWrappedSubchannel(), |
|
}; |
|
} |
|
} else { |
|
return wrappedPick; |
|
} |
|
} |
|
} |
|
|
|
export class OutlierDetectionLoadBalancer implements LoadBalancer { |
|
private childBalancer: ChildLoadBalancerHandler; |
|
private addressMap: Map<string, MapEntry> = new Map<string, MapEntry>(); |
|
private latestConfig: OutlierDetectionLoadBalancingConfig | null = null; |
|
private ejectionTimer: NodeJS.Timeout; |
|
private timerStartTime: Date | null = null; |
|
|
|
constructor(channelControlHelper: ChannelControlHelper) { |
|
this.childBalancer = new ChildLoadBalancerHandler( |
|
createChildChannelControlHelper(channelControlHelper, { |
|
createSubchannel: ( |
|
subchannelAddress: SubchannelAddress, |
|
subchannelArgs: ChannelOptions |
|
) => { |
|
const originalSubchannel = channelControlHelper.createSubchannel( |
|
subchannelAddress, |
|
subchannelArgs |
|
); |
|
const mapEntry = this.addressMap.get( |
|
subchannelAddressToString(subchannelAddress) |
|
); |
|
const subchannelWrapper = new OutlierDetectionSubchannelWrapper( |
|
originalSubchannel, |
|
mapEntry |
|
); |
|
if (mapEntry?.currentEjectionTimestamp !== null) { |
|
|
|
subchannelWrapper.eject(); |
|
} |
|
mapEntry?.subchannelWrappers.push(subchannelWrapper); |
|
return subchannelWrapper; |
|
}, |
|
updateState: (connectivityState: ConnectivityState, picker: Picker) => { |
|
if (connectivityState === ConnectivityState.READY) { |
|
channelControlHelper.updateState( |
|
connectivityState, |
|
new OutlierDetectionPicker(picker, this.isCountingEnabled()) |
|
); |
|
} else { |
|
channelControlHelper.updateState(connectivityState, picker); |
|
} |
|
}, |
|
}) |
|
); |
|
this.ejectionTimer = setInterval(() => {}, 0); |
|
clearInterval(this.ejectionTimer); |
|
} |
|
|
|
private isCountingEnabled(): boolean { |
|
return ( |
|
this.latestConfig !== null && |
|
(this.latestConfig.getSuccessRateEjectionConfig() !== null || |
|
this.latestConfig.getFailurePercentageEjectionConfig() !== null) |
|
); |
|
} |
|
|
|
private getCurrentEjectionPercent() { |
|
let ejectionCount = 0; |
|
for (const mapEntry of this.addressMap.values()) { |
|
if (mapEntry.currentEjectionTimestamp !== null) { |
|
ejectionCount += 1; |
|
} |
|
} |
|
return (ejectionCount * 100) / this.addressMap.size; |
|
} |
|
|
|
private runSuccessRateCheck(ejectionTimestamp: Date) { |
|
if (!this.latestConfig) { |
|
return; |
|
} |
|
const successRateConfig = this.latestConfig.getSuccessRateEjectionConfig(); |
|
if (!successRateConfig) { |
|
return; |
|
} |
|
trace('Running success rate check'); |
|
|
|
const targetRequestVolume = successRateConfig.request_volume; |
|
let addresesWithTargetVolume = 0; |
|
const successRates: number[] = []; |
|
for (const [address, mapEntry] of this.addressMap) { |
|
const successes = mapEntry.counter.getLastSuccesses(); |
|
const failures = mapEntry.counter.getLastFailures(); |
|
trace( |
|
'Stats for ' + |
|
address + |
|
': successes=' + |
|
successes + |
|
' failures=' + |
|
failures + |
|
' targetRequestVolume=' + |
|
targetRequestVolume |
|
); |
|
if (successes + failures >= targetRequestVolume) { |
|
addresesWithTargetVolume += 1; |
|
successRates.push(successes / (successes + failures)); |
|
} |
|
} |
|
trace( |
|
'Found ' + |
|
addresesWithTargetVolume + |
|
' success rate candidates; currentEjectionPercent=' + |
|
this.getCurrentEjectionPercent() + |
|
' successRates=[' + |
|
successRates + |
|
']' |
|
); |
|
if (addresesWithTargetVolume < successRateConfig.minimum_hosts) { |
|
return; |
|
} |
|
|
|
|
|
const successRateMean = |
|
successRates.reduce((a, b) => a + b) / successRates.length; |
|
let successRateDeviationSum = 0; |
|
for (const rate of successRates) { |
|
const deviation = rate - successRateMean; |
|
successRateDeviationSum += deviation * deviation; |
|
} |
|
const successRateVariance = successRateDeviationSum / successRates.length; |
|
const successRateStdev = Math.sqrt(successRateVariance); |
|
const ejectionThreshold = |
|
successRateMean - |
|
successRateStdev * (successRateConfig.stdev_factor / 1000); |
|
trace( |
|
'stdev=' + successRateStdev + ' ejectionThreshold=' + ejectionThreshold |
|
); |
|
|
|
|
|
for (const [address, mapEntry] of this.addressMap.entries()) { |
|
|
|
if ( |
|
this.getCurrentEjectionPercent() >= |
|
this.latestConfig.getMaxEjectionPercent() |
|
) { |
|
break; |
|
} |
|
|
|
const successes = mapEntry.counter.getLastSuccesses(); |
|
const failures = mapEntry.counter.getLastFailures(); |
|
if (successes + failures < targetRequestVolume) { |
|
continue; |
|
} |
|
|
|
const successRate = successes / (successes + failures); |
|
trace('Checking candidate ' + address + ' successRate=' + successRate); |
|
if (successRate < ejectionThreshold) { |
|
const randomNumber = Math.random() * 100; |
|
trace( |
|
'Candidate ' + |
|
address + |
|
' randomNumber=' + |
|
randomNumber + |
|
' enforcement_percentage=' + |
|
successRateConfig.enforcement_percentage |
|
); |
|
if (randomNumber < successRateConfig.enforcement_percentage) { |
|
trace('Ejecting candidate ' + address); |
|
this.eject(mapEntry, ejectionTimestamp); |
|
} |
|
} |
|
} |
|
} |
|
|
|
private runFailurePercentageCheck(ejectionTimestamp: Date) { |
|
if (!this.latestConfig) { |
|
return; |
|
} |
|
const failurePercentageConfig = |
|
this.latestConfig.getFailurePercentageEjectionConfig(); |
|
if (!failurePercentageConfig) { |
|
return; |
|
} |
|
trace( |
|
'Running failure percentage check. threshold=' + |
|
failurePercentageConfig.threshold + |
|
' request volume threshold=' + |
|
failurePercentageConfig.request_volume |
|
); |
|
|
|
let addressesWithTargetVolume = 0; |
|
for (const mapEntry of this.addressMap.values()) { |
|
const successes = mapEntry.counter.getLastSuccesses(); |
|
const failures = mapEntry.counter.getLastFailures(); |
|
if (successes + failures >= failurePercentageConfig.request_volume) { |
|
addressesWithTargetVolume += 1; |
|
} |
|
} |
|
if (addressesWithTargetVolume < failurePercentageConfig.minimum_hosts) { |
|
return; |
|
} |
|
|
|
|
|
for (const [address, mapEntry] of this.addressMap.entries()) { |
|
|
|
if ( |
|
this.getCurrentEjectionPercent() >= |
|
this.latestConfig.getMaxEjectionPercent() |
|
) { |
|
break; |
|
} |
|
|
|
const successes = mapEntry.counter.getLastSuccesses(); |
|
const failures = mapEntry.counter.getLastFailures(); |
|
trace('Candidate successes=' + successes + ' failures=' + failures); |
|
if (successes + failures < failurePercentageConfig.request_volume) { |
|
continue; |
|
} |
|
|
|
const failurePercentage = (failures * 100) / (failures + successes); |
|
if (failurePercentage > failurePercentageConfig.threshold) { |
|
const randomNumber = Math.random() * 100; |
|
trace( |
|
'Candidate ' + |
|
address + |
|
' randomNumber=' + |
|
randomNumber + |
|
' enforcement_percentage=' + |
|
failurePercentageConfig.enforcement_percentage |
|
); |
|
if (randomNumber < failurePercentageConfig.enforcement_percentage) { |
|
trace('Ejecting candidate ' + address); |
|
this.eject(mapEntry, ejectionTimestamp); |
|
} |
|
} |
|
} |
|
} |
|
|
|
private eject(mapEntry: MapEntry, ejectionTimestamp: Date) { |
|
mapEntry.currentEjectionTimestamp = new Date(); |
|
mapEntry.ejectionTimeMultiplier += 1; |
|
for (const subchannelWrapper of mapEntry.subchannelWrappers) { |
|
subchannelWrapper.eject(); |
|
} |
|
} |
|
|
|
private uneject(mapEntry: MapEntry) { |
|
mapEntry.currentEjectionTimestamp = null; |
|
for (const subchannelWrapper of mapEntry.subchannelWrappers) { |
|
subchannelWrapper.uneject(); |
|
} |
|
} |
|
|
|
private switchAllBuckets() { |
|
for (const mapEntry of this.addressMap.values()) { |
|
mapEntry.counter.switchBuckets(); |
|
} |
|
} |
|
|
|
private startTimer(delayMs: number) { |
|
this.ejectionTimer = setTimeout(() => this.runChecks(), delayMs); |
|
this.ejectionTimer.unref?.(); |
|
} |
|
|
|
private runChecks() { |
|
const ejectionTimestamp = new Date(); |
|
trace('Ejection timer running'); |
|
|
|
this.switchAllBuckets(); |
|
|
|
if (!this.latestConfig) { |
|
return; |
|
} |
|
this.timerStartTime = ejectionTimestamp; |
|
this.startTimer(this.latestConfig.getIntervalMs()); |
|
|
|
this.runSuccessRateCheck(ejectionTimestamp); |
|
this.runFailurePercentageCheck(ejectionTimestamp); |
|
|
|
for (const [address, mapEntry] of this.addressMap.entries()) { |
|
if (mapEntry.currentEjectionTimestamp === null) { |
|
if (mapEntry.ejectionTimeMultiplier > 0) { |
|
mapEntry.ejectionTimeMultiplier -= 1; |
|
} |
|
} else { |
|
const baseEjectionTimeMs = this.latestConfig.getBaseEjectionTimeMs(); |
|
const maxEjectionTimeMs = this.latestConfig.getMaxEjectionTimeMs(); |
|
const returnTime = new Date( |
|
mapEntry.currentEjectionTimestamp.getTime() |
|
); |
|
returnTime.setMilliseconds( |
|
returnTime.getMilliseconds() + |
|
Math.min( |
|
baseEjectionTimeMs * mapEntry.ejectionTimeMultiplier, |
|
Math.max(baseEjectionTimeMs, maxEjectionTimeMs) |
|
) |
|
); |
|
if (returnTime < new Date()) { |
|
trace('Unejecting ' + address); |
|
this.uneject(mapEntry); |
|
} |
|
} |
|
} |
|
} |
|
|
|
updateAddressList( |
|
addressList: SubchannelAddress[], |
|
lbConfig: LoadBalancingConfig, |
|
attributes: { [key: string]: unknown } |
|
): void { |
|
if (!(lbConfig instanceof OutlierDetectionLoadBalancingConfig)) { |
|
return; |
|
} |
|
const subchannelAddresses = new Set<string>(); |
|
for (const address of addressList) { |
|
subchannelAddresses.add(subchannelAddressToString(address)); |
|
} |
|
for (const address of subchannelAddresses) { |
|
if (!this.addressMap.has(address)) { |
|
trace('Adding map entry for ' + address); |
|
this.addressMap.set(address, { |
|
counter: new CallCounter(), |
|
currentEjectionTimestamp: null, |
|
ejectionTimeMultiplier: 0, |
|
subchannelWrappers: [], |
|
}); |
|
} |
|
} |
|
for (const key of this.addressMap.keys()) { |
|
if (!subchannelAddresses.has(key)) { |
|
trace('Removing map entry for ' + key); |
|
this.addressMap.delete(key); |
|
} |
|
} |
|
const childPolicy: LoadBalancingConfig = getFirstUsableConfig( |
|
lbConfig.getChildPolicy(), |
|
true |
|
); |
|
this.childBalancer.updateAddressList(addressList, childPolicy, attributes); |
|
|
|
if ( |
|
lbConfig.getSuccessRateEjectionConfig() || |
|
lbConfig.getFailurePercentageEjectionConfig() |
|
) { |
|
if (this.timerStartTime) { |
|
trace('Previous timer existed. Replacing timer'); |
|
clearTimeout(this.ejectionTimer); |
|
const remainingDelay = |
|
lbConfig.getIntervalMs() - |
|
(new Date().getTime() - this.timerStartTime.getTime()); |
|
this.startTimer(remainingDelay); |
|
} else { |
|
trace('Starting new timer'); |
|
this.timerStartTime = new Date(); |
|
this.startTimer(lbConfig.getIntervalMs()); |
|
this.switchAllBuckets(); |
|
} |
|
} else { |
|
trace('Counting disabled. Cancelling timer.'); |
|
this.timerStartTime = null; |
|
clearTimeout(this.ejectionTimer); |
|
for (const mapEntry of this.addressMap.values()) { |
|
this.uneject(mapEntry); |
|
mapEntry.ejectionTimeMultiplier = 0; |
|
} |
|
} |
|
|
|
this.latestConfig = lbConfig; |
|
} |
|
exitIdle(): void { |
|
this.childBalancer.exitIdle(); |
|
} |
|
resetBackoff(): void { |
|
this.childBalancer.resetBackoff(); |
|
} |
|
destroy(): void { |
|
clearTimeout(this.ejectionTimer); |
|
this.childBalancer.destroy(); |
|
} |
|
getTypeName(): string { |
|
return TYPE_NAME; |
|
} |
|
} |
|
|
|
export function setup() { |
|
if (OUTLIER_DETECTION_ENABLED) { |
|
registerLoadBalancerType( |
|
TYPE_NAME, |
|
OutlierDetectionLoadBalancer, |
|
OutlierDetectionLoadBalancingConfig |
|
); |
|
} |
|
} |
|
|