grpc-js-xds: Refactor matcher and routeAction for logging, add more interop client logging

This commit is contained in:
Michael Lumish 2021-03-24 14:08:21 -07:00
parent d160a2f248
commit b995fa62cf
4 changed files with 357 additions and 91 deletions

View File

@ -303,6 +303,8 @@ function main() {
currentConfig.callTypes = call.request.types; currentConfig.callTypes = call.request.types;
currentConfig.metadata = callMetadata; currentConfig.metadata = callMetadata;
currentConfig.timeoutSec = call.request.timeout_sec currentConfig.timeoutSec = call.request.timeout_sec
console.log('Received new client configuration: ' + JSON.stringify(currentConfig, undefined, 2));
callback(null, {});
} }
} }

View File

@ -0,0 +1,245 @@
/*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Metadata } from "@grpc/grpc-js";
import { RE2 } from "re2-wasm";
/**
* An object representing a predicate that determines whether a given
* combination of a methodName and metadata matches some internal conditions.
*/
export interface Matcher {
toString(): string;
apply(methodName: string, metadata: Metadata): boolean;
}
/**
* An object representing a predicate that determines whether a given string
* value matches some internal conditions.
*/
export interface ValueMatcher {
toString(): string;
apply(value: string): boolean;
}
export class ExactValueMatcher implements ValueMatcher {
constructor(private targetValue: string) {}
apply(value: string) {
return value === this.targetValue;
}
toString() {
return 'Exact(' + this.targetValue + ')';
}
}
export class SafeRegexValueMatcher implements ValueMatcher {
private targetRegexImpl: RE2;
constructor(targetRegex: string) {
this.targetRegexImpl = new RE2(`^${targetRegex}$`, 'u');
}
apply(value: string) {
return this.targetRegexImpl.test(value);
}
toString() {
return 'SafeRegex(' + this.targetRegexImpl.toString() + ')';
}
}
const numberRegex = new RE2(/^-?\d+$/u);
export class RangeValueMatcher implements ValueMatcher {
constructor(private start: BigInt, private end: BigInt) {}
apply(value: string) {
if (!numberRegex.test(value)) {
return false;
}
const numberValue = BigInt(value);
return this.start <= numberValue && numberValue < this.end;
}
toString() {
return 'Range(' + this.start + ', ' + this.end + ')';
}
}
export class PresentValueMatcher implements ValueMatcher {
constructor() {}
apply(value: string) {
return true;
}
toString() {
return 'Present()';
}
}
export class PrefixValueMatcher implements ValueMatcher {
constructor(private prefix: string) {}
apply(value: string) {
return value.startsWith(this.prefix);
}
toString() {
return 'Prefix(' + this.prefix + ')';
}
}
export class SuffixValueMatcher implements ValueMatcher {
constructor(private suffix: string) {}
apply(value: string) {
return value.endsWith(this.suffix);
}
toString() {
return 'Suffix(' + this.suffix + ')';
}
}
export class RejectValueMatcher implements ValueMatcher {
constructor() {}
apply(value: string) {
return false;
}
toString() {
return 'Reject()';
}
}
export class HeaderMatcher implements Matcher {
constructor(private headerName: string, private valueMatcher: ValueMatcher, private invertMatch: boolean) {}
private applyHelper(methodName: string, metadata: Metadata) {
if (this.headerName.endsWith('-bin')) {
return false;
}
let value: string;
if (this.headerName === 'content-type') {
value = 'application/grpc';
} else {
const valueArray = metadata.get(this.headerName);
if (valueArray.length === 0) {
return false;
} else {
value = valueArray.join(',');
}
}
return this.valueMatcher.apply(value);
}
apply(methodName: string, metadata: Metadata) {
const result = this.applyHelper(methodName, metadata);
if (this.invertMatch) {
return !result;
} else {
return result;
}
}
toString() {
return 'HeaderMatch(' + this.headerName + ', ' + this.valueMatcher.toString() + ')';
}
}
export class PathPrefixValueMatcher {
constructor(private prefix: string, private caseInsensitive: boolean) {}
apply(value: string) {
if (this.caseInsensitive) {
return value.toLowerCase().startsWith(this.prefix.toLowerCase());
} else {
return value.startsWith(this.prefix);
}
}
toString() {
return 'Prefix(' + this.prefix + ', ' + this.caseInsensitive + ')';
}
}
export class PathExactValueMatcher {
constructor(private targetValue: string, private caseInsensitive: boolean) {}
apply(value: string) {
if (this.caseInsensitive) {
return value.toLowerCase().startsWith(this.targetValue.toLowerCase());
} else {
return value === this.targetValue;
}
}
toString() {
return 'Exact(' + this.targetValue + ', ' + this.caseInsensitive + ')';
}
}
export class PathSafeRegexValueMatcher {
private targetRegexImpl: RE2;
constructor(targetRegex: string, caseInsensitive: boolean) {
this.targetRegexImpl = new RE2(`^${targetRegex}$`, caseInsensitive ? 'iu' : 'u');
}
apply(value: string) {
return this.targetRegexImpl.test(value);
}
toString() {
return 'SafeRegex(' + this.targetRegexImpl.toString() + ')';
}
}
export interface Fraction {
numerator: number;
denominator: number;
}
function fractionToString(fraction: Fraction): string {
return `${fraction.numerator}/${fraction.denominator}`;
}
export class FullMatcher implements Matcher {
constructor(private pathMatcher: ValueMatcher, private headerMatchers: Matcher[], private fraction: Fraction | null) {}
apply(methodName: string, metadata: Metadata) {
if (!this.pathMatcher.apply(methodName)) {
return false;
}
if (!this.headerMatchers.every(matcher => matcher.apply(methodName, metadata))) {
return false;
}
if (this.fraction === null) {
return true;
} else {
const randomNumber = Math.random() * this.fraction.denominator;
return randomNumber < this.fraction.numerator;
}
}
toString() {
return `path: ${this.pathMatcher}
headers: ${this.headerMatchers.map(matcher => matcher.toString()).join('\n\t')}
fraction: ${this.fraction ? fractionToString(this.fraction): 'none'}`;
}
}

