Skip to content

arrow: add Prometheus series staleness detection and StalenessTracker - #41

Open
bwplotka wants to merge 14 commits into
mainfrom
bwplotka/staleness-detection
Open

bwplotka wants to merge 14 commits into
mainfrom
bwplotka/staleness-detection

Conversation

@bwplotka

@bwplotka bwplotka commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Adds Prometheus series staleness detection and optional cross-scrape disappeared series tracking to expfmt::arrow::ArrowDecoder.

  • Exports STALE_NAN_BITS, STALE_NAN, DATA_POINT_FLAG_NO_RECORDED_VALUE, and is_stale_nan(f64) in expfmt::arrow.
  • Lazily materializes the optional flags column on NumberDataPoints, SummaryDataPoints, and HistogramDataPoints only when a batch contains a non-zero flag, and omits stale data point values per OTLP FLAG_NO_RECORDED_VALUE semantics rather than writing STALE_NAN into OTAP payloads.
  • Adds opt-in StalenessTracker along with ArrowDecoder::decode_with_staleness, decode_stream_with_staleness, step_decode_with_staleness, finish_decode_with_staleness, and flush_target_stale, with DecodeOptions::track_timestamps_staleness defaulting to false, transactional rollback on failed or aborted scrapes, and dead-series arena compaction under series churn.
  • Static live-heap delta on the 16-target, 159k-series prombenchy otap-rust workload is 0 B when StalenessTracker is not used, or 17.1 MiB in initial steady state and 29.1 MiB at the 2x compaction watermark under 1h full series churn when enabled across all 16 targets.
Static live-heap estimate (prombenchy otap-rust)

Workload from avalanche.exampletarget.yaml and manifests/scenarios/otap-rust/1_collector.yaml:

  • 1 collector pod with --num-cores=1 and max_data_points_per_batch: 200 scraping 16 avalanche pods every 15s.
  • Per target: 514 metric families (158 gauges, 280 counters, 28 classic 10-bucket histograms, 48 2-objective summaries), --series-count=10, 12 labels per series (series_id, cycle_id, and 10 label_key_kkkkk_* labels), and --series-interval=3600 rotating cycle_id every hour.
  • Each target exposes 9,940 Prometheus text series (159,040 across 16 targets), which assemble into 5,140 OTAP data-point series per target (82,240 across 16 targets) because StalenessTracker tracks assembled HistogramDataPoints and SummaryDataPoints rows rather than per-bucket or per-quantile lines.

Stateless mode without StalenessTracker (decode, decode_stream, step_decode):

  • 0 B additional heap when no wire StaleNaN is present because SparseFlagsCol and OptionalValueCol::valid stay at capacity 0 (104 B inline struct fields on the shared ArrowDecoder).

Opt-in StalenessTracker enabled (1 tracker per target, 64-bit Vec doubling capacities and 70% load-factor hash tables):

