This is an automated email from the ASF dual-hosted git repository.

dominikriemer pushed a commit to branch improve-latest-event-query-performance
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit 8f2494c280251b8f3b6ef6209888bbd010982d4b
Author: Dominik Riemer <[email protected]>
AuthorDate: Fri Jul 3 20:53:18 2026 +0200

    chore: Improve query performance of latest event queries
---
 .../api/IDataExplorerQueryManagement.java          |  31 ++
 .../influx/DataExplorerInfluxQueryExecutor.java    |  74 +++++
 .../influx/DataExplorerQueryManagementInflux.java  |  88 ++++++
 .../DataExplorerInfluxQueryExecutorTest.java       |  41 +++
 .../influx/InfluxLatestTimestampBenchmarkTest.java | 331 +++++++++++++++++++++
 .../dataexplorer/influx/SelectQueryParamsTest.java |  27 ++
 .../rest/impl/datalake/DataLakeResource.java       |  25 +-
 .../DataLakeLatestEventsBenchmarkTest.java         | 108 +++++++
 .../rest/impl/datalake/DataLakeResourceTest.java   |  84 ++++++
 9 files changed, 785 insertions(+), 24 deletions(-)

diff --git 
a/streampipes-data-explorer-api/src/main/java/org/apache/streampipes/dataexplorer/api/IDataExplorerQueryManagement.java
 
b/streampipes-data-explorer-api/src/main/java/org/apache/streampipes/dataexplorer/api/IDataExplorerQueryManagement.java
index d114cb2886..a5f68204cd 100644
--- 
a/streampipes-data-explorer-api/src/main/java/org/apache/streampipes/dataexplorer/api/IDataExplorerQueryManagement.java
+++ 
b/streampipes-data-explorer-api/src/main/java/org/apache/streampipes/dataexplorer/api/IDataExplorerQueryManagement.java
@@ -25,8 +25,16 @@ import 
org.apache.streampipes.model.datalake.param.ProvidedRestQueryParams;
 
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_END_DATE;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_LIMIT;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_MISSING_VALUE_BEHAVIOUR;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_ORDER;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_START_DATE;
+
 public interface IDataExplorerQueryManagement {
 
   SpQueryResult getData(
@@ -47,4 +55,27 @@ public interface IDataExplorerQueryManagement {
 
   Map<String, Object> getTagValues(String measurementId,
                                    String fields);
+
+  default Map<String, Long> getLatestTimestamps(List<String> measurementNames) 
{
+    Map<String, Long> latestTimestamps = new HashMap<>();
+    measurementNames.forEach(measurementName -> 
latestTimestamps.put(measurementName, getLatestTimestamp(
+        measurementName)));
+    return latestTimestamps;
+  }
+
+  private Long getLatestTimestamp(String measurementName) {
+    Map<String, String> queryParams = Map.of(
+        QP_START_DATE, "0",
+        QP_END_DATE, String.valueOf(System.currentTimeMillis()),
+        QP_LIMIT, "1",
+        QP_ORDER, "DESC",
+        QP_MISSING_VALUE_BEHAVIOUR, "empty"
+    );
+
+    try {
+      return getData(new ProvidedRestQueryParams(measurementName, 
queryParams), true).getLastTimestamp();
+    } catch (RuntimeException e) {
+      return 0L;
+    }
+  }
 }
diff --git 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutor.java
 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutor.java
index fbb715be88..d7a00a00a7 100644
--- 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutor.java
+++ 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutor.java
@@ -37,6 +37,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.TreeMap;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.stream.Collectors;
@@ -180,6 +181,79 @@ public class DataExplorerInfluxQueryExecutor extends 
DataExplorerQueryExecutor<Q
     }
   }
 
