PromQL: Add fill*() binop modifiers to provide default values for missing series

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2025-12-03 18:46:35 +01:00
parent ccb7468b09
commit af3277f832
22 changed files with 1296 additions and 702 deletions

View file

@ -2862,7 +2862,8 @@ func (ev *evaluator) VectorBinop(op parser.ItemType, lhs, rhs Vector, matching *
if matching.Card == parser.CardManyToMany {
panic("many-to-many only allowed for set operators")
}
if len(lhs) == 0 || len(rhs) == 0 {
if (len(lhs) == 0 && len(rhs) == 0) ||
((len(lhs) == 0 || len(rhs) == 0) && matching.FillValues.RHS == nil && matching.FillValues.LHS == nil) {
return nil, nil // Short-circuit: nothing is going to match.
}
@ -2910,17 +2911,9 @@ func (ev *evaluator) VectorBinop(op parser.ItemType, lhs, rhs Vector, matching *
}
matchedSigs := enh.matchedSigs
// For all lhs samples find a respective rhs sample and perform
// the binary operation.
var lastErr error
for i, ls := range lhs {
sigOrd := lhsh[i].sigOrdinal
rs, found := rightSigs[sigOrd] // Look for a match in the rhs Vector.
if !found {
continue
}
doBinOp := func(ls, rs Sample, sigOrd int) {
// Account for potentially swapped sidedness.
fl, fr := ls.F, rs.F
hl, hr := ls.H, rs.H
@ -2931,7 +2924,7 @@ func (ev *evaluator) VectorBinop(op parser.ItemType, lhs, rhs Vector, matching *
floatValue, histogramValue, keep, info, err := vectorElemBinop(op, fl, fr, hl, hr, pos)
if err != nil {
lastErr = err
continue
return
}
if info != nil {
lastErr = info
@ -2971,7 +2964,7 @@ func (ev *evaluator) VectorBinop(op parser.ItemType, lhs, rhs Vector, matching *
}
if !keep && !returnBool {
continue
return
}
enh.Out = append(enh.Out, Sample{
@ -2981,6 +2974,43 @@ func (ev *evaluator) VectorBinop(op parser.ItemType, lhs, rhs Vector, matching *
DropName: returnBool,
})
}
// For all lhs samples, find a respective rhs sample and perform
// the binary operation.
for i, ls := range lhs {
sigOrd := lhsh[i].sigOrdinal
rs, found := rightSigs[sigOrd] // Look for a match in the rhs Vector.
if !found {
fill := matching.FillValues.RHS
if fill == nil {
continue
}
rs = Sample{
Metric: ls.Metric.MatchLabels(matching.On, matching.MatchingLabels...),
F: *fill,
}
}
doBinOp(ls, rs, sigOrd)
}
// For any rhs samples which have not been matched, check if we need to
// perform the operation with a fill value from the lhs.
if fill := matching.FillValues.LHS; fill != nil {
for sigOrd, rs := range rightSigs {
if _, matched := matchedSigs[sigOrd]; matched {
continue // Already matched.
}
ls := Sample{
Metric: rs.Metric.MatchLabels(matching.On, matching.MatchingLabels...),
F: *fill,
}
doBinOp(ls, rs, sigOrd)
}
}
return enh.Out, lastErr
}

View file

@ -318,6 +318,19 @@ type VectorMatching struct {
// Include contains additional labels that should be included in
// the result from the side with the lower cardinality.
Include []string
// Fill-in values to use when a series from one side does not find a match on the other side.
FillValues VectorMatchFillValues
}
// VectorMatchFillValues contains the fill values to use for Vector matching
// when one side does not find a match on the other side.
// When a fill value is nil, no fill is applied for that side, and there
// is no output for the match group if there is no match.
type VectorMatchFillValues struct {
// RHS is the fill value to use for the right-hand side.
RHS *float64
// LHS is the fill value to use for the left-hand side.
LHS *float64
}
// Visitor allows visiting a Node and its child nodes. The Visit method is

View file

@ -139,6 +139,9 @@ BOOL
BY
GROUP_LEFT
GROUP_RIGHT
FILL
FILL_LEFT
FILL_RIGHT
IGNORING
OFFSET
SMOOTHED
@ -190,7 +193,7 @@ START_METRIC_SELECTOR
%type <int> int
%type <uint> uint
%type <float> number series_value signed_number signed_or_unsigned_number
%type <node> step_invariant_expr aggregate_expr aggregate_modifier bin_modifier binary_expr bool_modifier expr function_call function_call_args function_call_body group_modifiers label_matchers matrix_selector number_duration_literal offset_expr anchored_expr smoothed_expr on_or_ignoring paren_expr string_literal subquery_expr unary_expr vector_selector duration_expr paren_duration_expr positive_duration_expr offset_duration_expr
%type <node> step_invariant_expr aggregate_expr aggregate_modifier bin_modifier fill_modifiers binary_expr bool_modifier expr function_call function_call_args function_call_body group_modifiers fill_value label_matchers matrix_selector number_duration_literal offset_expr anchored_expr smoothed_expr on_or_ignoring paren_expr string_literal subquery_expr unary_expr vector_selector duration_expr paren_duration_expr positive_duration_expr offset_duration_expr
%start start
@ -302,7 +305,7 @@ binary_expr : expr ADD bin_modifier expr { $$ = yylex.(*parser).newBinar
// Using left recursion for the modifier rules, helps to keep the parser stack small and
// reduces allocations.
bin_modifier : group_modifiers;
bin_modifier : fill_modifiers;
bool_modifier : /* empty */
{ $$ = &BinaryExpr{
@ -346,6 +349,47 @@ group_modifiers: bool_modifier /* empty */
}
;
fill_modifiers: group_modifiers /* empty */
/* Only fill() */
| group_modifiers FILL fill_value
{
$$ = $1
fill := $3.(*NumberLiteral).Val
$$.(*BinaryExpr).VectorMatching.FillValues.LHS = &fill
$$.(*BinaryExpr).VectorMatching.FillValues.RHS = &fill
}
/* Only fill_left() */
| group_modifiers FILL_LEFT fill_value
{
$$ = $1
fill := $3.(*NumberLiteral).Val
$$.(*BinaryExpr).VectorMatching.FillValues.LHS = &fill
}
/* Only fill_right() */
| group_modifiers FILL_RIGHT fill_value
{
$$ = $1
fill := $3.(*NumberLiteral).Val
$$.(*BinaryExpr).VectorMatching.FillValues.RHS = &fill
}
/* fill_left() fill_right() */
| group_modifiers FILL_LEFT fill_value FILL_RIGHT fill_value
{
$$ = $1
fill_left := $3.(*NumberLiteral).Val
fill_right := $5.(*NumberLiteral).Val
$$.(*BinaryExpr).VectorMatching.FillValues.LHS = &fill_left
$$.(*BinaryExpr).VectorMatching.FillValues.RHS = &fill_right
}
/* fill_right() fill_left() */
| group_modifiers FILL_RIGHT fill_value FILL_LEFT fill_value
{
fill_right := $3.(*NumberLiteral).Val
fill_left := $5.(*NumberLiteral).Val
$$.(*BinaryExpr).VectorMatching.FillValues.LHS = &fill_left
$$.(*BinaryExpr).VectorMatching.FillValues.RHS = &fill_right
}
;
grouping_labels : LEFT_PAREN grouping_label_list RIGHT_PAREN
{ $$ = $2 }
@ -387,6 +431,21 @@ grouping_label : maybe_label
{ yylex.(*parser).unexpected("grouping opts", "label"); $$ = Item{} }
;
fill_value : LEFT_PAREN number_duration_literal RIGHT_PAREN
{
$$ = $2.(*NumberLiteral)
}
| LEFT_PAREN unary_op number_duration_literal RIGHT_PAREN
{
nl := $3.(*NumberLiteral)
if $2.Typ == SUB {
nl.Val *= -1
}
nl.PosRange.Start = $2.Pos
$$ = nl
}
;
/*
* Function calls.
*/
@ -697,7 +756,7 @@ metric : metric_identifier label_set
;
metric_identifier: AVG | BOTTOMK | BY | COUNT | COUNT_VALUES | GROUP | IDENTIFIER | LAND | LOR | LUNLESS | MAX | METRIC_IDENTIFIER | MIN | OFFSET | QUANTILE | STDDEV | STDVAR | SUM | TOPK | WITHOUT | START | END | LIMITK | LIMIT_RATIO | STEP | RANGE | ANCHORED | SMOOTHED;
metric_identifier: AVG | BOTTOMK | BY | COUNT | COUNT_VALUES | FILL | FILL_LEFT | FILL_RIGHT | GROUP | IDENTIFIER | LAND | LOR | LUNLESS | MAX | METRIC_IDENTIFIER | MIN | OFFSET | QUANTILE | STDDEV | STDVAR | SUM | TOPK | WITHOUT | START | END | LIMITK | LIMIT_RATIO | STEP | RANGE | ANCHORED | SMOOTHED;
label_set : LEFT_BRACE label_set_list RIGHT_BRACE
{ $$ = labels.New($2...) }
@ -954,7 +1013,7 @@ counter_reset_hint : UNKNOWN_COUNTER_RESET | COUNTER_RESET | NOT_COUNTER_RESET |
aggregate_op : AVG | BOTTOMK | COUNT | COUNT_VALUES | GROUP | MAX | MIN | QUANTILE | STDDEV | STDVAR | SUM | TOPK | LIMITK | LIMIT_RATIO;
// Inside of grouping options label names can be recognized as keywords by the lexer. This is a list of keywords that could also be a label name.
maybe_label : AVG | BOOL | BOTTOMK | BY | COUNT | COUNT_VALUES | GROUP | GROUP_LEFT | GROUP_RIGHT | IDENTIFIER | IGNORING | LAND | LOR | LUNLESS | MAX | METRIC_IDENTIFIER | MIN | OFFSET | ON | QUANTILE | STDDEV | STDVAR | SUM | TOPK | START | END | ATAN2 | LIMITK | LIMIT_RATIO | STEP | RANGE | ANCHORED | SMOOTHED;
maybe_label : AVG | BOOL | BOTTOMK | BY | COUNT | COUNT_VALUES | GROUP | GROUP_LEFT | GROUP_RIGHT | FILL | FILL_LEFT | FILL_RIGHT | IDENTIFIER | IGNORING | LAND | LOR | LUNLESS | MAX | METRIC_IDENTIFIER | MIN | OFFSET | ON | QUANTILE | STDDEV | STDVAR | SUM | TOPK | START | END | ATAN2 | LIMITK | LIMIT_RATIO | STEP | RANGE | ANCHORED | SMOOTHED;
unary_op : ADD | SUB;
@ -1162,7 +1221,7 @@ offset_duration_expr : number_duration_literal
}
| duration_expr
;
min_max: MIN | MAX ;
duration_expr : number_duration_literal
@ -1277,14 +1336,14 @@ duration_expr : number_duration_literal
;
paren_duration_expr : LEFT_PAREN duration_expr RIGHT_PAREN
{
{
yylex.(*parser).experimentalDurationExpr($2.(Expr))
if durationExpr, ok := $2.(*DurationExpr); ok {
durationExpr.Wrapped = true
$$ = durationExpr
break
}
$$ = $2
$$ = $2
}
;

File diff suppressed because it is too large Load diff

View file

@ -137,6 +137,9 @@ var key = map[string]ItemType{
"ignoring": IGNORING,
"group_left": GROUP_LEFT,
"group_right": GROUP_RIGHT,
"fill": FILL,
"fill_left": FILL_LEFT,
"fill_right": FILL_RIGHT,
"bool": BOOL,
// Preprocessors.
@ -1083,6 +1086,17 @@ Loop:
word := l.input[l.start:l.pos]
switch kw, ok := key[strings.ToLower(word)]; {
case ok:
// For fill/fill_left/fill_right, only treat as keyword if followed by '('
// This allows using these as metric names (e.g., "fill + fill").
// This could be done for other keywords as well, but for the new fill
// modifiers this is especially important so we don't break any existing
// queries.
if kw == FILL || kw == FILL_LEFT || kw == FILL_RIGHT {
if !l.peekFollowedByLeftParen() {
l.emit(IDENTIFIER)
break Loop
}
}
l.emit(kw)
case !strings.Contains(word, ":"):
l.emit(IDENTIFIER)
@ -1098,6 +1112,23 @@ Loop:
return lexStatements
}
// peekFollowedByLeftParen checks if the next non-whitespace character is '('.
// This is used for context-sensitive keywords like fill/fill_left/fill_right
// that should only be treated as keywords when followed by '('.
func (l *Lexer) peekFollowedByLeftParen() bool {
pos := l.pos
for {
if int(pos) >= len(l.input) {
return false
}
r, w := utf8.DecodeRuneInString(l.input[pos:])
if !isSpace(r) {
return r == '('
}
pos += posrange.Pos(w)
}
}
func isSpace(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
}

View file

@ -768,6 +768,9 @@ func (p *parser) checkAST(node Node) (typ ValueType) {
if len(n.VectorMatching.MatchingLabels) > 0 {
p.addParseErrf(n.PositionRange(), "vector matching only allowed between instant vectors")
}
if n.VectorMatching.FillValues.LHS != nil || n.VectorMatching.FillValues.RHS != nil {
p.addParseErrf(n.PositionRange(), "filling in missing series only allowed between instant vectors")
}
n.VectorMatching = nil
case n.Op.IsSetOperator(): // Both operands are Vectors.
if n.VectorMatching.Card == CardOneToMany || n.VectorMatching.Card == CardManyToOne {
@ -776,6 +779,9 @@ func (p *parser) checkAST(node Node) (typ ValueType) {
if n.VectorMatching.Card != CardManyToMany {
p.addParseErrf(n.PositionRange(), "set operations must always be many-to-many")
}
if n.VectorMatching.FillValues.LHS != nil || n.VectorMatching.FillValues.RHS != nil {
p.addParseErrf(n.PositionRange(), "filling in missing series not allowed for set operators")
}
}
if (lt == ValueTypeScalar || rt == ValueTypeScalar) && n.Op.IsSetOperator() {

View file

@ -172,6 +172,19 @@ func (node *BinaryExpr) getMatchingStr() string {
b.WriteString(")")
matching += b.String()
}
if vm.FillValues.LHS != nil || vm.FillValues.RHS != nil {
if vm.FillValues.LHS == vm.FillValues.RHS {
matching += fmt.Sprintf(" fill (%v)", *vm.FillValues.LHS)
} else {
if vm.FillValues.LHS != nil {
matching += fmt.Sprintf(" fill_left (%v)", *vm.FillValues.LHS)
}
if vm.FillValues.RHS != nil {
matching += fmt.Sprintf(" fill_right (%v)", *vm.FillValues.RHS)
}
}
}
}
return matching
}

View file

@ -113,6 +113,26 @@ func TestExprString(t *testing.T) {
in: `a - ignoring() group_left c`,
out: `a - ignoring () group_left () c`,
},
{
in: `a + fill(-23) b`,
out: `a + fill (-23) b`,
},
{
in: `a + fill_left(-23) b`,
out: `a + fill_left (-23) b`,
},
{
in: `a + fill_right(42) b`,
out: `a + fill_right (42) b`,
},
{
in: `a + fill_left(-23) fill_right(42) b`,
out: `a + fill_left (-23) fill_right (42) b`,
},
{
in: `a + on(b) group_left fill(-23) c`,
out: `a + on (b) group_left () fill (-23) c`,
},
{
in: `up > bool 0`,
},

View file

@ -47,6 +47,10 @@ func translateAST(node parser.Expr) any {
"labels": sanitizeList(m.MatchingLabels),
"on": m.On,
"include": sanitizeList(m.Include),
"fillValues": map[string]*float64{
"lhs": m.FillValues.LHS,
"rhs": m.FillValues.RHS,
},
}
}

View file

@ -8,6 +8,7 @@ import {
MatchErrorType,
computeVectorVectorBinOp,
filteredSampleValue,
MaybeFilledInstantSample,
} from "../../../../promql/binOp";
import { formatNode, labelNameList } from "../../../../promql/format";
import {
@ -177,11 +178,10 @@ const explanationText = (node: BinaryExpr): React.ReactNode => {
</List.Item>
) : (
<List.Item>
<span className="promql-code promql-keyword">
group_{manySide}({labelNameList(matching.include)})
</span>
: {matching.card} match. Each series from the {oneSide}-hand side is
allowed to match with multiple series from the {manySide}-hand side.
<span className="promql-code promql-keyword">group_{manySide}</span>
({labelNameList(matching.include)}) : {matching.card} match. Each
series from the {oneSide}-hand side is allowed to match with
multiple series from the {manySide}-hand side.
{matching.include.length !== 0 && (
<>
{" "}
@ -192,6 +192,55 @@ const explanationText = (node: BinaryExpr): React.ReactNode => {
)}
</List.Item>
)}
{(matching.fillValues.lhs !== null ||
matching.fillValues.rhs !== null) &&
(matching.fillValues.lhs === matching.fillValues.rhs ? (
<List.Item>
<span className="promql-code promql-keyword">fill</span>(
<span className="promql-code promql-number">
{matching.fillValues.lhs}
</span>
) : For series on either side missing a match, fill in the sample
value{" "}
<span className="promql-code promql-number">
{matching.fillValues.lhs}
</span>
.
</List.Item>
) : (
<>
{matching.fillValues.lhs !== null && (
<List.Item>
<span className="promql-code promql-keyword">fill_left</span>(
<span className="promql-code promql-number">
{matching.fillValues.lhs}
</span>
) : For series on the left-hand side missing a match, fill in
the sample value{" "}
<span className="promql-code promql-number">
{matching.fillValues.lhs}
</span>
.
</List.Item>
)}
{matching.fillValues.rhs !== null && (
<List.Item>
<span className="promql-code promql-keyword">fill_right</span>
(
<span className="promql-code promql-number">
{matching.fillValues.rhs}
</span>
) : For series on the right-hand side missing a match, fill in
the sample value{" "}
<span className="promql-code promql-number">
{matching.fillValues.rhs}
</span>
.
</List.Item>
)}
</>
))}
{node.bool && (
<List.Item>
<span className="promql-code promql-keyword">bool</span>: Instead of
@ -239,7 +288,12 @@ const explainError = (
matching: {
...(binOp.matching
? binOp.matching
: { labels: [], on: false, include: [] }),
: {
labels: [],
on: false,
include: [],
fillValues: { lhs: null, rhs: null },
}),
card:
err.dupeSide === "left"
? vectorMatchCardinality.manyToOne
@ -403,7 +457,7 @@ const VectorVectorBinaryExprExplainView: FC<
);
const matchGroupTable = (
series: InstantSample[],
series: MaybeFilledInstantSample[],
seriesCount: number,
color: string,
colorOffset?: number
@ -458,6 +512,11 @@ const VectorVectorBinaryExprExplainView: FC<
)}
format={true}
/>
{s.filled && (
<Text size="sm" c="dimmed">
no match, filling in default value
</Text>
)}
</Group>
</Table.Td>
{showSampleValues && (

View file

@ -104,11 +104,16 @@ export interface LabelMatcher {
value: string;
}
export interface FillValues {
lhs: number | null;
rhs: number | null;
}
export interface VectorMatching {
card: vectorMatchCardinality;
labels: string[];
on: boolean;
include: string[];
fillValues: FillValues;
}
export type StartOrEnd = "start" | "end" | null;

View file

@ -81,6 +81,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -247,6 +248,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1", "label2"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -413,6 +415,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: ["same"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -579,6 +582,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricB,
rhs: testMetricC,
@ -701,6 +705,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricB,
rhs: testMetricC,
@ -791,6 +796,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricB,
rhs: testMetricC,
@ -905,6 +911,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricC,
rhs: testMetricB,
@ -1019,6 +1026,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricC,
rhs: testMetricB,
@ -1107,6 +1115,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -1223,6 +1232,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -1409,6 +1419,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -1596,6 +1607,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -1763,6 +1775,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -1929,6 +1942,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -2022,6 +2036,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricB,
rhs: testMetricC,
@ -2105,6 +2120,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricB,
rhs: testMetricC,
@ -2156,6 +2172,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA,
rhs: testMetricB,
@ -2342,6 +2359,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA.slice(0, 3),
rhs: testMetricB.slice(1, 4),
@ -2474,6 +2492,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA.slice(0, 3),
rhs: testMetricB.slice(1, 4),
@ -2568,6 +2587,7 @@ const testCases: TestCase[] = [
on: true,
include: [],
labels: ["label1"],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA.slice(0, 3),
rhs: testMetricB.slice(1, 4),
@ -2700,6 +2720,7 @@ const testCases: TestCase[] = [
on: false,
include: [],
labels: [],
fillValues: { lhs: null, rhs: null },
},
lhs: testMetricA.slice(0, 3),
rhs: testMetricB.slice(1, 4),
@ -2886,6 +2907,7 @@ describe("binOp", () => {
on: true,
labels: ["label1"],
include: [],
fillValues: { lhs: null, rhs: null },
};
const result = resultMetric(lhs, rhs, op, matching);
@ -2911,6 +2933,7 @@ describe("binOp", () => {
on: true,
labels: ["label1"],
include: [],
fillValues: { lhs: null, rhs: null },
};
const result = resultMetric(lhs, rhs, op, matching);
@ -2931,6 +2954,7 @@ describe("binOp", () => {
on: true,
labels: ["label1"],
include: ["label2"],
fillValues: { lhs: null, rhs: null },
};
const result = resultMetric(lhs, rhs, op, matching);

View file

@ -45,13 +45,18 @@ export type VectorMatchError =
| MultipleMatchesOnBothSidesError
| MultipleMatchesOnOneSideError;
export type MaybeFilledInstantSample = InstantSample & {
// If the sample was filled in via a fill(...) modifier, this is true.
filled?: boolean;
};
// 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[];
rhs: MaybeFilledInstantSample[];
rhsCount: number; // Number of samples before applying limits.
lhs: InstantSample[];
lhs: MaybeFilledInstantSample[];
lhsCount: number; // Number of samples before applying limits.
result: {
sample: InstantSample;
@ -338,6 +343,26 @@ export const computeVectorVectorBinOp = (
groups[sig].lhsCount++;
});
// Check for any LHS / RHS with no series and fill in default values, if specified.
Object.values(groups).forEach((mg) => {
if (mg.lhs.length === 0 && matching.fillValues.lhs !== null) {
mg.lhs.push({
metric: {},
value: [0, formatPrometheusFloat(matching.fillValues.lhs as number)],
filled: true,
});
mg.lhsCount = 1;
}
if (mg.rhs.length === 0 && matching.fillValues.rhs !== null) {
mg.rhs.push({
metric: {},
value: [0, formatPrometheusFloat(matching.fillValues.rhs as number)],
filled: true,
});
mg.rhsCount = 1;
}
});
// Annotate the match groups with errors (if any) and populate the results.
Object.values(groups).forEach((mg) => {
switch (matching.card) {

View file

@ -265,6 +265,7 @@ const formatNodeInternal = (
case nodeType.binaryExpr: {
let matching = <></>;
let grouping = <></>;
let fill = <></>;
const vm = node.matching;
if (vm !== null) {
if (
@ -305,6 +306,45 @@ const formatNodeInternal = (
</>
);
}
const lfill = vm.fillValues.lhs;
const rfill = vm.fillValues.rhs;
if (lfill !== null || rfill !== null) {
if (lfill === rfill) {
fill = (
<>
{" "}
<span className="promql-keyword">fill</span>
<span className="promql-paren">(</span>
<span className="promql-number">{lfill}</span>
<span className="promql-paren">)</span>
</>
);
} else {
fill = (
<>
{lfill !== null && (
<>
{" "}
<span className="promql-keyword">fill_left</span>
<span className="promql-paren">(</span>
<span className="promql-number">{lfill}</span>
<span className="promql-paren">)</span>
</>
)}
{rfill !== null && (
<>
{" "}
<span className="promql-keyword">fill_right</span>
<span className="promql-paren">(</span>
<span className="promql-number">{rfill}</span>
<span className="promql-paren">)</span>
</>
)}
</>
);
}
}
}
return (
@ -327,7 +367,8 @@ const formatNodeInternal = (
</>
)}
{matching}
{grouping}{" "}
{grouping}
{fill}{" "}
{showChildren &&
formatNode(
maybeParenthesizeBinopChild(node.op, node.rhs),

View file

@ -135,6 +135,7 @@ const serializeNode = (
case nodeType.binaryExpr: {
let matching = "";
let grouping = "";
let fill = "";
const vm = node.matching;
if (vm !== null) {
if (
@ -152,11 +153,26 @@ const serializeNode = (
) {
grouping = ` group_${vm.card === vectorMatchCardinality.manyToOne ? "left" : "right"}(${labelNameList(vm.include)})`;
}
const lfill = vm.fillValues.lhs;
const rfill = vm.fillValues.rhs;
if (lfill !== null || rfill !== null) {
if (lfill === rfill) {
fill = ` fill(${lfill})`;
} else {
if (lfill !== null) {
fill += ` fill_left(${lfill})`;
}
if (rfill !== null) {
fill += ` fill_right(${rfill})`;
}
}
}
}
return `${serializeNode(maybeParenthesizeBinopChild(node.op, node.lhs), childIndent, pretty)}${childSeparator}${ind}${
node.op
}${node.bool ? " bool" : ""}${matching}${grouping}${childSeparator}${serializeNode(
}${node.bool ? " bool" : ""}${matching}${grouping}${fill}${childSeparator}${serializeNode(
maybeParenthesizeBinopChild(node.op, node.rhs),
childIndent,
pretty

View file

@ -658,6 +658,7 @@ describe("serializeNode and formatNode", () => {
labels: [],
on: false,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -677,6 +678,7 @@ describe("serializeNode and formatNode", () => {
labels: [],
on: true,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -696,6 +698,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -715,6 +718,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: false,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -735,6 +739,7 @@ describe("serializeNode and formatNode", () => {
labels: [],
on: false,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -755,6 +760,7 @@ describe("serializeNode and formatNode", () => {
labels: [],
on: false,
include: ["__name__"],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -774,6 +780,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -793,6 +800,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: ["label3"],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -812,6 +820,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: [],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -831,6 +840,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: ["label3"],
fillValues: { lhs: null, rhs: null },
},
bool: false,
},
@ -864,6 +874,7 @@ describe("serializeNode and formatNode", () => {
labels: ["label1", "label2"],
on: true,
include: ["label3"],
fillValues: { lhs: null, rhs: null },
},
bool: true,
},
@ -911,6 +922,7 @@ describe("serializeNode and formatNode", () => {
include: ["c", "ü"],
labels: ["b", "ö"],
on: true,
fillValues: { lhs: null, rhs: null },
},
op: binaryOperatorType.div,
rhs: {
@ -948,6 +960,7 @@ describe("serializeNode and formatNode", () => {
include: [],
labels: ["e", "ö"],
on: false,
fillValues: { lhs: null, rhs: null },
},
op: binaryOperatorType.add,
rhs: {

View file

@ -39,6 +39,10 @@ export const binOpModifierTerms = [
{ label: 'ignoring', info: 'Ignore specified labels for matching', type: 'keyword' },
{ label: 'group_left', info: 'Allow many-to-one matching', type: 'keyword' },
{ label: 'group_right', info: 'Allow one-to-many matching', type: 'keyword' },
{ label: 'bool', info: 'Return boolean result (0 or 1) instead of filtering', type: 'keyword' },
{ label: 'fill', info: 'Fill in missing series on both sides', type: 'keyword' },
{ label: 'fill_left', info: 'Fill in missing series on the left side', type: 'keyword' },
{ label: 'fill_right', info: 'Fill in missing series on the right side', type: 'keyword' },
];
export const atModifierTerms = [

View file

@ -15,29 +15,31 @@ import { buildVectorMatching } from './vector';
import { createEditorState } from '../test/utils-test';
import { BinaryExpr } from '@prometheus-io/lezer-promql';
import { syntaxTree } from '@codemirror/language';
import { VectorMatchCardinality } from '../types';
import { VectorMatchCardinality, VectorMatching } from '../types';
const noFill = { fill: { lhs: null, rhs: null } };
describe('buildVectorMatching test', () => {
const testCases = [
const testCases: { binaryExpr: string; expectedVectorMatching: VectorMatching }[] = [
{
binaryExpr: 'foo * bar',
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] },
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill },
},
{
binaryExpr: 'foo * sum',
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] },
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill },
},
{
binaryExpr: 'foo == 1',
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] },
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill },
},
{
binaryExpr: 'foo == bool 1',
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] },
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill },
},
{
binaryExpr: '2.5 / bar',
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] },
expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill },
},
{
binaryExpr: 'foo and bar',
@ -46,6 +48,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -55,6 +58,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -64,6 +68,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -75,6 +80,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -86,6 +92,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -95,6 +102,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: true,
include: [],
...noFill,
},
},
{
@ -104,6 +112,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: true,
include: [],
...noFill,
},
},
{
@ -113,6 +122,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: true,
include: [],
...noFill,
},
},
{
@ -122,6 +132,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: true,
include: [],
...noFill,
},
},
{
@ -131,6 +142,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: false,
include: [],
...noFill,
},
},
{
@ -140,6 +152,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: [],
on: false,
include: [],
...noFill,
},
},
{
@ -149,6 +162,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['bar'],
on: true,
include: [],
...noFill,
},
},
{
@ -158,6 +172,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: true,
include: ['bar'],
...noFill,
},
},
{
@ -167,6 +182,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: false,
include: ['blub'],
...noFill,
},
},
{
@ -176,6 +192,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: false,
include: ['bar'],
...noFill,
},
},
{
@ -185,6 +202,7 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: true,
include: ['bar', 'foo'],
...noFill,
},
},
{
@ -194,6 +212,57 @@ describe('buildVectorMatching test', () => {
matchingLabels: ['test', 'blub'],
on: false,
include: ['bar', 'foo'],
...noFill,
},
},
{
binaryExpr: 'foo + fill(23) bar',
expectedVectorMatching: {
card: VectorMatchCardinality.CardOneToOne,
matchingLabels: [],
on: false,
include: [],
fill: { lhs: 23, rhs: 23 },
},
},
{
binaryExpr: 'foo + fill_left(23) bar',
expectedVectorMatching: {
card: VectorMatchCardinality.CardOneToOne,
matchingLabels: [],
on: false,
include: [],
fill: { lhs: 23, rhs: null },
},
},
{
binaryExpr: 'foo + fill_right(23) bar',
expectedVectorMatching: {
card: VectorMatchCardinality.CardOneToOne,
matchingLabels: [],
on: false,
include: [],
fill: { lhs: null, rhs: 23 },
},
},
{
binaryExpr: 'foo + fill_left(23) fill_right(42) bar',
expectedVectorMatching: {
card: VectorMatchCardinality.CardOneToOne,
matchingLabels: [],
on: false,
include: [],
fill: { lhs: 23, rhs: 42 },
},
},
{
binaryExpr: 'foo + fill_right(23) fill_left(42) bar',
expectedVectorMatching: {
card: VectorMatchCardinality.CardOneToOne,
matchingLabels: [],
on: false,
include: [],
fill: { lhs: 42, rhs: 23 },
},
},
];
@ -203,7 +272,7 @@ describe('buildVectorMatching test', () => {
const node = syntaxTree(state).topNode.getChild(BinaryExpr);
expect(node).toBeTruthy();
if (node) {
expect(value.expectedVectorMatching).toEqual(buildVectorMatching(state, node));
expect(buildVectorMatching(state, node)).toEqual(value.expectedVectorMatching);
}
});
});

View file

@ -24,6 +24,11 @@ import {
On,
Or,
Unless,
NumberDurationLiteral,
FillModifier,
FillClause,
FillLeftClause,
FillRightClause,
} from '@prometheus-io/lezer-promql';
import { VectorMatchCardinality, VectorMatching } from '../types';
import { containsAtLeastOneChild } from './path-finder';
@ -37,6 +42,10 @@ export function buildVectorMatching(state: EditorState, binaryNode: SyntaxNode):
matchingLabels: [],
on: false,
include: [],
fill: {
lhs: null,
rhs: null,
},
};
const modifierClause = binaryNode.getChild(MatchingModifierClause);
if (modifierClause) {
@ -60,6 +69,32 @@ export function buildVectorMatching(state: EditorState, binaryNode: SyntaxNode):
}
}
const fillModifier = binaryNode.getChild(FillModifier);
if (fillModifier) {
const fill = fillModifier.getChild(FillClause);
const fillLeft = fillModifier.getChild(FillLeftClause);
const fillRight = fillModifier.getChild(FillRightClause);
const getFillValue = (node: SyntaxNode) => {
const valueNode = node.getChild(NumberDurationLiteral);
return valueNode ? parseFloat(state.sliceDoc(valueNode.from, valueNode.to)) : null;
};
if (fill) {
const value = getFillValue(fill);
result.fill.lhs = value;
result.fill.rhs = value;
}
if (fillLeft) {
result.fill.lhs = getFillValue(fillLeft);
}
if (fillRight) {
result.fill.rhs = getFillValue(fillRight);
}
}
const isSetOperator = containsAtLeastOneChild(binaryNode, And, Or, Unless);
if (isSetOperator && result.card === VectorMatchCardinality.CardOneToOne) {
result.card = VectorMatchCardinality.CardManyToMany;

View file

@ -18,6 +18,11 @@ export enum VectorMatchCardinality {
CardManyToMany = 'many-to-many',
}
export interface FillValues {
lhs: number | null;
rhs: number | null;
}
export interface VectorMatching {
// The cardinality of the two Vectors.
card: VectorMatchCardinality;
@ -30,4 +35,6 @@ export interface VectorMatching {
// Include contains additional labels that should be included in
// the result from the side with the lower cardinality.
include: string[];
// Fill contains optional fill values for missing elements.
fill: FillValues;
}

View file

@ -101,11 +101,30 @@ MatchingModifierClause {
((GroupLeft | GroupRight) (!group GroupingLabels)?)?
}
FillClause {
Fill "(" NumberDurationLiteral ")"
}
FillLeftClause {
FillLeft "(" NumberDurationLiteral ")"
}
FillRightClause {
FillRight "(" NumberDurationLiteral ")"
}
FillModifier {
(FillClause | FillLeftClause | FillRightClause) |
(FillLeftClause FillRightClause) |
(FillRightClause FillLeftClause)
}
BoolModifier { Bool }
binModifiers {
BoolModifier?
MatchingModifierClause?
FillModifier?
}
GroupingLabels {
@ -366,7 +385,10 @@ NumberDurationLiteralInDurationContext {
Start,
End,
Smoothed,
Anchored
Anchored,
Fill,
FillLeft,
FillRight
}
@external propSource promQLHighLight from "./highlight"

View file

@ -12,82 +12,88 @@
// limitations under the License.
import {
And,
Avg,
Atan2,
Bool,
Bottomk,
By,
Count,
CountValues,
End,
Group,
GroupLeft,
GroupRight,
Ignoring,
inf,
Max,
Min,
nan,
Offset,
On,
Or,
Quantile,
LimitK,
LimitRatio,
Start,
Stddev,
Stdvar,
Sum,
Topk,
Unless,
Without,
Smoothed,
Anchored,
} from './parser.terms.js';
And,
Avg,
Atan2,
Bool,
Bottomk,
By,
Count,
CountValues,
End,
Group,
GroupLeft,
GroupRight,
Ignoring,
inf,
Max,
Min,
nan,
Offset,
On,
Or,
Quantile,
LimitK,
LimitRatio,
Start,
Stddev,
Stdvar,
Sum,
Topk,
Unless,
Without,
Smoothed,
Anchored,
Fill,
FillLeft,
FillRight,
} from "./parser.terms.js";
const keywordTokens = {
inf: inf,
nan: nan,
bool: Bool,
ignoring: Ignoring,
on: On,
group_left: GroupLeft,
group_right: GroupRight,
offset: Offset,
inf: inf,
nan: nan,
bool: Bool,
ignoring: Ignoring,
on: On,
group_left: GroupLeft,
group_right: GroupRight,
offset: Offset,
};
export const specializeIdentifier = (value, stack) => {
return keywordTokens[value.toLowerCase()] || -1;
return keywordTokens[value.toLowerCase()] || -1;
};
const contextualKeywordTokens = {
avg: Avg,
atan2: Atan2,
bottomk: Bottomk,
count: Count,
count_values: CountValues,
group: Group,
max: Max,
min: Min,
quantile: Quantile,
limitk: LimitK,
limit_ratio: LimitRatio,
stddev: Stddev,
stdvar: Stdvar,
sum: Sum,
topk: Topk,
by: By,
without: Without,
and: And,
or: Or,
unless: Unless,
start: Start,
end: End,
smoothed: Smoothed,
anchored: Anchored,
avg: Avg,
atan2: Atan2,
bottomk: Bottomk,
count: Count,
count_values: CountValues,
group: Group,
max: Max,
min: Min,
quantile: Quantile,
limitk: LimitK,
limit_ratio: LimitRatio,
stddev: Stddev,
stdvar: Stdvar,
sum: Sum,
topk: Topk,
by: By,
without: Without,
and: And,
or: Or,
unless: Unless,
start: Start,
end: End,
smoothed: Smoothed,
anchored: Anchored,
fill: Fill,
fill_left: FillLeft,
fill_right: FillRight,
};
export const extendIdentifier = (value, stack) => {
return contextualKeywordTokens[value.toLowerCase()] || -1;
return contextualKeywordTokens[value.toLowerCase()] || -1;
};