Structure (per target) Occupied payload Steady-state Vec capacity (0 < t < 1h) 2x churn watermark (1h <= t < 2h) Post-compact() (t >= 2h)
series_attrs ((u32, u32) @ 8 B, 61,680 active pairs) 481.9 KiB 512.0 KiB (cap 65,536) 1,024.0 KiB (cap 131,072) 642.5 KiB (cap 82,240)
family_bytes (514 names + ~361 B # HELP text) 198.8 KiB 256.0 KiB (cap 262,144) 256.0 KiB (cap 262,144) 256.0 KiB (cap 262,144)
series (TrackedSeries @ 16 B, 5,140 active) 80.3 KiB 128.0 KiB (cap 8,192) 256.0 KiB (cap 16,384) 80.3 KiB (cap 5,140)
series_buckets (SeriesBucket @ 16 B) 80.3 KiB 128.0 KiB (cap 8,192) 256.0 KiB (cap 16,384) 256.0 KiB (cap 16,384)
families (TrackedFamily @ 52 B) + family_buckets 42.1 KiB 68.0 KiB (cap 1,024 each) 68.0 KiB (cap 1,024 each) 42.1 KiB (cap 514 / 1,024)
key_interner + val_interner + remap scratch + struct 1.1 KiB 2.4 KiB 2.4 KiB 2.8 KiB
Per target (5,140 OTAP / 9,940 Prom series) 884.5 KiB 1,094.4 KiB (1.07 MiB) 1,862.4 KiB (1.82 MiB) 1,279.7 KiB (1.25 MiB)
All 16 targets (82,240 OTAP / 159,040 Prom series) 13.82 MiB 17.10 MiB 29.10 MiB 20.00 MiB

Notes on churn and label cardinality:

  • commit_scrape() runs compact() once series.len() > 2 * active_count, so the first 1h churn (5,140 active + 5,140 dead = 10,280) retains one dead generation (29.10 MiB across 16 targets) and the second 1h churn (15,420 > 10,280) compacts back to 20.00 MiB, with a ~2.3 MiB single-target transient spike while scraping and compacting one target at a time (~31.4 MiB collector peak).
  • avalanche reuses 21 distinct label values across all 5,140 series per target; if a non-synthetic target instead carried 5,140 distinct 16-byte label values per target, val_interner would add ~368 KiB per target (~5.8 MiB across 16 targets).
Commits in order
  1. 2b0161b arrow: add Prometheus series staleness detection and StalenessTracker
  2. a92e491 arrow: document why SparseFlagsCol omits all-zero flags columns
  3. 9d0f192 arrow: clarify signaling StaleNaN bit semantics and implement Debug for StalenessTracker
  4. 31b5eb4 arrow: lazily intern only series attributes in StalenessTracker
  5. bc491a0 arrow: key StalenessTracker families by canonical name and MetricType
  6. 6cff462 arrow: defer StalenessTracker active and family metadata commit until scrape completion
  7. a076c06 arrow: bound StalenessTracker live heap via dead-series compaction and lazy allocation
  8. 00bde12 arrow: omit stale data point values instead of writing STALE_NAN into OTAP batches
  9. e65fe06 arrow: skip staleness tracking for explicit wire timestamps by default
  10. 87d4b18 arrow: honor DecodeBudget during stale draining and add step_decode_with_staleness
  11. 03ab0a3 arrow: remove duplicate encode_stale_target alias and document staleness in SPEC.md
  12. 2ee1003 tests: preserve stateless OpenMetrics dhat check alongside optional StalenessTracker loop
  13. 37b3679 arrow: add unit and OTAP tests for staleness edge cases, compaction, and budgets
  14. 0d63e9c arrow: specialize untracked decode loop and fast-path live scalar values
Benchmarks (benchstat before.txt after.txt)

Comparing fd6c3c1 (before.txt) vs 0d63e9c (after.txt):

goos: linux
goarch: amd64
pkg: expfmt
cpu: Intel(R) Xeon(R) CPU @ 2.20GHz
                                                                        │  before.txt  │              after.txt              │
                                                                        │    sec/op    │    sec/op      vs base              │
Decode/case=alltypes.237mfs.nometa/mode=decode-2                          951.0µ ± 10%    966.6µ ±  7%       ~ (p=0.394 n=6)
Decode/case=alltypes.237mfs.nometa/mode=decode_cooperative-2              977.0µ ±  5%    973.6µ ±  5%       ~ (p=0.699 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_16k-2                      964.9µ ±  2%    982.8µ ±  3%  +1.86% (p=0.026 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_256-2                      973.7µ ±  1%   1001.7µ ±  2%  +2.88% (p=0.002 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_256_cooperative-2          997.0µ ±  2%    995.9µ ±  8%       ~ (p=0.937 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=decode-2                   327.8µ ±  1%    336.4µ ±  9%  +2.64% (p=0.009 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_cooperative-2       337.8µ ±  1%    348.2µ ±  7%  +3.07% (p=0.002 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_16k-2               332.6µ ±  2%    345.0µ ±  3%  +3.74% (p=0.009 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256-2               345.0µ ±  3%    352.2µ ±  6%  +2.09% (p=0.041 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256_cooperative-2   354.2µ ±  1%    359.6µ ±  3%  +1.52% (p=0.041 n=6)
Decode/case=alltypes.237mfs/mode=decode-2                                 1.148m ±  2%    1.142m ±  6%       ~ (p=0.937 n=6)
Decode/case=alltypes.237mfs/mode=decode_cooperative-2                     1.170m ±  9%    1.166m ±  5%       ~ (p=0.589 n=6)
Decode/case=alltypes.237mfs/mode=stream_16k-2                             1.149m ±  2%    1.184m ±  4%  +3.07% (p=0.009 n=6)
Decode/case=alltypes.237mfs/mode=stream_256-2                             1.208m ±  8%    1.237m ± 11%       ~ (p=0.310 n=6)
Decode/case=alltypes.237mfs/mode=stream_256_cooperative-2                 1.225m ± 23%    1.214m ±  7%       ~ (p=0.485 n=6)
Decode/case=alltypes.237mfs.nometa/mode=decode_staleness-2                                1.146m ±  5%
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_staleness-2                         393.7µ ±  2%
Decode/case=alltypes.237mfs/mode=decode_staleness-2                                       1.232m ±  2%
geomean                                                                   730.2µ          754.1µ        +1.50%

                                                                        │  before.txt   │              after.txt              │
                                                                        │      B/s      │      B/s       vs base              │
Decode/case=alltypes.237mfs.nometa/mode=decode-2                          148.5Mi ±  9%   146.2Mi ±  7%       ~ (p=0.394 n=6)
Decode/case=alltypes.237mfs.nometa/mode=decode_cooperative-2              144.6Mi ±  5%   145.1Mi ±  5%       ~ (p=0.699 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_16k-2                      146.4Mi ±  2%   143.7Mi ±  3%  -1.82% (p=0.026 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_256-2                      145.1Mi ±  1%   141.0Mi ±  2%  -2.80% (p=0.002 n=6)
Decode/case=alltypes.237mfs.nometa/mode=stream_256_cooperative-2          141.7Mi ±  2%   141.8Mi ±  8%       ~ (p=0.937 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=decode-2                   212.6Mi ±  1%   207.1Mi ±  8%  -2.57% (p=0.011 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_cooperative-2       206.3Mi ±  1%   200.1Mi ±  6%  -2.98% (p=0.002 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_16k-2               209.5Mi ±  2%   202.0Mi ±  3%  -3.61% (p=0.009 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256-2               202.0Mi ±  3%   197.8Mi ±  6%  -2.05% (p=0.041 n=6)
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256_cooperative-2   196.7Mi ±  1%   193.8Mi ±  3%  -1.50% (p=0.041 n=6)
Decode/case=alltypes.237mfs/mode=decode-2                                 158.0Mi ±  2%   158.9Mi ±  5%       ~ (p=0.937 n=6)
Decode/case=alltypes.237mfs/mode=decode_cooperative-2                     155.0Mi ±  8%   155.5Mi ±  4%       ~ (p=0.589 n=6)
Decode/case=alltypes.237mfs/mode=stream_16k-2                             157.9Mi ±  2%   153.2Mi ±  4%  -2.97% (p=0.009 n=6)
Decode/case=alltypes.237mfs/mode=stream_256-2                             150.2Mi ±  7%   146.6Mi ± 10%       ~ (p=0.310 n=6)
Decode/case=alltypes.237mfs/mode=stream_256_cooperative-2                 148.1Mi ± 19%   149.4Mi ±  6%       ~ (p=0.485 n=6)
Decode/case=alltypes.237mfs.nometa/mode=decode_staleness-2                                123.3Mi ±  5%
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_staleness-2                         177.0Mi ±  2%
Decode/case=alltypes.237mfs/mode=decode_staleness-2                                       147.2Mi ±  2%
geomean                                                                   166.1Mi         160.9Mi        -1.48%

                                                                        │  before.txt  │              after.txt               │
                                                                        │     B/op     │     B/op      vs base                │
Decode/case=alltypes.237mfs.nometa/mode=decode-2                          102.1Ki ± 0%   102.1Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=decode_cooperative-2              102.1Ki ± 0%   102.1Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_16k-2                      102.1Ki ± 0%   102.1Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_256-2                      136.9Ki ± 0%   136.9Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_256_cooperative-2          136.9Ki ± 0%   136.9Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=decode-2                   50.58Ki ± 0%   50.58Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_cooperative-2       50.58Ki ± 0%   50.58Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_16k-2               50.58Ki ± 0%   50.58Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256-2               56.39Ki ± 0%   56.39Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256_cooperative-2   56.39Ki ± 0%   56.39Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=decode-2                                 82.63Ki ± 0%   82.63Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=decode_cooperative-2                     82.63Ki ± 0%   82.63Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_16k-2                             82.63Ki ± 0%   82.63Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_256-2                             105.6Ki ± 0%   105.6Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_256_cooperative-2                 105.6Ki ± 0%   105.6Ki ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=decode_staleness-2                               102.1Ki ± 0%
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_staleness-2                        50.58Ki ± 0%
Decode/case=alltypes.237mfs/mode=decode_staleness-2                                      82.63Ki ± 0%
geomean                                                                   82.07Ki        80.90Ki       +0.00%
¹ all samples are equal

                                                                        │ before.txt │             after.txt              │
                                                                        │ allocs/op  │ allocs/op   vs base                │
Decode/case=alltypes.237mfs.nometa/mode=decode-2                          76.00 ± 0%   76.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=decode_cooperative-2              76.00 ± 0%   76.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_16k-2                      76.00 ± 0%   76.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_256-2                      601.0 ± 0%   601.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=stream_256_cooperative-2          601.0 ± 0%   601.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=decode-2                   91.00 ± 0%   91.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_cooperative-2       91.00 ± 0%   91.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_16k-2               91.00 ± 0%   91.00 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256-2               181.0 ± 0%   181.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.209mfs.counter_gauge/mode=stream_256_cooperative-2   181.0 ± 0%   181.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=decode-2                                 182.0 ± 0%   182.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=decode_cooperative-2                     182.0 ± 0%   182.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_16k-2                             182.0 ± 0%   182.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_256-2                             524.0 ± 0%   524.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs/mode=stream_256_cooperative-2                 524.0 ± 0%   524.0 ± 0%       ~ (p=1.000 n=6) ¹
Decode/case=alltypes.237mfs.nometa/mode=decode_staleness-2                             76.00 ± 0%
Decode/case=alltypes.209mfs.counter_gauge/mode=decode_staleness-2                      91.00 ± 0%
Decode/case=alltypes.237mfs/mode=decode_staleness-2                                    182.0 ± 0%
geomean                                                                   179.5        164.9       +0.00%
¹ all samples are equal

Add STALE_NAN_BITS, STALE_NAN, DATA_POINT_FLAG_NO_RECORDED_VALUE, is_stale_nan, lazy flags column materialization on NumberDataPoints, SummaryDataPoints, and HistogramDataPoints, and zero-steady-state-allocation per-target StalenessTracker support in ArrowDecoder.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
@bwplotka
bwplotka force-pushed the bwplotka/staleness-detection branch from 099e550 to 2b0161b Compare November 12, 2026 07:30
Explain that OTAP treats data-point flags as default_values_optional so omitting all-zero flags columns preserves schemas and avoids UInt32 allocations on live-only batches.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…or StalenessTracker

Fix the module rustdoc describing StaleNaN as quiet, note bitwise-only handling for signaling NaN bits on STALE_NAN and is_stale_nan, and implement Debug on StalenessTracker.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Use NONE_IDX sentinels in batch_key_to_tracker and batch_val_to_tracker instead of sequential catch-up loops so ephemeral exemplar label keys and values in batch interners never leak into StalenessTracker.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Normalize counter family names by stripping trailing _total suffixes and include MetricType and saw_total_suffix in TrackedFamily keys so # TYPE changes never attach data points to mismatched UnivariateMetrics rows and # TYPE req counter vs # TYPE req_total counter share one family.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
… scrape completion

Stage per-scrape family metadata updates and preserve committed active flags until commit_scrape runs after final RecordBatch construction so mid-scrape errors and early DecodeStream drops roll back cleanly.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…d lazy allocation

Make StalenessTracker::new() zero-allocating until first use, shrink TrackedSeries layout, clear arenas when active_count drops to zero, compact dead series, families, and interned strings under label churn, and document the opt-in memory model.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
… OTAP batches

Emit NULL for NumberDataPoints.double_value and HistogramDataPoints.sum (and 0.0 for SummaryDataPoints.sum with empty buckets, quantiles, and exemplars) when FLAG_NO_RECORDED_VALUE is set, matching OTLP and OTAP semantics without poisoning Arrow float aggregations.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Add DecodeOptions::track_timestamps_staleness (default false, matching Go Prometheus scrape/scrape.go) so client-timestamped samples are excluded from StalenessTracker unless explicitly enabled.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…ith_staleness

Enforce entry and duration DecodeBudget limits inside StalenessTracker::finish_scrape_step so cooperative streams yield during large target drops, and add step_decode_with_staleness and finish_decode_with_staleness for lifetime-free DecodeCursor callers.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…ess in SPEC.md

Drop ArrowDecoder::encode_stale_target in favor of flush_target_stale to keep the public API surface minimal, and document StalenessTracker, FLAG_NO_RECORDED_VALUE, and track_timestamps_staleness in specs/SPEC.md.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…talenessTracker loop

Keep the stateless ArrowDecoder::decode OpenMetrics bounded-memory check intact and add separate opt-in StalenessTracker checks covering steady-state scrapes and alternating full/subset disappearing-series cycles.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
…and budgets

Add tests covering flushed metadata rollback, zero-capacity rehashing, key-order resorting, exemplar interner isolation, churn compaction, wire NaN vs StaleNaN, # TYPE transitions, UntypedPolicy::Drop, track_timestamps_staleness, and budgeted cooperative stale draining.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Monomorphize ingest_entries and step_cursor over const TRACKED: bool so untracked decode, decode_stream, and step_decode paths compile out StalenessTracker branches and register state. Split OptionalValueCol::push_value and SparseFlagsCol::push_zero from #[cold] null/flag slow paths so live scalar samples avoid Option<f64> construction and inlined Vec growth paths.

Signed-off-by: Bartek Plotka <bwplotka@gmail.com>
Comment thread src/arrow/staleness.rs
/// `flags = DATA_POINT_FLAG_NO_RECORDED_VALUE` (`1`), and then removed from the active set so
/// subsequent scrapes do not re-emit it.
///
/// # Memory model and optional usage

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: I tried to optimize it hard. I think there's a room for a 3rd alternative (currently it's no staleness or staleness per series) -- staleness per target as we discussed in https://cloud-native.slack.com/archives/C0CFYDE79D5/p1794385004482139

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.

1 participant