+  public Map<String, Long> getLatestTimestamps(Map<String, String> 
measurementFields) {
+    if (measurementFields.isEmpty()) {
+      return Map.of();
+    }
+
+    var query = makeLatestTimestampQuery(measurementFields);
+    try (final InfluxDB influxDB = InfluxClientProvider.getInfluxDBClient()) {
+      return parseLatestTimestampResult(influxDB.query(query, 
TimeUnit.MILLISECONDS));
+    }
+  }
+
+  Query makeLatestTimestampQuery(Map<String, String> measurementFields) {
+    var query = measurementFields.entrySet()
+        .stream()
+        .collect(Collectors.groupingBy(Map.Entry::getValue, TreeMap::new,
+            Collectors.mapping(Map.Entry::getKey, Collectors.toList())))
+        .entrySet()
+        .stream()
+        .map(entry -> makeLastSelectorQuery(entry.getKey(), entry.getValue()))
+        .collect(Collectors.joining(";"));
+
+    return new Query(query, getDatabaseName());
+  }
+
+  private String makeLastSelectorQuery(String field,
+                                       List<String> measurements) {
+    return "SELECT LAST(\""
+        + field
+        + "\") FROM /"
+        + measurements.stream()
+            .map(this::escapeRegex)
+            .collect(Collectors.joining("|", "^(", ")$"))
+        + "/";
+  }
+
+  private String escapeRegex(String measurement) {
+    return measurement.replaceAll("([\\\\.\\[\\]{}()*+?^$|])", "\\\\$1");
+  }
+
+  private Map<String, Long> parseLatestTimestampResult(QueryResult 
queryResult) {
+    Map<String, Long> latestTimestamps = new HashMap<>();
+    if (queryResult.getResults() != null) {
+      queryResult.getResults().forEach(result -> {
+        if (result.getSeries() != null) {
+          result.getSeries().forEach(series -> 
parseLatestTimestampSeries(series, latestTimestamps));
+        }
+      });
+    }
+    return latestTimestamps;
+  }
+
+  private void parseLatestTimestampSeries(QueryResult.Series series,
+                                          Map<String, Long> latestTimestamps) {
+    var values = series.getValues();
+    if (values != null && !values.isEmpty() && !values.get(0).isEmpty()) {
+      latestTimestamps.put(series.getName(), 
parseTimestamp(values.get(0).get(0)));
+    }
+  }
+
+  private Long parseTimestamp(Object timestamp) {
+    if (timestamp instanceof Number number) {
+      return number.longValue();
+    } else if (timestamp instanceof String timestampString) {
+      try {
+        return Long.parseLong(timestampString);
+      } catch (NumberFormatException e) {
+        return 0L;
+      }
+    } else {
+      return 0L;
+    }
+  }
+
   @Override
   public boolean deleteData(DataLakeMeasure measure) {
     QueryResult queryResult = new DeleteDataQuery(measure).executeQuery();
diff --git 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerQueryManagementInflux.java
 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerQueryManagementInflux.java
index eee432d3e2..ed2bea62fc 100644
--- 
a/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerQueryManagementInflux.java
+++ 
b/streampipes-data-explorer-influx/src/main/java/org/apache/streampipes/dataexplorer/influx/DataExplorerQueryManagementInflux.java
@@ -30,12 +30,25 @@ import 
org.apache.streampipes.model.datalake.DataLakeMeasure;
 import org.apache.streampipes.model.datalake.SpQueryResult;
 import org.apache.streampipes.model.datalake.SpQueryStatus;
 import org.apache.streampipes.model.datalake.param.ProvidedRestQueryParams;
+import org.apache.streampipes.model.schema.EventProperty;
+import org.apache.streampipes.model.schema.EventPropertyPrimitive;
 
 
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_END_DATE;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_LIMIT;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_MISSING_VALUE_BEHAVIOUR;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_ORDER;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_START_DATE;
 
 public class DataExplorerQueryManagementInflux implements 
IDataExplorerQueryManagement {
 
@@ -112,6 +125,81 @@ public class DataExplorerQueryManagementInflux implements 
IDataExplorerQueryMana
     return new DataExplorerInfluxQueryExecutor().getTagValues(measurementId, 
fields);
   }
 
+  @Override
+  public Map<String, Long> getLatestTimestamps(List<String> measurementNames) {
+    Map<String, Long> latestTimestamps = measurementNames.stream()
+        .collect(Collectors.toMap(
+            Function.identity(),
+            measurementName -> 0L,
+            (left, right) -> left
+        ));
+    var measurementFields = getLatestTimestampFields(measurementNames);
+
+    if (!measurementFields.isEmpty()) {
+      try {
+        var batchedLatestTimestamps = new 
DataExplorerInfluxQueryExecutor().getLatestTimestamps(measurementFields);
+        latestTimestamps.putAll(batchedLatestTimestamps);
+        measurementFields.keySet()
+            .stream()
+            .filter(measurementName -> 
!batchedLatestTimestamps.containsKey(measurementName))
+            .forEach(measurementName -> latestTimestamps.put(measurementName, 
getLatestTimestampFallback(measurementName)));
+      } catch (RuntimeException e) {
+        measurementFields.keySet()
+            .forEach(measurementName ->
+                latestTimestamps.put(measurementName, 
getLatestTimestampFallback(measurementName)));
+      }
+    }
+
+    measurementNames.stream()
+        .filter(measurementName -> 
!measurementFields.containsKey(measurementName))
+        .forEach(measurementName -> latestTimestamps.put(measurementName, 
getLatestTimestampFallback(measurementName)));
+
+    return latestTimestamps;
+  }
+
+  private Map<String, String> getLatestTimestampFields(List<String> 
measurementNames) {
+    Map<String, String> measurementFields = new HashMap<>();
+    Map<String, DataLakeMeasure> measuresByName = getAllMeasurements()
+        .stream()
+        .collect(Collectors.toMap(DataLakeMeasure::getMeasureName, 
Function.identity(), (left, right) -> left));
+
+    measurementNames.forEach(measurementName -> 
findLatestTimestampField(measuresByName.get(measurementName))
+        .ifPresent(field -> measurementFields.put(measurementName, field)));
+
+    return measurementFields;
+  }
+
+  private Optional<String> findLatestTimestampField(DataLakeMeasure measure) {
+    if (measure == null || measure.getEventSchema() == null || 
measure.getEventSchema().getEventProperties() == null) {
+      return Optional.empty();
+    }
+
+    return measure.getEventSchema()
+        .getEventProperties()
+        .stream()
+        .filter(Objects::nonNull)
+        .filter(EventPropertyPrimitive.class::isInstance)
+        .map(EventProperty::getRuntimeName)
+        .filter(Objects::nonNull)
+        .findFirst();
+  }
+
+  private Long getLatestTimestampFallback(String measurementName) {
+    Map<String, String> queryParams = Map.of(
+        QP_START_DATE, "0",
+        QP_END_DATE, String.valueOf(System.currentTimeMillis()),
+        QP_LIMIT, "1",
+        QP_ORDER, "DESC",
+        QP_MISSING_VALUE_BEHAVIOUR, "empty"
+    );
+
+    try {
+      return getData(new ProvidedRestQueryParams(measurementName, 
queryParams), true).getLastTimestamp();
+    } catch (RuntimeException e) {
+      return 0L;
+    }
+  }
+
   private List<DataLakeMeasure> getAllMeasurements() {
     return this.dataExplorerSchemaManagement.getAllMeasurements();
   }
diff --git 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutorTest.java
 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutorTest.java
new file mode 100644
index 0000000000..b8e5c3f8ef
--- /dev/null
+++ 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/DataExplorerInfluxQueryExecutorTest.java
@@ -0,0 +1,41 @@
+/*
+ * 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.streampipes.dataexplorer.influx;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class DataExplorerInfluxQueryExecutorTest {
+
+  @Test
+  public void makeLatestTimestampQueryGroupsMeasurementsByField() {
+    var measurementFields = new LinkedHashMap<String, String>();
+    measurementFields.put("measure-1", "value");
+    measurementFields.put("measure-2", "value");
+    measurementFields.put("measure-3", "temperature");
+
+    var query = new 
DataExplorerInfluxQueryExecutor().makeLatestTimestampQuery(measurementFields);
+
+    assertEquals("SELECT LAST(\"temperature\") FROM /^(measure-3)$/;"
+        + "SELECT LAST(\"value\") FROM /^(measure-1|measure-2)$/", 
query.getCommand());
+  }
+}
diff --git 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/InfluxLatestTimestampBenchmarkTest.java
 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/InfluxLatestTimestampBenchmarkTest.java
new file mode 100644
index 0000000000..baedaf322d
--- /dev/null
+++ 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/InfluxLatestTimestampBenchmarkTest.java
@@ -0,0 +1,331 @@
+/*
+ * 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.streampipes.dataexplorer.influx;
+
+import org.apache.streampipes.dataexplorer.influx.client.InfluxClientUtils;
+
+import org.influxdb.InfluxDB;
+import org.influxdb.InfluxDBFactory;
+import org.influxdb.dto.BatchPoints;
+import org.influxdb.dto.Point;
+import org.influxdb.dto.Query;
+import org.influxdb.dto.QueryResult;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import java.util.function.IntFunction;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Tag("benchmark")
+public class InfluxLatestTimestampBenchmarkTest {
+
+  private static final String ENABLED = "sp.influx.benchmark";
+  private static final String URL = "sp.influx.benchmark.url";
+  private static final String USERNAME = "sp.influx.benchmark.username";
+  private static final String PASSWORD = "sp.influx.benchmark.password";
+  private static final String TOKEN = "sp.influx.benchmark.token";
+  private static final String DATABASE = "sp.influx.benchmark.database";
+  private static final String RETENTION_POLICY = 
"sp.influx.benchmark.retention-policy";
+  private static final String RECREATE_DATABASE = 
"sp.influx.benchmark.recreate-database";
+  private static final String CLEANUP_MEASUREMENTS = 
"sp.influx.benchmark.cleanup-measurements";
+  private static final String MEASUREMENTS = 
"sp.influx.benchmark.measurements";
+  private static final String POINTS_PER_MEASUREMENT = 
"sp.influx.benchmark.points-per-measurement";
+  private static final String FIELDS = "sp.influx.benchmark.fields";
+  private static final String BATCH_SIZE = "sp.influx.benchmark.batch-size";
+  private static final String WARMUP_RUNS = "sp.influx.benchmark.warmup-runs";
+  private static final String MEASUREMENT_RUNS = 
"sp.influx.benchmark.measurement-runs";
+  private static final String ALLOW_NON_BENCHMARK_DATABASE = 
"sp.influx.benchmark.allow-non-benchmark-database";
+
+  private static final long BASE_TIMESTAMP = 1700000000000L;
+
+  @Test
+  public void benchmarkLatestTimestampQueriesAgainstInfluxDb() {
+    Assumptions.assumeTrue(Boolean.getBoolean(ENABLED),
+        "Enable with -D" + ENABLED + "=true and provide a reachable InfluxDB 
1.x instance");
+
+    var config = BenchmarkConfig.fromSystemProperties();
+    assertBenchmarkDatabase(config);
+    try (var influxDb = config.connect()) {
+      for (int measurementCount : config.measurementCounts()) {
+        if (config.recreateDatabase()) {
+          recreateDatabase(influxDb, config.database());
+        } else if (config.cleanupMeasurements()) {
+          influxDb.setDatabase(config.database());
+          dropBenchmarkMeasurements(influxDb, config.database());
+        } else {
+          influxDb.setDatabase(config.database());
+        }
+        loadData(influxDb, config, measurementCount);
+
+        printHeader();
+        runScenario(
+            influxDb,
+            config,
+            measurementCount,
+            "current_select_star",
+            index -> currentLatestTimestampQuery(index, 
config.pointsPerMeasurement()),
+            measurementCount
+        );
+        runScenario(
+            influxDb,
+            config,
+            measurementCount,
+            "last_selector",
+            InfluxLatestTimestampBenchmarkTest::lastSelectorQuery,
+            measurementCount
+        );
+        runScenario(
+            influxDb,
+            config,
+            measurementCount,
+            "batched_last_selector",
+            ignored -> batchedLastSelectorQuery(),
+            1
+        );
+      }
+    }
+  }
+
+  private static void printHeader() {
+    
System.out.println("measurements,points_per_measurement,total_points,strategy,query_count,total_ms,mean_ms,p50_ms,"
+        + "p95_ms");
+  }
+
+  private static void runScenario(InfluxDB influxDb,
+                                  BenchmarkConfig config,
+                                  int measurementCount,
+                                  String strategy,
+                                  IntFunction<String> queryFactory,
+                                  int queryCount) {
+    for (int i = 0; i < config.warmupRuns(); i++) {
+      executeQueries(influxDb, config.database(), queryFactory, queryCount);
+    }
+
+    var durations = new ArrayList<Long>();
+    for (int i = 0; i < config.measurementRuns(); i++) {
+      durations.add(executeQueries(influxDb, config.database(), queryFactory, 
queryCount));
+    }
+
+    durations.sort(Comparator.naturalOrder());
+    long totalNanos = durations.stream().mapToLong(Long::longValue).sum();
+    long totalPoints = (long) measurementCount * config.pointsPerMeasurement();
+    System.out.printf(Locale.ROOT,
+        "%d,%d,%d,%s,%d,%.3f,%.3f,%.3f,%.3f%n",
+        measurementCount,
+        config.pointsPerMeasurement(),
+        totalPoints,
+        strategy,
+        queryCount,
+        toMillis(totalNanos),
+        toMillis(totalNanos / config.measurementRuns()),
+        toMillis(percentile(durations, 0.50)),
+        toMillis(percentile(durations, 0.95)));
+  }
+
+  private static long executeQueries(InfluxDB influxDb,
+                                     String database,
+                                     IntFunction<String> queryFactory,
+                                     int queryCount) {
+    long startNanos = System.nanoTime();
+    for (int i = 0; i < queryCount; i++) {
+      var result = influxDb.query(new Query(queryFactory.apply(i), database), 
TimeUnit.MILLISECONDS);
+      assertNoQueryError(result);
+    }
+    return System.nanoTime() - startNanos;
+  }
+
+  private static void assertNoQueryError(QueryResult result) {
+    assertFalse(result.hasError(), () -> "Influx query failed: " + 
result.getError());
+    if (result.getResults() != null) {
+      result.getResults()
+          .forEach(r -> assertFalse(r.hasError(), () -> "Influx query failed: 
" + r.getError()));
+    }
+  }
+
+  private static void assertBenchmarkDatabase(BenchmarkConfig config) {
+    assertTrue(
+        Boolean.getBoolean(ALLOW_NON_BENCHMARK_DATABASE) || 
config.database().toLowerCase(Locale.ROOT)
+            .contains("benchmark"),
+        "Refusing to drop and recreate database '"
+            + config.database()
+            + "'. Use a benchmark database name or set -D"
+            + ALLOW_NON_BENCHMARK_DATABASE
+            + "=true."
+    );
+  }
+
+  private static void recreateDatabase(InfluxDB influxDb,
+                                       String database) {
+    influxDb.query(new Query("DROP DATABASE \"" + database + "\"", ""));
+    influxDb.query(new Query("CREATE DATABASE \"" + database + "\"", ""));
+    influxDb.setDatabase(database);
+  }
+
+  private static void dropBenchmarkMeasurements(InfluxDB influxDb,
+                                                String database) {
+    assertNoQueryError(influxDb.query(new Query("DROP SERIES FROM 
/^measure-[0-9]+$/", database)));
+  }
+
+  private static void loadData(InfluxDB influxDb,
+                               BenchmarkConfig config,
+                               int measurementCount) {
+    BatchPoints batch = newBatch(config.database(), config.retentionPolicy());
+    int pointsInBatch = 0;
+    for (int measurementIndex = 0; measurementIndex < measurementCount; 
measurementIndex++) {
+      for (int pointIndex = 0; pointIndex < config.pointsPerMeasurement(); 
pointIndex++) {
+        batch.point(makePoint(measurementIndex, pointIndex, config.fields()));
+        pointsInBatch++;
+        if (pointsInBatch == config.batchSize()) {
+          influxDb.write(batch);
+          batch = newBatch(config.database(), config.retentionPolicy());
+          pointsInBatch = 0;
+        }
+      }
+    }
+
+    if (pointsInBatch > 0) {
+      influxDb.write(batch);
+    }
+  }
+
+  private static BatchPoints newBatch(String database,
+                                      String retentionPolicy) {
+    var builder = BatchPoints.database(database)
+        .consistency(InfluxDB.ConsistencyLevel.ALL);
+    if (retentionPolicy != null && !retentionPolicy.isBlank()) {
+      builder.retentionPolicy(retentionPolicy);
+    }
+    return builder.build();
+  }
+
+  private static Point makePoint(int measurementIndex,
+                                 int pointIndex,
+                                 int fields) {
+    var point = Point.measurement(measurementName(measurementIndex))
+        .time(BASE_TIMESTAMP + pointIndex * 1000L, TimeUnit.MILLISECONDS)
+        .tag("device", "device-" + measurementIndex)
+        .tag("site", "site-" + measurementIndex % 10)
+        .addField("value", pointIndex);
+
+    for (int fieldIndex = 1; fieldIndex < fields; fieldIndex++) {
+      point.addField("value_" + fieldIndex, pointIndex + fieldIndex);
+    }
+
+    return point.build();
+  }
+
+  private static String currentLatestTimestampQuery(int measurementIndex,
+                                                    int pointsPerMeasurement) {
+    long endTimestamp = (BASE_TIMESTAMP + pointsPerMeasurement * 1000L + 
1000L) * 1000000L;
+    return "SELECT * FROM \"" + measurementName(measurementIndex)
+        + "\" WHERE time > 0 AND time < "
+        + endTimestamp
+        + " ORDER BY time DESC LIMIT 1";
+  }
+
+  private static String lastSelectorQuery(int measurementIndex) {
+    return "SELECT LAST(\"value\") FROM \"" + 
measurementName(measurementIndex) + "\"";
+  }
+
+  private static String batchedLastSelectorQuery() {
+    return "SELECT LAST(\"value\") FROM /^measure-[0-9]+$/";
+  }
+
+  private static String measurementName(int measurementIndex) {
+    return "measure-" + measurementIndex;
+  }
+
+  private static long percentile(List<Long> durations,
+                                 double percentile) {
+    int index = (int) Math.ceil(percentile * durations.size()) - 1;
+    return durations.get(Math.max(index, 0));
+  }
+
+  private static double toMillis(long durationNanos) {
+    return durationNanos / 1_000_000.0;
+  }
+
+  private record BenchmarkConfig(String url,
+                                 String username,
+                                 String password,
+                                 String token,
+                                 String database,
+                                 String retentionPolicy,
+                                 boolean recreateDatabase,
+                                 boolean cleanupMeasurements,
+                                 List<Integer> measurementCounts,
+                                 int pointsPerMeasurement,
+                                 int fields,
+                                 int batchSize,
+                                 int warmupRuns,
+                                 int measurementRuns) {
+
+    static BenchmarkConfig fromSystemProperties() {
+      return new BenchmarkConfig(
+          System.getProperty(URL, "http://localhost:8086";),
+          System.getProperty(USERNAME, ""),
+          System.getProperty(PASSWORD, ""),
+          System.getProperty(TOKEN, ""),
+          System.getProperty(DATABASE, "streampipes_benchmark"),
+          System.getProperty(RETENTION_POLICY, ""),
+          Boolean.parseBoolean(System.getProperty(RECREATE_DATABASE, "true")),
+          Boolean.parseBoolean(System.getProperty(CLEANUP_MEASUREMENTS, 
"true")),
+          getIntList(MEASUREMENTS, "10,100"),
+          getInt(POINTS_PER_MEASUREMENT, 1000),
+          getInt(FIELDS, 3),
+          getInt(BATCH_SIZE, 5000),
+          getInt(WARMUP_RUNS, 2),
+          getInt(MEASUREMENT_RUNS, 5)
+      );
+    }
+
+    InfluxDB connect() {
+      if (token != null && !token.isBlank()) {
+        return InfluxDBFactory.connect(url, 
InfluxClientUtils.getHttpClientBuilder(token));
+      } else if (username == null || username.isBlank()) {
+        return InfluxDBFactory.connect(url);
+      } else {
+        return InfluxDBFactory.connect(url, username, password);
+      }
+    }
+
+    private static int getInt(String property,
+                              int defaultValue) {
+      return Integer.parseInt(System.getProperty(property, 
String.valueOf(defaultValue)));
+    }
+
+    private static List<Integer> getIntList(String property,
+                                            String defaultValue) {
+      return Arrays.stream(System.getProperty(property, 
defaultValue).split(","))
+          .map(String::trim)
+          .filter(value -> !value.isBlank())
+          .map(Integer::parseInt)
+          .toList();
+    }
+  }
+}
diff --git 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/SelectQueryParamsTest.java
 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/SelectQueryParamsTest.java
index 6513f2a7c3..62188caac1 100644
--- 
a/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/SelectQueryParamsTest.java
+++ 
b/streampipes-data-explorer-influx/src/test/java/org/apache/streampipes/dataexplorer/influx/SelectQueryParamsTest.java
@@ -21,17 +21,44 @@ package org.apache.streampipes.dataexplorer.influx;
 import 
org.apache.streampipes.dataexplorer.influx.utils.ProvidedQueryParameterBuilder;
 import 
org.apache.streampipes.dataexplorer.param.ProvidedRestQueryParamConverter;
 import org.apache.streampipes.dataexplorer.param.SelectQueryParams;
+import org.apache.streampipes.model.datalake.param.ProvidedRestQueryParams;
 
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_END_DATE;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_LIMIT;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_MISSING_VALUE_BEHAVIOUR;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_ORDER;
+import static 
org.apache.streampipes.model.datalake.param.SupportedRestQueryParams.QP_START_DATE;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class SelectQueryParamsTest {
 
+  @Test
+  public void testLatestEventTimestampQuery() {
+    var params = new ProvidedRestQueryParams(
+        "abc",
+        Map.of(
+            QP_START_DATE, "0",
+            QP_END_DATE, "100",
+            QP_LIMIT, "1",
+            QP_ORDER, "DESC",
+            QP_MISSING_VALUE_BEHAVIOUR, "empty"
+        )
+    );
+
+    SelectQueryParams qp = 
ProvidedRestQueryParamConverter.getSelectQueryParams(params);
+
+    String query = 
qp.toQuery(DataLakeInfluxQueryBuilder.create("abc")).getCommand();
+
+    assertEquals("SELECT * FROM \"abc\" WHERE (time < 100000000 AND time > 0) 
ORDER BY time DESC LIMIT 1;", query);
+  }
+
   @Test
   public void testWildcardTimeBoundQuery() {
     var params = ProvidedQueryParameterBuilder.create("abc")
diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
index e38489b109..61cf4ed204 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/datalake/DataLakeResource.java
@@ -274,11 +274,7 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
       );
     }
 
-    Map<String, Long> latestEvents = distinctMeasurementNames.stream()
-        .collect(Collectors.toMap(
-            measurementName -> measurementName,
-            this::getLatestEvent
-        ));
+    Map<String, Long> latestEvents = 
this.dataExplorerQueryManagement.getLatestTimestamps(distinctMeasurementNames);
 
     return ok(latestEvents);
   }
@@ -436,25 +432,6 @@ public class DataLakeResource extends 
AbstractDataLakeResource {
     return new ProvidedRestQueryParams(measurementId, queryParamMap);
   }
 
-  private Long getLatestEvent(String measurementName) {
-    Map<String, String> queryParams = Map.of(
-        QP_START_DATE, "0",
-        QP_END_DATE, String.valueOf(System.currentTimeMillis()),
-        QP_LIMIT, "1",
-        QP_ORDER, "DESC",
-        QP_MISSING_VALUE_BEHAVIOUR, "empty"
-    );
-
-    try {
-      return this.dataExplorerQueryManagement
-          .getData(new ProvidedRestQueryParams(measurementName, queryParams), 
true)
-          .getLastTimestamp();
-    } catch (RuntimeException e) {
-      LOG.warn("Could not get latest event for measurement {}", 
measurementName, e);
-      return 0L;
-    }
-  }
-
   // Checks if the parameter for missing value behaviour is set
   private boolean isIgnoreMissingValues(String missingValueBehaviour) {
     return "ignore".equals(missingValueBehaviour);
diff --git 
a/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeLatestEventsBenchmarkTest.java
 
b/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeLatestEventsBenchmarkTest.java
new file mode 100644
index 0000000000..23bb3170da
--- /dev/null
+++ 
b/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeLatestEventsBenchmarkTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.streampipes.rest.impl.datalake;
+
+import org.apache.streampipes.dataexplorer.api.IDataExplorerQueryManagement;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+@Tag("benchmark")
+class DataLakeLatestEventsBenchmarkTest {
+
+  private static final List<Integer> MEASUREMENT_COUNTS = List.of(10, 100, 
1000);
+  private static final int WARMUP_RUNS = 2;
+  private static final int MEASUREMENT_RUNS = 5;
+
+  @Test
+  void benchmarkLatestEventsFanOut() throws Exception {
+    
System.out.println("measurement_count,mean_duration_ms,query_management_calls");
+    for (int measurementCount : MEASUREMENT_COUNTS) {
+      for (int i = 0; i < WARMUP_RUNS; i++) {
+        runLatestEvents(measurementCount);
+      }
+
+      long totalDurationNanos = 0L;
+      for (int i = 0; i < MEASUREMENT_RUNS; i++) {
+        totalDurationNanos += runLatestEvents(measurementCount);
+      }
+
+      var meanDurationMillis = 
TimeUnit.NANOSECONDS.toMicros(totalDurationNanos / MEASUREMENT_RUNS) / 1000.0;
+      System.out.printf(Locale.ROOT, "%d,%.3f,%d%n", measurementCount, 
meanDurationMillis, 1);
+    }
+  }
+
+  private static long runLatestEvents(int measurementCount) throws Exception {
+    var queryManagement = mock(IDataExplorerQueryManagement.class);
+    var measurementNames = measurementNames(measurementCount);
+    
when(queryManagement.getLatestTimestamps(measurementNames)).thenReturn(latestTimestamps(measurementNames));
+    var resource = dataLakeResource(queryManagement);
+
+    long startNanos = System.nanoTime();
+    var response = resource.getLatestEvents(measurementNames);
+    long durationNanos = System.nanoTime() - startNanos;
+
+    assertEquals(measurementCount, ((java.util.Map<?, ?>) 
response.getBody()).size());
+    verify(queryManagement).getLatestTimestamps(measurementNames);
+    verifyNoMoreInteractions(queryManagement);
+    return durationNanos;
+  }
+
+  private static List<String> measurementNames(int measurementCount) {
+    return java.util.stream.IntStream.range(0, measurementCount)
+        .mapToObj(i -> "measure-" + i)
+        .toList();
+  }
+
+  private static java.util.Map<String, Long> latestTimestamps(List<String> 
measurementNames) {
+    var latestTimestamps = new HashMap<String, Long>();
+    measurementNames.forEach(measurementName -> 
latestTimestamps.put(measurementName, (long) measurementName.length()));
+    return latestTimestamps;
+  }
+
+  private static DataLakeResource 
dataLakeResource(IDataExplorerQueryManagement queryManagement) throws Exception 
{
+    var resource = mock(DataLakeResource.class, CALLS_REAL_METHODS);
+    doReturn(true).when(resource).checkPermissionByName(any(), eq("READ"));
+    setQueryManagement(resource, queryManagement);
+    return resource;
+  }
+
+  private static void setQueryManagement(DataLakeResource resource,
+                                         IDataExplorerQueryManagement 
queryManagement) throws Exception {
+    Field field = 
DataLakeResource.class.getDeclaredField("dataExplorerQueryManagement");
+    field.setAccessible(true);
+    field.set(resource, queryManagement);
+  }
+}
diff --git 
a/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeResourceTest.java
 
b/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeResourceTest.java
new file mode 100644
index 0000000000..aea4c1e753
--- /dev/null
+++ 
b/streampipes-rest/src/test/java/org/apache/streampipes/rest/impl/datalake/DataLakeResourceTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.streampipes.rest.impl.datalake;
+
+import org.apache.streampipes.dataexplorer.api.IDataExplorerQueryManagement;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class DataLakeResourceTest {
+
+  @Test
+  void getLatestEventsRequestsLatestTimestampsForDistinctMeasurements() throws 
Exception {
+    var queryManagement = mock(IDataExplorerQueryManagement.class);
+    when(queryManagement.getLatestTimestamps(List.of("a", "bb", "broken")))
+        .thenReturn(Map.of("a", 1L, "bb", 2L, "broken", 0L));
+    var resource = dataLakeResource(queryManagement, true);
+
+    var response = resource.getLatestEvents(List.of("a", "bb", "a", "broken"));
+
+    assertEquals(HttpStatus.OK, response.getStatusCode());
+    assertEquals(Map.of("a", 1L, "bb", 2L, "broken", 0L), response.getBody());
+    verify(queryManagement).getLatestTimestamps(List.of("a", "bb", "broken"));
+    verify(queryManagement, times(0)).getData(any(), eq(true));
+  }
+
+  @Test
+  void getLatestEventsRejectsUnauthorizedMeasurementBeforeQuerying() throws 
Exception {
+    var queryManagement = mock(IDataExplorerQueryManagement.class);
+    var resource = dataLakeResource(queryManagement, false);
+
+    var response = resource.getLatestEvents(List.of("a"));
+
+    assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+    assertEquals("No read permission for measurement a", response.getBody());
+    verify(queryManagement, times(0)).getData(any(), eq(true));
+    verify(queryManagement, times(0)).getLatestTimestamps(any());
+  }
+
+  private static DataLakeResource 
dataLakeResource(IDataExplorerQueryManagement queryManagement,
+                                                   boolean canRead) throws 
Exception {
+    var resource = mock(DataLakeResource.class, CALLS_REAL_METHODS);
+    doReturn(canRead).when(resource).checkPermissionByName(any(), eq("READ"));
+    setQueryManagement(resource, queryManagement);
+    return resource;
+  }
+
+  private static void setQueryManagement(DataLakeResource resource,
+                                         IDataExplorerQueryManagement 
queryManagement) throws Exception {
+    Field field = 
DataLakeResource.class.getDeclaredField("dataExplorerQueryManagement");
+    field.setAccessible(true);
+    field.set(resource, queryManagement);
+  }
+}


Reply via email to