diff --git a/README.md b/README.md index addfbb37..18ec1c78 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ select ?time ?value { ## Building * This is a plain Maven project * a full build can be executed via `mvn package` +* KVIN ingestion benchmark instructions are in [docs/benchmarks/kvin-ingestion.md](docs/benchmarks/kvin-ingestion.md) ## Running * change to the folder `launch/equinox` diff --git a/bundles/io.github.linkedfactory.service/pom.xml b/bundles/io.github.linkedfactory.service/pom.xml index 069651de..dd6ccbcc 100644 --- a/bundles/io.github.linkedfactory.service/pom.xml +++ b/bundles/io.github.linkedfactory.service/pom.xml @@ -222,4 +222,55 @@ + + + + jmh + + io.github.linkedfactory.service.benchmark.KvinIngestionBenchmark + json + ${project.build.directory}/jmh-result.json + 3 + 3s + 5 + 3s + 2 + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + ${java.home}/bin/java + test + + -Djmh.temp.root=${jmh.temp.root} + -cp + + org.openjdk.jmh.Main + ${jmh.includes} + -wi + ${jmh.warmups} + -w + ${jmh.warmup.time} + -i + ${jmh.measurements} + -r + ${jmh.measurement.time} + -f + ${jmh.forks} + -rf + ${jmh.result.format} + -rff + ${jmh.result.file} + + + + + + + diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/core/kvin/KvinHttpTest.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/core/kvin/KvinHttpTest.java index 37803c67..86d90d75 100644 --- a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/core/kvin/KvinHttpTest.java +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/core/kvin/KvinHttpTest.java @@ -92,10 +92,12 @@ public Function0> apply(Req in) { } @BeforeClass - public static void setupClass() throws ClassNotFoundException { + public static void setupClass() { // create configuration and a model set factory - KommaModule module = ModelPlugin.createModelSetModule(Class.forName("net.enilink.komma.model.ModelPlugin").getClassLoader()); - IModelSetFactory factory = (IModelSetFactory) Guice.createInjector(new ModelSetModule(module)).getInstance(Class.forName("net.enilink.komma.model.IModelSetFactory")); + KommaModule module = ModelPlugin.createModelSetModule(ModelPlugin.class.getClassLoader()); + IModelSetFactory factory = + Guice.createInjector(new ModelSetModule(module)) + .getInstance(IModelSetFactory.class); // create a model set with an in-memory repository modelSet = factory.createModelSet(MODELS.NAMESPACE_URI.appendFragment("MemoryModelSet")); Globals.contextModelSet().theDefault().set(VendorJ.vendor(new Full(modelSet))); @@ -315,4 +317,4 @@ private static List generateTuples(int numberOfItems, int numberOfPro .generate() .toList(); } -} \ No newline at end of file +} diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionBenchmark.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionBenchmark.java new file mode 100644 index 00000000..7135b47b --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionBenchmark.java @@ -0,0 +1,312 @@ +package io.github.linkedfactory.service.benchmark; + +import com.google.inject.Guice; +import io.github.linkedfactory.core.kvin.KvinTuple; +import io.github.linkedfactory.core.kvin.leveldb.KvinLevelDb; +import io.github.linkedfactory.core.kvin.util.CsvFormatParser; +import io.github.linkedfactory.core.kvin.util.JsonFormatParser; +import io.github.linkedfactory.service.KvinService; +import io.github.linkedfactory.service.MockHttpServletRequest; +import net.enilink.commons.iterator.IExtendedIterator; +import net.enilink.komma.core.KommaModule; +import net.enilink.komma.core.URI; +import net.enilink.komma.core.URIs; +import net.enilink.komma.model.IModelSet; +import net.enilink.komma.model.IModelSetFactory; +import net.enilink.komma.model.MODELS; +import net.enilink.komma.model.ModelPlugin; +import net.enilink.komma.model.ModelSetModule; +import net.enilink.platform.lift.util.Globals; +import net.liftweb.common.Box; +import net.liftweb.common.Empty$; +import net.liftweb.common.Full; +import net.liftweb.http.CurrentReq$; +import net.liftweb.http.LiftResponse; +import net.liftweb.http.Req; +import net.liftweb.http.provider.servlet.HTTPRequestServlet; +import net.liftweb.util.VendorJ; +import org.json4s.JValue; +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.OperationsPerInvocation; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.junit.Assert; +import scala.Function0; +import scala.PartialFunction; +import scala.collection.immutable.Nil$; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Fork(2) +@Threads(1) +public class KvinIngestionBenchmark { + private static final int SEQUENTIAL_CSV_FILE_COUNT = 10; + + @State(Scope.Thread) + public static class BenchmarkState { + private List workloads; + private List jsonPayloadVariants; + private List csvPayloadVariants; + private List> csvPayloadPartitionVariants; + private int nextVariantIndex; + private KvinIngestionWorkload workload; + private IModelSet modelSet; + private KvinLevelDb store; + private File storeDirectory; + private KvinService service; + private KvinService parseOnlyService; + private byte[] jsonPayload; + private byte[] csvPayload; + private List csvPayloads; + private boolean measuredWrites; + + @Setup(Level.Trial) + public void setupTrial() { + workloads = KvinIngestionWorkload.variants(); + jsonPayloadVariants = workloads.stream().map(KvinIngestionWorkload::jsonPayload).toList(); + csvPayloadVariants = workloads.stream().map(KvinIngestionWorkload::csvPayload).toList(); + csvPayloadPartitionVariants = workloads.stream() + .map(variant -> variant.csvPayloads(SEQUENTIAL_CSV_FILE_COUNT)).toList(); + nextVariantIndex = 0; + KommaModule module = ModelPlugin.createModelSetModule(getClass().getClassLoader()); + IModelSetFactory factory = Guice.createInjector(new ModelSetModule(module)) + .getInstance(IModelSetFactory.class); + modelSet = factory.createModelSet(MODELS.NAMESPACE_URI.appendFragment("MemoryModelSet")); + Globals.contextModelSet().theDefault().set(VendorJ.vendor(new Full(modelSet))); + } + + @Setup(Level.Invocation) + public void setupInvocation() throws IOException { + int variantIndex = nextVariantIndex; + nextVariantIndex = (nextVariantIndex + 1) % KvinIngestionWorkload.VARIANT_COUNT; + workload = workloads.get(variantIndex); + jsonPayload = jsonPayloadVariants.get(variantIndex); + csvPayload = csvPayloadVariants.get(variantIndex); + csvPayloads = csvPayloadPartitionVariants.get(variantIndex); + String tempRoot = System.getProperty("jmh.temp.root", ""); + Path directory = tempRoot.isEmpty() + ? Files.createTempDirectory("kvin-ingestion-jmh-") + : Files.createTempDirectory(Path.of(tempRoot), "kvin-ingestion-jmh-"); + storeDirectory = directory.toFile(); + store = new KvinLevelDb(storeDirectory); + store.put(workload.preseedTuples()); + service = new BenchmarkService(false); + parseOnlyService = new BenchmarkService(true); + measuredWrites = false; + } + + @TearDown(Level.Invocation) + public void teardownInvocation() throws IOException { + try { + validateStore(); + } finally { + if (store != null) { + store.close(); + store = null; + } + if (storeDirectory != null) { + deleteDirectory(storeDirectory.toPath()); + storeDirectory = null; + } + } + } + + @TearDown(Level.Trial) + public void teardownTrial() { + if (modelSet != null) { + modelSet.dispose(); + modelSet = null; + } + } + + public void putBatch() { + store.put(workload.tuples()); + measuredWrites = true; + } + + public void postJson() throws IOException { + post(jsonPayload, "application/json", service, true); + } + + public void postJsonParseOnly() throws IOException { + post(jsonPayload, "application/json", parseOnlyService, false); + } + + public void postCsv() throws IOException { + post(csvPayload, "text/csv", service, true); + } + + public void putCsvDirect() throws IOException { + CsvFormatParser parser = new CsvFormatParser( + URIs.createURI("http://foo.com/linkedfactory/"), ',', + new ByteArrayInputStream(csvPayload)); + parser.setContext(KvinIngestionWorkload.CONTEXT); + try (IExtendedIterator tuples = parser.parse()) { + store.put(tuples); + } + measuredWrites = true; + } + + public void postCsvSequentialFiles() throws IOException { + for (byte[] payload : csvPayloads) { + post(payload, "text/csv", service, true); + } + } + + private void post(byte[] payload, String contentType, KvinService targetService, + boolean writesMeasuredTuples) throws IOException { + MockHttpServletRequest request = new MockHttpServletRequest("http://foo.com/linkedfactory/values"); + request.method_$eq("POST"); + request.body_$eq(payload); + request.contentType_$eq(contentType); + Req req = Req.apply(new HTTPRequestServlet(request, null), + Nil$.MODULE$.$colon$colon(PartialFunction.empty()), System.nanoTime()); + Box result = targetService.apply(req).apply(); + LiftResponse response = result.openOr(null); + if (response == null || response.toResponse().code() != 200) { + int status = response == null ? -1 : response.toResponse().code(); + throw new IOException("KVIN ingestion request failed with HTTP status " + status); + } + if (writesMeasuredTuples) { + measuredWrites = true; + } + } + + private class BenchmarkService extends KvinService { + private final boolean parseOnly; + + private BenchmarkService(boolean parseOnly) { + super(Nil$.MODULE$.$colon$colon("linkedfactory"), store); + this.parseOnly = parseOnly; + } + + @Override + public URI contextModelUri() { + return KvinIngestionWorkload.CONTEXT; + } + + @Override + public Box saveJsonValues(InputStream in, scala.collection.immutable.List path, long currentTime) { + if (!parseOnly) { + return super.saveJsonValues(in, path, currentTime); + } + try { + new JsonFormatParser(in).parse(currentTime).toList(); // parse and discard the tuples + } catch (IOException e) { + throw new RuntimeException(e); + } + return Empty$.MODULE$; + } + + @Override + public Function0> apply(Req in) { + IModelSet currentModelSet = Globals.contextModelSet().vend().openOr(null); + return CurrentReq$.MODULE$.doWith(in, () -> { + try { + currentModelSet.getUnitOfWork().begin(); + if (isDefinedAt(in)) { + return super.apply(in); + } + return (Function0) (() -> Box.legacyNullTest((LiftResponse) null)); + } finally { + currentModelSet.getUnitOfWork().end(); + } + }); + } + } + + private void validateStore() { + Set expected = new HashSet<>(workload.preseedTuples()); + if (measuredWrites) { + expected.addAll(workload.tuples()); + } + Set actual = new HashSet<>(); + for (URI item : workload.items()) { + try (IExtendedIterator iterator = store.fetch(item, workload.property(), + KvinIngestionWorkload.CONTEXT, 0)) { + while (iterator.hasNext()) { + actual.add(iterator.next()); + } + } + } + Assert.assertEquals("Unexpected persisted KVIN tuples", expected, actual); + Assert.assertEquals((measuredWrites ? KvinIngestionWorkload.TUPLE_COUNT : 0) + + KvinIngestionWorkload.CHANNEL_COUNT, actual.size()); + } + + private static void deleteDirectory(Path directory) throws IOException { + if (!Files.exists(directory)) { + return; + } + Files.walkFileTree(directory, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exception) throws IOException { + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + } + + @Benchmark + @OperationsPerInvocation(KvinIngestionWorkload.TUPLE_COUNT) + public void putBatch(BenchmarkState state) { + state.putBatch(); + } + + @Benchmark + @OperationsPerInvocation(KvinIngestionWorkload.TUPLE_COUNT) + public void postJson(BenchmarkState state) throws IOException { + state.postJson(); + } + + @Benchmark + @OperationsPerInvocation(KvinIngestionWorkload.TUPLE_COUNT) + public void postCsv(BenchmarkState state) throws IOException { + state.postCsv(); + } + + @Benchmark + @OperationsPerInvocation(KvinIngestionWorkload.TUPLE_COUNT) + public void putCsvDirect(BenchmarkState state) throws IOException { + state.putCsvDirect(); + } + + @Benchmark + @OperationsPerInvocation(KvinIngestionWorkload.TUPLE_COUNT) + public void postCsvSequentialFiles(BenchmarkState state) throws IOException { + state.postCsvSequentialFiles(); + } +} diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmark.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmark.java new file mode 100644 index 00000000..ccdca880 --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmark.java @@ -0,0 +1,287 @@ +package io.github.linkedfactory.service.benchmark; + +import com.google.inject.Guice; +import com.opencsv.CSVParser; +import com.opencsv.CSVParserBuilder; +import com.opencsv.CSVReader; +import com.opencsv.CSVReaderBuilder; +import com.opencsv.exceptions.CsvValidationException; +import io.github.linkedfactory.core.kvin.DelegatingKvin; +import io.github.linkedfactory.core.kvin.Kvin; +import io.github.linkedfactory.core.kvin.KvinListener; +import io.github.linkedfactory.core.kvin.KvinTuple; +import io.github.linkedfactory.core.kvin.util.CsvFormatParser; +import io.github.linkedfactory.service.KvinService; +import io.github.linkedfactory.service.MockHttpServletRequest; +import net.enilink.commons.iterator.IExtendedIterator; +import net.enilink.komma.core.KommaModule; +import net.enilink.komma.core.URI; +import net.enilink.komma.core.URIs; +import net.enilink.komma.model.IModelSet; +import net.enilink.komma.model.IModelSetFactory; +import net.enilink.komma.model.MODELS; +import net.enilink.komma.model.ModelPlugin; +import net.enilink.komma.model.ModelSetModule; +import net.enilink.platform.lift.util.Globals; +import net.liftweb.common.Box; +import net.liftweb.http.CurrentReq$; +import net.liftweb.http.LiftResponse; +import net.liftweb.http.Req; +import net.liftweb.http.provider.servlet.HTTPRequestServlet; +import net.liftweb.util.VendorJ; +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.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import scala.Function0; +import scala.PartialFunction; +import scala.collection.immutable.Nil$; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.SingleShotTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Fork(2) +@Threads(1) +public class KvinIngestionCsvDiagnosticBenchmark { + private static final URI BASE = URIs.createURI("http://foo.com/linkedfactory/"); + + @State(Scope.Thread) + public static class BenchmarkState { + private static final int EXPECTED_FIELD_COUNT = KvinIngestionWorkload.ROW_COUNT + 1; + private static final int EXPECTED_FIELDS_PER_ROW = KvinIngestionWorkload.CHANNEL_COUNT + 2; + + private KvinIngestionWorkload workload; + private byte[] csvPayload; + private IModelSet modelSet; + private ConsumingKvin sink; + private KvinService service; + private String measuredStage; + private int rowCount; + private int fieldCount; + private int tupleCount; + + @Setup(Level.Trial) + public void setupTrial() { + workload = new KvinIngestionWorkload(); + csvPayload = workload.csvPayload(); + + KommaModule module = ModelPlugin.createModelSetModule(ModelPlugin.class.getClassLoader()); + IModelSetFactory factory = + Guice.createInjector(new ModelSetModule(module)) + .getInstance(IModelSetFactory.class); + modelSet = factory.createModelSet(MODELS.NAMESPACE_URI.appendFragment("MemoryModelSet")); + + Globals.contextModelSet().theDefault().set(VendorJ.vendor(new net.liftweb.common.Full(modelSet))); + sink = new ConsumingKvin(); + service = new BenchmarkService(sink); + } + + @TearDown(Level.Invocation) + public void validateInvocation() { + switch (measuredStage) { + case "consumePrebuilt", "parseCsvAndConsumeTuples", "postCsvParseOnly" -> + org.junit.Assert.assertEquals(KvinIngestionWorkload.TUPLE_COUNT, tupleCount); + case "decodeCsvAndConsumeFields" -> { + org.junit.Assert.assertEquals(EXPECTED_FIELD_COUNT, rowCount); + org.junit.Assert.assertEquals(EXPECTED_FIELD_COUNT * EXPECTED_FIELDS_PER_ROW, fieldCount); + } + default -> throw new AssertionError("Unknown CSV diagnostic stage " + measuredStage); + } + } + + @TearDown(Level.Trial) + public void teardownTrial() { + if (modelSet != null) { + modelSet.dispose(); + modelSet = null; + } + } + + public void consumePrebuilt(Blackhole blackhole) { + measuredStage = "consumePrebuilt"; + tupleCount = 0; + for (KvinTuple tuple : workload.tuples()) { + blackhole.consume(tuple); + tupleCount++; + } + } + + public void decodeCsvAndConsumeFields(Blackhole blackhole) throws IOException { + measuredStage = "decodeCsvAndConsumeFields"; + rowCount = 0; + fieldCount = 0; + CSVParser parser = new CSVParserBuilder() + .withSeparator(',') + .withIgnoreQuotations(true) + .build(); + try (CSVReader reader = new CSVReaderBuilder(new InputStreamReader( + new ByteArrayInputStream(csvPayload), StandardCharsets.UTF_8)) + .withSkipLines(0) + .withCSVParser(parser) + .build()) { + String[] row; + while ((row = readNext(reader)) != null) { + rowCount++; + for (String field : row) { + blackhole.consume(field); + fieldCount++; + } + } + } + } + + public void parseCsvAndConsumeTuples(Blackhole blackhole) throws IOException { + measuredStage = "parseCsvAndConsumeTuples"; + tupleCount = 0; + CsvFormatParser parser = new CsvFormatParser(BASE, ',', + new ByteArrayInputStream(csvPayload)); + parser.setContext(KvinIngestionWorkload.CONTEXT); + try (IExtendedIterator tuples = parser.parse()) { + while (tuples.hasNext()) { + blackhole.consume(tuples.next()); + tupleCount++; + } + } + } + + public void postCsvParseOnly(Blackhole blackhole) throws IOException { + measuredStage = "postCsvParseOnly"; + tupleCount = 0; + sink.startInvocation(blackhole); + MockHttpServletRequest request = new MockHttpServletRequest("http://foo.com/linkedfactory/values"); + request.method_$eq("POST"); + request.body_$eq(csvPayload); + request.contentType_$eq("text/csv"); + Req req = Req.apply(new HTTPRequestServlet(request, null), + Nil$.MODULE$.$colon$colon(PartialFunction.empty()), System.nanoTime()); + Box result = service.apply(req).apply(); + LiftResponse response = result.openOr(null); + if (response == null || response.toResponse().code() != 200) { + int status = response == null ? -1 : response.toResponse().code(); + throw new IOException("CSV parse-only request failed with HTTP status " + status); + } + tupleCount = sink.tupleCount(); + } + + private static String[] readNext(CSVReader reader) throws IOException { + try { + return reader.readNext(); + } catch (CsvValidationException e) { + throw new IOException(e); + } + } + + private class BenchmarkService extends KvinService { + private BenchmarkService(Kvin sink) { + super(Nil$.MODULE$.$colon$colon("linkedfactory"), sink); + } + + @Override + public URI contextModelUri() { + return KvinIngestionWorkload.CONTEXT; + } + + @Override + public Function0> apply(Req in) { + IModelSet currentModelSet = Globals.contextModelSet().vend().openOr(null); + return CurrentReq$.MODULE$.doWith(in, () -> { + try { + currentModelSet.getUnitOfWork().begin(); + if (isDefinedAt(in)) { + return super.apply(in); + } + return () -> Box.legacyNullTest((LiftResponse) null); + } finally { + currentModelSet.getUnitOfWork().end(); + } + }); + } + } + } + + static class ConsumingKvin extends DelegatingKvin { + private Blackhole blackhole; + private int iterablePutCount; + private int scalarPutCount; + private int tupleCount; + + ConsumingKvin() { + super(() -> null); + } + + void startInvocation(Blackhole blackhole) { + this.blackhole = blackhole; + iterablePutCount = 0; + scalarPutCount = 0; + tupleCount = 0; + } + + @Override + public void put(KvinTuple... tuples) { + scalarPutCount++; + throw new IllegalStateException("CSV ingestion used scalar KVIN persistence"); + } + + @Override + public void put(Iterable tuples) { + iterablePutCount++; + for (KvinTuple tuple : tuples) { + consumeTuple(tuple); + tupleCount++; + } + } + + protected void consumeTuple(KvinTuple tuple) { + blackhole.consume(tuple); + } + + int iterablePutCount() { + return iterablePutCount; + } + + int scalarPutCount() { + return scalarPutCount; + } + + int tupleCount() { + return tupleCount; + } + } + + @Benchmark + public void consumePrebuilt(BenchmarkState state, Blackhole blackhole) { + state.consumePrebuilt(blackhole); + } + + @Benchmark + public void decodeCsvAndConsumeFields(BenchmarkState state, Blackhole blackhole) throws IOException { + state.decodeCsvAndConsumeFields(blackhole); + } + + @Benchmark + public void parseCsvAndConsumeTuples(BenchmarkState state, Blackhole blackhole) throws IOException { + state.parseCsvAndConsumeTuples(blackhole); + } + + @Benchmark + public void postCsvParseOnly(BenchmarkState state, Blackhole blackhole) throws IOException { + state.postCsvParseOnly(blackhole); + } +} diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmarkTest.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmarkTest.java new file mode 100644 index 00000000..3afe3c3b --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionCsvDiagnosticBenchmarkTest.java @@ -0,0 +1,34 @@ +package io.github.linkedfactory.service.benchmark; + +import io.github.linkedfactory.core.kvin.KvinTuple; +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class KvinIngestionCsvDiagnosticBenchmarkTest { + @Test + public void consumingSinkUsesTheIterableContract() { + Set observed = new HashSet<>(); + KvinIngestionCsvDiagnosticBenchmark.ConsumingKvin sink = + new KvinIngestionCsvDiagnosticBenchmark.ConsumingKvin() { + @Override + protected void consumeTuple(KvinTuple tuple) { + observed.add(tuple); + } + }; + KvinIngestionWorkload workload = new KvinIngestionWorkload(); + + sink.startInvocation(null); + sink.put(workload.tuples()); + + assertEquals(1, sink.iterablePutCount()); + assertEquals(0, sink.scalarPutCount()); + assertEquals(KvinIngestionWorkload.TUPLE_COUNT, sink.tupleCount()); + assertEquals(new HashSet<>(workload.tuples()), observed); + assertTrue(observed.stream().allMatch(tuple -> tuple.context.equals(KvinIngestionWorkload.CONTEXT))); + } +} \ No newline at end of file diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionJsonDiagnosticBenchmark.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionJsonDiagnosticBenchmark.java new file mode 100644 index 00000000..bc4f288a --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionJsonDiagnosticBenchmark.java @@ -0,0 +1,27 @@ +package io.github.linkedfactory.service.benchmark; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode(Mode.SingleShotTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Fork(2) +@Threads(1) +public class KvinIngestionJsonDiagnosticBenchmark { + @Benchmark + public void postJsonParseOnly(KvinIngestionBenchmark.BenchmarkState state) throws IOException { + state.postJsonParseOnly(); + } + +} diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkload.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkload.java new file mode 100644 index 00000000..e9187934 --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkload.java @@ -0,0 +1,187 @@ +package io.github.linkedfactory.service.benchmark; + +import io.github.linkedfactory.core.kvin.KvinTuple; +import io.github.linkedfactory.core.kvin.util.JsonFormatWriter; +import net.enilink.commons.iterator.WrappedIterator; +import net.enilink.komma.core.URI; +import net.enilink.komma.core.URIs; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Random; + +public final class KvinIngestionWorkload { + public static final int ROW_COUNT = 5_000; + public static final int CHANNEL_COUNT = 6; + public static final int TUPLE_COUNT = ROW_COUNT * CHANNEL_COUNT; + public static final int TIMESTAMP_COUNT = 1_000; + public static final int SEQUENCES_PER_TIMESTAMP = 5; + public static final int VARIANT_COUNT = 10; + public static final int CHANNEL_POOL_SIZE = 100; + public static final int PROPERTY_POOL_SIZE = 10; + public static final long BASE_START_TIME = 1_710_000_000_000L; + public static final long TIMESTAMP_STEP = 1_000L; + public static final long TIMESTAMP_WINDOW_SIZE = TIMESTAMP_COUNT * TIMESTAMP_STEP; + public static final long SHUFFLE_SEED = 0x4B56494E_20260721L; + + public static final URI CONTEXT = URIs.createURI("http://iwu.lf.de/ecc4p/models/emag"); + + private static final List CHANNEL_POOL = createUriPool("http://iwu.lf.de/ecc4p/emag/channel-", CHANNEL_POOL_SIZE, 3); + private static final List PROPERTY_POOL = createUriPool("http://iwu.lf.de/ecc4p/property-", PROPERTY_POOL_SIZE, 2); + private static final List SHUFFLED_CHANNELS = shuffledChannels(); + + private final int variantIndex; + private final List items; + private final URI property; + private final long startTime; + private final List tuples; + private final List preseedTuples; + private final byte[] jsonPayload; + private final byte[] csvPayload; + + public KvinIngestionWorkload() { + this(0); + } + + KvinIngestionWorkload(int variantIndex) { + if (variantIndex < 0 || variantIndex >= VARIANT_COUNT) { + throw new IllegalArgumentException("variantIndex must be in [0, " + VARIANT_COUNT + "): " + variantIndex); + } + this.variantIndex = variantIndex; + int firstChannel = variantIndex * CHANNEL_COUNT; + this.items = List.copyOf(SHUFFLED_CHANNELS.subList(firstChannel, firstChannel + CHANNEL_COUNT)); + this.property = PROPERTY_POOL.get(variantIndex); + this.startTime = BASE_START_TIME + variantIndex * TIMESTAMP_WINDOW_SIZE; + this.tuples = createTuples(); + this.preseedTuples = createPreseedTuples(); + this.jsonPayload = createJsonPayload(tuples); + this.csvPayload = createCsvPayload(0, ROW_COUNT); + } + + static List variants() { + List variants = new ArrayList<>(VARIANT_COUNT); + for (int variant = 0; variant < VARIANT_COUNT; variant++) { + variants.add(new KvinIngestionWorkload(variant)); + } + return List.copyOf(variants); + } + + int variantIndex() { + return variantIndex; + } + + List items() { + return items; + } + + URI property() { + return property; + } + + public List tuples() { + return tuples; + } + + public List preseedTuples() { + return preseedTuples; + } + + public byte[] jsonPayload() { + return jsonPayload.clone(); + } + + public byte[] csvPayload() { + return csvPayload.clone(); + } + + public List csvPayloads(int fileCount) { + if (fileCount <= 0 || ROW_COUNT % fileCount != 0) { + throw new IllegalArgumentException("fileCount must divide " + ROW_COUNT + ": " + fileCount); + } + int rowsPerFile = ROW_COUNT / fileCount; + List payloads = new ArrayList<>(fileCount); + for (int file = 0; file < fileCount; file++) { + int startRow = file * rowsPerFile; + payloads.add(createCsvPayload(startRow, startRow + rowsPerFile)); + } + return List.copyOf(payloads); + } + + private List createTuples() { + List result = new ArrayList<>(TUPLE_COUNT); + for (int row = 0; row < ROW_COUNT; row++) { + for (int channel = 0; channel < CHANNEL_COUNT; channel++) { + long time = startTime + (row / SEQUENCES_PER_TIMESTAMP) * TIMESTAMP_STEP; + int seqNr = row % SEQUENCES_PER_TIMESTAMP + 1; + result.add(new KvinTuple(items.get(channel), property, CONTEXT, time, seqNr, value(channel, row))); + } + } + return List.copyOf(result); + } + + private List createPreseedTuples() { + List result = new ArrayList<>(CHANNEL_COUNT); + for (int channel = 0; channel < CHANNEL_COUNT; channel++) { + result.add(new KvinTuple(items.get(channel), property, CONTEXT, startTime - TIMESTAMP_STEP, 0, + value(channel, -1))); + } + return List.copyOf(result); + } + + private static byte[] createJsonPayload(List tuples) { + try { + String json = JsonFormatWriter.toJsonString(WrappedIterator.create(tuples.stream() + .sorted(Comparator.comparing((KvinTuple t) -> t.item.toString()) + .thenComparing(t -> t.property.toString()) + .thenComparingLong(t -> t.time) + .thenComparingInt(t -> t.seqNr)) + .toList().iterator())); + return json.getBytes(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private byte[] createCsvPayload(int startRow, int endRow) { + StringBuilder csv = new StringBuilder((endRow - startRow) * CHANNEL_COUNT * 12); + csv.append("time,seqNr"); + for (URI item : items) { + csv.append(',').append(item).append('@').append(property); + } + csv.append('\n'); + + for (int row = startRow; row < endRow; row++) { + csv.append(startTime + (row / SEQUENCES_PER_TIMESTAMP) * TIMESTAMP_STEP) + .append(',').append(row % SEQUENCES_PER_TIMESTAMP + 1); + for (int channel = 0; channel < CHANNEL_COUNT; channel++) { + csv.append(',').append(value(channel, row)); + } + csv.append('\n'); + } + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private static List createUriPool(String prefix, int size, int digits) { + List uris = new ArrayList<>(size); + for (int index = 0; index < size; index++) { + uris.add(URIs.createURI(prefix + String.format(Locale.ROOT, "%0" + digits + "d", index))); + } + return List.copyOf(uris); + } + + private static List shuffledChannels() { + List channels = new ArrayList<>(CHANNEL_POOL); + Collections.shuffle(channels, new Random(SHUFFLE_SEED)); + return List.copyOf(channels); + } + + private static double value(int channel, int row) { + return channel * 100_000.0 + row + 0.25; + } +} diff --git a/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkloadTest.java b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkloadTest.java new file mode 100644 index 00000000..9f5520dc --- /dev/null +++ b/bundles/io.github.linkedfactory.service/src/test/java/io/github/linkedfactory/service/benchmark/KvinIngestionWorkloadTest.java @@ -0,0 +1,156 @@ +package io.github.linkedfactory.service.benchmark; + +import io.github.linkedfactory.core.kvin.KvinTuple; +import io.github.linkedfactory.core.kvin.util.CsvFormatParser; +import io.github.linkedfactory.core.kvin.util.JsonFormatParser; +import net.enilink.commons.iterator.IExtendedIterator; +import net.enilink.komma.core.URI; +import net.enilink.komma.core.URIs; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class KvinIngestionWorkloadTest { + @Test + public void suitesAreDeterministicAndSelectDisjointChannelsAndProperties() { + List first = KvinIngestionWorkload.variants(); + List second = KvinIngestionWorkload.variants(); + + assertEquals(KvinIngestionWorkload.VARIANT_COUNT, first.size()); + assertEquals(first.stream().map(KvinIngestionWorkload::tuples).toList(), + second.stream().map(KvinIngestionWorkload::tuples).toList()); + + Set properties = new HashSet<>(); + Set selectedChannels = new HashSet<>(); + for (int index = 0; index < first.size(); index++) { + KvinIngestionWorkload workload = first.get(index); + KvinIngestionWorkload copy = second.get(index); + assertEquals(index, workload.variantIndex()); + assertEquals(workload.items(), copy.items()); + assertEquals(workload.property(), copy.property()); + assertEquals(workload.preseedTuples(), copy.preseedTuples()); + assertArrayEquals(workload.csvPayload(), copy.csvPayload()); + assertArrayEquals(workload.jsonPayload(), copy.jsonPayload()); + assertEquals(KvinIngestionWorkload.CHANNEL_COUNT, new HashSet<>(workload.items()).size()); + assertTrue("Channels overlap at variant " + index, selectedChannels.addAll(workload.items())); + assertTrue("Property repeated at variant " + index, properties.add(workload.property())); + } + assertEquals(KvinIngestionWorkload.VARIANT_COUNT, properties.size()); + assertEquals(KvinIngestionWorkload.VARIANT_COUNT * KvinIngestionWorkload.CHANNEL_COUNT, + selectedChannels.size()); + assertEquals(first.get(0).tuples(), new KvinIngestionWorkload().tuples()); + } + + @Test + public void everyVariantHasTheExpectedShapeAndDisjointKeysAndWindows() { + Set allKeys = new HashSet<>(); + Set allTimestamps = new HashSet<>(); + + for (KvinIngestionWorkload workload : KvinIngestionWorkload.variants()) { + long expectedStartTime = KvinIngestionWorkload.BASE_START_TIME + + workload.variantIndex() * KvinIngestionWorkload.TIMESTAMP_WINDOW_SIZE; + assertEquals(KvinIngestionWorkload.TUPLE_COUNT, workload.tuples().size()); + assertEquals(KvinIngestionWorkload.CHANNEL_COUNT, workload.preseedTuples().size()); + + Map> sequencesByTimestamp = new HashMap<>(); + Set variantTimestamps = new HashSet<>(); + for (KvinTuple tuple : workload.tuples()) { + assertEquals(workload.property(), tuple.property); + assertEquals(KvinIngestionWorkload.CONTEXT, tuple.context); + assertTrue(tuple.seqNr >= 1 && tuple.seqNr <= KvinIngestionWorkload.SEQUENCES_PER_TIMESTAMP); + assertTrue("Duplicate tuple key", allKeys.add(key(tuple))); + variantTimestamps.add(tuple.time); + sequencesByTimestamp.computeIfAbsent(tuple.time, ignored -> new HashSet<>()).add(tuple.seqNr); + } + + assertEquals(KvinIngestionWorkload.TIMESTAMP_COUNT, variantTimestamps.size()); + assertTrue("Timestamp windows overlap", allTimestamps.stream().noneMatch(variantTimestamps::contains)); + allTimestamps.addAll(variantTimestamps); + assertEquals(KvinIngestionWorkload.TIMESTAMP_COUNT, sequencesByTimestamp.size()); + assertTrue(sequencesByTimestamp.values().stream() + .allMatch(sequences -> sequences.size() == KvinIngestionWorkload.SEQUENCES_PER_TIMESTAMP)); + assertEquals(expectedStartTime, variantTimestamps.stream().mapToLong(Long::longValue).min().orElseThrow()); + assertEquals(expectedStartTime + (KvinIngestionWorkload.TIMESTAMP_COUNT - 1) + * KvinIngestionWorkload.TIMESTAMP_STEP, + variantTimestamps.stream().mapToLong(Long::longValue).max().orElseThrow()); + } + } + + @Test + public void allPayloadFormsNormalizeToEachVariantsCanonicalTupleSet() throws Exception { + for (KvinIngestionWorkload workload : KvinIngestionWorkload.variants()) { + Set expected = new HashSet<>(workload.tuples()); + + List csv = parseCsv(workload.csvPayload()); + assertEquals("CSV tuple order for variant " + workload.variantIndex(), workload.tuples(), csv); + assertEquals(expected, new HashSet<>(csv)); + + List json = parseJson(workload.jsonPayload()); + assertEquals(KvinIngestionWorkload.TUPLE_COUNT, json.size()); + assertEquals(expected, new HashSet<>(json)); + assertJsonOrder(json); + + List partitionedCsv = new ArrayList<>(); + List payloads = workload.csvPayloads(10); + assertEquals(10, payloads.size()); + for (byte[] payload : payloads) { + partitionedCsv.addAll(parseCsv(payload)); + } + assertEquals(KvinIngestionWorkload.TUPLE_COUNT, partitionedCsv.size()); + assertEquals(workload.tuples(), partitionedCsv); + assertEquals(expected, new HashSet<>(partitionedCsv)); + + assertEquals(KvinIngestionWorkload.ROW_COUNT + 1, + new String(workload.csvPayload(), StandardCharsets.UTF_8).split("\\n").length); + assertNotEquals(0, workload.jsonPayload().length); + } + } + + private static List parseCsv(byte[] payload) throws Exception { + List tuples = new ArrayList<>(); + CsvFormatParser parser = new CsvFormatParser(URIs.createURI("http://foo.com/linkedfactory/"), ',', + new ByteArrayInputStream(payload)); + parser.setContext(KvinIngestionWorkload.CONTEXT); + try (IExtendedIterator iterator = parser.parse()) { + while (iterator.hasNext()) { + tuples.add(iterator.next()); + } + } + return tuples; + } + + private static List parseJson(byte[] payload) throws Exception { + return new JsonFormatParser(new ByteArrayInputStream(payload)) + .setContext(KvinIngestionWorkload.CONTEXT) + .parse().toList(); + } + + private static void assertJsonOrder(List tuples) { + Comparator order = Comparator.comparing((KvinTuple tuple) -> tuple.item.toString()) + .thenComparing(tuple -> tuple.property.toString()) + .thenComparingLong(tuple -> tuple.time) + .thenComparingInt(tuple -> tuple.seqNr); + for (int index = 1; index < tuples.size(); index++) { + assertFalse("JSON is out of order at tuple " + index, + order.compare(tuples.get(index - 1), tuples.get(index)) > 0); + } + } + + private static String key(KvinTuple tuple) { + return tuple.context + "|" + tuple.item + "|" + tuple.property + "|" + tuple.time + "|" + tuple.seqNr; + } +} diff --git a/bundles/io.github.linkedfactory.service/src/test/scala/io/github/linkedfactory/service/benchmark/KvinServiceBenchmark.scala b/bundles/io.github.linkedfactory.service/src/test/scala/io/github/linkedfactory/service/benchmark/KvinServiceBenchmark.scala deleted file mode 100644 index faed154e..00000000 --- a/bundles/io.github.linkedfactory.service/src/test/scala/io/github/linkedfactory/service/benchmark/KvinServiceBenchmark.scala +++ /dev/null @@ -1,168 +0,0 @@ -package io.github.linkedfactory.service.benchmark - -import com.google.inject.Guice -import io.github.linkedfactory.core.kvin.leveldb.KvinLevelDb -import io.github.linkedfactory.core.kvin.util.JsonFormatWriter -import io.github.linkedfactory.core.kvin.{Kvin, KvinTuple} -import io.github.linkedfactory.service.{KvinService, MockHttpServletRequest} -import net.enilink.commons.iterator.WrappedIterator -import net.enilink.komma.core.{KommaModule, URI, URIs} -import net.enilink.komma.model._ -import net.enilink.platform.lift.util.Globals -import net.liftweb.common.{Box, Full} -import net.liftweb.http.provider.servlet.HTTPRequestServlet -import net.liftweb.http.{CurrentReq, LiftResponse, Req} -import org.junit.{AfterClass, BeforeClass, Ignore, Test} -import sun.invoke.util.ValueConversions - -import java.io.{File, IOException} -import java.nio.file.attribute.BasicFileAttributes -import java.nio.file.{FileVisitResult, Files, Path, SimpleFileVisitor} -import java.util -import java.util.concurrent.LinkedBlockingQueue -import jakarta.servlet.http.HttpServletRequest -import scala.util.Random -import scala.compiletime.uninitialized - -/** - * Companion object of unit tests for the KVIN service endpoint - */ -object KvinServiceBenchmark { - var modelSet: IModelSet = null - var storeDirectory: File = uninitialized - var store: Kvin = uninitialized - - @BeforeClass - def setup(): Unit = { - // create configuration and a model set factory - val module: KommaModule = ModelPlugin.createModelSetModule(classOf[ModelPlugin].getClassLoader) - val factory: IModelSetFactory = Guice.createInjector(new ModelSetModule(module)).getInstance(classOf[IModelSetFactory]) - - // create a model set with an in-memory repository - modelSet = factory.createModelSet(MODELS.NAMESPACE_URI.appendFragment("MemoryModelSet")) - Globals.contextModelSet.default.set(Full(modelSet)) - - createStore() - } - - @AfterClass - def tearDown(): Unit = { - modelSet.dispose() - modelSet = null - - store.close - store = null - deleteDirectory(storeDirectory.toPath) - } - - def createStore(): Unit = { - storeDirectory = new File("/tmp/leveldb-test-" + System.currentTimeMillis + "-" + Random.nextInt(1000) + "/") - storeDirectory.deleteOnExit - store = new KvinLevelDb(storeDirectory) - } - - def deleteDirectory(dir: Path): Unit = { - // delete store directory - Files.walkFileTree(dir, new SimpleFileVisitor[Path]() { - override def visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult = { - Files.delete(file) - FileVisitResult.CONTINUE - } - - override def postVisitDirectory(dir: Path, ex: IOException): FileVisitResult = { - Files.delete(dir) - FileVisitResult.CONTINUE - } - }) - } -} - -/** - * Unit tests for the KVIN service endpoint - */ -class KvinServiceBenchmark { - val kvinService = new KvinService("linkedfactory" :: Nil, KvinServiceBenchmark.store) { - override def apply(in: Req): () => Box[LiftResponse] = { - try { - Globals.contextModelSet.vend.map(_.getUnitOfWork.begin) - // S.request is used in Data.pathToURI therefore the request needs to be initialized here - CurrentReq.doWith(in) { - super.apply(in) - } - } finally { - Globals.contextModelSet.vend.map(_.getUnitOfWork.end) - } - } - } - - def kvinRest(req: Req): () => Box[LiftResponse] = { - kvinService(req) - } - - val baseUrl = "http://foo.com/linkedfactory/values" - - def toReq(httpRequest: HttpServletRequest): Req = { - Req(new HTTPRequestServlet(httpRequest, null), Nil, System.nanoTime) - } - - @Test - @Ignore - def postValues(): Unit = { - val valueProperty = URIs.createURI("property:value") - - val seed = 200 - val writeValues = 1000000 - - val benchmarkStart = System.currentTimeMillis - - val startTimeValues = 1478252048736L - val nrs = Array.fill(100)(Random.nextInt(Integer.MAX_VALUE)) - val rand = new Random(seed) - - // decouples client-side serialization and server-side parsing and insertion - val queue = new LinkedBlockingQueue[Option[String]](2) - val inserter = new Thread() { - override def run() : Unit = { - var finished = false - while (!finished) { - queue.take() match { - case None => finished = true - case Some(json) => - // support post request - val postReq = new MockHttpServletRequest(baseUrl) { - method = "POST" - body_=(json, "application/json") - } - kvinRest(toReq(postReq))().map(_.toResponse.code) - } - } - } - } - inserter.start() - - var tuples = new util.ArrayList[KvinTuple]() - var currentTime = startTimeValues - (0 to writeValues).foreach { i => - val randomNr = nrs(rand.nextInt(nrs.length)) - val uri = URIs.createURI("http://linkedfactory.github.io/" + randomNr + "/e3fabrik/rollex/" + randomNr + "/measured-point-1") - val ctx = URIs.createURI("ctx:" + randomNr) - - val value = if (randomNr % 2 == 0) rand.nextGaussian() else rand.nextLong(100000) - - tuples.add(new KvinTuple(uri, valueProperty, ctx, currentTime, value)) - currentTime += rand.nextInt(1000) - - if (i % 10000 == 0) { - println(" at: " + i) - val json = JsonFormatWriter.toJsonString(WrappedIterator.create(tuples.iterator())) - queue.put(Some(json)) - tuples = new util.ArrayList[KvinTuple]() - } - } - queue.put(None) - inserter.join() - - val seconds = (System.currentTimeMillis - benchmarkStart) / 1000.0 - println(s"Wrote $writeValues in %1$$,.2f seconds: %2$$,.2f ops per second".format(seconds, writeValues / seconds)) - } -} \ No newline at end of file diff --git a/docs/benchmarks/kvin-ingestion.md b/docs/benchmarks/kvin-ingestion.md new file mode 100644 index 00000000..d92d558f --- /dev/null +++ b/docs/benchmarks/kvin-ingestion.md @@ -0,0 +1,184 @@ +# KVIN Ingestion Benchmarks + +This JMH suite measures **KVIN ingestion throughput** through the direct LevelDB API and the in-process JSON and CSV service routes. + +Each primary benchmark processes **30,000 `KvinTuple`s per invocation**. JMH reports throughput as `ops/s`; `@OperationsPerInvocation(30000)` normalizes the result so that: + +> **1 op = 1 KvinTuple** + +The displayed `ops/s` score therefore directly represents tuple throughput. + +Test-data generation, validation, cleanup, network transport, TLS, and server startup are outside the timed methods. + +## What is measured + +```mermaid +flowchart LR + PB["putBatch"] --> W["KVIN batch writer"] + CD["putCsvDirect"] --> CP["CSV parser"] --> W + PC["postCsv"] --> L["Lift route"] --> CP + PJ["postJson"] --> L --> JP["JSON parser"] --> W + PS["postCsvSequentialFiles
10 requests"] --> L + W --> DB[("LevelDB")] +``` + +| Benchmark | Measures | +| ------------------------ | ----------------------------------------------------------------------- | +| `putBatch` | Prebuilt tuples → KVIN encoding / ID resolution / batch write → LevelDB | +| `putCsvDirect` | CSV parsing + tuple creation + persistence, without Lift routing | +| `postCsv` | In-process CSV request → route → parsing → persistence | +| `postJson` | In-process JSON request → route → parsing → persistence | +| `postCsvSequentialFiles` | Ten sequential CSV requests to the same service and store | + +`putBatch` is the persistence baseline. `putCsvDirect` adds CSV parsing. `postCsv` and `postJson` represent the main in-process service ingestion paths. + +### Test data + +Before measurement starts, the benchmark creates **10 fixed workload variants**. Each variant contains **5,000 rows across 6 channels, producing 30,000 `KvinTuple`s**. + +A channel represents one KVIN **item URI**, i.e. one time-series source. In CSV input, the six channels correspond to six value columns. Each resulting tuple contains: + +```text +(item, property, context, timestamp, sequence number, value) +``` + +For each workload variant: + +* **6 channels** are selected from a pool of 100 item URIs. The pool is shuffled once with a fixed seed, so the same variant always receives the same channels across benchmark runs. +* **1 property** is selected from a pool of 10 properties, with a different property assigned to each variant. +* **1 context** is shared by all tuples. +* **1,000 timestamps** form a fixed time window for that variant. +* **5 sequence numbers per timestamp** produce the 5,000 rows. +* Each row contains values for all 6 channels, resulting in `5,000 × 6 = 30,000` tuples. + +During measurement, the benchmark cycles through the 10 workload variants on repeat. Each warmup or measurement iteration runs the benchmark repeatedly for 3 seconds, so the ten variants are normally cycled through many times. The exact number of repetitions depends on how many benchmark invocations complete during that time. + +The variant sequence and contents remain fixed across runs; each persistence invocation writes its selected variant to a fresh temporary LevelDB store. + +## Run + +From the repository root, build once: + +```sh +mvn clean install -DskipTests +``` + +Run the five primary benchmarks: + +```sh +mvn -pl bundles/io.github.linkedfactory.service -Pjmh \ + -Djmh.result.file=target/jmh-primary.json \ + test-compile exec:exec +``` + +The default run uses 3 × 3 s warmups, 5 × 3 s measurements, 2 forks, and 1 thread. + +When comparing a change, repeat the full run with a separate result file. + +## Results + +A full run produces standard JMH throughput output like: + +```text +Benchmark Mode Cnt Score Error Units +KvinIngestionBenchmark.postCsv thrpt 10 712702.231 ± 234440.177 ops/s +KvinIngestionBenchmark.postCsvSequentialFiles thrpt 10 692147.445 ± 279633.790 ops/s +KvinIngestionBenchmark.postJson thrpt 10 504792.294 ± 42078.535 ops/s +KvinIngestionBenchmark.putBatch thrpt 10 1673947.197 ± 132642.643 ops/s +KvinIngestionBenchmark.putCsvDirect thrpt 10 880491.470 ± 126678.908 ops/s +``` + +For the primary benchmarks, these values are already normalized to `KvinTuple`s per second. For example, `880491 ops/s` means approximately **880,491 tuples/s**. + +Results are also written as JSON. Print a compact summary with: + +```sh +jq -r '.[] | + [(.benchmark | split(".")[-1]), + .primaryMetric.score, + .primaryMetric.scoreError, + .primaryMetric.scoreUnit] | + @tsv' \ + bundles/io.github.linkedfactory.service/target/jmh-primary.json +``` + +Use the score together with its error interval when comparing runs. The values above are example measurements, not performance thresholds. + +## Diagnostics + +Diagnostics go **one level deeper** when a primary benchmark shows that CSV or JSON ingestion needs investigation. Unlike the primary suite, they time complete diagnostic invocations in `ms/op`. + +### CSV diagnostics + +Use these when the difference between `putBatch`, `putCsvDirect`, and `postCsv` suggests that CSV parsing or routing is responsible for significant overhead. + +```sh +mvn -pl bundles/io.github.linkedfactory.service -Pjmh \ + -Djmh.includes=io.github.linkedfactory.service.benchmark.KvinIngestionCsvDiagnosticBenchmark \ + -Djmh.result.file=target/jmh-csv-diagnostic.json \ + test-compile exec:exec +``` + +With example result: + +```text +Benchmark Mode Cnt Score Error Units +KvinIngestionCsvDiagnosticBenchmark.consumePrebuilt ss 10 0.384 ± 0.290 ms/op +KvinIngestionCsvDiagnosticBenchmark.decodeCsvAndConsumeFields ss 10 12.234 ± 3.119 ms/op +KvinIngestionCsvDiagnosticBenchmark.parseCsvAndConsumeTuples ss 10 29.207 ± 17.566 ms/op +KvinIngestionCsvDiagnosticBenchmark.postCsvParseOnly ss 10 30.864 ± 22.291 ms/op +``` + +They progressively inspect the time taken up to each gate: + +```text +prebuilt tuple iteration ~0.4 ms + ↓ +CSV decoding ~12.2 ms + ↓ +full CSV → KvinTuple parsing ~29.2 ms + ↓ +Lift route + full CSV parsing ~30.9 ms +``` + +This separates tuple-consumption overhead, CSV tokenization, tuple construction, and routing without involving LevelDB persistence. + +### JSON diagnostics + +Use this diagnostic when `postJson` is slow and you want to distinguish **JSON request/parsing overhead** from persistence. + +```sh +mvn -pl bundles/io.github.linkedfactory.service -Pjmh \ + -Djmh.includes=io.github.linkedfactory.service.benchmark.KvinIngestionJsonDiagnosticBenchmark \ + -Djmh.result.file=target/jmh-json-diagnostic.json \ + test-compile exec:exec +``` + +Example result: + +```text +Benchmark Mode Cnt Score Error Units +KvinIngestionJsonDiagnosticBenchmark.postJsonParseOnly ss 10 37.008 ± 3.228 ms/op +``` + +`postJsonParseOnly` measures the in-process JSON request and parsing path without persistence, so this run spends about **37 ms** parsing one 30,000-tuple payload. + +For comparison, convert the throughput results from the primary benchmarks to equivalent 30,000-tuple batch times: + +```text +postJson 504,792 ops/s → ~59.4 ms per 30,000 tuples +putBatch 1,673,947 ops/s → ~17.9 ms per 30,000 tuples +``` + +This gives the following conceptual breakdown: + +```text +JSON request + parsing only ~37.0 ms + ↓ +shared KVIN/LevelDB persistence +baseline ~17.9 ms + ↓ +full JSON ingestion (postJson) ~59.4 ms +``` + +The values come from separate benchmark boundaries and are useful for locating overhead, but they should not be treated as exactly additive stage timings.