Skip to content

Commit ebe3d8e

Browse files
committed
fix: bound striped accumulator snapshot latency
Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent 03c3da3 commit ebe3d8e

3 files changed

Lines changed: 78 additions & 22 deletions

File tree

prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/ClassicOnlyAccumulator.java

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import java.util.Set;
44
import java.util.concurrent.ConcurrentHashMap;
5-
import java.util.concurrent.TimeUnit;
65
import java.util.concurrent.atomic.AtomicBoolean;
76
import java.util.concurrent.atomic.AtomicLong;
87

@@ -22,9 +21,6 @@
2221
final class ClassicOnlyAccumulator {
2322

2423
private static final long NOT_WRITING = -1;
25-
// A stalled recorder must not make a scrape wait indefinitely. The next snapshot will retry
26-
// the buffer after the recorder has left its epoch.
27-
private static final long SNAPSHOT_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(1);
2824

2925
private final int bucketCount;
3026
private final AtomicLong epoch = new AtomicLong();
@@ -78,14 +74,17 @@ void observe(int bucket, double value) {
7874
}
7975
}
8076

81-
@SuppressWarnings({"ModifyCollectionInEnhancedForLoop", "ThreadPriorityCheck"})
77+
@SuppressWarnings("ModifyCollectionInEnhancedForLoop")
8278
synchronized Snapshot snapshot() {
79+
// A snapshot is intentionally allowed to be stale for a cell whose recorder is paused. Do not
80+
// wait here: this keeps collect(), getCount(), and getSum() bounded by the registered-cell and
81+
// bucket counts, independent of writer stalls, while a subsequent snapshot includes the
82+
// delayed observation after the recorder publishes NOT_WRITING.
8383
long inactiveEpoch = epoch.getAndIncrement();
8484
int inactiveBuffer = (int) (inactiveEpoch & 1);
85-
long waitDeadline = System.nanoTime() + SNAPSHOT_WAIT_NANOS;
8685

8786
for (Cell cell : cells) {
88-
if (!awaitInactiveBuffer(cell, inactiveBuffer, waitDeadline)) {
87+
if (!canDrainInactiveBuffer(cell, inactiveBuffer)) {
8988
// The writer may be paused indefinitely. Leave this buffer untouched; a later snapshot
9089
// will collect it after the writer has published NOT_WRITING.
9190
continue;
@@ -113,21 +112,12 @@ && isEmpty(cell.buffers[1])
113112
return new Snapshot(collectedBuckets.clone(), collectedCount, collectedSum);
114113
}
115114

116-
@SuppressWarnings("ThreadPriorityCheck")
117-
private boolean awaitInactiveBuffer(Cell cell, int inactiveBuffer, long waitDeadline) {
118-
while (true) {
119-
long writingEpoch = cell.writingEpoch;
120-
// An old writer can still be in the same parity after a pair of epoch flips. It is not
121-
// enough to compare with inactiveEpoch: draining while that writer is active would race
122-
// with its plain bucket writes.
123-
if (writingEpoch == NOT_WRITING || (writingEpoch & 1) != inactiveBuffer) {
124-
return true;
125-
}
126-
if (System.nanoTime() >= waitDeadline) {
127-
return false;
128-
}
129-
Thread.yield();
130-
}
115+
private static boolean canDrainInactiveBuffer(Cell cell, int inactiveBuffer) {
116+
long writingEpoch = cell.writingEpoch;
117+
// An old writer can still be in the same parity after a pair of epoch flips. It is not enough
118+
// to compare with the current epoch: draining while that writer is active would race with its
119+
// plain bucket writes.
120+
return writingEpoch == NOT_WRITING || (writingEpoch & 1) != inactiveBuffer;
131121
}
132122

133123
private static boolean isEmpty(CellBuffer buffer) {

prometheus-metrics-core/src/main/java/io/prometheus/metrics/core/metrics/Histogram.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ private DataPoint() {
232232
@Override
233233
public double getSum() {
234234
if (classicOnlyAccumulator != null) {
235+
// A paused recorder may make this exact value temporarily stale. The next call retries
236+
// its buffer rather than blocking the scrape indefinitely.
235237
return classicOnlyAccumulator.snapshot().sum;
236238
}
237239
return sum.sum();
@@ -240,6 +242,8 @@ public double getSum() {
240242
@Override
241243
public long getCount() {
242244
if (classicOnlyAccumulator != null) {
245+
// A paused recorder may make this exact value temporarily stale. The next call retries
246+
// its buffer rather than blocking the scrape indefinitely.
243247
return classicOnlyAccumulator.snapshot().count;
244248
}
245249
return count.sum();
@@ -311,6 +315,8 @@ private void doObserve(double value, boolean fromBuffer) {
311315
private HistogramSnapshot.HistogramDataPointSnapshot collect(Labels labels) {
312316
Exemplars exemplars = exemplarSampler != null ? exemplarSampler.collect() : Exemplars.EMPTY;
313317
if (classicOnlyAccumulator != null) {
318+
// collect() is intentionally allowed to return a stale snapshot for a paused recorder;
319+
// the following collection retries its buffer without an unbounded wait.
314320
ClassicOnlyAccumulator.Snapshot snapshot = classicOnlyAccumulator.snapshot();
315321
return new HistogramSnapshot.HistogramDataPointSnapshot(
316322
ClassicHistogramBuckets.of(classicUpperBounds, snapshot.buckets),

prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/ClassicOnlyAccumulatorTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,66 @@ void stalledWriterDoesNotBlockSnapshotAndIsCollectedLater() throws Exception {
3737
assertThat(collected.sum).isEqualTo(1.0);
3838
}
3939

40+
@Test
41+
void stalledCellDoesNotPreventHealthyCellsFromBeingCollected() throws Exception {
42+
ClassicOnlyAccumulator accumulator = new ClassicOnlyAccumulator(1);
43+
accumulator.observe(0, 1.0);
44+
for (int i = 0; i < 4; i++) {
45+
Thread recorder = new Thread(() -> accumulator.observe(0, 1.0));
46+
recorder.start();
47+
recorder.join();
48+
}
49+
50+
Object stalledCell = onlyCell(accumulator);
51+
Field writingEpoch = stalledCell.getClass().getDeclaredField("writingEpoch");
52+
writingEpoch.setAccessible(true);
53+
writingEpoch.setLong(stalledCell, 0);
54+
55+
ClassicOnlyAccumulator.Snapshot first = accumulator.snapshot();
56+
// The four healthy cells are drained even though the first cell consumes its own wait budget.
57+
assertThat(first.count).isEqualTo(4);
58+
59+
writingEpoch.setLong(stalledCell, -1);
60+
accumulator.snapshot();
61+
ClassicOnlyAccumulator.Snapshot finalSnapshot = accumulator.snapshot();
62+
assertThat(finalSnapshot.count).isEqualTo(5);
63+
}
64+
65+
@Test
66+
void activeWriterCanResumeAfterAStaleSnapshot() throws Exception {
67+
ClassicOnlyAccumulator accumulator = new ClassicOnlyAccumulator(1);
68+
accumulator.observe(0, 1.0);
69+
Object cell = onlyCell(accumulator);
70+
Field writingEpoch = cell.getClass().getDeclaredField("writingEpoch");
71+
writingEpoch.setAccessible(true);
72+
CountDownLatch entered = new CountDownLatch(1);
73+
CountDownLatch release = new CountDownLatch(1);
74+
Thread writer =
75+
new Thread(
76+
() -> {
77+
try {
78+
writingEpoch.setLong(cell, 0);
79+
entered.countDown();
80+
release.await();
81+
writingEpoch.setLong(cell, -1);
82+
} catch (InterruptedException e) {
83+
Thread.currentThread().interrupt();
84+
} catch (IllegalAccessException e) {
85+
throw new AssertionError(e);
86+
}
87+
});
88+
writer.start();
89+
entered.await();
90+
91+
ClassicOnlyAccumulator.Snapshot stale = accumulator.snapshot();
92+
assertThat(stale.count).isZero();
93+
release.countDown();
94+
writer.join();
95+
accumulator.snapshot();
96+
ClassicOnlyAccumulator.Snapshot resumed = accumulator.snapshot();
97+
assertThat(resumed.count).isEqualTo(1);
98+
}
99+
40100
@Test
41101
void emptyCellsAreReclaimedAndCanBeReused() throws Exception {
42102
ClassicOnlyAccumulator accumulator = new ClassicOnlyAccumulator(1);

0 commit comments

Comments
 (0)