Skip to content

[PROTOTYPE] Histogram Query Conversions - #3

Closed
bwplotka wants to merge 45 commits into
mainfrom
histogram-promql
Closed

bwplotka wants to merge 45 commits into
mainfrom
histogram-promql

Conversation

@bwplotka

@bwplotka bwplotka commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

rbizos and others added 30 commits September 18, 2026 12:14
this allows to query nhcb with _buckets, _sum or _count suffix and get
classic histograms

Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Signed-off-by: r.bizos <r.bizos@criteo.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
This allows to preserve ordering and thus having sorted output

Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
fixing lint as well

Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
fixing typo on feature flag name

Co-authored-by: Bartlomiej Plotka <bwplotka@gmail.com>
Signed-off-by: rbizos <58781501+rbizos@users.noreply.github.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Performance improvements
- added a cache to not create the labels per samble but rather per
serie
- use hash rather than label.String in the converter
- only convert _sum/_buckets/_count when needed (this significantly
improved the  _count/sum as buckets are the most expensive and were
built each time)

Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
the typo was fixed on the code but not in the docs

Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
Signed-off-by: Raphael Bizos <r.bizos@criteo.com>
The NHCB-as-classic querier buffers the classic series it selected, but
dropped the warnings of that series set on the floor.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Adds --enable-feature=promql-classic-as-nhcb, the opposite direction of
promql-nhcb-as-classic: a query for a native histogram also selects the
classic histogram series of the same metric and folds them into an NHCB
at query time.

Classic histograms that cannot be converted are skipped per timestamp and
reported as a warning annotation. Enabling both compatibility layers at
once is rejected, as they would convert each other's output back and
duplicate every histogram series.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
End-to-end tests for promql-nhcb-as-classic and promql-classic-as-nhcb
that pin down what happens when both representations of a histogram are
in storage: disjoint time ranges work, overlapping samples fail with a
labelset collision, aggregations silently count both, and le matchers
have to use the normalized bucket boundary.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…sion

Pure refactor, so that the upcoming conversion of exponential native
histograms to classic histogram series can reuse it.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
ConvertExponentialToClassic is the counterpart of ConvertNHCBToClassic
for native histograms with an exponential schema.

As exponential histograms have no fixed bucket boundaries, the caller
passes the le boundaries of the _bucket series to emit. The value of each
of them is the number of observations in the buckets whose upper boundary
is at most le, which is exact for the bucket boundaries of the histogram
and of any lower schema.

AppendClassicBoundaries returns the boundaries representing a single
histogram best: the upper boundaries of its buckets, plus the lower
boundary of the lowest bucket and of the first bucket after a gap. The
latter keep the linear interpolation of a classic histogram_quantile()
within the buckets that hold observations, rather than spreading it
across gaps or down to zero. To aggregate converted histograms by le,
e.g. across series, callers can convert all of them with the union of
their boundaries at the lowest schema amongst them.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
A classic histogram series converted from an NHCB used to stay queryable
until the end of the lookback window when the NHCB went stale, or when
the NHCB changed its bucket layout and did not have the bucket anymore.
With a layout change, this mixed the old and the new buckets in
histogram_quantile().

Mark a converted series stale at the first sample of its NHCB that does
not result in it anymore, just like the scrape loop marks the series
stale that disappear from a target.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Stale markers of the classic series used to be converted like any other
sample. When a classic histogram went stale, the NaN counts failed the
conversion with a spurious "count mismatch: count=NaN" warning, and the
last converted NHCB stayed queryable until the end of the lookback window.
When a bucket was dropped from the exposition, the NHCB got NaN buckets.

Skip the stale samples instead. If all the classic series of a histogram
are stale at a timestamp, e.g. because the target went away, the NHCB is
marked stale there. Otherwise the remaining series are converted, e.g.
because the bucket layout changed.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Pure refactor, so that the upcoming conversion of exponential native
histograms can pick the classic bucket boundaries from all of them before
converting the first one. The native histograms are read as float
histograms, which the conversion supports just as well.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Allow NHCBAsClassicQuerier to convert native histograms with an
exponential schema, too, not only NHCB. This is not enabled for the
promql-nhcb-as-classic feature flag, but for the upcoming flag making
native and classic histograms interchangeable.

Exponential histograms have no fixed bucket boundaries. So that the
converted classic histograms can be aggregated by le, across series and
over time, e.g. with sum by (le) (rate(...)), all of them are converted
with the same le boundaries: the union of the boundaries of all the
selected exponential histograms, reduced to the lowest schema amongst
them. As the boundaries of a lower schema are boundaries of every higher
schema, too, each conversion is exact. The price is that the le set
depends on the selected series and time range.

Staleness markers of histogram series are read back from the TSDB with
schema 0, so they are explicitly excluded from the conversion.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
promql-nhcb-as-classic and promql-classic-as-nhcb convert in one direction
each, and cannot be enabled together: stacking both layers converts the
output of one layer back with the other one, returning every histogram
series twice.

