Skip to content

Fix histogram bucket consistency during concurrent collection - #179

Merged
knyar merged 5 commits into
knyar:mainfrom
creationix:codex/fix-histogram-snapshots
Sep 8, 2026
Merged

knyar merged 5 commits into
knyar:mainfrom
creationix:codex/fix-histogram-snapshots

Conversation

@creationix

@creationix creationix commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Concurrent histogram updates and collection can report observations in ranges
that have never received a value. Store each observation in one disjoint range
and construct the cumulative buckets from those range counters during collection.
This also makes +Inf and _count identical in every exported label set.

Related to #161, which partially mitigated the race by changing increment order.
The per-worker counter buffer is flushed with pairs(), and collection can also
overlap an otherwise complete flush, so increment order alone cannot prevent it.

Concrete failure

Suppose a histogram has bounds {1, 10, 100}, and all ten observations are 0.5:

Scrape le="1" le="10" le="100" le="+Inf"
Before another observation 10 10 10 10
During an overlapping flush 10 11 11 11
After the flush 11 11 11 11

The collector reads le="1" before another worker flushes one more 0.5
observation, then reads the other buckets afterward. The middle scrape claims
one observation in (1, 10], although every observation was below 1. Other
interleavings can expose decreasing cumulative buckets or disagreeing +Inf
and _count values.

Consumers that convert cumulative buckets into per-range observations can turn
this discrepancy into phantom slow samples, inflating tail percentiles and
histogram-derived maxima. A later consistent scrape does not necessarily undo
observations already ingested into a downstream distribution.

Changes

  • Increment one disjoint bucket and _sum per observation. Collection reads
    each bucket once and builds cumulative counts from those same reads.
  • Publish one descriptor per label set and export all configured buckets,
    including zeros, so a newly observed label set has a complete schema.
  • Isolate the new internal storage from legacy cumulative cells and different
    bucket layouts. Serialize boundaries with 17 significant digits so distinct
    floating-point layouts cannot share counters. Preserve escaped labels, output
    prefixes, and reset behavior.
  • Remove the obsolete histogram key-formatting helpers and share HELP/TYPE
    generation. Bucket labels retain sub-microsecond and longer decimal bounds
    instead of rounding them to six decimal places.
  • Add deterministic interleaving regressions and lifecycle coverage using the
    existing Lua unit-test harness. Public metric assertions read exported samples.

This provides bucket/count consistency, not a globally atomic snapshot:
observations remain eventually consistent, and _sum is sampled independently.
Histogram history is not migrated from the old storage format. Upgrading or
changing bucket boundaries requires a full nginx restart or fresh shared
dictionary; graceful reloads can retain old cells and overlap incompatible worker
generations. The changelog documents this upgrade requirement. Counter and gauge
storage remains unchanged.

Validation

  • All 47 unit tests pass on Lua 5.1 and Lua 5.2; source and test luacheck
    commands from the existing CI workflow pass.
  • CI-equivalent coverage is 97.85% overall (up from 96.93%, above the
    97.05% comparison baseline), with 95.12% coverage of prometheus.lua.
    Tests exercise every newly added runtime line, including histogram read/reset
    errors and timer-driven worker index synchronization.
  • The new deterministic flush regression fails against unmodified upstream:
    expected zero observations in (1, 10], got one. The zero-bucket regression
    also fails upstream because the empty lower bucket is absent.
  • A regression verifies independent counts for {0.3, 1} and {0.1 + 0.2, 1},
    whose distinct boundaries both become 0.3 with default number formatting.
  • New regressions for precise bucket output and scalar metric metadata fail on
    the previous implementation and pass with the cleanup.
  • The unchanged four-worker nginx integration suite passes with 52,205 requests
    and concurrent reset checks.

Copilot AI lite review requested due to automatic review settings September 5, 2026 03:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new histogram layout hashing uses table.concat(metric.buckets, ",") on numeric bucket values, which will throw at runtime and break histogram registration.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR revises histogram storage/collection to avoid inconsistent cumulative bucket exports under concurrent worker flush + collection interleavings by storing disjoint per-range counters and deriving cumulative buckets during collection, with accompanying documentation and test coverage updates.

Changes:

  • Switch histogram observations to increment exactly one disjoint bucket counter plus _sum, then build cumulative buckets (and _count) from a single read pass during collection.
  • Add a separate histogram key index / namespace to isolate the new format (and bucket layouts) from legacy cumulative histogram cells.
  • Update Lua unit tests and documentation/changelog to reflect the new behavior (including exporting zero-valued buckets).
File summaries
File Description
README.md Updates histogram concurrency/consistency documentation.
prometheus.lua Implements disjoint histogram storage, new histogram key index, and cumulative reconstruction at collection time.
prometheus_test.lua Refactors histogram assertions to read public exposition and adds deterministic concurrency/layout isolation regressions.
CHANGELOG.md Documents the new histogram storage model and upgrade/reload implications.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread prometheus.lua Outdated

@knyar knyar left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you, this looks good. Improving consistency/correctness is great, but this also allows us to clean up some of the older hacks added for histogram support.

I left a few minor comments but I am also happy to merge this as is and fix those minor issues myself as a follow up - please let me know if you prefer that.

Comment thread README.md Outdated
Comment on lines +351 to +358
Each observation increments one disjoint bucket counter and the sum. Collection
reads each bucket counter once and derives the cumulative buckets and `_count`
from those reads. Concurrent worker flushes cannot create observations in an
empty range, and the `+Inf` bucket always equals `_count`.

Observations remain eventually consistent across workers. The bucket reads and
`_sum` do not form a single atomic snapshot, so an observation may become visible
in the sum and count in different scrapes.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is user-facing documentation for histogram:observe(), so I don't think we need to describe implementation details (disjoint counters) here. How about a shorter note documenting the impact for the user? E.g. "Note that bucket counters and the _sum counter are incremented independently, so an observation may become visible in the sum and count in different scrapes."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread prometheus_test.lua Outdated
Comment on lines +289 to +290
luaunit.assertEquals(self.dict:get("metric1"), nil)
luaunit.assertEquals(self.dict:get("l1_count"), nil)
luaunit.assertEquals(sample(self.p, "l1_count"), nil)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Any thoughts on replacing other metric reads that use self.dict:get with the new sample() helper? That would make the tests more readable, making it explicit whether we're testing metric output vs internal state (self.dict:get will need to stay for checking internal keys)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, metric-output checks now use sample(). Kept direct reads for internal keys and injected-failure checks, where sample() would trigger another scrape.

Comment thread prometheus.lua Outdated
Comment thread prometheus.lua Outdated
Comment on lines +910 to +916
if metric.help then
table_insert_tail(output, string.format("# HELP %s%s %s\n",
self.prefix, metric.name, metric.help))
end
table_insert_tail(output, string.format("# TYPE %s%s histogram\n",
self.prefix, metric.name))
seen_metrics[metric.name] = true

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This is almost identical to the existing comment output code in metric_data. Maybe move it to a method on metric so that this becomes just:

if not seen_metrics[metric.name] then
  metric:write_comments(output)
  seen_metrics[metric.name] = true
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done: shared method, histogram caller, and scalar caller. seen_metrics stays in the callers.

Comment thread prometheus.lua Outdated
@@ -743,7 +701,10 @@ function Prometheus.init(dict_name, options_or_prefix)
if m and m.typ == TYPE_HISTOGRAM then

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think we no longer need special handling of histogram keys here, or do we?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, no longer needed. Reset now uses the base metric name directly; the _sum special case is gone.

Comment thread prometheus.lua Outdated
Comment on lines 952 to 953

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We could keep sorting keys just for aesthetical reasons, but this comment is no longer accurate.

Suggested change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread prometheus.lua Outdated
Comment on lines +961 to +969
-- Ignore cumulative histogram keys left in the old index after a reload.
local name = key:match("^[^{]+")
local metric = self.registry[name] or self.registry[
name:match("^(.*)_bucket$") or name:match("^(.*)_count$") or
name:match("^(.*)_sum$")]
local value, err
if not metric or metric.typ ~= TYPE_HISTOGRAM then
value, err = self.dict:get(key)
end

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Sould we make a helper for this to make it a bit more readable? E.g.

if not should_ignore_key(key, self.registry) then
  value, err = self.dict:get(key)
end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread prometheus.lua Outdated
value, err = self.dict:get(key)
end
if value then
local short_name = short_metric_name(key)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We no longer need short_metric_name, I believe - or maybe it could just return key:match("^[^{]+")?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, removed short_metric_name. The caller now uses key:match("^[^{]+") directly.

Comment thread prometheus.lua Outdated
local function reset_lookup(metric_key)
-- When another worker calls reset or del on a metric, reset that
-- metric's local lookup table.
local metric_name = ngx_re_gsub(metric_key, "{.*", "", "jo")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Should we replace this gsub with your new key:match("^[^{]+") approach?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread prometheus.lua Outdated
@@ -872,6 +834,14 @@ local function register(self, name, help, label_names, buckets, typ)
metric.buckets = buckets or DEFAULT_BUCKETS
metric.bucket_count = #metric.buckets
metric.bucket_format = construct_bucket_format(metric.buckets)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

We no longer need construct_bucket_format, fix_histogram_bucket_labels and METRICS_KEY_REGEX I believe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

creationix and others added 2 commits September 6, 2026 14:48
Co-authored-by: Anton Tolchanov <1687799+knyar@users.noreply.github.com>
@creationix

Copy link
Copy Markdown
Contributor Author

Feel free to clean it up more as you wish before merging.

@knyar
knyar merged commit af85cf9 into knyar:main Sep 8, 2026
3 checks passed
knyar added a commit that referenced this pull request Sep 9, 2026
Quick follow-up to the recent histogram changes in #179.
knyar added a commit that referenced this pull request Sep 9, 2026
Quick follow-up to the recent histogram changes in #179.
@knyar

knyar commented Sep 11, 2026

Copy link
Copy Markdown
Owner

I just created a release 0.20260912 which includes this change. Thank you for your contribution @creationix!

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.

3 participants