Repository navigation
perf(api): read only the matched metrics' tags rows in PromQL label lookups #3603
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c83953e
b7c7613
a34014d
71e2aa6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@hyperdx/api': patch | ||
| '@hyperdx/common-utils': patch | ||
| --- | ||
|
|
||
| perf: PromQL label lookups with `match[]` read only the matched metrics' tags | ||
| rows when every selector names its metric |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { | |
| Metadata, | ||
| MetadataCache, | ||
| } from '@hyperdx/common-utils/dist/core/metadata'; | ||
| import { promqlSelectorMetricName } from '@hyperdx/common-utils/dist/core/promqlSelector'; | ||
| import { | ||
| PrometheusMatrixResult, | ||
| PrometheusVectorResult, | ||
|
|
@@ -121,7 +122,16 @@ function getSeriesSelectorCondition({ | |
| )`, | ||
| ); | ||
|
|
||
| return chSql`id IN (${concatChSql(' UNION DISTINCT ', idsPerSelector)})`; | ||
| const ids = chSql`id IN (${concatChSql(' UNION DISTINCT ', idsPerSelector)})`; | ||
| // The tags table is sorted by metric name, so `id IN (...)` alone reads every | ||
| // tags row. When each selector names its metric, say so: same rows, but only | ||
| // those metrics' part of the table is read. | ||
| const names = match | ||
| .map(promqlSelectorMetricName) | ||
| .filter((n): n is string => n != null); | ||
| if (names.length < match.length) return ids; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — No test checks that the By design the filter returns the same rows, so the integration tests in Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| const list = [...new Set(names)].map(n => chSql`${{ String: n }}`); | ||
| return chSql`metric_name IN (${concatChSql(', ', list)}) AND ${ids}`; | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { promqlSelectorMetricName } from '@/core/promqlSelector'; | ||
|
|
||
| describe('promqlSelectorMetricName', () => { | ||
| it.each([ | ||
| ['up', 'up'], | ||
| [' node_cpu:rate5m{job="x"}', 'node_cpu:rate5m'], | ||
| ['{__name__="up"}', 'up'], | ||
| ['{job="x", __name__="up"}', 'up'], | ||
| ["{job=~'a|b', env!=`x`, __name__='up'}", 'up'], | ||
| ])('reads the metric of %s', (selector, name) => { | ||
| expect(promqlSelectorMetricName(selector)).toBe(name); | ||
| }); | ||
|
|
||
| it.each([ | ||
| '{job="x"}', | ||
| '{__name__=~"up|down"}', | ||
| '{job="x", __name__!="up"}', | ||
| '{job="__name__=\\"up\\""}', | ||
| `{job='a,__name__="up"'}`, | ||
| '{job=`a,__name__="up"`}', | ||
| '{__name__="a\\"b"}', | ||
| 'sum(up)', | ||
| 'up{', | ||
| ])('returns undefined for %s', selector => { | ||
| expect(promqlSelectorMetricName(selector)).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { parser } from '@prometheus-io/lezer-promql'; | ||
|
|
||
| /** | ||
| * The metric a series selector is limited to: `name`, `name{...}`, or | ||
| * `{__name__="name", ...}`. Undefined when the selector may match more than | ||
| * one metric, isn't a single plain selector, or doesn't parse. | ||
| */ | ||
| export function promqlSelectorMetricName(selector: string): string | undefined { | ||
| const tree = parser.parse(selector); | ||
| let parseError = false; | ||
| tree.iterate({ | ||
| enter: node => { | ||
| if (node.type.isError) parseError = true; | ||
| }, | ||
| }); | ||
| const vectorSelector = tree.topNode.firstChild; | ||
| if (parseError || vectorSelector?.name !== 'VectorSelector') return; | ||
|
|
||
| const identifier = vectorSelector.getChild('Identifier'); | ||
| if (identifier) return selector.slice(identifier.from, identifier.to); | ||
|
|
||
| const matchers = | ||
| vectorSelector | ||
| .getChild('LabelMatchers') | ||
| ?.getChildren('UnquotedLabelMatcher') ?? []; | ||
| for (const matcher of matchers) { | ||
| const name = matcher.getChild('LabelName'); | ||
| const op = matcher.getChild('MatchOp')?.firstChild; | ||
| const value = matcher.getChild('StringLiteral'); | ||
| if ( | ||
| name && | ||
| value && | ||
| op?.name === 'EqlSingle' && | ||
| selector.slice(name.from, name.to) === '__name__' | ||
| ) { | ||
| // Values with escapes would need unquoting; fall back instead. | ||
| const text = selector.slice(value.from + 1, value.to - 1); | ||
| return text.includes('\\') ? undefined : text; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — No test covers the new
metric_name IN (...)clause or its fallbackAdd a unit test on
getSeriesSelectorCondition(or on the SQL thatqueryLabelValues/queryLabelNamesbuilds) for three cases: all selectors named, which should add themetric_namefilter with deduped names; one unnamed selector mixed in, which should leave onlyid IN (...); and two different named selectors, which should givemetric_name IN (a, b). The existingtimeseriesEngine.int.test.tscases check only that results don't change, so they would still pass if the filter were never emitted (for example with an invertednames.length < match.lengthcheck), and they never exercise the mixed named/unnamed fallback.Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in 71e2aa6:
labelValuesforjobwith['{job="web"}', 'old_metric']expects['batch', 'web'], and fails if a mixed list is narrowed to the named metric. I said earlier that thelabelNamestest covered this. It didn't:web's label names also appear onold_metricseries.Not adding a test on the generated SQL: the filter changes only speed, not results, and the parser's cases are unit-tested.