diff --git a/CHANGES.txt b/CHANGES.txt index d2008610ce..041b524cc9 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,6 +1,7 @@ 5.0.2 * Correct out-of-date metrics and configuration documentation for SAI (CASSANDRA-19898) Merged from 4.1: + * Fix race condition in DecayingEstimatedHistogramReservoir during rescale (CASSANDRA-19365) Merged from 4.0: * Ensure thread-safety for CommitLogArchiver in CommitLog (CASSANDRA-19960) * Fix text containing "/*" being interpreted as multiline comment in cqlsh (CASSANDRA-17667) diff --git a/src/java/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoir.java b/src/java/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoir.java index 565e33f1b5..cfca646042 100644 --- a/src/java/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoir.java +++ b/src/java/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoir.java @@ -25,15 +25,15 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Objects; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLongArray; +import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import com.google.common.annotations.VisibleForTesting; import com.google.common.primitives.Ints; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.codahale.metrics.ExponentiallyDecayingReservoir; import com.codahale.metrics.Reservoir; import com.codahale.metrics.Snapshot; import org.apache.cassandra.utils.EstimatedHistogram; @@ -52,9 +52,8 @@ import static org.apache.cassandra.config.CassandraRelevantProperties.DECAYING_E *

* The histogram use forward decay [1] to make recent values more significant. The forward decay factor will be doubled * every minute (half-life time set to 60 seconds) [2]. The forward decay landmark is reset every 30 minutes (or at - * first read/update after 30 minutes). During landmark reset, updates and reads in the reservoir will be blocked in a - * fashion similar to the one used in the metrics library [3]. The 30 minute rescale interval is used based on the - * assumption that in an extreme case we would have to collect a metric 1M times for a single bucket each second. By the + * first read/update after 30 minutes). The 30 minute rescale interval is used based on the assumption that in an + * extreme case we would have to collect a metric 1M times for a single bucket each second. By the * end of the 30:th minute all collected values will roughly add up to 1.000.000 * 60 * pow(2, 30) which can be * represented with 56 bits giving us some head room in a signed 64 bit long. *

@@ -82,6 +81,8 @@ import static org.apache.cassandra.config.CassandraRelevantProperties.DECAYING_E *

  • [2]: https://en.wikipedia.org/wiki/Half-life
  • *
  • [3]: https://github.com/dropwizard/metrics/blob/v3.1.2/metrics-core/src/main/java/com/codahale/metrics/ExponentiallyDecayingReservoir.java
  • * + * + * @see ExponentiallyDecayingReservoir */ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoir { @@ -149,19 +150,20 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi private final long[] bucketOffsets; private final int distributionPrime; + private static final AtomicReferenceFieldUpdater decayingBucketsUpdater = + AtomicReferenceFieldUpdater.newUpdater(DecayingEstimatedHistogramReservoir.class, DecayingBuckets.class, "decayingBuckets"); + // decayingBuckets and buckets are one element longer than bucketOffsets -- the last element is values greater than the last offset - private final AtomicLongArray decayingBuckets; + private volatile DecayingBuckets decayingBuckets; private final AtomicLongArray buckets; public static final long HALF_TIME_IN_S = 60L; public static final double MEAN_LIFETIME_IN_S = HALF_TIME_IN_S / Math.log(2.0); public static final long LANDMARK_RESET_INTERVAL_IN_NS = TimeUnit.MINUTES.toNanos(30L); - - private final AtomicBoolean rescaling = new AtomicBoolean(false); - private volatile long decayLandmark; - // Wrapper around System.nanoTime() to simplify unit testing. private final MonotonicClock clock; + /** Interval in minutes to reset the forward decay landmark for the decaying histograms. Default {@code 30 mins}. */ + private final long landmarkResetIntervalInNs; /** * Construct a decaying histogram with default number of buckets and without considering zeroes. @@ -202,6 +204,16 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi @VisibleForTesting DecayingEstimatedHistogramReservoir(boolean considerZeroes, int bucketCount, int stripes, MonotonicClock clock) + { + this(considerZeroes, bucketCount, stripes, clock, LANDMARK_RESET_INTERVAL_IN_NS); + } + + @VisibleForTesting + public DecayingEstimatedHistogramReservoir(boolean considerZeroes, + int bucketCount, + int stripes, + MonotonicClock clock, + long landmarkResetIntervalInNs) { assert bucketCount <= MAX_BUCKET_COUNT : "bucket count cannot exceed: " + MAX_BUCKET_COUNT; @@ -222,10 +234,10 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi } nStripes = stripes; - decayingBuckets = new AtomicLongArray((bucketOffsets.length + 1) * nStripes); - buckets = new AtomicLongArray((bucketOffsets.length + 1) * nStripes); this.clock = clock; - decayLandmark = clock.now(); + buckets = new AtomicLongArray((bucketOffsets.length + 1) * nStripes); + decayingBuckets = new DecayingBuckets(clock.now()); + this.landmarkResetIntervalInNs = landmarkResetIntervalInNs; int distributionPrime = 1; for (int prime : DISTRIBUTION_PRIMES) { @@ -246,11 +258,11 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi public void update(long value) { long now = clock.now(); - rescaleIfNeeded(now); + DecayingBuckets rescaledDecayingBuckets = rescaleIfNeeded(now); int index = findIndex(bucketOffsets, value); - updateBucket(decayingBuckets, index, Math.round(forwardDecayWeight(now))); + rescaledDecayingBuckets.update(index, now); updateBucket(buckets, index, 1); } @@ -288,11 +300,6 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi return value <= bucketOffsets[firstCandidate] ? firstCandidate : firstCandidate + 1; } - private double forwardDecayWeight(long now) - { - return Math.exp(TimeUnit.NANOSECONDS.toSeconds(now - decayLandmark) / MEAN_LIFETIME_IN_S); - } - /** * Returns the logical number of buckets where recorded values are stored. The actual number of physical buckets * is size() * stripeCount() @@ -322,32 +329,34 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi @Override public Snapshot getSnapshot() { - rescaleIfNeeded(); return new EstimatedHistogramReservoirSnapshot(this); } @Override public Snapshot getPercentileSnapshot() { - rescaleIfNeeded(); return new DecayingBucketsOnlySnapshot(this); } + private DecayingBuckets getDecayingBuckets() + { + return rescaleIfNeeded(clock.now()); + } + /** * @return true if this histogram has overflowed -- that is, a value larger than our largest bucket could bound was added */ @VisibleForTesting boolean isOverflowed() { - return bucketValue(bucketOffsets.length, true) > 0; + return bucketValue(bucketOffsets.length, getDecayingBuckets().decayBuckets) > 0; } - private long bucketValue(int index, boolean withDecay) + private long bucketValue(int index, AtomicLongArray buckets) { long val = 0; - AtomicLongArray bs = withDecay ? decayingBuckets : buckets; for (int stripe = 0; stripe < nStripes; stripe++) - val += bs.get(stripedIndex(index, stripe)); + val += buckets.get(stripedIndex(index, stripe)); return val; } @@ -355,65 +364,41 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi @VisibleForTesting long stripedBucketValue(int i, boolean withDecay) { - return withDecay ? decayingBuckets.get(i) : buckets.get(i); + return withDecay ? getDecayingBuckets().decayBuckets.get(i) : buckets.get(i); } - private void rescaleIfNeeded() + private DecayingBuckets rescaleIfNeeded(long now) { - rescaleIfNeeded(clock.now()); - } - - private void rescaleIfNeeded(long now) - { - if (needRescale(now)) + DecayingBuckets buckets = decayingBuckets; + while (now - buckets.decayLandmark > landmarkResetIntervalInNs) { - if (rescaling.compareAndSet(false, true)) - { - try - { - rescale(now); - } - finally - { - decayLandmark = now; - rescaling.set(false); - } - } - } - } + double rescaleFactor = buckets.forwardDecayWeight(now); + DecayingBuckets newBuckets = new DecayingBuckets(now); + for (int i = 0; i < buckets.decayBuckets.length(); i++) + newBuckets.decayBuckets.set(i, Math.round(buckets.decayBuckets.get(i) / rescaleFactor)); - private void rescale(long now) - { - // despite striping its safe to rescale each bucket individually - final double rescaleFactor = forwardDecayWeight(now); - for (int i = 0; i < decayingBuckets.length(); i++) - { - long newValue = Math.round(decayingBuckets.get(i) / rescaleFactor); - decayingBuckets.set(i, newValue); + boolean success = decayingBucketsUpdater.compareAndSet(this, buckets, newBuckets); + if (success) + return newBuckets; + buckets = decayingBuckets; } - } - - private boolean needRescale(long now) - { - return (now - decayLandmark) > LANDMARK_RESET_INTERVAL_IN_NS; + return buckets; } @VisibleForTesting public void clear() { - final int bucketCount = decayingBuckets.length(); - for (int i = 0; i < bucketCount; i++) - { - decayingBuckets.set(i, 0L); + for (int i = 0; i < buckets.length(); i++) buckets.set(i, 0L); - } + + decayingBucketsUpdater.set(this, new DecayingBuckets(clock.now())); } /** * Replaces current internal values with the given one from a Snapshot. This method is NOT thread safe, values * added at the same time to this reservoir using methods such as update may lose their data */ - public void rebase(EstimatedHistogramReservoirSnapshot snapshot) + private void rebase(EstimatedHistogramReservoirSnapshot snapshot) { // Check bucket count (a snapshot always has one stripe so the logical bucket count is used if (size() != snapshot.decayingBuckets.length) @@ -430,19 +415,54 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi } } - this.decayLandmark = snapshot.snapshotLandmark; + DecayingBuckets newDecayingBuckets = new DecayingBuckets(snapshot.snapshotLandmark); for (int i = 0; i < size(); i++) { // set rebased values in the first stripe and clear out all other data - decayingBuckets.set(stripedIndex(i, 0), snapshot.decayingBuckets[i]); + newDecayingBuckets.decayBuckets.set(stripedIndex(i, 0), snapshot.decayingBuckets[i]); buckets.set(stripedIndex(i, 0), snapshot.values[i]); for (int stripe = 1; stripe < nStripes; stripe++) { - decayingBuckets.set(stripedIndex(i, stripe), 0); + newDecayingBuckets.decayBuckets.set(stripedIndex(i, stripe), 0); buckets.set(stripedIndex(i, stripe), 0); } } + decayingBucketsUpdater.set(this, newDecayingBuckets); + } + /** + * The DecayingBuckets class provides a facility to prevent destruction of the reservoir internal state from + * occurring. The root cause of CASSANDRA-19365 is lack of synchronization between udpates and rescaling. + * This class lets us retain lack of synchronization (for performance reasons) while changing the race condition + * effect from destructive to benign. + *

    + * DecayingBuckets class encapsulates the decaying buckets and the decay landmark together. The decay landmark is + * immutable and so the values in the buckets are consistent with the landmark at any given time. In particular, + * every update is always given a weight that's consistent with weights given to other updates of the same buckets. + *

    + * Additionally, the class allows creating snapshots of the reservoir without risking that the snapshot uses data + * from a half-rescaled reservoir. + */ + private class DecayingBuckets + { + private final long decayLandmark; + private final AtomicLongArray decayBuckets; + + public DecayingBuckets(long decayLandmark) + { + this.decayLandmark = decayLandmark; + this.decayBuckets = new AtomicLongArray((bucketOffsets.length + 1) * nStripes); + } + + public void update(int index, long now) + { + updateBucket(decayBuckets, index, forwardDecayWeight(now)); + } + + private long forwardDecayWeight(long now) + { + return Math.round(Math.exp(TimeUnit.NANOSECONDS.toSeconds(now - decayLandmark) / MEAN_LIFETIME_IN_S)); + } } private static abstract class AbstractSnapshot extends Snapshot @@ -649,16 +669,19 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi super(reservoir); int length = reservoir.size(); - double rescaleFactor = reservoir.forwardDecayWeight(reservoir.clock.now()); - this.values = new long[length]; - this.snapshotLandmark = reservoir.decayLandmark; + + DecayingBuckets decayingBucketsRef = reservoir.getDecayingBuckets(); + + this.snapshotLandmark = decayingBucketsRef.decayLandmark; + double rescaleFactor = decayingBucketsRef.forwardDecayWeight(reservoir.clock.now()); for (int i = 0; i < length; i++) { - this.decayingBuckets[i] = Math.round(reservoir.bucketValue(i, true) / rescaleFactor); - this.values[i] = reservoir.bucketValue(i, false); + this.decayingBuckets[i] = Math.round(reservoir.bucketValue(i, decayingBucketsRef.decayBuckets) / rescaleFactor); + this.values[i] = reservoir.bucketValue(i, reservoir.buckets); } + this.count = count(); this.reservoir = reservoir; } @@ -777,11 +800,12 @@ public class DecayingEstimatedHistogramReservoir implements SnapshottingReservoi super(reservoir); int length = reservoir.size(); - double rescaleFactor = reservoir.forwardDecayWeight(reservoir.clock.now()); + DecayingBuckets decayingBucketsRef = reservoir.getDecayingBuckets(); + double rescaleFactor = decayingBucketsRef.forwardDecayWeight(reservoir.clock.now()); for (int i = 0; i < length; i++) { - this.decayingBuckets[i] = Math.round(reservoir.bucketValue(i, true) / rescaleFactor); + this.decayingBuckets[i] = Math.round(reservoir.bucketValue(i, decayingBucketsRef.decayBuckets) / rescaleFactor); } this.count = count(); diff --git a/test/microbench/org/apache/cassandra/test/microbench/DecayingEstimatedHistogramBench.java b/test/microbench/org/apache/cassandra/test/microbench/DecayingEstimatedHistogramBench.java new file mode 100644 index 0000000000..79be0a13ea --- /dev/null +++ b/test/microbench/org/apache/cassandra/test/microbench/DecayingEstimatedHistogramBench.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.test.microbench; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +import org.apache.cassandra.metrics.DecayingEstimatedHistogramReservoir; +import org.apache.cassandra.utils.MonotonicClock; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import static org.apache.cassandra.metrics.DecayingEstimatedHistogramReservoir.DEFAULT_BUCKET_COUNT; +import static org.apache.cassandra.metrics.DecayingEstimatedHistogramReservoir.DEFAULT_STRIPE_COUNT; +import static org.apache.cassandra.metrics.DecayingEstimatedHistogramReservoir.DEFAULT_ZERO_CONSIDERATION; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 4, time = 2, timeUnit = TimeUnit.SECONDS) +@Threads(4) +@Fork(value = 3) +@State(Scope.Benchmark) +public class DecayingEstimatedHistogramBench +{ + @Param({ "100000", "500000", "1000000" }) + private long landmarkResetIntervalNs; + + DecayingEstimatedHistogramReservoir reservoir; + + @Setup + public void setup() + { + reservoir = new DecayingEstimatedHistogramReservoir(DEFAULT_ZERO_CONSIDERATION, + DEFAULT_BUCKET_COUNT, + DEFAULT_STRIPE_COUNT, + MonotonicClock.Global.approxTime, + landmarkResetIntervalNs); + } + + @State(Scope.Thread) + public static class HistogramUpdateState + { + int update; + + @Setup(Level.Invocation) + public void setup() throws Throwable + { + update = ThreadLocalRandom.current().nextInt(10, 1000); + } + } + + @Benchmark + public void update(HistogramUpdateState state) + { + reservoir.update(state.update); + } + + public static void main(String[] args) throws RunnerException + { + Options options = new OptionsBuilder() + .include(DecayingEstimatedHistogramBench.class.getSimpleName()) + .build(); + new Runner(options).run(); + } +} diff --git a/test/unit/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoirTest.java b/test/unit/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoirTest.java index 64fb00d208..cd7077bf16 100644 --- a/test/unit/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoirTest.java +++ b/test/unit/org/apache/cassandra/metrics/DecayingEstimatedHistogramReservoirTest.java @@ -18,22 +18,29 @@ package org.apache.cassandra.metrics; +import java.io.ByteArrayOutputStream; import java.util.Arrays; import java.util.Collection; import java.util.Random; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.LockSupport; import java.util.function.Function; +import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Uninterruptibles; import org.junit.Assert; import org.junit.Ignore; import org.junit.Test; import org.junit.experimental.runners.Enclosed; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.codahale.metrics.Snapshot; import org.apache.cassandra.utils.EstimatedHistogram; @@ -42,16 +49,21 @@ import org.apache.cassandra.utils.MonotonicClockTranslation; import org.apache.cassandra.utils.Pair; import org.quicktheories.core.Gen; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.apache.cassandra.metrics.DecayingEstimatedHistogramReservoir.LANDMARK_RESET_INTERVAL_IN_NS; import static org.apache.cassandra.utils.Clock.Global.nanoTime; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.quicktheories.QuickTheory.qt; -import static org.quicktheories.generators.SourceDSL.*; +import static org.quicktheories.generators.SourceDSL.booleans; +import static org.quicktheories.generators.SourceDSL.integers; +import static org.quicktheories.generators.SourceDSL.longs; @RunWith(Enclosed.class) public class DecayingEstimatedHistogramReservoirTest { + public static final Logger logger = LoggerFactory.getLogger(DecayingEstimatedHistogramReservoirTest.class); public static class NonParameterizedTests { public static final int numExamples = 1000000; @@ -540,9 +552,9 @@ public class DecayingEstimatedHistogramReservoirTest DecayingEstimatedHistogramReservoir histogram = new DecayingEstimatedHistogramReservoir(clock); - clock.addNanos(DecayingEstimatedHistogramReservoir.LANDMARK_RESET_INTERVAL_IN_NS - TimeUnit.SECONDS.toNanos(1L)); + clock.addNanos(LANDMARK_RESET_INTERVAL_IN_NS - TimeUnit.SECONDS.toNanos(1L)); - while (clock.now() < DecayingEstimatedHistogramReservoir.LANDMARK_RESET_INTERVAL_IN_NS + TimeUnit.SECONDS.toNanos(1L)) + while (clock.now() < LANDMARK_RESET_INTERVAL_IN_NS + TimeUnit.SECONDS.toNanos(1L)) { clock.addNanos(TimeUnit.MILLISECONDS.toNanos(900)); for (int i = 0; i < 1_000_000; i++) @@ -566,7 +578,7 @@ public class DecayingEstimatedHistogramReservoirTest DecayingEstimatedHistogramReservoir histogram = new DecayingEstimatedHistogramReservoir(clock); DecayingEstimatedHistogramReservoir another = new DecayingEstimatedHistogramReservoir(clock); - clock.addNanos(DecayingEstimatedHistogramReservoir.LANDMARK_RESET_INTERVAL_IN_NS - TimeUnit.SECONDS.toNanos(1L)); + clock.addNanos(LANDMARK_RESET_INTERVAL_IN_NS - TimeUnit.SECONDS.toNanos(1L)); histogram.update(1000); clock.addMillis(100); @@ -604,6 +616,110 @@ public class DecayingEstimatedHistogramReservoirTest assertEquals(2, toSnapshot.apply(histogram).size()); } + /** + * This looks for invalid percentiles that are unchanged for too long to expose the CASSANDRA-19365 race + * condition between rescale and update. The idea is to update a histogram from multiple threads and observe + * if the reported p99 doesn't get stuck at a low value or p50 at a high value due to update with high weight + * being inserted after the buckets are rescaled. + *

    + * The load has 95% of 42, and 5% of the time it's 1109. Despite that the histogram may be convinced for a long + * time that p99 is 42 or that p50 is 1109. The reason may be seen in the snapshot dump, where after rescale + * the bucket values may get very big due to the race condition and too big weight of the inserted samples. + * The values were picked to match bucket boundaries, but that's only for aesthetics. + *

    + * In production the rescale happens every 30 minutes. In this test time we're pushing time to run faster, + * roughly 1000 times faster to hit the race condition in a reasonable time. + */ + @Test + public void testConcurrentUpdateAndRescale() throws InterruptedException + { + int UPDATE_THREADS = 60; + int maxTestDurationMillis = 30_000; + // how many times in a row the percentiles may be invalid before we fail the test + int tooManySuspiciousPercentilesThreshold = 5; // 5 translates to 500ms * 1000 speedup = 500s = 8m20s; + AtomicBoolean stop = new AtomicBoolean(false); + AtomicBoolean failed = new AtomicBoolean(false); + TestClock clock = new TestClock(); + + DecayingEstimatedHistogramReservoir histogram = new DecayingEstimatedHistogramReservoir(clock); + ExecutorService executors = Executors.newFixedThreadPool(2 + UPDATE_THREADS); + + for (int i = 0; i < UPDATE_THREADS; i++) + { + executors.submit(() -> { + while (!stop.get() && !Thread.currentThread().isInterrupted()) + { + // a mischievous usage pattern to quickly trigger the + // CASSANDRA-19365 race condition; + // the load has 95% of 42, and only 5% of the time it's 1109 + // and yet, the histogram may be convinced for a long time that + // the p99 is 42 or that the p50 is 1109 + for (int sampleIdx = 0; sampleIdx < 900; sampleIdx++) + histogram.update(42); + + for (int sampleIdx = 0; sampleIdx < 50; sampleIdx++) + { + // add some noise so that low value samples do not race with the same likelyhood as the high value samples + Uninterruptibles.sleepUninterruptibly(ThreadLocalRandom.current().nextInt(1, 10), MILLISECONDS); + histogram.update(1109); + } + } + }); + } + // clock update thread + executors.submit(() -> { + while (!stop.get() && !Thread.currentThread().isInterrupted()) + { + Uninterruptibles.sleepUninterruptibly(1, TimeUnit.MILLISECONDS); + // x1000 speedup so that we hit rescale interval every 30 minutes / 1000 = 1.8s + clock.addMillis(1000); + } + }); + // percentiles check thread + executors.submit(() -> { + // how many times in a row p99 was suspiciously low or P50 suspiciously high + int consecutiveInvalidPercentiles = 0; + + // how often to check the percentiles + int iterationDelayMillis = 100; + + for (int i = 0; i < maxTestDurationMillis / iterationDelayMillis; i++) + { + Uninterruptibles.sleepUninterruptibly(iterationDelayMillis, MILLISECONDS); + Snapshot snapshot = toSnapshot.apply(histogram); + double p99 = snapshot.getValue(0.99); + double p50 = snapshot.getValue(0.50); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + snapshot.dump(output); + String decayingNonZeroBuckets = Arrays.stream(output.toString().split("\n")) + .filter(s -> !s.equals("0")) + .collect(Collectors.joining(",")); + logger.info("\"clock={}, p50={}, p99={}, decaying non-zero buckets: {}", + clock.now() / 1_000_000, p50, p99, decayingNonZeroBuckets); + if (p99 < 100 || p50 > 900) + { + consecutiveInvalidPercentiles++; + logger.warn("p50 or p99 at suspicious level p50={}, p99={}", p50, p99); + if (consecutiveInvalidPercentiles > tooManySuspiciousPercentilesThreshold) + { + failed.set(true); + stop.set(true); + break; + } + } + else + { + consecutiveInvalidPercentiles = 0; + } + } + stop.set(true); + }); + executors.shutdown(); + boolean success = executors.awaitTermination(maxTestDurationMillis * 2, MILLISECONDS); + Assert.assertFalse("p50 too high or p99 too low for too long", failed.get()); + Assert.assertTrue("Timeout exceeded the limit", success); + } + private void assertEstimatedQuantile(long expectedValue, double actualValue) { assertTrue("Expected at least [" + expectedValue + "] but actual is [" + actualValue + ']', actualValue >= expectedValue);