From ec09ee860eb42526fcbde6828b0f9fe4ab64469e Mon Sep 17 00:00:00 2001 From: Bastrykov Evgeniy Date: Fri, 24 Jul 2026 01:33:19 +0400 Subject: [PATCH 1/4] querier: release LSS query result buffers deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C-allocated series-id result buffer returned by Querier::query was kept alive by the series sets and only freed lazily by a Go GC finalizer. Because the real bytes live off the Go heap (jemalloc), the GC sees almost no pressure and finalization lags, so thousands of over-allocated buffers (capacity = max matcher cardinality) pile up — the dominant on-heap consumer in heap profiles. Make every series set independent of LSSQueryResult so it can be closed right after construction: - Add idempotent LSSQueryResult.Close() (frees the C buffers, cancels the finalizer which stays a harmless fallback). - InstantSeries carries the series id inline next to its sample; mirror the field in the C++ SampleWithGoLabels struct so the shared ABI still matches. - StaleNaNSeries carries the series id inline. - ChunkSeriesSet takes the series id straight from the recoded chunks and no longer references the result at all. - Close the query results in selectInstant/selectRange and ChunkQuerier.Select once all in-flight C queries have completed. Co-authored-by: Cursor --- pp/entrypoint/types/querier.h | 3 +++ pp/go/cppbridge/lss_snapshot.go | 18 +++++++++++++-- pp/go/storage/querier/chunk_querier.go | 4 +++- pp/go/storage/querier/chunk_series.go | 24 +++----------------- pp/go/storage/querier/instant_series.go | 19 ++++++++++++---- pp/go/storage/querier/querier.go | 19 ++++++++++++++++ pp/go/storage/querier/stalenan_series_set.go | 14 ++++++++---- 7 files changed, 69 insertions(+), 32 deletions(-) diff --git a/pp/entrypoint/types/querier.h b/pp/entrypoint/types/querier.h index e0081876f9..7bedf1c416 100644 --- a/pp/entrypoint/types/querier.h +++ b/pp/entrypoint/types/querier.h @@ -59,6 +59,9 @@ class InstantQuerierWithArgumentsWrapper { struct SampleWithGoLabels : public ::series_data::encoder::Sample { private: + // series_id_ and go_labels_ are written from the Go side (querier.InstantSeries.SeriesID + // and .LabelSet); C++ only fills the Sample base. They must mirror the Go struct layout. + uint32_t series_id_; char go_labels_[Sizeof_GoLabels]; }; diff --git a/pp/go/cppbridge/lss_snapshot.go b/pp/go/cppbridge/lss_snapshot.go index 32995e8907..00ad173a2e 100644 --- a/pp/go/cppbridge/lss_snapshot.go +++ b/pp/go/cppbridge/lss_snapshot.go @@ -158,18 +158,32 @@ func newLSSQueryResult( } if status != LSSQueryStatusMatch { - primitivesLabelSetMatchesFree(lqr) + lqr.Close() return lqr } runtime.SetFinalizer(lqr, func(result *LSSQueryResult) { - primitivesLabelSetMatchesFree(result) + result.Close() }) return lqr } +// Close frees the C-allocated result buffers and cancels the finalizer. +// It is idempotent: subsequent calls (and the finalizer) are no-ops. +// After Close the result must not be read anymore. +func (r *LSSQueryResult) Close() { + if r.matches == nil && r.labelSetLengths == nil { + return + } + + runtime.SetFinalizer(r, nil) + primitivesLabelSetMatchesFree(r) + r.matches = nil + r.labelSetLengths = nil +} + func (r *LSSQueryResult) IndexOf(seriesID uint32) int { for i, match := range r.matches { if match == seriesID { diff --git a/pp/go/storage/querier/chunk_querier.go b/pp/go/storage/querier/chunk_querier.go index e943779be8..1653fd6bcf 100644 --- a/pp/go/storage/querier/chunk_querier.go +++ b/pp/go/storage/querier/chunk_querier.go @@ -142,6 +142,9 @@ func (q *ChunkQuerier[TTask, TDataStorage, TLSS, TShard, THead]) Select( defer poolProvider.PutSnapshots(snapshots) lssQueryResults := poolProvider.GetLSSQueryResults() defer poolProvider.PutLSSQueryResults(lssQueryResults) + // The chunk series sets take the series id from the recoded chunks and do not + // reference the query result buffers, so they can be released on return. + defer closeLSSQueryResults(lssQueryResults) if err = queryLss(lssQueryChunkQuerySelector, q.head, matchers, snapshots, lssQueryResults); err != nil { logger.Warnf("[ChunkQuerier]: failed: %s", err) @@ -162,7 +165,6 @@ func (q *ChunkQuerier[TTask, TDataStorage, TLSS, TShard, THead]) Select( } chunkSeriesSets[shardID] = NewChunkSeriesSet( - lssQueryResults[shardID], snapshots[shardID], cppbridge.NewSerializedChunkRecoder(serializedData, cppbridge.TimeInterval{MinT: q.mint, MaxT: q.maxt}), ) diff --git a/pp/go/storage/querier/chunk_series.go b/pp/go/storage/querier/chunk_series.go index f87e785ed4..2b83f661b7 100644 --- a/pp/go/storage/querier/chunk_series.go +++ b/pp/go/storage/querier/chunk_series.go @@ -15,11 +15,9 @@ import ( // ChunkSeriesSet contains a set of chunked series. type ChunkSeriesSet struct { - lssQueryResult *cppbridge.LSSQueryResult labelSetSnapshot *cppbridge.LabelSetSnapshot chunkRecoder *cppbridge.ChunkRecoder - index int lastRecodedChunk *cppbridge.RecodedChunk chunkSeries *ChunkSeries @@ -27,13 +25,13 @@ type ChunkSeriesSet struct { } // NewChunkSeriesSet init new [ChunkSeriesSet]. +// The series id is taken directly from the recoded chunks during iteration, so the +// query result buffer does not need to be retained (see [cppbridge.LSSQueryResult.Close]). func NewChunkSeriesSet( - lssQueryResult *cppbridge.LSSQueryResult, labelSetSnapshot *cppbridge.LabelSetSnapshot, chunkRecoder *cppbridge.ChunkRecoder, ) *ChunkSeriesSet { return &ChunkSeriesSet{ - lssQueryResult: lssQueryResult, labelSetSnapshot: labelSetSnapshot, chunkRecoder: chunkRecoder, } @@ -72,24 +70,8 @@ func (css *ChunkSeriesSet) Next() bool { css.lastRecodedChunk = nil } - var lsID uint32 - - for { - if css.index >= css.lssQueryResult.Len() { - return false - } - - lsID, _ = css.lssQueryResult.GetByIndex(css.index) - - if lsID == seriesID { - break - } - - css.index++ - } - css.chunkSeries = &ChunkSeries{ - labelSet: labels.NewLabelsWithLSS(css.labelSetSnapshot, lsID), + labelSet: labels.NewLabelsWithLSS(css.labelSetSnapshot, seriesID), recodedChunks: recodedChunks, } diff --git a/pp/go/storage/querier/instant_series.go b/pp/go/storage/querier/instant_series.go index 3acfae38db..296e3dac3d 100644 --- a/pp/go/storage/querier/instant_series.go +++ b/pp/go/storage/querier/instant_series.go @@ -24,7 +24,6 @@ func NewInstantSeriesSlice(size int, defaultTimestamp int64) []InstantSeries { // InstantSeriesSet contains a instant set of series, allows to iterate over sorted, populated series. type InstantSeriesSet struct { - lssQueryResult *cppbridge.LSSQueryResult labelSetSnapshot *cppbridge.LabelSetSnapshot valueNotFoundTimestampValue int64 nextSeriesIndex int @@ -32,14 +31,21 @@ type InstantSeriesSet struct { } // NewInstantSeriesSet init new [InstantSeriesSet]. +// The series ids from lssQueryResult are stored inline in instantSeries (next to the +// data, like serializedData), so the C-allocated result buffer is no longer referenced +// after construction and can be released immediately (see [cppbridge.LSSQueryResult.Close]). func NewInstantSeriesSet( lssQueryResult *cppbridge.LSSQueryResult, labelSetSnapshot *cppbridge.LabelSetSnapshot, valueNotFoundTimestampValue int64, instantSeries []InstantSeries, ) *InstantSeriesSet { + ids := lssQueryResult.IDs() + for i := range instantSeries { + instantSeries[i].SeriesID = ids[i] + } + return &InstantSeriesSet{ - lssQueryResult: lssQueryResult, labelSetSnapshot: labelSetSnapshot, valueNotFoundTimestampValue: valueNotFoundTimestampValue, instantSeries: instantSeries, @@ -70,8 +76,8 @@ func (ss *InstantSeriesSet) Next() bool { ss.nextSeriesIndex++ } - lsID, _ := ss.lssQueryResult.GetByIndex(ss.nextSeriesIndex) - ss.instantSeries[ss.nextSeriesIndex].LabelSet = labels.NewLabelsWithLSS(ss.labelSetSnapshot, lsID) + series := &ss.instantSeries[ss.nextSeriesIndex] + series.LabelSet = labels.NewLabelsWithLSS(ss.labelSetSnapshot, series.SeriesID) ss.nextSeriesIndex++ return true @@ -87,9 +93,14 @@ func (*InstantSeriesSet) Warnings() annotations.Annotations { // // InstantSeries is a instant stream of data points belonging to a metric. +// NOTE: this struct is shared with C++ (entrypoint::types::SampleWithGoLabels): +// the data storage instant query writes Timestamp/Value into it by pointer, so its +// layout must stay in sync with the C++ side. Any field added here must be mirrored +// in SampleWithGoLabels (SeriesID -> series_id_, LabelSet -> go_labels_). type InstantSeries struct { Timestamp int64 Value float64 + SeriesID uint32 LabelSet labels.Labels } diff --git a/pp/go/storage/querier/querier.go b/pp/go/storage/querier/querier.go index 3862b3b521..8ae9c6b03b 100644 --- a/pp/go/storage/querier/querier.go +++ b/pp/go/storage/querier/querier.go @@ -306,6 +306,9 @@ func (q *Querier[TTask, TDataStorage, TLSS, TShard, THead]) selectInstant( defer poolProvider.PutSnapshots(snapshots) lssQueryResults := poolProvider.GetLSSQueryResults() defer poolProvider.PutLSSQueryResults(lssQueryResults) + // The instant series sets copy out the series ids they need, so the query result + // buffers can be released on return (after all in-flight C queries have completed). + defer closeLSSQueryResults(lssQueryResults) if err = queryLss(lssQueryInstantQuerySelector, q.head, matchers, snapshots, lssQueryResults); err != nil { logger.Warnf("[QUERIER]: failed to instant: %s", err) @@ -396,6 +399,10 @@ func (q *Querier[TTask, TDataStorage, TLSS, TShard, THead]) selectRange( defer poolProvider.PutSnapshots(snapshots) lssQueryResults := poolProvider.GetLSSQueryResults() defer poolProvider.PutLSSQueryResults(lssQueryResults) + // Range/aggr series sets only use the result length at construction, so the query + // result buffers can be released on return (queryDataStorage has already waited for + // all in-flight C queries by then). + defer closeLSSQueryResults(lssQueryResults) if err = queryLss(lssQueryRangeQuerySelector, q.head, matchers, snapshots, lssQueryResults); err != nil { logger.Warnf("[QUERIER]: failed to range: %s", err) @@ -720,6 +727,18 @@ func queryLabelValues[ return lvs, anns, nil } +// closeLSSQueryResults releases the C-allocated buffers held by the query results. +// It is safe to call once every series set built from these results has copied out +// (or no longer needs) the data it references. Nil entries are skipped and Close is +// idempotent, so the finalizer stays a harmless fallback. +func closeLSSQueryResults(lssQueryResults []*cppbridge.LSSQueryResult) { + for _, lssQueryResult := range lssQueryResults { + if lssQueryResult != nil { + lssQueryResult.Close() + } + } +} + // queryLss returns query results and snapshots. // //revive:disable-next-line:cyclomatic but readable. diff --git a/pp/go/storage/querier/stalenan_series_set.go b/pp/go/storage/querier/stalenan_series_set.go index f1e6db153e..0d9fb2b631 100644 --- a/pp/go/storage/querier/stalenan_series_set.go +++ b/pp/go/storage/querier/stalenan_series_set.go @@ -42,22 +42,28 @@ func NewStaleNaNSeriesSliceFromTimestamps(timestamps []int64) []StaleNaNSeries { // StaleNaNSeriesSet contains a set of series that always return the staleNaN value for the specified timestamps. type StaleNaNSeriesSet struct { series []StaleNaNSeries - lssQueryResult *cppbridge.LSSQueryResult labelSetSnapshot *cppbridge.LabelSetSnapshot valueNotFoundTimestampValue int64 nextSeriesIndex int } // NewStaleNaNSeriesSet creates a new [StaleNaNSeriesSet]. +// The series ids from lssQueryResult are copied into the (Go-owned) series slice so that +// the C-allocated result buffer is no longer referenced after construction and can be +// released immediately (see [cppbridge.LSSQueryResult.Close]). func NewStaleNaNSeriesSet( series []StaleNaNSeries, lssQueryResult *cppbridge.LSSQueryResult, labelSetSnapshot *cppbridge.LabelSetSnapshot, valueNotFoundTimestampValue int64, ) *StaleNaNSeriesSet { + ids := lssQueryResult.IDs() + for i := range series { + series[i].seriesID = ids[i] + } + return &StaleNaNSeriesSet{ series: series, - lssQueryResult: lssQueryResult, labelSetSnapshot: labelSetSnapshot, valueNotFoundTimestampValue: valueNotFoundTimestampValue, } @@ -90,8 +96,7 @@ func (ss *StaleNaNSeriesSet) Next() bool { ss.nextSeriesIndex++ } - lsID, _ := ss.lssQueryResult.GetByIndex(ss.nextSeriesIndex) - ss.series[ss.nextSeriesIndex].labelSet = labels.NewLabelsWithLSS(ss.labelSetSnapshot, lsID) + ss.series[ss.nextSeriesIndex].labelSet = labels.NewLabelsWithLSS(ss.labelSetSnapshot, ss.series[ss.nextSeriesIndex].seriesID) ss.nextSeriesIndex++ return true @@ -111,6 +116,7 @@ func (*StaleNaNSeriesSet) Warnings() annotations.Annotations { type StaleNaNSeries struct { timestamp int64 labelSet labels.Labels + seriesID uint32 } // Iterator returns an iterator that iterates over the samples of the series. From 8743571584b31517dc6e6aee49933442ebf224f3 Mon Sep 17 00:00:00 2001 From: Bastrykov Evgeniy Date: Fri, 24 Jul 2026 09:18:09 +0400 Subject: [PATCH 2/4] querier: fill instant series id in C++ next to the sample Instead of copying the series ids on the Go side after the query, the instant querier now writes the label-set id into SampleWithGoLabels.series_id in the same loop that fills the sample. store_series_id is a no-op for plain encoder::Sample (used in C++ tests) via an if-constexpr guard, so the generic querier is unaffected. NewInstantSeriesSet no longer needs the query result. Co-authored-by: Cursor --- pp/entrypoint/types/querier.h | 7 ++++--- pp/go/storage/querier/instant_series.go | 12 +++--------- pp/go/storage/querier/querier.go | 1 - pp/go/storage/storagetest/fixtures.go | 2 +- pp/series_data/querier/instant_querier.h | 10 ++++++++++ 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/pp/entrypoint/types/querier.h b/pp/entrypoint/types/querier.h index 7bedf1c416..5de0881060 100644 --- a/pp/entrypoint/types/querier.h +++ b/pp/entrypoint/types/querier.h @@ -58,10 +58,11 @@ class InstantQuerierWithArgumentsWrapper { }; struct SampleWithGoLabels : public ::series_data::encoder::Sample { + // series_id is filled by the instant querier alongside the sample; go_labels_ is written + // from the Go side (querier.InstantSeries.LabelSet). This layout mirrors the Go struct. + uint32_t series_id; + private: - // series_id_ and go_labels_ are written from the Go side (querier.InstantSeries.SeriesID - // and .LabelSet); C++ only fills the Sample base. They must mirror the Go struct layout. - uint32_t series_id_; char go_labels_[Sizeof_GoLabels]; }; diff --git a/pp/go/storage/querier/instant_series.go b/pp/go/storage/querier/instant_series.go index 296e3dac3d..ddf96a7c60 100644 --- a/pp/go/storage/querier/instant_series.go +++ b/pp/go/storage/querier/instant_series.go @@ -31,20 +31,14 @@ type InstantSeriesSet struct { } // NewInstantSeriesSet init new [InstantSeriesSet]. -// The series ids from lssQueryResult are stored inline in instantSeries (next to the -// data, like serializedData), so the C-allocated result buffer is no longer referenced -// after construction and can be released immediately (see [cppbridge.LSSQueryResult.Close]). +// The series id is filled inline in each instantSeries by the C++ instant query (next to the +// sample), so the query result buffer is not referenced here and can be released right after +// the data storage query (see [cppbridge.LSSQueryResult.Close]). func NewInstantSeriesSet( - lssQueryResult *cppbridge.LSSQueryResult, labelSetSnapshot *cppbridge.LabelSetSnapshot, valueNotFoundTimestampValue int64, instantSeries []InstantSeries, ) *InstantSeriesSet { - ids := lssQueryResult.IDs() - for i := range instantSeries { - instantSeries[i].SeriesID = ids[i] - } - return &InstantSeriesSet{ labelSetSnapshot: labelSetSnapshot, valueNotFoundTimestampValue: valueNotFoundTimestampValue, diff --git a/pp/go/storage/querier/querier.go b/pp/go/storage/querier/querier.go index 8ae9c6b03b..cc18cae0b0 100644 --- a/pp/go/storage/querier/querier.go +++ b/pp/go/storage/querier/querier.go @@ -345,7 +345,6 @@ func (q *Querier[TTask, TDataStorage, TLSS, TShard, THead]) selectInstant( } seriesSets[shardID] = NewInstantSeriesSet( - lssQueryResult, snapshots[shardID], valueNotFoundTimestampValue, instantSeries, diff --git a/pp/go/storage/storagetest/fixtures.go b/pp/go/storage/storagetest/fixtures.go index dd5feb39a3..bfe49a5fd9 100644 --- a/pp/go/storage/storagetest/fixtures.go +++ b/pp/go/storage/storagetest/fixtures.go @@ -286,7 +286,7 @@ func InstantQuery(lss *shard.LSS, ds *shard.DataStorage, targetTimestamp, valueN return nil, fmt.Errorf("invalid data storage query result status") } - return querier.NewInstantSeriesSet(lssQueryResult, snapshot, valueNotFoundTimestampValue, instantSeries), nil + return querier.NewInstantSeriesSet(snapshot, valueNotFoundTimestampValue, instantSeries), nil } const ( diff --git a/pp/series_data/querier/instant_querier.h b/pp/series_data/querier/instant_querier.h index ca9aae7776..50caf03b29 100644 --- a/pp/series_data/querier/instant_querier.h +++ b/pp/series_data/querier/instant_querier.h @@ -24,6 +24,7 @@ class InstantQuerier { assert(std::size(samples) == std::size(label_set_ids)); for (auto&& [sample, ls_id] : std::ranges::views::zip(samples, label_set_ids)) { + store_series_id(sample, ls_id); query_sample(sample, ls_id, timestamp); } } @@ -48,6 +49,15 @@ class InstantQuerier { DataStorage& storage_; BareBones::Bitset series_to_load_; + // store_series_id records the label-set id next to the sample when the sample type carries it + // (entrypoint::types::SampleWithGoLabels). For plain encoder::Sample it is a no-op. + template + PROMPP_ALWAYS_INLINE static void store_series_id(SampleType& sample, LabelSetID ls_id) noexcept { + if constexpr (requires { sample.series_id = ls_id; }) { + sample.series_id = ls_id; + } + } + PROMPP_ALWAYS_INLINE void query_sample(Sample& sample, LabelSetID ls_id, const Timestamp& timestamp) noexcept { if (storage_.open_chunks.size() <= ls_id || storage_.open_chunks[ls_id].is_empty()) [[unlikely]] { return; From f56334c6dadafbc5c2ce0c743f8c937bb024ede7 Mon Sep 17 00:00:00 2001 From: Bastrykov Evgeniy Date: Fri, 24 Jul 2026 09:37:57 +0400 Subject: [PATCH 3/4] querier: fill stalenan series id in C++ next to the timestamp Mirror the instant-query optimization for the stalenan path: the C++ data storage query now writes both the first-sample timestamp and the series id directly into a C-shared StaleNaNSeries slice, so the LSS query result buffer is no longer referenced after construction and can be released immediately. Co-authored-by: Cursor --- .../bridge/series_data_data_storage.cpp | 21 ++++++++++ .../bridge/series_data_data_storage.h | 12 ++++++ pp/entrypoint/types/querier.h | 10 +++++ pp/go/cppbridge/data_storage.go | 7 ++++ pp/go/cppbridge/entrypoint.go | 14 +++++++ pp/go/cppbridge/entrypoint.h | 12 ++++++ pp/go/storage/head/shard/data_storage.go | 8 ++++ pp/go/storage/querier/stalenan_series_set.go | 38 +++++++------------ pp/go/storage/storagetest/fixtures.go | 17 ++++----- 9 files changed, 106 insertions(+), 33 deletions(-) diff --git a/pp/entrypoint/bridge/series_data_data_storage.cpp b/pp/entrypoint/bridge/series_data_data_storage.cpp index 62026ed52a..9578e90527 100644 --- a/pp/entrypoint/bridge/series_data_data_storage.cpp +++ b/pp/entrypoint/bridge/series_data_data_storage.cpp @@ -1,6 +1,8 @@ #include "series_data_data_storage.h" #include +#include +#include #include #include "entrypoint/types/data_storage.h" @@ -266,6 +268,25 @@ extern "C" void prompp_series_data_data_storage_query_first_timestamps(void* arg [&storage](uint32_t series_id) { return Decoder::get_series_min_timestamp(storage, series_id); }); } +extern "C" void prompp_series_data_data_storage_query_stalenan_series(void* args) { + using series_data::Decoder; + + struct Arguments { + DataStoragePtr data_storage; + SliceView series_ids; + entrypoint::types::StaleNaNSeriesWithGoLabels* series; + }; + + const auto in = static_cast(args); + const auto& storage = *in->data_storage; + + auto series = std::span(in->series, in->series_ids.size()); + for (auto&& [stalenan_series, series_id] : std::ranges::views::zip(series, in->series_ids)) { + stalenan_series.timestamp = Decoder::get_series_min_timestamp(storage, series_id); + stalenan_series.series_id = series_id; + } +} + extern "C" void prompp_series_data_data_storage_allocated_memory(void* args, void* res) { struct Arguments { DataStoragePtr data_storage; diff --git a/pp/entrypoint/bridge/series_data_data_storage.h b/pp/entrypoint/bridge/series_data_data_storage.h index 2b5c9f721e..622c230e55 100644 --- a/pp/entrypoint/bridge/series_data_data_storage.h +++ b/pp/entrypoint/bridge/series_data_data_storage.h @@ -181,6 +181,18 @@ void prompp_series_data_data_storage_instant_query(void* args, void* res); */ void prompp_series_data_data_storage_query_first_timestamps(void* args, void* res); +/** + * @brief Fill stalenan series (first sample timestamp + series id) per series id. + * + * @param args { + * dataStorage uintptr // pointer to constructed data storage + * seriesIds []uint32 // series ids + * series uintptr // pointer to []querier.StaleNaNSeries (same length as seriesIds); + * // timestamp and seriesID fields are filled from storage + * } + */ +void prompp_series_data_data_storage_query_stalenan_series(void* args); + /** * @brief finishes all Queriers after data load. * diff --git a/pp/entrypoint/types/querier.h b/pp/entrypoint/types/querier.h index 5de0881060..a2236237d5 100644 --- a/pp/entrypoint/types/querier.h +++ b/pp/entrypoint/types/querier.h @@ -66,6 +66,16 @@ struct SampleWithGoLabels : public ::series_data::encoder::Sample { char go_labels_[Sizeof_GoLabels]; }; +struct StaleNaNSeriesWithGoLabels { + // timestamp and series_id are filled by the data storage stalenan query; go_labels_ is + // written from the Go side (querier.StaleNaNSeries.labelSet). This layout mirrors the Go struct. + PromPP::Primitives::Timestamp timestamp; + uint32_t series_id; + + private: + char go_labels_[Sizeof_GoLabels]; +}; + using InstantQuerierWithArgumentsWrapperEntrypoint = InstantQuerierWithArgumentsWrapper, std::span>; using GoSelectHints = PromPP::Prometheus::GenericSelectHints; diff --git a/pp/go/cppbridge/data_storage.go b/pp/go/cppbridge/data_storage.go index edc520faad..da8995dc89 100644 --- a/pp/go/cppbridge/data_storage.go +++ b/pp/go/cppbridge/data_storage.go @@ -126,6 +126,13 @@ func (ds *DataStorage) QueryFirstTimestamps(seriesIDs []uint32, timestamps []int runtime.KeepAlive(ds) } +// QueryStaleNaNSeries fills the first sample timestamp (Prometheus ms) and the series id for each +// series in seriesIDs directly into the C-shared series slice (pointed to by series). +func (ds *DataStorage) QueryStaleNaNSeries(seriesIDs []uint32, series uintptr) { + seriesDataDataStorageQueryStaleNaNSeries(ds.dataStorage, seriesIDs, series) + runtime.KeepAlive(ds) +} + func (ds *DataStorage) QueryFinal(queriers []uintptr) { seriesDataDataStorageQueryFinal(queriers) runtime.KeepAlive(queriers) diff --git a/pp/go/cppbridge/entrypoint.go b/pp/go/cppbridge/entrypoint.go index e90c73a9ec..67629432b0 100644 --- a/pp/go/cppbridge/entrypoint.go +++ b/pp/go/cppbridge/entrypoint.go @@ -2214,6 +2214,20 @@ func seriesDataDataStorageQueryFirstTimestamps(dataStorage uintptr, seriesIDs [] ) } +func seriesDataDataStorageQueryStaleNaNSeries(dataStorage uintptr, seriesIDs []uint32, series uintptr) { + args := struct { + dataStorage uintptr + seriesIDs []uint32 + series uintptr + }{dataStorage, seriesIDs, series} + + testGC() + fastcgo.UnsafeCall1( + C.prompp_series_data_data_storage_query_stalenan_series, + uintptr(unsafe.Pointer(&args)), + ) +} + func seriesDataDataStorageQueryFinal(queriers []uintptr) { args := struct { queriers []uintptr diff --git a/pp/go/cppbridge/entrypoint.h b/pp/go/cppbridge/entrypoint.h index 802a8480ec..4d6d7a5083 100755 --- a/pp/go/cppbridge/entrypoint.h +++ b/pp/go/cppbridge/entrypoint.h @@ -1746,6 +1746,18 @@ void prompp_series_data_data_storage_instant_query(void* args, void* res); */ void prompp_series_data_data_storage_query_first_timestamps(void* args, void* res); +/** + * @brief Fill stalenan series (first sample timestamp + series id) per series id. + * + * @param args { + * dataStorage uintptr // pointer to constructed data storage + * seriesIds []uint32 // series ids + * series uintptr // pointer to []querier.StaleNaNSeries (same length as seriesIds); + * // timestamp and seriesID fields are filled from storage + * } + */ +void prompp_series_data_data_storage_query_stalenan_series(void* args); + /** * @brief finishes all Queriers after data load. * diff --git a/pp/go/storage/head/shard/data_storage.go b/pp/go/storage/head/shard/data_storage.go index 920de99963..cc1f1bfc05 100644 --- a/pp/go/storage/head/shard/data_storage.go +++ b/pp/go/storage/head/shard/data_storage.go @@ -96,6 +96,14 @@ func (ds *DataStorage) QueryFirstTimestamps(ids []uint32, timestamps []int64) { ds.locker.RUnlock() } +// QueryStaleNaNSeries fills the first sample timestamp (Prometheus ms) and the series id for each +// series in ids directly into the C-shared series slice (pointed to by series). +func (ds *DataStorage) QueryStaleNaNSeries(ids []uint32, series uintptr) { + ds.locker.RLock() + ds.dataStorage.QueryStaleNaNSeries(ids, series) + ds.locker.RUnlock() +} + // QueryStatus get head status from [DataStorage]. func (ds *DataStorage) QueryStatus(status *cppbridge.HeadStatus) { ds.locker.RLock() diff --git a/pp/go/storage/querier/stalenan_series_set.go b/pp/go/storage/querier/stalenan_series_set.go index 0d9fb2b631..d52b7d218e 100644 --- a/pp/go/storage/querier/stalenan_series_set.go +++ b/pp/go/storage/querier/stalenan_series_set.go @@ -15,21 +15,13 @@ import ( // floatStaleNaN is the float64 representation of the [value.StaleNaN] value. var floatStaleNaN = math.Float64frombits(value.StaleNaN) -// MakeTimestampsSliceWithDefault creates a slice with the default timestamp. -func MakeTimestampsSliceWithDefault(size int, defaultTimestamp int64) []int64 { - timestamps := make([]int64, size) - for i := range timestamps { - timestamps[i] = defaultTimestamp - } - - return timestamps -} - -// NewStaleNaNSeriesSliceFromTimestamps creates [StaleNaNSeries] slice from timestamps. -func NewStaleNaNSeriesSliceFromTimestamps(timestamps []int64) []StaleNaNSeries { - seriesSlice := make([]StaleNaNSeries, len(timestamps)) +// NewStaleNaNSeriesSlice creates a [StaleNaNSeries] slice and sets the default timestamp to each series. +// The timestamp and seriesID fields are then filled in place by the C++ stalenan query +// (see [cppbridge.DataStorage.QueryStaleNaNSeries]). +func NewStaleNaNSeriesSlice(size int, defaultTimestamp int64) []StaleNaNSeries { + seriesSlice := make([]StaleNaNSeries, size) for i := range seriesSlice { - seriesSlice[i].timestamp = timestamps[i] + seriesSlice[i].timestamp = defaultTimestamp } return seriesSlice @@ -48,20 +40,14 @@ type StaleNaNSeriesSet struct { } // NewStaleNaNSeriesSet creates a new [StaleNaNSeriesSet]. -// The series ids from lssQueryResult are copied into the (Go-owned) series slice so that -// the C-allocated result buffer is no longer referenced after construction and can be -// released immediately (see [cppbridge.LSSQueryResult.Close]). +// The seriesID is filled inline in each series by the C++ stalenan query (next to the +// timestamp), so the query result buffer is not referenced here and can be released right +// after the data storage query (see [cppbridge.LSSQueryResult.Close]). func NewStaleNaNSeriesSet( series []StaleNaNSeries, - lssQueryResult *cppbridge.LSSQueryResult, labelSetSnapshot *cppbridge.LabelSetSnapshot, valueNotFoundTimestampValue int64, ) *StaleNaNSeriesSet { - ids := lssQueryResult.IDs() - for i := range series { - series[i].seriesID = ids[i] - } - return &StaleNaNSeriesSet{ series: series, labelSetSnapshot: labelSetSnapshot, @@ -113,10 +99,14 @@ func (*StaleNaNSeriesSet) Warnings() annotations.Annotations { // // StaleNaNSeries is a series that always returns the staleNaN value for the specified timestamps. +// NOTE: this struct is shared with C++ (entrypoint::types::StaleNaNSeriesWithGoLabels): +// the data storage stalenan query writes timestamp/seriesID into it by pointer, so its +// layout must stay in sync with the C++ side (timestamp -> timestamp, seriesID -> series_id, +// labelSet -> go_labels_). type StaleNaNSeries struct { timestamp int64 - labelSet labels.Labels seriesID uint32 + labelSet labels.Labels } // Iterator returns an iterator that iterates over the samples of the series. diff --git a/pp/go/storage/storagetest/fixtures.go b/pp/go/storage/storagetest/fixtures.go index bfe49a5fd9..79f03f8663 100644 --- a/pp/go/storage/storagetest/fixtures.go +++ b/pp/go/storage/storagetest/fixtures.go @@ -342,13 +342,12 @@ func StaleNaNQuery( return &querier.StaleNaNSeriesSet{}, nil } - timestamps := querier.MakeTimestampsSliceWithDefault(lssQueryResult.Len(), valueNotFoundTimestampValue) - ds.QueryFirstTimestamps(lssQueryResult.IDs(), timestamps) - - return querier.NewStaleNaNSeriesSet( - querier.NewStaleNaNSeriesSliceFromTimestamps(timestamps), - lssQueryResult, - snapshot, - valueNotFoundTimestampValue, - ), nil + series := querier.NewStaleNaNSeriesSlice(lssQueryResult.Len(), valueNotFoundTimestampValue) + + ds.QueryStaleNaNSeries( + lssQueryResult.IDs(), + uintptr(unsafe.Pointer(unsafe.SliceData(series))), // #nosec G103 // it's meant to be that way + ) + + return querier.NewStaleNaNSeriesSet(series, snapshot, valueNotFoundTimestampValue), nil } From 19d0c6784d0e7b02a08b978a0ddb69636f531680 Mon Sep 17 00:00:00 2001 From: Bastrykov Evgeniy Date: Wed, 29 Jul 2026 15:35:59 +0200 Subject: [PATCH 4/4] review --- pp/series_data/querier/instant_querier.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/pp/series_data/querier/instant_querier.h b/pp/series_data/querier/instant_querier.h index 50caf03b29..f2d3afa536 100644 --- a/pp/series_data/querier/instant_querier.h +++ b/pp/series_data/querier/instant_querier.h @@ -49,8 +49,6 @@ class InstantQuerier { DataStorage& storage_; BareBones::Bitset series_to_load_; - // store_series_id records the label-set id next to the sample when the sample type carries it - // (entrypoint::types::SampleWithGoLabels). For plain encoder::Sample it is a no-op. template PROMPP_ALWAYS_INLINE static void store_series_id(SampleType& sample, LabelSetID ls_id) noexcept { if constexpr (requires { sample.series_id = ls_id; }) {