2024-09-02 04:45:36 -07:00
import { InstantSample , Metric } from "../api/responseTypes/query" ;
import {
formatPrometheusFloat ,
parsePrometheusFloat ,
} from "../lib/formatFloatValue" ;
import {
binaryOperatorType ,
vectorMatchCardinality ,
VectorMatching ,
} from "./ast" ;
2024-09-16 02:38:35 -07:00
import { isComparisonOperator , isSetOperator } from "./utils" ;
2024-09-02 04:45:36 -07:00
// We use a special (otherwise invalid) sample value to indicate that
// a sample has been filtered away by a comparison operator.
export const filteredSampleValue = "filtered" ;
export enum MatchErrorType {
multipleMatchesForOneToOneMatching = "multipleMatchesForOneToOneMatching" ,
multipleMatchesOnBothSides = "multipleMatchesOnBothSides" ,
multipleMatchesOnOneSide = "multipleMatchesOnOneSide" ,
}
// There's no group_x() modifier, but one of the sides has multiple matches.
export interface MultipleMatchesForOneToOneMatchingError {
type : MatchErrorType . multipleMatchesForOneToOneMatching ;
dupeSide : "left" | "right" ;
}
// There's no group_x() modifier and there are multiple matches on both sides.
// This is good to keep as a separate error from MultipleMatchesForOneToOneMatchingError
// because it can't be fixed by adding group_x() but rather by expanding the set of
// matching labels.
export interface MultipleMatchesOnBothSidesError {
type : MatchErrorType . multipleMatchesOnBothSides ;
}
// There's a group_x() modifier, but the "one" side has multiple matches. This could mean
// that either the matching labels are not sufficient or that group_x() is the wrong way around.
export interface MultipleMatchesOnOneSideError {
type : MatchErrorType . multipleMatchesOnOneSide ;
}
export type VectorMatchError =
| MultipleMatchesForOneToOneMatchingError
| MultipleMatchesOnBothSidesError
| MultipleMatchesOnOneSideError ;
// A single match group as produced by a vector-to-vector binary operation, with all of its
// left-hand side and right-hand side series, as well as a result and error, if applicable.
export type BinOpMatchGroup = {
groupLabels : Metric ;
rhs : InstantSample [ ] ;
rhsCount : number ; // Number of samples before applying limits.
lhs : InstantSample [ ] ;
lhsCount : number ; // Number of samples before applying limits.
result : {
sample : InstantSample ;
// Which "many"-side sample did this sample come from? This is needed for use cases where
// we want to style the corresponding "many" side input sample and the result sample in
// a similar way (e.g. shading them in the same color) to be able to trace which "many"
// side sample a result sample came from.
manySideIdx : number ;
} [ ] ;
error : VectorMatchError | null ;
} ;
// The result of computeVectorVectorBinOp(), modeling the match groups produced by a
// vector-to-vector binary operation.
export type BinOpMatchGroups = {
[ sig : string ] : BinOpMatchGroup ;
} ;
export type BinOpResult = {
groups : BinOpMatchGroups ;
// Can differ from the number of returned groups if a limit was applied.
numGroups : number ;
} ;
// FNV-1a hash parameters.
const FNV_PRIME = 0x01000193 ;
const OFFSET_BASIS = 0x811c9dc5 ;
const SEP = "\uD800" . charCodeAt ( 0 ) ; // Using a Unicode "high surrogate" code point as a separator. These should not appear by themselves (without a low surrogate pairing) in a valid Unicode string.
// Compute an FNV-1a hash over a given set of values in order to
// produce a signature for a match group.
export const fnv1a = ( values : string [ ] ) : string = > {
let h = OFFSET_BASIS ;
for ( let i = 0 ; i < values . length ; i ++ ) {
// Skip labels that are not set on the metric.
if ( values [ i ] !== undefined ) {
for ( let c = 0 ; c < values [ i ] . length ; c ++ ) {
h ^= values [ i ] . charCodeAt ( c ) ;
h *= FNV_PRIME ;
}
}
if ( i < values . length - 1 ) {
h ^= SEP ;
h *= FNV_PRIME ;
}
}
return h . toString ( ) ;
} ;
// Return a function that generates the match group signature for a given label set.
const signatureFunc = ( on : boolean , names : string [ ] ) = > {
names . sort ( ) ;
if ( on ) {
return ( lset : Metric ) : string = > {
return fnv1a ( names . map ( ( ln : string ) = > lset [ ln ] ) ) ;
} ;
}
return ( lset : Metric ) : string = >
fnv1a (
Object . keys ( lset )
. filter ( ( ln ) = > ! names . includes ( ln ) && ln !== "__name__" )
. map ( ( ln ) = > lset [ ln ] )
) ;
} ;
// For a given metric, return only the labels used for matching.
const matchLabels = ( metric : Metric , on : boolean , labels : string [ ] ) : Metric = > {
const result : Metric = { } ;
for ( const name in metric ) {
if ( labels . includes ( name ) === on && ( on || name !== "__name__" ) ) {
result [ name ] = metric [ name ] ;
}
}
return result ;
} ;
export const scalarBinOp = (
op : binaryOperatorType ,
lhs : number ,
rhs : number
) : number = > {
const { value , keep } = vectorElemBinop ( op , lhs , rhs ) ;
if ( isComparisonOperator ( op ) ) {
return Number ( keep ) ;
}
return value ;
} ;
export const vectorElemBinop = (
op : binaryOperatorType ,
lhs : number ,
rhs : number
) : { value : number ; keep : boolean } = > {
switch ( op ) {
case binaryOperatorType . add :
return { value : lhs + rhs , keep : true } ;
case binaryOperatorType . sub :
return { value : lhs - rhs , keep : true } ;
case binaryOperatorType . mul :
return { value : lhs * rhs , keep : true } ;
case binaryOperatorType . div :
return { value : lhs / rhs , keep : true } ;
case binaryOperatorType . pow :
return { value : Math.pow ( lhs , rhs ) , keep : true } ;
case binaryOperatorType . mod :
return { value : lhs % rhs , keep : true } ;
case binaryOperatorType . eql :
return { value : lhs , keep : lhs === rhs } ;
case binaryOperatorType . neq :
return { value : lhs , keep : lhs !== rhs } ;
case binaryOperatorType . gtr :
return { value : lhs , keep : lhs > rhs } ;
case binaryOperatorType . lss :
return { value : lhs , keep : lhs < rhs } ;
case binaryOperatorType . gte :
return { value : lhs , keep : lhs >= rhs } ;
case binaryOperatorType . lte :
return { value : lhs , keep : lhs <= rhs } ;
case binaryOperatorType . atan2 :
return { value : Math.atan2 ( lhs , rhs ) , keep : true } ;
default :
throw new Error ( "invalid binop" ) ;
}
} ;
// Operations that change the metric's original meaning should drop the metric name from the result.
const shouldDropMetricName = ( op : binaryOperatorType ) : boolean = >
[
binaryOperatorType . add ,
binaryOperatorType . sub ,
binaryOperatorType . mul ,
binaryOperatorType . div ,
binaryOperatorType . pow ,
binaryOperatorType . mod ,
binaryOperatorType . atan2 ,
] . includes ( op ) ;
// Compute the time series labels for the result metric.
export const resultMetric = (
lhs : Metric ,
rhs : Metric ,
op : binaryOperatorType ,
matching : VectorMatching
) : Metric = > {
const result : Metric = { } ;
// Start out with all labels from the LHS.
for ( const name in lhs ) {
result [ name ] = lhs [ name ] ;
}
// Drop metric name for operations that change the metric's meaning.
if ( shouldDropMetricName ( op ) ) {
delete result . __name__ ;
}
// Keep only match group labels for 1:1 matches.
if ( matching . card === vectorMatchCardinality . oneToOne ) {
if ( matching . on ) {
// Drop all labels that are not in the "on" clause.
for ( const name in result ) {
if ( ! matching . labels . includes ( name ) ) {
delete result [ name ] ;
}
}
} else {
// Drop all labels that are in the "ignoring" clause.
for ( const name of matching . labels ) {
delete result [ name ] ;
}
}
}
// Include extra labels from the RHS that were mentioned in a group_x(...) modifier.
matching . include . forEach ( ( name ) = > {
if ( name in rhs ) {
result [ name ] = rhs [ name ] ;
} else {
// If we are trying to include a label from the "one" side that is not actually set there,
// we need to make sure that we don't accidentally take its value from the "many" side
// if it exists there.
//
// Example to provoke this case:
//
// up == on(job, instance) group_left(__name__) node_exporter_build_info*1
delete result [ name ] ;
}
} ) ;
return result ;
} ;
// Compute the match groups and results for each match group for a binary operator between two vectors.
// In the error case, the match groups are still populated and returned, but the error field is set for
// the respective group. Results are not populated for error cases, since especially in the case of a
// many-to-many matching, the cross-product output can become prohibitively expensive.
export const computeVectorVectorBinOp = (
op : binaryOperatorType ,
matching : VectorMatching ,
bool : boolean ,
lhs : InstantSample [ ] ,
rhs : InstantSample [ ] ,
limits ? : {
maxGroups? : number ;
maxSeriesPerGroup? : number ;
}
) : BinOpResult = > {
// For the simplification of further calculations, we assume that the "one" side of a one-to-many match
// is always the right-hand side of the binop and swap otherwise to ensure this. We swap back in the end.
[ lhs , rhs ] =
matching . card === vectorMatchCardinality . oneToMany
? [ rhs , lhs ]
: [ lhs , rhs ] ;
const groups : BinOpMatchGroups = { } ;
const sigf = signatureFunc ( matching . on , matching . labels ) ;
// While we only use this set to compute a count of limited groups in the end, we can encounter each
// group multiple times (since multiple series can map to the same group). So we need to use a set
// to track which groups we've already counted.
const outOfLimitGroups = new Set < string > ( ) ;
// Add all RHS samples to the grouping map.
rhs . forEach ( ( rs ) = > {
const sig = sigf ( rs . metric ) ;
if ( ! ( sig in groups ) ) {
if ( limits ? . maxGroups && Object . keys ( groups ) . length >= limits . maxGroups ) {
outOfLimitGroups . add ( sig ) ;
return ;
}
groups [ sig ] = {
groupLabels : matchLabels ( rs . metric , matching . on , matching . labels ) ,
lhs : [ ] ,
lhsCount : 0 ,
rhs : [ ] ,
rhsCount : 0 ,
result : [ ] ,
error : null ,
} ;
}
if (
! limits ? . maxSeriesPerGroup ||
groups [ sig ] . rhsCount < limits . maxSeriesPerGroup
) {
groups [ sig ] . rhs . push ( rs ) ;
}
groups [ sig ] . rhsCount ++ ;
} ) ;
// Add all LHS samples to the grouping map.
lhs . forEach ( ( ls ) = > {
const sig = sigf ( ls . metric ) ;
if ( ! ( sig in groups ) ) {
if ( limits ? . maxGroups && Object . keys ( groups ) . length >= limits . maxGroups ) {
outOfLimitGroups . add ( sig ) ;
return ;
}
groups [ sig ] = {
groupLabels : matchLabels ( ls . metric , matching . on , matching . labels ) ,
lhs : [ ] ,
lhsCount : 0 ,
rhs : [ ] ,
rhsCount : 0 ,
result : [ ] ,
error : null ,
} ;
}
if (
! limits ? . maxSeriesPerGroup ||
groups [ sig ] . lhsCount < limits . maxSeriesPerGroup
) {
groups [ sig ] . lhs . push ( ls ) ;
}
groups [ sig ] . lhsCount ++ ;
} ) ;
// Annotate the match groups with errors (if any) and populate the results.
Object . values ( groups ) . forEach ( ( mg ) = > {
2024-09-16 02:38:35 -07:00
// Do not populate results for set operators.
if ( isSetOperator ( op ) ) {
return ;
}
2024-09-02 04:45:36 -07:00
if ( matching . card === vectorMatchCardinality . oneToOne ) {
if ( mg . lhs . length > 1 && mg . rhs . length > 1 ) {
mg . error = { type : MatchErrorType . multipleMatchesOnBothSides } ;
} else if ( mg . lhs . length > 1 || mg . rhs . length > 1 ) {
mg . error = {
type : MatchErrorType . multipleMatchesForOneToOneMatching ,
dupeSide : mg.lhs.length > 1 ? "left" : "right" ,
} ;
}
} else if ( mg . rhs . length > 1 ) {
// Check for dupes on the "one" side in one-to-many or many-to-one matching.
mg . error = {
type : MatchErrorType . multipleMatchesOnOneSide ,
} ;
}
if ( mg . error ) {
// We don't populate results for error cases, as especially in the case of a
// many-to-many matching, the cross-product output can become expensive,
// and the LHS/RHS are sufficient to diagnose the matching problem.
return ;
}
// Calculate the results for this match group.
mg . rhs . forEach ( ( rs ) = > {
mg . lhs . forEach ( ( ls , lIdx ) = > {
if ( ! ls . value || ! rs . value ) {
// TODO: Implement native histogram support.
throw new Error ( "native histogram support not implemented yet" ) ;
}
const [ vl , vr ] =
matching . card !== vectorMatchCardinality . oneToMany
? [ ls . value [ 1 ] , rs . value [ 1 ] ]
: [ rs . value [ 1 ] , ls . value [ 1 ] ] ;
let { value , keep } = vectorElemBinop (
op ,
parsePrometheusFloat ( vl ) ,
parsePrometheusFloat ( vr )
) ;
const metric = resultMetric ( ls . metric , rs . metric , op , matching ) ;
if ( bool ) {
value = keep ? 1.0 : 0.0 ;
delete metric . __name__ ;
}
mg . result . push ( {
sample : {
metric : metric ,
value : [
ls . value [ 0 ] ,
keep || bool ? formatPrometheusFloat ( value ) : filteredSampleValue ,
] ,
} ,
manySideIdx : lIdx ,
} ) ;
} ) ;
} ) ;
} ) ;
// If we originally swapped the LHS and RHS, swap them back to the original order.
if ( matching . card === vectorMatchCardinality . oneToMany ) {
Object . keys ( groups ) . forEach ( ( sig ) = > {
[ groups [ sig ] . lhs , groups [ sig ] . rhs ] = [ groups [ sig ] . rhs , groups [ sig ] . lhs ] ;
[ groups [ sig ] . lhsCount , groups [ sig ] . rhsCount ] = [
groups [ sig ] . rhsCount ,
groups [ sig ] . lhsCount ,
] ;
} ) ;
}
return {
groups ,
numGroups : Object.keys ( groups ) . length + outOfLimitGroups . size ,
} ;
} ;