Skip to content

Commit 5da5c49

Browse files
committed
fix: keep late observations out of subsequent collection buffers
Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent e45bd73 commit 5da5c49

2 files changed

Lines changed: 130 additions & 73 deletions

File tree

  • prometheus-metrics-core/src

‎prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Buffer.java‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ private static final class Generation {
4949
// available processors. This is simpler than the striping used by LongAdder, so hot spots remain
5050
// possible when several recording threads resolve to the same stripe.
5151
private final AtomicLong[] stripedObservationCounts;
52+
// Protected by appendLock. These are absolute per-stripe observation counts at activation, not
53+
// the reset-adjusted count used by complete. Reused across generations to avoid scrape
54+
// allocations.
55+
private final long[] generationStartCounts;
5256
private final ReentrantLock observationLock = new ReentrantLock();
5357
private boolean reset;
5458
private long observationCountOffset;
@@ -76,33 +80,41 @@ private static final class Generation {
7680
this.maxBufferSize = maxBufferSize;
7781
this.beforeAppendLock = beforeAppendLock;
7882
stripedObservationCounts = new AtomicLong[Runtime.getRuntime().availableProcessors()];
83+
generationStartCounts = new long[stripedObservationCounts.length];
7984
for (int i = 0; i < stripedObservationCounts.length; i++) {
8085
stripedObservationCounts[i] = new AtomicLong();
8186
}
8287
}
8388

8489
boolean append(double value) {
85-
AtomicLong counter =
86-
stripedObservationCounts[
87-
stripeIndex(Thread.currentThread().getId(), stripedObservationCounts.length)];
90+
int stripe = stripeIndex(Thread.currentThread().getId(), stripedObservationCounts.length);
91+
AtomicLong counter = stripedObservationCounts[stripe];
8892
long count = counter.incrementAndGet();
8993
// The active bit is the exact handoff decision. An observation either increments its stripe
9094
// 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.
95+
// active bit and may be buffered. The stripe ticket below also checks that it was not counted
96+
// by a later collection that started before this thread read activeGeneration.
9297
if ((count & BUFFER_ACTIVE_BIT) == 0) {
9398
return false;
9499
}
100+
// Allow tests to pause between allocating an observation ticket and reading the generation.
101+
beforeAppendLock.run();
95102
Generation generation = activeGeneration;
96103
if (generation == null) {
97104
return false;
98105
}
99-
beforeAppendLock.run();
100106
appendLock.lock();
101107
try {
102108
Generation current = activeGeneration;
103109
if (current != generation || !generation.active) {
104110
return false;
105111
}
112+
if ((count & ~BUFFER_ACTIVE_BIT) <= generationStartCounts[stripe]) {
113+
// This observation incremented its stripe in an earlier generation. The current collector
114+
// already includes it in expectedCount, so buffering it here would make the collector wait
115+
// for an observation that is only replayed after that same wait finishes.
116+
return false;
117+
}
106118
while (generation.size >= maxBufferSize && generation.active) {
107119
try {
108120
bufferSpaceAvailable.await();
@@ -179,8 +191,10 @@ <T extends DataPointSnapshot> T run(
179191
try {
180192
activeGeneration = generation;
181193
long total = 0;
182-
for (AtomicLong counter : stripedObservationCounts) {
183-
total += counter.getAndAdd(BUFFER_ACTIVE_BIT);
194+
for (int i = 0; i < stripedObservationCounts.length; i++) {
195+
long count = stripedObservationCounts[i].getAndAdd(BUFFER_ACTIVE_BIT);
196+
generationStartCounts[i] = count;
197+
total += count;
184198
}
185199
expectedCount = total - observationCountOffset;
186200
} finally {

‎prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/BufferTest.java‎

Lines changed: 109 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import java.util.ArrayList;
99
import java.util.List;
1010
import java.util.concurrent.CountDownLatch;
11+
import java.util.concurrent.ExecutorService;
12+
import java.util.concurrent.Executors;
13+
import java.util.concurrent.Future;
1114
import java.util.concurrent.TimeUnit;
1215
import java.util.concurrent.atomic.AtomicBoolean;
1316
import java.util.concurrent.atomic.AtomicLong;
@@ -180,83 +183,123 @@ void interruptedAppenderLeavesBoundedBufferWait() throws InterruptedException {
180183
}
181184

182185
@Test
183-
void lateAppenderCannotBeAddedToTheNextGeneration() throws InterruptedException {
184-
CountDownLatch firstRunStarted = new CountDownLatch(1);
185-
CountDownLatch firstRunMayFinish = new CountDownLatch(1);
186-
CountDownLatch stalled = new CountDownLatch(1);
187-
CountDownLatch release = new CountDownLatch(1);
186+
void lateAppenderCountedByNextGenerationMustNotBeBufferedAgain() throws Exception {
187+
assertLateAppenderHandoff(false);
188+
}
189+
190+
@Test
191+
void lateAppenderHandoffUsesAbsoluteStripeCountsAfterReset() throws Exception {
192+
assertLateAppenderHandoff(true);
193+
}
194+
195+
private static void assertLateAppenderHandoff(boolean reset) throws Exception {
196+
CountDownLatch firstSnapshotStarted = new CountDownLatch(1);
197+
CountDownLatch finishFirstSnapshot = new CountDownLatch(1);
198+
CountDownLatch observationCounted = new CountDownLatch(1);
199+
CountDownLatch readGeneration = new CountDownLatch(1);
188200
CountDownLatch secondRunStarted = new CountDownLatch(1);
189-
AtomicBoolean appended = new AtomicBoolean();
190201
AtomicLong completedObservations = new AtomicLong();
202+
AtomicLong secondExpectedCount = new AtomicLong();
203+
AtomicBoolean pauseFirstAppender = new AtomicBoolean(true);
191204
Buffer buffer =
192205
new Buffer(
193-
TimeUnit.SECONDS.toNanos(1),
206+
TimeUnit.SECONDS.toNanos(5),
194207
16,
195208
() -> {
196-
stalled.countDown();
197-
try {
198-
release.await();
199-
} catch (InterruptedException e) {
200-
Thread.currentThread().interrupt();
209+
if (pauseFirstAppender.compareAndSet(true, false)) {
210+
observationCounted.countDown();
211+
awaitLatch(readGeneration);
201212
}
202213
});
203-
Thread firstRun =
204-
new Thread(
205-
() ->
206-
buffer.run(
207-
ignored -> {
208-
firstRunStarted.countDown();
209-
return firstRunMayFinish.getCount() == 0;
210-
},
211-
() -> new CounterSnapshot.CounterDataPointSnapshot(0, Labels.EMPTY, null, 0),
212-
ignored -> {}),
213-
"buffer-first-runner");
214-
firstRun.setDaemon(true);
215-
firstRun.start();
216-
assertThat(firstRunStarted.await(5, TimeUnit.SECONDS)).isTrue();
214+
if (reset) {
215+
assertThat(buffer.append(1.0)).isFalse();
216+
buffer.observeDirect(completedObservations::incrementAndGet);
217+
}
218+
ExecutorService executor = Executors.newFixedThreadPool(2);
219+
try {
220+
Future<CounterSnapshot.CounterDataPointSnapshot> firstRun =
221+
executor.submit(
222+
() ->
223+
buffer.run(
224+
expectedCount -> completedObservations.get() == expectedCount,
225+
() -> {
226+
firstSnapshotStarted.countDown();
227+
awaitLatch(finishFirstSnapshot);
228+
CounterSnapshot.CounterDataPointSnapshot snapshot =
229+
new CounterSnapshot.CounterDataPointSnapshot(
230+
completedObservations.get(), Labels.EMPTY, null, 0);
231+
if (reset) {
232+
completedObservations.set(0);
233+
buffer.reset();
234+
}
235+
return snapshot;
236+
},
237+
ignored -> completedObservations.incrementAndGet()));
238+
awaitLatch(firstSnapshotStarted);
217239

218-
Thread appender =
219-
new Thread(
220-
() -> {
221-
appended.set(buffer.append(1.0));
222-
if (!appended.get()) {
223-
buffer.observeDirect(
224-
() -> {
225-
completedObservations.incrementAndGet();
226-
return null;
227-
});
228-
}
229-
},
230-
"buffer-late-appender");
231-
appender.setDaemon(true);
232-
appender.start();
233-
assertThat(stalled.await(5, TimeUnit.SECONDS)).isTrue();
240+
// Increment while generation A is active, but do not read activeGeneration yet.
241+
Future<Boolean> appender =
242+
executor.submit(
243+
() -> {
244+
boolean appended = buffer.append(1.0);
245+
if (!appended) {
246+
buffer.observeDirect(completedObservations::incrementAndGet);
247+
}
248+
return appended;
249+
});
250+
awaitLatch(observationCounted);
251+
finishFirstSnapshot.countDown();
252+
assertThat(firstRun.get(10, TimeUnit.SECONDS).getValue()).isEqualTo(reset ? 1 : 0);
234253

235-
firstRunMayFinish.countDown();
236-
firstRun.join(5_000);
237-
assertThat(firstRun.isAlive()).isFalse();
254+
Future<CounterSnapshot.CounterDataPointSnapshot> secondRun =
255+
executor.submit(
256+
() ->
257+
buffer.run(
258+
expectedCount -> {
259+
secondExpectedCount.set(expectedCount);
260+
secondRunStarted.countDown();
261+
return completedObservations.get() == expectedCount;
262+
},
263+
() ->
264+
new CounterSnapshot.CounterDataPointSnapshot(
265+
completedObservations.get(), Labels.EMPTY, null, 0),
266+
ignored -> completedObservations.incrementAndGet()));
267+
awaitLatch(secondRunStarted);
268+
assertThat(secondExpectedCount).hasValue(1);
269+
// An observation arriving after B's activation still belongs in B's buffer. It must not
270+
// appear in B's snapshot and must be replayed exactly once before the following collection.
271+
assertThat(buffer.append(1.0)).isTrue();
238272

239-
Thread secondRun =
240-
new Thread(
241-
() ->
242-
buffer.run(
243-
expectedCount -> {
244-
secondRunStarted.countDown();
245-
return completedObservations.get() == expectedCount;
246-
},
247-
() -> new CounterSnapshot.CounterDataPointSnapshot(0, Labels.EMPTY, null, 0),
248-
ignored -> {}),
249-
"buffer-second-runner");
250-
secondRun.setDaemon(true);
251-
secondRun.start();
252-
assertThat(secondRunStarted.await(5, TimeUnit.SECONDS)).isTrue();
253-
release.countDown();
254-
appender.join(5_000);
255-
secondRun.join(5_000);
273+
// B includes the paused observation in expectedCount. Buffering it in B would make B wait
274+
// until its own timeout/replay; it must instead complete via the direct observation path.
275+
readGeneration.countDown();
276+
assertThat(secondRun.get(10, TimeUnit.SECONDS).getValue()).isEqualTo(1);
277+
assertThat(appender.get(10, TimeUnit.SECONDS)).isFalse();
278+
assertThat(completedObservations).hasValue(2);
279+
assertThat(
280+
buffer
281+
.run(
282+
expectedCount -> completedObservations.get() == expectedCount,
283+
() ->
284+
new CounterSnapshot.CounterDataPointSnapshot(
285+
completedObservations.get(), Labels.EMPTY, null, 0),
286+
ignored -> completedObservations.incrementAndGet())
287+
.getValue())
288+
.isEqualTo(2);
289+
} finally {
290+
finishFirstSnapshot.countDown();
291+
readGeneration.countDown();
292+
executor.shutdownNow();
293+
assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
294+
}
295+
}
256296

257-
assertThat(appender.isAlive()).isFalse();
258-
assertThat(secondRun.isAlive()).isFalse();
259-
assertThat(appended).isFalse();
260-
assertThat(completedObservations).hasValue(1);
297+
private static void awaitLatch(CountDownLatch latch) {
298+
try {
299+
assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
300+
} catch (InterruptedException e) {
301+
Thread.currentThread().interrupt();
302+
throw new RuntimeException(e);
303+
}
261304
}
262305
}

0 commit comments

Comments
 (0)