View File

@ -38,6 +38,8 @@ import { HeaderMatcher__Output } from './generated/envoy/api/v2/route/HeaderMatc
import ConfigSelector = experimental.ConfigSelector; import ConfigSelector = experimental.ConfigSelector;
import LoadBalancingConfig = experimental.LoadBalancingConfig; import LoadBalancingConfig = experimental.LoadBalancingConfig;
import { XdsClusterManagerLoadBalancingConfig } from './load-balancer-xds-cluster-manager'; import { XdsClusterManagerLoadBalancingConfig } from './load-balancer-xds-cluster-manager';
import { ExactValueMatcher, Fraction, FullMatcher, HeaderMatcher, Matcher, PathExactValueMatcher, PathPrefixValueMatcher, PathSafeRegexValueMatcher, PrefixValueMatcher, PresentValueMatcher, RangeValueMatcher, RejectValueMatcher, SafeRegexValueMatcher, SuffixValueMatcher, ValueMatcher } from './matcher';
import { RouteAction, SingleClusterRouteAction, WeightedCluster, WeightedClusterRouteAction } from './route-action';
const TRACER_NAME = 'xds_resolver'; const TRACER_NAME = 'xds_resolver';
@ -119,68 +121,35 @@ function findVirtualHostForDomain(virutalHostList: VirtualHost__Output[], domain
return targetVhost; return targetVhost;
} }
interface Matcher {
(methodName: string, metadata: Metadata): boolean;
}
const numberRegex = new RE2(/^-?\d+$/u); const numberRegex = new RE2(/^-?\d+$/u);
function getPredicateForHeaderMatcher(headerMatch: HeaderMatcher__Output): Matcher { function getPredicateForHeaderMatcher(headerMatch: HeaderMatcher__Output): Matcher {
let valueChecker: (value: string) => boolean; let valueChecker: ValueMatcher;
switch (headerMatch.header_match_specifier) { switch (headerMatch.header_match_specifier) {
case 'exact_match': case 'exact_match':
valueChecker = value => value === headerMatch.exact_match; valueChecker = new ExactValueMatcher(headerMatch.exact_match!);
break; break;
case 'safe_regex_match': case 'safe_regex_match':
const regex = new RE2(`^${headerMatch.safe_regex_match}$`, 'u'); valueChecker = new SafeRegexValueMatcher(headerMatch.safe_regex_match!.regex);
valueChecker = value => regex.test(value);
break; break;
case 'range_match': case 'range_match':
const start = BigInt(headerMatch.range_match!.start); const start = BigInt(headerMatch.range_match!.start);
const end = BigInt(headerMatch.range_match!.end); const end = BigInt(headerMatch.range_match!.end);
valueChecker = value => { valueChecker = new RangeValueMatcher(start, end);
if (!numberRegex.test(value)) {
return false;
}
const numberValue = BigInt(value);
return start <= numberValue && numberValue < end;
}
break; break;
case 'present_match': case 'present_match':
valueChecker = value => true; valueChecker = new PresentValueMatcher();
break; break;
case 'prefix_match': case 'prefix_match':
valueChecker = value => value.startsWith(headerMatch.prefix_match!); valueChecker = new PrefixValueMatcher(headerMatch.prefix_match!);
break; break;
case 'suffix_match': case 'suffix_match':
valueChecker = value => value.endsWith(headerMatch.suffix_match!); valueChecker = new SuffixValueMatcher(headerMatch.suffix_match!);
break; break;
default: default:
// Should be prevented by validation rules valueChecker = new RejectValueMatcher();
return (methodName, metadata) => false;
}
const headerMatcher: Matcher = (methodName, metadata) => {
if (headerMatch.name.endsWith('-bin')) {
return false;
}
let value: string;
if (headerMatch.name === 'content-type') {
value = 'application/grpc';
} else {
const valueArray = metadata.get(headerMatch.name);
if (valueArray.length === 0) {
return false;
} else {
value = valueArray.join(',');
}
}
return valueChecker(value);
}
if (headerMatch.invert_match) {
return (methodName, metadata) => !headerMatcher(methodName, metadata);
} else {
return headerMatcher;
} }
return new HeaderMatcher(headerMatch.name, valueChecker, headerMatch.invert_match);
} }
const RUNTIME_FRACTION_DENOMINATOR_VALUES = { const RUNTIME_FRACTION_DENOMINATOR_VALUES = {
@ -190,48 +159,32 @@ const RUNTIME_FRACTION_DENOMINATOR_VALUES = {
} }
function getPredicateForMatcher(routeMatch: RouteMatch__Output): Matcher { function getPredicateForMatcher(routeMatch: RouteMatch__Output): Matcher {
let pathMatcher: Matcher; let pathMatcher: ValueMatcher;
const caseInsensitive = routeMatch.case_sensitive?.value === false;
switch (routeMatch.path_specifier) { switch (routeMatch.path_specifier) {
case 'prefix': case 'prefix':
if (routeMatch.case_sensitive?.value === false) { pathMatcher = new PathPrefixValueMatcher(routeMatch.prefix!, caseInsensitive);
const prefix = routeMatch.prefix!.toLowerCase();
pathMatcher = (methodName, metadata) => (methodName.toLowerCase().startsWith(prefix));
} else {
const prefix = routeMatch.prefix!;
pathMatcher = (methodName, metadata) => (methodName.startsWith(prefix));
}
break; break;
case 'path': case 'path':
if (routeMatch.case_sensitive?.value === false) { pathMatcher = new PathExactValueMatcher(routeMatch.path!, caseInsensitive);
const path = routeMatch.path!.toLowerCase();
pathMatcher = (methodName, metadata) => (methodName.toLowerCase() === path);
} else {
const path = routeMatch.path!;
pathMatcher = (methodName, metadata) => (methodName === path);
}
break; break;
case 'safe_regex': case 'safe_regex':
const flags = routeMatch.case_sensitive?.value === false ? 'ui' : 'u'; pathMatcher = new PathSafeRegexValueMatcher(routeMatch.safe_regex!.regex, caseInsensitive);
const regex = new RE2(`^${routeMatch.safe_regex!.regex!}$`, flags);
pathMatcher = (methodName, metadata) => (regex.test(methodName));
break; break;
default: default:
// Should be prevented by validation rules pathMatcher = new RejectValueMatcher();
return (methodName, metadata) => false;
} }
const headerMatchers: Matcher[] = routeMatch.headers.map(getPredicateForHeaderMatcher); const headerMatchers: Matcher[] = routeMatch.headers.map(getPredicateForHeaderMatcher);
let runtimeFractionHandler: () => boolean; let runtimeFraction: Fraction | null;
if (!routeMatch.runtime_fraction?.default_value) { if (!routeMatch.runtime_fraction?.default_value) {
runtimeFractionHandler = () => true; runtimeFraction = null;
} else { } else {
const numerator = routeMatch.runtime_fraction.default_value.numerator; runtimeFraction = {
const denominator = RUNTIME_FRACTION_DENOMINATOR_VALUES[routeMatch.runtime_fraction.default_value.denominator]; numerator: routeMatch.runtime_fraction.default_value.numerator,
runtimeFractionHandler = () => { denominator: RUNTIME_FRACTION_DENOMINATOR_VALUES[routeMatch.runtime_fraction.default_value.denominator]
const randomNumber = Math.random() * denominator; };
return randomNumber < numerator;
}
} }
return (methodName, metadata) => pathMatcher(methodName, metadata) && headerMatchers.every(matcher => matcher(methodName, metadata)) && runtimeFractionHandler(); return new FullMatcher(pathMatcher, headerMatchers, runtimeFraction);
} }
class XdsResolver implements Resolver { class XdsResolver implements Resolver {
@ -340,38 +293,27 @@ class XdsResolver implements Resolver {
this.reportResolutionError('No matching route found'); this.reportResolutionError('No matching route found');
return; return;
} }
trace('Received virtual host config ' + JSON.stringify(virtualHost, undefined, 2));
const allConfigClusters = new Set<string>(); const allConfigClusters = new Set<string>();
const matchList: {matcher: Matcher, action: () => string}[] = []; const matchList: {matcher: Matcher, action: RouteAction}[] = [];
for (const route of virtualHost.routes) { for (const route of virtualHost.routes) {
let routeAction: () => string; let routeAction: RouteAction;
switch (route.route!.cluster_specifier) { switch (route.route!.cluster_specifier) {
case 'cluster_header': case 'cluster_header':
continue; continue;
case 'cluster':{ case 'cluster':{
const cluster = route.route!.cluster!; const cluster = route.route!.cluster!;
allConfigClusters.add(cluster); allConfigClusters.add(cluster);
routeAction = () => cluster; routeAction = new SingleClusterRouteAction(cluster);
break; break;
} }
case 'weighted_clusters': { case 'weighted_clusters': {
let lastNumerator = 0; const weightedClusters: WeightedCluster[] = [];
// clusterChoices is essentially the weighted choices represented as a CDF
const clusterChoices: {cluster: string, numerator: number}[] = [];
for (const clusterWeight of route.route!.weighted_clusters!.clusters) { for (const clusterWeight of route.route!.weighted_clusters!.clusters) {
allConfigClusters.add(clusterWeight.name); allConfigClusters.add(clusterWeight.name);
lastNumerator = lastNumerator + (clusterWeight.weight?.value ?? 0); weightedClusters.push({name: clusterWeight.name, weight: clusterWeight.weight?.value ?? 0});
clusterChoices.push({cluster: clusterWeight.name, numerator: lastNumerator});
}
routeAction = () => {
const randomNumber = Math.random() * (route.route!.weighted_clusters!.total_weight?.value ?? 100);
for (const choice of clusterChoices) {
if (randomNumber < choice.numerator) {
return choice.cluster;
}
}
// This should be prevented by the validation rules
return '';
} }
routeAction = new WeightedClusterRouteAction(weightedClusters, route.route!.weighted_clusters!.total_weight?.value ?? 100);
} }
} }
const routeMatcher = getPredicateForMatcher(route.match!); const routeMatcher = getPredicateForMatcher(route.match!);
@ -397,8 +339,8 @@ class XdsResolver implements Resolver {
} }
const configSelector: ConfigSelector = (methodName, metadata) => { const configSelector: ConfigSelector = (methodName, metadata) => {
for (const {matcher, action} of matchList) { for (const {matcher, action} of matchList) {
if (matcher(methodName, metadata)) { if (matcher.apply(methodName, metadata)) {
const clusterName = action(); const clusterName = action.getCluster();
this.refCluster(clusterName); this.refCluster(clusterName);
const onCommitted = () => { const onCommitted = () => {
this.unrefCluster(clusterName); this.unrefCluster(clusterName);
@ -418,6 +360,11 @@ class XdsResolver implements Resolver {
status: status.UNAVAILABLE status: status.UNAVAILABLE
}; };
}; };
trace('Created ConfigSelector with configuration:');
for (const {matcher, action} of matchList) {
trace(matcher.toString());
trace('=> ' + action.toString());
}
const clusterConfigMap = new Map<string, {child_policy: LoadBalancingConfig[]}>(); const clusterConfigMap = new Map<string, {child_policy: LoadBalancingConfig[]}>();
for (const clusterName of this.clusterRefcounts.keys()) { for (const clusterName of this.clusterRefcounts.keys()) {
clusterConfigMap.set(clusterName, {child_policy: [new CdsLoadBalancingConfig(clusterName)]}); clusterConfigMap.set(clusterName, {child_policy: [new CdsLoadBalancingConfig(clusterName)]});

View File

@ -0,0 +1,72 @@
/*
* Copyright 2021 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface RouteAction {
toString(): string;
getCluster(): string;
}
export class SingleClusterRouteAction implements RouteAction {
constructor(private cluster: string) {}
getCluster() {
return this.cluster;
}
toString() {
return 'SingleCluster(' + this.cluster + ')';
}
}
export interface WeightedCluster {
name: string;
weight: number;
}
interface ClusterChoice {
name: string;
numerator: number;
}
export class WeightedClusterRouteAction implements RouteAction {
/**
* The weighted cluster choices represented as a CDF
*/
private clusterChoices: ClusterChoice[];
constructor(private clusters: WeightedCluster[], private totalWeight: number) {
this.clusterChoices = [];
let lastNumerator = 0;
for (const clusterWeight of clusters) {
lastNumerator += clusterWeight.weight;
this.clusterChoices.push({name: clusterWeight.name, numerator: lastNumerator});
}
}
getCluster() {
const randomNumber = Math.random() * this.totalWeight;
for (const choice of this.clusterChoices) {
if (randomNumber < choice.numerator) {
return choice.name;
}
}
// This should be prevented by the validation rules
return '';
}
toString() {
return 'WeightedCluster(' + this.clusters.map(({name, weight}) => '(' + name + ':' + weight + ')').join(', ') + ')';
}
}