Skip to content

sdk/metric: add internal finish-aware Sum lifecycle - #8925

Draft
MrAlias wants to merge 17 commits into
open-telemetry:mainfrom
MrAlias:finish-sum-lifecycle
Draft

sdk/metric: add internal finish-aware Sum lifecycle#8925
MrAlias wants to merge 17 commits into
open-telemetry:mainfrom
MrAlias:finish-sum-lifecycle

Conversation

@MrAlias

@MrAlias MrAlias commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add an internal finish-aware Sum aggregation for cumulative and delta streams
  • isolate finish-aware series coordination in the documented sdk/metric/internal/finish package
  • expose only the supported lifecycle protocol and represent measurement and collection phases with explicit tokens
  • model each attribute series with active, shared, finish-pending, collecting, and retired lifecycle states
  • keep the finish-aware recording path free of mutex acquisition by tracking state and in-flight measurements atomically
  • make marker-to-overflow promotion compete atomically with Finish, so shared overflow can never be retired by a stale finish
  • reclaim cardinality slots after retirement while preserving the shared overflow series
  • retire and clear all retained series during shutdown

This establishes the internal lifecycle needed for #8905. It intentionally does not expose SDK activation or connect metricx.Finisher; that wiring will follow after this implementation is reviewed.

The existing stable Sum implementation and its recording path are unchanged.

Performance

BenchmarkFinishSumMeasure is committed in this PR and directly compares the stable (finish=false) and finish-aware (finish=true) recording paths with a prebuilt attribute set. The following raw benchstat output was produced from the pushed commit a749b3b0f:

$ go test -run '^$' -bench '^BenchmarkFinishSumMeasure$' -benchmem -benchtime=300ms -count=10 ./internal/aggregate > a749b3b0f.txt
$ benchstat -filter '.name:FinishSumMeasure' -row /mode -col /finish a749b3b0f.txt
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/sdk/metric/internal/aggregate
cpu: Intel(R) Xeon(R) Platinum 8124M CPU @ 3.00GHz
         │    false    │                true                 │
         │   sec/op    │   sec/op     vs base                │
serial     50.30n ± 0%   72.34n ± 3%  +43.82% (p=0.000 n=10)
parallel   47.41n ± 0%   73.65n ± 1%  +55.34% (p=0.000 n=10)
geomean    48.83n        72.99n       +49.47%

         │    false     │                true                 │
         │     B/op     │    B/op     vs base                 │
serial     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
parallel   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean               ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

         │    false     │                true                 │
         │  allocs/op   │ allocs/op   vs base                 │
serial     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
parallel   0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean               ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

BenchmarkFinishSumOverflowMeasurement separately measures the one-time marker-to-shared-overflow promotion and later measurements of the promoted point. The identical benchmark now committed at a749b3b0f was run against the mutex implementation at 0e2a8f49e and the atomic lifecycle implementation at a749b3b0f:

$ # Run the benchmark committed at a749b3b0f against each implementation.
$ go test -run '^$' -bench '^BenchmarkFinishSum(Measure|OverflowMeasurement)$' -benchmem -benchtime=300ms -count=10 ./internal/aggregate > 0e2a8f49e-mutex.txt
$ go test -run '^$' -bench '^BenchmarkFinishSum(Measure|OverflowMeasurement)$' -benchmem -benchtime=300ms -count=10 ./internal/aggregate > a749b3b0f-atomic.txt
$ benchstat -filter '.name:FinishSumOverflowMeasurement' -row /state 0e2a8f49e-mutex.txt a749b3b0f-atomic.txt
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/sdk/metric/internal/aggregate
cpu: Intel(R) Xeon(R) Platinum 8124M CPU @ 3.00GHz
          │ 0e2a8f49e-mutex.txt │        a749b3b0f-atomic.txt         │
          │       sec/op        │   sec/op     vs base                │
promotion           191.3n ± 1%   172.1n ± 1%  -10.06% (p=0.000 n=10)
promoted            34.79n ± 0%   29.52n ± 0%  -15.16% (p=0.000 n=10)
geomean             81.58n        71.26n       -12.65%

          │ 0e2a8f49e-mutex.txt │        a749b3b0f-atomic.txt         │
          │        B/op         │    B/op     vs base                 │
promotion          144.0 ± 0%     144.0 ± 0%       ~ (p=1.000 n=10) ¹
promoted           0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                       ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

          │ 0e2a8f49e-mutex.txt │        a749b3b0f-atomic.txt         │
          │      allocs/op      │ allocs/op   vs base                 │
promotion          1.000 ± 0%     1.000 ± 0%       ~ (p=1.000 n=10) ¹
promoted           0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=10) ¹
geomean                       ²               +0.00%                ²
¹ all samples are equal
² summaries must be >0 to compute geomean

Ordinary measurements, the promotion measurement, and subsequent shared-overflow measurements acquire no mutex. Promotion and Finish instead compete through one lifecycle CAS. Finish, collection, and shutdown control transitions remain serialized by each series lifecycle.

Validation

  • make precommit
  • race-enabled tests for the complete sdk/metric module
  • deterministic regression coverage for marker promotion racing with Finish
  • lifecycle, cumulative and delta collection, timestamp, recreation, cardinality, overflow, exemplar, shutdown, and concurrency tests

Builds on #8906.

@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.65079% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.6%. Comparing base (9ba91a2) to head (6b718f8).

Files with missing lines Patch % Lines
sdk/metric/internal/finish/lifecycle.go 91.1% 8 Missing ⚠️
sdk/metric/internal/aggregate/finish_sum.go 94.7% 7 Missing ⚠️
sdk/metric/internal/aggregate/atomic.go 96.4% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@          Coverage Diff           @@
##            main   #8925    +/-   ##
======================================
  Coverage   88.5%   88.6%            
======================================
  Files        333     335     +2     
  Lines      21101   21353   +252     
======================================
+ Hits       18687   18921   +234     
- Misses      2414    2432    +18     
Files with missing lines Coverage Δ
sdk/metric/internal/aggregate/atomic.go 93.2% <96.4%> (+0.9%) ⬆️
sdk/metric/internal/aggregate/finish_sum.go 94.7% <94.7%> (ø)
sdk/metric/internal/finish/lifecycle.go 91.1% <91.1%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Introduce an internal Sum implementation that can retire individual
attribute series after their final measurement while preserving cumulative
and delta collection semantics.

Keep recording free of mutex acquisition by combining the lifecycle state
and in-flight writer count in one atomic value. Reclaim retired cardinality
slots, retain overflow protection, and prevent shutdown from recreating
series.

Cover lifecycle transitions, timestamp behavior, collection, cardinality,
exemplars, shutdown, concurrency, and recording performance.
Add the required Unreleased changelog entry so the lifecycle work is
represented in the module release notes.

Reshape the recording benchmarks into reproducible stable and finish-aware
cases for both serial and parallel execution. This allows benchstat to
report the feature's synchronization cost from committed code.
Exercise retirement while a measurement remains in flight and verify that
cleanup waits for the writer before making the series terminal.

Cover repeated identity-safe deletion and collection of retired points. These
paths preserve cardinality accounting and raise patch coverage above the
repository threshold.
Document why lifecycle state and admitted measurement count share one
atomic value. This makes the lock-free admission guarantee easier to
understand and maintain.
Name each state after the series lifecycle it describes. This avoids
suggesting that an active series has a finish operation in progress.
Rename lifecycle methods as an acquire/release pair and document the
successful acquisition obligation. This makes the Boolean result and
writer-count ownership explicit without changing the hot path.
Document the unsigned decrement and the acquisition invariant that
prevents it from borrowing into the packed lifecycle state.
Explain why the lifecycle remains active across the separate check and
atomic update, and how concurrent writer-count changes are preserved.
Name the collection result as a decision and remove the ambiguous finish
prefix from its mode. This distinguishes collection policy and outcomes
from the lifecycle's collecting state.
Replace the collection-mode control parameter with explicit cumulative
and delta entry points. This makes supported behavior visible and lets
the API prevent invalid collection modes.
Move finish-aware series coordination behind a dedicated internal
package. Export only the supported lifecycle protocol and use phase tokens
to make measurement release and collection completion explicit. The
lifecycle now owns serialization, preventing callers from reaching
packed-state helpers.
@MrAlias
MrAlias force-pushed the finish-sum-lifecycle branch from 2cdaf59 to 5971006 Compare September 4, 2026 19:54
Track lifecycle state and admitted writers independently so collection
cannot reset a writer count that a stale measurement later decrements.
Reserve a new series' first measurement before publishing that series so
collection cannot emit an empty point during its creation.
Distinguish series routed to shared cardinality overflow from ordinary
series whose attributes equal the overflow marker. Promote a colliding
ordinary series when it later becomes the shared overflow destination so
Finish remains disabled only for genuinely shared state.
Keep finish Sum value construction separate from lifecycle admission. The
map creation callback now reserves the first measurement immediately before
publishing the point, making the publication ordering explicit while
preserving collection safety.
Prevent a stale overflow check from finishing a series after it becomes
shared cardinality overflow. Promotion now admits its triggering
measurement while serialized with Finish, preserving the shared series
through cumulative collection without locking ordinary measurements.
Represent shared overflow as a lifecycle state so promotion and Finish
compete through one atomic transition. This prevents a delayed Finish from
retiring shared overflow without putting a mutex on the measurement path.

Exercise the real marker-promotion path and benchmark both the transition
and subsequent shared-overflow measurements.
Return a Boolean from lifecycle collection methods so callers reject a
retired lifetime before passing its Collection token downstream. Every token
that reaches the aggregate collector is now valid and completed exactly once.

This removes the overloaded ShouldEmit contract and makes collection lock
ownership visible at the lifecycle boundary.
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