Add the promql-nh-classic-compat feature flag, which makes native and
classic histograms interchangeable in queries in both directions, e.g.
while migrating from one to the other:

* A query for classic histogram series (_bucket, _count or _sum) also
  returns the classic series converted from the native histograms of the
  base metric name. Unlike promql-nhcb-as-classic, this includes native
  histograms with an exponential schema, not only NHCB.
* Any other query also returns the NHCB assembled from the classic
  histogram series of the queried metric name.

NHClassicCompatQuerier dispatches every Select to exactly one of the two
conversions, both of them wrapping the original querier, so converted
series are never converted back. The new flag cannot be combined with
either of the existing ones.

The PromQL tests cover classic only, NHCB only and exponential only
metrics, a partially migrated metric, exponential histograms with
different buckets aggregated by le, a schema change, staleness in both
directions, and the collisions of overlapping representations.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Describe how to turn the three prototype feature flags into one feature:
one flag with the representations to convert from, per selector control
and debugging with __convert_stored_as__ and __debug_stored_as__
matchers, and stored data winning over converted data.

The proposal lives next to the code for now.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…n package

Move the NHCB as classic, classic as NHCB and combined queriers, their
tests and the PromQL tests of the compatibility layer from the storage
and promql packages to a new storage/histogramconv package, without
behaviour changes. The package gets its own copies of the storage
package's unexported sample types and test mocks.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…ram-conversion

The three prototype feature flags are replaced with a single
promql-histogram-conversion feature flag and a
--query.convert-histograms-from flag, which lists the representations
(classic, nhcb, nhe) to convert from. Nothing is converted by default.

Conversion is now an engine option that wraps the querier of each query,
instead of wrapping the local storage under the fanout. Data from remote
read storage is converted too, and the remote read endpoint no longer
returns converted series.

Only selectors with a single equality metric name matcher are converted,
regex name matchers are not anymore.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…s__ matchers

Matchers on __convert_stored_as__ select the representations (classic,
nhcb, nhe) a selector reads, stored or converted, and override
--query.convert-histograms-from for that selector. E.g.
foo_bucket{__convert_stored_as__="nhcb"} only returns the classic series
converted from NHCB, and foo{__convert_stored_as__="nhe"} only the stored
exponential histograms. Stored series that change to a representation the
selector does not read are marked stale there.

__debug_stored_as__="true" adds a normal __stored_as__ label with the
representation each series is stored as, splitting stored series whose
representation changes.

Both control labels are removed before selecting from the storage. A
selector still needs a non-empty matcher besides them.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Where a selector read a histogram both stored and converted, e.g. in a
range covering a migration or while always_scrape_classic_histograms is
enabled, both were returned. Queries failed with "vector cannot contain
metrics with the same labelset", and aggregations counted the histogram
twice.

Now stored data wins. Converted samples are dropped at the timestamps of
the stored samples of the same histogram, which for classic histograms
means the same labels except le, and the rest is merged into the stored
series with the same labels. A converted series gets a staleness marker
where the stored histogram takes over, so that buckets converted from an
exponential histogram don't linger until the end of the lookback window.
A staleness marker of one side of a merged series only ends that side,
so the markers the scrape loop writes when a histogram switches
representation don't hide the other one.

Selectors with a le matcher do one more select to find the timestamps of
the stored histogram. Series are now returned sorted by labels.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…ionFrom

EnableHistogramConversion was redundant with HistogramConversionFrom, so
it's removed, and an empty HistogramConversionFrom disables query-time
histogram conversion.

This means --enable-feature=promql-histogram-conversion no longer enables
the __convert_stored_as__ and __debug_stored_as__ matchers on its own. It
has no effect, and logs a warning, until --query.convert-histograms-from
lists at least one representation.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…as__=""

A __convert_stored_as__ matcher that matched none of the representations
selected nothing, including __convert_stored_as__="". Now matchers that
match the empty value, but none of the representations, turn the
conversion off for the selector, which returns the stored series as they
are. That's a safe way to rule out the conversion, e.g. when debugging a
query, and it's also what such a selector returns where the feature is
disabled, as no stored series has the label.

__debug_stored_as__="true" still adds the __stored_as__ label.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…owners

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Comment thread storage/histogramconv/querier.go Outdated
Comment thread model/histogram/convert.go Outdated
Comment thread model/histogram/convert.go
Comment thread storage/histogramconv/merge.go
Comment thread storage/histogramconv/merge.go Outdated
Comment thread storage/histogramconv/merge.go Outdated
Comment thread storage/histogramconv/to_classic.go Outdated
Comment thread storage/histogramconv/to_nhcb.go Outdated
Comment thread model/histogram/convert_test.go Outdated
Comment thread storage/histogramconv/to_nhcb_test.go Outdated
The querier selected the series to convert from, and the stored series
without the le matchers, on the first Next of the returned series set.
The PromQL engine wraps the fanout querier, which merges remote read
storage as secondary queriers, and those panic if Select is called after
the first Next of any series set they returned. So every converting
selector panicked with remote_read configured.

Select now selects all series upfront, and Next only reads them. The
cost is that a _bucket selector with le matchers that converts from
native histograms selects the stored series without the le matchers,
even if nothing ends up converted.

Also document that only PromQL uses the querier, so the remote read
endpoint returns stored data only.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
The cache holds full label sets, including e.g. job and instance, but it
was only reset when the metric name changed. Reusing it for another
series of the same metric emitted the labels of the previous series.
Reset it whenever the labels change instead.

TestConvertExponentialToClassic now shares one cache across all cases,
which fails without the fix.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
AllBucketIterator returns the empty buckets within spans, e.g. the ones
the TSDB keeps. AppendClassicBoundaries added their boundaries, so the
result depended on the span layout. An empty bucket within the zero
bucket, whose lower boundary the iterator clamps to the zero threshold,
even added a boundary within the zero bucket, which is neither exact nor
ascending, so ConvertExponentialToClassic rejected the boundaries.

Skip empty buckets, so they count as a gap.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
TestConvertNHCBToClassicHistogram_CacheMatchesNoCache claimed to re-run
every case of TestConvertNHCBToClassicHistogram through a cache, but only
converted one histogram. Run every case of the table for every
onlySuffix, with and without a cache shared across all cases, like
TestConvertExponentialToClassic does, and drop the separate test.

Also update the benchmark comment, which still referred to
storage/nhcb_querier.go.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
No test case gave the stored series of a classic histogram different
timestamps, so the union of their timestamps in addTimestamps was not
covered.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
… from shadow

Where shadow dropped the first converted samples, or replaced a dropped
one with a staleness marker, a staleness marker of the converted series
right after it was returned as the first sample, or right after another
staleness marker.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
mergeSamples returned a staleness marker right after another one, e.g.
where the stored side went stale and the converted side went stale after
it. Drop staleness markers that would be returned first or right after
another one.

Where the merged samples switch between stored and converted data, the
counter reset hint of a native histogram refers to the sample before it
on its own side, e.g. the TSDB returns not_reset for all but the first
histogram of a chunk. Reset such hints to unknown, unless the histogram
is a gauge histogram, like the chain sample iterator of
storage.ChainedSeriesMerge does.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
toClassic and toNHCB dropped the start timestamps of the samples they
converted, unlike the stored samples, so functions like rate() couldn't
use them with UseStartTimestamps enabled.

Classic histogram samples converted from a native histogram get its
start timestamp. An NHCB gets the latest start timestamp of the classic
histogram samples it was converted from, which usually all have the same
one.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
toClassic only validated native histograms when converting them, after
it derived the classic bucket boundaries of exponential histograms. So a
corrupt stored histogram, e.g. with fewer buckets than its spans need,
made CopyToSchema panic.

Validate native histograms before anything reads their buckets, and skip
invalid ones like the classic histograms that cannot be converted to
NHCB: the series converted from them are marked stale, and a new
NativeToClassicConversionWarning annotation reports them.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…rkers

toNHCB marked the NHCB stale at every timestamp where all of its classic
series were stale. So it returned leading and consecutive staleness
markers where the classic series had them, or had them around a classic
histogram that could not be converted.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
seriesSummary only printed sample timestamps, and the stored NHCB of the
"stored native histogram wins" case had the same values as the classic
histogram at that timestamp. So the test passed even if converted data
overwrote stored data.

Use samplesSummary, which prints the values, give the stored NHCB other
values than the classic histogram, and drop seriesSummary.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Selectors with le matchers now select the stored series without them
upfront, even if nothing is converted.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Previously, classic _bucket queries with le matchers issued an extra
Select without the le matchers just to collect the timestamps of all
stored classic buckets so stored-wins could suppress converted buckets
whose le value did not exist in the stored classic histogram.

Remove that extra Select and use only the timestamps of the stored
series actually selected by the query. If a query asks for a bucket
bound that does not exist as a stored classic series, converted buckets
for that bound are returned at timestamps where the source native
histogram has samples.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
…where possible

Previously, every converted selector issued two Select calls to the
wrapped querier: one for the stored series and one for the series to
convert from.

When selecting native histograms, or classic histogram series without le
matchers, select both the stored series and the series to convert from
in a single Select call using a metric name regexp matcher such as
__name__=~"foo(|_bucket)" or __name__=~"foo(|_bucket|_count|_sum)".
These patterns decompose into exact set matches in FastRegexMatcher, so
the TSDB looks up their postings directly. Only selectors for classic
histogram series with le matchers still issue a second Select call, so
the storage filters the stored series by le.

Signed-off-by: Bartek Plotka <bwplotka@google.com>
Signed-off-by: Bartek Plotka <bwplotka@google.com>
@bwplotka bwplotka closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants