Skip to content

Commit 43788f5

Browse files
authored
fix: bound observation buffering during collection (#2336)
## Summary - bound the collection spin wait and observation buffer - coordinate collection through explicit buffer generations and phase transitions - replay buffered observations before surfacing a collection timeout - skip timed-out native-histogram maintenance rather than throwing from `observe()` - document the generation handoff and striped-count protocol This is stacked on the focused #2282 fix so the two `Buffer` concerns can be reviewed separately. It replaces the #2287 portion of #2297. Fixes #2287 ## Ongoing discussion - Should the five-second collection deadline remain an internal default, or become publicly configurable? See the [earlier discussion](#2297 (comment)). - The bounded buffer currently applies backpressure rather than dropping observations. The earlier review suggested drop-on-overflow; this version avoids silent data loss but can block an observer while the generation remains active. See the [earlier discussion](#2297 (comment)). - The generation/phase handoff is a substantial concurrency change and should be reviewed independently of the simple stripe-index fix. The earlier concerns about an unbounded second wait and exceptions escaping from native-histogram maintenance are addressed by this implementation. ## Validation - `mise run lint:fix` - `mise run build` - `./mvnw test -pl prometheus-metrics-core -Dcoverage.skip=true -Dcheckstyle.skip=true` --------- Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 041b8ee commit 43788f5

5 files changed

Lines changed: 449 additions & 101 deletions

File tree

docs/apidiffs/current_vs_latest/prometheus-metrics-core.txt

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 174 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,238 @@
11
package io.prometheus.metrics.core.metrics;
22

3+
import static java.util.Objects.requireNonNull;
4+
35
import io.prometheus.metrics.model.snapshots.DataPointSnapshot;
46
import java.util.Arrays;
7+
import java.util.concurrent.TimeUnit;
58
import java.util.concurrent.atomic.AtomicLong;
69
import java.util.concurrent.locks.Condition;
710
import java.util.concurrent.locks.ReentrantLock;
811
import java.util.function.Consumer;
912
import java.util.function.Function;
1013
import java.util.function.Supplier;
14+
import javax.annotation.Nullable;
1115

1216
/**
13-
* Metrics support concurrent write and scrape operations.
17+
* Coordinates concurrent metric observations with collection.
18+
*
19+
* <p>Collection activates a generation. Observations that start after activation are appended to
20+
* that generation while the collector waits for observations from the previous phase to finish. The
21+
* collector then creates a snapshot, deactivates the generation, and replays its buffered
22+
* observations into the live metric state.
1423
*
15-
* <p>This is implemented by switching to a Buffer when the scrape starts, and applying the values
16-
* from the buffer after the scrape ends.
24+
* <p>The default collection wait is five seconds. A generation is capped at one million buffered
25+
* observations (about eight MiB of double storage) to keep a stalled collection from growing
26+
* without bound; the cap applies backpressure rather than dropping observations.
1727
*/
1828
class Buffer {
29+
private static final long BUFFER_ACTIVE_BIT = 1L << 63;
30+
private static final double[] EMPTY_BUFFER = new double[0];
31+
32+
// Keep collection bounded without failing healthy scrapes during short periods of scheduler or
33+
// CI-host contention. The one-million-observation cap uses at most 8 MiB for one generation;
34+
// it is deliberately an internal safeguard rather than a data-loss policy.
35+
private static final long DEFAULT_MAX_SPIN_WAIT_NANOS = TimeUnit.SECONDS.toNanos(5);
36+
private static final int DEFAULT_MAX_BUFFER_SIZE = 1_000_000;
37+
private static final int INITIAL_BUFFER_SIZE = 128;
38+
39+
/** Observations buffered during one collection cycle. */
40+
private static final class Generation {
41+
private double[] values = EMPTY_BUFFER;
42+
private int size;
43+
private boolean active = true;
44+
}
1945

20-
private static final long bufferActiveBit = 1L << 63;
2146
// Tracking observation counts requires an AtomicLong for coordination between recording and
2247
// collecting. AtomicLong does much worse under contention than the LongAdder instances used
23-
// elsewhere to hold aggregated state. To improve, we stripe the AtomicLong into N instances,
24-
// where N is the number of available processors. Each record operation chooses the appropriate
25-
// instance to use based on the modulo of its thread id and N. This is a more naive / simple
26-
// implementation compared to the striping used under the hood in java.util.concurrent classes
27-
// like LongAdder - contention and hot spots can still occur if recording thread ids happen to
28-
// resolve to the same index. Further improvement is possible.
48+
// elsewhere to hold aggregated state. To reduce contention, the count is striped across the
49+
// available processors. This is simpler than the striping used by LongAdder, so hot spots remain
50+
// possible when several recording threads resolve to the same stripe.
2951
private final AtomicLong[] stripedObservationCounts;
30-
private double[] observationBuffer = new double[0];
31-
private int bufferPos = 0;
32-
private boolean reset = false;
33-
52+
private final ReentrantLock observationLock = new ReentrantLock();
53+
private boolean reset;
54+
private long observationCountOffset;
55+
@Nullable private volatile Generation activeGeneration;
3456
ReentrantLock appendLock = new ReentrantLock();
3557
ReentrantLock runLock = new ReentrantLock();
36-
Condition bufferFilled = appendLock.newCondition();
58+
private final Condition bufferSpaceAvailable = appendLock.newCondition();
59+
private final long maxSpinWaitNanos;
60+
private final int maxBufferSize;
61+
private final Runnable beforeAppendLock;
3762

3863
Buffer() {
64+
this(DEFAULT_MAX_SPIN_WAIT_NANOS, DEFAULT_MAX_BUFFER_SIZE, () -> {});
65+
}
66+
67+
Buffer(long maxSpinWaitNanos) {
68+
this(maxSpinWaitNanos, DEFAULT_MAX_BUFFER_SIZE, () -> {});
69+
}
70+
71+
Buffer(long maxSpinWaitNanos, int maxBufferSize, Runnable beforeAppendLock) {
72+
if (maxBufferSize <= 0) {
73+
throw new IllegalArgumentException("maxBufferSize must be positive");
74+
}
75+
this.maxSpinWaitNanos = maxSpinWaitNanos;
76+
this.maxBufferSize = maxBufferSize;
77+
this.beforeAppendLock = beforeAppendLock;
3978
stripedObservationCounts = new AtomicLong[Runtime.getRuntime().availableProcessors()];
4079
for (int i = 0; i < stripedObservationCounts.length; i++) {
41-
stripedObservationCounts[i] = new AtomicLong(0);
80+
stripedObservationCounts[i] = new AtomicLong();
4281
}
4382
}
4483

4584
boolean append(double value) {
46-
int index = stripeIndex(Thread.currentThread().getId(), stripedObservationCounts.length);
47-
AtomicLong observationCountForThread = stripedObservationCounts[index];
48-
long count = observationCountForThread.incrementAndGet();
49-
if ((count & bufferActiveBit) == 0) {
50-
return false; // sign bit not set -> buffer not active.
51-
} else {
52-
doAppend(value);
85+
AtomicLong counter =
86+
stripedObservationCounts[
87+
stripeIndex(Thread.currentThread().getId(), stripedObservationCounts.length)];
88+
long count = counter.incrementAndGet();
89+
// The active bit is the exact handoff decision. An observation either increments its stripe
90+
// before the collector's getAndAdd(BUFFER_ACTIVE_BIT) and takes the direct path, or sees the
91+
// active bit and is buffered in the current generation.
92+
if ((count & BUFFER_ACTIVE_BIT) == 0) {
93+
return false;
94+
}
95+
Generation generation = activeGeneration;
96+
if (generation == null) {
97+
return false;
98+
}
99+
beforeAppendLock.run();
100+
appendLock.lock();
101+
try {
102+
Generation current = activeGeneration;
103+
if (current != generation || !generation.active) {
104+
return false;
105+
}
106+
while (generation.size >= maxBufferSize && generation.active) {
107+
try {
108+
bufferSpaceAvailable.await();
109+
} catch (InterruptedException e) {
110+
Thread.currentThread().interrupt();
111+
return false;
112+
}
113+
}
114+
if (!generation.active) {
115+
return false;
116+
}
117+
if (generation.size >= generation.values.length) {
118+
int doubled =
119+
generation.values.length > maxBufferSize / 2
120+
? maxBufferSize
121+
: generation.values.length * 2;
122+
generation.values =
123+
Arrays.copyOf(
124+
generation.values, Math.min(maxBufferSize, Math.max(INITIAL_BUFFER_SIZE, doubled)));
125+
}
126+
generation.values[generation.size++] = value;
53127
return true;
128+
} finally {
129+
appendLock.unlock();
54130
}
55131
}
56132

57133
static int stripeIndex(long threadId, int stripeCount) {
58134
return (int) Math.floorMod(threadId, stripeCount);
59135
}
60136

61-
private void doAppend(double amount) {
62-
appendLock.lock();
63-
try {
64-
if (bufferPos >= observationBuffer.length) {
65-
observationBuffer = Arrays.copyOf(observationBuffer, observationBuffer.length + 128);
66-
}
67-
observationBuffer[bufferPos] = amount;
68-
bufferPos++;
137+
void reset() {
138+
reset = true;
139+
}
69140

70-
bufferFilled.signalAll();
141+
<T> T observeDirect(Supplier<T> observeFunction) {
142+
// In steady state this is the lock-free path used before this buffer was introduced. Keep the
143+
// lock only while a generation is active, so direct observations cannot race collection/replay.
144+
if (activeGeneration == null) {
145+
return observeFunction.get();
146+
}
147+
observationLock.lock();
148+
try {
149+
return observeFunction.get();
71150
} finally {
72-
appendLock.unlock();
151+
observationLock.unlock();
73152
}
74153
}
75154

76-
/** Must be called by the runnable in the run() method. */
77-
void reset() {
78-
reset = true;
155+
@SuppressWarnings("ThreadPriorityCheck")
156+
<T extends DataPointSnapshot> T run(
157+
Function<Long, Boolean> complete,
158+
Supplier<T> createResult,
159+
Consumer<Double> observeFunction) {
160+
return requireNonNull(run(complete, createResult, observeFunction, true));
79161
}
80162

81163
@SuppressWarnings("ThreadPriorityCheck")
164+
@Nullable
82165
<T extends DataPointSnapshot> T run(
83166
Function<Long, Boolean> complete,
84167
Supplier<T> createResult,
85-
Consumer<Double> observeFunction) {
168+
Consumer<Double> observeFunction,
169+
boolean failOnTimeout) {
170+
Generation generation = new Generation();
86171
double[] buffer;
87172
int bufferSize;
88-
T result;
89-
173+
boolean timedOut = false;
174+
T result = null;
90175
runLock.lock();
91176
try {
92-
// Signal that the buffer is active.
93-
long expectedCount = 0L;
94-
for (AtomicLong observationCount : stripedObservationCounts) {
95-
expectedCount += observationCount.getAndAdd(bufferActiveBit);
177+
long expectedCount;
178+
appendLock.lock();
179+
try {
180+
activeGeneration = generation;
181+
long total = 0;
182+
for (AtomicLong counter : stripedObservationCounts) {
183+
total += counter.getAndAdd(BUFFER_ACTIVE_BIT);
184+
}
185+
expectedCount = total - observationCountOffset;
186+
} finally {
187+
appendLock.unlock();
96188
}
97-
189+
long deadline = System.nanoTime() + maxSpinWaitNanos;
98190
while (!complete.apply(expectedCount)) {
99-
// Wait until all in-flight threads have added their observations to the histogram /
100-
// summary.
101-
// we can't use a condition here, because the other thread doesn't have a lock as it's on
102-
// the fast path.
103-
Thread.yield();
104-
}
105-
result = createResult.get();
106-
107-
// Signal that the buffer is inactive.
108-
long expectedBufferSize = 0;
109-
if (reset) {
110-
for (AtomicLong observationCount : stripedObservationCounts) {
111-
expectedBufferSize += observationCount.getAndSet(0) & ~bufferActiveBit;
112-
}
113-
reset = false;
114-
} else {
115-
for (AtomicLong observationCount : stripedObservationCounts) {
116-
expectedBufferSize += observationCount.addAndGet(bufferActiveBit);
191+
if (System.nanoTime() - deadline >= 0) {
192+
timedOut = true;
193+
break;
117194
}
195+
Thread.yield();
118196
}
119-
expectedBufferSize -= expectedCount;
120-
121-
appendLock.lock();
197+
observationLock.lock();
122198
try {
123-
while (bufferPos < expectedBufferSize) {
124-
// Wait until all in-flight threads have added their observations to the buffer.
125-
bufferFilled.await();
126-
}
199+
result = timedOut ? null : createResult.get();
127200
} finally {
128-
appendLock.unlock();
201+
try {
202+
appendLock.lock();
203+
try {
204+
generation.active = false;
205+
for (AtomicLong counter : stripedObservationCounts) {
206+
counter.addAndGet(BUFFER_ACTIVE_BIT);
207+
}
208+
if (reset) {
209+
observationCountOffset += expectedCount;
210+
reset = false;
211+
}
212+
buffer = generation.values;
213+
bufferSize = generation.size;
214+
generation.values = EMPTY_BUFFER;
215+
generation.size = 0;
216+
bufferSpaceAvailable.signalAll();
217+
} finally {
218+
appendLock.unlock();
219+
}
220+
for (int i = 0; i < bufferSize; i++) {
221+
observeFunction.accept(buffer[i]);
222+
}
223+
// Keep the inactive generation visible until replay completes. An appender that loses the
224+
// generation race must take observationLock before observing directly.
225+
activeGeneration = null;
226+
} finally {
227+
observationLock.unlock();
228+
}
129229
}
130-
131-
buffer = observationBuffer;
132-
bufferSize = bufferPos;
133-
observationBuffer = new double[0];
134-
bufferPos = 0;
135-
} catch (InterruptedException e) {
136-
throw new RuntimeException(e);
230+
if (timedOut && failOnTimeout) {
231+
throw new IllegalStateException("Timed out while waiting for in-flight observations.");
232+
}
233+
return result;
137234
} finally {
138235
runLock.unlock();
139236
}
140-
141-
for (int i = 0; i < bufferSize; i++) {
142-
observeFunction.accept(buffer[i]);
143-
}
144-
return result;
145237
}
146238
}

0 commit comments

Comments
 (0)