Skip to content

Bound distribution sample buffer with automatic aggregation (fixes #41, #52) - #77

Open
robacourt wants to merge 1 commit into
beam-telemetry:mainfrom
robacourt:feat/auto-flush-distributions
Open

Bound distribution sample buffer with automatic aggregation (fixes #41, #52)#77
robacourt wants to merge 1 commit into
beam-telemetry:mainfrom
robacourt:feat/auto-flush-distributions

Conversation

@robacourt

Copy link
Copy Markdown

Problem

Distribution (histogram) samples are buffered in the *_dist :duplicate_bag table and only folded into their histogram buckets when Aggregator.aggregate/3 runs — which today happens only at scrape time (:ets.take in aggregator.ex). A reporter whose /metrics endpoint is scraped infrequently, or never, therefore accumulates one ETS row per observation, without bound.

This bites anyone who starts the reporter but doesn't actually scrape it (e.g. metrics are shipped via another path, or the scraper is misconfigured). It's reported in:

In #52 the suggested fix was "a GenServer cron job that regularly aggregates the ETS data" — this PR builds that in.

Approach

The registry now aggregates on its own, in addition to on scrape, with two triggers:

  • Size trigger (primary): a lock-free :atomics counter incremented in the distribution event handlers enqueues a single flush once :flush_threshold samples have buffered (default 10_000). A second :atomics flag (compare_exchange) de-bounces it so a burst over the threshold enqueues one flush, not one per event. The hot path adds only an atomic increment + compare.
  • Time fallback (secondary): at most every :max_flush_interval_ms (default 60_000) so low-volume distributions still drain and exported histograms stay fresh.

Both default on, so the reporter is safe out of the box. Set either to :infinity to restore the previous scrape-only behaviour.

Race-freedom

Aggregation is a read-modify-write of the cumulative aggregates table (get → merge → put in aggregator.ex). Today scrape/1 runs it in the caller process, so two overlapping scrapes can already lose updates. This PR moves aggregation into the registry process (scrape/1 now calls Registry.aggregate/1), so all aggregation — scrape-triggered, size-triggered, and time-triggered — is serialized through one mailbox. The read-only export stays in the caller. This also fixes the pre-existing overlapping-scrape race.

Compatibility

  • Default behaviour changes (auto-flush is on), but the exposition output is unchanged — histograms are cumulative, so flushing only moves samples from the staging bag into the cumulative counters earlier. Scrapes report the same totals whenever they happen.
  • :infinity/:infinity restores exact prior behaviour.
  • Distribution.register/4 gains an optional flush-context arg defaulting to a disabled context, so existing register/3 callers keep working.

Tests

  • test/flush_test.exs: size-triggered flush (no scrape), time-fallback flush incl. the periodic chain re-arming across intervals, and the :infinity/:infinity opt-out (drains on scrape only).
  • Full suite green (47 tests), mix format --check-formatted clean.

Docs (core.ex moduledoc + start_link/1 options) and CHANGELOG.md updated.

Distribution (histogram) samples are buffered in a :duplicate_bag and only
folded into histogram buckets when Aggregator.aggregate/3 runs, which until
now happened solely at scrape time. A reporter whose /metrics endpoint is
scraped infrequently -- or never -- therefore accumulates one ETS row per
observation without bound (issues beam-telemetry#41 and beam-telemetry#52).

The registry now aggregates on its own in addition to on scrape:

  * size trigger: a lock-free counter in the distribution event handlers
    enqueues a single flush once :flush_threshold samples have buffered
    (default 10_000), de-bounced via an atomics flag so a burst over the
    threshold doesn't flood the registry mailbox;
  * time fallback: at most every :max_flush_interval_ms (default 60_000) for
    low-volume distributions, keeping exported histograms fresh.

Set either option to :infinity to restore the previous scrape-only behaviour.

Aggregation now runs inside the registry process, so the read-modify-write of
the cumulative aggregates table is serialized with the flushes and with
concurrent scrapes -- also removing a pre-existing race when scrapes overlapped.

Fixes beam-telemetry#41, beam-telemetry#52.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robacourt
robacourt requested a review from bryannaegele as a code owner June 29, 2026 14:17
robacourt added a commit to electric-sql/electric that referenced this pull request Jun 29, 2026
When ELECTRIC_PROMETHEUS_PORT is set but the /metrics endpoint is scraped
infrequently or never (e.g. OTel-only deployments), telemetry_metrics_prometheus_core
buffers one ETS row per distribution observation and only drains on scrape, so
the dist table grows without bound (the per-transaction receive_lag metric
dominates). An 8GB+ ETS table and eventual OOM was observed in the field.

Pin telemetry_metrics_prometheus_core to a fork that bounds the buffer by
aggregating automatically on a size threshold (default 10k samples) and a time
fallback (default 60s), in addition to on scrape. Only affects the
MIX_TARGET=application build (the standalone sync-service / Docker image); the
telemetry deps are target-gated out of the Hex package, so this git dep does not
affect publishing of `electric`.

Upstream PR: beam-telemetry/telemetry_metrics_prometheus_core#77
Revert to the Hex release once it lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YW7Njz5ZpBDaoGviW1eVR8
robacourt added a commit to electric-sql/electric that referenced this pull request Jun 30, 2026
## Summary

If `ELECTRIC_PROMETHEUS_PORT` is set but nothing ever scrapes the
`/metrics` endpoint, Electric's memory grows without bound and can
eventually OOM-kill the service. This came out of debugging a customer
whose `prometheus_metrics_dist` ETS table had grown to ~8 GB on an
OTel-only deployment that had the Prometheus port enabled but unscraped.

### Why it happens

`telemetry_metrics_prometheus_core` stores **distribution (histogram)**
metrics in a `:duplicate_bag` and appends one row per observation
(`distribution.ex` `:ets.insert`), only aggregating into buckets — and
draining the bag via `:ets.take` — **at scrape time** (documented in the
library's moduledoc: *"aggregations for distributions (histogram) only
occur at scrape time"*). So if the endpoint is never scraped, the bag is
never drained.

Electric's default Prometheus metric set includes
`electric.postgres.replication.transaction_received.receive_lag`, a
distribution emitted roughly once per replication transaction, so the
table accretes continuously under load (~0.55 GB/day in the customer's
case). Counters/sums/last-values are unaffected — they use bounded
`:set` storage.

This is purely a docs change to make the "must be scraped" requirement
explicit everywhere we mention Prometheus.

## Changes

- **Telemetry reference** (`website/docs/sync/reference/telemetry.md`) —
warning callout in the Metrics section.
- **Config reference** (`website/docs/sync/api/config.md`) — note on
`ELECTRIC_PROMETHEUS_PORT`.
- **Deployment guide** (`website/docs/sync/guides/deployment.md`) —
warning in the Observability section.
- **`prometheus_port` docstring**
(`packages/sync-service/lib/electric.ex`).

## Follow-up (not in this PR)

Worth considering a code-level guard so an enabled-but-unscraped
endpoint can't OOM the service (e.g. move high-frequency distributions
like `receive_lag` out of the default Prometheus set, or run an internal
periodic drain).

I have raised a PR with the library maintainer for a potential fix:
beam-telemetry/telemetry_metrics_prometheus_core#77

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alco

alco commented Jun 30, 2026

Copy link
Copy Markdown

@robacourt was the close intentional?

@robacourt robacourt reopened this Jun 30, 2026
@robacourt

Copy link
Copy Markdown
Author

@robacourt was the close intentional?

No! Thank you :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants