/*
* Copyright (c) 2015 Spotify AB.
*
* 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 com.spotify.heroic.test;
import static com.spotify.heroic.test.Data.events;
import static com.spotify.heroic.test.Data.points;
import static org.junit.Assert.assertEquals;
import static org.junit.Assume.assumeTrue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.spotify.heroic.HeroicConfig;
import com.spotify.heroic.HeroicCore;
import com.spotify.heroic.HeroicCoreInstance;
import com.spotify.heroic.QueryOptions;
import com.spotify.heroic.common.DateRange;
import com.spotify.heroic.common.GroupMember;
import com.spotify.heroic.common.Series;
import com.spotify.heroic.metric.FetchData;
import com.spotify.heroic.metric.FetchQuotaWatcher;
import com.spotify.heroic.metric.Metric;
import com.spotify.heroic.metric.MetricBackend;
import com.spotify.heroic.metric.MetricCollection;
import com.spotify.heroic.metric.MetricManagerModule;
import com.spotify.heroic.metric.MetricModule;
import com.spotify.heroic.metric.MetricType;
import com.spotify.heroic.metric.WriteMetric;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import org.junit.runners.model.Statement;
@Slf4j
public abstract class AbstractMetricBackendIT {
protected final Series s1 = Series.of("s1", ImmutableMap.of("id", "s1"));
protected final Series s2 = Series.of("s2", ImmutableMap.of("id", "s2"));
protected final Series s3 = Series.of("s3", ImmutableMap.of("id", "s3"));
public static final Map<String, String> EVENT = ImmutableMap.of();
protected MetricBackend backend;
/* For backends which currently does not implement period edge cases correctly,
* See: https://github.com/spotify/heroic/pull/208 */
protected boolean brokenSegmentsPr208 = false;
protected boolean eventSupport = false;
protected Optional<Integer> maxBatchSize = Optional.empty();
@Rule
public TestRule setupBackend = (base, description) -> new Statement() {
@Override
public void evaluate() throws Throwable {
Optional<MetricModule> module = setupModule();
if (module.isPresent()) {
final MetricManagerModule.Builder metric =
MetricManagerModule.builder().backends(ImmutableList.of(module.get()));
final HeroicConfig.Builder fragment = HeroicConfig.builder().metrics(metric);
final HeroicCoreInstance core = HeroicCore
.builder()
.setupShellServer(false)
.setupService(false)
.configFragment(fragment)
.build()
.newInstance();
core.start().get();
backend = core
.inject(c -> c
.metricManager()
.groupSet()
.inspectAll()
.stream()
.map(GroupMember::getMember)
.findFirst())
.orElseThrow(() -> new IllegalStateException("Failed to find backend"));
base.evaluate();
core.shutdown().get();
} else {
log.info("Omitting " + description + " since module is not configured");
}
}
};
@Before
public void setup() {
setupSupport();
}
protected abstract Optional<MetricModule> setupModule();
protected Optional<Long> period() {
return Optional.empty();
}
/**
* Setup backend-specific support.
*/
protected void setupSupport() {
}
@Test
public void testInterval() throws Exception {
newCase()
.input(99L, 100L, 101L, 199L, 200L, 201L)
.expect(101L, 199L, 200L)
.forEach((input, expected) -> {
verifyReadWrite(input, expected, new DateRange(100L, 200L));
});
}
/**
* Some backends have optimized code for writing a single sample.
*/
@Test
public void testOne() throws Exception {
newCase().input(100L).expect(100L).forEach((input, expected) -> {
verifyReadWrite(input, expected, new DateRange(99L, 100L));
});
}
/**
* Some backends have limits on how many metrics should be written in a single request.
*
* This test case creates a dense pool of metrics after a given max batch size to make sure that
* they can be written and read out.
*/
@Test
public void testMaxBatchSize() throws Exception {
assumeTrue("max batch size", maxBatchSize.isPresent());
final int maxBatchSize = this.maxBatchSize.get();
newCase().denseStart(100).dense(maxBatchSize * 4).forEach((input, expected) -> {
verifyReadWrite(input, expected, new DateRange(99L, 100L + (maxBatchSize * 4)));
});
}
/**
* This test is run if the specific test overrides the {@link #period()} method and returns a
* value (not {@link java.util.Optional#empty()}.
* <p>
* This value will be considered the period of the metric backend, which indicates at what
* distance the backend will split samples up into distinct storage parts. These would typically
* make up edge cases that this test case attempts to read and write.
*/
@Test
public void testPeriod() throws Exception {
final Optional<Long> maybePeriod = period();
assumeTrue("period is present", maybePeriod.isPresent());
final long count = 5;
final long period = maybePeriod.get();
final Points points = points();
// seed data just at the edges of the period
for (int i = 1; i < count; i++) {
long first = i * period;
if (brokenSegmentsPr208) {
// due to off-by-one, points exactly on the period are invisible.
first += 1;
}
points.p(first, (double) i);
points.p((i + 1) * period - 1, (double) i + .5D);
}
final MetricCollection written = points.build();
verifyReadWrite(written, written, new DateRange(period - 1, (period + 1) * count));
}
private void verifyReadWrite(
final MetricCollection input, final MetricCollection expected, final DateRange range
) throws Exception {
backend.write(new WriteMetric.Request(s1, input)).get();
FetchData data = backend
.fetch(new FetchData.Request(expected.getType(), s1, range,
QueryOptions.builder().build()), FetchQuotaWatcher.NO_QUOTA)
.get();
assertEquals(ImmutableSet.of(expected), ImmutableSet.copyOf(data.getGroups()));
}
private TestCase newCase() {
return new TestCase();
}
@lombok.Data
private class TestCase {
private Optional<Integer> denseStart = Optional.empty();
private Optional<Integer> dense = Optional.empty();
private final List<Long> input = new ArrayList<>();
private final List<Long> expected = new ArrayList<>();
TestCase denseStart(final int denseStart) {
this.denseStart = Optional.of(denseStart);
return this;
}
TestCase dense(final int dense) {
this.dense = Optional.of(dense);
return this;
}
TestCase input(final long... inputs) {
for (final long input : inputs) {
this.input.add(input);
}
return this;
}
TestCase expect(final long... expected) {
for (final long expect : expected) {
this.expected.add(expect);
}
return this;
}
void forEach(final ThrowingBiConsumer<MetricCollection, MetricCollection> consumer)
throws Exception {
// test for events, if supported
if (eventSupport) {
final Events input = events();
final Events expected = events();
inputStream().forEach(t -> input.e(t, EVENT));
expectedStream().forEach(t -> expected.e(t, EVENT));
consumer.accept(input.build(), expected.build());
}
// test for points
{
final Points input = points();
final Points expected = points();
inputStream().forEach(t -> input.p(t, 42D));
expectedStream().forEach(t -> expected.p(t, 42D));
consumer.accept(input.build(), expected.build());
}
}
private Stream<Long> inputStream() {
return Stream.concat(this.input.stream(), this.denseStream());
}
private Stream<Long> expectedStream() {
return Stream.concat(this.expected.stream(), this.denseStream());
}
private Stream<Long> denseStream() {
return dense.map(d -> {
final Stream.Builder<Long> builder = Stream.builder();
final int start = denseStart.orElse(0);
for (int t = 0; t < d; t++) {
builder.add((long) (t + start));
}
return builder.build();
}).orElseGet(Stream::empty);
}
}
@FunctionalInterface
interface ThrowingBiConsumer<A, B> {
void accept(final A a, final B b) throws Exception;
}
@Test
public void testWriteAndFetchOne() throws Exception {
final MetricCollection points = Data.points().p(100000L, 42D).build();
backend.write(new WriteMetric.Request(s1, points)).get();
FetchData.Request request =
new FetchData.Request(MetricType.POINT, s1, new DateRange(10000L, 200000L),
QueryOptions.builder().build());
assertEqualMetrics(points, fetchMetrics(request, true));
assertEqualMetrics(points, fetchMetrics(request, false));
}
@Test
public void testWriteAndFetchMultipleFetches() throws Exception {
int fetchSize = 20;
Points points = Data.points();
for (int i = 0; i < 10 * fetchSize; i++) {
points.p(100000L + i, 42D);
}
final MetricCollection mc = points.build();
backend.write(new WriteMetric.Request(s2, mc)).get();
FetchData.Request request =
new FetchData.Request(MetricType.POINT, s2, new DateRange(10000L, 200000L),
QueryOptions.builder().fetchSize(fetchSize).build());
assertEqualMetrics(mc, fetchMetrics(request, true));
}
@Test
public void testWriteAndFetchLongSeries() throws Exception {
Random random = new Random(1);
Points points = Data.points();
long timestamp = 1;
long maxTimestamp = 1000000000000L;
// timestamps [1, maxTimestamp] since we can't fetch 0 (range start is exclusive)
while (timestamp < maxTimestamp) {
points.p(timestamp, random.nextDouble());
timestamp += Math.abs(random.nextInt(100000000));
}
points.p(maxTimestamp, random.nextDouble());
MetricCollection mc = points.build();
backend.write(new WriteMetric.Request(s3, mc)).get();
FetchData.Request request =
new FetchData.Request(MetricType.POINT, s3, new DateRange(0, maxTimestamp),
QueryOptions.builder().build());
assertEqualMetrics(mc, fetchMetrics(request, true));
assertEqualMetrics(mc, fetchMetrics(request, false));
}
private List<MetricCollection> fetchMetrics(FetchData.Request request, boolean slicedFetch)
throws Exception {
if (slicedFetch) {
List<MetricCollection> fetchedMetrics = Collections.synchronizedList(new ArrayList<>());
backend.fetch(request, FetchQuotaWatcher.NO_QUOTA, fetchedMetrics::add).get();
return fetchedMetrics;
} else {
return backend.fetch(request, FetchQuotaWatcher.NO_QUOTA).get().getGroups();
}
}
private static void assertEqualMetrics(
MetricCollection expected, List<MetricCollection> actual
) {
Stream<MetricType> types = actual.stream().map(MetricCollection::getType);
assertEquals(ImmutableSet.of(expected.getType()), types.collect(Collectors.toSet()));
actual
.stream()
.flatMap(mc -> mc.getData().stream())
.sorted(Metric.comparator())
.forEach(new Consumer<Metric>() {
int i;
@Override
public void accept(final Metric metric) {
assertEquals(expected.getData().get(i), metric);
i++;
}
});
}
}