diff --git a/.github/workflows/docker-nightly.yml b/.github/workflows/docker-nightly.yml index fb5a2c67d73..d55dbb78f2a 100644 --- a/.github/workflows/docker-nightly.yml +++ b/.github/workflows/docker-nightly.yml @@ -33,6 +33,21 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - + name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'temurin' + java-version: 25 + cache: maven + - + name: Build allinone service jars + # Produces /service/target/ditto--service-0-SNAPSHOT-allinone.jar for + # every service module. The dockerfile-snapshot COPY below picks these up. + # Required because Eclipse's Maven repos do not host 0-SNAPSHOT artifacts — + # dockerfile-release's curl from ditto-maven2-releases silently 404s into a + # broken image otherwise. + run: mvn package -DskipTests -B -ntp - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 @@ -58,7 +73,7 @@ jobs: name: Branch name id: branch_name run: | - echo "IMAGE_TAG=nightly" >> $GITHUB_ENV + echo "IMAGE_TAG=nightly-ts" >> $GITHUB_ENV - name: Building + pushing Docker images for tag run: | @@ -68,9 +83,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: dockerfile-release + file: dockerfile-snapshot platforms: linux/amd64,linux/arm64 build-args: | + TARGET_DIR=policies/service/target SERVICE_STARTER=ditto-policies-service SERVICE_VERSION=0-SNAPSHOT MAIN_CLASS=org.eclipse.ditto.policies.service.starter.PoliciesService @@ -83,9 +99,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: dockerfile-release + file: dockerfile-snapshot platforms: linux/amd64,linux/arm64 build-args: | + TARGET_DIR=things/service/target SERVICE_STARTER=ditto-things-service SERVICE_VERSION=0-SNAPSHOT MAIN_CLASS=org.eclipse.ditto.things.service.starter.ThingsService @@ -98,9 +115,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: dockerfile-release + file: dockerfile-snapshot platforms: linux/amd64,linux/arm64 build-args: | + TARGET_DIR=gateway/service/target SERVICE_STARTER=ditto-gateway-service SERVICE_VERSION=0-SNAPSHOT MAIN_CLASS=org.eclipse.ditto.gateway.service.starter.GatewayService @@ -113,9 +131,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: dockerfile-release + file: dockerfile-snapshot platforms: linux/amd64,linux/arm64 build-args: | + TARGET_DIR=thingsearch/service/target SERVICE_STARTER=ditto-thingsearch-service SERVICE_VERSION=0-SNAPSHOT MAIN_CLASS=org.eclipse.ditto.thingsearch.service.starter.SearchService @@ -128,9 +147,10 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: dockerfile-release + file: dockerfile-snapshot platforms: linux/amd64,linux/arm64 build-args: | + TARGET_DIR=connectivity/service/target SERVICE_STARTER=ditto-connectivity-service SERVICE_VERSION=0-SNAPSHOT MAIN_CLASS=org.eclipse.ditto.connectivity.service.ConnectivityService @@ -139,6 +159,22 @@ jobs: push: true tags: | eclipse/ditto-connectivity:${{ env.IMAGE_TAG }} + - + name: Build and push ditto-timeseries + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: dockerfile-snapshot + platforms: linux/amd64,linux/arm64 + build-args: | + TARGET_DIR=timeseries/service/target + SERVICE_STARTER=ditto-timeseries-service + SERVICE_VERSION=0-SNAPSHOT + MAIN_CLASS=org.eclipse.ditto.timeseries.service.starter.TimeseriesService + pull: true + push: true + tags: | + eclipse/ditto-timeseries:${{ env.IMAGE_TAG }} - name: Use Node.js 18.x uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -213,7 +249,17 @@ jobs: ignore-unfixed: true vuln-type: 'os,library' severity: 'CRITICAL' - - + - + name: Run Trivy vulnerability scanner for ditto-timeseries + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: 'docker.io/eclipse/ditto-timeseries:${{ env.IMAGE_TAG }}' + format: 'table' + exit-code: '1' + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL' + - name: Run Trivy vulnerability scanner for ditto-ui uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: diff --git a/.run/Ditto.run.xml b/.run/Ditto.run.xml index 6aacf9255db..98673bcd134 100644 --- a/.run/Ditto.run.xml +++ b/.run/Ditto.run.xml @@ -5,6 +5,7 @@ + \ No newline at end of file diff --git a/.run/TimeseriesService.run.xml b/.run/TimeseriesService.run.xml new file mode 100644 index 00000000000..5ee3fbbd3c2 --- /dev/null +++ b/.run/TimeseriesService.run.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/base/model/src/main/java/org/eclipse/ditto/base/model/signals/FeatureToggle.java b/base/model/src/main/java/org/eclipse/ditto/base/model/signals/FeatureToggle.java index 972d230cb21..4602efacbd7 100644 --- a/base/model/src/main/java/org/eclipse/ditto/base/model/signals/FeatureToggle.java +++ b/base/model/src/main/java/org/eclipse/ditto/base/model/signals/FeatureToggle.java @@ -118,6 +118,17 @@ public final class FeatureToggle { public static final String POLICY_LOCKOUT_PREVENTION_ENABLED = "ditto.devops.feature.policy-lockout-prevention-enabled"; + /** + * System property name of the property defining whether the timeseries feature + * (per-Thing ingest from WoT-annotated property events, RetrieveTimeseries query) is enabled. + * When disabled, things-service does not start the {@code TimeseriesIngestPublisher} + * (so no per-event WoT-resolution work on the write path) and gateway-routed + * {@code RetrieveTimeseries} commands are rejected with {@link UnsupportedSignalException}. + * + * @since 4.0.0 + */ + public static final String TIMESERIES_ENABLED = "ditto.devops.feature.timeseries-enabled"; + /** * Resolves the system property {@value MERGE_THINGS_ENABLED}. */ @@ -172,6 +183,11 @@ public final class FeatureToggle { private static final boolean IS_POLICY_LOCKOUT_PREVENTION_ENABLED = resolveProperty(POLICY_LOCKOUT_PREVENTION_ENABLED); + /** + * Resolves the system property {@value TIMESERIES_ENABLED}. + */ + private static final boolean IS_TIMESERIES_ENABLED = resolveProperty(TIMESERIES_ENABLED); + private static boolean resolveProperty(final String propertyName) { final String propertyValue = System.getProperty(propertyName, Boolean.TRUE.toString()); return !Boolean.FALSE.toString().equalsIgnoreCase(propertyValue); @@ -339,4 +355,35 @@ public static boolean isStacklessFlowControlExceptionsEnabled() { public static boolean isPolicyLockoutPreventionEnabled() { return IS_POLICY_LOCKOUT_PREVENTION_ENABLED; } + + /** + * Checks if the timeseries feature is enabled based on the system property {@value TIMESERIES_ENABLED}. + * + * @param signal the name of the signal that was supposed to be processed + * @param dittoHeaders headers used to build exception + * @return the unmodified headers parameters + * @throws UnsupportedSignalException if the system property {@value TIMESERIES_ENABLED} + * resolves to {@code false} + * @since 4.0.0 + */ + public static DittoHeaders checkTimeseriesFeatureEnabled(final String signal, final DittoHeaders dittoHeaders) { + if (!IS_TIMESERIES_ENABLED) { + throw UnsupportedSignalException + .newBuilder(signal) + .dittoHeaders(dittoHeaders) + .build(); + } + return dittoHeaders; + } + + /** + * Returns whether the timeseries feature is enabled based on the system property + * {@value TIMESERIES_ENABLED}. + * + * @return whether the timeseries feature is enabled or not. + * @since 4.0.0 + */ + public static boolean isTimeseriesFeatureEnabled() { + return IS_TIMESERIES_ENABLED; + } } diff --git a/base/service/src/main/java/org/eclipse/ditto/base/service/DittoService.java b/base/service/src/main/java/org/eclipse/ditto/base/service/DittoService.java index a8913fab16e..25737cc6b39 100644 --- a/base/service/src/main/java/org/eclipse/ditto/base/service/DittoService.java +++ b/base/service/src/main/java/org/eclipse/ditto/base/service/DittoService.java @@ -431,6 +431,8 @@ private void injectSystemPropertiesLimits(final C serviceSpecificConfig) { rawConfig.getBoolean(FeatureToggle.STACKLESS_FLOW_CONTROL_EXCEPTIONS_ENABLED))); System.setProperty(FeatureToggle.POLICY_LOCKOUT_PREVENTION_ENABLED, Boolean.toString(rawConfig.getBoolean(FeatureToggle.POLICY_LOCKOUT_PREVENTION_ENABLED))); + System.setProperty(FeatureToggle.TIMESERIES_ENABLED, + Boolean.toString(rawConfig.getBoolean(FeatureToggle.TIMESERIES_ENABLED))); System.setProperty(DittoSystemProperties.DITTO_LIMITS_POLICY_IMPORTS_LIMIT, Integer.toString(limitsConfig.getPolicyImportsLimit())); final MetricsConfig metricsConfig = serviceSpecificConfig.getMetricsConfig(); diff --git a/bom/pom.xml b/bom/pom.xml index d16119833b7..938aa864596 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -531,6 +531,11 @@ ditto-thingsearch-model ${project.version} + + org.eclipse.ditto + ditto-timeseries-model + ${project.version} + org.eclipse.ditto ditto-placeholders @@ -755,6 +760,11 @@ ditto-thingsearch-api ${project.version} + + org.eclipse.ditto + ditto-timeseries-api + ${project.version} + org.eclipse.ditto ditto-internal-models-signal @@ -798,6 +808,16 @@ ditto-thingsearch-service ${project.version} + + org.eclipse.ditto + ditto-timeseries-service + ${project.version} + + + org.eclipse.ditto + ditto-timeseries-mongodb + ${project.version} + nl.grons metrics4-scala_${scala.version} diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandRegistryTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandRegistryTest.java index 2cda099fa10..e410c1769a6 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandRegistryTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandRegistryTest.java @@ -44,6 +44,8 @@ import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThings; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.CreateSubscription; import org.eclipse.ditto.things.model.devops.commands.CreateWotValidationConfig; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; public class ConnectivityServiceGlobalCommandRegistryTest extends GlobalCommandRegistryTestCases { @@ -79,7 +81,9 @@ public ConnectivityServiceGlobalCommandRegistryTest() { CheckPermissions.class, MigrateConnectionEncryption.class, MigrateConnectionEncryptionAbort.class, - MigrateConnectionEncryptionStatus.class + MigrateConnectionEncryptionStatus.class, + IngestDataPoints.class, + RetrieveTimeseries.class ); } diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandResponseRegistryTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandResponseRegistryTest.java index f063c40303d..f9a21d4d349 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandResponseRegistryTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalCommandResponseRegistryTest.java @@ -43,6 +43,8 @@ import org.eclipse.ditto.connectivity.service.messaging.persistence.migration.MigrateConnectionEncryptionResponse; import org.eclipse.ditto.connectivity.service.messaging.persistence.migration.MigrateConnectionEncryptionStatusResponse; import org.eclipse.ditto.things.model.devops.commands.CreateWotValidationConfigResponse; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; public final class ConnectivityServiceGlobalCommandResponseRegistryTest extends GlobalCommandResponseRegistryTestCases { @@ -77,7 +79,9 @@ public ConnectivityServiceGlobalCommandResponseRegistryTest() { CheckPermissionsResponse.class, MigrateConnectionEncryptionResponse.class, MigrateConnectionEncryptionStatusResponse.class, - MigrateConnectionEncryptionAbortResponse.class + MigrateConnectionEncryptionAbortResponse.class, + IngestDataPointsResponse.class, + RetrieveTimeseriesResponse.class ); } diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalErrorRegistryTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalErrorRegistryTest.java index c78d8cb9165..1f1c8daa42b 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalErrorRegistryTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/ConnectivityServiceGlobalErrorRegistryTest.java @@ -48,6 +48,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.AttributePointerInvalidException; import org.eclipse.ditto.thingsearch.api.QueryTimeExceededException; import org.eclipse.ditto.thingsearch.model.signals.commands.exceptions.InvalidNamespacesException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; import org.eclipse.ditto.wot.model.WotThingModelInvalidException; import org.eclipse.ditto.wot.validation.WotThingModelPayloadValidationException; @@ -92,7 +93,8 @@ public ConnectivityServiceGlobalErrorRegistryTest() { WotValidationConfigHistoryNotAccessibleException.class, WotValidationConfigInvalidException.class, WotValidationConfigNotAccessibleException.class, - ServiceTimeoutException.class + ServiceTimeoutException.class, + TimeseriesQueryInvalidException.class ); } diff --git a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/MessageMappingProcessorActorTest.java b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/MessageMappingProcessorActorTest.java index c20d5b59eae..c7876a1612b 100644 --- a/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/MessageMappingProcessorActorTest.java +++ b/connectivity/service/src/test/java/org/eclipse/ditto/connectivity/service/messaging/MessageMappingProcessorActorTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -70,6 +71,10 @@ import org.eclipse.ditto.things.model.Thing; import org.eclipse.ditto.things.model.ThingFieldSelector; import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.Aggregation; import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; import org.eclipse.ditto.things.model.signals.commands.modify.DeleteThingResponse; import org.eclipse.ditto.things.model.signals.commands.modify.ModifyAttribute; @@ -137,6 +142,46 @@ public void testMappingFailedExpectErrorResponseWitMapperId() { ); } + /** + * Third transport: a cross-Thing aggregation arriving over a Connectivity source. + *

+ * {@code RetrieveAggregatedTimeseries} is deliberately not a {@code WithEntityId} command, and + * {@code InboundDispatchingSink} branches on the entity ID — an entity-less command falls to the + * generic {@code proxyActor.tell(signal, sender)} path. That path is untested for such commands, + * and {@code CheckPermissions} (the only other entity-less command) needed a dedicated branch, so + * the generic route cannot be assumed to work by inspection. This drives the real inbound + * pipeline: Ditto Protocol text on a source, payload mapping, protocol adapter, dispatch. + */ + @Test + public void crossThingAggregationFromAConnectivitySourceIsDispatched() { + new TestKit(actorSystem) {{ + final ActorRef outboundMappingProcessorActor = createOutboundMappingProcessorActor(this); + final ActorRef inboundMappingProcessorActor = + createInboundMappingProcessorActor(this, outboundMappingProcessorActor); + + final CrossThingTimeseriesQuery query = CrossThingTimeseriesQuery.of("org.eclipse.ditto", + List.of(JsonPointer.of("/features/env/properties/flowTemperature")), + Instant.parse("2026-07-01T00:00:00Z"), Instant.parse("2026-07-02T00:00:00Z"), + Duration.ofHours(1), Aggregation.AVG, + List.of(GroupBy.thingId()), "eq(attributes/building,'A')", null, null, null); + final RetrieveAggregatedTimeseries command = RetrieveAggregatedTimeseries.of(query, + DittoHeaders.newBuilder().correlationId("conn-aggregate-1").build()); + + final TestProbe collectorProbe = TestProbe.apply("collector", actorSystem); + inboundMappingProcessorActor.tell( + new ExternalMessageWithSender(toExternalMessage(command), collectorProbe.ref()), + ActorRef.noSender()); + + final RetrieveAggregatedTimeseries dispatched = + fishForMsg(this, RetrieveAggregatedTimeseries.class); + + // The whole query must survive source -> protocol adapter -> dispatch, not just the type. + assertThat(dispatched.getQuery()).isEqualTo(query); + assertThat(dispatched.getNamespace()).isEqualTo("org.eclipse.ditto"); + assertThat(dispatched.getDittoHeaders().getCorrelationId()).contains("conn-aggregate-1"); + }}; + } + @Test public void testSignalEnrichment() { // GIVEN: test probe actor started with configured values diff --git a/deployment/helm/ditto/Chart.yaml b/deployment/helm/ditto/Chart.yaml index 140f74d1e42..12e2edbde09 100644 --- a/deployment/helm/ditto/Chart.yaml +++ b/deployment/helm/ditto/Chart.yaml @@ -16,7 +16,7 @@ description: | A digital twin is a virtual, cloud based, representation of his real world counterpart (real world “Things”, e.g. devices like sensors, smart heating, connected cars, smart grids, EV charging stations etc). type: application -version: 4.6.0 # chart version is effectively set by release-job +version: 4.7.0 # chart version is effectively set by release-job appVersion: 3.9.6 keywords: - iot-chart diff --git a/deployment/helm/ditto/logback-config/timeseries.xml b/deployment/helm/ditto/logback-config/timeseries.xml new file mode 100644 index 00000000000..02c7ab7ceb6 --- /dev/null +++ b/deployment/helm/ditto/logback-config/timeseries.xml @@ -0,0 +1,37 @@ + + + + + + sourceActorSystem + pekkoUid + pekkoTimestamp + x-correlation-id=correlation-id + + + + + + + + + + + + + + + + + diff --git a/deployment/helm/ditto/service-config/timeseries-extension.conf.tpl b/deployment/helm/ditto/service-config/timeseries-extension.conf.tpl new file mode 100644 index 00000000000..c88c2782168 --- /dev/null +++ b/deployment/helm/ditto/service-config/timeseries-extension.conf.tpl @@ -0,0 +1,50 @@ +# Ditto "Timeseries" configuration extension file to be placed at /opt/ditto/timeseries-extension.conf +ditto { + headers { + redacted-in-log = [ + {{- range $index, $header := .Values.global.headersRedactedInLog }} + "{{$header}}" + {{- end }} + ] + } + + {{- if .Values.global.namespacePolicies }} + namespace-policies { + {{- range $pattern, $policyIds := .Values.global.namespacePolicies }} + "{{$pattern}}" = [ + {{- range $idx, $policyId := $policyIds }} + "{{$policyId}}" + {{- end }} + ] + {{- end }} + } + {{- end }} + + {{- with .Values.timeseries.config.adapter.mongodb.retentionOverrides }} + # Per-namespace timeseries retention overrides (env vars can't carry a map; rendered here). + timeseries.adapter.mongodb.retention-overrides { + {{- range $namespace, $duration := . }} + "{{ $namespace }}" = "{{ $duration }}" + {{- end }} + } + {{- end }} + + {{- with .Values.timeseries.config.adapter.mongodb.capabilities }} + {{- if .pushableAggregations }} + # Aggregations MongoDB pushes down natively (lists can't come from env vars; rendered here). + timeseries.adapter.mongodb.capabilities.pushable-aggregations = [ + {{- range $index, $aggregation := .pushableAggregations }} + "{{ $aggregation }}" + {{- end }} + ] + {{- end }} + {{- if .nativeFillStrategies }} + # Gap-fill strategies MongoDB applies natively (rendered here; env vars can't carry a list). + timeseries.adapter.mongodb.capabilities.native-fill-strategies = [ + {{- range $index, $fill := .nativeFillStrategies }} + "{{ $fill }}" + {{- end }} + ] + {{- end }} + {{- end }} +} diff --git a/deployment/helm/ditto/templates/mongodb-secret.yaml b/deployment/helm/ditto/templates/mongodb-secret.yaml index 94f7577bdd3..8162dcabee2 100644 --- a/deployment/helm/ditto/templates/mongodb-secret.yaml +++ b/deployment/helm/ditto/templates/mongodb-secret.yaml @@ -25,4 +25,5 @@ data: things-uri: {{ .Values.dbconfig.things.uri | replace "#{PLACEHOLDER_MONGODB_HOSTNAME}#" $mongoName | b64enc | quote}} thingsSearch-uri: {{ .Values.dbconfig.thingsSearch.uri | replace "#{PLACEHOLDER_MONGODB_HOSTNAME}#" $mongoName | b64enc | quote}} policies-uri: {{ .Values.dbconfig.policies.uri | replace "#{PLACEHOLDER_MONGODB_HOSTNAME}#" $mongoName | b64enc | quote}} + timeseries-uri: {{ .Values.dbconfig.timeseries.uri | replace "#{PLACEHOLDER_MONGODB_HOSTNAME}#" $mongoName | b64enc | quote}} {{- end }} diff --git a/deployment/helm/ditto/templates/timeseries-configmap.yaml b/deployment/helm/ditto/templates/timeseries-configmap.yaml new file mode 100644 index 00000000000..89587d941f5 --- /dev/null +++ b/deployment/helm/ditto/templates/timeseries-configmap.yaml @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +{{- $releaseName := .Release.Name -}} +{{- $name := include "ditto.name" . -}} +{{- $labels := include "ditto.labels" . -}} +{{ $root := . }} +{{- if .Values.timeseries.enabled -}} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ $releaseName }}-service-config-timeseries-extension-conf + namespace: {{ $root.Release.Namespace }} + labels: + app.kubernetes.io/name: {{ $name }}-service-config-timeseries-extension-conf +{{ $labels | indent 4 }} +data: + timeseries-extension.conf: |- +{{ tpl ($root.Files.Get "service-config/timeseries-extension.conf.tpl") $ | indent 4 }} +--- +{{- end }} diff --git a/deployment/helm/ditto/templates/timeseries-deployment.yaml b/deployment/helm/ditto/templates/timeseries-deployment.yaml new file mode 100644 index 00000000000..ea72bddac71 --- /dev/null +++ b/deployment/helm/ditto/templates/timeseries-deployment.yaml @@ -0,0 +1,385 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +# +# Adapted from things-deployment.yaml. Things-specific env vars (LIMITS_THINGS_*, +# MERGE_*, THINGS_WOT_TM_MODEL_VALIDATION_*, ENFORCEMENT_*, PEKKO_PERSISTENCE_MONGO_* +# circuit-breaker tuning, etc.) are intentionally omitted; the timeseries-service +# does not consume them. The timeseries-specific knobs come from timeseries.conf: +# TIMESERIES_SIMPLIFIED_READ_PERMISSION, TIMESERIES_ADAPTER_TYPE, TIMESERIES_QUERY_*, +# TIMESERIES_MONGODB_COLLECTION_PREFIX, TIMESERIES_MONGODB_GRANULARITY, TIMESERIES_MONGODB_RETENTION, +# TIMESERIES_MONGODB_CAP_NATIVE_QUERY (capability list overrides render via the extension conf). +# MongoDB connection settings come from `ditto.mongodb.*` via DefaultMongoDbConfig (same as the +# sibling services); see the MONGO_DB_* env vars in the spec.template.spec.containers env block. +{{- $name := include "ditto.name" . -}} +{{- if .Values.timeseries.enabled -}} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ditto.fullname" . }}-timeseries + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ $name }}-timeseries +{{ include "ditto.labels" . | indent 4 }} +spec: + replicas: {{ .Values.timeseries.replicaCount }} + strategy: + {{- with .Values.timeseries.updateStrategy }} + {{- toYaml . | nindent 4 }} + {{- end }} + minReadySeconds: {{ .Values.timeseries.minReadySeconds }} + selector: + matchLabels: + app.kubernetes.io/name: {{ $name }}-timeseries + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ $name }}-timeseries + app.kubernetes.io/instance: {{ .Release.Name }} + actorSystemName: {{ .Values.pekko.actorSystemName }} + {{- with .Values.timeseries.additionalLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + annotations: + {{- if .Values.global.prometheus.enabled }} + prometheus.io/scrape: "true" + prometheus.io/path: "{{ .Values.global.prometheus.path }}" + prometheus.io/port: "{{ .Values.global.prometheus.port }}" + {{- end }} + checksum/mongodb-config: {{ include (print $.Template.BasePath "/mongodb-secret.yaml") . | sha256sum }} + checksum/config: {{ include (print $.Template.BasePath "/timeseries-configmap.yaml") . | sha256sum }} + {{- with .Values.global.additionalAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.timeseries.additionalAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- if .Values.rbac.enabled }} + serviceAccountName: {{ template "ditto.serviceAccountName" . }} + {{- end }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + fsGroup: 1000 + initContainers: + {{- if .Values.global.logging.logFiles.enabled }} + - name: change-volume-owner + image: busybox + securityContext: + runAsUser: 0 + command: [ "sh", "-c", "chown -R 1005:1005 /var/log/ditto && echo 'changed ownership of /var/log/ditto to 1005:1005'" ] + volumeMounts: + - name: ditto-log-files-directory + mountPath: /var/log/ditto + {{- end }} + topologySpreadConstraints: + {{- range .Values.timeseries.topologySpreadConstraints }} + - maxSkew: {{ .maxSkew }} + topologyKey: {{ .topologyKey }} + whenUnsatisfiable: {{ .whenUnsatisfiable }} + labelSelector: + matchLabels: + app.kubernetes.io/name: {{ $name }}-timeseries + {{- end }} + terminationGracePeriodSeconds: {{ .Values.timeseries.terminationGracePeriodSeconds }} + containers: + - name: {{ .Chart.Name }}-timeseries + image: {{ printf "%s:%s" .Values.timeseries.image.repository ( default .Chart.AppVersion ( default .Values.dittoTag .Values.timeseries.image.tag ) ) }} + imagePullPolicy: {{ .Values.timeseries.image.pullPolicy }} + env: + {{- if not .Values.global.logging.customConfigFile.enabled }} + - name: DITTO_LOGGING_DISABLE_SYSOUT_LOG + value: "{{ not .Values.global.logging.sysout.enabled }}" + - name: DITTO_LOGGING_FILE_APPENDER + value: "{{ printf "%t" .Values.global.logging.logFiles.enabled }}" + {{- end }} + - name: DITTO_METRICS_METRIC_PREFIX + value: "{{ .Values.global.metrics.metricsPrefix }}" + - name: SYSTEM_METRICS_ENABLED + value: "{{ .Values.global.metrics.systemMetrics.enabled }}" + - name: DITTO_TRACING_ENABLED + value: "{{ .Values.global.tracing.enabled }}" + - name: DITTO_TRACING_TICK_INTERVAL + value: "{{ .Values.global.tracing.tickInterval }}" + - name: DITTO_TRACING_REPORTER_QUEUE_SIZE + value: "{{ .Values.global.tracing.reporterQueueSize }}" + - name: DITTO_TRACING_OTEL_TRACE_REPORTER_ENABLED + value: "{{ .Values.global.tracing.otelTraceReporterEnabled }}" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "{{ tpl .Values.global.tracing.otelExporterOtlpEndpoint . }}" + - name: DITTO_TRACING_SAMPLER + value: "{{ .Values.global.tracing.sampler }}" + - name: DITTO_TRACING_RANDOM_SAMPLER_PROBABILITY + value: "{{ .Values.global.tracing.randomSampler.probability }}" + - name: DITTO_TRACING_ADAPTIVE_SAMPLER_THROUGHPUT + value: "{{ .Values.global.tracing.adaptiveSampler.throughput }}" + {{- if .Values.global.logging.logstash.enabled }} + - name: DITTO_LOGGING_LOGSTASH_SERVER + value: "{{ .Values.global.logging.logstash.endpoint }}" + - name: LOGSTASH_WRITE_BUFFER_SIZE + value: "{{ .Values.global.logging.logstash.writeBufferSize }}" + - name: LOGSTASH_RING_BUFFER_SIZE + value: "{{ .Values.global.logging.logstash.ringBufferSize }}" + {{- end }} + - name: POD_LABEL_SELECTOR + value: "app.kubernetes.io/name=%s" + - name: POD_NAMESPACE + value: {{ .Release.Namespace }} + - name: INSTANCE_INDEX + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.name + - name: HOSTNAME + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: DISCOVERY_METHOD + value: "kubernetes-api" + - name: TZ + value: "{{ .Values.global.timezone }}" + - name: JAVA_TOOL_OPTIONS + value: > + {{ .Values.global.jvmOptions }} + -XX:ActiveProcessorCount={{ .Values.timeseries.jvm.activeProcessorCount }} + {{- if gt (int .Values.timeseries.jvm.parallelGcThreads) 0 }} + -XX:ParallelGCThreads={{ .Values.timeseries.jvm.parallelGcThreads }} + {{- end }} + {{- if gt (int .Values.timeseries.jvm.concGcThreads) 0 }} + -XX:ConcGCThreads={{ .Values.timeseries.jvm.concGcThreads }} + {{- end }} + -XX:MaxRAMPercentage={{ .Values.timeseries.jvm.heapRamPercentage }} + -XX:InitialRAMPercentage={{ .Values.timeseries.jvm.heapRamPercentage }} + -XX:MaxGCPauseMillis={{ .Values.timeseries.jvm.maxGcPauseMillis }} + -XX:G1ReservePercent={{ .Values.timeseries.jvm.g1ReservePercent }} + -XX:InitiatingHeapOccupancyPercent={{ .Values.timeseries.jvm.initiatingHeapOccupancyPercent }} + -XX:G1NewSizePercent={{ .Values.timeseries.jvm.g1NewSizePercent }} + -XX:G1MaxNewSizePercent={{ .Values.timeseries.jvm.g1MaxNewSizePercent }} + {{- if .Values.timeseries.activateGcLogging }} + {{ .Values.global.gcLoggingConfig }} + {{- end }} + {{ .Values.timeseries.additionalJvmOptions }} + {{ .Values.global.pekkoOptions }} + {{- if .Values.global.logging.customConfigFile.enabled }} + -Dlogback.configurationFile=/opt/ditto/{{ .Values.global.logging.customConfigFile.fileName }} + {{- end }} + {{ join " " .Values.timeseries.systemProps }} + # MongoDB connection settings — read by DefaultMongoDbConfig (same code path the + # sibling services use). MongoClientWrapper handles AWS IAM, pool sizing, SSL and + # circuit-breaker tuning internally based on these env vars. + - name: MONGO_DB_SSL_ENABLED + value: "{{ printf "%t" .Values.dbconfig.timeseries.ssl }}" + - name: MONGO_DB_URI + valueFrom: + secretKeyRef: + name: {{ .Values.dbconfig.uriSecret | default ( printf "%s-mongodb-secret" ( include "ditto.fullname" . )) }} + key: timeseries-uri + - name: MONGO_DB_CONNECTION_MIN_POOL_SIZE + value: "{{ .Values.timeseries.config.mongodb.minPoolSize }}" + - name: MONGO_DB_CONNECTION_POOL_SIZE + value: "{{ .Values.timeseries.config.mongodb.maxPoolSize }}" + - name: MONGO_DB_CONNECTION_POOL_IDLE_TIME + value: "{{ .Values.timeseries.config.mongodb.maxPoolIdleTime }}" + {{- if .Values.global.prometheus.enabled }} + - name: PROMETHEUS_PORT + value: "{{ .Values.global.prometheus.port }}" + {{- end }} + - name: CLUSTER_BS_REQUIRED_CONTACTS + value: "{{ .Values.global.cluster.requiredContactPoints }}" + - name: DITTO_DDATA_NUMBER_OF_SHARDS + value: "{{ .Values.global.cluster.ddata.numberOfShards }}" + - name: DITTO_DDATA_MAX_DELTA_ELEMENTS + value: "{{ .Values.global.cluster.ddata.maxDeltaElements }}" + - name: CLUSTER_NUMBER_OF_SHARDS + value: "{{ .Values.global.cluster.numberOfShards }}" + - name: CLUSTER_DOWNING_STABLE_AFTER + value: "{{ .Values.global.cluster.downingStableAfter }}" + - name: CLUSTER_DOWNING_DOWN_ALL_WHEN_UNSTABLE + value: "{{ .Values.global.cluster.downAllWhenUnstable }}" + - name: REMOTE_MAX_FRAMESIZE + value: "{{ .Values.global.limits.clusterMaxFramesize }}" + # Timeseries-specific knobs (timeseries.conf). + - name: TIMESERIES_SIMPLIFIED_READ_PERMISSION + value: "{{ printf "%t" .Values.timeseries.config.simplifiedReadPermission }}" + - name: TIMESERIES_ADAPTER_TYPE + value: "{{ .Values.timeseries.config.adapter.type }}" + {{- with .Values.timeseries.config.adapter.mongodb }} + {{- if .collectionPrefix }} + - name: TIMESERIES_MONGODB_COLLECTION_PREFIX + value: {{ .collectionPrefix | quote }} + {{- end }} + {{- if .granularity }} + - name: TIMESERIES_MONGODB_GRANULARITY + value: {{ .granularity | quote }} + {{- end }} + {{- if .retention }} + - name: TIMESERIES_MONGODB_RETENTION + value: {{ .retention | quote }} + {{- end }} + {{- if .maxQueryResultSize }} + - name: TIMESERIES_MONGODB_MAX_QUERY_RESULT_SIZE + value: "{{ .maxQueryResultSize }}" + {{- end }} + {{- if .queryTimeout }} + - name: TIMESERIES_MONGODB_QUERY_TIMEOUT + value: {{ .queryTimeout | quote }} + {{- end }} + {{- with .capabilities }} + {{- if kindIs "bool" .nativeQuery }} + - name: TIMESERIES_MONGODB_CAP_NATIVE_QUERY + value: "{{ .nativeQuery }}" + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.timeseries.extraEnv }} + {{- toYaml .Values.timeseries.extraEnv | nindent 12 }} + {{- end }} + # AWS IAM (IRSA) — identical wiring to sibling services. DefaultMongoDbConfig reads + # MONGO_DB_USE_AWS_IAM_ROLE + MONGO_DB_AWS_* and assumeRoleWithWebIdentity's the + # role internally; the resulting credential supplier is given to MongoClientWrapper. + - name: MONGO_DB_USE_AWS_IAM_ROLE + value: "{{ printf "%t" .Values.dbconfig.timeseries.useAwsIamRole }}" + {{- if .Values.dbconfig.timeseries.useAwsIamRole }} + - name: MONGO_DB_AWS_REGION + value: "{{ .Values.dbconfig.timeseries.awsRegion }}" + - name: MONGO_DB_AWS_ROLE_ARN + value: "{{ .Values.dbconfig.timeseries.awsRoleArn }}" + - name: AWS_ROLE_ARN + value: "{{ .Values.dbconfig.timeseries.awsRoleArn }}" + - name: AWS_WEB_IDENTITY_TOKEN_FILE + value: "/var/run/secrets/eks.amazonaws.com/serviceaccount/token" + - name: MONGO_DB_AWS_SESSION_NAME + value: "{{ .Values.dbconfig.timeseries.awsSessionName }}" + {{- end }} + ports: + - name: remoting + containerPort: {{ .Values.pekko.remoting.port }} + protocol: TCP + - name: management + containerPort: {{ .Values.pekko.mgmthttp.port }} + protocol: TCP + {{- if .Values.global.prometheus.enabled }} + - name: prometheus + protocol: TCP + containerPort: {{ .Values.global.prometheus.port }} + {{- end }} + startupProbe: + httpGet: + port: management + path: /ready + initialDelaySeconds: {{ .Values.timeseries.startupProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.timeseries.startupProbe.periodSeconds }} + timeoutSeconds: {{ .Values.timeseries.startupProbe.timeoutSeconds }} + successThreshold: {{ .Values.timeseries.startupProbe.successThreshold }} + failureThreshold: {{ .Values.timeseries.startupProbe.failureThreshold }} + readinessProbe: + httpGet: + port: management + path: /ready + periodSeconds: {{ .Values.timeseries.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.timeseries.readinessProbe.timeoutSeconds }} + successThreshold: {{ .Values.timeseries.readinessProbe.successThreshold }} + failureThreshold: {{ .Values.timeseries.readinessProbe.failureThreshold }} + livenessProbe: + httpGet: + port: management + path: /alive + initialDelaySeconds: {{ .Values.timeseries.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.timeseries.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.timeseries.livenessProbe.timeoutSeconds }} + successThreshold: {{ .Values.timeseries.livenessProbe.successThreshold }} + failureThreshold: {{ .Values.timeseries.livenessProbe.failureThreshold }} + volumeMounts: + - name: timeseries-extension-conf + mountPath: /opt/ditto/timeseries-extension.conf + subPath: timeseries-extension.conf + {{- if .Values.global.logging.customConfigFile.enabled }} + - name: ditto-custom-log-config + mountPath: /opt/ditto/{{ .Values.global.logging.customConfigFile.fileName }} + subPath: {{ .Values.global.logging.customConfigFile.fileName }} + {{- end }} + {{- if .Values.global.logging.logFiles.enabled }} + - name: ditto-log-files-directory + mountPath: /var/log/ditto + {{- end }} + {{- if .Values.global.extraVolumeMounts }} + {{- toYaml .Values.global.extraVolumeMounts | nindent 12 }} + {{- end }} + {{- if .Values.timeseries.extraVolumeMounts }} + {{- toYaml .Values.timeseries.extraVolumeMounts | nindent 12 }} + {{- end }} + - name: ditto-heap-dumps + mountPath: /opt/ditto/dumps + resources: + requests: + cpu: {{ mulf .Values.timeseries.resources.cpu 1000 }}m + memory: {{ .Values.timeseries.resources.memoryMi }}Mi + ephemeral-storage: {{ .Values.timeseries.resources.ephemeralStorageMi }}Mi + limits: + # ## no cpu limit to avoid CFS scheduler limits + # ref: https://pekko.apache.org/docs/pekko/current/additional/deploying.html#deploying-to-kubernetes + # cpu: "" + memory: {{ .Values.timeseries.resources.memoryMi }}Mi + ephemeral-storage: {{ .Values.timeseries.resources.ephemeralStorageMi }}Mi + {{- if .Values.openshift.enabled }} + {{- with .Values.openshift.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- else }} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + allowPrivilegeEscalation: false + {{- end }} + priorityClassName: "{{ .Values.timeseries.priorityClassName }}" + {{- with .Values.timeseries.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.timeseries.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.timeseries.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: timeseries-extension-conf + configMap: + name: {{ .Release.Name }}-service-config-timeseries-extension-conf + {{- if .Values.global.logging.customConfigFile.enabled }} + - name: ditto-custom-log-config + configMap: + name: {{ .Release.Name }}-logback-config-timeseries-xml + {{- end }} + {{- if .Values.global.logging.logFiles.enabled }} + - name: ditto-log-files-directory + hostPath: + path: /var/log/ditto + type: DirectoryOrCreate + {{- end }} + {{- if .Values.global.extraVolumes }} + {{- toYaml .Values.global.extraVolumes | nindent 8 }} + {{- end }} + {{- if .Values.timeseries.extraVolumes }} + {{- toYaml .Values.timeseries.extraVolumes | nindent 8 }} + {{- end }} + - name: ditto-heap-dumps + emptyDir: {} +{{- end }} diff --git a/deployment/helm/ditto/templates/timeseries-pdb.yaml b/deployment/helm/ditto/templates/timeseries-pdb.yaml new file mode 100644 index 00000000000..fb55b5fb625 --- /dev/null +++ b/deployment/helm/ditto/templates/timeseries-pdb.yaml @@ -0,0 +1,34 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +{{- if and .Values.timeseries.podDisruptionBudget.enabled (gt .Values.timeseries.replicaCount 1.0) -}} +{{- if and .Values.timeseries.podDisruptionBudget.minAvailable .Values.timeseries.podDisruptionBudget.maxUnavailable }} +{{- fail "timeseries.podDisruptionBudget cannot have both minAvailable and maxUnavailable set. Please set only one of them." }} +{{- end }} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "ditto.fullname" . }}-timeseries + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ include "ditto.name" . }}-timeseries +{{ include "ditto.labels" . | indent 4 }} +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ include "ditto.name" . }}-timeseries + app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Values.timeseries.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.timeseries.podDisruptionBudget.minAvailable }} +{{- else }} + maxUnavailable: {{ .Values.timeseries.podDisruptionBudget.maxUnavailable }} +{{- end }} +{{- end }} diff --git a/deployment/helm/ditto/templates/timeseries-podmonitor.yaml b/deployment/helm/ditto/templates/timeseries-podmonitor.yaml new file mode 100644 index 00000000000..529476065cf --- /dev/null +++ b/deployment/helm/ditto/templates/timeseries-podmonitor.yaml @@ -0,0 +1,39 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +{{- if and .Values.timeseries.podMonitor.enabled .Values.global.prometheus.port -}} +{{- if .Capabilities.APIVersions.Has "monitoring.coreos.com/v1" -}} +--- +kind: PodMonitor +apiVersion: monitoring.coreos.com/v1 +metadata: + name: {{ include "ditto.fullname" . }}-timeseries + namespace: {{ .Release.Namespace }} + labels: + app.kubernetes.io/name: {{ include "ditto.name" . }}-timeseries +{{ include "ditto.labels" . | indent 4 }} +spec: + podMetricsEndpoints: + - targetPort: {{ .Values.global.prometheus.port }} + path: "/" + {{- if .Values.timeseries.podMonitor.interval }} + interval: {{ .Values.timeseries.podMonitor.interval }} + {{- end }} + {{- if .Values.timeseries.podMonitor.scrapeTimeout }} + scrapeTimeout: {{ .Values.timeseries.podMonitor.scrapeTimeout }} + {{- end }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "ditto.name" . }}-timeseries + namespaceSelector: + matchNames: + - {{ $.Release.Namespace | quote }} +{{- end }} +{{- end }} diff --git a/deployment/helm/ditto/values.yaml b/deployment/helm/ditto/values.yaml index c6946f4e92e..599b8967708 100644 --- a/deployment/helm/ditto/values.yaml +++ b/deployment/helm/ditto/values.yaml @@ -390,6 +390,36 @@ dbconfig: # awsSessionName is the name of the session when assuming the AWS role. # This can be used to identify the session in logs or IAM policies. awsSessionName: "dittoSession" + # timeseries the MongoDB configuration for Ditto "timeseries" service. + # Authentication note: the timeseries adapter uses the MongoDB Java driver directly + # (not Ditto's shared DefaultMongoDbConfig path). The driver supports MONGODB-AWS auth + # natively since v4.4. When `useAwsIamRole: true`, the mongodb-secret template appends + # `?authMechanism=MONGODB-AWS&authSource=$external` to this URI before base64-encoding, + # and the deployment template injects the standard AWS_ROLE_ARN + + # AWS_WEB_IDENTITY_TOKEN_FILE + AWS_REGION + AWS_ROLE_SESSION_NAME env vars. The driver's + # built-in AWS auth chain assumes the role via STS:AssumeRoleWithWebIdentity using the + # IRSA-mounted token; no additional Ditto code path is needed. For non-IAM auth, encode + # credentials in the URI directly (e.g. `mongodb://user:pass@host:27017/ditto_ts`). + timeseries: + uri: mongodb://#{PLACEHOLDER_MONGODB_HOSTNAME}#:27017/ditto + ssl: false + # useAwsIamRole — when true, the URI is augmented with MONGODB-AWS authMechanism params + # in mongodb-secret.yaml and the deployment template sets the AWS_* env vars the driver + # consumes. Requires `serviceAccount.assumeAwsIamRole: true` so the IRSA token file is + # mounted into the pod, and the assumed role must have IAM auth granted on the Atlas / + # DocumentDB user (via the Atlas database-user IAM mapping). + useAwsIamRole: false + # awsRegion — passed as both MONGO_DB_AWS_REGION (informational) and AWS_REGION (consumed + # by the MongoDB driver's bundled AWS auth chain for STS endpoint selection). Empty string + # lets the AWS SDK resolve from IMDS / pod metadata — usually works in EKS but can be flaky. + awsRegion: "" + # awsRoleArn — the IAM role the pod assumes for MongoDB access. Required when + # useAwsIamRole is true. Set as AWS_ROLE_ARN in the pod env so the driver's STS call + # targets this role. Example: arn:aws:iam:::role/ + awsRoleArn: "" + # awsSessionName — session name attached to the assumed-role identity; appears in + # CloudTrail and IAM-policy `aws:RoleSessionName` conditions. Set as AWS_ROLE_SESSION_NAME. + awsSessionName: "dittoSession" ## If following property is set, an existing secret will be used to retrieve the mongodb connectionUris from. # uriSecret: my-uri-secret @@ -1744,6 +1774,210 @@ thingsSearch: poolSizeMax: 16 +## ---------------------------------------------------------------------------- +## timeseries configuration +## Phase 1 of the timeseries feature (issue #2291) — ingest path from things-service +## via cluster pub/sub, per-thing persistent ingest actor, and a query handler that +## enforces READ_TS on RetrieveTimeseries. +timeseries: + # enabled controls whether timeseries related resources should be created + enabled: false + # replicaCount configuration for timeseries + replicaCount: 1 + # updateStrategy configuration for timeseries + # ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy + updateStrategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + # minReadySeconds configures the minimum number of seconds for which a newly created Pod should be ready without any + # of its containers crashing, for it to be considered available + # ref: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#min-ready-seconds + minReadySeconds: 10 + # additionalLabels configuration for timeseries + additionalLabels: {} + # additionalAnnotations configuration for timeseries + additionalAnnotations: {} + image: + # repository for the timeseries docker image + repository: docker.io/eclipse/ditto-timeseries + # tag for the timeseries docker image - overwrite to specify something else than Chart.AppVersion + # tag: 3.3.0 + # pullPolicy for the timeseries docker image + pullPolicy: IfNotPresent + # activateGcLogging whether to enable GC logging for the timeseries service + activateGcLogging: false + # additionalJvmOptions JVM options to put into JAVA_TOOL_OPTIONS + additionalJvmOptions: "" + # systemProps used to define arbitrary system properties for timeseries service + # ref: https://www.eclipse.dev/ditto/installation-operating.html#configuration + systemProps: + # extraEnv to add arbitrary environment variable to timeseries container + extraEnv: + # - name: LOG_LEVEL_APPLICATION + # value: "DEBUG" + extraVolumes: + extraVolumeMounts: + # resources configures the resources available/to use for the timeseries service + resources: + # cpu defines the "required" CPU of a node so that the service is placed there + cpu: 0.5 + # memoryMi defines the memory in mebibyte (MiB) used as "required" and "limit" in k8s + memoryMi: 1024 + # ephemeralStorageMi defines the storage in mebibyte (MiB) used as "required" and "limit" in k8s + ephemeralStorageMi: 2048 + # jvm contains JVM specific scaling/tuning configuration of e.g. processors and garbage collector settings + jvm: + # activeProcessorCount defines how many processors the JVM should be configured to use + activeProcessorCount: 2 + # parallelGcThreads defines how many threads the G1 garbage collector should use for parallel GC operations + # if configured < 1, the JVM chooses according to its own logic + parallelGcThreads: 0 + # concGcThreads defines how many threads the G1 garbage collector should use for concurrent GC operations + # if configured < 1, the JVM chooses according to its own logic + concGcThreads: 0 + # heapRamPercentage defines how much memory of the configured "resources.memoryMi" can be used by the JVM heap space + heapRamPercentage: 60 + # maxGcPauseMillis configures the used G1 GC "target for the maximum GC pause time" + maxGcPauseMillis: 200 + # g1ReservePercent configures the used G1 GC "amount of heap to keep free after a mixed GC" + g1ReservePercent: 10 + # initiatingHeapOccupancyPercent configures the G1 GC "heap occupancy threshold" to start a concurrent GC cycle + initiatingHeapOccupancyPercent: 45 + # g1NewSizePercent sets the percentage of the heap size to use as default minimum young (new) generation + g1NewSizePercent: 5 + # g1MaxNewSizePercent sets the maximum percentage of the heap that can be allocated to the young (new) generation + g1MaxNewSizePercent: 60 + # startupProbe configuration for timeseries + # ref: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#configure-probes + startupProbe: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 30 + # readinessProbe configuration for timeseries + readinessProbe: + periodSeconds: 5 + timeoutSeconds: 3 + successThreshold: 1 + failureThreshold: 6 + # livenessProbe configuration for timeseries + livenessProbe: + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 6 + # podDisruptionBudget configuration for timeseries + podDisruptionBudget: + enabled: true + # NOTE: You can only set either minAvailable OR maxUnavailable, not both + minAvailable: 1 + maxUnavailable: + # priorityClassName configuration for timeseries + priorityClassName: "" + # nodeSelector configuration for timeseries + nodeSelector: {} + # tolerations configuration for timeseries + tolerations: [] + # affinity configuration for timeseries + affinity: {} + # Pod topology spread constraints for timeseries + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + # terminationGracePeriodSeconds how much time to give the application to gracefully leave + terminationGracePeriodSeconds: 300 + # podMonitor configuration for timeseries + podMonitor: + enabled: false + # interval: 30s + # scrapeTimeout: 15s + # config holds timeseries-specific configuration + config: + # mongodb holds the MongoDB connection-tuning knobs read by DefaultMongoDbConfig (same as + # sibling services). The URI itself (with IAM auth metadata) comes from the + # `timeseries-uri` secret key → MONGO_DB_URI env var. The database segment of that URI sets + # the timeseries database (default `ditto_ts`). + mongodb: + # minPoolSize configures the minimum number of connections in the connection pool + minPoolSize: 10 + # maxPoolSize configures the maximum number of connections in the connection pool + maxPoolSize: 200 + # maxPoolIdleTime configures the maximum amount of time a pooled connection is allowed to idle before closing + maxPoolIdleTime: 10m + # simplifiedReadPermission selects the read-permission mode (two-mode contract, issue #2291 + # Section 6.1): + # - false (default, strict): read-path enforcer requires an explicit READ_TS grant on every + # requested path. Historical access can be granted separately from live access. + # - true (simplified): the enforcer instead checks the standard READ permission. Useful for + # deployments preferring "if you can read the current value, you can read its history", + # without rewriting existing policies to add READ_TS grants. + # A boolean rather than a permission-name string — the contract is binary; any other value + # would be a deployment footgun (typo'd permissions are silently treated as "no permission" + # by the policy enforcer). + simplifiedReadPermission: false + # adapter selects the timeseries storage backend + adapter: + # type selects the timeseries database adapter implementation. Currently supports "mongodb". + type: "mongodb" + # mongodb carries adapter-specific tuning for the MongoDB Time Series implementation. + # The connection (URI, IAM, pool, SSL) comes from dbconfig.timeseries.* + timeseries.config.mongodb + # above via DefaultMongoDbConfig — same code path as the sibling services use. + mongodb: + # collectionPrefix is prepended to each per-namespace time-series collection name. + # Leave empty to inherit the timeseries.conf default ("ts_"). + collectionPrefix: "" + # granularity is the MongoDB Time Series bucketing granularity for NEW collections. + # One of "seconds", "minutes", "hours" — cannot be changed once a collection exists. + # Leave empty to inherit the timeseries.conf default ("seconds"). + granularity: "" + # retention is the default expiry for time-series collections (HOCON duration, e.g. "30d", + # "8760h"); "unlimited" disables expiration. Empty inherits the timeseries.conf default + # ("90d") — timeseries is append-only, so leaving it unbounded grows storage forever. The + # adapter reconciles EXISTING collections to this value on startup via collMod, so changes + # take effect without a manual migration. + retention: "" + # retentionOverrides sets per-namespace retention, keyed by Thing namespace. A listed + # namespace uses its own duration instead of `retention` above; others fall back to it. + # Reconciled onto existing collections on startup, same as the default. Example: + # retentionOverrides: + # com.acme.hifreq: "7d" + # org.eclipse.ditto: "365d" + retentionOverrides: {} + # maxQueryResultSize is the safety ceiling on data points read into memory per path on the + # raw / window-function read paths (guards against OOM on over-broad ranges). Empty inherits + # the timeseries.conf default (1000000). + maxQueryResultSize: "" + # queryTimeout is the server-side time budget per read, applied as MongoDB maxTime (HOCON + # duration, e.g. "60s"). Empty inherits the timeseries.conf default ("60s"). + queryTimeout: "" + # capabilities advertise to the query planner what this MongoDB supports, so it routes each + # query to the native (fast) path or the portable scan()+compute-kernel path. Defaults suit + # a modern MongoDB (5.0+); override for an older / feature-limited MongoDB. Leave a value + # unset to inherit the timeseries.conf default. + capabilities: + # nativeQuery: whether MongoDB's query() is a complete executor (planner delegates whole + # queries to it, preserving unit metadata). KEEP true for MongoDB. Must be an explicit + # boolean (true/false) to override; leave empty ("") to inherit the default (true). + nativeQuery: "" + # pushableAggregations: aggregations computed IN the DB engine. A NON-EMPTY list overrides + # the timeseries.conf default [avg,min,max,sum,count,first,last,stddev,derivative,integral] + # (all exact); an empty list inherits that default (an empty list cannot be rendered, so it + # is treated as "inherit"). percentile is OFF by default (native $percentile is approximate) + # — opt in on MongoDB 7.0+ by listing the full set plus "percentile". On older MongoDB, list + # only the group accumulators to disable derivative/integral push-down. + pushableAggregations: [] + # nativeFillStrategies: gap-fill strategies applied natively via $densify/$fill (5.3+). A + # NON-EMPTY list overrides the timeseries.conf default ["linear","previous"] (exact match to + # the kernel; zero/null and timezone-aligned queries always stay in the kernel); an empty + # list inherits that default. (An empty list cannot force "no native fill" via Helm — set + # native-fill-strategies = [] directly in timeseries-extension.conf if you need that.) + nativeFillStrategies: [] + ## ---------------------------------------------------------------------------- ## connectivity configuration ## ref: https://www.eclipse.dev/ditto/architecture-services-connectivity.html diff --git a/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml b/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml index 72572a52c42..dc497cde24b 100644 --- a/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml +++ b/documentation/src/main/resources/_data/sidebars/ditto_sidebar.yml @@ -99,6 +99,9 @@ entries: - title: Search url: /basic-search.html output: web + - title: Timeseries + url: /basic-timeseries.html + output: web - title: RQL Expressions url: /basic-rql.html output: web diff --git a/documentation/src/main/resources/jsonschema/policy.json b/documentation/src/main/resources/jsonschema/policy.json index 0ffa67c4c86..25c454a3a1f 100644 --- a/documentation/src/main/resources/jsonschema/policy.json +++ b/documentation/src/main/resources/jsonschema/policy.json @@ -133,10 +133,11 @@ "type": "array", "items": { "type": "string", - "description": "All subjects specified in this policy entry are granted read/write permission on the resources specified in the path, and all subsequent paths, except they are revoked at a subsequent policy label.", + "description": "All subjects specified in this policy entry are granted the listed permissions on the resources specified in the path, and all subsequent paths, except they are revoked at a subsequent policy label. `READ_TS` (since 4.0.0) only applies to `thing:/...` resources and gates retrieval of historical timeseries data via the Timeseries API; when the Timeseries service is configured with `required-permission=READ`, a `READ` grant alone is also sufficient to read timeseries data.", "enum": [ "READ", - "WRITE" + "WRITE", + "READ_TS" ] } }, @@ -144,10 +145,11 @@ "type": "array", "items": { "type": "string", - "description": "All subjects specified in this policy entry are prohibited to read/write on the resources specified in the path, and all subsequent paths, except they are granted again such permission at a subsequent policy label.", + "description": "All subjects specified in this policy entry are prohibited from exercising the listed permissions on the resources specified in the path, and all subsequent paths, except they are granted again such permission at a subsequent policy label. `READ_TS` (since 4.0.0) only applies to `thing:/...` resources and gates retrieval of historical timeseries data via the Timeseries API; an explicit `READ_TS` revoke takes effect only when the Timeseries service is configured with `required-permission=READ_TS` (its default).", "enum": [ "READ", - "WRITE" + "WRITE", + "READ_TS" ] } } diff --git a/documentation/src/main/resources/llms.txt b/documentation/src/main/resources/llms.txt index 2691e87c67f..26ed9932fe2 100644 --- a/documentation/src/main/resources/llms.txt +++ b/documentation/src/main/resources/llms.txt @@ -35,6 +35,7 @@ Eclipse Ditto provides five microservices (Things, Policies, Gateway, Connectivi - [Conditional Requests](https://www.eclipse.dev/ditto/basic-conditional-requests.html.md): Conditional operations based on RQL conditions or ETags - [Search](https://www.eclipse.dev/ditto/basic-search.html.md): Searching across all digital twins using RQL - [History](https://www.eclipse.dev/ditto/basic-history.html.md): Accessing historical state of Things via event journal +- [Timeseries](https://www.eclipse.dev/ditto/basic-timeseries.html.md): Recording and querying the history of feature properties, including cross-thing aggregation - [Acknowledgements / QoS](https://www.eclipse.dev/ditto/basic-acknowledgements.html.md): At-least-once delivery and acknowledgement handling ## Architecture diff --git a/documentation/src/main/resources/openapi/ditto-api-2.yml b/documentation/src/main/resources/openapi/ditto-api-2.yml index 00c078bc97f..f6f2fd7b36b 100644 --- a/documentation/src/main/resources/openapi/ditto-api-2.yml +++ b/documentation/src/main/resources/openapi/ditto-api-2.yml @@ -32,6 +32,8 @@ tags: description: Manage connections - name: WoT description: WoT (Web of Things) Discovery endpoints + - name: Timeseries + description: 'Read historical timeseries data ingested from properties annotated with `ditto:timeseries`' - name: Devops description: Devops APIs to manage log levels and configuration in runtime and send piggyback command security: @@ -4670,6 +4672,7 @@ paths: parameters: - $ref: '#/components/parameters/PolicyIdPathParam' - $ref: '#/components/parameters/PolicyFieldsQueryParam' + - $ref: '#/components/parameters/PolicyViewParam' - $ref: '#/components/parameters/IfMatchHeaderParam' - $ref: '#/components/parameters/IfNoneMatchHeaderParam' - $ref: '#/components/parameters/TimeoutParam' @@ -9308,6 +9311,633 @@ paths: description: Deleted successfully '404': description: Not found + '/api/2/timeseries/things/{thingId}/features/{featureId}/properties/{propertyPath}': + get: + summary: Retrieve historical timeseries data for one feature property + description: |- + Returns the chronological sequence of values that were observed for one feature + property within the specified time range. + + The property must be annotated with `ditto:timeseries.ingest = "ALL"` in the + WoT ThingModel referenced by the thing's `definition`; properties without the + annotation (or with `ingest = "NONE"`) are not ingested and the response will + have `count = 0`. + + Authorization is enforced via the Timeseries service's configured permission — + `READ_TS` by default. A subject without the configured permission on the + requested resource path receives a `404` (per Ditto's "404-not-403" discipline: + a `403` would leak existence). + + When `step` and `agg` are supplied the points are downsampled into `step`-sized + buckets and aggregated per bucket; otherwise the raw points are returned. Cross-thing + aggregation queries use a separate endpoint and are not exposed here. + tags: + - Timeseries + parameters: + - $ref: '#/components/parameters/ThingIdPathParam' + - $ref: '#/components/parameters/FeatureIdPathPathParam' + - $ref: '#/components/parameters/PropertyPathPathParam' + - name: from + in: query + description: |- + Inclusive lower bound of the queried time range. Either an ISO-8601 instant or a + relative expression (`now`, `now-24h`, `now-30m`, `now-7d`). + required: true + schema: + type: string + example: now-24h + - name: to + in: query + description: |- + Exclusive upper bound of the queried time range. ISO-8601 instant or relative + expression (e.g. `now`). + required: true + schema: + type: string + example: now + - name: step + in: query + description: |- + Downsampling interval. Short form (`30s`, `5m`, `1h`, `1d`) or ISO-8601 duration + (`PT1H`). Required for the per-bucket aggregations (`avg`, `min`, `max`, `sum`, + `count`, `first`, `last`, `stddev`); optional for the window functions. + required: false + schema: + type: string + example: 1h + - name: agg + in: query + description: |- + Aggregation function applied per `step` bucket (or, for the window functions, + over the fetched points). + required: false + schema: + type: string + enum: + - avg + - min + - max + - sum + - count + - first + - last + - stddev + - derivative + - rate + - integral + - percentile + example: avg + - name: percentile + in: query + description: 'Percentile in `[0, 100]`. Required when `agg=percentile`.' + required: false + schema: + type: number + minimum: 0 + maximum: 100 + example: 95 + - name: fill + in: query + description: |- + Gap-fill strategy for empty buckets when downsampling. `null` emits a gap marker, + `previous` carries the last value forward, `zero` fills with 0, and `linear` linearly + interpolates between the surrounding non-empty buckets (numeric series only; non-numeric + neighbours fall back to a `null` gap). + required: false + schema: + type: string + enum: + - null + - previous + - zero + - linear + example: previous + - name: tz + in: query + description: IANA time-zone ID used to align `step` bucket boundaries (default UTC). + required: false + schema: + type: string + example: Europe/Berlin + - name: limit + in: query + description: |- + Page size for raw queries — the maximum number of data points to return. When omitted, the + server-side ceiling is used. If more points match than are returned, the response's + `meta.nextCursor` carries an opaque cursor for the next page (see `cursor`). + required: false + schema: + type: integer + format: int32 + minimum: 1 + example: 1000 + - name: cursor + in: query + description: |- + Opaque pagination cursor for raw queries. Pass back the `meta.nextCursor` value from a + previous response (unchanged) to fetch the next page; omit it for the first page. Keep + `from`/`to` identical across pages. Pagination is keyset-based over the `(timestamp, revision)` + order, so it is stable while new points are appended. Cursors are only valid for a + single-property raw read — combining `cursor` with `step`, `agg` or `fill` is rejected with + `400`. + required: false + schema: + type: string + example: eyJ0IjoiMjAyNi0wMS0xNFQxMDowMTowMFoiLCJyIjo0Mn0 + - name: order + in: query + description: |- + Chronological order of a raw read: `asc` (default, oldest first) or `desc` (newest first, + convenient for "scroll back into the past" UIs paged with `cursor`). `desc` is only supported + for raw reads — combining it with `step`, `agg` or `fill` is rejected with `400`. Keep `order` + consistent across pages of a cursor walk. + required: false + schema: + type: string + enum: + - asc + - desc + default: asc + example: desc + - name: tagFilter + in: query + description: |- + Restrict the result to points whose resolved tags match every given `key:value` pair + (comma-separated, logical AND) — e.g. `building:A,floor:2`. Tags are declared on the WoT + property's `ditto:timeseries` annotation and resolved at ingestion time. Applies to raw and + aggregated reads. + required: false + schema: + type: string + example: 'building:A,floor:2' + - name: timeFormat + in: query + description: |- + How timestamps are rendered in the response `data` array: `iso` (default, ISO-8601 string) + or `ms` (epoch milliseconds, compact and convenient for charting libraries). + required: false + schema: + type: string + enum: + - iso + - ms + default: iso + example: ms + responses: + '200': + description: Timeseries data points for the requested path. + content: + application/json: + schema: + $ref: '#/components/schemas/TimeseriesQueryResult' + '400': + description: |- + The request could not be completed (`timeseries:query.invalid`). Possible reasons: + * the `thingId` does not conform to the namespaced entity ID notation. + * `from`/`to` is not a valid ISO-8601 instant or relative expression. + * `step` is not a valid duration, or `agg`/`fill` is not a recognised value. + * a per-bucket aggregation was requested without a `step`. + * `agg=percentile` without a `percentile`, or `percentile` outside `[0, 100]`. + * `limit` is not a valid integer. + * `cursor` is malformed, or combined with `step`, `agg` or `fill`. + * `order` is not `asc`/`desc`, or `order=desc` combined with `step`, `agg` or `fill`. + * `tagFilter` is not a comma-separated list of `key:value` pairs. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '404': + description: |- + The request could not be completed. Either the thing with the given ID was + not found, or the requesting subject lacks the configured + `READ_TS`/`READ` permission on the requested resource path. The two cases + return the same error to avoid leaking the existence of resources the + caller cannot access. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + 5XX: + description: |- + The Timeseries service is temporarily unavailable or the underlying + timeseries database is degraded. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '/api/2/timeseries/things/{thingId}': + get: + summary: Retrieve historical timeseries data for multiple paths of a Thing + description: |- + Returns the chronological sequence of values for several feature properties and/or attributes + of one Thing in a single request. The paths are given explicitly via the required `paths` + query parameter (comma-separated JSON pointers). + + The same downsampling / aggregation / fill / timezone / percentile / timeFormat parameters as the + single-property endpoint apply, and they apply uniformly to every requested path. The response is + an array with one entry (`path`, `result`, `data`) per requested path. + + Only paths whose property is annotated with `ditto:timeseries.ingest = "ALL"` produce data; the + others come back with `count = 0`. Authorization is enforced per path (`READ_TS` by default); a + path the caller cannot read is reported as having no data rather than leaking its existence. + tags: + - Timeseries + parameters: + - $ref: '#/components/parameters/ThingIdPathParam' + - name: paths + in: query + description: |- + Comma-separated list of JSON pointers (relative to the Thing root) to retrieve, e.g. + `/features/environment/properties/temperature,/attributes/batteryExchangeDate`. + + At most 100 paths per request. Each path is scanned independently and bounded by the + adapter's per-path ceiling, so the path count multiplies the memory one request can use. + required: true + schema: + type: string + example: '/features/environment/properties/temperature,/features/environment/properties/humidity' + - name: from + in: query + description: 'Inclusive lower bound — ISO-8601 instant or relative expression (`now`, `now-24h`).' + required: true + schema: + type: string + example: now-24h + - name: to + in: query + description: Exclusive upper bound — ISO-8601 instant or relative expression (e.g. `now`). + required: true + schema: + type: string + example: now + - name: step + in: query + description: 'Downsampling interval (`30s`, `5m`, `1h`, `1d` or ISO-8601 `PT1H`).' + required: false + schema: + type: string + example: 1h + - name: agg + in: query + description: Aggregation function applied per `step` bucket (or over fetched points for window functions). + required: false + schema: + type: string + enum: + - avg + - min + - max + - sum + - count + - first + - last + - stddev + - derivative + - rate + - integral + - percentile + example: avg + - name: percentile + in: query + description: 'Percentile in `[0, 100]`. Required when `agg=percentile`.' + required: false + schema: + type: number + minimum: 0 + maximum: 100 + example: 95 + - name: fill + in: query + description: |- + Gap-fill strategy for empty buckets when downsampling. `null` emits a gap marker, + `previous` carries the last value forward, `zero` fills with 0, and `linear` linearly + interpolates between the surrounding non-empty buckets (numeric series only). + required: false + schema: + type: string + enum: + - null + - previous + - zero + - linear + example: previous + - name: tz + in: query + description: IANA time-zone ID used to align `step` bucket boundaries (default UTC). + required: false + schema: + type: string + example: Europe/Berlin + - name: limit + in: query + description: Maximum number of data points per path for raw queries (subject to a server-side ceiling). + required: false + schema: + type: integer + format: int32 + minimum: 1 + example: 1000 + - name: tagFilter + in: query + description: |- + Restrict the result to points whose resolved tags match every given `key:value` pair + (comma-separated, logical AND) — e.g. `building:A,floor:2`. Tags are declared on the WoT + property's `ditto:timeseries` annotation and resolved at ingestion time. Applies to raw and + aggregated reads. + required: false + schema: + type: string + example: 'building:A,floor:2' + - name: timeFormat + in: query + description: 'Timestamp rendering in the response: `iso` (default) or `ms` (epoch milliseconds).' + required: false + schema: + type: string + enum: + - iso + - ms + default: iso + example: ms + responses: + '200': + description: One timeseries result entry per requested path. + content: + application/json: + schema: + $ref: '#/components/schemas/TimeseriesQueryResult' + '400': + description: |- + The request could not be completed (`timeseries:query.invalid`). Possible reasons: + * `paths` is missing, contains no valid JSON pointer, or lists more than 100 paths. + * `from`/`to` is not a valid ISO-8601 instant or relative expression. + * `step` is not a valid duration, or `agg`/`fill`/`timeFormat` is not a recognised value. + * a per-bucket aggregation was requested without a `step`. + * `agg=percentile` without a `percentile`, or `percentile` outside `[0, 100]`. + * `tagFilter` is not a comma-separated list of `key:value` pairs. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '404': + description: |- + The Thing with the given ID was not found, or the requesting subject lacks the configured + `READ_TS` permission. The two cases return the same error to avoid leaking existence. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + 5XX: + description: The Timeseries service is temporarily unavailable or the timeseries database is degraded. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + /api/2/timeseries/things: + get: + summary: Aggregate timeseries data across many things of one namespace + description: |- + Aggregates historical values across **all things of one namespace** and returns one series per + group — for example "average `flowTemperature` per building, hourly, over the last day". + + ### Aggregation is mandatory + Unlike the single-thing endpoints, `step` and `agg` are **required**. A raw cross-thing read + would stream every point of every matching thing, so the result size must be a function of the + time range rather than of the tenant's thing count. Only the per-bucket aggregations are + available (`avg`, `min`, `max`, `sum`, `count`, `first`, `last`, `stddev`); the window functions + (`derivative`, `rate`, `integral`, `percentile`) remain single-thing only. + + ### `filter` uses RQL, and selects points rather than things + The filter is written in **RQL** — the same query language as `/api/2/search/things` — so there is + one filter syntax to learn across Ditto. Field references are the tag keys declared in the WoT + model, which are full thing paths, e.g. `eq(attributes/building,'A')`. + + Tags are frozen on each data point at ingest, so the filter selects by the state of the world + **when the measurement was recorded**. For a thing moved from building A to building B, + `filter=eq(attributes/building,'A')` returns only the points recorded while it was in A — not its + whole history. This is deliberate and differs from selecting things by their current attributes. + + ### Authorization + Access is evaluated against **current** policy state on every request; nothing about + authorization is stored with the data points. A subject granted access today therefore sees + history ingested before the grant, and a revoked subject immediately loses access to history it + could previously read. + + Three checks apply, and each covers a different disclosure: + + 0. **`READ` on the fields you filter or group by.** Filtering by a tag reveals which points carry + which value, and grouping by one reveals its distinct values — so slicing a series by + `attributes/building` requires `READ` on `attributes/building`, independently of `READ_TS` on + the data. Because tag keys are thing paths, they are ordinary policy resources. + + Then, on the data itself: + + 1. **Entry condition** — a **namespace-wide** `READ_TS` grant, normally expressed through a + namespace root policy. Without it the request is rejected with `403`. Per-thing grants + scattered across individual policies are not sufficient to *use* this endpoint; query those + things individually instead. + 2. **Per-thing, per-path narrowing** — a namespace-wide grant does **not** imply every thing + grants. Namespace-root entries are merged *additively*, so a thing's own policy may still revoke + `READ_TS`, and a revoke wins. Every thing that would contribute data is therefore verified + against live policy first. + + This is **path-granular**, because `READ_TS` is grantable per property: a thing denied one + requested path still contributes to the paths it is entitled to, rather than being dropped from + the whole query. (This mirrors how thing-search authorizes an RQL predicate per field.) + + When anything is withheld the response says so: `authorization.withheldByPath` names the affected + paths with a count, and `authorization.partial` is `true`. Check that flag before treating an + aggregate as representative — the values are otherwise indistinguishable from a complete result. + + Only entries whose `importable` type is `implicit` count toward the namespace-wide grant, since + those are the only ones the merge propagates to the namespace's things. + + ### Scope + Exactly one namespace per request: storage is partitioned one collection per namespace, so + spanning several would be a multi-collection merge. + tags: + - Timeseries + parameters: + - name: namespaces + in: query + description: The namespace to aggregate over. Exactly one value — a comma-separated list is rejected. + required: true + schema: + type: string + example: io.beyonnex.smartheating + - name: paths + in: query + description: |- + Comma-separated list of JSON pointers (relative to the thing root) to aggregate, e.g. + `/features/circuit/properties/flowTemperature`. Results are always reported per path. + + At most 100 paths per request — the same bound the single-thing endpoints apply. It matters + more here: each path is a separate grouped scan over every thing in the namespace, so the + path count multiplies a fan-out that is already namespace-wide. + required: true + schema: + type: string + example: /features/circuit/properties/flowTemperature + - name: from + in: query + description: 'Inclusive lower bound — ISO-8601 instant or relative expression (`now`, `now-24h`).' + required: true + schema: + type: string + example: now-24h + - name: to + in: query + description: Exclusive upper bound — ISO-8601 instant or relative expression (e.g. `now`). + required: true + schema: + type: string + example: now + - name: step + in: query + description: 'Bucket width (`30s`, `5m`, `1h`, `1d` or ISO-8601 `PT1H`). Required — see above.' + required: true + schema: + type: string + example: 1h + - name: agg + in: query + description: The per-bucket aggregation. Required — see above. + required: true + schema: + type: string + enum: + - avg + - min + - max + - sum + - count + - first + - last + - stddev + example: avg + - name: groupBy + in: query + description: |- + Comma-separated grouping dimensions. Each is one of: + * `thingId` — one series per thing; + * `path` — one series per requested path (implicit: results are always per path, so + declaring this only adds the path to the emitted `group` object); + * a **tag path** such as `attributes/floor` — one series per distinct value of that + ingest-time tag. (`tag:` is still accepted.) + + A tag dimension requires `READ` on the referenced field, as with `filter`. + + Omitted means one series per path, aggregated across every matching thing. + required: false + schema: + type: string + example: attributes/building + - name: filter + in: query + description: |- + An RQL predicate over ingest-time tags, using the same syntax as `/api/2/search/things`. + Supported operators: `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in`, `like`, `ilike`, `exists`, + `and`, `or`, `not`. Field references are tag keys as declared in the WoT model (full thing + paths). Matches points, not things — see above. + + Every field referenced here additionally requires `READ` permission. + required: false + schema: + type: string + example: 'and(eq(attributes/building,''A''),ge(attributes/floor,2))' + - name: fill + in: query + description: |- + How buckets with no data are filled. Omitted means such buckets are simply absent. + Synthesised buckets are marked `_gap: true`. + required: false + schema: + type: string + enum: + - previous + - zero + - linear + - name: tz + in: query + description: |- + IANA timezone for calendar-aligned bucket boundaries, e.g. `Europe/Berlin`. Affects + day-and-coarser steps across DST transitions. Defaults to UTC. + required: false + schema: + type: string + example: Europe/Berlin + - name: maxGroups + in: query + description: |- + Cap on the number of distinct groups returned (default 1000, ceiling 10000). Exceeding the + cap **fails the request** rather than truncating the result, because a silently truncated + set would look like a complete answer and misreport anything computed on top of it. + + A separate, operator-configured ceiling bounds how many *things* one request will authorize + (`ditto.timeseries.max-verified-things`, default 1000). Exceeding it also fails the request, + for the same reason: authorizing a truncated set of contributors would silently admit + whatever fell off the end. + required: false + schema: + type: integer + minimum: 1 + maximum: 10000 + - name: timeFormat + in: query + description: 'Rendering of the `t` fields: `iso` (default) or `ms` for epoch milliseconds.' + required: false + schema: + type: string + enum: + - iso + - ms + responses: + '200': + description: The aggregation succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/AggregatedTimeseriesResult' + '400': + description: |- + The request is malformed: a missing or unparseable required parameter, more than 100 + `paths`, a window-function aggregation, a non-positive `step`, a `maxGroups` outside its + bounds, more than one namespace, an unknown `groupBy` dimension, a query matching more + groups than `maxGroups`, or a query spanning more things than the service will authorize + per request. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' + '403': + description: |- + The caller lacks `READ` on a field referenced by `filter`/`groupBy`, or has no namespace-wide + `READ_TS` grant covering every requested path — including + the case where they hold only scattered per-thing grants, which are not sufficient to use + this endpoint, and the case where the grant is carried by a policy entry that is not + `importable: implicit`. + content: + application/json: + schema: + $ref: '#/components/schemas/AdvancedError' components: requestBodies: Attributes: @@ -10003,6 +10633,29 @@ components: required: true schema: type: string + PolicyViewParam: + name: policy-view + in: query + description: |- + Selects the policy representation. Accepted values: `original` (default — policy as stored) + and `resolved` (merged view including imports, references and namespace-root policies). + Imported entries appear under labels of the form `imported--` + (treat the format as opaque; policy IDs may contain `-`). The `imports` block is preserved + unchanged. + + Note on caching: the response ETag reflects only the importing policy's revision, so a `304` + may be returned even when imported or namespace-root policies have changed. Treat the + resolved view as read-through. + + The resolved view exposes all `importable=implicit` entries from imported policies declared + by the importing policy, matching how the enforcer evaluates the policy at runtime. + required: false + schema: + type: string + enum: + - original + - resolved + default: original PropertiesFieldsQueryParam: name: fields in: query @@ -10420,6 +11073,296 @@ components: - status - error - message + TimeseriesQueryResult: + type: array + description: |- + Timeseries query result: one entry per requested path, in request order. The + single-property endpoint requests one path, so its array holds a single entry; + the multi-path Thing endpoint (`?paths=...`) returns one entry per requested path. + items: + type: object + required: + - thingId + - path + - query + - result + - data + properties: + thingId: + type: string + description: The thing the data points belong to. + path: + type: string + description: |- + The Ditto Protocol path inside the thing whose timeseries was queried, + e.g. `/features/environment/properties/temperature`. + query: + type: object + description: Echo of the effective query that produced this result. Optional parameters are present only when supplied on the request. + properties: + thingId: + type: string + paths: + type: array + items: + type: string + from: + type: string + format: date-time + to: + type: string + format: date-time + step: + type: string + description: 'Downsampling interval as an ISO-8601 duration, e.g. `PT1H`.' + aggregation: + type: string + enum: + - avg + - min + - max + - sum + - count + - first + - last + - stddev + - derivative + - rate + - integral + - percentile + fillStrategy: + type: string + enum: + - null + - previous + - zero + - linear + percentile: + type: number + minimum: 0 + maximum: 100 + timezone: + type: string + description: IANA time-zone ID used to align bucket boundaries. + limit: + type: integer + format: int32 + cursor: + type: string + description: 'Opaque pagination cursor supplied on the request, if any.' + order: + type: string + enum: + - asc + - desc + description: 'Sort order supplied on the request, if any.' + tagFilters: + type: object + additionalProperties: + type: string + description: 'Tag filters supplied on the request, if any.' + result: + type: object + description: Per-path metadata. + required: + - count + - dataType + properties: + count: + type: integer + format: int32 + description: Number of data points returned. + hasMore: + type: boolean + description: |- + Whether more data matched the query than this page returned. Present only for raw + (paginated) reads: `true` means another page follows (see `nextCursor`), `false` means + this is the last page. Absent for aggregated reads. + nextCursor: + type: string + description: |- + Opaque cursor for the next page of a raw read. Present only when `hasMore` is `true`; + pass it back as the request's `cursor` to continue. Absent on the last page. + dataType: + type: string + enum: + - number + - string + - boolean + - 'null' + description: |- + Inferred scalar type of the returned values. `null` when the result + set is empty. + unit: + type: string + description: |- + Unit declared on the WoT property the timeseries was ingested from + (semantic prefix stripped). Omitted when the property has no `unit`. + tags: + type: object + additionalProperties: + type: string + description: |- + The resolved tags of the series, as stored with the points (representative: the first + point's). Omitted when the series has no tags or for aggregated reads (which group away + per-point metadata). + data: + type: array + description: |- + Returned data points in the requested chronological order — ascending timestamp by default, + or descending when `order=desc`. + items: + type: object + required: + - t + - v + properties: + t: + type: string + format: date-time + description: ISO-8601 timestamp at which the value was observed. + v: + description: The observed scalar value. Type matches the per-path `dataType`. + AggregatedTimeseriesResult: + type: object + required: + - results + - authorization + description: |- + Cross-Thing aggregation result. + + Unlike the single-thing endpoints (whose body is a bare array), this is an object so it can carry the + `authorization` summary alongside the series. An aggregate computed over the subset of things you may + read is indistinguishable from one computed over everything if you only look at the numbers — so the + exclusion is stated rather than left implicit. + properties: + authorization: + type: object + required: + - contributingThings + - excludedThings + - withheldByPath + - partial + description: How much of the matching data is actually reflected in `results`. + properties: + contributingThings: + type: integer + description: |- + How many distinct things shaped these aggregates, i.e. were permitted on at least one + requested path. + excludedThings: + type: integer + description: |- + How many things had matching data but were withheld from **every** requested path. + + This is not an error. A namespace-wide `READ_TS` grant is the entry condition for this + endpoint, but namespace-root policy entries are merged *additively* — an individual thing's + own policy may still revoke `READ_TS`, and a revoke wins. + withheldByPath: + type: object + additionalProperties: + type: integer + description: |- + Per requested path, how many things had matching data for that path but were withheld from + it. Only non-zero entries appear. + + `READ_TS` is grantable **per property**, so access is path-granular: a thing denied one + requested path still contributes to the paths it is entitled to. Such a thing appears here + under the denied path while remaining counted in `contributingThings` — it is *not* in + `excludedThings`, which counts only things withheld from every path. + + A thing is counted against a path only if it actually had data for that path, so this never + inflates with things that would have contributed nothing anyway. + example: + /features/circuit/properties/returnTemperature: 1 + partial: + type: boolean + description: |- + `true` when anything at all was withheld, i.e. `withheldByPath` is non-empty. Every affected + series in `results` is then computed over a subset of the matching data — treat those numbers + as a lower-bound sample, not the population. Check this before presenting an aggregate as + representative. + results: + type: array + description: |- + One entry per `(group, path)` combination. + + There is no `thingId` field — a group generally spans many things. When the query groups by + `thingId`, the thing ID appears as an ordinary entry inside `group` instead, so every grouping + choice produces the same response shape. + items: + type: object + required: + - path + - result + - data + properties: + group: + type: object + additionalProperties: + type: string + description: |- + The group identity: one entry per `groupBy` dimension, keyed by the dimension's name + (`thingId`, `path`, or the bare tag key for a `tag:` dimension). Absent when the + query declared no `groupBy`, in which case the entry aggregates every matching thing. + + A point that carries no value for a grouped tag appears under an empty-string value + rather than being dropped, so the set of keys is the same across all entries. + example: + building: A + path: + type: string + description: |- + The Ditto Protocol path this series belongs to, e.g. + `/features/circuit/properties/flowTemperature`. Results are always reported per path. + result: + type: object + required: + - count + - dataType + properties: + count: + type: integer + description: Number of data points in `data`. + unit: + type: string + description: 'The unit declared on the WoT property, when the points of this series carry one.' + example: cel + dataType: + type: string + description: JSON type of the aggregated values. + example: number + tags: + type: object + additionalProperties: + type: string + description: |- + Always empty for aggregated results: a group spans many points whose tags may differ, + so there is no single tag set to report. Use `group` instead. + data: + type: array + description: |- + The aggregated values in chronological order, one per `step` bucket that has data (plus + the interpolated buckets when `fill` is set). + items: + type: object + required: + - t + - v + properties: + t: + type: string + format: date-time + description: |- + Start of the bucket, ISO-8601. Rendered as epoch milliseconds instead when + `timeFormat=ms`. + v: + description: The aggregated value for this bucket. Type matches `dataType`. + _gap: + type: boolean + description: |- + Present and `true` when this bucket had no data of its own and was synthesised by the + `fill` strategy. Absent on buckets backed by real measurements. Attributes: type: object description: An arbitrary JSON object describing the attributes of a thing. @@ -10831,10 +11774,18 @@ components: $ref: '#/components/schemas/Permission' Permission: type: string - description: A Permission allows a certain action on an entity + description: | + A Permission allows a certain action on an entity. + * `READ` and `WRITE` apply to all resource types (`thing:/`, `policy:/`, `message:/`). + * `READ_TS` (since 4.0.0) only applies to `thing:/...` resources and gates retrieval + of historical timeseries data via the Timeseries API. Its enforcement is governed + by the boolean Timeseries service setting `ditto.timeseries.simplified-read-permission`: + when `false` (default, strict) an explicit `READ_TS` grant is required on every requested + path; when `true` (simplified) a standard `READ` grant on the resource is also sufficient. enum: - READ - WRITE + - READ_TS AllowedAdditions: type: array description: |- diff --git a/documentation/src/main/resources/openapi/sources/api-2-index.yml b/documentation/src/main/resources/openapi/sources/api-2-index.yml index fdb8d391a5b..859204ab5f1 100644 --- a/documentation/src/main/resources/openapi/sources/api-2-index.yml +++ b/documentation/src/main/resources/openapi/sources/api-2-index.yml @@ -44,6 +44,8 @@ tags: description: Manage connections - name: WoT description: WoT (Web of Things) Discovery endpoints + - name: Timeseries + description: Read historical timeseries data ingested from properties annotated with `ditto:timeseries` - name: Devops description: Devops APIs to manage log levels and configuration in runtime and send piggyback command @@ -232,6 +234,16 @@ paths: /devops/wot/config/dynamicConfigs/{scopeId}: $ref: "./paths/wotValidationConfig/dynamicConfigScopeId.yml" + ### + ### Timeseries + ### + '/api/2/timeseries/things/{thingId}/features/{featureId}/properties/{propertyPath}': + $ref: "./paths/timeseries/property.yml" + '/api/2/timeseries/things/{thingId}': + $ref: "./paths/timeseries/thing.yml" + '/api/2/timeseries/things': + $ref: "./paths/timeseries/crossThing.yml" + components: requestBodies: @@ -385,6 +397,10 @@ components: $ref: "./schemas/errors/error.yml" AdvancedError: $ref: "./schemas/errors/advancedError.yml" + TimeseriesQueryResult: + $ref: "./schemas/timeseries/timeseriesQueryResult.yml" + AggregatedTimeseriesResult: + $ref: "./schemas/timeseries/aggregatedTimeseriesResult.yml" Attributes: $ref: "./schemas/things/attributes.yml" Definition: diff --git a/documentation/src/main/resources/openapi/sources/paths/timeseries/crossThing.yml b/documentation/src/main/resources/openapi/sources/paths/timeseries/crossThing.yml new file mode 100644 index 00000000000..cd97400425d --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/paths/timeseries/crossThing.yml @@ -0,0 +1,233 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +get: + summary: Aggregate timeseries data across many things of one namespace + description: |- + Aggregates historical values across **all things of one namespace** and returns one series per + group — for example "average `flowTemperature` per building, hourly, over the last day". + + ### Aggregation is mandatory + Unlike the single-thing endpoints, `step` and `agg` are **required**. A raw cross-thing read + would stream every point of every matching thing, so the result size must be a function of the + time range rather than of the tenant's thing count. Only the per-bucket aggregations are + available (`avg`, `min`, `max`, `sum`, `count`, `first`, `last`, `stddev`); the window functions + (`derivative`, `rate`, `integral`, `percentile`) remain single-thing only. + + ### `filter` uses RQL, and selects points rather than things + The filter is written in **RQL** — the same query language as `/api/2/search/things` — so there is + one filter syntax to learn across Ditto. Field references are the tag keys declared in the WoT + model, which are full thing paths, e.g. `eq(attributes/building,'A')`. + + Tags are frozen on each data point at ingest, so the filter selects by the state of the world + **when the measurement was recorded**. For a thing moved from building A to building B, + `filter=eq(attributes/building,'A')` returns only the points recorded while it was in A — not its + whole history. This is deliberate and differs from selecting things by their current attributes. + + ### Authorization + Access is evaluated against **current** policy state on every request; nothing about + authorization is stored with the data points. A subject granted access today therefore sees + history ingested before the grant, and a revoked subject immediately loses access to history it + could previously read. + + Three checks apply, and each covers a different disclosure: + + 0. **`READ` on the fields you filter or group by.** Filtering by a tag reveals which points carry + which value, and grouping by one reveals its distinct values — so slicing a series by + `attributes/building` requires `READ` on `attributes/building`, independently of `READ_TS` on + the data. Because tag keys are thing paths, they are ordinary policy resources. + + Then, on the data itself: + + 1. **Entry condition** — a **namespace-wide** `READ_TS` grant, normally expressed through a + namespace root policy. Without it the request is rejected with `403`. Per-thing grants + scattered across individual policies are not sufficient to *use* this endpoint; query those + things individually instead. + 2. **Per-thing, per-path narrowing** — a namespace-wide grant does **not** imply every thing + grants. Namespace-root entries are merged *additively*, so a thing's own policy may still revoke + `READ_TS`, and a revoke wins. Every thing that would contribute data is therefore verified + against live policy first. + + This is **path-granular**, because `READ_TS` is grantable per property: a thing denied one + requested path still contributes to the paths it is entitled to, rather than being dropped from + the whole query. (This mirrors how thing-search authorizes an RQL predicate per field.) + + When anything is withheld the response says so: `authorization.withheldByPath` names the affected + paths with a count, and `authorization.partial` is `true`. Check that flag before treating an + aggregate as representative — the values are otherwise indistinguishable from a complete result. + + Only entries whose `importable` type is `implicit` count toward the namespace-wide grant, since + those are the only ones the merge propagates to the namespace's things. + + ### Scope + Exactly one namespace per request: storage is partitioned one collection per namespace, so + spanning several would be a multi-collection merge. + tags: + - Timeseries + parameters: + - name: namespaces + in: query + description: |- + The namespace to aggregate over. Exactly one value — a comma-separated list is rejected. + required: true + schema: + type: string + example: 'io.beyonnex.smartheating' + - name: paths + in: query + description: |- + Comma-separated list of JSON pointers (relative to the thing root) to aggregate, e.g. + `/features/circuit/properties/flowTemperature`. Results are always reported per path. + + At most 100 paths per request — the same bound the single-thing endpoints apply. It matters + more here: each path is a separate grouped scan over every thing in the namespace, so the + path count multiplies a fan-out that is already namespace-wide. + required: true + schema: + type: string + example: '/features/circuit/properties/flowTemperature' + - name: from + in: query + description: 'Inclusive lower bound — ISO-8601 instant or relative expression (`now`, `now-24h`).' + required: true + schema: + type: string + example: 'now-24h' + - name: to + in: query + description: 'Exclusive upper bound — ISO-8601 instant or relative expression (e.g. `now`).' + required: true + schema: + type: string + example: 'now' + - name: step + in: query + description: |- + Bucket width (`30s`, `5m`, `1h`, `1d` or ISO-8601 `PT1H`). Required — see above. + required: true + schema: + type: string + example: '1h' + - name: agg + in: query + description: |- + The per-bucket aggregation. Required — see above. + required: true + schema: + type: string + enum: [avg, min, max, sum, count, first, last, stddev] + example: 'avg' + - name: groupBy + in: query + description: |- + Comma-separated grouping dimensions. Each is one of: + * `thingId` — one series per thing; + * `path` — one series per requested path (implicit: results are always per path, so + declaring this only adds the path to the emitted `group` object); + * a **tag path** such as `attributes/floor` — one series per distinct value of that + ingest-time tag. (`tag:` is still accepted.) + + A tag dimension requires `READ` on the referenced field, as with `filter`. + + Omitted means one series per path, aggregated across every matching thing. + required: false + schema: + type: string + example: 'attributes/building' + - name: filter + in: query + description: |- + An RQL predicate over ingest-time tags, using the same syntax as `/api/2/search/things`. + Supported operators: `eq`, `ne`, `gt`, `ge`, `lt`, `le`, `in`, `like`, `ilike`, `exists`, + `and`, `or`, `not`. Field references are tag keys as declared in the WoT model (full thing + paths). Matches points, not things — see above. + + Every field referenced here additionally requires `READ` permission. + required: false + schema: + type: string + example: "and(eq(attributes/building,'A'),ge(attributes/floor,2))" + - name: fill + in: query + description: |- + How buckets with no data are filled. Omitted means such buckets are simply absent. + Synthesised buckets are marked `_gap: true`. + required: false + schema: + type: string + enum: [previous, zero, linear] + - name: tz + in: query + description: |- + IANA timezone for calendar-aligned bucket boundaries, e.g. `Europe/Berlin`. Affects + day-and-coarser steps across DST transitions. Defaults to UTC. + required: false + schema: + type: string + example: 'Europe/Berlin' + - name: maxGroups + in: query + description: |- + Cap on the number of distinct groups returned (default 1000, ceiling 10000). Exceeding the + cap **fails the request** rather than truncating the result, because a silently truncated + set would look like a complete answer and misreport anything computed on top of it. + + A separate, operator-configured ceiling bounds how many *things* one request will authorize + (`ditto.timeseries.max-verified-things`, default 1000). Exceeding it also fails the request, + for the same reason: authorizing a truncated set of contributors would silently admit + whatever fell off the end. + required: false + schema: + type: integer + minimum: 1 + maximum: 10000 + - name: timeFormat + in: query + description: |- + Rendering of the `t` fields: `iso` (default) or `ms` for epoch milliseconds. + required: false + schema: + type: string + enum: [iso, ms] + responses: + '200': + description: The aggregation succeeded. + content: + application/json: + schema: + $ref: '../../schemas/timeseries/aggregatedTimeseriesResult.yml' + '400': + description: |- + The request is malformed: a missing or unparseable required parameter, more than 100 + `paths`, a window-function aggregation, a non-positive `step`, a `maxGroups` outside its + bounds, more than one namespace, an unknown `groupBy` dimension, a query matching more + groups than `maxGroups`, or a query spanning more things than the service will authorize + per request. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '403': + description: |- + The caller lacks `READ` on a field referenced by `filter`/`groupBy`, or has no namespace-wide + `READ_TS` grant covering every requested path — including + the case where they hold only scattered per-thing grants, which are not sufficient to use + this endpoint, and the case where the grant is carried by a policy entry that is not + `importable: implicit`. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' diff --git a/documentation/src/main/resources/openapi/sources/paths/timeseries/property.yml b/documentation/src/main/resources/openapi/sources/paths/timeseries/property.yml new file mode 100644 index 00000000000..bc5cf27ad74 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/paths/timeseries/property.yml @@ -0,0 +1,209 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +get: + summary: Retrieve historical timeseries data for one feature property + description: |- + Returns the chronological sequence of values that were observed for one feature + property within the specified time range. + + The property must be annotated with `ditto:timeseries.ingest = "ALL"` in the + WoT ThingModel referenced by the thing's `definition`; properties without the + annotation (or with `ingest = "NONE"`) are not ingested and the response will + have `count = 0`. + + Authorization is enforced via the Timeseries service's configured permission — + `READ_TS` by default. A subject without the configured permission on the + requested resource path receives a `404` (per Ditto's "404-not-403" discipline: + a `403` would leak existence). + + When `step` and `agg` are supplied the points are downsampled into `step`-sized + buckets and aggregated per bucket; otherwise the raw points are returned. Cross-thing + aggregation queries use a separate endpoint and are not exposed here. + tags: + - Timeseries + parameters: + - $ref: '../../parameters/thingIdPathParam.yml' + - $ref: '../../parameters/featureIdPathPathParam.yml' + - $ref: '../../parameters/propertyPathPathParam.yml' + - name: from + in: query + description: |- + Inclusive lower bound of the queried time range. Either an ISO-8601 instant or a + relative expression (`now`, `now-24h`, `now-30m`, `now-7d`). + required: true + schema: + type: string + example: 'now-24h' + - name: to + in: query + description: |- + Exclusive upper bound of the queried time range. ISO-8601 instant or relative + expression (e.g. `now`). + required: true + schema: + type: string + example: 'now' + - name: step + in: query + description: |- + Downsampling interval. Short form (`30s`, `5m`, `1h`, `1d`) or ISO-8601 duration + (`PT1H`). Required for the per-bucket aggregations (`avg`, `min`, `max`, `sum`, + `count`, `first`, `last`, `stddev`); optional for the window functions. + required: false + schema: + type: string + example: '1h' + - name: agg + in: query + description: |- + Aggregation function applied per `step` bucket (or, for the window functions, + over the fetched points). + required: false + schema: + type: string + enum: [avg, min, max, sum, count, first, last, stddev, derivative, rate, integral, percentile] + example: avg + - name: percentile + in: query + description: 'Percentile in `[0, 100]`. Required when `agg=percentile`.' + required: false + schema: + type: number + minimum: 0 + maximum: 100 + example: 95 + - name: fill + in: query + description: |- + Gap-fill strategy for empty buckets when downsampling. `null` emits a gap marker, + `previous` carries the last value forward, `zero` fills with 0, and `linear` linearly + interpolates between the surrounding non-empty buckets (numeric series only; non-numeric + neighbours fall back to a `null` gap). + required: false + schema: + type: string + enum: [null, previous, zero, linear] + example: previous + - name: tz + in: query + description: IANA time-zone ID used to align `step` bucket boundaries (default UTC). + required: false + schema: + type: string + example: 'Europe/Berlin' + - name: limit + in: query + description: |- + Page size for raw queries — the maximum number of data points to return. When omitted, the + server-side ceiling is used. If more points match than are returned, the response's + `meta.nextCursor` carries an opaque cursor for the next page (see `cursor`). + required: false + schema: + type: integer + format: int32 + minimum: 1 + example: 1000 + - name: cursor + in: query + description: |- + Opaque pagination cursor for raw queries. Pass back the `meta.nextCursor` value from a + previous response (unchanged) to fetch the next page; omit it for the first page. Keep + `from`/`to` identical across pages. Pagination is keyset-based over the `(timestamp, revision)` + order, so it is stable while new points are appended. Cursors are only valid for a + single-property raw read — combining `cursor` with `step`, `agg` or `fill` is rejected with + `400`. + required: false + schema: + type: string + example: 'eyJ0IjoiMjAyNi0wMS0xNFQxMDowMTowMFoiLCJyIjo0Mn0' + - name: order + in: query + description: |- + Chronological order of a raw read: `asc` (default, oldest first) or `desc` (newest first, + convenient for "scroll back into the past" UIs paged with `cursor`). `desc` is only supported + for raw reads — combining it with `step`, `agg` or `fill` is rejected with `400`. Keep `order` + consistent across pages of a cursor walk. + required: false + schema: + type: string + enum: [asc, desc] + default: asc + example: desc + - name: tagFilter + in: query + description: |- + Restrict the result to points whose resolved tags match every given `key:value` pair + (comma-separated, logical AND) — e.g. `building:A,floor:2`. Tags are declared on the WoT + property's `ditto:timeseries` annotation and resolved at ingestion time. Applies to raw and + aggregated reads. + required: false + schema: + type: string + example: 'building:A,floor:2' + - name: timeFormat + in: query + description: |- + How timestamps are rendered in the response `data` array: `iso` (default, ISO-8601 string) + or `ms` (epoch milliseconds, compact and convenient for charting libraries). + required: false + schema: + type: string + enum: [iso, ms] + default: iso + example: ms + responses: + '200': + description: Timeseries data points for the requested path. + content: + application/json: + schema: + $ref: '../../schemas/timeseries/timeseriesQueryResult.yml' + '400': + description: |- + The request could not be completed (`timeseries:query.invalid`). Possible reasons: + * the `thingId` does not conform to the namespaced entity ID notation. + * `from`/`to` is not a valid ISO-8601 instant or relative expression. + * `step` is not a valid duration, or `agg`/`fill` is not a recognised value. + * a per-bucket aggregation was requested without a `step`. + * `agg=percentile` without a `percentile`, or `percentile` outside `[0, 100]`. + * `limit` is not a valid integer. + * `cursor` is malformed, or combined with `step`, `agg` or `fill`. + * `order` is not `asc`/`desc`, or `order=desc` combined with `step`, `agg` or `fill`. + * `tagFilter` is not a comma-separated list of `key:value` pairs. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '404': + description: |- + The request could not be completed. Either the thing with the given ID was + not found, or the requesting subject lacks the configured + `READ_TS`/`READ` permission on the requested resource path. The two cases + return the same error to avoid leaking the existence of resources the + caller cannot access. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '5XX': + description: |- + The Timeseries service is temporarily unavailable or the underlying + timeseries database is degraded. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' diff --git a/documentation/src/main/resources/openapi/sources/paths/timeseries/thing.yml b/documentation/src/main/resources/openapi/sources/paths/timeseries/thing.yml new file mode 100644 index 00000000000..427c0da0d18 --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/paths/timeseries/thing.yml @@ -0,0 +1,165 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +get: + summary: Retrieve historical timeseries data for multiple paths of a Thing + description: |- + Returns the chronological sequence of values for several feature properties and/or attributes + of one Thing in a single request. The paths are given explicitly via the required `paths` + query parameter (comma-separated JSON pointers). + + The same downsampling / aggregation / fill / timezone / percentile / timeFormat parameters as the + single-property endpoint apply, and they apply uniformly to every requested path. The response is + an array with one entry (`path`, `result`, `data`) per requested path. + + Only paths whose property is annotated with `ditto:timeseries.ingest = "ALL"` produce data; the + others come back with `count = 0`. Authorization is enforced per path (`READ_TS` by default); a + path the caller cannot read is reported as having no data rather than leaking its existence. + tags: + - Timeseries + parameters: + - $ref: '../../parameters/thingIdPathParam.yml' + - name: paths + in: query + description: |- + Comma-separated list of JSON pointers (relative to the Thing root) to retrieve, e.g. + `/features/environment/properties/temperature,/attributes/batteryExchangeDate`. + + At most 100 paths per request. Each path is scanned independently and bounded by the + adapter's per-path ceiling, so the path count multiplies the memory one request can use. + required: true + schema: + type: string + example: '/features/environment/properties/temperature,/features/environment/properties/humidity' + - name: from + in: query + description: 'Inclusive lower bound — ISO-8601 instant or relative expression (`now`, `now-24h`).' + required: true + schema: + type: string + example: 'now-24h' + - name: to + in: query + description: 'Exclusive upper bound — ISO-8601 instant or relative expression (e.g. `now`).' + required: true + schema: + type: string + example: 'now' + - name: step + in: query + description: 'Downsampling interval (`30s`, `5m`, `1h`, `1d` or ISO-8601 `PT1H`).' + required: false + schema: + type: string + example: '1h' + - name: agg + in: query + description: 'Aggregation function applied per `step` bucket (or over fetched points for window functions).' + required: false + schema: + type: string + enum: [avg, min, max, sum, count, first, last, stddev, derivative, rate, integral, percentile] + example: avg + - name: percentile + in: query + description: 'Percentile in `[0, 100]`. Required when `agg=percentile`.' + required: false + schema: + type: number + minimum: 0 + maximum: 100 + example: 95 + - name: fill + in: query + description: |- + Gap-fill strategy for empty buckets when downsampling. `null` emits a gap marker, + `previous` carries the last value forward, `zero` fills with 0, and `linear` linearly + interpolates between the surrounding non-empty buckets (numeric series only). + required: false + schema: + type: string + enum: [null, previous, zero, linear] + example: previous + - name: tz + in: query + description: IANA time-zone ID used to align `step` bucket boundaries (default UTC). + required: false + schema: + type: string + example: 'Europe/Berlin' + - name: limit + in: query + description: Maximum number of data points per path for raw queries (subject to a server-side ceiling). + required: false + schema: + type: integer + format: int32 + minimum: 1 + example: 1000 + - name: tagFilter + in: query + description: |- + Restrict the result to points whose resolved tags match every given `key:value` pair + (comma-separated, logical AND) — e.g. `building:A,floor:2`. Tags are declared on the WoT + property's `ditto:timeseries` annotation and resolved at ingestion time. Applies to raw and + aggregated reads. + required: false + schema: + type: string + example: 'building:A,floor:2' + - name: timeFormat + in: query + description: 'Timestamp rendering in the response: `iso` (default) or `ms` (epoch milliseconds).' + required: false + schema: + type: string + enum: [iso, ms] + default: iso + example: ms + responses: + '200': + description: One timeseries result entry per requested path. + content: + application/json: + schema: + $ref: '../../schemas/timeseries/timeseriesQueryResult.yml' + '400': + description: |- + The request could not be completed (`timeseries:query.invalid`). Possible reasons: + * `paths` is missing, contains no valid JSON pointer, or lists more than 100 paths. + * `from`/`to` is not a valid ISO-8601 instant or relative expression. + * `step` is not a valid duration, or `agg`/`fill`/`timeFormat` is not a recognised value. + * a per-bucket aggregation was requested without a `step`. + * `agg=percentile` without a `percentile`, or `percentile` outside `[0, 100]`. + * `tagFilter` is not a comma-separated list of `key:value` pairs. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '401': + description: The request could not be completed due to missing authentication. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '404': + description: |- + The Thing with the given ID was not found, or the requesting subject lacks the configured + `READ_TS` permission. The two cases return the same error to avoid leaking existence. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' + '5XX': + description: The Timeseries service is temporarily unavailable or the timeseries database is degraded. + content: + application/json: + schema: + $ref: '../../schemas/errors/advancedError.yml' diff --git a/documentation/src/main/resources/openapi/sources/schemas/policies/permission.yml b/documentation/src/main/resources/openapi/sources/schemas/policies/permission.yml index 7cdda747335..b2b0347fbe0 100644 --- a/documentation/src/main/resources/openapi/sources/schemas/policies/permission.yml +++ b/documentation/src/main/resources/openapi/sources/schemas/policies/permission.yml @@ -9,7 +9,15 @@ # # SPDX-License-Identifier: EPL-2.0 type: string -description: A Permission allows a certain action on an entity +description: | + A Permission allows a certain action on an entity. + * `READ` and `WRITE` apply to all resource types (`thing:/`, `policy:/`, `message:/`). + * `READ_TS` (since 4.0.0) only applies to `thing:/...` resources and gates retrieval + of historical timeseries data via the Timeseries API. Its enforcement is governed + by the boolean Timeseries service setting `ditto.timeseries.simplified-read-permission`: + when `false` (default, strict) an explicit `READ_TS` grant is required on every requested + path; when `true` (simplified) a standard `READ` grant on the resource is also sufficient. enum: - READ - - WRITE \ No newline at end of file + - WRITE + - READ_TS \ No newline at end of file diff --git a/documentation/src/main/resources/openapi/sources/schemas/timeseries/aggregatedTimeseriesResult.yml b/documentation/src/main/resources/openapi/sources/schemas/timeseries/aggregatedTimeseriesResult.yml new file mode 100644 index 00000000000..d54ad89e0eb --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/timeseries/aggregatedTimeseriesResult.yml @@ -0,0 +1,151 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +type: object +required: + - results + - authorization +description: |- + Cross-Thing aggregation result. + + Unlike the single-thing endpoints (whose body is a bare array), this is an object so it can carry the + `authorization` summary alongside the series. An aggregate computed over the subset of things you may + read is indistinguishable from one computed over everything if you only look at the numbers — so the + exclusion is stated rather than left implicit. +properties: + authorization: + type: object + required: + - contributingThings + - excludedThings + - withheldByPath + - partial + description: |- + How much of the matching data is actually reflected in `results`. + properties: + contributingThings: + type: integer + description: |- + How many distinct things shaped these aggregates, i.e. were permitted on at least one + requested path. + excludedThings: + type: integer + description: |- + How many things had matching data but were withheld from **every** requested path. + + This is not an error. A namespace-wide `READ_TS` grant is the entry condition for this + endpoint, but namespace-root policy entries are merged *additively* — an individual thing's + own policy may still revoke `READ_TS`, and a revoke wins. + withheldByPath: + type: object + additionalProperties: + type: integer + description: |- + Per requested path, how many things had matching data for that path but were withheld from + it. Only non-zero entries appear. + + `READ_TS` is grantable **per property**, so access is path-granular: a thing denied one + requested path still contributes to the paths it is entitled to. Such a thing appears here + under the denied path while remaining counted in `contributingThings` — it is *not* in + `excludedThings`, which counts only things withheld from every path. + + A thing is counted against a path only if it actually had data for that path, so this never + inflates with things that would have contributed nothing anyway. + example: + /features/circuit/properties/returnTemperature: 1 + partial: + type: boolean + description: |- + `true` when anything at all was withheld, i.e. `withheldByPath` is non-empty. Every affected + series in `results` is then computed over a subset of the matching data — treat those numbers + as a lower-bound sample, not the population. Check this before presenting an aggregate as + representative. + results: + type: array + description: |- + One entry per `(group, path)` combination. + + There is no `thingId` field — a group generally spans many things. When the query groups by + `thingId`, the thing ID appears as an ordinary entry inside `group` instead, so every grouping + choice produces the same response shape. + items: + type: object + required: + - path + - result + - data + properties: + group: + type: object + additionalProperties: + type: string + description: |- + The group identity: one entry per `groupBy` dimension, keyed by the dimension's name + (`thingId`, `path`, or the bare tag key for a `tag:` dimension). Absent when the + query declared no `groupBy`, in which case the entry aggregates every matching thing. + + A point that carries no value for a grouped tag appears under an empty-string value + rather than being dropped, so the set of keys is the same across all entries. + example: + building: 'A' + path: + type: string + description: |- + The Ditto Protocol path this series belongs to, e.g. + `/features/circuit/properties/flowTemperature`. Results are always reported per path. + result: + type: object + required: + - count + - dataType + properties: + count: + type: integer + description: Number of data points in `data`. + unit: + type: string + description: |- + The unit declared on the WoT property, when the points of this series carry one. + example: 'cel' + dataType: + type: string + description: JSON type of the aggregated values. + example: 'number' + tags: + type: object + additionalProperties: + type: string + description: |- + Always empty for aggregated results: a group spans many points whose tags may differ, + so there is no single tag set to report. Use `group` instead. + data: + type: array + description: |- + The aggregated values in chronological order, one per `step` bucket that has data (plus + the interpolated buckets when `fill` is set). + items: + type: object + required: + - t + - v + properties: + t: + type: string + format: date-time + description: |- + Start of the bucket, ISO-8601. Rendered as epoch milliseconds instead when + `timeFormat=ms`. + v: + description: The aggregated value for this bucket. Type matches `dataType`. + _gap: + type: boolean + description: |- + Present and `true` when this bucket had no data of its own and was synthesised by the + `fill` strategy. Absent on buckets backed by real measurements. diff --git a/documentation/src/main/resources/openapi/sources/schemas/timeseries/timeseriesQueryResult.yml b/documentation/src/main/resources/openapi/sources/schemas/timeseries/timeseriesQueryResult.yml new file mode 100644 index 00000000000..c2d0ec8ce4f --- /dev/null +++ b/documentation/src/main/resources/openapi/sources/schemas/timeseries/timeseriesQueryResult.yml @@ -0,0 +1,143 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 +type: array +description: |- + Timeseries query result: one entry per requested path, in request order. The + single-property endpoint requests one path, so its array holds a single entry; + the multi-path Thing endpoint (`?paths=...`) returns one entry per requested path. +items: + type: object + required: + - thingId + - path + - query + - result + - data + properties: + thingId: + type: string + description: The thing the data points belong to. + path: + type: string + description: |- + The Ditto Protocol path inside the thing whose timeseries was queried, + e.g. `/features/environment/properties/temperature`. + query: + type: object + description: Echo of the effective query that produced this result. Optional + parameters are present only when supplied on the request. + properties: + thingId: + type: string + paths: + type: array + items: + type: string + from: + type: string + format: date-time + to: + type: string + format: date-time + step: + type: string + description: Downsampling interval as an ISO-8601 duration, e.g. `PT1H`. + aggregation: + type: string + enum: [avg, min, max, sum, count, first, last, stddev, derivative, rate, integral, percentile] + fillStrategy: + type: string + enum: [null, previous, zero, linear] + percentile: + type: number + minimum: 0 + maximum: 100 + timezone: + type: string + description: IANA time-zone ID used to align bucket boundaries. + limit: + type: integer + format: int32 + cursor: + type: string + description: Opaque pagination cursor supplied on the request, if any. + order: + type: string + enum: [asc, desc] + description: Sort order supplied on the request, if any. + tagFilters: + type: object + additionalProperties: + type: string + description: Tag filters supplied on the request, if any. + result: + type: object + description: Per-path metadata. + required: + - count + - dataType + properties: + count: + type: integer + format: int32 + description: Number of data points returned. + hasMore: + type: boolean + description: |- + Whether more data matched the query than this page returned. Present only for raw + (paginated) reads: `true` means another page follows (see `nextCursor`), `false` means + this is the last page. Absent for aggregated reads. + nextCursor: + type: string + description: |- + Opaque cursor for the next page of a raw read. Present only when `hasMore` is `true`; + pass it back as the request's `cursor` to continue. Absent on the last page. + dataType: + type: string + enum: + - number + - string + - boolean + - "null" + description: |- + Inferred scalar type of the returned values. `null` when the result + set is empty. + unit: + type: string + description: |- + Unit declared on the WoT property the timeseries was ingested from + (semantic prefix stripped). Omitted when the property has no `unit`. + tags: + type: object + additionalProperties: + type: string + description: |- + The resolved tags of the series, as stored with the points (representative: the first + point's). Omitted when the series has no tags or for aggregated reads (which group away + per-point metadata). + data: + type: array + description: |- + Returned data points in the requested chronological order — ascending timestamp by default, + or descending when `order=desc`. + items: + type: object + required: + - t + - v + properties: + t: + type: string + format: date-time + description: ISO-8601 timestamp at which the value was observed. + v: + description: |- + The observed scalar value. Type matches the per-path `dataType`. diff --git a/documentation/src/main/resources/pages/ditto/basic-timeseries.md b/documentation/src/main/resources/pages/ditto/basic-timeseries.md new file mode 100644 index 00000000000..5357b701fff --- /dev/null +++ b/documentation/src/main/resources/pages/ditto/basic-timeseries.md @@ -0,0 +1,190 @@ +--- +title: Timeseries +keywords: timeseries, history, aggregation, downsampling, wot, retention, READ_TS +tags: [timeseries, wot, model] +permalink: basic-timeseries.html +--- + +A `Thing` holds only the *current* state of its properties. Ditto's timeseries feature additionally records the **history** of selected feature properties and provides an API to query it — raw, downsampled, or aggregated across many things. + +{% include callout.html content="**TL;DR**: Recording is opt-in per property via a `ditto:timeseries` annotation in the thing's WoT Thing Model. Reading requires the `READ_TS` permission and is evaluated against *current* policy state on every request." type="primary" %} + +## Overview + +| API | Access method | Shape | +|---|---|---| +| [HTTP](#reading-one-thing) | `GET /api/2/timeseries/things/...` | request-response | +| [Ditto protocol](protocol-specification-topic.html#timeseries-criterion) | [WebSocket](httpapi-protocol-bindings-websocket.html) and [connections](basic-connections.html) | request-response | + +Two read shapes exist: + +- **single-thing** — the recorded history of one thing's properties; +- **cross-thing** — an aggregation across every authorized thing of one namespace, optionally grouped. + +## Recording data points + +### Opting a property in + +Nothing is recorded until a property's WoT Thing Model says so. Add a `ditto:timeseries` annotation to the property's schema: + +```json +{ + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { "ditto": "https://ditto.eclipseprojects.io/wot/ditto-extension#" } + ], + "@type": "tm:ThingModel", + "properties": { + "flowTemperature": { + "type": "number", + "unit": "om2:degreeCelsius", + "ditto:timeseries": { + "ingest": "ALL" + } + } + } +} +``` + +`ingest` is `ALL` to record every change, or `NONE` (equivalently, omitting the annotation) to record nothing. The annotation may sit on a nested scalar, so a single value can be opted in without dragging in its siblings. + +Recording is **forward-only**: annotating a property records values from that point on. Existing history is never backfilled, and removing the annotation stops recording without deleting what was already stored. + +{% include note.html content="A thing with no annotated property produces no data points and no additional load — the write path costs nothing when the feature is unused." %} + +### Tags + +A data point can carry **tags** — labels frozen onto the point at the moment it is recorded. Tag values may be constants or WoT placeholders resolved against the thing: + +```json +"ditto:timeseries": { + "ingest": "ALL", + "tags": { + "attributes/building": "{{ thing-json:attributes/building }}" + } +} +``` + +Tag keys are full thing paths, which is what makes them checkable as [policy](basic-policy.html) resources. A tag whose placeholder resolves to no value on a given thing is simply omitted from that point rather than stored empty, so one shared model can declare tags that only some things carry. + +Because tags are frozen at ingest, they describe **the state of the world when the measurement was taken**. For a thing moved from building A to building B, points recorded while it was in A keep `building=A` forever. This is deliberate, and it differs from selecting things by their current attributes. + +## Reading one thing + +``` +GET /api/2/timeseries/things/{thingId}/features/{featureId}/properties/{propertyPath} +GET /api/2/timeseries/things/{thingId}?paths=,,... +``` + +| Parameter | Meaning | +|---|---| +| `from`, `to` | ISO-8601 instants or relative expressions (`now-1h`, `now`). Both are anchored to a single `now`, so `from=now-1h&to=now` spans exactly one hour. | +| `step` | Bucket width (`30s`, `5m`, `1h`, `1d`, `1w`, or ISO-8601). Omit for a raw read. | +| `agg` | The aggregation to apply per bucket or per window. | +| `percentile` | Companion to `agg=percentile`. | +| `fill` | `linear` interpolates empty interior buckets; `previous` carries the last value forward. | +| `tz` | Zone the bucket grid aligns to (DST-aware). | +| `limit`, `cursor`, `order` | Keyset pagination and sort direction on raw reads. | +| `tagFilter` | Comma-separated `key:value` tag predicates. | +| `timeFormat` | `ms` emits epoch millis instead of ISO-8601. | + +At most **100 paths** per request. + +### Aggregations + +| Kind | Values | Available cross-thing? | +|---|---|---| +| Per-bucket | `avg` `min` `max` `sum` `count` `first` `last` `stddev` | yes | +| Window | `derivative` `rate` `integral` `percentile` | no | + +Window functions need the full ordered series of one thing, so they are single-thing only. `stddev` is the *sample* standard deviation, which is undefined for a bucket holding a single point. + +### Pagination + +Raw reads page with a **keyset cursor**, not an offset: timeseries data is append-heavy, and an offset would skip or repeat rows as new points land mid-pagination. Pass `limit`, then feed `result.nextCursor` back as `cursor` until `hasMore` is false. + +## Aggregating across things + +``` +GET /api/2/timeseries/things?namespaces=&paths=...&from=...&to=...&step=...&agg=... +``` + +Returns one series per `(group, path)` — for example "average `flowTemperature` per building, hourly, over the last day". + +Unlike the single-thing endpoints, **`step` and `agg` are required**. A raw cross-thing read would stream every point of every thing, so the response size must follow the time range rather than the tenant's thing count. + +Exactly one namespace per request: storage is one collection per namespace, so spanning several would require a multi-collection merge. + +### Grouping + +`groupBy` takes a comma-separated list of dimensions: + +- `thingId` — one series per thing; +- `path` — one series per requested path (implicit, since results are always per path; declaring it only adds the path to the emitted group); +- a **tag path** such as `attributes/building` — one series per distinct value of that ingest-time tag. + +Grouping by a tag requires `READ` on the referenced field, because the distinct values are themselves disclosure. Anything that is not `thingId` or `path` is read as a tag path, so a mistyped dimension yields a single group with an empty value rather than an error. + +### Filtering + +The optional `filter` is an [RQL](basic-rql.html) predicate over ingest-time tags — the same query language as [search](basic-search.html), so there is one filter syntax to learn: + +``` +filter=and(eq(attributes/building,'A'),ge(attributes/floor,2)) +``` + +Field references are tag keys. Only tags are addressable: a filter cannot reach a point's thing ID, path or value, so it cannot be used to sidestep authorization. Like `groupBy`, filtering on a field requires `READ` on it. + +### Response shape + +Cross-thing responses are an object rather than a bare array, so they can carry an authorization summary: + +```json +{ + "results": [ + { "group": { "attributes/building": "A" }, "path": "...", "result": { ... }, "data": [ { "t": "...", "v": 23.9 } ] } + ], + "authorization": { + "contributingThings": 4, + "excludedThings": 1, + "partial": true, + "withheldByPath": { "/features/env/properties/flowTemperature": 1 } + } +} +``` + +`partial: true` means the aggregate was computed over a subset. An average taken over the things you may read is indistinguishable, from the numbers alone, from one taken over all of them — so Ditto states it rather than leaving you to infer it. + +## Authorization + +Reading history requires the **`READ_TS`** permission, granted per resource path like any other [policy](basic-policy.html) permission. Recording is *not* gated by it: the permission governs reads, which is what makes a revocation retroactive — the data continues to exist, it simply stops being readable. + +Access is a statement about *now*, while data points are historical. Any snapshot of grants taken at ingest time would be wrong in one direction or the other, so **nothing about authorization is stored alongside the data**. Every request is decided against current policy state: a subject granted access today sees history recorded before the grant, and a revoked subject immediately loses history it could previously read. + +Set `ditto.timeseries.simplified-read-permission=true` to let a plain `READ` grant unlock timeseries reads instead, for deployments that prefer "if you can read the current value, you can read its history". + +### Cross-thing checks + +A cross-thing aggregation enumerates, so it is gated more tightly than a single-thing read: + +1. **`READ` on every field used in `filter` or `groupBy`** — slicing a series by a field discloses that field. +2. **A namespace-wide `READ_TS` grant**, expressible only through a namespace root policy (`ditto.namespace-policies`). Without one, the request is refused outright — this is the most common cause of an unexpected `403`. Grants scattered across individual thing policies are not sufficient. +3. **Per-thing verification.** Passing the gate does not prove every thing grants: root-policy entries merge *additively*, so a thing's own policy can still revoke, and a revoke wins. Every contributing thing is verified against live policy, and any that are withheld are reported in the `authorization` block. + +## Retention + +Data points expire after a configurable retention period, `90d` by default, with per-namespace overrides. Changing the configured value is applied to an existing namespace the next time that namespace is written to. + +{% include note.html content="Deleting a thing does not delete its recorded history. Orphaned points survive until retention expires them, and because no policy governs them any more they are unreadable — they are counted as excluded on cross-thing queries covering that period." %} + +## Limits + +| Limit | Default | Configuration | +|---|---|---| +| Paths per request | 100 | — | +| Distinct groups per cross-thing query | 1 000 (ceiling 10 000) | `maxGroups` request parameter | +| Things authorized per cross-thing request | 1 000 | `ditto.timeseries.max-verified-things` | +| Points scanned per path | 1 000 000 | `ditto.timeseries.adapter.mongodb.max-query-result-size` | +| Retention | 90 days | `ditto.timeseries.adapter.mongodb.retention` | + +Exceeding the group or thing ceilings **fails the request** rather than silently returning a truncated answer, for the same reason `partial` exists: a truncated aggregate looks exactly like a complete one. diff --git a/documentation/src/main/resources/pages/ditto/protocol-specification-topic.md b/documentation/src/main/resources/pages/ditto/protocol-specification-topic.md index 0701d10ec64..039968f8fc5 100644 --- a/documentation/src/main/resources/pages/ditto/protocol-specification-topic.md +++ b/documentation/src/main/resources/pages/ditto/protocol-specification-topic.md @@ -25,6 +25,8 @@ Examples of valid topic paths: * `org.eclipse.ditto/fancy-policy-1/policies/commands/create` * `org.eclipse.ditto/fancy-policy-1/policies/commands/delete` * `org.eclipse.ditto/fancy-policy-1/policies/announcements/subjectDeletion` +* `org.eclipse.ditto/fancy-car-1/things/twin/timeseries/retrieve` +* `org.eclipse.ditto/_/things/twin/timeseries/aggregate` ## Namespace @@ -91,6 +93,21 @@ For each command it processes, Ditto creates a command response indicating succe **search** requests work only on the twin channel. They contain a query string that searches across all digital twins. Ditto respects [authorization](basic-auth.html) and returns paginated results. +### Timeseries criterion + +**timeseries** requests read recorded history of feature properties and work only on the twin channel, because Ditto serves them from its own timeseries store rather than from the device. + +Two shapes exist, distinguished by the [action](#timeseries-criterion-actions): + +- a **single-thing** read, addressed like any other thing command: + `org.eclipse.ditto/fancy-car-1/things/twin/timeseries/retrieve` +- a **cross-thing** aggregation, which spans every authorized thing of one namespace and therefore + names no single entity. The `{entity-name}` segment carries the `_` placeholder instead: + `org.eclipse.ditto/_/things/twin/timeseries/aggregate` + +Only properties annotated for ingest in the thing's WoT Thing Model are recorded, and reads require +the `READ_TS` permission. See [Timeseries](basic-timeseries.html). + ### Messages criterion **messages** are exchanged only via the live channel. They carry custom payloads and can be answered by correlated response messages. @@ -144,6 +161,15 @@ Search protocol actions: See [Search protocol specification](protocol-specification-things-search.html) for details. +### Timeseries criterion actions + +Timeseries protocol actions: +* `retrieve` -- read one thing's recorded history, for one or more property paths +* `aggregate` -- aggregate across every authorized thing of one namespace, optionally grouped + +`aggregate` always requires `step` and an aggregation, so the response size follows the time range +rather than the number of things in the namespace. See [Timeseries](basic-timeseries.html). + ### Acknowledgement criterion actions For the **acks** criterion, the action is the acknowledgement label. It must match the regular expression `[a-zA-Z0-9-_:]{3,100}`. diff --git a/edge/service/pom.xml b/edge/service/pom.xml index 419868634c1..c1d71810725 100644 --- a/edge/service/pom.xml +++ b/edge/service/pom.xml @@ -52,6 +52,14 @@ org.eclipse.ditto ditto-things-model + + org.eclipse.ditto + ditto-timeseries-model + + + org.eclipse.ditto + ditto-timeseries-api + org.eclipse.ditto ditto-messages-model diff --git a/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActor.java b/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActor.java index c8f7be0e0e0..cd6fcdeee62 100644 --- a/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActor.java +++ b/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActor.java @@ -60,6 +60,9 @@ import org.eclipse.ditto.thingsearch.api.ThingsSearchConstants; import org.eclipse.ditto.thingsearch.api.commands.sudo.ThingSearchSudoCommand; import org.eclipse.ditto.thingsearch.model.signals.commands.ThingSearchCommand; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; import com.typesafe.config.Config; @@ -168,6 +171,8 @@ public Receive createReceive() { this::forwardToConnectivity ) .match(WotValidationConfigCommand.class, this::forwardToWotValidationConfig) + .match(RetrieveTimeseries.class, this::forwardToTimeseries) + .match(RetrieveAggregatedTimeseries.class, this::forwardToTimeseriesAggregate) .match(Signal.class, this::handleUnknownSignal) .matchAny(m -> log.warning("Got unknown message: {}", m)) .build(); @@ -318,6 +323,22 @@ private void forwardToThingSearch(final Command command) { pubSubMediator.tell(DistPubSubAccess.send(ThingsSearchConstants.SEARCH_ACTOR_PATH, command), getSender()); } + private void forwardToTimeseries(final RetrieveTimeseries command) { + // Same per-Thing shard entity serves the write and read paths, so this routes exactly + // like forwardToThings. + askWithRetryCommandForwarder.forwardCommand(command, + shardRegions.timeseries(), + getSender()); + } + + + private void forwardToTimeseriesAggregate(final RetrieveAggregatedTimeseries command) { + // No thingId to shard on, so address the per-node handler by path, as forwardToThingSearch + // does. Not "ask with retry": retrying an expensive aggregation multiplies the load. + pubSubMediator.tell( + DistPubSubAccess.send(TimeseriesMessagingConstants.AGGREGATE_ACTOR_PATH, command), + getSender()); + } private void handleUnknownSignal(final Signal signal) { applySignalTransformation(signal, sender()) diff --git a/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/ShardRegions.java b/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/ShardRegions.java index 90bfe7ab853..88f4ca0fce2 100644 --- a/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/ShardRegions.java +++ b/edge/service/src/main/java/org/eclipse/ditto/edge/service/dispatching/ShardRegions.java @@ -35,12 +35,16 @@ public final class ShardRegions { private static final String CONNECTIVITY_CLUSTER_ROLE = "connectivity"; private static final String CONNECTIVITY_SHARD_REGION = "connection"; + private static final String TIMESERIES_CLUSTER_ROLE = "timeseries"; + private static final String TIMESERIES_SHARD_REGION = "timeseries-ingest"; + private final ShardRegionProxyActorFactory shardRegionProxyActorFactory; private final ActorRef policies; private final ActorRef things; private final ActorRef wotValidationConfig; private final ActorRef connections; private final ActorRef search; + private final ActorRef timeseries; private ShardRegions(final ShardRegionProxyActorFactory shardRegionProxyActorFactory) { this.shardRegionProxyActorFactory = shardRegionProxyActorFactory; @@ -49,6 +53,7 @@ private ShardRegions(final ShardRegionProxyActorFactory shardRegionProxyActorFac wotValidationConfig = startShardRegionProxy(THINGS_CLUSTER_ROLE, WOT_VALIDATION_CONFIG_SHARD_REGION); connections = startShardRegionProxy(CONNECTIVITY_CLUSTER_ROLE, CONNECTIVITY_SHARD_REGION); search = startShardRegionProxy(SEARCH_CLUSTER_ROLE, SEARCH_SHARD_REGION); + timeseries = startShardRegionProxy(TIMESERIES_CLUSTER_ROLE, TIMESERIES_SHARD_REGION); } private ActorRef startShardRegionProxy(final CharSequence clusterRole, final CharSequence shardRegionName) { @@ -112,4 +117,15 @@ public ActorRef search() { return search; } + /** + * Return the timeseries shard region proxy. The same per-Thing sharded entity handles both + * the {@code IngestDataPoints} write path and the {@code RetrieveTimeseries} read path — + * mirroring how {@code ThingPersistenceActor} services every command for its Thing. + * + * @return timeseries shard region proxy. + */ + public ActorRef timeseries() { + return timeseries; + } + } diff --git a/edge/service/src/test/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActorTest.java b/edge/service/src/test/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActorTest.java index 315ca2f620c..658212d77cb 100644 --- a/edge/service/src/test/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActorTest.java +++ b/edge/service/src/test/java/org/eclipse/ditto/edge/service/dispatching/EdgeCommandForwarderActorTest.java @@ -56,6 +56,38 @@ public static void tearDown() { } } + @Test + public void retrieveTimeseriesIsForwardedToTimeseriesShardRegion() { + // The per-Thing TimeseriesIngestActor handles both ingest and query, so the forwarder + // routes RetrieveTimeseries via askWithRetryCommandForwarder against the timeseries + // shard region — same shape as forwardToThings / forwardToPolicies. + assert actorSystem != null; + new TestKit(actorSystem) {{ + final ShardRegions shardRegionsMock = Mockito.mock(ShardRegions.class); + final TestProbe timeseriesProbe = new TestProbe(actorSystem); + Mockito.when(shardRegionsMock.timeseries()).thenReturn(timeseriesProbe.ref()); + + final Props props = EdgeCommandForwarderActor.props(getRef(), shardRegionsMock); + final ActorRef underTest = actorSystem.actorOf(props); + + final java.time.Instant from = java.time.Instant.parse("2026-01-14T00:00:00Z"); + final java.time.Instant to = java.time.Instant.parse("2026-01-15T00:00:00Z"); + final org.eclipse.ditto.timeseries.model.TimeseriesQuery query = + org.eclipse.ditto.timeseries.model.TimeseriesQuery.of(THING_ID, + java.util.Collections.singletonList(JsonPointer.of( + "/features/env/properties/temperature")), + from, to); + final org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries cmd = + org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries.of( + query, + DittoHeaders.newBuilder().correlationId("ts-fwd-test").build()); + + underTest.tell(cmd, getRef()); + + timeseriesProbe.expectMsg(cmd); + }}; + } + @Test public void ensureCommandOrderIsMaintainedForSlowSignalTransformations() { assert actorSystem != null; diff --git a/gateway/service/pom.xml b/gateway/service/pom.xml index 80bead5d583..bb07eb9b8c8 100644 --- a/gateway/service/pom.xml +++ b/gateway/service/pom.xml @@ -122,6 +122,14 @@ org.eclipse.ditto ditto-edge-service + + org.eclipse.ditto + ditto-timeseries-model + + + org.eclipse.ditto + ditto-timeseries-api + diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRoute.java index 748570d5441..c1712fb8581 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRoute.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRoute.java @@ -62,6 +62,7 @@ import org.eclipse.ditto.gateway.service.endpoints.routes.status.OverallStatusRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.things.ThingsRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.thingsearch.ThingSearchRoute; +import org.eclipse.ditto.gateway.service.endpoints.routes.timeseries.TimeseriesRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.websocket.WebSocketRouteBuilder; import org.eclipse.ditto.gateway.service.endpoints.routes.whoami.WhoamiRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.wot.WotDiscoveryThingDirectoryRoute; @@ -94,6 +95,7 @@ public final class RootRoute extends AllDirectives { private final PoliciesRoute policiesRoute; private final SseRouteBuilder sseThingsRouteBuilder; private final ThingsRoute thingsRoute; + private final TimeseriesRoute timeseriesRoute; private final ThingSearchRoute thingSearchRoute; private final ConnectionsRoute connectionsRoute; private final WebSocketRouteBuilder websocketRouteBuilder; @@ -129,6 +131,7 @@ private RootRoute(final Builder builder) { policiesRoute = builder.policiesRoute; sseThingsRouteBuilder = builder.sseThingsRouteBuilder; thingsRoute = builder.thingsRoute; + timeseriesRoute = builder.timeseriesRoute; thingSearchRoute = builder.thingSearchRoute; connectionsRoute = builder.connectionsRoute; websocketRouteBuilder = builder.websocketRouteBuilder; @@ -363,6 +366,8 @@ private Route buildApiSubRoutes(final RequestContext ctx, final DittoHeaders dit buildSseThingsRoute(ctx, dittoHeaders), // /api/{apiVersion}/things thingsRoute.buildThingsRoute(ctx, dittoHeaders), + // /api/{apiVersion}/timeseries + timeseriesRoute.buildTimeseriesRoute(ctx, dittoHeaders), // /api/{apiVersion}/search/things thingSearchRoute.buildSearchRoute(ctx, dittoHeaders), // /api/{apiVersion}/whoami @@ -480,6 +485,7 @@ private static final class Builder implements RootRouteBuilder { private PoliciesRoute policiesRoute; private SseRouteBuilder sseThingsRouteBuilder; private ThingsRoute thingsRoute; + private TimeseriesRoute timeseriesRoute; private ThingSearchRoute thingSearchRoute; private ConnectionsRoute connectionsRoute; private WebSocketRouteBuilder websocketRouteBuilder; @@ -560,6 +566,12 @@ public RootRouteBuilder thingsRoute(final ThingsRoute route) { return this; } + @Override + public RootRouteBuilder timeseriesRoute(final TimeseriesRoute route) { + timeseriesRoute = route; + return this; + } + @Override public RootRouteBuilder thingSearchRoute(final ThingSearchRoute route) { thingSearchRoute = route; diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteBuilder.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteBuilder.java index 10c4fce42ba..0571cee7cf2 100644 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteBuilder.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteBuilder.java @@ -32,6 +32,7 @@ import org.eclipse.ditto.gateway.service.endpoints.routes.status.OverallStatusRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.things.ThingsRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.thingsearch.ThingSearchRoute; +import org.eclipse.ditto.gateway.service.endpoints.routes.timeseries.TimeseriesRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.websocket.WebSocketRouteBuilder; import org.eclipse.ditto.gateway.service.endpoints.routes.whoami.WhoamiRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.wot.WotDiscoveryThingDirectoryRoute; @@ -117,6 +118,15 @@ public interface RootRouteBuilder { */ RootRouteBuilder thingsRoute(ThingsRoute route); + /** + * Sets the timeseries sub-route. Exposes timeseries reads as a top-level resource alongside + * {@code /things} and {@code /policies}. + * + * @param route the route to set. + * @return the Builder to allow method chaining. + */ + RootRouteBuilder timeseriesRoute(TimeseriesRoute route); + /** * Sets the thing search sub-route. * diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRoute.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRoute.java new file mode 100644 index 00000000000..5cbacc10955 --- /dev/null +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRoute.java @@ -0,0 +1,612 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.gateway.service.endpoints.routes.timeseries; + +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +import org.apache.pekko.http.javadsl.model.ContentTypes; +import org.apache.pekko.http.javadsl.server.PathMatchers; +import org.apache.pekko.http.javadsl.server.RequestContext; +import org.apache.pekko.http.javadsl.server.Route; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.service.UriEncoding; +import org.eclipse.ditto.gateway.service.endpoints.routes.AbstractRoute; +import org.eclipse.ditto.gateway.service.endpoints.routes.RouteBaseProperties; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.gateway.api.NamespaceNotAccessibleException; +import org.eclipse.ditto.gateway.service.security.authorization.NamespaceAccessValidator; +import org.eclipse.ditto.gateway.service.security.authorization.NamespaceAccessValidatorFactory; +import org.eclipse.ditto.things.model.ThingConstants; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.SortOrder; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; + +/** + * Route exposing timeseries reads as a top-level resource alongside {@code /things} and + * {@code /policies}, matching the IOT-495 Phase 1 URL shape: + *

+ *   GET /api/2/timeseries/things/{thingId}/features/{featureId}/properties/{property-pointer}
+ *       ?from=<ISO-8601>&to=<ISO-8601>[&limit=<n>]
+ * 
+ * The route translates query / path parameters into a {@link RetrieveTimeseries} command and + * forwards it through the standard request pipeline so authorisation enforcement (READ_TS) and + * adapter dispatch happen on the timeseries service. {@code property-pointer} may itself contain + * slashes (e.g. {@code temperature/avg}) — the path matcher captures the entire trailing segment. + *

+ * Two REST shapes funnel into the same {@link RetrieveTimeseries}: the single-property URL above and + * a thing-level {@code GET /timeseries/things/{thingId}?paths=,,...} for multi-property reads. + *

+ * All query parameters that affect which data is returned ({@code from/to/step/agg/fill/tz/ + * percentile/limit/cursor/order/tagFilter}) live on {@link TimeseriesQuery} and therefore round-trip identically across HTTP, + * WebSocket and Connectivity. {@code timeFormat} is the deliberate exception: it only changes how the + * already-computed timestamps are rendered (ISO vs. epoch-ms), so it is an HTTP-edge-only + * presentation transform and is intentionally kept off the model — do not migrate it onto + * {@code TimeseriesQuery}, which would wrongly imply cross-transport query semantics. + */ +public final class TimeseriesRoute extends AbstractRoute { + + public static final String PATH_TIMESERIES = "timeseries"; + + private static final String PATH_THINGS = "things"; + private static final String PATH_FEATURES = "features"; + private static final String PATH_PROPERTIES = "properties"; + + private static final String PARAM_FROM = "from"; + private static final String PARAM_TO = "to"; + private static final String PARAM_LIMIT = "limit"; + private static final String PARAM_STEP = "step"; + private static final String PARAM_AGG = "agg"; + private static final String PARAM_FILL = "fill"; + private static final String PARAM_TZ = "tz"; + private static final String PARAM_PERCENTILE = "percentile"; + private static final String PARAM_CURSOR = "cursor"; + private static final String PARAM_ORDER = "order"; + private static final String PARAM_TAG_FILTER = "tagFilter"; + /** Cross-Thing tag predicate, expressed in RQL — the same language as /search/things. */ + private static final String PARAM_FILTER = "filter"; + private static final String PARAM_PATHS = "paths"; + private static final String PARAM_TIME_FORMAT = "timeFormat"; + private static final String PARAM_NAMESPACES = "namespaces"; + private static final String PARAM_GROUP_BY = "groupBy"; + private static final String PARAM_MAX_GROUPS = "maxGroups"; + + /** Field wrapping the series array in the cross-Thing response body. */ + private static final String FIELD_RESULTS = "results"; + + /** Short duration form used by {@code step} and relative time offsets, e.g. {@code 30s,5m,1h,1d,1w}. */ + private static final Pattern SHORT_DURATION = Pattern.compile("(\\d+)([smhdw])"); + + @Nullable private final NamespaceAccessValidatorFactory validatorFactory; + + public TimeseriesRoute(final RouteBaseProperties routeBaseProperties) { + this(routeBaseProperties, null); + } + + /** + * Constructs a {@code TimeseriesRoute} that enforces namespace access control on the + * caller-supplied {@code namespaces} parameter of the cross-Thing endpoint. + * + * @param routeBaseProperties the base properties of the route. + * @param validatorFactory creates the namespace access validator; may be {@code null}, in which + * case no namespace access control is applied (matching {@code ThingSearchRoute}). + */ + public TimeseriesRoute(final RouteBaseProperties routeBaseProperties, + @Nullable final NamespaceAccessValidatorFactory validatorFactory) { + super(routeBaseProperties); + this.validatorFactory = validatorFactory; + } + + /** + * Builds the timeseries routes under {@code /timeseries/things/{id}}: the single-property + * {@code .../features/{f}/properties/{path}} shape and the multi-property {@code ?paths=...} shape. + * + * @param ctx the request context. + * @param dittoHeaders the (already authenticated) Ditto headers carrying the auth context. + * @return the route. + */ + public Route buildTimeseriesRoute(final RequestContext ctx, final DittoHeaders dittoHeaders) { + return rawPathPrefix(PathMatchers.slash().concat(PATH_TIMESERIES), () -> + rawPathPrefix(PathMatchers.slash().concat(PATH_THINGS), () -> + concat( + // GET /timeseries/things?namespaces=… (cross-Thing aggregation). + // Must come first: the collection URL has no trailing segment, so it + // would otherwise never be reached past the segment() matcher below. + pathEndOrSingleSlash(() -> crossThingRoute(ctx, dittoHeaders)), + rawPathPrefix(PathMatchers.slash().concat(PathMatchers.segment()), + thingIdString -> { + final ThingId thingId = ThingId.of(thingIdString); + return concat( + // GET /timeseries/things/{id}/features/{f}/properties/{path} + singlePropertyRoute(ctx, dittoHeaders, thingId), + // GET /timeseries/things/{id}?paths=,,... + pathEndOrSingleSlash(() -> + parameter(PARAM_PATHS, pathsCsv -> + runTimeseriesQuery(ctx, dittoHeaders, + thingId, + parsePathsParam(pathsCsv)))) + ); + }) + ) + ) + ); + } + + /** + * The cross-Thing aggregation route. + *

+ * Reads its parameters from {@code parameterMap} rather than nesting a + * {@code parameterOptional} per parameter: this endpoint takes eleven of them, and the nested + * form does not survive that (the single-Thing route below is already twelve levels deep). + */ + private Route crossThingRoute(final RequestContext ctx, final DittoHeaders dittoHeaders) { + return get(() -> parameterMap(params -> { + final RetrieveAggregatedTimeseries command = + buildRetrieveAggregatedTimeseries(params, dittoHeaders); + // Enumerating and caller-scoped, so guarded like ThingSearchRoute: without this a tenant + // could aggregate over a namespace NamespaceAccessConfig excludes. Checked before + // dispatch, so no work is provoked. + requireNamespaceAccessible(command.getQuery().getNamespace(), dittoHeaders); + if (parseTimeFormatIsMillis(Optional.ofNullable(optional(params, PARAM_TIME_FORMAT)))) { + return handlePerRequest(ctx, command, (responseValue, response) -> + response.withEntity(ContentTypes.APPLICATION_JSON, + convertTimestampsToMillis(responseValue).toString())); + } + return handlePerRequest(ctx, command); + })); + } + + private static RetrieveAggregatedTimeseries buildRetrieveAggregatedTimeseries( + final Map params, final DittoHeaders dittoHeaders) { + + // Anchor both relative bounds to a single "now" so from=now-1h,to=now spans exactly 1h. + final Instant now = Instant.now(); + final String namespace = required(params, PARAM_NAMESPACES); + // One namespace only, for now: storage is one collection per namespace, so spanning several + // means a multi-collection merge. Rejecting a list is better than silently reading the first. + if (namespace.indexOf(',') >= 0) { + throw invalidParam(PARAM_NAMESPACES, namespace, + "Cross-Thing aggregation is scoped to a single namespace; pass exactly one value."); + } + final List paths = parsePathsParam(required(params, PARAM_PATHS)); + final Instant from = parseTimeParam(PARAM_FROM, required(params, PARAM_FROM), now); + final Instant to = parseTimeParam(PARAM_TO, required(params, PARAM_TO), now); + final Duration step = parseStepParam(required(params, PARAM_STEP)); + final Aggregation aggregation = parseAggregationParam(required(params, PARAM_AGG)); + + final List groupBy = parseGroupByParam(optional(params, PARAM_GROUP_BY)); + final String filter = Optional.ofNullable(optional(params, PARAM_FILTER)) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .orElse(null); + final ZoneId timezone = Optional.ofNullable(optional(params, PARAM_TZ)) + .map(TimeseriesRoute::parseTimezoneParam) + .orElse(null); + final FillStrategy fillStrategy = Optional.ofNullable(optional(params, PARAM_FILL)) + .map(TimeseriesRoute::parseFillParam) + .orElse(null); + final Integer maxGroups = Optional.ofNullable(optional(params, PARAM_MAX_GROUPS)) + .map(raw -> parseIntegerParam(PARAM_MAX_GROUPS, raw)) + .orElse(null); + + // Semantic validation (bucketed-aggregation-only, positive step, group caps) lives in + // CrossThingTimeseriesQuery.of so HTTP, WebSocket and Connectivity reject the same inputs. + final CrossThingTimeseriesQuery query = CrossThingTimeseriesQuery.of(namespace, paths, from, + to, step, aggregation, groupBy, filter, timezone, fillStrategy, maxGroups); + return RetrieveAggregatedTimeseries.of(query, dittoHeaders); + } + + /** Parses {@code groupBy=tag:building,thingId} into its dimensions; absent/blank means none. */ + private static List parseGroupByParam(@Nullable final String raw) { + if (raw == null || raw.trim().isEmpty()) { + return List.of(); + } + final List dimensions = new ArrayList<>(); + for (final String token : raw.split(",")) { + if (!token.trim().isEmpty()) { + dimensions.add(GroupBy.parse(token)); + } + } + return dimensions; + } + + private static String required(final Map params, final String name) { + final String value = optional(params, name); + if (value == null) { + throw invalidParam(name, "", "Query parameter '" + name + "' is required."); + } + return value; + } + + @Nullable + private static String optional(final Map params, final String name) { + final String value = params.get(name); + if (value == null) { + return null; + } + final String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private Route singlePropertyRoute(final RequestContext ctx, final DittoHeaders dittoHeaders, + final ThingId thingId) { + return rawPathPrefix(PathMatchers.slash().concat(PATH_FEATURES), () -> + rawPathPrefix(PathMatchers.slash().concat(PathMatchers.segment()), + featureId -> rawPathPrefix( + PathMatchers.slash() + .concat(PATH_PROPERTIES) + .concat(PathMatchers.slash()) + .concat(PathMatchers.remaining()) + .map(p -> UriEncoding.decode(p, UriEncoding.EncodingType.RFC3986)), + propertyPointerString -> { + // Re-assemble the full Ditto pointer so the command — and the + // read-path enforcement check — see the same resource key a normal + // RetrieveFeatureProperty would, identical across HTTP/WS/Connectivity. + final JsonPointer fullPath = JsonPointer.of( + "/features/" + featureId + "/properties/" + propertyPointerString); + return runTimeseriesQuery(ctx, dittoHeaders, thingId, + Collections.singletonList(fullPath)); + } + ) + ) + ); + } + + /** + * Shared query handler: parses the common query parameters (time range, downsampling, + * aggregation, fill, timezone, percentile, limit, timeFormat) and dispatches a single + * {@link RetrieveTimeseries} carrying the given paths. The single-property and multi-property + * routes both funnel through here so their behaviour is identical. + */ + private Route runTimeseriesQuery(final RequestContext ctx, final DittoHeaders dittoHeaders, + final ThingId thingId, final List paths) { + return get(() -> parameter(PARAM_FROM, fromString -> + parameter(PARAM_TO, toString -> + parameterOptional(PARAM_LIMIT, limitOpt -> + parameterOptional(PARAM_STEP, stepOpt -> + parameterOptional(PARAM_AGG, aggOpt -> + parameterOptional(PARAM_FILL, fillOpt -> + parameterOptional(PARAM_TZ, tzOpt -> + parameterOptional(PARAM_PERCENTILE, pctOpt -> + parameterOptional(PARAM_CURSOR, cursorOpt -> + parameterOptional(PARAM_ORDER, orderOpt -> + parameterOptional(PARAM_TAG_FILTER, tagFilterOpt -> + parameterOptional(PARAM_TIME_FORMAT, tfOpt -> + dispatchQuery(ctx, dittoHeaders, thingId, + paths, fromString, toString, + limitOpt, stepOpt, aggOpt, + fillOpt, tzOpt, pctOpt, + cursorOpt, orderOpt, tagFilterOpt, tfOpt) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + )); + } + + private Route dispatchQuery(final RequestContext ctx, final DittoHeaders dittoHeaders, + final ThingId thingId, final List paths, final String fromString, + final String toString, final Optional limitOpt, final Optional stepOpt, + final Optional aggOpt, final Optional fillOpt, final Optional tzOpt, + final Optional pctOpt, final Optional cursorOpt, + final Optional orderOpt, final Optional tagFilterOpt, + final Optional timeFormatOpt) { + + final RetrieveTimeseries command = buildRetrieveTimeseries(thingId, paths, fromString, toString, + limitOpt, stepOpt, aggOpt, fillOpt, tzOpt, pctOpt, cursorOpt, orderOpt, tagFilterOpt, + dittoHeaders); + if (parseTimeFormatIsMillis(timeFormatOpt)) { + // timeFormat=ms: render timestamps as epoch milliseconds. The model stays ISO-canonical; + // the conversion is a presentation transform applied only at the HTTP edge. + return handlePerRequest(ctx, command, (responseValue, response) -> + response.withEntity(ContentTypes.APPLICATION_JSON, + convertTimestampsToMillis(responseValue).toString())); + } + return handlePerRequest(ctx, command); + } + + private static RetrieveTimeseries buildRetrieveTimeseries(final ThingId thingId, + final List paths, + final String fromString, + final String toString, + final Optional limitOpt, + final Optional stepOpt, + final Optional aggOpt, + final Optional fillOpt, + final Optional tzOpt, + final Optional percentileOpt, + final Optional cursorOpt, + final Optional orderOpt, + final Optional tagFilterOpt, + final DittoHeaders dittoHeaders) { + // Anchor both relative bounds to a single "now" so e.g. from=now-1h, to=now span exactly 1h. + final Instant now = Instant.now(); + final Instant from = parseTimeParam(PARAM_FROM, fromString, now); + final Instant to = parseTimeParam(PARAM_TO, toString, now); + final Integer limit = limitOpt.map(s -> parseIntegerParam(PARAM_LIMIT, s)).orElse(null); + final Duration step = stepOpt.map(TimeseriesRoute::parseStepParam).orElse(null); + final Aggregation aggregation = aggOpt.map(TimeseriesRoute::parseAggregationParam).orElse(null); + final FillStrategy fillStrategy = fillOpt.map(TimeseriesRoute::parseFillParam).orElse(null); + final ZoneId timezone = tzOpt.map(TimeseriesRoute::parseTimezoneParam).orElse(null); + final Double percentile = percentileOpt.map(TimeseriesRoute::parsePercentileParam).orElse(null); + // The cursor is an opaque blob passed straight through; its format and the "cursor only for a + // single-path raw read" rule are validated in TimeseriesQuery.of so every transport agrees. + final String cursor = cursorOpt.map(String::trim).filter(s -> !s.isEmpty()).orElse(null); + final SortOrder order = orderOpt.map(String::trim).filter(s -> !s.isEmpty()) + .map(TimeseriesRoute::parseOrderParam).orElse(null); + final Map tagFilters = tagFilterOpt.map(String::trim).filter(s -> !s.isEmpty()) + .map(TimeseriesRoute::parseTagFiltersParam).orElseGet(Map::of); + // Semantic validation (e.g. percentile requires a value, group aggregations require a step, + // order=desc only for raw reads) lives in TimeseriesQuery.of so it applies uniformly across + // HTTP / WebSocket / Connectivity. + final TimeseriesQuery query = TimeseriesQuery.of( + thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, percentile, + cursor, order); + final TimeseriesQuery queryWithTags = tagFilters.isEmpty() ? query : query.withTagFilters(tagFilters); + return RetrieveTimeseries.of(queryWithTags, dittoHeaders); + } + + /** Parses an absolute ISO-8601 instant or a relative expression ({@code now}, {@code now-24h}). */ + private static Instant parseTimeParam(final String name, final String value, final Instant now) { + final String v = value.trim(); + if ("now".equals(v)) { + return now; + } + if (v.length() > 4 && (v.startsWith("now-") || v.startsWith("now+"))) { + final boolean minus = v.charAt(3) == '-'; + final Duration offset = parseShortDuration(name, v.substring(4)); + return minus ? now.minus(offset) : now.plus(offset); + } + try { + return Instant.parse(v); + } catch (final DateTimeParseException e) { + // Surface the same shape used elsewhere in Ditto for query-string parse failures so + // clients get a uniform 400 instead of a 500 / generic stack trace. + throw invalidParam(name, value, + "Expected an ISO-8601 instant (e.g. \"2026-01-15T10:30:00Z\") or a relative " + + "expression (e.g. \"now\", \"now-24h\")."); + } + } + + /** Parses a {@code step} such as {@code 30s}, {@code 5m}, {@code 1h}, {@code 1d} or ISO-8601 ({@code PT1H}). */ + private static Duration parseStepParam(final String value) { + return parseShortDuration(PARAM_STEP, value); + } + + private static Duration parseShortDuration(final String name, final String raw) { + final String v = raw.trim(); + final Matcher matcher = SHORT_DURATION.matcher(v); + if (matcher.matches()) { + final long n = Long.parseLong(matcher.group(1)); + return switch (matcher.group(2)) { + case "s" -> Duration.ofSeconds(n); + case "m" -> Duration.ofMinutes(n); + case "h" -> Duration.ofHours(n); + case "d" -> Duration.ofDays(n); + case "w" -> Duration.ofDays(7 * n); + default -> throw invalidParam(name, raw, "Unsupported duration unit."); + }; + } + try { + return Duration.parse(v); + } catch (final DateTimeParseException e) { + throw invalidParam(name, raw, + "Expected a duration like \"30s\", \"5m\", \"1h\", \"1d\" or ISO-8601 (e.g. \"PT1H\")."); + } + } + + private static Aggregation parseAggregationParam(final String value) { + return Aggregation.forName(value.trim()).orElseThrow(() -> invalidParam(PARAM_AGG, value, + "Expected one of: avg, min, max, sum, count, first, last, derivative, rate, " + + "integral, stddev, percentile.")); + } + + private static FillStrategy parseFillParam(final String value) { + return FillStrategy.forName(value.trim()).orElseThrow(() -> invalidParam(PARAM_FILL, value, + "Expected one of: null, previous, zero.")); + } + + private static ZoneId parseTimezoneParam(final String value) { + try { + return ZoneId.of(value.trim()); + } catch (final DateTimeException e) { + throw invalidParam(PARAM_TZ, value, + "Expected an IANA time-zone ID, e.g. \"Europe/Berlin\" or \"UTC\"."); + } + } + + private static SortOrder parseOrderParam(final String value) { + return SortOrder.forName(value.trim()).orElseThrow(() -> invalidParam(PARAM_ORDER, value, + "Expected one of: asc, desc.")); + } + + /** + * Parses the {@code tagFilter} parameter — a comma-separated list of {@code key:value} pairs + * (split on the first colon so values may contain colons) — into a tag-filter map. + */ + private static Map parseTagFiltersParam(final String csv) { + final Map filters = new LinkedHashMap<>(); + for (final String raw : csv.split(",")) { + final String pair = raw.trim(); + if (pair.isEmpty()) { + continue; + } + final int colon = pair.indexOf(':'); + if (colon <= 0 || colon == pair.length() - 1) { + throw invalidParam(PARAM_TAG_FILTER, csv, + "Expected comma-separated key:value pairs, e.g. building:A,floor:2."); + } + filters.put(pair.substring(0, colon).trim(), pair.substring(colon + 1).trim()); + } + if (filters.isEmpty()) { + throw invalidParam(PARAM_TAG_FILTER, csv, + "Expected at least one key:value pair, e.g. building:A."); + } + return filters; + } + + private static Double parsePercentileParam(final String value) { + try { + // Parse only; the 0-100 range check lives in TimeseriesQuery.of so every transport + // reports the same timeseries:query.invalid error rather than a transport-specific one. + return Double.parseDouble(value.trim()); + } catch (final NumberFormatException e) { + throw invalidParam(PARAM_PERCENTILE, value, "Expected a number between 0 and 100."); + } + } + + /** Parses the comma-separated {@code paths} parameter into a non-empty list of JSON pointers. */ + private static List parsePathsParam(final String csv) { + final List paths = new ArrayList<>(); + for (final String raw : csv.split(",")) { + final String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + paths.add(JsonPointer.of(trimmed)); + } + } + if (paths.isEmpty()) { + throw invalidParam(PARAM_PATHS, csv, "Expected one or more comma-separated JSON pointers, " + + "e.g. /features/env/properties/temperature,/attributes/battery."); + } + return paths; + } + + /** Returns {@code true} for {@code timeFormat=ms}, {@code false} for {@code iso} (the default). */ + private static boolean parseTimeFormatIsMillis(final Optional timeFormatOpt) { + final String value = timeFormatOpt.map(String::trim).filter(s -> !s.isEmpty()).orElse("iso"); + switch (value) { + case "iso": + return false; + case "ms": + return true; + default: + throw invalidParam(PARAM_TIME_FORMAT, value, "Expected \"iso\" (default) or \"ms\"."); + } + } + + /** + * Rewrites every {@code data[].t} timestamp in the response entity from an ISO-8601 string to + * epoch milliseconds. The entity is the per-path results array + * ({@code [{path, result, data:[{t, v}]}, ...]}). + */ + private static JsonValue convertTimestampsToMillis(final JsonValue entity) { + // The cross-Thing endpoint's body is an object ({results, authorization}) rather than the + // single-Thing bare array, so unwrap it, convert the series, and put it back — otherwise + // timeFormat=ms would silently pass the object through unconverted. + if (entity.isObject()) { + final JsonObject entityObject = entity.asObject(); + return entityObject.getValue(FIELD_RESULTS) + .filter(JsonValue::isArray) + .map(results -> (JsonValue) entityObject.setValue(FIELD_RESULTS, + convertTimestampsToMillis(results))) + .orElse(entity); + } + if (!entity.isArray()) { + return entity; + } + final JsonArrayBuilder seriesBuilder = JsonFactory.newArrayBuilder(); + for (final JsonValue seriesValue : entity.asArray()) { + seriesBuilder.add(seriesValue.isObject() + ? convertSeriesTimestamps(seriesValue.asObject()) + : seriesValue); + } + return seriesBuilder.build(); + } + + private static JsonObject convertSeriesTimestamps(final JsonObject series) { + final Optional dataOpt = + series.getValue("data").filter(JsonValue::isArray).map(JsonValue::asArray); + if (dataOpt.isEmpty()) { + return series; + } + final JsonArrayBuilder dataBuilder = JsonFactory.newArrayBuilder(); + for (final JsonValue pointValue : dataOpt.get()) { + dataBuilder.add(pointValue.isObject() ? convertPointTimestamp(pointValue.asObject()) : pointValue); + } + return series.setValue("data", dataBuilder.build()); + } + + private static JsonObject convertPointTimestamp(final JsonObject point) { + return point.getValue("t") + .filter(JsonValue::isString) + .map(JsonValue::asString) + .map(iso -> point.setValue("t", Instant.parse(iso).toEpochMilli())) + .orElse(point); + } + + /** Same idea for integer parameters. */ + private static Integer parseIntegerParam(final String name, final String value) { + try { + return Integer.parseInt(value.trim()); + } catch (final NumberFormatException e) { + throw invalidParam(name, value, "Expected an integer."); + } + } + + /** Builds the standard Ditto 400 error shape for an invalid query parameter. */ + private static org.eclipse.ditto.json.JsonParseException invalidParam(final String name, + final String value, final String description) { + return org.eclipse.ditto.json.JsonParseException.newBuilder() + .message("Query parameter <" + name + "> has an invalid value: <" + value + ">.") + .description(description) + .build(); + } + + /** + * Rejects a namespace the caller may not access, mirroring + * {@code ThingSearchRoute.applyNamespaceAccessControl}. A {@code null} factory means the feature is + * not configured, in which case every namespace is permitted. + */ + private void requireNamespaceAccessible(final String namespace, final DittoHeaders dittoHeaders) { + if (validatorFactory == null) { + return; + } + final NamespaceAccessValidator validator = + validatorFactory.createValidator(dittoHeaders, ThingConstants.ENTITY_TYPE.toString()); + if (!validator.isNamespaceAccessible(namespace)) { + throw NamespaceNotAccessibleException.forNamespace(namespace, dittoHeaders); + } + } + +} diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/starter/GatewayRootActor.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/starter/GatewayRootActor.java index 14670dd3e16..666dce38159 100755 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/starter/GatewayRootActor.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/starter/GatewayRootActor.java @@ -53,6 +53,7 @@ import org.eclipse.ditto.gateway.service.endpoints.routes.status.OverallStatusRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.things.ThingsRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.thingsearch.ThingSearchRoute; +import org.eclipse.ditto.gateway.service.endpoints.routes.timeseries.TimeseriesRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.websocket.WebSocketRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.whoami.WhoamiRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.wot.WotDiscoveryThingDirectoryRoute; @@ -282,6 +283,7 @@ private static Route createRoute(final ActorSystem actorSystem, gatewayConfig.getMessageConfig(), gatewayConfig.getClaimMessageConfig(), namespaceAccessDirective)) + .timeseriesRoute(new TimeseriesRoute(routeBaseProperties, namespaceAccessValidatorFactory)) .connectionsRoute(new ConnectionsRoute(routeBaseProperties, devopsAuthenticationDirective)) .thingSearchRoute(new ThingSearchRoute(routeBaseProperties, namespaceAccessValidatorFactory)) .whoamiRoute(new WhoamiRoute(routeBaseProperties)) diff --git a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/util/GatewayMappingStrategies.java b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/util/GatewayMappingStrategies.java index 33426f2e62c..8b3bd6e6959 100644 --- a/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/util/GatewayMappingStrategies.java +++ b/gateway/service/src/main/java/org/eclipse/ditto/gateway/service/util/GatewayMappingStrategies.java @@ -26,6 +26,7 @@ import org.eclipse.ditto.policies.api.PoliciesMappingStrategies; import org.eclipse.ditto.things.api.ThingsMappingStrategies; import org.eclipse.ditto.thingsearch.api.ThingSearchMappingStrategies; +import org.eclipse.ditto.timeseries.api.TimeseriesMappingStrategies; /** * {@link MappingStrategies} for the Gateway service containing all {@link Jsonifiable} types known to Gateway. @@ -69,6 +70,7 @@ private static MappingStrategies getGatewayMappingStrategies() { .putAll(PoliciesMappingStrategies.getInstance()) .putAll(ThingSearchMappingStrategies.getInstance()) .putAll(ConnectivityMappingStrategies.getInstance()) + .putAll(TimeseriesMappingStrategies.getInstance()) .putAll(GlobalMappingStrategies.getInstance()) .build(); } diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteTest.java index 67ffed07465..80b4a3889a3 100755 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/RootRouteTest.java @@ -56,6 +56,7 @@ import org.eclipse.ditto.gateway.service.endpoints.routes.things.ThingsParameter; import org.eclipse.ditto.gateway.service.endpoints.routes.things.ThingsRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.thingsearch.ThingSearchRoute; +import org.eclipse.ditto.gateway.service.endpoints.routes.timeseries.TimeseriesRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.websocket.WebSocketRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.whoami.WhoamiRoute; import org.eclipse.ditto.gateway.service.endpoints.routes.wot.WotDiscoveryThingDirectoryRoute; @@ -164,6 +165,7 @@ public void setUp() { streamingConfig, routeBaseProperties.getProxyActor(), httpHeaderTranslator)) .thingsRoute(new ThingsRoute(routeBaseProperties, messageConfig, claimMessageConfig)) + .timeseriesRoute(new TimeseriesRoute(routeBaseProperties)) .thingSearchRoute(new ThingSearchRoute(routeBaseProperties)) .whoamiRoute(new WhoamiRoute(routeBaseProperties)) .checkPermissionsRoute(new CheckPermissionsRoute(routeBaseProperties)) diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRouteCrossThingTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRouteCrossThingTest.java new file mode 100644 index 00000000000..cc7ae470a19 --- /dev/null +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/endpoints/routes/timeseries/TimeseriesRouteCrossThingTest.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.gateway.service.endpoints.routes.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.pekko.http.javadsl.model.HttpRequest; +import org.apache.pekko.http.javadsl.model.StatusCodes; +import org.apache.pekko.http.javadsl.server.Route; +import org.apache.pekko.http.javadsl.testkit.TestRoute; +import org.eclipse.ditto.base.model.headers.WithDittoHeaders; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.gateway.service.endpoints.EndpointTestBase; +import org.eclipse.ditto.gateway.service.security.authorization.NamespaceAccessValidatorFactory; +import org.eclipse.ditto.gateway.service.util.config.security.DefaultNamespaceAccessConfig; +import org.eclipse.ditto.gateway.service.util.config.security.NamespaceAccessConfig; + +import com.typesafe.config.ConfigFactory; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the cross-Thing aggregation route {@code GET /timeseries/things}. + *

+ * The focus is the HTTP edge: which requests are accepted, which are rejected before a command is + * ever built, and that an accepted request produces a {@code retrieveAggregatedTimeseries} command + * carrying the parsed query. The semantic rules themselves live on + * {@code CrossThingTimeseriesQuery} and are covered by its own tests; here we only check that the + * route surfaces them as {@code 400}s rather than {@code 500}s. + */ +public final class TimeseriesRouteCrossThingTest extends EndpointTestBase { + + private static final String BASE = "/timeseries/things"; + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final String PATH = "/features/circuit/properties/flowTemperature"; + + private static final Function, Optional> ECHO_RESPONSE_PROVIDER = + m -> DummyThingModifyCommandResponse.echo((Jsonifiable) () -> { + if (m instanceof WithDittoHeaders) { + return JsonObject.newBuilder().set("payload", m.toJson()).build(); + } + return m.toJson(); + }); + + private TestRoute underTest; + + @Override + protected Function, Optional> getResponseProvider() { + return ECHO_RESPONSE_PROVIDER; + } + + @Before + public void setUp() { + // Wire the namespace access validator exactly as GatewayRootActor does. Constructing the + // route without it (the one-arg overload passes null) would disable namespace access + // control for every case below, so the endpoint's first authorization layer would go + // unexercised while the tests still passed. + underTest = testRouteWithNamespaceAccess(List.of(NAMESPACE)); + } + + private TestRoute testRouteWithNamespaceAccess(final List allowedNamespaces) { + final NamespaceAccessValidatorFactory validatorFactory = + new NamespaceAccessValidatorFactory(List.of(namespaceAccessConfig(allowedNamespaces))); + final TimeseriesRoute timeseriesRoute = new TimeseriesRoute(routeBaseProperties, validatorFactory); + final Route route = + extractRequestContext(ctx -> timeseriesRoute.buildTimeseriesRoute(ctx, dittoHeaders)); + return testRoute(handleExceptions(() -> route)); + } + + private static NamespaceAccessConfig namespaceAccessConfig(final List allowedNamespaces) { + return DefaultNamespaceAccessConfig.of(ConfigFactory.parseString(String.format( + "{ conditions = [], allowed-namespaces = [%s], blocked-namespaces = [] }", + allowedNamespaces.stream() + .map(item -> "\"" + item + "\"") + .collect(Collectors.joining(", "))))); + } + + private static String url(final String query) { + return BASE + "?" + query; + } + + private static String validQuery() { + return "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z&step=1h&agg=avg"; + } + + @Test + public void validRequestBuildsAggregatedCommand() { + final var result = underTest.run(HttpRequest.GET(url(validQuery()))); + + result.assertStatusCode(StatusCodes.OK); + result.assertEntity(JsonObject.newBuilder() + .set("payload", JsonObject.newBuilder() + .set("type", "timeseries.commands:retrieveAggregatedTimeseries") + .set("query", JsonObject.newBuilder() + .set("namespace", NAMESPACE) + .set("paths", JsonArray.newBuilder() + .add(PATH) + .build()) + .set("from", "2026-07-01T00:00:00Z") + .set("to", "2026-07-02T00:00:00Z") + .set("step", "PT1H") + .set("aggregation", "avg") + .build()) + .build()) + .build() + .toString()); + } + + @Test + public void groupByAndRqlFilterAreParsed() { + final var result = underTest.run(HttpRequest.GET( + url(validQuery() + "&groupBy=attributes/building,thingId" + + "&filter=" + urlEncode("eq(attributes/building,'A')")))); + + result.assertStatusCode(StatusCodes.OK); + final String entity = result.entityString(); + assertThat(entity).contains("\"groupBy\""); + // A tag dimension is named by its Thing path; the wire form keeps the tag: discriminator. + assertThat(entity).contains("attributes/building"); + assertThat(entity).contains("thingId"); + assertThat(entity).contains("\"filter\""); + } + + private static String urlEncode(final String value) { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } + + @Test + public void relativeTimeExpressionsAreAccepted() { + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=now-24h&to=now&step=1h&agg=avg"))); + + result.assertStatusCode(StatusCodes.OK); + } + + @Test + public void missingNamespacesIsBadRequest() { + final var result = underTest.run(HttpRequest.GET(url( + "paths=" + PATH + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z" + + "&step=1h&agg=avg"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + public void missingStepIsBadRequest() { + // step is required here (unlike the single-Thing endpoints) because a cross-Thing raw read + // would be unbounded. + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z&agg=avg"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + public void missingAggIsBadRequest() { + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z&step=1h"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + public void multipleNamespacesAreRejected() { + // Silently reading only the first namespace would be worse than refusing. + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + ",other.namespace&paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z&step=1h&agg=avg"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + public void windowFunctionAggregationIsRejected() { + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z&step=1h&agg=derivative"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + /** + * A non-reserved groupBy value is a tag dimension named by its Thing path, so it is accepted at the + * route and authorized in the service (which requires READ on the field). What used to be an + * "unknown dimension" 400 is therefore no longer one; blank tokens are skipped rather than + * rejected, so a groupBy of only separators simply means "no grouping". + */ + public void blankGroupByTokensAreIgnored() { + final var result = underTest.run(HttpRequest.GET(url(validQuery() + "&groupBy=,"))); + + result.assertStatusCode(StatusCodes.OK); + assertThat(result.entityString()).doesNotContain("\"groupBy\""); + } + + @Test + public void maxGroupsAboveCeilingIsRejected() { + final var result = underTest.run(HttpRequest.GET(url(validQuery() + "&maxGroups=99999"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + @Test + public void unparseableFromIsRejected() { + final var result = underTest.run(HttpRequest.GET(url( + "namespaces=" + NAMESPACE + "&paths=" + PATH + + "&from=yesterday&to=now&step=1h&agg=avg"))); + + result.assertStatusCode(StatusCodes.BAD_REQUEST); + } + + /** + * The collection route must not shadow the existing single-Thing route, which lives one segment + * deeper and requires {@code paths} but no {@code step}/{@code agg}. + */ + @Test + public void crossThingRejectsNamespaceOutsideTheAccessAllowList() { + // The caller-supplied `namespaces` parameter is enumerating, so it is guarded the same way + // ThingSearchRoute guards its own. Checked at the route, before any command is dispatched. + final TestRoute restricted = testRouteWithNamespaceAccess(List.of("org.eclipse.allowed")); + + final var result = restricted.run(HttpRequest.GET(url(validQuery()))); + + result.assertStatusCode(StatusCodes.FORBIDDEN); + assertThat(result.entityString()).contains("gateway:namespace.notaccessible"); + } + + @Test + public void crossThingPassesThroughNamespaceInsideTheAccessAllowList() { + final TestRoute permitted = testRouteWithNamespaceAccess(List.of(NAMESPACE)); + + final var result = permitted.run(HttpRequest.GET(url(validQuery()))); + + result.assertStatusCode(StatusCodes.OK); + assertThat(result.entityString()) + .contains("timeseries.commands:retrieveAggregatedTimeseries"); + } + + @Test + public void singleThingRouteStillResolves() { + final var result = underTest.run(HttpRequest.GET( + "/timeseries/things/" + NAMESPACE + ":heatsource-1?paths=" + PATH + + "&from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z")); + + result.assertStatusCode(StatusCodes.OK); + assertThat(result.entityString()) + .contains("timeseries.commands:retrieveTimeseries"); + } +} diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandRegistryTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandRegistryTest.java index d4345d12ef4..bb1d7b70f85 100644 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandRegistryTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandRegistryTest.java @@ -44,6 +44,8 @@ import org.eclipse.ditto.thingsearch.api.commands.sudo.SudoCountThings; import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThings; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.CreateSubscription; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; public final class GatewayServiceGlobalCommandRegistryTest extends GlobalCommandRegistryTestCases { @@ -79,7 +81,9 @@ public GatewayServiceGlobalCommandRegistryTest() { RetrieveMergedWotValidationConfig.class, RetrieveWotValidationConfig.class, ModifyWotValidationConfig.class, - RetrieveWotDiscoveryThingDirectory.class + RetrieveWotDiscoveryThingDirectory.class, + RetrieveTimeseries.class, + IngestDataPoints.class ); } diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandResponseRegistryTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandResponseRegistryTest.java index a9fafe5e73d..093c43a1d5f 100644 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandResponseRegistryTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalCommandResponseRegistryTest.java @@ -45,6 +45,8 @@ import org.eclipse.ditto.thingsearch.api.commands.sudo.SudoRetrieveNamespaceReportResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.SearchErrorResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThingsResponse; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; public final class GatewayServiceGlobalCommandResponseRegistryTest extends GlobalCommandResponseRegistryTestCases { @@ -81,7 +83,9 @@ public GatewayServiceGlobalCommandResponseRegistryTest() { RetrieveMergedWotValidationConfigResponse.class, RetrieveWotValidationConfigResponse.class, CreateWotValidationConfigResponse.class, - RetrieveWotDiscoveryThingDirectoryResponse.class + RetrieveWotDiscoveryThingDirectoryResponse.class, + RetrieveTimeseriesResponse.class, + IngestDataPointsResponse.class ); excludeKnownNotAnnotatedClass("org.eclipse.ditto.gateway.service.endpoints.EndpointTestBase$DummyThingModifyCommandResponse"); } diff --git a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalErrorRegistryTest.java b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalErrorRegistryTest.java index 05c1bf30eda..bbb42106259 100644 --- a/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalErrorRegistryTest.java +++ b/gateway/service/src/test/java/org/eclipse/ditto/gateway/service/starter/GatewayServiceGlobalErrorRegistryTest.java @@ -46,6 +46,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.AttributePointerInvalidException; import org.eclipse.ditto.thingsearch.api.QueryTimeExceededException; import org.eclipse.ditto.thingsearch.model.signals.commands.exceptions.InvalidNamespacesException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; import org.eclipse.ditto.wot.model.WotThingModelInvalidException; import org.eclipse.ditto.wot.validation.WotThingModelPayloadValidationException; @@ -87,7 +88,8 @@ public GatewayServiceGlobalErrorRegistryTest() { WotThingModelInvalidException.class, ServiceTimeoutException.class, WotThingModelPayloadValidationException.class, - WotValidationConfigNotAccessibleException.class + WotValidationConfigNotAccessibleException.class, + TimeseriesQueryInvalidException.class ); } diff --git a/internal/utils/config/src/main/resources/ditto-devops.conf b/internal/utils/config/src/main/resources/ditto-devops.conf index 383f6d679de..2b16799833e 100644 --- a/internal/utils/config/src/main/resources/ditto-devops.conf +++ b/internal/utils/config/src/main/resources/ditto-devops.conf @@ -67,5 +67,13 @@ ditto.devops { // already considers namespace-scoped root policies and is unaffected by this toggle. policy-lockout-prevention-enabled = true policy-lockout-prevention-enabled = ${?DITTO_DEVOPS_FEATURE_POLICY_LOCKOUT_PREVENTION_ENABLED} + + // enables/disables the timeseries feature (Phase 1: WoT-driven ingest on the things-service + // write path + RetrieveTimeseries query path on the timeseries-service). + // When disabled, things-service skips starting the TimeseriesIngestPublisher (no per-event + // WoT-resolution overhead) and RetrieveTimeseries commands routed through the edge-forwarder + // are rejected with UnsupportedSignalException. + timeseries-enabled = true + timeseries-enabled = ${?DITTO_DEVOPS_FEATURE_TIMESERIES_ENABLED} } } diff --git a/pom.xml b/pom.xml index 9d589fbec91..8b6de931189 100644 --- a/pom.xml +++ b/pom.xml @@ -110,6 +110,7 @@ rql things thingsearch + timeseries utils wot edge diff --git a/protocol/pom.xml b/protocol/pom.xml index 4ccc1959e6a..33f27b15408 100755 --- a/protocol/pom.xml +++ b/protocol/pom.xml @@ -53,6 +53,10 @@ org.eclipse.ditto ditto-thingsearch-model + + org.eclipse.ditto + ditto-timeseries-model + org.eclipse.ditto ditto-connectivity-model diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java index 9117c84a7ac..45cd1b1fc49 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/ImmutableTopicPath.java @@ -254,7 +254,7 @@ public String toString() { private static final class Builder implements TopicPathBuilder, MessagesTopicPathBuilder, EventsTopicPathBuilder, CommandsTopicPathBuilder, AcknowledgementTopicPathBuilder, SearchTopicPathBuilder, AnnouncementsTopicPathBuilder, - StreamingTopicPathBuilder { + StreamingTopicPathBuilder, TimeseriesTopicPathBuilder { private final String namespace; private final String name; @@ -327,6 +327,12 @@ public CommandsTopicPathBuilder commands() { return this; } + @Override + public TimeseriesTopicPathBuilder timeseries() { + criterion = Criterion.TIMESERIES; + return this; + } + @Override public AnnouncementsTopicPathBuilder announcements() { criterion = Criterion.ANNOUNCEMENTS; @@ -587,6 +593,7 @@ public ImmutableTopicPath get() { switch (topicPathBuilder.criterion) { case COMMANDS: case EVENTS: + case TIMESERIES: topicPathBuilder.action = tryToGetActionForName(tryToGetActionName()); break; case SEARCH: diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/TimeseriesTopicPathBuilder.java b/protocol/src/main/java/org/eclipse/ditto/protocol/TimeseriesTopicPathBuilder.java new file mode 100644 index 00000000000..bc95bf58b21 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/TimeseriesTopicPathBuilder.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol; + +/** + * Builder for the timeseries criterion of a {@link TopicPath} (Phase 1 carries only the {@code + * retrieve} action, future phases will add aggregation actions). Reached through + * {@link TopicPathBuilder#timeseries()}. + * + * @since 4.0.0 + */ +public interface TimeseriesTopicPathBuilder extends TopicPathBuildable { + + /** + * Sets the {@code Action} of this builder to {@link TopicPath.Action#RETRIEVE}. A previously + * set action is replaced. + * + * @return this builder to allow method chaining. + */ + TopicPathBuildable retrieve(); +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java index b1a34e49f14..4dd2c38fabf 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPath.java @@ -308,7 +308,14 @@ enum Criterion { * * @since 3.2.0 */ - STREAMING("streaming"); + STREAMING("streaming"), + + /** + * Criterion for timeseries-API commands and responses. + * + * @since 4.0.0 + */ + TIMESERIES("timeseries"); private final String name; @@ -429,7 +436,14 @@ enum Action { * * @since 3.9.0 */ - CHECK_PERMISSIONS("checkPermissions"); + CHECK_PERMISSIONS("checkPermissions"), + + /** + * Action for aggregating timeseries data across many entities. + * + * @since 4.0.0 + */ + AGGREGATE("aggregate"); private final String name; diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPathBuilder.java b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPathBuilder.java index 31532183d0a..beaec23f9d1 100755 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPathBuilder.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/TopicPathBuilder.java @@ -56,6 +56,15 @@ public interface TopicPathBuilder { */ CommandsTopicPathBuilder commands(); + /** + * Sets the {@code Criterion} of this builder to {@link TopicPath.Criterion#TIMESERIES}. A previously set criterion + * is replaced. + * + * @return this builder to allow method chaining. + * @since 4.0.0 + */ + TimeseriesTopicPathBuilder timeseries(); + /** * Sets the {@code Criterion} of this builder to {@link TopicPath.Criterion#ANNOUNCEMENTS}. * diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java index 1c35a56f656..996db70aa9d 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/AdapterResolverBySignal.java @@ -43,6 +43,14 @@ import org.eclipse.ditto.protocol.UnknownSignalException; import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandAdapter; import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandResponseAdapter; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; import org.eclipse.ditto.protocol.adapter.connectivity.ConnectivityCommandAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.AcknowledgementAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.PolicyCommandAdapterProvider; @@ -75,6 +83,10 @@ final class AdapterResolverBySignal { private final StreamingSubscriptionEventAdapter streamingSubscriptionEventAdapter; private final CheckPermissionsCommandAdapter checkPermissionsCommandAdapter; private final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter; + private final TimeseriesQueryCommandAdapter timeseriesQueryCommandAdapter; + private final TimeseriesQueryCommandResponseAdapter timeseriesQueryCommandResponseAdapter; + private final TimeseriesAggregateCommandAdapter timeseriesAggregateCommandAdapter; + private final TimeseriesAggregateCommandResponseAdapter timeseriesAggregateCommandResponseAdapter; AdapterResolverBySignal(final ThingCommandAdapterProvider thingsAdapters, final PolicyCommandAdapterProvider policiesAdapters, @@ -83,7 +95,11 @@ final class AdapterResolverBySignal { final StreamingSubscriptionCommandAdapter streamingSubscriptionCommandAdapter, final StreamingSubscriptionEventAdapter streamingSubscriptionEventAdapter, final CheckPermissionsCommandAdapter checkPermissionsCommandAdapter, - final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter) { + final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter, + final TimeseriesQueryCommandAdapter timeseriesQueryCommandAdapter, + final TimeseriesQueryCommandResponseAdapter timeseriesQueryCommandResponseAdapter, + final TimeseriesAggregateCommandAdapter timeseriesAggregateCommandAdapter, + final TimeseriesAggregateCommandResponseAdapter timeseriesAggregateCommandResponseAdapter) { this.thingsAdapters = thingsAdapters; this.policiesAdapters = policiesAdapters; @@ -93,6 +109,10 @@ final class AdapterResolverBySignal { this.streamingSubscriptionEventAdapter = streamingSubscriptionEventAdapter; this.checkPermissionsCommandAdapter = checkPermissionsCommandAdapter; this.checkPermissionsCommandResponseAdapter = checkPermissionsCommandResponseAdapter; + this.timeseriesQueryCommandAdapter = timeseriesQueryCommandAdapter; + this.timeseriesQueryCommandResponseAdapter = timeseriesQueryCommandResponseAdapter; + this.timeseriesAggregateCommandAdapter = timeseriesAggregateCommandAdapter; + this.timeseriesAggregateCommandResponseAdapter = timeseriesAggregateCommandResponseAdapter; } @SuppressWarnings("unchecked") @@ -194,6 +214,16 @@ private > Adapter resolveCommandResponse( return (Adapter) checkPermissionsCommandResponseAdapter; } + if (commandResponse instanceof RetrieveTimeseriesResponse) { + validateChannel(channel, commandResponse, TWIN); + return (Adapter) timeseriesQueryCommandResponseAdapter; + } + + if (commandResponse instanceof RetrieveAggregatedTimeseriesResponse) { + validateChannel(channel, commandResponse, TWIN); + return (Adapter) timeseriesAggregateCommandResponseAdapter; + } + if (commandResponse instanceof PolicyModifyCommandResponse) { validateChannel(channel, commandResponse, NONE); return (Adapter) policiesAdapters.getModifyCommandResponseAdapter(); @@ -273,6 +303,16 @@ private > Adapter resolveCommand(final Command command return (Adapter) checkPermissionsCommandAdapter; } + if (command instanceof RetrieveTimeseries) { + validateChannel(channel, command, TWIN); + return (Adapter) timeseriesQueryCommandAdapter; + } + + if (command instanceof RetrieveAggregatedTimeseries) { + validateChannel(channel, command, TWIN); + return (Adapter) timeseriesAggregateCommandAdapter; + } + if (command instanceof PolicyModifyCommand) { validateChannel(channel, command, NONE); return (Adapter) policiesAdapters.getModifyCommandAdapter(); diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DefaultAdapterResolver.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DefaultAdapterResolver.java index a432ddc8fdf..a9626c64e40 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DefaultAdapterResolver.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DefaultAdapterResolver.java @@ -30,6 +30,10 @@ import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandAdapter; import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandResponseAdapter; import org.eclipse.ditto.protocol.adapter.connectivity.ConnectivityCommandAdapterProvider; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandResponseAdapter; import org.eclipse.ditto.protocol.adapter.provider.AcknowledgementAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.PolicyCommandAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.ThingCommandAdapterProvider; @@ -49,7 +53,11 @@ final class DefaultAdapterResolver implements AdapterResolver { final StreamingSubscriptionCommandAdapter streamingSubscriptionCommandAdapter, final StreamingSubscriptionEventAdapter streamingSubscriptionEventAdapter, final CheckPermissionsCommandAdapter checkPermissionsCommandAdapter, - final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter) { + final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter, + final TimeseriesQueryCommandAdapter timeseriesQueryCommandAdapter, + final TimeseriesQueryCommandResponseAdapter timeseriesQueryCommandResponseAdapter, + final TimeseriesAggregateCommandAdapter timeseriesAggregateCommandAdapter, + final TimeseriesAggregateCommandResponseAdapter timeseriesAggregateCommandResponseAdapter) { final List> adapters = new ArrayList<>(); adapters.addAll(thingsAdapters.getAdapters()); adapters.addAll(policiesAdapters.getAdapters()); @@ -59,10 +67,16 @@ final class DefaultAdapterResolver implements AdapterResolver { adapters.add(streamingSubscriptionEventAdapter); adapters.add(checkPermissionsCommandAdapter); adapters.add(checkPermissionsCommandResponseAdapter); + adapters.add(timeseriesQueryCommandAdapter); + adapters.add(timeseriesQueryCommandResponseAdapter); + adapters.add(timeseriesAggregateCommandAdapter); + adapters.add(timeseriesAggregateCommandResponseAdapter); resolver = computeResolver(adapters); resolverBySignal = new AdapterResolverBySignal(thingsAdapters, policiesAdapters, connectivityAdapters, acknowledgementAdapters, streamingSubscriptionCommandAdapter, streamingSubscriptionEventAdapter, - checkPermissionsCommandAdapter, checkPermissionsCommandResponseAdapter); + checkPermissionsCommandAdapter, checkPermissionsCommandResponseAdapter, + timeseriesQueryCommandAdapter, timeseriesQueryCommandResponseAdapter, + timeseriesAggregateCommandAdapter, timeseriesAggregateCommandResponseAdapter); } @Override diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapter.java index e85e5b654bf..b97a57a912f 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapter.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapter.java @@ -30,6 +30,10 @@ import org.eclipse.ditto.protocol.adapter.acknowledgements.DefaultAcknowledgementsAdapterProvider; import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandAdapter; import org.eclipse.ditto.protocol.adapter.common.CheckPermissionsCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandResponseAdapter; import org.eclipse.ditto.protocol.adapter.connectivity.ConnectivityCommandAdapterProvider; import org.eclipse.ditto.protocol.adapter.connectivity.DefaultConnectivityCommandAdapterProvider; import org.eclipse.ditto.protocol.adapter.policies.DefaultPolicyCommandAdapterProvider; @@ -53,6 +57,10 @@ public final class DittoProtocolAdapter implements ProtocolAdapter { private final StreamingSubscriptionEventAdapter streamingSubscriptionEventAdapter; private final CheckPermissionsCommandAdapter checkPermissionsCommandAdapter; private final CheckPermissionsCommandResponseAdapter checkPermissionsCommandResponseAdapter; + private final TimeseriesQueryCommandAdapter timeseriesQueryCommandAdapter; + private final TimeseriesQueryCommandResponseAdapter timeseriesQueryCommandResponseAdapter; + private final TimeseriesAggregateCommandAdapter timeseriesAggregateCommandAdapter; + private final TimeseriesAggregateCommandResponseAdapter timeseriesAggregateCommandResponseAdapter; private final AdapterResolver adapterResolver; private DittoProtocolAdapter(final ErrorRegistry errorRegistry, @@ -66,9 +74,16 @@ private DittoProtocolAdapter(final ErrorRegistry errorReg streamingSubscriptionEventAdapter = StreamingSubscriptionEventAdapter.of(headerTranslator, errorRegistry); checkPermissionsCommandAdapter = CheckPermissionsCommandAdapter.of(headerTranslator); checkPermissionsCommandResponseAdapter = CheckPermissionsCommandResponseAdapter.of(headerTranslator); + timeseriesQueryCommandAdapter = TimeseriesQueryCommandAdapter.of(headerTranslator); + timeseriesQueryCommandResponseAdapter = TimeseriesQueryCommandResponseAdapter.of(headerTranslator); + timeseriesAggregateCommandAdapter = TimeseriesAggregateCommandAdapter.of(headerTranslator); + timeseriesAggregateCommandResponseAdapter = + TimeseriesAggregateCommandResponseAdapter.of(headerTranslator); this.adapterResolver = new DefaultAdapterResolver(thingsAdapters, policiesAdapters, connectivityAdapters, acknowledgementAdapters, streamingSubscriptionCommandAdapter, streamingSubscriptionEventAdapter, - checkPermissionsCommandAdapter, checkPermissionsCommandResponseAdapter); + checkPermissionsCommandAdapter, checkPermissionsCommandResponseAdapter, + timeseriesQueryCommandAdapter, timeseriesQueryCommandResponseAdapter, + timeseriesAggregateCommandAdapter, timeseriesAggregateCommandResponseAdapter); } private DittoProtocolAdapter(final HeaderTranslator headerTranslator, @@ -90,6 +105,12 @@ private DittoProtocolAdapter(final HeaderTranslator headerTranslator, "streamingSubscriptionEventAdapter"); this.checkPermissionsCommandAdapter = CheckPermissionsCommandAdapter.of(headerTranslator); this.checkPermissionsCommandResponseAdapter = CheckPermissionsCommandResponseAdapter.of(headerTranslator); + this.timeseriesQueryCommandAdapter = TimeseriesQueryCommandAdapter.of(headerTranslator); + this.timeseriesQueryCommandResponseAdapter = + TimeseriesQueryCommandResponseAdapter.of(headerTranslator); + this.timeseriesAggregateCommandAdapter = TimeseriesAggregateCommandAdapter.of(headerTranslator); + this.timeseriesAggregateCommandResponseAdapter = + TimeseriesAggregateCommandResponseAdapter.of(headerTranslator); this.adapterResolver = checkNotNull(adapterResolver, "adapterResolver"); } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapter.java new file mode 100644 index 00000000000..ec319c1cbf7 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapter.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.AbstractAdapter; +import org.eclipse.ditto.protocol.adapter.EmptyPathMatcher; +import org.eclipse.ditto.protocol.mapper.SignalMapper; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; + +/** + * Adapter for mapping a {@link RetrieveAggregatedTimeseries} command to and from an + * {@link Adaptable}. + *

+ * Topic path: {@code /_/things/twin/timeseries/aggregate}. The entity-name position holds + * the {@code _} placeholder because the command aggregates a namespace rather than targeting one + * Thing. + * + * @since 4.0.0 + */ +public final class TimeseriesAggregateCommandAdapter extends AbstractAdapter { + + private final SignalMapper signalMapper; + + private TimeseriesAggregateCommandAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getTimeseriesAggregateCommandMappingStrategies(), + headerTranslator, + EmptyPathMatcher.getInstance()); + this.signalMapper = SignalMapperFactory.newTimeseriesAggregateSignalMapper(); + } + + /** + * Returns a new {@code TimeseriesAggregateCommandAdapter}. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static TimeseriesAggregateCommandAdapter of(final HeaderTranslator headerTranslator) { + return new TimeseriesAggregateCommandAdapter(requireNonNull(headerTranslator, "headerTranslator")); + } + + @Override + protected String getType(final Adaptable adaptable) { + return RetrieveAggregatedTimeseries.TYPE; + } + + @Override + protected Adaptable mapSignalToAdaptable(final RetrieveAggregatedTimeseries signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToAdaptable(signal, channel); + } + + @Override + public TopicPath toTopicPath(final RetrieveAggregatedTimeseries signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToTopicPath(signal, channel); + } + + @Override + public Set getGroups() { + return EnumSet.of(TopicPath.Group.THINGS); + } + + @Override + public Set getChannels() { + return EnumSet.of(TopicPath.Channel.TWIN); + } + + @Override + public Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.TIMESERIES); + } + + @Override + public Set getActions() { + return EnumSet.of(TopicPath.Action.AGGREGATE); + } + + @Override + public boolean isForResponses() { + return false; + } + + @Override + public boolean supportsWildcardTopics() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapter.java new file mode 100644 index 00000000000..29aeb2de675 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapter.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.AbstractAdapter; +import org.eclipse.ditto.protocol.adapter.EmptyPathMatcher; +import org.eclipse.ditto.protocol.mapper.SignalMapper; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; + +/** + * Adapter for mapping a {@link RetrieveAggregatedTimeseriesResponse} to and from an + * {@link Adaptable}. + *

+ * Topic path: {@code /_/things/twin/timeseries/aggregate} + * + * @since 4.0.0 + */ +public final class TimeseriesAggregateCommandResponseAdapter + extends AbstractAdapter { + + private final SignalMapper signalMapper; + + private TimeseriesAggregateCommandResponseAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getTimeseriesAggregateCommandResponseMappingStrategies(), + headerTranslator, + EmptyPathMatcher.getInstance()); + this.signalMapper = SignalMapperFactory.newTimeseriesAggregateResponseSignalMapper(); + } + + /** + * Returns a new {@code TimeseriesAggregateCommandResponseAdapter}. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static TimeseriesAggregateCommandResponseAdapter of(final HeaderTranslator headerTranslator) { + return new TimeseriesAggregateCommandResponseAdapter( + requireNonNull(headerTranslator, "headerTranslator")); + } + + @Override + protected String getType(final Adaptable adaptable) { + return RetrieveAggregatedTimeseriesResponse.TYPE; + } + + @Override + protected Adaptable mapSignalToAdaptable(final RetrieveAggregatedTimeseriesResponse signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToAdaptable(signal, channel); + } + + @Override + public TopicPath toTopicPath(final RetrieveAggregatedTimeseriesResponse signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToTopicPath(signal, channel); + } + + @Override + public Set getGroups() { + return EnumSet.of(TopicPath.Group.THINGS); + } + + @Override + public Set getChannels() { + return EnumSet.of(TopicPath.Channel.TWIN); + } + + @Override + public Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.TIMESERIES); + } + + @Override + public Set getActions() { + return EnumSet.of(TopicPath.Action.AGGREGATE); + } + + @Override + public boolean isForResponses() { + return true; + } + + @Override + public boolean supportsWildcardTopics() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapter.java new file mode 100644 index 00000000000..2468491a473 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapter.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.AbstractAdapter; +import org.eclipse.ditto.protocol.adapter.EmptyPathMatcher; +import org.eclipse.ditto.protocol.mapper.SignalMapper; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; + +/** + * Adapter for mapping a {@link RetrieveTimeseries} command to and from an {@link Adaptable}. + *

+ * Topic path: {@code //things/twin/timeseries/retrieve} + * + * @since 4.0.0 + */ +public final class TimeseriesQueryCommandAdapter extends AbstractAdapter { + + private final SignalMapper signalMapper; + + private TimeseriesQueryCommandAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getTimeseriesQueryCommandMappingStrategies(), + headerTranslator, + EmptyPathMatcher.getInstance()); + this.signalMapper = SignalMapperFactory.newTimeseriesQuerySignalMapper(); + } + + /** + * Returns a new {@code TimeseriesQueryCommandAdapter}. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static TimeseriesQueryCommandAdapter of(final HeaderTranslator headerTranslator) { + return new TimeseriesQueryCommandAdapter(requireNonNull(headerTranslator, "headerTranslator")); + } + + @Override + protected String getType(final Adaptable adaptable) { + return RetrieveTimeseries.TYPE; + } + + @Override + protected Adaptable mapSignalToAdaptable(final RetrieveTimeseries signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToAdaptable(signal, channel); + } + + @Override + public TopicPath toTopicPath(final RetrieveTimeseries signal, final TopicPath.Channel channel) { + return signalMapper.mapSignalToTopicPath(signal, channel); + } + + @Override + public Set getGroups() { + return EnumSet.of(TopicPath.Group.THINGS); + } + + @Override + public Set getChannels() { + return EnumSet.of(TopicPath.Channel.TWIN); + } + + @Override + public Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.TIMESERIES); + } + + @Override + public Set getActions() { + return EnumSet.of(TopicPath.Action.RETRIEVE); + } + + @Override + public boolean isForResponses() { + return false; + } + + @Override + public boolean supportsWildcardTopics() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapter.java b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapter.java new file mode 100644 index 00000000000..2e1af58bd59 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapter.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static java.util.Objects.requireNonNull; + +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.AbstractAdapter; +import org.eclipse.ditto.protocol.adapter.EmptyPathMatcher; +import org.eclipse.ditto.protocol.mapper.SignalMapper; +import org.eclipse.ditto.protocol.mapper.SignalMapperFactory; +import org.eclipse.ditto.protocol.mappingstrategies.MappingStrategiesFactory; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; + +/** + * Adapter for mapping a {@link RetrieveTimeseriesResponse} to and from an {@link Adaptable}. + *

+ * Topic path: {@code //things/twin/timeseries/retrieve} + * + * @since 4.0.0 + */ +public final class TimeseriesQueryCommandResponseAdapter + extends AbstractAdapter { + + private final SignalMapper signalMapper; + + private TimeseriesQueryCommandResponseAdapter(final HeaderTranslator headerTranslator) { + super(MappingStrategiesFactory.getTimeseriesQueryCommandResponseMappingStrategies(), + headerTranslator, + EmptyPathMatcher.getInstance()); + this.signalMapper = SignalMapperFactory.newTimeseriesQueryResponseSignalMapper(); + } + + /** + * Returns a new {@code TimeseriesQueryCommandResponseAdapter}. + * + * @param headerTranslator translator between external and Ditto headers. + * @return the adapter. + */ + public static TimeseriesQueryCommandResponseAdapter of(final HeaderTranslator headerTranslator) { + return new TimeseriesQueryCommandResponseAdapter( + requireNonNull(headerTranslator, "headerTranslator")); + } + + @Override + protected String getType(final Adaptable adaptable) { + return RetrieveTimeseriesResponse.TYPE; + } + + @Override + protected Adaptable mapSignalToAdaptable(final RetrieveTimeseriesResponse signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToAdaptable(signal, channel); + } + + @Override + public TopicPath toTopicPath(final RetrieveTimeseriesResponse signal, + final TopicPath.Channel channel) { + return signalMapper.mapSignalToTopicPath(signal, channel); + } + + @Override + public Set getGroups() { + return EnumSet.of(TopicPath.Group.THINGS); + } + + @Override + public Set getChannels() { + return EnumSet.of(TopicPath.Channel.TWIN); + } + + @Override + public Set getCriteria() { + return EnumSet.of(TopicPath.Criterion.TIMESERIES); + } + + @Override + public Set getActions() { + return EnumSet.of(TopicPath.Action.RETRIEVE); + } + + @Override + public boolean isForResponses() { + return true; + } + + @Override + public boolean supportsWildcardTopics() { + return false; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java index 51788788054..c4a0bbc826d 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/SignalMapperFactory.java @@ -38,6 +38,10 @@ import org.eclipse.ditto.things.model.signals.events.ThingMerged; import org.eclipse.ditto.things.model.signals.events.ThingDefinitionMigrated; import org.eclipse.ditto.thingsearch.model.signals.commands.ThingSearchCommand; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; import org.eclipse.ditto.thingsearch.model.signals.events.SubscriptionEvent; /** @@ -162,4 +166,45 @@ public static SignalMapper> newConnectivityAnnouncem return new ConnectivityAnnouncementSignalMapper(); } + /** + * Signal mapper for {@code RetrieveTimeseries} commands. + * + * @return the mapper. + * @since 4.0.0 + */ + public static SignalMapper newTimeseriesQuerySignalMapper() { + return new TimeseriesQuerySignalMapper(); + } + + /** + * Signal mapper for {@code RetrieveTimeseriesResponse}. + * + * @return the mapper. + * @since 4.0.0 + */ + public static SignalMapper newTimeseriesQueryResponseSignalMapper() { + return new TimeseriesQueryResponseSignalMapper(); + } + + /** + * Signal mapper for {@code RetrieveAggregatedTimeseries} commands. + * + * @return the mapper. + * @since 4.0.0 + */ + public static SignalMapper newTimeseriesAggregateSignalMapper() { + return new TimeseriesAggregateSignalMapper(); + } + + /** + * Signal mapper for {@code RetrieveAggregatedTimeseriesResponse}. + * + * @return the mapper. + * @since 4.0.0 + */ + public static SignalMapper + newTimeseriesAggregateResponseSignalMapper() { + return new TimeseriesAggregateResponseSignalMapper(); + } + } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateResponseSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateResponseSignalMapper.java new file mode 100644 index 00000000000..92ad889b330 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateResponseSignalMapper.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mapper; + +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; + +/** + * Signal mapper for {@link RetrieveAggregatedTimeseriesResponse}. + *

+ * Reuses the command's {@code /_/things/twin/timeseries/aggregate} topic path — the + * response has no single Thing to name either — and serialises the whole response entity, so the + * {@code authorization} block travels with the results rather than being dropped on non-HTTP + * transports. The namespace is recovered from the topic, since the entity does not carry it. + * + * @since 4.0.0 + */ +final class TimeseriesAggregateResponseSignalMapper + extends AbstractSignalMapper { + + @Override + TopicPath getTopicPath(final RetrieveAggregatedTimeseriesResponse signal, + final TopicPath.Channel channel) { + + return ProtocolFactory.newTopicPath( + TimeseriesAggregateTopicPath.forNamespace(signal.getNamespace())); + } + + @Override + void enhancePayloadBuilder(final RetrieveAggregatedTimeseriesResponse signal, + final PayloadBuilder payloadBuilder) { + + payloadBuilder.withValue(signal.getEntity(JsonSchemaVersion.LATEST)); + payloadBuilder.withStatus(signal.getHttpStatus()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateSignalMapper.java new file mode 100644 index 00000000000..00efcc333ee --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateSignalMapper.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mapper; + +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; + +/** + * Signal mapper for {@link RetrieveAggregatedTimeseries} commands. + *

+ * Unlike {@link TimeseriesQuerySignalMapper}, this command targets no single Thing — it aggregates + * across a whole namespace and is deliberately not a {@code WithEntityId} signal. It borrows the + * {@code _} entity-name placeholder that {@code CheckPermissions} uses for its cross-entity command, + * but keeps the real namespace in the namespace position: + * {@code /_/things/twin/timeseries/aggregate}. + *

+ * Putting the namespace in the topic rather than only in the payload keeps it visible to + * topic-level routing and filtering, and lets the response be reconstructed without an envelope — + * the response entity carries {@code results} and {@code authorization} but not the namespace. + * + * @since 4.0.0 + */ +final class TimeseriesAggregateSignalMapper extends AbstractSignalMapper { + + @Override + TopicPath getTopicPath(final RetrieveAggregatedTimeseries signal, final TopicPath.Channel channel) { + return ProtocolFactory.newTopicPath( + TimeseriesAggregateTopicPath.forNamespace(signal.getNamespace())); + } + + @Override + void enhancePayloadBuilder(final RetrieveAggregatedTimeseries command, + final PayloadBuilder payloadBuilder) { + + payloadBuilder.withValue(command.getQuery().toJson()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateTopicPath.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateTopicPath.java new file mode 100644 index 00000000000..76cbbf9dc59 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesAggregateTopicPath.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mapper; + +/** + * The topic path shared by the cross-Thing timeseries command and its response. + *

+ * Cross-Thing aggregation names a namespace but no entity, so the entity-name position carries the + * {@code _} placeholder — the same convention {@code CheckPermissions} uses for a command that + * targets no single entity. Keeping both sides of the exchange on one definition means the request + * and reply cannot drift apart. + * + * @since 4.0.0 + */ +final class TimeseriesAggregateTopicPath { + + /** Entity-name placeholder for a command that targets a namespace rather than one entity. */ + private static final String NO_ENTITY_NAME = "_"; + + private static final String SUFFIX = "/things/twin/timeseries/aggregate"; + + private TimeseriesAggregateTopicPath() { + throw new AssertionError(); + } + + /** + * @param namespace the namespace being aggregated. + * @return the topic path string {@code /_/things/twin/timeseries/aggregate}. + */ + static String forNamespace(final String namespace) { + return namespace + "/" + NO_ENTITY_NAME + SUFFIX; + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQueryResponseSignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQueryResponseSignalMapper.java new file mode 100644 index 00000000000..2075bb4ba8f --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQueryResponseSignalMapper.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mapper; + +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; + +/** + * Signal mapper for {@link RetrieveTimeseriesResponse}. + *

+ * Reuses the same {@code //things/twin/timeseries/retrieve} topic path as the inbound + * command and serialises the per-path {@link TimeseriesQueryResult} list as a JSON array + * payload. + * + * @since 4.0.0 + */ +final class TimeseriesQueryResponseSignalMapper extends AbstractSignalMapper { + + @Override + TopicPath getTopicPath(final RetrieveTimeseriesResponse signal, final TopicPath.Channel channel) { + return ProtocolFactory.newTopicPathBuilder(signal.getEntityId()) + .things() + .twin() + .timeseries() + .retrieve() + .build(); + } + + @Override + void enhancePayloadBuilder(final RetrieveTimeseriesResponse signal, + final PayloadBuilder payloadBuilder) { + + final JsonArrayBuilder resultsBuilder = JsonFactory.newArrayBuilder(); + for (final TimeseriesQueryResult result : signal.getResults()) { + resultsBuilder.add(result.toJson()); + } + payloadBuilder.withValue(resultsBuilder.build()); + payloadBuilder.withStatus(signal.getHttpStatus()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQuerySignalMapper.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQuerySignalMapper.java new file mode 100644 index 00000000000..9ca5e1939d6 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mapper/TimeseriesQuerySignalMapper.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mapper; + +import org.eclipse.ditto.protocol.PayloadBuilder; +import org.eclipse.ditto.protocol.ProtocolFactory; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; + +/** + * Signal mapper for {@link RetrieveTimeseries} commands. + *

+ * Builds the topic path {@code //things/twin/timeseries/retrieve} from the command's + * Thing ID and serialises the carried {@link org.eclipse.ditto.timeseries.model.TimeseriesQuery} + * into the payload value. + * + * @since 4.0.0 + */ +final class TimeseriesQuerySignalMapper extends AbstractSignalMapper { + + @Override + TopicPath getTopicPath(final RetrieveTimeseries signal, final TopicPath.Channel channel) { + // Phase 1 only honours the twin channel; the channel arg is accepted to satisfy + // SignalMapper but always twin'd here. + return ProtocolFactory.newTopicPathBuilder(signal.getEntityId()) + .things() + .twin() + .timeseries() + .retrieve() + .build(); + } + + @Override + void enhancePayloadBuilder(final RetrieveTimeseries command, final PayloadBuilder payloadBuilder) { + payloadBuilder.withValue(command.getQuery().toJson()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java index f57e428db7c..f71324503b7 100644 --- a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/MappingStrategiesFactory.java @@ -13,6 +13,10 @@ package org.eclipse.ditto.protocol.mappingstrategies; import org.eclipse.ditto.base.model.signals.ErrorRegistry; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; /** * Factory class to get instances of all {@link MappingStrategies}. @@ -143,4 +147,47 @@ public static CheckPermissionsCommandMappingStrategies getCheckPermissionsComman return CheckPermissionsCommandResponseMappingStrategies.getInstance(); } + /** + * Mapping strategies for {@code RetrieveTimeseries} commands. + * + * @return the strategies. + * @since 4.0.0 + */ + public static MappingStrategies getTimeseriesQueryCommandMappingStrategies() { + return TimeseriesQueryCommandMappingStrategies.getInstance(); + } + + /** + * Mapping strategies for {@code RetrieveTimeseriesResponse}. + * + * @return the strategies. + * @since 4.0.0 + */ + public static MappingStrategies + getTimeseriesQueryCommandResponseMappingStrategies() { + return TimeseriesQueryCommandResponseMappingStrategies.getInstance(); + } + + /** + * Mapping strategies for {@code RetrieveAggregatedTimeseries} commands. + * + * @return the strategies. + * @since 4.0.0 + */ + public static MappingStrategies + getTimeseriesAggregateCommandMappingStrategies() { + return TimeseriesAggregateCommandMappingStrategies.getInstance(); + } + + /** + * Mapping strategies for {@code RetrieveAggregatedTimeseriesResponse}. + * + * @return the strategies. + * @since 4.0.0 + */ + public static MappingStrategies + getTimeseriesAggregateCommandResponseMappingStrategies() { + return TimeseriesAggregateCommandResponseMappingStrategies.getInstance(); + } + } diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandMappingStrategies.java new file mode 100644 index 00000000000..1901bccd1f8 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandMappingStrategies.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mappingstrategies; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; + +/** + * Defines mapping strategies (map from signal type to {@link JsonifiableMapper}) for + * {@link RetrieveAggregatedTimeseries} commands. + *

+ * The whole query — including its namespace — is carried in the payload, so validation runs through + * {@link CrossThingTimeseriesQuery#fromJson} exactly as it does for an HTTP request. A malformed + * cross-Thing query is therefore rejected identically on every transport. + * + * @since 4.0.0 + */ +final class TimeseriesAggregateCommandMappingStrategies + extends AbstractMappingStrategies { + + private static final TimeseriesAggregateCommandMappingStrategies INSTANCE = + new TimeseriesAggregateCommandMappingStrategies(); + + private TimeseriesAggregateCommandMappingStrategies() { + super(initMappingStrategies()); + } + + static TimeseriesAggregateCommandMappingStrategies getInstance() { + return INSTANCE; + } + + private static Map> initMappingStrategies() { + final Map> strategies = new HashMap<>(); + strategies.put(RetrieveAggregatedTimeseries.TYPE, + adaptable -> RetrieveAggregatedTimeseries.of( + CrossThingTimeseriesQuery.fromJson(payloadValueAsObject(adaptable)), + dittoHeadersFrom(adaptable))); + return strategies; + } + + private static JsonObject payloadValueAsObject(final Adaptable adaptable) { + return adaptable.getPayload().getValue() + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .orElse(JsonObject.empty()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandResponseMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandResponseMappingStrategies.java new file mode 100644 index 00000000000..ed30e7288e1 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesAggregateCommandResponseMappingStrategies.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mappingstrategies; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; + +/** + * Defines mapping strategies (map from response type to {@link JsonifiableMapper}) for + * {@link RetrieveAggregatedTimeseriesResponse}. + *

+ * The response entity carries {@code results} and {@code authorization} but not the namespace — the + * HTTP caller already knows it from the request URL. Over the Ditto protocol it is recovered from + * the topic path, which is why the cross-Thing topic keeps the real namespace rather than the + * {@code _} placeholder used for the entity name. + * + * @since 4.0.0 + */ +final class TimeseriesAggregateCommandResponseMappingStrategies + extends AbstractMappingStrategies { + + private static final String RESULTS = "results"; + private static final String AUTHORIZATION = "authorization"; + private static final String CONTRIBUTING_THINGS = "contributingThings"; + private static final String EXCLUDED_THINGS = "excludedThings"; + private static final String WITHHELD_BY_PATH = "withheldByPath"; + + private static final TimeseriesAggregateCommandResponseMappingStrategies INSTANCE = + new TimeseriesAggregateCommandResponseMappingStrategies(); + + private TimeseriesAggregateCommandResponseMappingStrategies() { + super(initMappingStrategies()); + } + + static TimeseriesAggregateCommandResponseMappingStrategies getInstance() { + return INSTANCE; + } + + private static Map> + initMappingStrategies() { + + final Map> strategies = + new HashMap<>(); + strategies.put(RetrieveAggregatedTimeseriesResponse.TYPE, adaptable -> { + final String namespace = adaptable.getTopicPath().getNamespace(); + final JsonObject entity = payloadValueAsObject(adaptable); + + final JsonArray resultsArray = entity.getValue(RESULTS) + .filter(JsonValue::isArray) + .map(JsonValue::asArray) + .orElseGet(JsonArray::empty); + final List results = new ArrayList<>(resultsArray.getSize()); + for (final JsonValue value : resultsArray) { + results.add(AggregatedTimeseriesResult.fromJson(value.asObject())); + } + + final JsonObject authorization = entity.getValue(AUTHORIZATION) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .orElseGet(JsonObject::empty); + + return RetrieveAggregatedTimeseriesResponse.of(namespace, results, + intOrZero(authorization, CONTRIBUTING_THINGS), + intOrZero(authorization, EXCLUDED_THINGS), + withheldFrom(authorization), + dittoHeadersFrom(adaptable)); + }); + return strategies; + } + + private static int intOrZero(final JsonObject authorization, final String key) { + return authorization.getValue(key) + .filter(JsonValue::isNumber) + .map(JsonValue::asInt) + .orElse(0); + } + + private static Map withheldFrom(final JsonObject authorization) { + final Map withheld = new LinkedHashMap<>(); + authorization.getValue(WITHHELD_BY_PATH) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .ifPresent(byPath -> { + for (final JsonField field : byPath) { + if (field.getValue().isNumber()) { + withheld.put(field.getKeyName(), field.getValue().asInt()); + } + } + }); + return withheld; + } + + private static JsonObject payloadValueAsObject(final Adaptable adaptable) { + return adaptable.getPayload().getValue() + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .orElse(JsonObject.empty()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandMappingStrategies.java new file mode 100644 index 00000000000..fdf0c18b63d --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandMappingStrategies.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mappingstrategies; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; + +/** + * Defines mapping strategies (map from signal type to {@link JsonifiableMapper}) for + * {@link RetrieveTimeseries} commands. + * + * @since 4.0.0 + */ +final class TimeseriesQueryCommandMappingStrategies extends AbstractMappingStrategies { + + private static final TimeseriesQueryCommandMappingStrategies INSTANCE = + new TimeseriesQueryCommandMappingStrategies(); + + private TimeseriesQueryCommandMappingStrategies() { + super(initMappingStrategies()); + } + + static TimeseriesQueryCommandMappingStrategies getInstance() { + return INSTANCE; + } + + private static Map> initMappingStrategies() { + final Map> strategies = new HashMap<>(); + strategies.put(RetrieveTimeseries.TYPE, + adaptable -> RetrieveTimeseries.of( + TimeseriesQuery.fromJson(payloadValueAsObject(adaptable)), + dittoHeadersFrom(adaptable))); + return strategies; + } + + private static JsonObject payloadValueAsObject(final Adaptable adaptable) { + return adaptable.getPayload().getValue() + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .orElse(JsonObject.empty()); + } +} diff --git a/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandResponseMappingStrategies.java b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandResponseMappingStrategies.java new file mode 100644 index 00000000000..5419ceef3d4 --- /dev/null +++ b/protocol/src/main/java/org/eclipse/ditto/protocol/mappingstrategies/TimeseriesQueryCommandResponseMappingStrategies.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.mappingstrategies; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.JsonifiableMapper; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; + +/** + * Defines mapping strategies (map from response type to {@link JsonifiableMapper}) for + * {@link RetrieveTimeseriesResponse}. + * + * @since 4.0.0 + */ +final class TimeseriesQueryCommandResponseMappingStrategies + extends AbstractMappingStrategies { + + private static final TimeseriesQueryCommandResponseMappingStrategies INSTANCE = + new TimeseriesQueryCommandResponseMappingStrategies(); + + private TimeseriesQueryCommandResponseMappingStrategies() { + super(initMappingStrategies()); + } + + static TimeseriesQueryCommandResponseMappingStrategies getInstance() { + return INSTANCE; + } + + private static Map> initMappingStrategies() { + final Map> strategies = new HashMap<>(); + strategies.put(RetrieveTimeseriesResponse.TYPE, adaptable -> { + final ThingId thingId = ThingId.of(adaptable.getTopicPath().getNamespace() + ":" + + adaptable.getTopicPath().getEntityName()); + final JsonArray results = adaptable.getPayload().getValue() + .filter(JsonValue::isArray) + .map(JsonValue::asArray) + .orElseGet(JsonArray::empty); + final List parsed = new ArrayList<>(results.getSize()); + for (final JsonValue value : results) { + parsed.add(TimeseriesQueryResult.fromJson(value.asObject())); + } + return RetrieveTimeseriesResponse.of(thingId, parsed, dittoHeadersFrom(adaptable)); + }); + return strategies; + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/TopicPathCriterionTimeseriesTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/TopicPathCriterionTimeseriesTest.java new file mode 100644 index 00000000000..69d93dcbee2 --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/TopicPathCriterionTimeseriesTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +/** + * Tests covering the {@link TopicPath.Criterion#TIMESERIES} enum value introduced for the + * Timeseries API (GitHub issue #2291). + *

+ * The full bi-directional protocol adapter (TimeseriesQueryAdapter, MappingStrategies, + * SignalMapper, registration in DittoProtocolAdapter / DefaultAdapterResolver / + * AdapterResolverBySignal, and a dedicated TimeseriesTopicPathBuilder) lands in a separate + * commit; this test pins + * the enum addition itself. + */ +public final class TopicPathCriterionTimeseriesTest { + + @Test + public void timeseriesCriterionWireFormatIsLowercase() { + assertThat(TopicPath.Criterion.TIMESERIES.getName()).isEqualTo("timeseries"); + } + + @Test + public void forNameResolvesTimeseries() { + assertThat(TopicPath.Criterion.forName("timeseries")) + .contains(TopicPath.Criterion.TIMESERIES); + } + + @Test + public void forNameIsCaseSensitive() { + assertThat(TopicPath.Criterion.forName("TIMESERIES")).isEmpty(); + } + + @Test + public void timeseriesIsAmongValues() { + assertThat(TopicPath.Criterion.values()).contains(TopicPath.Criterion.TIMESERIES); + } + + @Test + public void retrieveActionAlreadyExistsForTimeseriesUse() { + // The concept document's topic path //things/twin/timeseries/retrieve reuses + // the existing RETRIEVE action — verify it remains available so the protocol adapter + // can reuse it. + assertThat(TopicPath.Action.forName("retrieve")).contains(TopicPath.Action.RETRIEVE); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapterParameterizedTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapterParameterizedTest.java index d5a80bdaee6..f8751c4408f 100644 --- a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapterParameterizedTest.java +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/DittoProtocolAdapterParameterizedTest.java @@ -51,6 +51,10 @@ import org.eclipse.ditto.protocol.adapter.provider.AcknowledgementAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.PolicyCommandAdapterProvider; import org.eclipse.ditto.protocol.adapter.provider.ThingCommandAdapterProvider; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesAggregateCommandResponseAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandAdapter; +import org.eclipse.ditto.protocol.adapter.things.TimeseriesQueryCommandResponseAdapter; import org.eclipse.ditto.things.model.signals.commands.ThingErrorResponse; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThing; import org.eclipse.ditto.things.model.signals.commands.modify.MergeThingResponse; @@ -257,7 +261,11 @@ public void setUp() { policyCommandAdapterProvider, connectivityCommandAdapterProvider, acknowledgementAdapterProvider, streamingSubscriptionCommandAdapter, streamingSubscriptionEventAdapter, CheckPermissionsCommandAdapter.of(HeaderTranslator.empty()), - CheckPermissionsCommandResponseAdapter.of(HeaderTranslator.empty())); + CheckPermissionsCommandResponseAdapter.of(HeaderTranslator.empty()), + TimeseriesQueryCommandAdapter.of(HeaderTranslator.empty()), + TimeseriesQueryCommandResponseAdapter.of(HeaderTranslator.empty()), + TimeseriesAggregateCommandAdapter.of(HeaderTranslator.empty()), + TimeseriesAggregateCommandResponseAdapter.of(HeaderTranslator.empty())); underTest = DittoProtocolAdapter.newInstance(HeaderTranslator.empty(), thingCommandAdapterProvider, policyCommandAdapterProvider, connectivityCommandAdapterProvider, acknowledgementAdapterProvider, streamingSubscriptionCommandAdapter, streamingSubscriptionEventAdapter, diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapterTest.java new file mode 100644 index 00000000000..38b45c35fd4 --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandAdapterTest.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.base.model.signals.Signal; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.DittoProtocolAdapter; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.junit.Before; +import org.junit.Test; + +/** + * Round-trip tests for {@link TimeseriesAggregateCommandAdapter} — verifies a + * {@link RetrieveAggregatedTimeseries} survives signal → adaptable → signal without loss, and that + * the topic path is {@code /_/things/twin/timeseries/aggregate}. + *

+ * The cross-Thing command carries no entity ID, so the entity-name position holds the {@code _} + * placeholder while the namespace stays addressable. Only a round-trip test catches a mapping + * strategy that silently drops part of the query. + */ +public final class TimeseriesAggregateCommandAdapterTest { + + private static final String NAMESPACE = "org.eclipse.ditto"; + private static final JsonPointer FLOW = + JsonPointer.of("/features/environment/properties/temperature"); + private static final JsonPointer RETURN = + JsonPointer.of("/features/environment/properties/returnTemperature"); + + private TimeseriesAggregateCommandAdapter underTest; + + @Before + public void setUp() { + underTest = TimeseriesAggregateCommandAdapter.of(HeaderTranslator.empty()); + } + + @Test + public void adapterReportsCorrectTopicPathSegments() { + assertThat(underTest.getGroups()).containsExactly(TopicPath.Group.THINGS); + assertThat(underTest.getChannels()).containsExactly(TopicPath.Channel.TWIN); + assertThat(underTest.getCriteria()).containsExactly(TopicPath.Criterion.TIMESERIES); + assertThat(underTest.getActions()).containsExactly(TopicPath.Action.AGGREGATE); + assertThat(underTest.isForResponses()).isFalse(); + assertThat(underTest.supportsWildcardTopics()).isFalse(); + } + + @Test + public void toAdaptableProducesNamespaceScopedTopicPathWithPlaceholderEntityName() { + final Adaptable adaptable = underTest.toAdaptable(sampleCommand(), TopicPath.Channel.TWIN); + + final TopicPath topicPath = adaptable.getTopicPath(); + assertThat(topicPath.getNamespace()).isEqualTo(NAMESPACE); + assertThat(topicPath.getEntityName()).isEqualTo("_"); + assertThat(topicPath.getGroup()).isEqualTo(TopicPath.Group.THINGS); + assertThat(topicPath.getChannel()).isEqualTo(TopicPath.Channel.TWIN); + assertThat(topicPath.getCriterion()).isEqualTo(TopicPath.Criterion.TIMESERIES); + assertThat(topicPath.getAction()).contains(TopicPath.Action.AGGREGATE); + assertThat(topicPath.getPath()) + .isEqualTo(NAMESPACE + "/_/things/twin/timeseries/aggregate"); + } + + /** Every optional field populated, so a strategy that drops one is caught. */ + @Test + public void roundTripPreservesTheWholeQuery() { + final RetrieveAggregatedTimeseries original = sampleCommand(); + + final Adaptable adaptable = underTest.toAdaptable(original, TopicPath.Channel.TWIN); + final RetrieveAggregatedTimeseries reconstructed = underTest.fromAdaptable(adaptable); + + assertThat(reconstructed.getQuery()).isEqualTo(original.getQuery()); + assertThat(reconstructed.getNamespace()).isEqualTo(NAMESPACE); + } + + @Test + public void roundTripPreservesGroupByAndFilter() { + final RetrieveAggregatedTimeseries original = sampleCommand(); + + final CrossThingTimeseriesQuery query = + underTest.fromAdaptable(underTest.toAdaptable(original, TopicPath.Channel.TWIN)) + .getQuery(); + + assertThat(query.getGroupBy()) + .containsExactly(GroupBy.tag("attributes/building"), GroupBy.thingId()); + assertThat(query.getFilter()).contains("eq(attributes/building,'A')"); + assertThat(query.getPaths()).containsExactly(FLOW, RETURN); + assertThat(query.getTimezone()).contains(ZoneId.of("Europe/Berlin")); + assertThat(query.getFillStrategy()).contains(FillStrategy.LINEAR); + assertThat(query.getMaxGroups()).contains(42); + } + + @Test + public void payloadValueParsesBackIntoTheQuery() { + final RetrieveAggregatedTimeseries command = sampleCommand(); + + final Adaptable adaptable = underTest.toAdaptable(command, TopicPath.Channel.TWIN); + + assertThat(adaptable.getPayload().getValue()).isPresent(); + final CrossThingTimeseriesQuery parsed = CrossThingTimeseriesQuery.fromJson( + adaptable.getPayload().getValue().orElseThrow().asObject()); + assertThat(parsed).isEqualTo(command.getQuery()); + } + + /** Headers must survive the hop, or correlation-id based request/response matching breaks. */ + @Test + public void roundTripPreservesCorrelationId() { + final DittoHeaders headers = DittoHeaders.newBuilder() + .correlationId("ts-aggregate-1") + .build(); + final RetrieveAggregatedTimeseries original = + RetrieveAggregatedTimeseries.of(sampleQuery(), headers); + + final RetrieveAggregatedTimeseries reconstructed = + underTest.fromAdaptable(underTest.toAdaptable(original, TopicPath.Channel.TWIN)); + + assertThat(reconstructed.getDittoHeaders().getCorrelationId()).contains("ts-aggregate-1"); + } + + /** + * The end-to-end dispatch, not just this adapter in isolation. Before the aggregate adapter was + * registered, an inbound {@code .../timeseries/aggregate} topic failed resolution with + * {@code protocoladapter:unknown.topicpath — Action name is unknown}; that is what a + * WebSocket or Connectivity client saw. Resolving through {@link DittoProtocolAdapter} is the + * only assertion that covers the resolver wiring rather than the adapter alone. + */ + @Test + public void dittoProtocolAdapterResolvesTheAggregateTopicPath() { + final DittoProtocolAdapter protocolAdapter = DittoProtocolAdapter.newInstance(); + final Adaptable adaptable = + underTest.toAdaptable(sampleCommand(), TopicPath.Channel.TWIN); + + final Signal resolved = protocolAdapter.fromAdaptable(adaptable); + + assertThat(resolved).isInstanceOf(RetrieveAggregatedTimeseries.class); + assertThat(((RetrieveAggregatedTimeseries) resolved).getQuery()) + .isEqualTo(sampleQuery()); + } + + private static RetrieveAggregatedTimeseries sampleCommand() { + return RetrieveAggregatedTimeseries.of(sampleQuery(), DittoHeaders.empty()); + } + + private static CrossThingTimeseriesQuery sampleQuery() { + final List paths = Arrays.asList(FLOW, RETURN); + final List groupBy = + Arrays.asList(GroupBy.tag("attributes/building"), GroupBy.thingId()); + return CrossThingTimeseriesQuery.of(NAMESPACE, paths, + Instant.parse("2026-07-01T00:00:00Z"), + Instant.parse("2026-07-02T00:00:00Z"), + Duration.ofHours(1), Aggregation.AVG, + groupBy, "eq(attributes/building,'A')", + ZoneId.of("Europe/Berlin"), FillStrategy.LINEAR, 42); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapterTest.java new file mode 100644 index 00000000000..08adf08a5ea --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesAggregateCommandResponseAdapterTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.base.model.signals.Signal; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.protocol.adapter.DittoProtocolAdapter; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; +import org.junit.Before; +import org.junit.Test; + +/** + * Round-trip tests for {@link TimeseriesAggregateCommandResponseAdapter}. + *

+ * The response entity carries {@code results} and {@code authorization} but not the + * namespace — the HTTP caller knows it from the request URL. Over the Ditto protocol it has to be + * recovered from the topic path, so these tests pin that specifically: a response that loses its + * namespace, or drops the {@code authorization} block, would report a partial aggregate as if it + * were complete. + */ +public final class TimeseriesAggregateCommandResponseAdapterTest { + + private static final String NAMESPACE = "org.eclipse.ditto"; + private static final JsonPointer FLOW = + JsonPointer.of("/features/environment/properties/temperature"); + + private TimeseriesAggregateCommandResponseAdapter underTest; + + @Before + public void setUp() { + underTest = TimeseriesAggregateCommandResponseAdapter.of(HeaderTranslator.empty()); + } + + @Test + public void adapterReportsCorrectTopicPathSegments() { + assertThat(underTest.getGroups()).containsExactly(TopicPath.Group.THINGS); + assertThat(underTest.getChannels()).containsExactly(TopicPath.Channel.TWIN); + assertThat(underTest.getCriteria()).containsExactly(TopicPath.Criterion.TIMESERIES); + assertThat(underTest.getActions()).containsExactly(TopicPath.Action.AGGREGATE); + assertThat(underTest.isForResponses()).isTrue(); + } + + @Test + public void roundTripPreservesResultsAndNamespace() { + final RetrieveAggregatedTimeseriesResponse original = sampleResponse(); + + final Adaptable adaptable = underTest.toAdaptable(original, TopicPath.Channel.TWIN); + final RetrieveAggregatedTimeseriesResponse reconstructed = underTest.fromAdaptable(adaptable); + + assertThat(reconstructed.getNamespace()).isEqualTo(NAMESPACE); + assertThat(reconstructed.getResults()).isEqualTo(original.getResults()); + } + + /** + * The honesty signal. If the authorization block did not survive the hop, a caller on WebSocket + * would see an aggregate computed over a permitted subset and have no way to tell. + */ + @Test + public void roundTripPreservesTheAuthorizationSummary() { + final RetrieveAggregatedTimeseriesResponse original = sampleResponse(); + + final RetrieveAggregatedTimeseriesResponse reconstructed = + underTest.fromAdaptable(underTest.toAdaptable(original, TopicPath.Channel.TWIN)); + + assertThat(reconstructed.getContributingThings()).isEqualTo(4); + assertThat(reconstructed.getExcludedThings()).isEqualTo(2); + assertThat(reconstructed.getWithheldByPath()) + .containsEntry(FLOW.toString(), 2); + } + + @Test + public void topicPathCarriesTheNamespaceAndPlaceholderEntityName() { + final Adaptable adaptable = underTest.toAdaptable(sampleResponse(), TopicPath.Channel.TWIN); + + assertThat(adaptable.getTopicPath().getPath()) + .isEqualTo(NAMESPACE + "/_/things/twin/timeseries/aggregate"); + } + + @Test + public void dittoProtocolAdapterResolvesTheAggregateResponse() { + final DittoProtocolAdapter protocolAdapter = DittoProtocolAdapter.newInstance(); + final Adaptable adaptable = underTest.toAdaptable(sampleResponse(), TopicPath.Channel.TWIN); + + final Signal resolved = protocolAdapter.fromAdaptable(adaptable); + + assertThat(resolved).isInstanceOf(RetrieveAggregatedTimeseriesResponse.class); + } + + private static RetrieveAggregatedTimeseriesResponse sampleResponse() { + final Map group = new LinkedHashMap<>(); + group.put("attributes/building", "A"); + final List data = Arrays.asList( + TimeseriesDataValue.of(Instant.parse("2026-07-01T00:00:00Z"), JsonValue.of(23.5)), + TimeseriesDataValue.of(Instant.parse("2026-07-01T01:00:00Z"), JsonValue.of(24.0))); + final AggregatedTimeseriesResult result = AggregatedTimeseriesResult.of(group, FLOW, + TimeseriesResultMeta.of(data.size(), null, "number"), data); + + final Map withheld = new LinkedHashMap<>(); + withheld.put(FLOW.toString(), 2); + + return RetrieveAggregatedTimeseriesResponse.of(NAMESPACE, + Collections.singletonList(result), 4, 2, withheld, DittoHeaders.empty()); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapterTest.java new file mode 100644 index 00000000000..8e257487f8a --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandAdapterTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collections; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.junit.Before; +import org.junit.Test; + +/** + * Round-trip tests for {@link TimeseriesQueryCommandAdapter} — verifies a {@link RetrieveTimeseries} + * survives signal → adaptable → signal without loss, and that the produced topic path matches the + * concept-doc shape {@code //things/twin/timeseries/retrieve}. + */ +public final class TimeseriesQueryCommandAdapterTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + + private TimeseriesQueryCommandAdapter underTest; + + @Before + public void setUp() { + underTest = TimeseriesQueryCommandAdapter.of(HeaderTranslator.empty()); + } + + @Test + public void adapterReportsCorrectTopicPathSegments() { + assertThat(underTest.getGroups()).containsExactly(TopicPath.Group.THINGS); + assertThat(underTest.getChannels()).containsExactly(TopicPath.Channel.TWIN); + assertThat(underTest.getCriteria()).containsExactly(TopicPath.Criterion.TIMESERIES); + assertThat(underTest.getActions()).containsExactly(TopicPath.Action.RETRIEVE); + assertThat(underTest.isForResponses()).isFalse(); + assertThat(underTest.supportsWildcardTopics()).isFalse(); + } + + @Test + public void toAdaptableProducesExpectedTopicPath() { + final RetrieveTimeseries command = sampleCommand(); + + final Adaptable adaptable = underTest.toAdaptable(command, TopicPath.Channel.TWIN); + + final TopicPath topicPath = adaptable.getTopicPath(); + assertThat(topicPath.getNamespace()).isEqualTo("org.eclipse.ditto"); + assertThat(topicPath.getEntityName()).isEqualTo("sensor-1"); + assertThat(topicPath.getGroup()).isEqualTo(TopicPath.Group.THINGS); + assertThat(topicPath.getChannel()).isEqualTo(TopicPath.Channel.TWIN); + assertThat(topicPath.getCriterion()).isEqualTo(TopicPath.Criterion.TIMESERIES); + assertThat(topicPath.getAction()).contains(TopicPath.Action.RETRIEVE); + } + + @Test + public void roundTripPreservesQuery() { + final RetrieveTimeseries original = sampleCommand(); + + final Adaptable adaptable = underTest.toAdaptable(original, TopicPath.Channel.TWIN); + final RetrieveTimeseries reconstructed = underTest.fromAdaptable(adaptable); + + assertThat(reconstructed.getQuery()).isEqualTo(original.getQuery()); + assertThat((Object) reconstructed.getEntityId()).isEqualTo(THING_ID); + } + + @Test + public void payloadValueContainsSerialisedQuery() { + final RetrieveTimeseries command = sampleCommand(); + + final Adaptable adaptable = underTest.toAdaptable(command, TopicPath.Channel.TWIN); + + assertThat(adaptable.getPayload().getValue()).isPresent(); + // Round-trip-equivalent check: the payload value parses back into the original query. + final TimeseriesQuery parsed = + TimeseriesQuery.fromJson(adaptable.getPayload().getValue().get().asObject()); + assertThat(parsed).isEqualTo(command.getQuery()); + } + + @Test + public void getTypeReturnsRetrieveTimeseries() { + final RetrieveTimeseries command = sampleCommand(); + final Adaptable adaptable = underTest.toAdaptable(command, TopicPath.Channel.TWIN); + + // Validate that the adapter can extract the right type from a parsed adaptable. + assertThat(underTest.fromAdaptable(adaptable).getType()).isEqualTo(RetrieveTimeseries.TYPE); + } + + private static RetrieveTimeseries sampleCommand() { + final TimeseriesQuery query = TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + return RetrieveTimeseries.of(query, + DittoHeaders.newBuilder().correlationId("test-1").build()); + } +} diff --git a/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapterTest.java b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapterTest.java new file mode 100644 index 00000000000..4139b67f347 --- /dev/null +++ b/protocol/src/test/java/org/eclipse/ditto/protocol/adapter/things/TimeseriesQueryCommandResponseAdapterTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.protocol.adapter.things; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.headers.translator.HeaderTranslator; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.protocol.Adaptable; +import org.eclipse.ditto.protocol.TopicPath; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; +import org.junit.Before; +import org.junit.Test; + +/** + * Round-trip tests for {@link TimeseriesQueryCommandResponseAdapter}. + */ +public final class TimeseriesQueryCommandResponseAdapterTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + + private TimeseriesQueryCommandResponseAdapter underTest; + + @Before + public void setUp() { + underTest = TimeseriesQueryCommandResponseAdapter.of(HeaderTranslator.empty()); + } + + @Test + public void adapterIsForResponses() { + assertThat(underTest.isForResponses()).isTrue(); + assertThat(underTest.getCriteria()).containsExactly(TopicPath.Criterion.TIMESERIES); + assertThat(underTest.getActions()).containsExactly(TopicPath.Action.RETRIEVE); + } + + @Test + public void roundTripPreservesPayloadFields() { + // The adapter framework augments headers (content-type, ditto-entity-id) on round-trip, + // so equality on the full response would be brittle. Compare just the carried payload. + final RetrieveTimeseriesResponse original = sampleResponse(); + + final Adaptable adaptable = underTest.toAdaptable(original, TopicPath.Channel.TWIN); + final RetrieveTimeseriesResponse reconstructed = underTest.fromAdaptable(adaptable); + + assertThat((Object) reconstructed.getEntityId()).isEqualTo(original.getEntityId()); + assertThat(reconstructed.getResults()).isEqualTo(original.getResults()); + assertThat(reconstructed.getHttpStatus()).isEqualTo(original.getHttpStatus()); + } + + @Test + public void payloadCarriesHttpStatus() { + final RetrieveTimeseriesResponse response = sampleResponse(); + + final Adaptable adaptable = underTest.toAdaptable(response, TopicPath.Channel.TWIN); + + assertThat(adaptable.getPayload().getHttpStatus()).contains(HttpStatus.OK); + } + + private static RetrieveTimeseriesResponse sampleResponse() { + final TimeseriesQuery query = TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + final TimeseriesQueryResult result = TimeseriesQueryResult.of( + THING_ID, + PATH, + query, + TimeseriesResultMeta.of(2, "cel", "number"), + Arrays.asList( + TimeseriesDataValue.of(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.3)), + TimeseriesDataValue.of(Instant.parse("2026-01-14T11:00:00Z"), JsonValue.of(22.1)))); + return RetrieveTimeseriesResponse.of(THING_ID, Collections.singletonList(result), + DittoHeaders.newBuilder().correlationId("test-resp").build()); + } +} diff --git a/things/api/src/main/java/org/eclipse/ditto/things/api/Permission.java b/things/api/src/main/java/org/eclipse/ditto/things/api/Permission.java index 4c6ffea3ca9..33908f691cd 100755 --- a/things/api/src/main/java/org/eclipse/ditto/things/api/Permission.java +++ b/things/api/src/main/java/org/eclipse/ditto/things/api/Permission.java @@ -32,6 +32,33 @@ public final class Permission { */ public static final String WRITE = "WRITE"; + /** + * Permission to read timeseries data of a Thing's properties. + *

+ * Applies only to {@code thing:/...} resources and gates retrieval of historical timeseries data via the + * Timeseries API. The Timeseries service exposes a boolean + * {@code ditto.timeseries.simplified-read-permission} (default {@code false}) which selects between + * two enforcement modes: + *

    + *
  • Strict (default, {@code simplified-read-permission = false}): the subject must have an + * explicit {@code READ_TS} grant on the requested resource. {@link #READ} alone is not + * sufficient. An explicit {@code READ_TS} revoke at a more specific path takes precedence over a + * {@code READ_TS} grant at a parent path, following Ditto's standard policy resolution.
  • + *
  • Simplified ({@code simplified-read-permission = true}): a {@link #READ} grant on the + * requested resource is sufficient to read timeseries data; no separate {@code READ_TS} grant is + * required. In this mode an explicit {@code READ_TS} revoke is a no-op because the enforcer does + * not look up {@code READ_TS}.
  • + *
+ *

+ * Note: {@code READ_TS} is intentionally not included in {@link #DEFAULT_THING_PERMISSIONS}. + * Subjects that own a Thing without an explicit policy do not implicitly gain timeseries read access; + * timeseries access is opt-in via policy. + * + * @see Ditto issue #2291 + * @since 4.0.0 + */ + public static final String READ_TS = "READ_TS"; + /** * The set of Permissions which must be set as default on the 'thing:/' Resource for the current Subject, * if no policy is present. diff --git a/things/api/src/test/java/org/eclipse/ditto/things/api/PermissionTest.java b/things/api/src/test/java/org/eclipse/ditto/things/api/PermissionTest.java new file mode 100644 index 00000000000..7d7ab86f445 --- /dev/null +++ b/things/api/src/test/java/org/eclipse/ditto/things/api/PermissionTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.things.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.policies.model.Permissions; +import org.junit.Test; + +/** + * Unit tests for {@link Permission} — focusing on the {@code READ_TS} permission introduced for the + * Timeseries API (Ditto issue #2291). + */ +public final class PermissionTest { + + @Test + public void readTsConstantHasExpectedStringValue() { + assertThat(Permission.READ_TS).isEqualTo("READ_TS"); + } + + @Test + public void readTsIsNotIncludedInDefaultThingPermissions() { + // Least-privilege invariant: a Thing without an explicit policy must NOT auto-grant timeseries + // read access. Operators must opt in via policy. + assertThat(Permission.DEFAULT_THING_PERMISSIONS).doesNotContain(Permission.READ_TS); + } + + @Test + public void readTsCanBeUsedToConstructPermissions() { + final Permissions permissions = Permissions.newInstance(Permission.READ_TS); + + assertThat(permissions.contains(Permission.READ_TS)).isTrue(); + assertThat(permissions).hasSize(1); + } + + @Test + public void readTsCoexistsWithReadAndWriteInAPermissionsSet() { + final Permissions permissions = + Permissions.newInstance(Permission.READ, Permission.WRITE, Permission.READ_TS); + + assertThat(permissions.contains(Permission.READ, Permission.WRITE, Permission.READ_TS)).isTrue(); + assertThat(permissions).hasSize(3); + } + + @Test + public void readTsSurvivesJsonSerialization() { + final Permissions permissions = Permissions.newInstance(Permission.READ_TS); + + final JsonArray json = permissions.toJson(); + + assertThat(json.contains(JsonValue.of("READ_TS"))).isTrue(); + assertThat(json).hasSize(1); + } + + @Test + public void permissionsWithReadTsAndReadJsonContainsBoth() { + final Permissions permissions = Permissions.newInstance(Permission.READ, Permission.READ_TS); + + final JsonArray json = permissions.toJson(); + + assertThat(json.contains(JsonValue.of("READ"))).isTrue(); + assertThat(json.contains(JsonValue.of("READ_TS"))).isTrue(); + assertThat(json).hasSize(2); + } + + @Test + public void readTsRoundTripsThroughJson() { + final Permissions original = Permissions.newInstance(Permission.READ, Permission.READ_TS); + + final JsonArray json = original.toJson(); + final Permissions reconstructed = jsonArrayToPermissions(json); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.contains(Permission.READ_TS)).isTrue(); + assertThat(reconstructed.contains(Permission.READ)).isTrue(); + } + + private static Permissions jsonArrayToPermissions(final JsonArray jsonArray) { + final String[] strings = jsonArray.stream() + .map(value -> value.asString()) + .toArray(String[]::new); + if (strings.length == 0) { + return Permissions.none(); + } + final String first = strings[0]; + final String[] rest = new String[strings.length - 1]; + System.arraycopy(strings, 1, rest, 0, rest.length); + return Permissions.newInstance(first, rest); + } +} diff --git a/things/service/pom.xml b/things/service/pom.xml index 53b177a9da1..ed2ae62c81e 100644 --- a/things/service/pom.xml +++ b/things/service/pom.xml @@ -100,6 +100,10 @@ org.eclipse.ditto ditto-thingsearch-api + + org.eclipse.ditto + ditto-timeseries-api + org.eclipse.ditto diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java index d3633a0c1f9..dba653297ea 100755 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActor.java @@ -58,6 +58,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; import org.eclipse.ditto.things.model.signals.commands.modify.CreateThing; import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThing; +import org.eclipse.ditto.things.service.timeseries.TimeseriesIngestPublisher; import org.eclipse.ditto.things.model.signals.commands.query.ThingQueryCommandResponse; import org.eclipse.ditto.things.model.signals.events.ThingEvent; import org.eclipse.ditto.things.service.common.config.ThingConfig; @@ -95,6 +96,7 @@ public final class ThingPersistenceActor private final NamespaceActivityCheckConfigProvider activityCheckConfigProvider; private final DistributedPub> distributedPub; @Nullable private final ActorRef searchShardRegionProxy; + @Nullable private final ActorRef timeseriesIngestPublisher; private final ThingEventEnricher thingEventEnricher; @SuppressWarnings("unused") @@ -103,7 +105,8 @@ private ThingPersistenceActor(final ThingId thingId, final ThingConfig thingConfig, final DistributedPub> distributedPub, @Nullable final ActorRef searchShardRegionProxy, - final PolicyEnforcerProvider policyEnforcerProvider) { + final PolicyEnforcerProvider policyEnforcerProvider, + @Nullable final ActorRef timeseriesIngestPublisher) { super(thingId, mongoReadJournal); this.thingConfig = thingConfig; @@ -113,6 +116,7 @@ private ThingPersistenceActor(final ThingId thingId, ); this.distributedPub = distributedPub; this.searchShardRegionProxy = searchShardRegionProxy; + this.timeseriesIngestPublisher = timeseriesIngestPublisher; this.thingEventEnricher = new ThingEventEnricher( policyEnforcerProvider, thingConfig.getEventConfig().isPartialAccessEventsEnabled(), @@ -130,6 +134,8 @@ private ThingPersistenceActor(final ThingId thingId, * @param searchShardRegionProxy the proxy of the shard region of search updaters. * @param policyEnforcerProvider a provider for the used Policy {@code Enforcer} which "guards" the * ThingPersistenceActor for applying access control. + * @param timeseriesIngestPublisher local-node publisher that turns selected events into + * timeseries data points. May be {@code null} — in which case publishing is skipped. * @return the Pekko configuration Props object */ public static Props props(final ThingId thingId, @@ -137,10 +143,27 @@ public static Props props(final ThingId thingId, final ThingConfig thingConfig, final DistributedPub> distributedPub, @Nullable final ActorRef searchShardRegionProxy, - final PolicyEnforcerProvider policyEnforcerProvider + final PolicyEnforcerProvider policyEnforcerProvider, + @Nullable final ActorRef timeseriesIngestPublisher ) { return Props.create(ThingPersistenceActor.class, thingId, mongoReadJournal, thingConfig, distributedPub, - searchShardRegionProxy, policyEnforcerProvider); + searchShardRegionProxy, policyEnforcerProvider, timeseriesIngestPublisher); + } + + /** + * Convenience overload kept for tests that don't exercise the timeseries integration. Wires + * the timeseries publisher to {@code null}, which makes {@link #publishEvent} skip the + * forward. + */ + public static Props props(final ThingId thingId, + final MongoReadJournal mongoReadJournal, + final ThingConfig thingConfig, + final DistributedPub> distributedPub, + @Nullable final ActorRef searchShardRegionProxy, + final PolicyEnforcerProvider policyEnforcerProvider + ) { + return props(thingId, mongoReadJournal, thingConfig, distributedPub, searchShardRegionProxy, + policyEnforcerProvider, null); } @Override @@ -281,11 +304,14 @@ protected void recoveryCompleted(final RecoveryCompleted event) { @Override protected void publishEvent(@Nullable final Thing previousEntity, final ThingEvent event) { + // Snapshot on the actor thread: the continuation below runs off the mailbox, by which + // time `entity` may already hold the next command's state. + final Thing entitySnapshot = entity; final CompletionStage> stage = thingEventEnricher.enrichWithPredefinedExtraFields( thingConfig.getEventConfig().getPredefinedExtraFieldsConfigs(), entityId, - entity, - Optional.ofNullable(entity).flatMap(Thing::getPolicyId) + entitySnapshot, + Optional.ofNullable(entitySnapshot).flatMap(Thing::getPolicyId) .orElse(Optional.ofNullable(previousEntity) .flatMap(Thing::getPolicyId) .orElse(null) @@ -303,6 +329,12 @@ protected void publishEvent(@Nullable final Thing previousEntity, final ThingEve if (searchShardRegionProxy != null) { searchShardRegionProxy.tell(eventToPublish, getSelf()); } + if (timeseriesIngestPublisher != null) { + // Unenriched event plus the snapshot: the publisher resolves the WoT model + // against the post-event Thing and filters by annotation itself. + timeseriesIngestPublisher.tell( + new TimeseriesIngestPublisher.IngestRequest(event, entitySnapshot), getSelf()); + } }); } diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorPropsFactory.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorPropsFactory.java index 077d5f5aaf9..8f3753f2425 100644 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorPropsFactory.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorPropsFactory.java @@ -39,9 +39,13 @@ public interface ThingPersistenceActorPropsFactory { * @param searchShardRegionProxy the proxy of the shard region of search updaters. * @param policyEnforcerProvider a provider for the used Policy {@code Enforcer} which "guards" the * ThingPersistenceActor for applying access control. + * @param timeseriesIngestPublisher the local Timeseries ingest publisher to also forward each + * persisted {@code ThingEvent} to. May be {@code null} in test contexts or when the + * Timeseries integration is disabled — the persistence actor short-circuits the forward. * @return Props of the thing-persistence-actor. */ Props props(ThingId thingId, MongoReadJournal mongoReadJournal, ThingConfig thingConfig, DistributedPub> distributedPub, @Nullable ActorRef searchShardRegionProxy, - PolicyEnforcerProvider policyEnforcerProvider); + PolicyEnforcerProvider policyEnforcerProvider, + @Nullable ActorRef timeseriesIngestPublisher); } diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingSupervisorActor.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingSupervisorActor.java index 09c91745793..3731ecbd697 100755 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingSupervisorActor.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/ThingSupervisorActor.java @@ -107,6 +107,7 @@ public final class ThingSupervisorActor extends AbstractPersistenceSupervisor> distributedPub, @Nullable final ActorRef searchShardRegionProxy, - final PolicyEnforcerProvider policyEnforcerProvider) { + final PolicyEnforcerProvider policyEnforcerProvider, + @Nullable final ActorRef timeseriesIngestPublisher) { argumentNotEmpty(thingId); return ThingPersistenceActor.props(thingId, mongoReadJournal, thingConfig, distributedPub, - searchShardRegionProxy, policyEnforcerProvider); + searchShardRegionProxy, policyEnforcerProvider, timeseriesIngestPublisher); } } diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/starter/ThingsRootActor.java b/things/service/src/main/java/org/eclipse/ditto/things/service/starter/ThingsRootActor.java index 734a5949c17..c95718bab29 100755 --- a/things/service/src/main/java/org/eclipse/ditto/things/service/starter/ThingsRootActor.java +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/starter/ThingsRootActor.java @@ -28,12 +28,14 @@ import org.apache.pekko.pattern.Patterns; import org.eclipse.ditto.base.api.devops.signals.commands.RetrieveStatisticsDetails; import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.signals.FeatureToggle; import org.eclipse.ditto.base.service.RootChildActorStarter; import org.eclipse.ditto.base.service.actors.DittoRootActor; import org.eclipse.ditto.internal.utils.cluster.DistPubSubAccess; import org.eclipse.ditto.internal.utils.cluster.RetrieveStatisticsDetailsResponseSupplier; import org.eclipse.ditto.internal.utils.cluster.ShardRegionCreator; import org.eclipse.ditto.internal.utils.cluster.ShardRegionExtractor; +import org.eclipse.ditto.internal.utils.cluster.ShardRegionProxyActorFactory; import org.eclipse.ditto.internal.utils.cluster.config.DefaultLiveEntitiesMetricsConfig; import org.eclipse.ditto.internal.utils.cluster.config.LiveEntitiesMetricsConfig; import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; @@ -71,6 +73,8 @@ import org.eclipse.ditto.things.service.persistence.actors.WotValidationConfigSupervisorActor; import org.eclipse.ditto.things.service.persistence.actors.strategies.commands.WotValidationConfigDData; import org.eclipse.ditto.things.service.persistence.actors.strategies.commands.WotValidationConfigUtils; +import org.eclipse.ditto.things.service.timeseries.TimeseriesIngestPublisher; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; import org.eclipse.ditto.wot.api.validator.WotThingModelValidator; import org.eclipse.ditto.wot.integration.DittoWotIntegration; import org.eclipse.ditto.wot.validation.config.TmValidationConfig; @@ -115,6 +119,30 @@ private ThingsRootActor(final ThingsConfig thingsConfig, final ActorRef pubSubMe final EnforcementConfig enforcementConfig = DefaultEnforcementConfig.of( DefaultScopedConfig.dittoScoped(actorSystem.settings().config()) ); + + // Left null when the feature toggle is off; publishEvent null-guards it, so the write + // path pays nothing in deployments without the timeseries service. + final ActorRef timeseriesIngestPublisher; + if (FeatureToggle.isTimeseriesFeatureEnabled()) { + // Sends buffer at the proxy until a node with the "timeseries" role joins, so the + // timeseries service can be deployed after this one. + final ActorRef timeseriesShardRegionProxy = ShardRegionProxyActorFactory + .newInstance(actorSystem, clusterConfig) + .getShardRegionProxyActor(TimeseriesMessagingConstants.CLUSTER_ROLE, + TimeseriesMessagingConstants.SHARD_REGION); + + // Safe to look up ahead of the WoT initialisation at the end of this constructor: + // DittoWotIntegration is an actor-system extension. + final DittoWotIntegration earlyWotIntegration = DittoWotIntegration.get(actorSystem); + + timeseriesIngestPublisher = startChildActor( + TimeseriesIngestPublisher.ACTOR_NAME, + TimeseriesIngestPublisher.props(timeseriesShardRegionProxy, + earlyWotIntegration.getWotThingModelResolver())); + } else { + timeseriesIngestPublisher = null; + } + final Props thingSupervisorActorProps = getThingSupervisorActorProps(pubSubMediator, thingsConfig, enforcementConfig, @@ -123,7 +151,8 @@ private ThingsRootActor(final ThingsConfig thingsConfig, final ActorRef pubSubMe propsFactory, blockedNamespaces, policyEnforcerProvider, - mongoReadJournal + mongoReadJournal, + timeseriesIngestPublisher ); final ActorRef thingsShardRegion = @@ -302,10 +331,11 @@ private static Props getThingSupervisorActorProps(final ActorRef pubSubMediator, final ThingPersistenceActorPropsFactory propsFactory, final BlockedNamespaces blockedNamespaces, final PolicyEnforcerProvider policyEnforcerProvider, - final MongoReadJournal mongoReadJournal) { + final MongoReadJournal mongoReadJournal, + final ActorRef timeseriesIngestPublisher) { return ThingSupervisorActor.props(pubSubMediator, thingsConfig, enforcementConfig, distributedPubThingEventsForTwin, liveSignalPub, propsFactory, blockedNamespaces, - policyEnforcerProvider, mongoReadJournal); + policyEnforcerProvider, mongoReadJournal, timeseriesIngestPublisher); } private static MongoReadJournal newMongoReadJournal(final MongoDbConfig mongoDbConfig, diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/ThingEventLeafExtractor.java b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/ThingEventLeafExtractor.java new file mode 100644 index 00000000000..9b9846ceadc --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/ThingEventLeafExtractor.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.things.service.timeseries; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.signals.events.ThingEvent; + +/** + * Decomposes any {@link ThingEvent} into the scalar feature-property leaves it changed. Driven + * by {@code event.getResourcePath()} + {@code event.getEntity()}, so every create/modify/merge + * shape is handled without enumerating event subtypes. Non-feature resources (attributes, + * policyId, ...) and value-less events (deletes) yield no leaves. + */ +final class ThingEventLeafExtractor { + + private static final String FEATURES_FIELD = "features"; + private static final String PROPERTIES_FIELD = "properties"; + + private ThingEventLeafExtractor() { + throw new AssertionError(); + } + + static List extractLeaves(final ThingEvent event) { + final JsonValue value = event.getEntity().orElse(null); + if (value == null) { + return List.of(); + } + final List leaves = new ArrayList<>(); + decompose(event.getResourcePath(), value, leaves); + return leaves; + } + + /** + * Maps a resource path + value onto feature-property leaves, handling every granularity: + * whole Thing ({@code /}), all features ({@code /features}), a single feature, a feature's + * properties, or a sub-path within one property. + */ + private static void decompose(final JsonPointer path, final JsonValue value, + final List leaves) { + + final int levels = path.getLevelCount(); + if (levels == 0) { + asObject(value) + .flatMap(thingJson -> objectAt(thingJson, FEATURES_FIELD)) + .ifPresent(features -> mergeFeatures(features, leaves)); + return; + } + if (!FEATURES_FIELD.equals(path.get(0).map(Object::toString).orElse(null))) { + return; + } + if (levels == 1) { + asObject(value).ifPresent(features -> mergeFeatures(features, leaves)); + return; + } + final String featureId = path.get(1).map(Object::toString).orElse(null); + if (featureId == null) { + return; + } + if (levels == 2) { + asObject(value) + .flatMap(featureJson -> objectAt(featureJson, PROPERTIES_FIELD)) + .ifPresent(props -> flattenProperties(featureId, props, leaves)); + return; + } + if (!PROPERTIES_FIELD.equals(path.get(2).map(Object::toString).orElse(null))) { + return; + } + if (levels == 3) { + asObject(value).ifPresent(props -> flattenProperties(featureId, props, leaves)); + return; + } + // levels >= 4: features//properties/ + flatten(featureId, path.getSubPointer(3).orElse(JsonPointer.empty()), value, leaves); + } + + private static void mergeFeatures(final JsonObject features, final List leaves) { + for (final JsonField feature : features) { + asObject(feature.getValue()) + .flatMap(featureJson -> objectAt(featureJson, PROPERTIES_FIELD)) + .ifPresent(props -> flattenProperties(feature.getKeyName(), props, leaves)); + } + } + + private static void flattenProperties(final String featureId, final JsonObject properties, + final List leaves) { + + for (final JsonField field : properties) { + flatten(featureId, JsonPointer.empty().addLeaf(field.getKey()), field.getValue(), leaves); + } + } + + /** + * Reduces {@code value} to scalar leaves. Objects descend one key at a time; arrays are + * skipped (a single data point is a scalar reading); scalars (incl. null) emit a leaf. + */ + private static void flatten(final String featureId, final JsonPointer base, final JsonValue value, + final List leaves) { + + if (value.isObject()) { + for (final JsonField field : value.asObject()) { + flatten(featureId, base.addLeaf(field.getKey()), field.getValue(), leaves); + } + } else if (!value.isArray()) { + leaves.add(new PropertyLeaf(featureId, base, value)); + } + } + + private static Optional asObject(final JsonValue value) { + return value.isObject() ? Optional.of(value.asObject()) : Optional.empty(); + } + + private static Optional objectAt(final JsonObject object, final String key) { + return object.getValue(key).filter(JsonValue::isObject).map(JsonValue::asObject); + } + + record PropertyLeaf(String featureId, JsonPointer path, JsonValue value) {} +} diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisher.java b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisher.java new file mode 100644 index 00000000000..5b44c0bfa3e --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisher.java @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.things.service.timeseries; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionException; + +import javax.annotation.Nullable; + +import org.apache.pekko.actor.AbstractActor; +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.Props; +import org.apache.pekko.actor.Status; +import org.apache.pekko.pattern.Patterns; +import org.eclipse.ditto.base.model.entity.id.WithEntityId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.internal.utils.metrics.DittoMetrics; +import org.eclipse.ditto.internal.utils.metrics.instruments.counter.Counter; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoLoggerFactory; +import org.eclipse.ditto.internal.utils.pekko.logging.ThreadSafeDittoLogger; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.placeholders.ExpressionResolver; +import org.eclipse.ditto.placeholders.PlaceholderFactory; +import org.eclipse.ditto.placeholders.PlaceholderFilter; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingDefinition; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.events.ThingEvent; +import org.eclipse.ditto.things.service.timeseries.ThingEventLeafExtractor.PropertyLeaf; +import org.eclipse.ditto.things.service.timeseries.WotLeafResolver.ResolvedLeaf; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.WotTimeseriesAnnotation; +import org.eclipse.ditto.wot.api.resolver.ThingSubmodel; +import org.eclipse.ditto.wot.api.resolver.WotThingModelResolver; +import org.eclipse.ditto.wot.model.IRI; +import org.eclipse.ditto.wot.model.ThingModel; + +/** + * Per-Things-service-node actor that turns {@link ThingEvent}s into {@link IngestDataPoints} + * batches and asks the Timeseries shard region with bounded retries until the persistent entity + * acks. + *

+ * Each event is decomposed into the scalar feature-property leaves it changed + * ({@link ThingEventLeafExtractor}), and every leaf whose WoT schema carries a + * {@code ditto:timeseries} annotation with {@code ingest = ALL} ({@link WotLeafResolver}) becomes + * one {@link TimeseriesDataPoint}. All points produced from one event ship in a single batch. The + * annotation may sit on a nested property (e.g. {@code flowTemperature/temperature}), so a Thing + * can opt a single scalar into ingest without dragging in sibling fields (e.g. an + * {@code updatedAt} timestamp). This actor is opt-in: a Thing without any matching annotation + * produces no points and no traffic. + * + *

Delivery model

+ * Best-effort at-least-once with a bounded retry budget. Two crash domains: + *
    + *
  • Timeseries-side crashes (entity host node, MongoDB unavailable, network partition + * between Things and Timeseries) are covered by the retry loop: up to {@link #MAX_ATTEMPTS} + * attempts with the same correlation-id, which the receiving entity uses to recognise replays + * and avoid duplicate writes (`recentlyApplied` LRU on the entity side).
  • + *
  • Things-side crashes between {@code ThingPersistenceActor.publishEvent} firing the + * {@link IngestRequest} and the publisher's ask completing are not covered. The + * publisher is non-persistent; mailbox, in-flight WoT-resolution stages and in-flight asks are + * in-memory only. A Things-service JVM crash in that window loses the affected batches. The + * Things journal records the originating event durably, but {@code publishEvent} is a + * post-persist hook that only fires for new events going forward — recovery does not re-fire + * it. Closing this gap would require a persistent local publisher journal or a Timeseries-side + * replay against {@code MongoReadJournal}; neither is in Phase 1 scope, and the IoT + * property-stream use case tolerates the resulting occasional gaps.
  • + *
+ * The receiving entity is intentionally non-persistent (see {@code TimeseriesIngestActor} + * Javadoc, "No Pekko Persistence" section): the MongoDB Time Series collection is the durable + * truth, and this publisher's retry already covers the Timeseries-side crash window the journal + * would have protected. + * + *

WoT resolution

+ * {@link WotThingModelResolver} caches resolved ThingModels and submodel maps internally, so + * steady-state updates hit the cache; cold lookups pay one HTTP fetch per submodel. Resolution + * is composed with {@code thenCompose} on the WoT executor — the actor thread never blocks. + * + *

Concurrency

+ * Per Ditto's actor-concurrency rules, no actor state is mutated from inside + * {@link java.util.concurrent.CompletionStage} callbacks. The actor itself holds only + * constructor-immutable fields. + */ +public final class TimeseriesIngestPublisher extends AbstractActor { + + /** Name of this actor in the actor system. */ + public static final String ACTOR_NAME = TimeseriesMessagingConstants.INGEST_PUBLISHER_ACTOR_NAME; + + private static final Duration DEFAULT_ASK_TIMEOUT = Duration.ofSeconds(5); + + /** + * Maximum attempts (incl. first) before a batch is dropped with WARN. A small ceiling keeps + * a struggling backend from amplifying back-pressure into the publisher's mailbox; lost + * batches surface as gaps in the timeseries data rather than silent corruption. + */ + private static final int MAX_ATTEMPTS = 3; + + /** Kamon {@code timeseries_ingest_dropped} — incremented per batch given up after retries. */ + private static final Counter DROPPED_BATCHES = + DittoMetrics.counter("timeseries_ingest_dropped"); + + // Thread-safe variant — log calls happen inside CompletionStage callbacks (WoT resolution, + // Patterns.ask retries) which run off the actor thread. + private static final ThreadSafeDittoLogger LOGGER = + DittoLoggerFactory.getThreadSafeLogger(TimeseriesIngestPublisher.class); + + private final ActorRef shardRegionProxy; + private final WotThingModelResolver wotResolver; + private final Duration askTimeout; + + @SuppressWarnings("unused") + private TimeseriesIngestPublisher(final ActorRef shardRegionProxy, + final WotThingModelResolver wotResolver, + final Duration askTimeout) { + + this.shardRegionProxy = checkNotNull(shardRegionProxy, "shardRegionProxy"); + this.wotResolver = checkNotNull(wotResolver, "wotResolver"); + this.askTimeout = checkNotNull(askTimeout, "askTimeout"); + } + + /** + * @param shardRegionProxy proxy to the {@link TimeseriesMessagingConstants#SHARD_REGION} + * shard region on Timeseries-service nodes. + * @param wotResolver the shared WoT ThingModel resolver from {@code DittoWotIntegration}. + */ + public static Props props(final ActorRef shardRegionProxy, final WotThingModelResolver wotResolver) { + return props(shardRegionProxy, wotResolver, DEFAULT_ASK_TIMEOUT); + } + + /** Visible for tests so the ask-timeout can be tightened to keep tests fast. */ + public static Props props(final ActorRef shardRegionProxy, + final WotThingModelResolver wotResolver, + final Duration askTimeout) { + + return Props.create(TimeseriesIngestPublisher.class, shardRegionProxy, wotResolver, askTimeout); + } + + @Override + public Receive createReceive() { + return receiveBuilder() + .match(IngestRequest.class, this::handleIngestRequest) + .match(RetrySend.class, retry -> sendWithRetry(retry.command(), retry.attempt())) + .build(); + } + + private void handleIngestRequest(final IngestRequest request) { + final ThingEvent event = request.event(); + final Thing thing = request.thing(); + + final List leaves = ThingEventLeafExtractor.extractLeaves(event); + if (leaves.isEmpty()) { + return; + } + if (thing == null) { + LOGGER.debug("Dropping ingest request for event <{}>: post-event Thing snapshot was null.", + event.getType()); + return; + } + final Optional tmIri = thing.getDefinition().map(TimeseriesIngestPublisher::toTmIri); + if (tmIri.isEmpty()) { + LOGGER.debug("Dropping ingest request for thing <{}>: no `definition` field on the Thing.", + thing.getEntityId().orElse(null)); + return; + } + // ThingEvent's entityId is by contract a ThingId, but narrow at runtime so a stray event + // whose entityId resolves to a different type is dropped rather than throwing. + final Optional thingIdOpt = WithEntityId.getEntityIdOfType(ThingId.class, event); + if (thingIdOpt.isEmpty()) { + LOGGER.warn("Dropping ingest request for event <{}>: entityId <{}> is not a ThingId.", + event.getType(), event.getEntityId()); + return; + } + final ThingId thingId = thingIdOpt.get(); + final DittoHeaders headers = newDeliveryHeaders(event.getDittoHeaders()); + + wotResolver.resolveThingModel(tmIri.get(), headers) + .thenCompose(tm -> wotResolver.resolveThingModelSubmodels(tm, headers)) + .thenAccept(submodels -> { + final List dataPoints = + buildDataPoints(thingId, leaves, thing, submodels, event); + if (dataPoints.isEmpty()) { + return; + } + sendWithRetry(IngestDataPoints.of(thingId, dataPoints, headers), 1); + }) + .exceptionally(throwable -> { + final Throwable cause = throwable instanceof CompletionException + && throwable.getCause() != null ? throwable.getCause() : throwable; + LOGGER.debug("Could not resolve WoT TM for thing <{}>: {}", + thingId, cause.getMessage()); + return null; + }); + } + + private void sendWithRetry(final IngestDataPoints command, final int attempt) { + Patterns.ask(shardRegionProxy, command, askTimeout) + .whenComplete((response, throwable) -> { + if (throwable == null && response instanceof IngestDataPointsResponse) { + return; + } + final Throwable cause = unwrap(throwable); + final Object failureSubject = throwable != null + ? cause + : (response instanceof Status.Failure sf ? sf.cause() : response); + if (attempt < MAX_ATTEMPTS) { + LOGGER.debug("Retrying IngestDataPoints for thing <{}> (attempt {} of {}): {}", + command.getEntityId(), attempt + 1, MAX_ATTEMPTS, failureSubject); + // Schedule the retry on the actor thread so it doesn't race the next + // mailbox message. Same correlation-id signals "logical replay" to the + // receiving entity, which dedups via a bounded `recentlyApplied` ring. + getSelf().tell(new RetrySend(command, attempt + 1), getSelf()); + } else { + DROPPED_BATCHES.increment(); + LOGGER.warn("Giving up on IngestDataPoints for thing <{}> after <{}> attempts: {}", + command.getEntityId(), MAX_ATTEMPTS, failureSubject); + } + }); + } + + @Nullable + private static Throwable unwrap(@Nullable final Throwable throwable) { + if (throwable instanceof CompletionException ce && ce.getCause() != null) { + return ce.getCause(); + } + return throwable; + } + + private static IRI toTmIri(final ThingDefinition def) { + return IRI.of(def.toString()); + } + + private static List buildDataPoints(final ThingId thingId, + final List leaves, + final Thing thing, + final Map submodels, + final ThingEvent event) { + + final Instant timestamp = event.getTimestamp().orElse(Instant.now()); + final long revision = event.getRevision(); + final List dataPoints = new ArrayList<>(); + + // Resolve tag placeholders (e.g. "{{ thing-json:attributes/building }}") against the + // post-event Thing once per event — all leaves of one event share the same Thing snapshot. + final ExpressionResolver tagResolver = PlaceholderFactory.newExpressionResolver( + PlaceholderFactory.newThingJsonPlaceholder(), thing.toJson(FieldType.all())); + + for (final PropertyLeaf leaf : leaves) { + final Optional submodelTm = submodels.entrySet().stream() + .filter(e -> leaf.featureId().equals(e.getKey().instanceName())) + .map(Map.Entry::getValue) + .findFirst(); + if (submodelTm.isEmpty()) { + continue; + } + final Optional resolved = WotLeafResolver.resolveLeaf(submodelTm.get(), leaf.path()); + if (resolved.isEmpty()) { + continue; + } + final WotTimeseriesAnnotation annotation = resolved.get().annotation(); + final JsonPointer thingPath = JsonPointer.empty() + .append(JsonPointer.of("features")) + .append(JsonPointer.of(leaf.featureId())) + .append(JsonPointer.of("properties")) + .append(leaf.path()); + final Map resolvedTags = resolveTags(annotation.getTags(), tagResolver, thing); + dataPoints.add(TimeseriesDataPoint.of(thingId, thingPath, timestamp, leaf.value(), revision, + resolvedTags, resolved.get().unit())); + } + return dataPoints; + } + + /** + * Resolves each declared tag value against the post-event Thing, replacing WoT placeholder + * expressions (e.g. {@code "{{ thing-json:attributes/building }}"}) with the Thing's current + * values; constant tag values pass through unchanged. + *

+ * A tag whose placeholder resolves to no value on this Thing — or whose expression is malformed — + * is dropped for this data point rather than stored empty or failing ingestion. Tags are + * a point-in-time snapshot, so each point keeps exactly the values that were resolvable when it + * was recorded. + */ + private static Map resolveTags(final Map declared, + final ExpressionResolver resolver, final Thing thing) { + + if (declared.isEmpty()) { + return declared; + } + final Map resolved = new LinkedHashMap<>(declared.size()); + for (final Map.Entry entry : declared.entrySet()) { + try { + PlaceholderFilter.applyOrElseDelete(entry.getValue(), resolver) + .ifPresent(value -> resolved.put(entry.getKey(), value)); + } catch (final RuntimeException e) { + // Malformed/unknown placeholder in the model — drop this tag, never break ingest. + LOGGER.debug("Dropping timeseries tag <{}> for thing <{}>: {}", + entry.getKey(), thing.getEntityId().orElse(null), e.getMessage()); + } + } + return resolved; + } + + private static DittoHeaders newDeliveryHeaders(final DittoHeaders source) { + // Fresh correlation-id per delivery; reused on retry so the persistent entity recognises + // replays via its dedup table. Inheriting other headers preserves traceparent/tracestate. + return source.toBuilder() + .correlationId(UUID.randomUUID().toString()) + .build(); + } + + /** + * @param event the event that just persisted. + * @param thing the entity Thing after applying the event; may be null when the persistence + * actor is operating on a non-existent or just-deleted entity. + */ + public record IngestRequest(ThingEvent event, @Nullable Thing thing) { + public IngestRequest { + checkNotNull(event, "event"); + } + } + + /** Self-message scheduling a retry on the actor thread so it doesn't race the next message. */ + private record RetrySend(IngestDataPoints command, int attempt) {} +} diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/WotLeafResolver.java b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/WotLeafResolver.java new file mode 100644 index 00000000000..0dfd495326b --- /dev/null +++ b/things/service/src/main/java/org/eclipse/ditto/things/service/timeseries/WotLeafResolver.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.things.service.timeseries; + +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.model.Ingest; +import org.eclipse.ditto.timeseries.model.WotTimeseriesAnnotation; +import org.eclipse.ditto.wot.model.DittoWotExtension; +import org.eclipse.ditto.wot.model.ObjectSchema; +import org.eclipse.ditto.wot.model.Properties; +import org.eclipse.ditto.wot.model.Property; +import org.eclipse.ditto.wot.model.SingleDataSchema; +import org.eclipse.ditto.wot.model.ThingModel; + +/** + * Resolves a feature-property data path against a WoT submodel's typed schema, returning the + * governing {@code ditto:timeseries} annotation and the leaf's declared unit (when present). + *

+ * Path-to-property mapping mirrors {@code wot/validation/InternalValidation#findPropertyBasedOnPath} + * (which is not public): the optional leading {@code :category} segment is matched and + * stripped, then nested object properties are descended via {@link ObjectSchema#getProperties()}. + * The Ditto WoT extension prefix is taken from the model's {@code @context} via + * {@code AtContext#determinePrefixFor} — the same way the skeleton generator, TD generator and + * validator obtain it. + */ +final class WotLeafResolver { + + private static final String DEFAULT_DITTO_PREFIX = "ditto"; + + private WotLeafResolver() { + throw new AssertionError(); + } + + /** + * Walks the submodel's WoT property schema along {@code propertyPath}. Returns a match when a + * {@code ditto:timeseries} annotation with {@code ingest = ALL} sits on the leaf or on any + * ancestor along the path (the deepest, most specific one wins). The reported unit is the + * leaf schema's own {@code unit}, when declared. + */ + static Optional resolveLeaf(final ThingModel submodelTm, + final JsonPointer propertyPath) { + + final Properties properties = submodelTm.getProperties().orElse(null); + if (properties == null || propertyPath.isEmpty()) { + return Optional.empty(); + } + final String prefix = dittoExtensionPrefix(submodelTm); + final String categoryKey = prefix + ":" + DittoWotExtension.DITTO_WOT_EXTENSION_CATEGORY; + final String timeseriesKey = prefix + ":" + WotTimeseriesAnnotation.EXTENSION_LOCAL_NAME; + + final MatchedProperty matched = matchTopLevelProperty(properties, propertyPath, categoryKey); + if (matched == null) { + return Optional.empty(); + } + SingleDataSchema schema = matched.schema(); + WotTimeseriesAnnotation found = + WotTimeseriesAnnotation.findInProperty(schema.toJson(), timeseriesKey).orElse(null); + JsonPointer remaining = matched.remaining(); + while (!remaining.isEmpty()) { + final String segment = remaining.getRoot().map(Object::toString).orElse(null); + final SingleDataSchema nested = (segment == null) ? null + : ObjectSchema.fromJson(schema.toJson()).getProperties().get(segment); + if (nested == null) { + schema = null; + break; + } + schema = nested; + final WotTimeseriesAnnotation deeper = + WotTimeseriesAnnotation.findInProperty(schema.toJson(), timeseriesKey).orElse(null); + if (deeper != null) { + found = deeper; + } + remaining = remaining.nextLevel(); + } + if (found == null || found.getIngest() != Ingest.ALL) { + return Optional.empty(); + } + final String unit = (schema == null) ? null : schema.getUnit().orElse(null); + return Optional.of(new ResolvedLeaf(found, unit)); + } + + @Nullable + private static MatchedProperty matchTopLevelProperty(final Properties properties, + final JsonPointer path, final String categoryKey) { + + final String first = path.getRoot().map(Object::toString).orElse(null); + if (first == null) { + return null; + } + // Categorised form: //... — match when the named property declares + // exactly this category. + if (path.getLevelCount() >= 2) { + final String second = path.get(1).map(Object::toString).orElse(null); + final Property candidate = (second == null) ? null + : properties.getProperty(second).orElse(null); + if (candidate != null && categoryOf(candidate, categoryKey).filter(first::equals).isPresent()) { + return new MatchedProperty(candidate, path.getSubPointer(2).orElse(JsonPointer.empty())); + } + } + // Plain form: /... — only when the property declares no category. + final Property candidate = properties.getProperty(first).orElse(null); + if (candidate != null && categoryOf(candidate, categoryKey).isEmpty()) { + return new MatchedProperty(candidate, path.getSubPointer(1).orElse(JsonPointer.empty())); + } + return null; + } + + private static Optional categoryOf(final Property property, final String categoryKey) { + return property.toJson().getValue(categoryKey) + .filter(JsonValue::isString) + .map(JsonValue::asString); + } + + private static String dittoExtensionPrefix(final ThingModel submodelTm) { + try { + return submodelTm.getAtContext() + .determinePrefixFor(DittoWotExtension.DITTO_WOT_EXTENSION) + .orElse(DEFAULT_DITTO_PREFIX); + } catch (final RuntimeException e) { + // A well-formed WoT TM always declares @context, but never let a malformed model + // crash leaf resolution — fall back to the conventional prefix. + return DEFAULT_DITTO_PREFIX; + } + } + + record ResolvedLeaf(WotTimeseriesAnnotation annotation, @Nullable String unit) {} + + private record MatchedProperty(SingleDataSchema schema, JsonPointer remaining) {} +} diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/PersistenceActorTestBase.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/PersistenceActorTestBase.java index 0a7f1e42396..61e207a8416 100755 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/PersistenceActorTestBase.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/PersistenceActorTestBase.java @@ -247,14 +247,16 @@ protected ActorRef createPersistenceActorFor(final ThingId thingId) { protected ActorRef createPersistenceActorWithPubSubFor(final ThingId thingId) { return actorSystem.actorOf(getPropsOfThingPersistenceActor(thingId, Mockito.mock(MongoReadJournal.class), - thingsConfig.getThingConfig(), getDistributedPub(), null, policyEnforcerProvider)); + thingsConfig.getThingConfig(), getDistributedPub(), null, policyEnforcerProvider, null)); } private Props getPropsOfThingPersistenceActor(final ThingId thingId, final MongoReadJournal mongoReadJournal, final ThingConfig thingConfig, final DistributedPub> pub, @Nullable final ActorRef searchShardRegionProxy, - final PolicyEnforcerProvider policyEnforcerProvider) { - return ThingPersistenceActor.props(thingId, mongoReadJournal, thingConfig, pub, searchShardRegionProxy, policyEnforcerProvider); + final PolicyEnforcerProvider policyEnforcerProvider, + @Nullable final ActorRef timeseriesIngestPublisher) { + return ThingPersistenceActor.props(thingId, mongoReadJournal, thingConfig, pub, searchShardRegionProxy, + policyEnforcerProvider, timeseriesIngestPublisher); } protected ActorRef createSupervisorActorFor(final ThingId thingId) { diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorTest.java index 3327aa8afcd..c253667442c 100755 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceActorTest.java @@ -2101,7 +2101,7 @@ public void unavailableExpectedAndPolicyIsDeletedIfPersistenceActorFails() { ThingId thingId = getIdOrThrow(thing); ActorRef underTest = createSupervisorActorWithCustomPersistenceActor(thingId, - (thingId1, mongoReadJournal, thingConfig, distributedPub, searchShardRegionProxy, policyEnforcerProvider) -> FailingInCtorActor.props()); + (thingId1, mongoReadJournal, thingConfig, distributedPub, searchShardRegionProxy, policyEnforcerProvider, timeseriesIngestPublisher) -> FailingInCtorActor.props()); CreateThing createThing = CreateThing.of(thing, null, dittoHeaders); underTest.tell(createThing, getRef()); @@ -2141,7 +2141,7 @@ public void policyShouldNotBeDeletedOnThingRetrieveAndActorFail() { ThingId thingId = getIdOrThrow(thing); ActorRef underTest = createSupervisorActorWithCustomPersistenceActor(thingId, - (thingId1, mongoReadJournal, thingConfig, distributedPub, searchShardRegionProxy, policyEnforcerProvider) -> FailingInCtorActor.props()); + (thingId1, mongoReadJournal, thingConfig, distributedPub, searchShardRegionProxy, policyEnforcerProvider, timeseriesIngestPublisher) -> FailingInCtorActor.props()); RetrieveThing retrieveThing = RetrieveThing.of(thingId, dittoHeaders); underTest.tell(retrieveThing, getRef()); diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceOperationsActorIT.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceOperationsActorIT.java index 600dd58db8b..248159e04d3 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceOperationsActorIT.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ThingPersistenceOperationsActorIT.java @@ -170,7 +170,8 @@ public > Object wrapForPublicationWithAcks(final S messa ThingPersistenceActor::props, null, policyEnforcerProvider, - Mockito.mock(MongoReadJournal.class)); + Mockito.mock(MongoReadJournal.class), + null); return system.actorOf(props, id.toString()); } diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandRegistryTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandRegistryTest.java index e417f384fba..f8c9369aa25 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandRegistryTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandRegistryTest.java @@ -38,6 +38,8 @@ import org.eclipse.ditto.things.model.devops.commands.RetrieveMergedWotValidationConfig; import org.eclipse.ditto.things.model.devops.commands.RetrieveWotValidationConfig; import org.eclipse.ditto.things.model.devops.commands.ModifyWotValidationConfig; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; public final class ThingsServiceGlobalCommandRegistryTest extends GlobalCommandRegistryTestCases { @@ -68,7 +70,9 @@ public ThingsServiceGlobalCommandRegistryTest() { RetrieveMergedWotValidationConfig.class, RetrieveWotValidationConfig.class, ModifyWotValidationConfig.class, - CheckPermissions.class + CheckPermissions.class, + IngestDataPoints.class, + RetrieveTimeseries.class ); } diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandResponseRegistryTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandResponseRegistryTest.java index d42bfdc33f0..c58c98aeac8 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandResponseRegistryTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalCommandResponseRegistryTest.java @@ -37,6 +37,8 @@ import org.eclipse.ditto.thingsearch.api.commands.sudo.SudoRetrieveNamespaceReportResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.SearchErrorResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThingsResponse; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; public final class ThingsServiceGlobalCommandResponseRegistryTest extends GlobalCommandResponseRegistryTestCases { @@ -65,7 +67,9 @@ public ThingsServiceGlobalCommandResponseRegistryTest() { SudoRetrieveNamespaceReportResponse.class, RetrieveMergedWotValidationConfigResponse.class, RetrieveWotValidationConfigResponse.class, - CheckPermissionsResponse.class + CheckPermissionsResponse.class, + RetrieveTimeseriesResponse.class, + IngestDataPointsResponse.class ); } diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalErrorRegistryTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalErrorRegistryTest.java index 176e3b9d2f2..54807a6ac17 100644 --- a/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalErrorRegistryTest.java +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/starter/ThingsServiceGlobalErrorRegistryTest.java @@ -36,6 +36,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.AttributePointerInvalidException; import org.eclipse.ditto.thingsearch.api.QueryTimeExceededException; import org.eclipse.ditto.thingsearch.model.signals.commands.exceptions.InvalidOptionException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; import org.eclipse.ditto.wot.model.WotThingModelInvalidException; import org.eclipse.ditto.wot.validation.WotThingModelPayloadValidationException; @@ -66,7 +67,8 @@ public ThingsServiceGlobalErrorRegistryTest() { QueryTimeExceededException.class, WotThingModelPayloadValidationException.class, WotValidationConfigNotAccessibleException.class, - ServiceTimeoutException.class + ServiceTimeoutException.class, + TimeseriesQueryInvalidException.class ); } diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisherTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisherTest.java new file mode 100644 index 00000000000..95fb615628a --- /dev/null +++ b/things/service/src/test/java/org/eclipse/ditto/things/service/timeseries/TimeseriesIngestPublisherTest.java @@ -0,0 +1,564 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.things.service.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.actor.Status; +import org.apache.pekko.testkit.TestProbe; +import org.apache.pekko.testkit.javadsl.TestKit; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.events.FeaturePropertyModified; +import org.eclipse.ditto.things.model.signals.events.ThingMerged; +import org.eclipse.ditto.things.model.signals.events.ThingModified; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.wot.api.resolver.ThingSubmodel; +import org.eclipse.ditto.wot.api.resolver.WotThingModelResolver; +import org.eclipse.ditto.wot.model.IRI; +import org.eclipse.ditto.wot.model.ThingModel; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.typesafe.config.ConfigFactory; + +/** + * Unit tests for {@link TimeseriesIngestPublisher}. The publisher is the + * non-persistent half of the ingest pipeline; its contract is "given an event + + * post-event Thing entity + a WoT TM with {@code ditto:timeseries} annotations, ask + * the timeseries shard region with the right {@link IngestDataPoints}, retry on + * missing acks, give up after {@code MAX_ATTEMPTS}." The persistent shard entity is + * tested separately in {@code TimeseriesIngestActorTest}. + */ +public final class TimeseriesIngestPublisherTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final IRI TM_IRI = IRI.of("https://example.com/sensor-1.tm.jsonld"); + private static final String FEATURE_ID = "env"; + private static final String PROPERTY_NAME = "temperature"; + private static final String NESTED_FEATURE_PROPERTY = "status"; + + // Mirrors a real ThingModel @context: the Ditto WoT extension bound to the "ditto" prefix, + // so the publisher's getAtContext().determinePrefixFor(...) resolves it. + private static final JsonArray DITTO_CONTEXT = JsonFactory.newArrayBuilder() + .add("https://www.w3.org/2022/wot/td/v1.1") + .add(JsonFactory.newObjectBuilder() + .set("ditto", "https://ditto.eclipseprojects.io/wot/ditto-extension#") + .build()) + .build(); + + private static ActorSystem actorSystem; + + @BeforeClass + public static void beforeClass() { + actorSystem = ActorSystem.create("TimeseriesIngestPublisherTest", ConfigFactory.empty()); + } + + @AfterClass + public static void afterClass() { + if (actorSystem != null) { + TestKit.shutdownActorSystem(actorSystem); + actorSystem = null; + } + } + + @Test + public void propertyWithIngestAllAnnotationProducesIngestDataPoints() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("ALL", Map.of())); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinition()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + // Ack so the publisher does not retry — not under test in this case. + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat((Object) command.getEntityId()).isEqualTo(THING_ID); + assertThat(command.getDataPoints()).hasSize(1); + assertThat(command.getDataPoints().get(0).getValue()).isEqualTo(JsonValue.of(21.5)); + assertThat((Object) command.getDataPoints().get(0).getPath()) + .isEqualTo(JsonPointer.of("/features/env/properties/temperature")); + }}; + } + + @Test + public void tagPlaceholdersAreResolvedAgainstThing() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + // "building" is a placeholder into the Thing's attributes; "env" is a constant. + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("ALL", Map.of( + "building", "{{ thing-json:attributes/building }}", + "env", "prod"))); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinitionAndAttributes()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints().get(0).getTags()) + .containsEntry("building", "A") + .containsEntry("env", "prod"); + }}; + } + + @Test + public void unresolvableTagPlaceholderIsDroppedNotStoredEmpty() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + // "floor" points to an attribute the Thing does not have -> that tag is dropped for the + // point (not stored empty, not failing ingestion); the constant "env" survives. + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("ALL", Map.of( + "floor", "{{ thing-json:attributes/floor }}", + "env", "prod"))); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinitionAndAttributes()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints().get(0).getTags()) + .containsEntry("env", "prod") + .doesNotContainKey("floor"); + }}; + } + + @Test + public void propertyWithIngestNoneAnnotationDoesNotPublish() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("NONE", Map.of())); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinition()), getRef()); + + shardProbe.expectNoMessage(scala.concurrent.duration.Duration.create(300, TimeUnit.MILLISECONDS)); + }}; + } + + @Test + public void propertyWithoutAnnotationDoesNotPublish() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(submodelWithoutAnnotation()); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinition()), getRef()); + + shardProbe.expectNoMessage(scala.concurrent.duration.Duration.create(300, TimeUnit.MILLISECONDS)); + }}; + } + + @Test + public void thingWithoutDefinitionDoesNotPublish() { + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + // Strict resolver that fails if asked — proves the publisher never calls it. + final WotThingModelResolver resolver = mock(WotThingModelResolver.class); + when(resolver.resolveThingModel(any(IRI.class), any(DittoHeaders.class))) + .thenThrow(new AssertionError("resolveThingModel should not be called for a definition-less Thing")); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithoutDefinition()), getRef()); + + shardProbe.expectNoMessage(scala.concurrent.duration.Duration.create(300, TimeUnit.MILLISECONDS)); + }}; + } + + @Test + public void retriesOnNoAck() { + // Critical contract: when the shard region doesn't reply within the ask + // timeout, the publisher retries with the same correlation-id so the + // persistent entity recognises the replay. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("ALL", Map.of())); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofMillis(150))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinition()), getRef()); + + final IngestDataPoints first = shardProbe.expectMsgClass(IngestDataPoints.class); + + final IngestDataPoints second = shardProbe.expectMsgClass( + scala.concurrent.duration.Duration.create(2, TimeUnit.SECONDS), + IngestDataPoints.class); + assertThat(second.getDittoHeaders().getCorrelationId()) + .isEqualTo(first.getDittoHeaders().getCorrelationId()); + + // Ack the second to terminate retries cleanly — leaving it would result in + // a third attempt and then a WARN log, which isn't under test here. + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, second.getDittoHeaders())); + }}; + } + + @Test + public void retriesOnStatusFailureFromShard() { + // Status.Failure from the entity (e.g. transient MongoDB error) is treated + // identically to a timeout — retry. Either condition could be transient and + // benefit from a re-send. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(annotatedSubmodel("ALL", Map.of())); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + publisher.tell(new TimeseriesIngestPublisher.IngestRequest( + sampleEvent(JsonValue.of(21.5)), thingWithDefinition()), getRef()); + + final IngestDataPoints first = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(new Status.Failure(new RuntimeException("backend hiccup"))); + + final IngestDataPoints second = shardProbe.expectMsgClass(IngestDataPoints.class); + assertThat(second.getDittoHeaders().getCorrelationId()) + .isEqualTo(first.getDittoHeaders().getCorrelationId()); + + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, second.getDittoHeaders())); + }}; + } + + @Test + public void nestedScalarAnnotationIngestsOnlyAnnotatedLeaf() { + // The object property `status` is set in one go to {temperature, updatedAt}; only + // the nested `temperature` carries the annotation, so `updatedAt` must be skipped. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(nestedAnnotatedSubmodel()); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + final FeaturePropertyModified event = FeaturePropertyModified.of(THING_ID, FEATURE_ID, + JsonPointer.of(NESTED_FEATURE_PROPERTY), statusValue(), 1L, + Instant.parse("2026-01-01T00:00:00Z"), DittoHeaders.empty(), null); + publisher.tell(new TimeseriesIngestPublisher.IngestRequest(event, thingWithNestedStatus()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints()).hasSize(1); + assertThat((Object) command.getDataPoints().get(0).getPath()) + .isEqualTo(JsonPointer.of("/features/env/properties/status/temperature")); + assertThat(command.getDataPoints().get(0).getValue()).isEqualTo(JsonValue.of(55.0)); + }}; + } + + @Test + public void mergeEventProducesIngestDataPoints() { + // A merge command targeting features/env/properties/status produces a ThingMerged; + // the publisher must decompose the merged object and ingest the annotated leaf. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(nestedAnnotatedSubmodel()); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + final ThingMerged event = ThingMerged.of(THING_ID, + JsonPointer.of("/features/env/properties/status"), statusValue(), 1L, + Instant.parse("2026-01-01T00:00:00Z"), DittoHeaders.empty(), null); + publisher.tell(new TimeseriesIngestPublisher.IngestRequest(event, thingWithNestedStatus()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints()).hasSize(1); + assertThat((Object) command.getDataPoints().get(0).getPath()) + .isEqualTo(JsonPointer.of("/features/env/properties/status/temperature")); + assertThat(command.getDataPoints().get(0).getValue()).isEqualTo(JsonValue.of(55.0)); + }}; + } + + @Test + public void thingModifiedReplaceProducesIngestDataPoints() { + // A full-Thing replace fires ThingModified; the publisher must walk every feature's + // properties and ingest the annotated leaf. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(nestedAnnotatedSubmodel()); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + final Thing thing = thingWithNestedStatus(); + final ThingModified event = ThingModified.of(thing, 1L, + Instant.parse("2026-01-01T00:00:00Z"), DittoHeaders.empty(), null); + publisher.tell(new TimeseriesIngestPublisher.IngestRequest(event, thing), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints()).hasSize(1); + assertThat((Object) command.getDataPoints().get(0).getPath()) + .isEqualTo(JsonPointer.of("/features/env/properties/status/temperature")); + assertThat(command.getDataPoints().get(0).getValue()).isEqualTo(JsonValue.of(55.0)); + }}; + } + + @Test + public void categorisedNestedAnnotationResolvesUnderCategorySegment() { + // flowTemperature declares ditto:category "status", so its data lives under + // status/flowTemperature/...; the annotated nested temperature must still resolve. + new TestKit(actorSystem) {{ + final TestProbe shardProbe = new TestProbe(actorSystem); + final WotThingModelResolver resolver = resolverWith(categorisedAnnotatedSubmodel()); + + final ActorRef publisher = actorSystem.actorOf( + TimeseriesIngestPublisher.props(shardProbe.ref(), resolver, Duration.ofSeconds(2))); + + final FeaturePropertyModified event = FeaturePropertyModified.of(THING_ID, FEATURE_ID, + JsonPointer.of("status/flowTemperature"), statusValue(), 1L, + Instant.parse("2026-01-01T00:00:00Z"), DittoHeaders.empty(), null); + publisher.tell(new TimeseriesIngestPublisher.IngestRequest(event, thingWithNestedStatus()), getRef()); + + final IngestDataPoints command = shardProbe.expectMsgClass(IngestDataPoints.class); + shardProbe.reply(IngestDataPointsResponse.of(THING_ID, command.getDittoHeaders())); + + assertThat(command.getDataPoints()).hasSize(1); + assertThat((Object) command.getDataPoints().get(0).getPath()) + .isEqualTo(JsonPointer.of("/features/env/properties/status/flowTemperature/temperature")); + assertThat(command.getDataPoints().get(0).getValue()).isEqualTo(JsonValue.of(55.0)); + }}; + } + + // --- WoT TM construction helpers --- + + private static WotThingModelResolver resolverWith(final ThingModel submodel) { + final WotThingModelResolver resolver = mock(WotThingModelResolver.class); + final ThingModel topLevel = ThingModel.fromJson(JsonObject.empty()); + when(resolver.resolveThingModel(any(IRI.class), any(DittoHeaders.class))) + .thenReturn(CompletableFuture.completedFuture(topLevel)); + final Map submodels = new HashMap<>(); + submodels.put(new ThingSubmodel(FEATURE_ID, IRI.of("https://example.com/env.tm.jsonld")), + submodel); + when(resolver.resolveThingModelSubmodels(any(ThingModel.class), any(DittoHeaders.class))) + .thenReturn(CompletableFuture.completedFuture(submodels)); + return resolver; + } + + private static ThingModel annotatedSubmodel(final String ingestMode, final Map tags) { + final JsonObject tagsJson = tags.entrySet().stream() + .reduce(JsonFactory.newObjectBuilder(), + (b, e) -> b.set(e.getKey(), e.getValue()), + (a, b) -> a) + .build(); + final JsonObject annotation = JsonFactory.newObjectBuilder() + .set("ingest", ingestMode) + .set("tags", tagsJson) + .build(); + final JsonObject propertyJson = JsonFactory.newObjectBuilder() + .set("type", "number") + .set("unit", "Cel") + .set("ditto:timeseries", annotation) + .build(); + final JsonObject properties = JsonFactory.newObjectBuilder() + .set(PROPERTY_NAME, propertyJson) + .build(); + return ThingModel.fromJson(JsonObject.newBuilder() + .set("@context", DITTO_CONTEXT) + .set("@type", "tm:ThingModel") + .set("properties", properties) + .build()); + } + + private static ThingModel nestedAnnotatedSubmodel() { + // An object property `status` with an annotated nested `temperature` scalar and an + // un-annotated `updatedAt` sibling — mirrors the heatsense WoT temperature submodels. + final JsonObject annotation = JsonFactory.newObjectBuilder() + .set("ingest", "ALL") + .build(); + final JsonObject temperatureSchema = JsonFactory.newObjectBuilder() + .set("type", "number") + .set("unit", "Cel") + .set("ditto:timeseries", annotation) + .build(); + final JsonObject updatedAtSchema = JsonFactory.newObjectBuilder() + .set("type", "string") + .set("format", "date-time") + .build(); + final JsonObject nestedProperties = JsonFactory.newObjectBuilder() + .set("temperature", temperatureSchema) + .set("updatedAt", updatedAtSchema) + .build(); + final JsonObject statusSchema = JsonFactory.newObjectBuilder() + .set("type", "object") + .set("properties", nestedProperties) + .build(); + final JsonObject properties = JsonFactory.newObjectBuilder() + .set(NESTED_FEATURE_PROPERTY, statusSchema) + .build(); + return ThingModel.fromJson(JsonObject.newBuilder() + .set("@context", DITTO_CONTEXT) + .set("@type", "tm:ThingModel") + .set("properties", properties) + .build()); + } + + private static ThingModel categorisedAnnotatedSubmodel() { + // flowTemperature is an object property grouped under ditto:category "status", with an + // annotated nested temperature scalar — mirrors the heatsense circuit submodel. + final JsonObject annotation = JsonFactory.newObjectBuilder() + .set("ingest", "ALL") + .build(); + final JsonObject temperatureSchema = JsonFactory.newObjectBuilder() + .set("type", "number") + .set("unit", "Cel") + .set("ditto:timeseries", annotation) + .build(); + final JsonObject updatedAtSchema = JsonFactory.newObjectBuilder() + .set("type", "string") + .set("format", "date-time") + .build(); + final JsonObject nestedProperties = JsonFactory.newObjectBuilder() + .set("temperature", temperatureSchema) + .set("updatedAt", updatedAtSchema) + .build(); + final JsonObject flowTemperatureSchema = JsonFactory.newObjectBuilder() + .set("type", "object") + .set("ditto:category", "status") + .set("properties", nestedProperties) + .build(); + final JsonObject properties = JsonFactory.newObjectBuilder() + .set("flowTemperature", flowTemperatureSchema) + .build(); + return ThingModel.fromJson(JsonObject.newBuilder() + .set("@context", DITTO_CONTEXT) + .set("@type", "tm:ThingModel") + .set("properties", properties) + .build()); + } + + private static JsonObject statusValue() { + return JsonFactory.newObjectBuilder() + .set("temperature", JsonValue.of(55.0)) + .set("updatedAt", JsonValue.of("2026-01-01T00:00:00Z")) + .build(); + } + + private static Thing thingWithNestedStatus() { + return ThingsModelFactory.newThingBuilder() + .setId(THING_ID) + .setDefinition(ThingsModelFactory.newDefinition(TM_IRI.toString())) + .setFeature(ThingsModelFactory.newFeatureBuilder() + .properties(ThingsModelFactory.newFeaturePropertiesBuilder() + .set(NESTED_FEATURE_PROPERTY, statusValue()) + .build()) + .withId(FEATURE_ID) + .build()) + .build(); + } + + private static ThingModel submodelWithoutAnnotation() { + final JsonObject propertyJson = JsonFactory.newObjectBuilder() + .set("type", "number") + .build(); + final JsonObject properties = JsonFactory.newObjectBuilder() + .set(PROPERTY_NAME, propertyJson) + .build(); + return ThingModel.fromJson(JsonObject.newBuilder() + .set("@context", DITTO_CONTEXT) + .set("@type", "tm:ThingModel") + .set("properties", properties) + .build()); + } + + private static FeaturePropertyModified sampleEvent(final JsonValue value) { + return FeaturePropertyModified.of(THING_ID, FEATURE_ID, JsonPointer.of(PROPERTY_NAME), + value, 1L, Instant.parse("2026-01-01T00:00:00Z"), + DittoHeaders.empty(), null); + } + + private static Thing thingWithDefinition() { + return ThingsModelFactory.newThingBuilder() + .setId(THING_ID) + .setDefinition(ThingsModelFactory.newDefinition(TM_IRI.toString())) + .setFeature(ThingsModelFactory.newFeatureBuilder() + .properties(ThingsModelFactory.newFeaturePropertiesBuilder() + .set(PROPERTY_NAME, JsonValue.of(21.5)) + .build()) + .withId(FEATURE_ID) + .build()) + .build(); + } + + private static Thing thingWithDefinitionAndAttributes() { + return ThingsModelFactory.newThingBuilder() + .setId(THING_ID) + .setDefinition(ThingsModelFactory.newDefinition(TM_IRI.toString())) + .setAttribute(JsonPointer.of("building"), JsonValue.of("A")) + .setFeature(ThingsModelFactory.newFeatureBuilder() + .properties(ThingsModelFactory.newFeaturePropertiesBuilder() + .set(PROPERTY_NAME, JsonValue.of(21.5)) + .build()) + .withId(FEATURE_ID) + .build()) + .build(); + } + + private static Thing thingWithoutDefinition() { + return ThingsModelFactory.newThingBuilder() + .setId(THING_ID) + .setFeature(ThingsModelFactory.newFeatureBuilder() + .properties(ThingsModelFactory.newFeaturePropertiesBuilder() + .set(PROPERTY_NAME, JsonValue.of(21.5)) + .build()) + .withId(FEATURE_ID) + .build()) + .build(); + } +} diff --git a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingSearchServiceGlobalErrorRegistryTest.java b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingSearchServiceGlobalErrorRegistryTest.java index 20305686829..877d27f9e8a 100644 --- a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingSearchServiceGlobalErrorRegistryTest.java +++ b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingSearchServiceGlobalErrorRegistryTest.java @@ -42,6 +42,7 @@ import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; import org.eclipse.ditto.thingsearch.api.QueryTimeExceededException; import org.eclipse.ditto.thingsearch.model.signals.commands.exceptions.InvalidNamespacesException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; public final class ThingSearchServiceGlobalErrorRegistryTest extends GlobalErrorRegistryTestCases { @@ -74,7 +75,8 @@ public ThingSearchServiceGlobalErrorRegistryTest() { UnknownSignalException.class, IllegalAdaptableException.class, WotValidationConfigNotAccessibleException.class, - ServiceTimeoutException.class + ServiceTimeoutException.class, + TimeseriesQueryInvalidException.class ); } diff --git a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandRegistryTest.java b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandRegistryTest.java index 9ea246c7ee2..b488c967f05 100644 --- a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandRegistryTest.java +++ b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandRegistryTest.java @@ -38,6 +38,7 @@ import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThings; import org.eclipse.ditto.thingsearch.model.signals.commands.subscription.CreateSubscription; import org.eclipse.ditto.things.model.devops.commands.CreateWotValidationConfig; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; public final class ThingsSearchServiceGlobalCommandRegistryTest extends GlobalCommandRegistryTestCases { @@ -66,7 +67,8 @@ public ThingsSearchServiceGlobalCommandRegistryTest() { RetrieveConnection.class, SubscribeForPersistedEvents.class, CreateWotValidationConfig.class, - CheckPermissions.class + CheckPermissions.class, + RetrieveTimeseries.class ); } diff --git a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandResponseRegistryTest.java b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandResponseRegistryTest.java index efb7053ad1f..e59abb3444b 100644 --- a/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandResponseRegistryTest.java +++ b/thingsearch/service/src/test/java/org/eclipse/ditto/thingsearch/service/starter/ThingsSearchServiceGlobalCommandResponseRegistryTest.java @@ -39,6 +39,7 @@ import org.eclipse.ditto.thingsearch.model.signals.commands.SearchErrorResponse; import org.eclipse.ditto.thingsearch.model.signals.commands.query.QueryThingsResponse; import org.eclipse.ditto.things.model.devops.commands.RetrieveWotValidationConfigResponse; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; public final class ThingsSearchServiceGlobalCommandResponseRegistryTest extends GlobalCommandResponseRegistryTestCases { @@ -70,7 +71,8 @@ public ThingsSearchServiceGlobalCommandResponseRegistryTest() { ModifySplitBrainResolverResponse.class, ConnectivityErrorResponse.class, RetrieveWotValidationConfigResponse.class, - CheckPermissionsResponse.class + CheckPermissionsResponse.class, + RetrieveTimeseriesResponse.class ); } diff --git a/timeseries/api/pom.xml b/timeseries/api/pom.xml new file mode 100644 index 00000000000..1373d60956e --- /dev/null +++ b/timeseries/api/pom.xml @@ -0,0 +1,50 @@ + + + + + ditto-timeseries + org.eclipse.ditto + ${revision} + + 4.0.0 + + ditto-timeseries-api + Eclipse Ditto :: Timeseries :: API + + + + org.eclipse.ditto + ditto-timeseries-model + + + org.eclipse.ditto + ditto-things-model + + + org.eclipse.ditto + ditto-policies-model + + + org.eclipse.ditto + ditto-policies-api + + + org.eclipse.ditto + ditto-internal-utils-cluster + + + + diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/Capabilities.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/Capabilities.java new file mode 100644 index 00000000000..2a10c326f03 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/Capabilities.java @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; + +/** + * Declares what a {@link TimeseriesAdapter} can compute natively (i.e. push down into its + * backend) versus what must be computed in the shared, backend-neutral compute kernel. + *

+ * A query planner reads this to decide how to run each query: whether the adapter's own + * {@code query(...)} is a complete executor ({@link #supportsNativeQuery()}), which aggregations the + * backend computes in its engine ({@link #getPushableAggregations()}), and which gap-fill strategies + * it applies natively ({@link #getNativeFillStrategies()}). Advertising a capability only ever + * changes where a result is computed, never the result — the kernel remains the reference + * answer. + *

+ * Instances are immutable; the declared sets are defensively copied and returned unmodifiable. + * + * @since 4.0.0 + */ +@Immutable +public final class Capabilities { + + private final boolean supportsNativeQuery; + private final boolean supportsNativeCrossThingQuery; + private final Set pushableAggregations; + private final Set nativeFillStrategies; + + private Capabilities(final boolean supportsNativeQuery, + final boolean supportsNativeCrossThingQuery, + final Set pushableAggregations, + final Set nativeFillStrategies) { + + this.supportsNativeCrossThingQuery = supportsNativeCrossThingQuery; + + this.supportsNativeQuery = supportsNativeQuery; + this.pushableAggregations = immutableCopy(pushableAggregations, Aggregation.class); + this.nativeFillStrategies = immutableCopy(nativeFillStrategies, FillStrategy.class); + } + + /** + * The capabilities of a minimal backend that can only store and return raw points: nothing is + * pushed down, so the planner computes every query in the kernel over {@code scan(...)} output. + * + * @return the minimal (scan-only) capabilities. + */ + public static Capabilities minimal() { + return new Capabilities(false, false, EnumSet.noneOf(Aggregation.class), + EnumSet.noneOf(FillStrategy.class)); + } + + /** + * The capabilities of a backend whose {@code query(...)} is a complete executor — the planner + * delegates whole queries to it and never needs {@link TimeseriesAdapter#scan}. This is the + * default for any adapter that implements the required {@code query(...)} without opting into + * scan-based planning; a scan-only backend overrides {@code capabilities()} to return + * {@link #minimal()} instead. + * + * @return capabilities declaring a complete native query and nothing else. + */ + public static Capabilities nativeQuery() { + return new Capabilities(true, false, EnumSet.noneOf(Aggregation.class), + EnumSet.noneOf(FillStrategy.class)); + } + + /** + * @return a new builder for declaring richer capabilities. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return {@code true} if this adapter's + * {@link TimeseriesAdapter#query(org.eclipse.ditto.timeseries.model.TimeseriesQuery)} is a + * complete executor able to answer any valid query itself. The planner then delegates the whole + * query to the adapter (the fast path). A backend for which this is {@code false} is driven by + * the planner via {@link TimeseriesAdapter#scan} + the compute kernel (the portable path). + */ + public boolean supportsNativeQuery() { + return supportsNativeQuery; + } + + /** + * @return {@code true} if this adapter implements + * {@link TimeseriesAdapter#queryCrossThing} natively, i.e. it can group and aggregate across many + * Things inside its own engine. When {@code false} the cross-Thing endpoint is unavailable for + * this backend: unlike single-Thing queries there is no portable fallback yet, because computing + * a cross-Thing grouping in the kernel would mean scanning every matching series into service + * heap — which is exactly the fan-out the endpoint's guard rails exist to prevent. + */ + public boolean supportsNativeCrossThingQuery() { + return supportsNativeCrossThingQuery; + } + + /** + * @param aggregation the aggregation to check. + * @return {@code true} if the backend computes this aggregation natively rather than the kernel + * computing it from scanned points. + * @throws NullPointerException if {@code aggregation} is {@code null}. + */ + public boolean canPushDown(final Aggregation aggregation) { + return pushableAggregations.contains(checkNotNull(aggregation, "aggregation")); + } + + /** + * @param fillStrategy the fill strategy to check. + * @return {@code true} if the backend fills gaps for this strategy natively rather than the + * kernel filling them. + * @throws NullPointerException if {@code fillStrategy} is {@code null}. + */ + public boolean canFillNatively(final FillStrategy fillStrategy) { + return nativeFillStrategies.contains(checkNotNull(fillStrategy, "fillStrategy")); + } + + /** + * @return the aggregations the backend can compute natively (per {@code step} bucket). + */ + public Set getPushableAggregations() { + return pushableAggregations; + } + + /** + * @return the fill strategies the backend applies natively. + */ + public Set getNativeFillStrategies() { + return nativeFillStrategies; + } + + private static > Set immutableCopy(final Set set, final Class type) { + checkNotNull(set, "capability set"); + return set.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(EnumSet.copyOf(set)); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final Capabilities that = (Capabilities) o; + return supportsNativeQuery == that.supportsNativeQuery + && supportsNativeCrossThingQuery == that.supportsNativeCrossThingQuery + && pushableAggregations.equals(that.pushableAggregations) + && nativeFillStrategies.equals(that.nativeFillStrategies); + } + + @Override + public int hashCode() { + return Objects.hash(supportsNativeQuery, supportsNativeCrossThingQuery, + pushableAggregations, nativeFillStrategies); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "supportsNativeQuery=" + supportsNativeQuery + + ", supportsNativeCrossThingQuery=" + supportsNativeCrossThingQuery + + ", pushableAggregations=" + pushableAggregations + + ", nativeFillStrategies=" + nativeFillStrategies + + "]"; + } + + /** + * Mutable builder for {@link Capabilities}. Unset properties default to the + * {@linkplain #minimal() minimal} (scan-only) values. + */ + public static final class Builder { + + private boolean supportsNativeQuery = false; + private boolean supportsNativeCrossThingQuery = false; + private Set pushableAggregations = EnumSet.noneOf(Aggregation.class); + private Set nativeFillStrategies = EnumSet.noneOf(FillStrategy.class); + + private Builder() { + // use Capabilities.builder() + } + + /** + * @param supported whether the adapter's {@code query(...)} is a complete executor (the + * planner may delegate whole queries to it). + * @return this builder. + */ + public Builder supportsNativeQuery(final boolean supported) { + this.supportsNativeQuery = supported; + return this; + } + + /** + * @param supported whether the adapter implements {@code queryCrossThing(...)} natively. + * @return this builder. + */ + public Builder supportsNativeCrossThingQuery(final boolean supported) { + this.supportsNativeCrossThingQuery = supported; + return this; + } + + /** + * @param aggregations the aggregations the backend can compute natively per bucket. + * @return this builder. + * @throws NullPointerException if {@code aggregations} is {@code null}. + */ + public Builder pushableAggregations(final Set aggregations) { + this.pushableAggregations = checkNotNull(aggregations, "aggregations"); + return this; + } + + /** + * @param fillStrategies the fill strategies the backend applies natively. + * @return this builder. + * @throws NullPointerException if {@code fillStrategies} is {@code null}. + */ + public Builder nativeFillStrategies(final Set fillStrategies) { + this.nativeFillStrategies = checkNotNull(fillStrategies, "fillStrategies"); + return this; + } + + /** + * @return the immutable {@link Capabilities}. + */ + public Capabilities build() { + return new Capabilities(supportsNativeQuery, supportsNativeCrossThingQuery, + pushableAggregations, nativeFillStrategies); + } + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/HealthStatus.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/HealthStatus.java new file mode 100644 index 00000000000..aacf5e9face --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/HealthStatus.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import javax.annotation.concurrent.Immutable; + +/** + * Health status of a timeseries adapter as exposed by + * {@link TimeseriesAdapter#getHealth()}. + * + * @since 4.0.0 + */ +@Immutable +public enum HealthStatus { + + /** + * The adapter is fully operational. + */ + UP, + + /** + * The adapter is reachable but operating in a degraded mode (e.g. only some replicas + * available, write-through to backup disabled). + */ + DEGRADED, + + /** + * The adapter is not currently usable. + */ + DOWN +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapter.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapter.java new file mode 100644 index 00000000000..567bf10c943 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapter.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; + +/** + * Service Provider Interface for timeseries database backends. + *

+ * Implementations integrate the Timeseries service with a concrete backend (MongoDB Time Series, + * IoTDB, TimescaleDB, InfluxDB, etc.). The default Ditto distribution ships a MongoDB Time Series + * adapter; custom backends are integrated by implementing this interface and registering the + * implementation via the configured {@code ditto.timeseries.adapter.type}. + *

+ * The contract is intentionally narrow for the Phase 1 (MVP) surface: lifecycle, write, + * single-Thing query, and health. Cross-Thing aggregation, retention, statistics, and schema + * management are exposed via {@code default} methods that throw + * {@link UnsupportedOperationException}; adapters opt in by overriding them in later phases. + * + *

Concurrency

+ * Implementations are expected to be thread-safe. All asynchronous methods return a + * {@link CompletionStage} that completes on a thread the caller should not block. Per Ditto's + * actor-concurrency rules, callers route the result back through {@code Patterns.pipe(...)} + * before mutating actor state. + * + * @since 4.0.0 + */ +public interface TimeseriesAdapter { + + // --- Capabilities --- + + /** + * Declares what this adapter can compute natively (push down into its backend) versus what must + * be computed in the shared compute kernel. A future query planner reads this to route each + * operation to the fast (push-down) or portable (scan + kernel) path. + *

+ * The default is {@link Capabilities#nativeQuery()} — the planner delegates whole queries to + * this adapter's {@link #query(TimeseriesQuery)} (which is a required method, so every adapter + * has it). A scan-only backend instead overrides this to return {@link Capabilities#minimal()} + * and implements {@link #scan}, letting the planner compute everything in the kernel. Advertising + * a capability must never change results, only where they are computed. + * + * @return the adapter's capabilities. + */ + default Capabilities capabilities() { + return Capabilities.nativeQuery(); + } + + // --- Lifecycle --- + + /** + * Initialises the adapter with the given configuration. Called once during service start-up + * before any other method. + * + * @param config the resolved adapter configuration. + * @return a {@code CompletionStage} that completes once the adapter is ready to serve writes + * and queries. + * @throws NullPointerException if {@code config} is {@code null}. + */ + CompletionStage initialize(TimeseriesAdapterConfig config); + + /** + * Releases all resources held by the adapter (connection pools, background tasks). Idempotent. + * + * @return a {@code CompletionStage} that completes once shutdown is finished. + */ + CompletionStage shutdown(); + + /** + * Returns the current health of the adapter. Should be cheap; not necessarily issuing a + * round-trip to the backend on every call. + * + * @return the current health status. + */ + HealthStatus getHealth(); + + // --- Ingestion --- + + /** + * Writes a single data point to the backend. + * + * @param dataPoint the data point to write. + * @return a {@code CompletionStage} that completes once the write is durable. + * @throws NullPointerException if {@code dataPoint} is {@code null}. + */ + CompletionStage write(TimeseriesDataPoint dataPoint); + + /** + * Writes a batch of data points. Implementations should aim to perform a single round-trip per + * batch where the backend supports it. The default implementation falls back to looping over + * {@link #write(TimeseriesDataPoint)} and is therefore correct but unoptimised. + * + * @param dataPoints the data points to write. May be empty (no-op). + * @return a {@code CompletionStage} that completes once all writes are durable. + * @throws NullPointerException if {@code dataPoints} is {@code null} or contains a {@code null} + * element. + */ + default CompletionStage writeBatch(final List dataPoints) { + checkNotNull(dataPoints, "dataPoints"); + CompletionStage chain = CompletableFuture.completedFuture(null); + for (final TimeseriesDataPoint dataPoint : dataPoints) { + checkNotNull(dataPoint, "dataPoint in batch"); + chain = chain.thenCompose(ignored -> write(dataPoint)); + } + return chain; + } + + // --- Query --- + + /** + * Executes a single-Thing timeseries query and returns one result per requested path. + * + * @param query the query to execute. + * @return a {@code CompletionStage} that completes with the per-path results. + * @throws NullPointerException if {@code query} is {@code null}. + */ + CompletionStage> query(TimeseriesQuery query); + + /** + * The universal read primitive: returns the raw, non-gap data points for one series + * ({@code thingId} + {@code path}) in the half-open time range {@code [from, to)}, ordered + * ascending by timestamp, up to {@code limit} points. + *

+ * This is the lowest common denominator every timeseries backend can satisfy, and it is what a + * query planner builds on to compute any operation portably in the shared compute kernel (bucket + * / aggregate / fill) when the backend cannot push that operation down. An adapter that + * implements {@code scan} can be driven entirely by the planner; the default throws + * {@link UnsupportedOperationException} so a backend that only implements the monolithic + * {@link #query(TimeseriesQuery)} still compiles. + * + * @param thingId the Thing whose series to scan. + * @param path the feature-property pointer identifying the series. + * @param from the inclusive start of the time range. + * @param to the exclusive end of the time range. + * @param limit the maximum number of points to return; a non-positive value means "the adapter's + * configured ceiling". + * @return a {@code CompletionStage} completing with the ascending points (never {@code null}). + * @throws NullPointerException if {@code thingId}, {@code path}, {@code from} or {@code to} is + * {@code null}. + * @throws UnsupportedOperationException if this adapter does not implement {@code scan}. + */ + default CompletionStage> scan(final ThingId thingId, + final JsonPointer path, final Instant from, final Instant to, final int limit) { + throw new UnsupportedOperationException( + getClass().getName() + " does not implement scan(...)."); + } + + /** + * Executes a cross-Thing aggregation over one namespace, returning one series per + * {@code (group, path)} combination. + *

+ * Callers must have decided authorization before invoking this: the adapter has no + * notion of who is asking and stores no authorization state alongside the data points. Access is + * a statement about the present, whereas data points are historical, so any snapshot of grants + * taken at ingest time would be wrong in at least one direction (a later grant would hide + * history; a revoke would keep leaking it). Keeping enforcement out of the adapter also means a + * new backend never re-implements it. + *

+ * The default throws {@link UnsupportedOperationException} so a backend that predates cross-Thing + * support still compiles; adapters advertise support via + * {@link Capabilities#supportsNativeCrossThingQuery()}. + * + * @param query the cross-Thing query. + * @param permittedThingsPerPath the Things the caller may read, per path. Permission is + * path-granular — a subject may hold {@code READ_TS} on one requested property and not another, + * so a Thing can legitimately contribute to one path's series and be withheld from another's. + *

+ * Contract, and it is fail-closed by design: + *

    + *
  • The map is required. There is deliberately no "unrestricted" sentinel: a value + * meaning "skip filtering" is an authorization bypass one {@code null} away, and the caller + * that used to pass it has been removed for exactly that reason. An adapter that wants to + * serve a namespace-wide grant receives every Thing explicitly enumerated per path.
  • + *
  • A path maps to the Things permitted for that path. A path that is + * absent from the map, or maps to an empty collection, contributes + * nothing — implementations must never read an absent entry as "unrestricted".
  • + *
  • If no path has any permitted Thing, the result must be empty rather than an + * unfiltered scan.
  • + *
+ * @return a {@code CompletionStage} completing with one result per {@code (group, path)}. + * @throws NullPointerException if {@code query} or {@code permittedThingsPerPath} is {@code null}. + * @throws UnsupportedOperationException if this adapter does not implement cross-Thing queries. + */ + default CompletionStage> queryCrossThing( + final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + throw new UnsupportedOperationException( + getClass().getName() + " does not implement queryCrossThing(...)."); + } + + /** + * Returns, per requested path, the distinct Things that have at least one data point + * matching the given cross-Thing query's namespace, time range and tag filters. + *

+ * Grouped per path rather than flattened because permission is path-granular: knowing which + * {@code (Thing, path)} combinations actually carry data is what lets the caller report exactly + * which of them were withheld, instead of counting a Thing as "excluded" from a path it would + * have contributed nothing to anyway. + *

+ * This exists so authorization can be decided per contributing Thing before any values + * are aggregated. A namespace-wide grant does not imply every Thing grants: policy entries + * injected from a namespace root are merged additively, so a Thing's own policy may still + * revoke the permission, and a revoke wins. Aggregating first and filtering afterwards is not an + * option — once values are folded into a bucket average, one Thing's contribution cannot be + * subtracted back out. + *

+ * The result set is bounded by the query's selectivity rather than by the namespace's + * size: a one-hour window over a single property returns only the Things active in that hour. + * + * @param query the cross-Thing query whose filters scope the lookup. + * @param limit the maximum number of distinct Thing IDs across all paths. Implementations + * must allow at most {@code limit + 1} distinct Things through so the caller can distinguish + * "exactly at the ceiling" from "more than the ceiling" and fail loudly instead of silently + * verifying a truncated set. + * @return a {@code CompletionStage} completing with path → contributing Thing IDs. Paths with + * no matching data may be omitted. + * @throws NullPointerException if {@code query} is {@code null}. + * @throws UnsupportedOperationException if this adapter does not implement the lookup. + */ + default CompletionStage>> discoverContributors( + final CrossThingTimeseriesQuery query, final int limit) { + + throw new UnsupportedOperationException( + getClass().getName() + " does not implement discoverContributors(...)."); + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterConfig.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterConfig.java new file mode 100644 index 00000000000..2d46bfde00f --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterConfig.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +/** + * Marker interface for timeseries-adapter configuration. + *

+ * Concrete adapters define their own configuration types (e.g. a MongoDB adapter exposes + * connection-string and granularity, an IoTDB adapter exposes host/port). The Timeseries service + * loads the appropriate config according to {@code ditto.timeseries.adapter.type} and passes the + * resolved instance to {@link TimeseriesAdapter#initialize(TimeseriesAdapterConfig)}. + *

+ * No methods are declared on this interface yet; it exists to make the SPI contract explicit and + * to give a single type-token for adapter-config classes to share. + * + * @since 4.0.0 + */ +public interface TimeseriesAdapterConfig { +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMappingStrategies.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMappingStrategies.java new file mode 100644 index 00000000000..24e2f7c8579 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMappingStrategies.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import java.util.Map; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.base.model.signals.JsonParsable; +import org.eclipse.ditto.internal.utils.cluster.GlobalMappingStrategies; +import org.eclipse.ditto.internal.utils.cluster.MappingStrategies; +import org.eclipse.ditto.internal.utils.cluster.MappingStrategiesBuilder; +import org.eclipse.ditto.policies.api.PoliciesMappingStrategies; + +/** + * {@link MappingStrategies} for the Timeseries service. + *

+ * The Timeseries service does not introduce its own bespoke {@link Jsonifiable} entity types — the + * cross-cluster traffic it cares about (RetrieveTimeseries / RetrieveTimeseriesResponse) is + * already covered by the {@link GlobalMappingStrategies} via {@code @JsonParsableCommand} / + * {@code @JsonParsableCommandResponse} annotations. + *

+ * It also pulls in {@link PoliciesMappingStrategies} so that {@code PolicyTag} cache-invalidation + * messages published by the policies-service on the cluster pub-sub topic + * {@code policy-invalidate-enforcers} can be deserialized here — without that, the + * {@code CachingPolicyEnforcerProvider} silently drops invalidation messages and the local + * policy-enforcer cache never reflects subsequent policy updates (manifest: granting a new + * permission to an existing policy never takes effect on the timeseries-service until restart). + * Other Ditto services that use the caching enforcer (things, connectivity, search, gateway) + * include the same pull for the same reason. + * + * @since 4.0.0 + */ +@Immutable +public final class TimeseriesMappingStrategies extends MappingStrategies { + + private static final MappingStrategies TIMESERIES_MAPPING_STRATEGIES = + buildTimeseriesMappingStrategies(); + + @Nullable private static TimeseriesMappingStrategies instance; + + private TimeseriesMappingStrategies(final Map>> mappingStrategies) { + super(mappingStrategies); + } + + /** + * Constructs a new {@code TimeseriesMappingStrategies} instance. Used via reflection by + * {@code org.eclipse.ditto.internal.utils.cluster.MappingStrategies#loadMappingStrategies}. + */ + @SuppressWarnings("unused") + public TimeseriesMappingStrategies() { + this(TIMESERIES_MAPPING_STRATEGIES); + } + + /** + * @return the singleton instance. + */ + public static TimeseriesMappingStrategies getInstance() { + TimeseriesMappingStrategies result = instance; + if (null == result) { + result = new TimeseriesMappingStrategies(TIMESERIES_MAPPING_STRATEGIES); + instance = result; + } + return result; + } + + private static MappingStrategies buildTimeseriesMappingStrategies() { + return MappingStrategiesBuilder.newInstance() + .putAll(GlobalMappingStrategies.getInstance()) + .putAll(PoliciesMappingStrategies.getInstance()) + .build(); + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstants.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstants.java new file mode 100644 index 00000000000..c2fc3b6efd2 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstants.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import javax.annotation.concurrent.Immutable; + +/** + * Constants for the Timeseries service messaging — service name, Pekko cluster role, root-actor + * path. + * + * @since 4.0.0 + */ +@Immutable +public final class TimeseriesMessagingConstants { + + /** + * Service name as used for HOCON {@code ditto.service-name} and logging. + */ + public static final String SERVICE_NAME = "timeseries"; + + /** + * Name of the Pekko cluster role for Timeseries-service nodes. + */ + public static final String CLUSTER_ROLE = "timeseries"; + + @SuppressWarnings("squid:S1075") + private static final String USER_PATH = "/user"; + + /** + * Name of the Timeseries root actor. + */ + public static final String ROOT_ACTOR_NAME = "timeseriesRoot"; + + /** + * Path of the Timeseries root actor. + */ + public static final String ROOT_ACTOR_PATH = USER_PATH + "/" + ROOT_ACTOR_NAME; + + /** + * Name of the Pekko cluster shard region that hosts the per-Thing + * {@code TimeseriesIngestActor} entities on Timeseries-service nodes. Each entity is a + * Pekko-persistent actor whose journal lives in the {@code timeseries_journal} MongoDB + * collection — same backbone as {@code ThingPersistenceActor}'s {@code things_journal}. + *

+ * The same entity handles both the ingest write path + * ({@link org.eclipse.ditto.timeseries.api.commands.IngestDataPoints}) and the + * {@link org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries} read path — + * mirroring how {@code ThingPersistenceActor} services every command for its Thing. That + * lets the edge command forwarder route both with + * {@code askWithRetryCommandForwarder} against this shard region (same shape as + * {@code forwardToThings} / {@code forwardToPolicies}). + */ + public static final String SHARD_REGION = "timeseries-ingest"; + + /** + * Name of the per-node Timeseries ingest-publisher actor running on each Things-service + * node. Watches thing events flowing through the local {@code ThingPersistenceActor}, + * looks up the WoT {@code ditto:timeseries} annotation on touched feature properties, + * builds {@link org.eclipse.ditto.timeseries.model.TimeseriesDataPoint}s and asks the + * timeseries shard region with bounded retries until the entity acks. The entity's + * journal closes the durability gap on the receiving side; the publisher's retry loop + * handles transient delivery failures (shard rebalance, network blip). + */ + public static final String INGEST_PUBLISHER_ACTOR_NAME = "timeseriesIngestPublisher"; + + /** + * Name of the per-node actor serving cross-Thing timeseries aggregations + * ({@code RetrieveAggregatedTimeseries}). + *

+ * Cross-Thing queries have no {@code thingId}, so — unlike {@code RetrieveTimeseries} — they + * cannot be routed through {@link #SHARD_REGION}. They are addressed by path via pub/sub + * instead, the same way thing-search receives its query commands. + */ + public static final String AGGREGATE_ACTOR_NAME = "timeseriesAggregate"; + + /** + * Path of the cross-Thing aggregation actor, used by the edge command forwarder to address it + * from any cluster node via {@code DistPubSubAccess.send(...)}. + */ + public static final String AGGREGATE_ACTOR_PATH = ROOT_ACTOR_PATH + "/" + AGGREGATE_ACTOR_NAME; + + private TimeseriesMessagingConstants() { + // no-op + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlanner.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlanner.java new file mode 100644 index 00000000000..c957c59acac --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlanner.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.api.compute.TimeseriesComputeKernel; +import org.eclipse.ditto.timeseries.api.compute.TimeseriesComputeKernel.TimePoint; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; + +/** + * Executes a {@link TimeseriesQuery} against any {@link TimeseriesAdapter} by choosing, per query, + * between two equivalent execution paths: + *

    + *
  • push-down — when the adapter's {@link Capabilities} say it can compute the requested + * bucketed aggregation natively, the whole query is delegated to + * {@link TimeseriesAdapter#query(TimeseriesQuery)} so the backend does the heavy lifting; and
  • + *
  • portable — otherwise the planner fetches raw points via + * {@link TimeseriesAdapter#scan} and computes the result in the shared + * {@link TimeseriesComputeKernel} (bucket → aggregate → fill, or a window + * aggregation).
  • + *
+ * Both paths produce the same {@link TimeseriesQueryResult}s, so a scan-only backend is fully + * functional (portable path) and a capable backend is faster (push-down path) — without the caller + * knowing which ran. The kernel is the reference answer, so results stay identical across backends. + *

+ * The push-down decision is query-level (a query carries a single aggregation for all paths), which + * keeps the choice all-or-nothing and avoids re-running paths. + * + * @since 4.0.0 + */ +public final class TimeseriesQueryPlanner { + + private final TimeseriesAdapter adapter; + + /** + * @param adapter the backend adapter to execute against. + * @throws NullPointerException if {@code adapter} is {@code null}. + */ + public TimeseriesQueryPlanner(final TimeseriesAdapter adapter) { + this.adapter = checkNotNull(adapter, "adapter"); + } + + /** + * Executes the query, returning one result per requested path in request order. + * + * @param query the query to execute. + * @return a stage completing with the per-path results. + * @throws NullPointerException if {@code query} is {@code null}. + */ + public CompletionStage> execute(final TimeseriesQuery query) { + checkNotNull(query, "query"); + if (adapter.capabilities().supportsNativeQuery()) { + // The backend can execute the whole query itself — delegate (fast path, and for a + // complete backend like MongoDB this makes the planner transparent: identical behavior + // and metadata to calling the adapter directly). + return adapter.query(query); + } + if (!query.getTagFilters().isEmpty()) { + // Portable path: the compute kernel runs over scanned (timestamp, value) points, which + // do not carry the per-point tags, so tag filtering cannot be honoured here. Fail loud + // rather than silently returning unfiltered data — a backend must be able to push tag + // filters down to storage (native query) to support them. + final CompletableFuture> failed = new CompletableFuture<>(); + failed.completeExceptionally(TimeseriesQueryInvalidException.newBuilder( + "Tag filtering is not supported by this timeseries backend.") + .description("The 'tagFilter' parameter requires a backend that can push tag " + + "filters down to storage; this backend can only scan raw points.") + .build()); + return failed; + } + final List paths = query.getPaths(); + if (paths.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + final List> perPath = new ArrayList<>(paths.size()); + for (final JsonPointer path : paths) { + final int limit = query.getLimit().orElse(0); + perPath.add(adapter.scan(query.getThingId(), path, query.getFrom(), query.getTo(), limit) + .thenApply(points -> computeInKernel(query, path, points))); + } + return collectInOrder(perPath); + } + + /** Computes one path's result from scanned points using only the kernel (portable path). */ + private static TimeseriesQueryResult computeInKernel(final TimeseriesQuery query, + final JsonPointer path, final List points) { + + final ZoneId tz = query.getTimezone().orElse(null); + final Aggregation aggregation = query.getAggregation().orElse(null); + final List data; + if (aggregation == null) { + data = points; // raw read: pass the scanned points straight through + } else if (aggregation.requiresStep() && query.getStep().isPresent()) { + final Duration step = query.getStep().get(); + final LinkedHashMap byBucket = TimeseriesComputeKernel.aggregateBuckets( + toTimePoints(points), step, aggregation, tz, query.getPercentile().orElse(null)); + data = TimeseriesComputeKernel.fillBuckets(byBucket, step, + query.getFillStrategy().orElse(null), tz); + } else { + data = computeWindowAggregation(query, aggregation, points, tz); + } + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, inferDataType(data)); + return TimeseriesQueryResult.of(query.getThingId(), path, query, meta, data); + } + + private static List computeWindowAggregation(final TimeseriesQuery query, + final Aggregation aggregation, final List points, + final ZoneId tz) { + + switch (aggregation) { + case DERIVATIVE: + return TimeseriesComputeKernel.derivative(toTimePoints(points), false); + case RATE: + return TimeseriesComputeKernel.derivative(toTimePoints(points), true); + case INTEGRAL: + return TimeseriesComputeKernel.integral(toTimePoints(points)); + case PERCENTILE: + return percentile(query, points, tz); + default: + // A bucketed aggregation without a step is rejected by the model layer before here. + throw new IllegalStateException( + "Aggregation <" + aggregation.getName() + "> requires a step."); + } + } + + private static List percentile(final TimeseriesQuery query, + final List points, final ZoneId tz) { + + final double p = query.getPercentile().orElseThrow(() -> new IllegalStateException( + "The percentile aggregation requires a percentile value.")); + if (query.getStep().isPresent()) { + final Duration step = query.getStep().get(); + final LinkedHashMap byBucket = TimeseriesComputeKernel.aggregateBuckets( + toTimePoints(points), step, Aggregation.PERCENTILE, tz, p); + return TimeseriesComputeKernel.fillBuckets(byBucket, step, + query.getFillStrategy().orElse(null), tz); + } + // Whole-range percentile: a single point stamped at the range start (matches the native path). + final List values = new ArrayList<>(); + for (final TimePoint point : toTimePoints(points)) { + values.add(point.value()); + } + if (values.isEmpty()) { + return Collections.emptyList(); + } + return List.of(TimeseriesDataValue.of(query.getFrom(), + JsonValue.of(TimeseriesComputeKernel.percentile(values, p)))); + } + + private static List toTimePoints(final List points) { + final List out = new ArrayList<>(points.size()); + for (final TimeseriesDataValue point : points) { + point.getValue() + .filter(JsonValue::isNumber) + .ifPresent(value -> out.add(new TimePoint(point.getTimestamp(), value.asDouble()))); + } + return out; + } + + private static String inferDataType(final List data) { + for (final TimeseriesDataValue value : data) { + final JsonValue jsonValue = value.getValue().orElse(null); + if (jsonValue == null) { + continue; + } + if (jsonValue.isNumber()) { + return "number"; + } + if (jsonValue.isString()) { + return "string"; + } + if (jsonValue.isBoolean()) { + return "boolean"; + } + } + return "null"; + } + + private static CompletionStage> collectInOrder( + final List> stages) { + + final CompletableFuture[] array = stages.stream() + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(array).thenApply(ignored -> { + final List ordered = new ArrayList<>(stages.size()); + for (final CompletionStage stage : stages) { + ordered.add(stage.toCompletableFuture().join()); + } + return Collections.unmodifiableList(ordered); + }); + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPoints.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPoints.java new file mode 100644 index 00000000000..87d580cccc8 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPoints.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.entity.id.WithEntityId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommand; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommand; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.base.model.signals.commands.CommandJsonDeserializer; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; + +/** + * Internal cluster command sent from the Things service to the Timeseries shard region asking it + * to persist a batch of {@link TimeseriesDataPoint}s for a single Thing. + *

+ * Sharded by {@link ThingId} so that all data points for a Thing land in the same shard, giving + * per-Thing ordering on the write path even with the publisher running on multiple Things-service + * nodes. Not part of the public Ditto Protocol — there's no HTTP, WebSocket or Connectivity route + * that exposes it; the only sender is {@code TimeseriesIngestPublisher} on Things-service nodes + * and the only recipient is {@code TimeseriesIngestActor} on Timeseries-service nodes. + * + *

Idempotency

+ * The {@link DittoHeaders#getCorrelationId() correlation-id} doubles as the publisher's delivery + * identifier. A retry carries the same correlation-id; the receiver's in-memory + * {@code recentlyApplied} ring uses it to recognise replays within an actor lifetime and ack + * idempotently. A cross-passivation retry can still produce one duplicate row — closing that + * window is tracked as adapter-side {@code (thingId, path, timestamp)} dedup in a later phase. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommand(typePrefix = IngestDataPoints.TYPE_PREFIX, name = IngestDataPoints.NAME) +public final class IngestDataPoints extends AbstractCommand implements WithEntityId { + + /** + * Type prefix for ingest commands. Distinct from the user-facing {@code timeseries.commands:} + * prefix so this internal control message can never collide with — or be mistaken for — a + * Ditto Protocol command someone forwarded by accident. + */ + public static final String TYPE_PREFIX = "timeseries.ingest." + Command.TYPE_QUALIFIER + ":"; + + /** + * Name of this command. + */ + public static final String NAME = "ingestDataPoints"; + + /** + * Type of this command. + */ + public static final String TYPE = TYPE_PREFIX + NAME; + + /** + * Resource type the policy enforcer would use, were this command ever evaluated against a + * policy. Currently never enforced (publisher acts under the Things-service trust boundary) + * but included for future-proofing when fine-grained writer permissions land. + */ + public static final String RESOURCE_TYPE = "thing"; + + private static final JsonFieldDefinition JSON_THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_DATA_POINTS = + JsonFactory.newJsonArrayFieldDefinition("dataPoints", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private final ThingId thingId; + private final List dataPoints; + + private IngestDataPoints(final ThingId thingId, + final List dataPoints, + final DittoHeaders dittoHeaders) { + + super(TYPE, dittoHeaders); + this.thingId = thingId; + this.dataPoints = dataPoints; + } + + /** + * Returns a new {@code IngestDataPoints} command. + * + * @param thingId the Thing all data points in {@code dataPoints} belong to. Must equal the + * {@code thingId} carried by every entry in {@code dataPoints}. + * @param dataPoints the data points to persist. May be empty (no-op write); the receiver + * still acks an empty batch so a publisher's retry timer doesn't fire. + * @param dittoHeaders the headers — the {@code correlation-id} is treated as the delivery + * identifier by the publisher's retry logic. + * @return the command. + * @throws NullPointerException if any argument is {@code null}. + * @throws IllegalArgumentException if any data point's {@code thingId} differs from the + * {@code thingId} parameter. + */ + public static IngestDataPoints of(final ThingId thingId, + final List dataPoints, + final DittoHeaders dittoHeaders) { + + checkNotNull(thingId, "thingId"); + checkNotNull(dataPoints, "dataPoints"); + checkNotNull(dittoHeaders, "dittoHeaders"); + for (final TimeseriesDataPoint dp : dataPoints) { + if (!thingId.equals(dp.getThingId())) { + throw new IllegalArgumentException( + "Data point thingId <" + dp.getThingId() + "> does not match command " + + "thingId <" + thingId + ">. A single IngestDataPoints batch must " + + "carry data for exactly one Thing so the shard router can route " + + "the whole batch to one shard."); + } + } + return new IngestDataPoints(thingId, + Collections.unmodifiableList(new ArrayList<>(dataPoints)), + dittoHeaders); + } + + /** + * Parses an {@code IngestDataPoints} from JSON. Required by {@link JsonParsableCommand} so + * the command can travel between cluster nodes via the JSON-Jsonifiable serializer. + * + * @param jsonObject the JSON object. + * @param dittoHeaders the headers. + * @return the parsed command. + */ + public static IngestDataPoints fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + return new CommandJsonDeserializer(TYPE, jsonObject).deserialize(() -> { + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JSON_THING_ID)); + final JsonArray dataPointsArray = jsonObject.getValueOrThrow(JSON_DATA_POINTS); + final List parsed = new ArrayList<>(dataPointsArray.getSize()); + for (final JsonValue value : dataPointsArray) { + parsed.add(TimeseriesDataPoint.fromJson(value.asObject())); + } + return of(thingId, parsed, dittoHeaders); + }); + } + + /** + * @return the data points carried by this command. The list is unmodifiable. + */ + public List getDataPoints() { + return dataPoints; + } + + @Override + public ThingId getEntityId() { + return thingId; + } + + @Override + public String getTypePrefix() { + return TYPE_PREFIX; + } + + @Override + public String getResourceType() { + return RESOURCE_TYPE; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public Category getCategory() { + return Category.MODIFY; + } + + @Override + public IngestDataPoints setDittoHeaders(final DittoHeaders dittoHeaders) { + return new IngestDataPoints(thingId, dataPoints, dittoHeaders); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(JSON_THING_ID, thingId.toString(), predicate); + final JsonArrayBuilder arrayBuilder = JsonFactory.newArrayBuilder(); + for (final TimeseriesDataPoint dp : dataPoints) { + arrayBuilder.add(dp.toJson()); + } + jsonObjectBuilder.set(JSON_DATA_POINTS, arrayBuilder.build(), predicate); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof IngestDataPoints; + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final IngestDataPoints that = (IngestDataPoints) obj; + return that.canEqual(this) && + Objects.equals(thingId, that.thingId) && + Objects.equals(dataPoints, that.dataPoints) && + super.equals(that); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId, dataPoints); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", thingId=" + thingId + + ", dataPointCount=" + dataPoints.size() + "]"; + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsResponse.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsResponse.java new file mode 100644 index 00000000000..a6d81a22f52 --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsResponse.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.entity.id.WithEntityId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommandResponse; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommandResponse; +import org.eclipse.ditto.base.model.signals.commands.CommandResponse; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; + +/** + * Acknowledgement reply to an {@link IngestDataPoints} command. Sent by the + * {@code TimeseriesIngestActor} entity to the publisher only after the batch has been written + * to the MongoDB Time Series collection — receipt by the publisher confirms the batch is + * durable in the time-series store, so the publisher can drop it from its retry queue. + *

+ * Carries no payload other than the {@link ThingId} and an HTTP 200 status. The + * publisher matches the response to its in-flight request via the {@code correlation-id} + * which {@code Patterns.ask} propagates automatically. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommandResponse(type = IngestDataPointsResponse.TYPE) +public final class IngestDataPointsResponse extends AbstractCommandResponse + implements WithEntityId { + + /** + * Type prefix for ingest responses. Mirrors the command's prefix so a debugger + * eyeballing cluster traffic sees command/response come in pairs. + */ + public static final String TYPE_PREFIX = "timeseries.ingest." + CommandResponse.TYPE_QUALIFIER + ":"; + + /** + * Type of this response. + */ + public static final String TYPE = TYPE_PREFIX + IngestDataPoints.NAME; + + /** + * Resource type. See {@link IngestDataPoints#RESOURCE_TYPE}. + */ + public static final String RESOURCE_TYPE = IngestDataPoints.RESOURCE_TYPE; + + private static final JsonFieldDefinition JSON_THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private final ThingId thingId; + + private IngestDataPointsResponse(final ThingId thingId, + final HttpStatus httpStatus, + final DittoHeaders dittoHeaders) { + + super(TYPE, httpStatus, dittoHeaders); + this.thingId = thingId; + } + + /** + * Returns a successful ack response. + * + * @param thingId the Thing the original batch targeted. + * @param dittoHeaders the headers of the response — typically copied from the + * originating {@link IngestDataPoints} command so the publisher's + * {@code Patterns.ask} future correlates correctly. + * @return the response. + * @throws NullPointerException if any argument is {@code null}. + */ + public static IngestDataPointsResponse of(final ThingId thingId, final DittoHeaders dittoHeaders) { + checkNotNull(thingId, "thingId"); + checkNotNull(dittoHeaders, "dittoHeaders"); + return new IngestDataPointsResponse(thingId, HttpStatus.OK, dittoHeaders); + } + + /** + * Parses a response from JSON. Required by {@link JsonParsableCommandResponse} for + * cross-cluster deserialization. + */ + public static IngestDataPointsResponse fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + checkNotNull(jsonObject, "jsonObject"); + checkNotNull(dittoHeaders, "dittoHeaders"); + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JSON_THING_ID)); + return new IngestDataPointsResponse(thingId, HttpStatus.OK, dittoHeaders); + } + + @Override + public ThingId getEntityId() { + return thingId; + } + + @Override + public String getResourceType() { + return RESOURCE_TYPE; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public IngestDataPointsResponse setDittoHeaders(final DittoHeaders dittoHeaders) { + return new IngestDataPointsResponse(thingId, getHttpStatus(), dittoHeaders); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(JSON_THING_ID, thingId.toString(), predicate); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof IngestDataPointsResponse; + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final IngestDataPointsResponse that = (IngestDataPointsResponse) obj; + return that.canEqual(this) && + Objects.equals(thingId, that.thingId) && + super.equals(that); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", thingId=" + thingId + "]"; + } +} diff --git a/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernel.java b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernel.java new file mode 100644 index 00000000000..2126bd95e9d --- /dev/null +++ b/timeseries/api/src/main/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernel.java @@ -0,0 +1,473 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api.compute; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; + +/** + * Backend-neutral reference implementations of the timeseries computations that cannot (or should + * not) be delegated to a specific database: bucket-grid stepping, gap fill, and the "advanced" + * aggregations that are derived from fetched points ({@code derivative}, {@code rate}, + * {@code integral}, {@code percentile}). + *

+ * These functions define the meaning of each operation once, so that every + * {@link org.eclipse.ditto.timeseries.api.TimeseriesAdapter} produces identical results regardless + * of the underlying store. An adapter fetches/reduces raw data with its own database, then hands + * the small result to this kernel for the final, portable calculation ("the database reduces, the + * kernel decides"). The kernel is also the reference answer any native push-down implementation is + * expected to match. + *

+ * All methods are pure (no I/O, no shared mutable state) and therefore thread-safe. + * + * @since 4.0.0 + */ +public final class TimeseriesComputeKernel { + + private TimeseriesComputeKernel() { + throw new AssertionError(); + } + + // --- Bucket-grid stepping --- + + /** + * The {@code (unit, binSize)} pair a fixed-step bucket grid bins by, derived from a step + * {@link Duration}. The same pair drives both a backend's calendar bucketing (e.g. MongoDB's + * {@code $dateTrunc}) and the in-kernel gap-fill grid (see {@link #nextBucket}), so the two + * stay aligned. + * + * @param unit the calendar unit ({@code day}, {@code hour}, {@code minute} or {@code second}). + * @param binSize the number of {@code unit}s per bucket. + */ + public record StepUnit(String unit, long binSize) {} + + /** + * Derives the coarsest {@link StepUnit} that divides the given step exactly. + * + * @param step the bucket width; must be a whole number of seconds. + * @return the {@code (unit, binSize)} pair the grid bins by. + * @throws NullPointerException if {@code step} is {@code null}. + */ + public static StepUnit stepUnitFor(final Duration step) { + checkNotNull(step, "step"); + final long seconds = step.getSeconds(); + if (seconds % 86400 == 0) { + return new StepUnit("day", seconds / 86400); + } else if (seconds % 3600 == 0) { + return new StepUnit("hour", seconds / 3600); + } else if (seconds % 60 == 0) { + return new StepUnit("minute", seconds / 60); + } else { + return new StepUnit("second", seconds); + } + } + + /** + * Advances {@code cursor} to the next bucket start. Without a timezone the step is a fixed + * {@link Duration} (exact for UTC). With a timezone the step is taken in that zone's calendar, + * so day/hour boundaries track wall-clock time across DST transitions — mirroring how a + * tz-aware calendar truncation aligns buckets (a "day" bucket spans 23h or 25h around a + * transition, not a fixed 24h). + * + * @param cursor the current bucket start. + * @param step the bucket width. + * @param zone the timezone the buckets are aligned to, or {@code null} for UTC / fixed stepping. + * @return the start of the next bucket. + * @throws NullPointerException if {@code cursor} or {@code step} is {@code null}. + */ + public static Instant nextBucket(final Instant cursor, final Duration step, + @Nullable final ZoneId zone) { + checkNotNull(cursor, "cursor"); + checkNotNull(step, "step"); + if (zone == null) { + return cursor.plus(step); + } + final StepUnit stepUnit = stepUnitFor(step); + final ZonedDateTime zoned = cursor.atZone(zone); + final ZonedDateTime next = switch (stepUnit.unit()) { + case "day" -> zoned.plusDays(stepUnit.binSize()); + case "hour" -> zoned.plusHours(stepUnit.binSize()); + case "minute" -> zoned.plusMinutes(stepUnit.binSize()); + default -> zoned.plusSeconds(stepUnit.binSize()); + }; + return next.toInstant(); + } + + /** + * Computes the bucket start that a timestamp falls into, matching MongoDB's {@code $dateTrunc} + * binning so that in-kernel downsampling (over scanned points) and a backend's native + * downsampling produce the same grid. + *

+ * The reference point is {@code 2000-01-01T00:00:00} in the target zone (UTC when {@code zone} + * is {@code null}). {@code second}/{@code minute}/{@code hour} bins are elapsed-based (a fixed + * {@code binSize × unit} duration from the reference instant — the zone only shifts that + * instant); {@code day} bins are calendar-based (local midnight in the zone), so a bin spans + * 23h or 25h across a DST transition. + * + * @param t the timestamp to bin. + * @param step the bucket width. + * @param zone the zone the buckets align to, or {@code null} for UTC. + * @return the start instant of the bucket containing {@code t}. + * @throws NullPointerException if {@code t} or {@code step} is {@code null}. + */ + public static Instant bucketStart(final Instant t, final Duration step, + @Nullable final ZoneId zone) { + + checkNotNull(t, "t"); + checkNotNull(step, "step"); + final ZoneId z = zone != null ? zone : ZoneOffset.UTC; + final StepUnit stepUnit = stepUnitFor(step); + final ZonedDateTime ref = ZonedDateTime.of(2000, 1, 1, 0, 0, 0, 0, z); + if ("day".equals(stepUnit.unit())) { + final long daysBetween = ChronoUnit.DAYS.between(ref.toLocalDate(), t.atZone(z).toLocalDate()); + final long binDays = Math.floorDiv(daysBetween, stepUnit.binSize()) * stepUnit.binSize(); + return ref.toLocalDate().plusDays(binDays).atStartOfDay(z).toInstant(); + } + final long unitSeconds = switch (stepUnit.unit()) { + case "hour" -> 3600L; + case "minute" -> 60L; + default -> 1L; + }; + final long binSeconds = stepUnit.binSize() * unitSeconds; + final long refEpoch = ref.toInstant().getEpochSecond(); + final long elapsed = t.getEpochSecond() - refEpoch; + return Instant.ofEpochSecond(refEpoch + Math.floorDiv(elapsed, binSeconds) * binSeconds); + } + + // --- Bucketed aggregation (in-kernel downsampling) --- + + /** + * Downsamples scanned points into {@code step} buckets and reduces each bucket with the given + * {@code aggregation}, returning bucket-start → aggregated value in ascending order — the + * same shape a backend's native downsampling produces, ready for {@link #fillBuckets}. This is + * how a scan-only backend (one that cannot push bucketing down) still answers bucketed queries: + * the kernel does the grouping the database would otherwise do. + *

+ * Supports the single-value bucket reductions {@code AVG}, {@code MIN}, {@code MAX}, + * {@code SUM}, {@code COUNT}, {@code FIRST}, {@code LAST}, {@code STDDEV} (sample standard + * deviation — {@code null} for a single-point bucket, matching {@code $stdDevSamp}) and + * {@code PERCENTILE}. The whole-series aggregations ({@code DERIVATIVE}, {@code RATE}, + * {@code INTEGRAL}) are not bucket reductions and are rejected. A {@code null} bucket value + * (e.g. single-point {@code STDDEV}) is emitted and rendered as a gap by {@link #fillBuckets}. + * + * @param points the source points, ascending by time. + * @param step the bucket width. + * @param aggregation the per-bucket reduction. + * @param zone the zone the buckets align to, or {@code null} for UTC. + * @param percentile the percentile (0–100), required when {@code aggregation} is + * {@code PERCENTILE}; ignored otherwise. + * @return an ascending map of bucket start to aggregated value (values may be {@code null}). + * @throws NullPointerException if {@code points}, {@code step} or {@code aggregation} is + * {@code null}. + * @throws IllegalArgumentException if {@code aggregation} is not a bucket reduction, or is + * {@code PERCENTILE} without a {@code percentile}. + */ + public static LinkedHashMap aggregateBuckets(final List points, + final Duration step, + final Aggregation aggregation, + @Nullable final ZoneId zone, + @Nullable final Double percentile) { + + checkNotNull(points, "points"); + checkNotNull(step, "step"); + checkNotNull(aggregation, "aggregation"); + if (aggregation == Aggregation.PERCENTILE && percentile == null) { + throw new IllegalArgumentException("The percentile aggregation requires a percentile value."); + } + + // Group ascending points by bucket start; insertion order of the outer map stays ascending + // because the points are ascending, which is exactly the order fillBuckets expects. + final LinkedHashMap> grouped = new LinkedHashMap<>(); + for (final TimePoint point : points) { + grouped.computeIfAbsent(bucketStart(point.time(), step, zone), k -> new ArrayList<>()) + .add(point.value()); + } + final LinkedHashMap out = new LinkedHashMap<>(); + for (final Map.Entry> entry : grouped.entrySet()) { + out.put(entry.getKey(), reduceBucket(entry.getValue(), aggregation, percentile)); + } + return out; + } + + @Nullable + private static JsonValue reduceBucket(final List values, final Aggregation aggregation, + @Nullable final Double percentile) { + + switch (aggregation) { + case COUNT: + return JsonValue.of(values.size()); + case SUM: + return JsonValue.of(sum(values)); + case AVG: + return JsonValue.of(sum(values) / values.size()); + case MIN: + return JsonValue.of(Collections.min(values)); + case MAX: + return JsonValue.of(Collections.max(values)); + case FIRST: + return JsonValue.of(values.get(0)); + case LAST: + return JsonValue.of(values.get(values.size() - 1)); + case STDDEV: + return sampleStdDev(values); + case PERCENTILE: + return JsonValue.of(percentile(values, percentile)); + default: + throw new IllegalArgumentException( + "Aggregation <" + aggregation.getName() + "> is not a bucket reduction."); + } + } + + private static double sum(final List values) { + double total = 0.0; + for (final double v : values) { + total += v; + } + return total; + } + + /** Sample standard deviation ({@code n-1} denominator); {@code null} for fewer than 2 points. */ + @Nullable + private static JsonValue sampleStdDev(final List values) { + final int n = values.size(); + if (n < 2) { + return null; + } + final double mean = sum(values) / n; + double sumSq = 0.0; + for (final double v : values) { + final double d = v - mean; + sumSq += d * d; + } + return JsonValue.of(Math.sqrt(sumSq / (n - 1))); + } + + // --- Gap fill --- + + /** + * Materialises the bucket grid from a map of populated bucket starts to aggregated values. + * With no fill strategy only populated buckets are emitted; with a strategy, interior gaps + * between populated buckets are filled per {@link FillStrategy}. + *

+ * The grid is driven off the populated bucket starts rather than a free-running cursor: each + * {@code [present[i-1], present[i]]} segment is walked by {@link #nextBucket} and always closed + * by emitting {@code present[i]} exactly. This keeps the grid locked to the backend's bucket + * alignment — including tz/DST-aware boundaries — so a populated bucket is never skipped even + * if a calendar step would otherwise drift around a transition. Filled points are flagged as + * gaps ({@link TimeseriesDataValue#isGap()}); real buckets are not. + * + * @param byBucket populated bucket start → aggregated value (value may be {@code null}), + * in ascending bucket order. + * @param step the bucket width. + * @param fill the fill strategy, or {@code null} to emit only populated buckets. + * @param zone the timezone the buckets were aligned to ({@code null} for UTC); controls whether + * stepping is calendar-aware (see {@link #nextBucket}). + * @return the materialised, ascending list of data values. + * @throws NullPointerException if {@code byBucket} or {@code step} is {@code null}. + */ + public static List fillBuckets( + final LinkedHashMap byBucket, + final Duration step, + @Nullable final FillStrategy fill, + @Nullable final ZoneId zone) { + + checkNotNull(byBucket, "byBucket"); + checkNotNull(step, "step"); + final List data = new ArrayList<>(); + if (byBucket.isEmpty()) { + return data; + } + final List present = new ArrayList<>(byBucket.keySet()); + if (fill == null) { + for (final Instant bucket : present) { + data.add(toValue(bucket, byBucket.get(bucket))); + } + return data; + } + + data.add(toValue(present.get(0), byBucket.get(present.get(0)))); + for (int i = 1; i < present.size(); i++) { + final Instant segmentStart = present.get(i - 1); + final Instant segmentEnd = present.get(i); + final JsonValue startValue = byBucket.get(segmentStart); + final JsonValue endValue = byBucket.get(segmentEnd); + Instant cursor = nextBucket(segmentStart, step, zone); + while (cursor.isBefore(segmentEnd)) { + if (fill == FillStrategy.LINEAR) { + data.add(linearFill(cursor, segmentStart, startValue, segmentEnd, endValue)); + } else { + data.add(fillValue(cursor, fill, startValue)); + } + cursor = nextBucket(cursor, step, zone); + } + data.add(toValue(segmentEnd, endValue)); + } + return data; + } + + private static TimeseriesDataValue toValue(final Instant t, @Nullable final JsonValue v) { + return v == null ? TimeseriesDataValue.gap(t, null) : TimeseriesDataValue.of(t, v); + } + + private static TimeseriesDataValue fillValue(final Instant t, final FillStrategy fill, + @Nullable final JsonValue previous) { + return switch (fill) { + case ZERO -> TimeseriesDataValue.gap(t, JsonValue.of(0)); + case PREVIOUS -> TimeseriesDataValue.gap(t, previous); + case NULL -> TimeseriesDataValue.gap(t, null); + // LINEAR needs both surrounding anchors, so it is interpolated in fillBuckets directly. + case LINEAR -> throw new IllegalStateException( + "LINEAR fill is interpolated in fillBuckets and must not reach fillValue."); + }; + } + + /** + * Linearly interpolates the value at gap instant {@code t} between the surrounding populated + * buckets {@code (t0, v0)} and {@code (t1, v1)}. Interpolation is only defined for numeric + * endpoints; for non-numeric or missing neighbours it falls back to a {@code null} gap (the + * same shape {@link FillStrategy#NULL} would produce) rather than fabricating a value. + */ + private static TimeseriesDataValue linearFill(final Instant t, + final Instant t0, @Nullable final JsonValue v0, + final Instant t1, @Nullable final JsonValue v1) { + + if (v0 == null || v1 == null || !v0.isNumber() || !v1.isNumber()) { + return TimeseriesDataValue.gap(t, null); + } + final double spanMillis = Duration.between(t0, t1).toMillis(); + final double fraction = + spanMillis == 0.0 ? 0.0 : Duration.between(t0, t).toMillis() / spanMillis; + final double y0 = v0.asDouble(); + final double y1 = v1.asDouble(); + return TimeseriesDataValue.gap(t, JsonValue.of(y0 + (y1 - y0) * fraction)); + } + + // --- Advanced aggregations derived from fetched points --- + + /** + * An immutable {@code (timestamp, numeric value)} pair — the input shape for the point-derived + * aggregations below. An adapter maps its fetched (and optionally pre-downsampled) rows to a + * list of these before handing them to the kernel. + * + * @param time the observation timestamp. + * @param value the numeric value at {@code time}. + */ + public record TimePoint(Instant time, double value) {} + + /** + * Computes the discrete derivative of consecutive points: {@code (v[n]-v[n-1])/dt} in seconds. + * {@code rate} is the non-negative variant that treats a value decrease as a counter reset + * ({@code v[n]/dt}). Points that are not strictly after their predecessor ({@code dt <= 0}) are + * skipped. The first point has no predecessor, so the result has at most one fewer point than + * the input; each result is stamped at the later of the two timestamps. + * + * @param points the source points, ascending by time. + * @param rate {@code true} for the counter-reset (rate) variant, {@code false} for a plain + * derivative. + * @return the derivative series (never {@code null}). + * @throws NullPointerException if {@code points} is {@code null}. + */ + public static List derivative(final List points, + final boolean rate) { + + checkNotNull(points, "points"); + final List data = new ArrayList<>(); + for (int i = 1; i < points.size(); i++) { + final TimePoint prev = points.get(i - 1); + final TimePoint cur = points.get(i); + final double dt = (cur.time().toEpochMilli() - prev.time().toEpochMilli()) / 1000.0; + if (dt <= 0) { + continue; + } + final double d = (rate && cur.value() < prev.value()) + ? cur.value() / dt + : (cur.value() - prev.value()) / dt; + data.add(TimeseriesDataValue.of(cur.time(), JsonValue.of(d))); + } + return data; + } + + /** + * Computes the trapezoidal area under the curve over consecutive points, in value-seconds. A + * single whole-range result is returned, stamped at the last observation; an empty input yields + * an empty result. + * + * @param points the source points, ascending by time. + * @return a list holding the single integral result, or empty if {@code points} is empty. + * @throws NullPointerException if {@code points} is {@code null}. + */ + public static List integral(final List points) { + checkNotNull(points, "points"); + double integral = 0; + for (int i = 1; i < points.size(); i++) { + final TimePoint prev = points.get(i - 1); + final TimePoint cur = points.get(i); + final double dt = (cur.time().toEpochMilli() - prev.time().toEpochMilli()) / 1000.0; + integral += (cur.value() + prev.value()) / 2.0 * dt; + } + final List data = new ArrayList<>(); + if (!points.isEmpty()) { + data.add(TimeseriesDataValue.of(points.get(points.size() - 1).time(), + JsonValue.of(integral))); + } + return data; + } + + /** + * Computes the {@code p}-th percentile (0–100) of the given values using linear interpolation + * between the two closest ranks. Portable across backends and independent of any native + * percentile accumulator. The input is defensively copied and sorted; it is not mutated. + * + * @param values the values to compute the percentile over; must be non-empty. + * @param p the percentile in {@code [0, 100]}. + * @return the interpolated percentile value. + * @throws NullPointerException if {@code values} is {@code null}. + * @throws IllegalArgumentException if {@code values} is empty. + */ + public static double percentile(final List values, final double p) { + checkNotNull(values, "values"); + if (values.isEmpty()) { + throw new IllegalArgumentException("Cannot compute a percentile of an empty value list."); + } + final List sorted = new ArrayList<>(values); + Collections.sort(sorted); + if (sorted.size() == 1) { + return sorted.get(0); + } + final double rank = (p / 100.0) * (sorted.size() - 1); + final int lo = (int) Math.floor(rank); + final int hi = (int) Math.ceil(rank); + if (lo == hi) { + return sorted.get(lo); + } + return sorted.get(lo) + (rank - lo) * (sorted.get(hi) - sorted.get(lo)); + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/CapabilitiesTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/CapabilitiesTest.java new file mode 100644 index 00000000000..ebfdd7643b5 --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/CapabilitiesTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.EnumSet; + +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; +import nl.jqno.equalsverifier.Warning; + +/** + * Unit tests for {@link Capabilities}. + */ +public final class CapabilitiesTest { + + @Test + public void minimalPushesNothingDown() { + final Capabilities minimal = Capabilities.minimal(); + + assertThat(minimal.supportsNativeQuery()).isFalse(); + assertThat(minimal.getPushableAggregations()).isEmpty(); + assertThat(minimal.getNativeFillStrategies()).isEmpty(); + assertThat(minimal.canPushDown(Aggregation.AVG)).isFalse(); + assertThat(minimal.canFillNatively(FillStrategy.LINEAR)).isFalse(); + } + + @Test + public void builderDeclaresCapabilities() { + final Capabilities caps = Capabilities.builder() + .supportsNativeQuery(true) + .pushableAggregations(EnumSet.of(Aggregation.AVG, Aggregation.SUM)) + .nativeFillStrategies(EnumSet.of(FillStrategy.LINEAR)) + .build(); + + assertThat(caps.supportsNativeQuery()).isTrue(); + assertThat(caps.canPushDown(Aggregation.AVG)).isTrue(); + assertThat(caps.canPushDown(Aggregation.SUM)).isTrue(); + assertThat(caps.canPushDown(Aggregation.MIN)).isFalse(); + assertThat(caps.canFillNatively(FillStrategy.LINEAR)).isTrue(); + assertThat(caps.canFillNatively(FillStrategy.PREVIOUS)).isFalse(); + } + + @Test + public void declaredSetsAreDefensivelyCopied() { + final EnumSet aggs = EnumSet.of(Aggregation.AVG); + final Capabilities caps = Capabilities.builder().pushableAggregations(aggs).build(); + + aggs.add(Aggregation.MAX); // mutate the caller's set after build + + assertThat(caps.canPushDown(Aggregation.MAX)).isFalse(); + } + + @Test + public void pushableAggregationsSetIsUnmodifiable() { + final Capabilities caps = Capabilities.builder() + .pushableAggregations(EnumSet.of(Aggregation.AVG)) + .build(); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> caps.getPushableAggregations().add(Aggregation.MIN)); + } + + @Test + public void equalsAndHashCode() { + EqualsVerifier.forClass(Capabilities.class) + .usingGetClass() + .suppress(Warning.NULL_FIELDS) // sets are never null (constructor-enforced) + .verify(); + } + + @Test + public void canPushDownRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> Capabilities.minimal().canPushDown(null)); + } + + @Test + public void builderRejectsNullSets() { + assertThatNullPointerException() + .isThrownBy(() -> Capabilities.builder().pushableAggregations(null)); + assertThatNullPointerException() + .isThrownBy(() -> Capabilities.builder().nativeFillStrategies(null)); + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/HealthStatusTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/HealthStatusTest.java new file mode 100644 index 00000000000..d1c6c911f4d --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/HealthStatusTest.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link HealthStatus}. + */ +public final class HealthStatusTest { + + @Test + public void allExpectedValuesPresent() { + assertThat(HealthStatus.values()) + .containsExactly(HealthStatus.UP, HealthStatus.DEGRADED, HealthStatus.DOWN); + } + + @Test + public void valueOfMatchesByName() { + assertThat(HealthStatus.valueOf("UP")).isEqualTo(HealthStatus.UP); + assertThat(HealthStatus.valueOf("DEGRADED")).isEqualTo(HealthStatus.DEGRADED); + assertThat(HealthStatus.valueOf("DOWN")).isEqualTo(HealthStatus.DOWN); + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/InMemoryTimeseriesAdapter.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/InMemoryTimeseriesAdapter.java new file mode 100644 index 00000000000..e05625348da --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/InMemoryTimeseriesAdapter.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; + +/** + * A minimal, in-memory {@link TimeseriesAdapter} used as a reference "second backend" in tests. It + * implements only the {@link #scan} primitive (plus lifecycle/write) and declares the default + * {@linkplain Capabilities#minimal() minimal} capabilities — so it proves that the + * {@link TimeseriesQueryPlanner} can make a scan-only backend fully queryable via the shared kernel, + * with results that must match a capable backend such as MongoDB. + *

+ * Its {@link #query} is implemented by delegating to the planner; this is recursion-safe precisely + * because the adapter advertises no push-down, so the planner always takes the scan+kernel path and + * never calls back into {@code query}. + */ +final class InMemoryTimeseriesAdapter implements TimeseriesAdapter { + + private final Map> series = new HashMap<>(); + + /** Test helper: append a numeric point to a series. */ + void ingest(final ThingId thingId, final JsonPointer path, final Instant timestamp, + final double value) { + series.computeIfAbsent(key(thingId, path), k -> new ArrayList<>()) + .add(TimeseriesDataValue.of(timestamp, JsonValue.of(value))); + } + + @Override + public Capabilities capabilities() { + return Capabilities.minimal(); // scan-only: the planner must drive it via scan + kernel + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + series.computeIfAbsent(key(dataPoint.getThingId(), dataPoint.getPath()), k -> new ArrayList<>()) + .add(TimeseriesDataValue.of(dataPoint.getTimestamp(), dataPoint.getValue())); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + return new TimeseriesQueryPlanner(this).execute(query); + } + + @Override + public CompletionStage> scan(final ThingId thingId, + final JsonPointer path, final Instant from, final Instant to, final int limit) { + + final List out = new ArrayList<>(); + for (final TimeseriesDataValue value : series.getOrDefault(key(thingId, path), List.of())) { + final Instant t = value.getTimestamp(); + if (!t.isBefore(from) && t.isBefore(to)) { // [from, to) + out.add(value); + } + } + out.sort(Comparator.comparing(TimeseriesDataValue::getTimestamp)); + final int cap = (limit <= 0) ? out.size() : Math.min(limit, out.size()); + return CompletableFuture.completedFuture(List.copyOf(out.subList(0, cap))); + } + + private static String key(final ThingId thingId, final JsonPointer path) { + return thingId + "|" + path; + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterDefaultsTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterDefaultsTest.java new file mode 100644 index 00000000000..ae3de2ec0f0 --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesAdapterDefaultsTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.junit.Test; + +/** + * Tests for the {@code default} method behaviour on {@link TimeseriesAdapter}. + */ +public final class TimeseriesAdapterDefaultsTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = JsonPointer.of("/features/env/properties/temperature"); + + private static TimeseriesDataPoint dataPoint(final int second) { + return TimeseriesDataPoint.of( + THING_ID, + PATH, + Instant.parse("2026-01-15T10:30:0" + second + "Z"), + JsonValue.of(20 + second), + 42L + second, + Collections.emptyMap(), + null); + } + + @Test + public void defaultWriteBatchInvokesWriteForEachDataPointInOrder() { + final RecordingAdapter adapter = new RecordingAdapter(); + final List batch = Arrays.asList(dataPoint(0), dataPoint(1), dataPoint(2)); + + adapter.writeBatch(batch).toCompletableFuture().join(); + + assertThat(adapter.recorded).containsExactlyElementsOf(batch); + } + + @Test + public void defaultWriteBatchAcceptsEmptyList() { + final RecordingAdapter adapter = new RecordingAdapter(); + + adapter.writeBatch(Collections.emptyList()).toCompletableFuture().join(); + + assertThat(adapter.recorded).isEmpty(); + } + + @Test + public void defaultWriteBatchRejectsNullList() { + final RecordingAdapter adapter = new RecordingAdapter(); + + assertThatNullPointerException().isThrownBy(() -> adapter.writeBatch(null)); + } + + @Test + public void defaultWriteBatchRejectsNullElement() { + final RecordingAdapter adapter = new RecordingAdapter(); + final List batchWithNull = Arrays.asList(dataPoint(0), null); + + assertThatNullPointerException().isThrownBy(() -> adapter.writeBatch(batchWithNull)); + } + + /** + * Minimal in-memory adapter that records every data point passed to {@code write}, for + * verifying the default-method behaviour of {@link TimeseriesAdapter}. + */ + private static final class RecordingAdapter implements TimeseriesAdapter { + + private final List recorded = new ArrayList<>(); + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + recorded.add(dataPoint); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesBackendConformanceTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesBackendConformanceTest.java new file mode 100644 index 00000000000..6a50451652f --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesBackendConformanceTest.java @@ -0,0 +1,463 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.api.compute.TimeseriesComputeKernel; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.junit.Test; + +/** + * Conformance suite for the {@link TimeseriesAdapter} SPI: the push-down and portable + * execution paths must return identical results for the same data and query. + *

+ * {@link TimeseriesComputeKernel} is documented as "the reference answer any native push-down + * implementation is expected to match". {@link TimeseriesQueryPlannerTest} covers which path the + * planner chooses; this suite covers whether the two paths agree. Without it a + * backend could quietly disagree with the kernel — on sample-vs-population standard deviation, on + * how an empty bucket is represented, or on the ordering that {@code first}/{@code last} depend on — + * and every routing test would still pass. + *

+ * Both sides run through {@link TimeseriesQueryPlanner}, so this exercises the real dispatch: + *

    + *
  • portable — {@link InMemoryTimeseriesAdapter} declares + * {@linkplain Capabilities#minimal() minimal} capabilities, so the planner scans and computes in + * the kernel;
  • + *
  • push-down — {@link PushDownAdapter} declares + * {@linkplain Capabilities#nativeQuery() a complete native query} and reduces the buckets itself, + * standing in for a backend such as MongoDB.
  • + *
+ * The push-down side deliberately reduces each bucket with plain loops rather than calling the + * kernel's reductions, so the aggregation semantics are genuinely compared rather than trivially + * shared. It does align buckets via the public {@link TimeseriesComputeKernel#bucketStart} contract, + * because grid alignment is a published part of the SPI that a real backend targets too (MongoDB + * reaches the same grid through {@code $dateTrunc}). + */ +public final class TimeseriesBackendConformanceTest { + + private static final ThingId THING = ThingId.of("org.eclipse.ditto.ts", "heat"); + private static final JsonPointer FLOW = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + private static final Instant T0 = Instant.parse("2024-06-01T00:00:00Z"); + private static final Instant FROM = T0.minusSeconds(60); + private static final Instant TO = T0.plusSeconds(120); + private static final Duration STEP = Duration.ofSeconds(2); + + /** + * Buckets at a 2 s step: {@code :00=[10]}, {@code :02=[12,14,11]}, gap, {@code :10=[44,40]}. + * Deliberately shaped so the reductions are distinguishable — the {@code :02} bucket has three + * unordered values so {@code first}/{@code last}/{@code stddev} cannot accidentally agree, and + * {@code :10} is descending so {@code first} is not also {@code max}. + */ + private static void seed(final BiConsumerLike sink) { + sink.accept(T0, 10); + sink.accept(T0.plusSeconds(2), 12); + sink.accept(T0.plusSeconds(3), 14); + sink.accept(T0.plusSeconds(3).plusMillis(500), 11); + sink.accept(T0.plusSeconds(10), 44); + sink.accept(T0.plusSeconds(11), 40); + } + + @FunctionalInterface + private interface BiConsumerLike { + void accept(Instant timestamp, double value); + } + + @Test + public void avgAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.AVG); + } + + @Test + public void minAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.MIN); + } + + @Test + public void maxAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.MAX); + } + + @Test + public void sumAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.SUM); + } + + @Test + public void countAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.COUNT); + } + + @Test + public void firstAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.FIRST); + } + + @Test + public void lastAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.LAST); + } + + @Test + public void stddevAgreesAcrossBothPaths() { + assertPathsAgree(Aggregation.STDDEV); + } + + /** + * Pins the flavour of standard deviation, because this is the classic silent divergence: the + * kernel computes the sample standard deviation, so a bucket holding a single point has + * no defined value. MongoDB's {@code $stdDevSamp} behaves the same way; a backend reaching for + * {@code $stdDevPop} would return {@code 0} here and disagree. + */ + @Test + public void stddevIsSampleNotPopulation() { + final List portable = portable(Aggregation.STDDEV); + + // Bucket :00 holds exactly one point (10) -> sample stddev undefined. + assertThat(portable.get(0).getValue()).isEmpty(); + // Bucket :02 holds [12, 14, 11] -> sample stddev = 1.5275252316519465. + assertThat(portable.get(1).getValue().orElseThrow().asDouble()) + .isCloseTo(1.5275252316519465, within(1e-9)); + } + + /** An empty interior bucket must be absent on both paths when no fill strategy is requested. */ + @Test + public void emptyBucketsAreOmittedIdenticallyOnBothPaths() { + final List portable = portable(Aggregation.AVG); + final List pushDown = pushDown(Aggregation.AVG); + + assertThat(timestamps(portable)).containsExactly( + T0, T0.plusSeconds(2), T0.plusSeconds(10)); + assertThat(timestamps(pushDown)).isEqualTo(timestamps(portable)); + } + + /** + * Gap fill is the behaviour most likely to diverge in a real backend — MongoDB has its own + * {@code $densify}/{@code $fill} rather than the kernel's interpolation — and a divergence is + * invisible in the values alone, because an interpolated point looks like an observed one. The + * bucket at {@code :06} is empty in the fixture, so both paths must synthesise it identically + * and flag it as a gap. + */ + @Test + public void linearFillAgreesAcrossBothPaths() { + final TimeseriesQuery filled = query(Aggregation.AVG, FillStrategy.LINEAR, null, STEP); + + final List portable = portable(filled); + final List pushDown = pushDown(filled); + + assertThat(timestamps(pushDown)).isEqualTo(timestamps(portable)); + assertThat(values(pushDown)).isEqualTo(values(portable)); + // Non-vacuous: the fixture's gap really was materialised, and flagged. + assertThat(portable).hasSizeGreaterThan(3); + assertThat(portable.stream().anyMatch(TimeseriesDataValue::isGap)).isTrue(); + } + + /** + * Bucket boundaries are anchored to 2000-01-01 in the query's zone, so a zone with a + * sub-hour offset shifts the whole grid. Asia/Kolkata (+05:30) puts hourly buckets on the half + * hour; a backend truncating in UTC would land them on the hour and silently disagree. + */ + @Test + public void zoneAlignedBucketsAgreeAcrossBothPaths() { + final ZoneId halfHourOffset = ZoneId.of("Asia/Kolkata"); + final TimeseriesQuery zoned = + query(Aggregation.AVG, null, halfHourOffset, Duration.ofHours(1)); + + final List portable = portable(zoned); + final List pushDown = pushDown(zoned); + + assertThat(timestamps(pushDown)).isEqualTo(timestamps(portable)); + assertThat(values(pushDown)).isEqualTo(values(portable)); + // Non-vacuous: the grid really is offset from the UTC hour by the zone's :30. + assertThat(portable).isNotEmpty(); + assertThat(portable.get(0).getTimestamp().atZone(ZoneOffset.UTC).getMinute()).isEqualTo(30); + } + + private static void assertPathsAgree(final Aggregation aggregation) { + final List portable = portable(aggregation); + final List pushDown = pushDown(aggregation); + + assertThat(timestamps(pushDown)) + .as("bucket grid for %s", aggregation.getName()) + .isEqualTo(timestamps(portable)); + assertThat(values(pushDown)) + .as("reduced values for %s", aggregation.getName()) + .isEqualTo(values(portable)); + } + + private static List portable(final Aggregation aggregation) { + return portable(query(aggregation)); + } + + private static List pushDown(final Aggregation aggregation) { + return pushDown(query(aggregation)); + } + + private static List portable(final TimeseriesQuery query) { + final InMemoryTimeseriesAdapter adapter = new InMemoryTimeseriesAdapter(); + seed((t, v) -> adapter.ingest(THING, FLOW, t, v)); + return single(new TimeseriesQueryPlanner(adapter).execute(query)); + } + + private static List pushDown(final TimeseriesQuery query) { + final PushDownAdapter adapter = new PushDownAdapter(); + seed(adapter::ingest); + return single(new TimeseriesQueryPlanner(adapter).execute(query)); + } + + private static TimeseriesQuery query(final Aggregation aggregation) { + return query(aggregation, null, null, STEP); + } + + private static TimeseriesQuery query(final Aggregation aggregation, + @Nullable final FillStrategy fill, @Nullable final ZoneId zone, final Duration step) { + + return TimeseriesQuery.of(THING, List.of(FLOW), FROM, TO, step, aggregation, + fill, null, zone, null); + } + + private static List timestamps(final List data) { + final List out = new ArrayList<>(data.size()); + for (final TimeseriesDataValue value : data) { + out.add(value.getTimestamp()); + } + return out; + } + + /** + * Renders each point as {@code value|gapFlag} so {@code count} (an int) and the doubles compare + * structurally, and so a fill divergence cannot hide behind matching values: an interpolated + * bucket and a genuinely observed one carry the same number but different gap flags. + */ + private static List values(final List data) { + final List out = new ArrayList<>(data.size()); + for (final TimeseriesDataValue value : data) { + out.add(value.getValue().map(JsonValue::toString).orElse("") + + "|" + (value.isGap() ? "gap" : "observed")); + } + return out; + } + + private static List single( + final CompletionStage> stage) { + + final List results = stage.toCompletableFuture().join(); + assertThat(results).hasSize(1); + return results.get(0).getData(); + } + + /** + * A backend that advertises a complete native query and reduces buckets itself — the stand-in + * for MongoDB. Reductions are plain loops on purpose: sharing the kernel's implementation would + * make the conformance assertions vacuous. + */ + private static final class PushDownAdapter implements TimeseriesAdapter { + + private final List points = new ArrayList<>(); + + void ingest(final Instant timestamp, final double value) { + points.add(TimeseriesDataValue.of(timestamp, JsonValue.of(value))); + } + + @Override + public Capabilities capabilities() { + return Capabilities.nativeQuery(); + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + points.add(TimeseriesDataValue.of(dataPoint.getTimestamp(), dataPoint.getValue())); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + final Duration step = query.getStep().orElseThrow(); + final Aggregation aggregation = query.getAggregation().orElseThrow(); + final ZoneId zone = query.getTimezone().orElse(null); + + final List inRange = new ArrayList<>(); + for (final TimeseriesDataValue point : points) { + final Instant t = point.getTimestamp(); + if (!t.isBefore(query.getFrom()) && t.isBefore(query.getTo())) { + inRange.add(point); + } + } + inRange.sort(Comparator.comparing(TimeseriesDataValue::getTimestamp)); + + final Map> byBucket = new LinkedHashMap<>(); + for (final TimeseriesDataValue point : inRange) { + byBucket.computeIfAbsent( + TimeseriesComputeKernel.bucketStart(point.getTimestamp(), step, zone), + k -> new ArrayList<>()) + .add(point.getValue().orElseThrow().asDouble()); + } + + final List reducedBuckets = new ArrayList<>(byBucket.size()); + for (final Map.Entry> bucket : byBucket.entrySet()) { + final JsonValue reduced = reduce(aggregation, bucket.getValue()); + reducedBuckets.add(reduced == null + ? TimeseriesDataValue.gap(bucket.getKey(), null) + : TimeseriesDataValue.of(bucket.getKey(), reduced)); + } + final List data = + query.getFillStrategy().isPresent() && !reducedBuckets.isEmpty() + ? linearFill(reducedBuckets, step) + : reducedBuckets; + + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return CompletableFuture.completedFuture(List.of( + TimeseriesQueryResult.of(query.getThingId(), query.getPaths().get(0), query, + meta, data))); + } + + /** + * Materialises the empty buckets between the first and last populated one, interpolating + * linearly and flagging each as a gap. Hand-rolled for the same reason the reductions are: + * calling the kernel's own {@code fillBuckets} would make the comparison vacuous. + *

+ * Only the interior is filled — a leading or trailing empty stretch has nothing to + * interpolate between, so it stays absent. + */ + private static List linearFill(final List present, + final Duration step) { + + final List out = new ArrayList<>(); + for (int i = 0; i < present.size(); i++) { + final TimeseriesDataValue current = present.get(i); + out.add(current); + if (i + 1 >= present.size()) { + break; + } + final TimeseriesDataValue next = present.get(i + 1); + final double from = current.getValue().orElseThrow().asDouble(); + final double to = next.getValue().orElseThrow().asDouble(); + + // How many whole steps separate the two populated buckets? + long gaps = 0; + for (Instant t = current.getTimestamp().plus(step); + t.isBefore(next.getTimestamp()); t = t.plus(step)) { + gaps++; + } + final double increment = (to - from) / (gaps + 1); + Instant t = current.getTimestamp().plus(step); + for (long g = 1; g <= gaps; g++, t = t.plus(step)) { + out.add(TimeseriesDataValue.gap(t, JsonValue.of(from + increment * g))); + } + } + return out; + } + + /** Deliberately hand-rolled; see the class javadoc. Returns {@code null} for "no value". */ + private static JsonValue reduce(final Aggregation aggregation, final List values) { + switch (aggregation) { + case AVG: + double total = 0.0; + for (final double v : values) { + total += v; + } + return JsonValue.of(total / values.size()); + case MIN: + double min = values.get(0); + for (final double v : values) { + min = Math.min(min, v); + } + return JsonValue.of(min); + case MAX: + double max = values.get(0); + for (final double v : values) { + max = Math.max(max, v); + } + return JsonValue.of(max); + case SUM: + double sum = 0.0; + for (final double v : values) { + sum += v; + } + return JsonValue.of(sum); + case COUNT: + return JsonValue.of(values.size()); + case FIRST: + return JsonValue.of(values.get(0)); + case LAST: + return JsonValue.of(values.get(values.size() - 1)); + case STDDEV: + // Sample standard deviation — undefined for a single observation, matching + // MongoDB's $stdDevSamp. A population variant would return 0 here instead. + if (values.size() < 2) { + return null; + } + double mean = 0.0; + for (final double v : values) { + mean += v; + } + mean /= values.size(); + double sq = 0.0; + for (final double v : values) { + sq += (v - mean) * (v - mean); + } + return JsonValue.of(Math.sqrt(sq / (values.size() - 1))); + default: + throw new IllegalArgumentException( + "Not a bucket reduction: " + aggregation.getName()); + } + } + + @Override + public CompletionStage> scan(final ThingId thingId, + final JsonPointer path, final Instant from, final Instant to, final int limit) { + + throw new AssertionError("push-down backend must not be scanned by the planner"); + } + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstantsTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstantsTest.java new file mode 100644 index 00000000000..5c2a3de1bac --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesMessagingConstantsTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; + +/** + * Unit tests for {@link TimeseriesMessagingConstants}. + */ +public final class TimeseriesMessagingConstantsTest { + + @Test + public void serviceNameIsTimeseries() { + assertThat(TimeseriesMessagingConstants.SERVICE_NAME).isEqualTo("timeseries"); + } + + @Test + public void clusterRoleMatchesServiceName() { + assertThat(TimeseriesMessagingConstants.CLUSTER_ROLE) + .isEqualTo(TimeseriesMessagingConstants.SERVICE_NAME); + } + + @Test + public void rootActorPathStartsWithUserPath() { + assertThat(TimeseriesMessagingConstants.ROOT_ACTOR_PATH).startsWith("/user/"); + } + + @Test + public void rootActorPathEndsWithRootActorName() { + assertThat(TimeseriesMessagingConstants.ROOT_ACTOR_PATH) + .endsWith(TimeseriesMessagingConstants.ROOT_ACTOR_NAME); + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlannerTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlannerTest.java new file mode 100644 index 00000000000..585ef39a004 --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/TimeseriesQueryPlannerTest.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.junit.Before; +import org.junit.Test; + +/** + * Conformance + routing tests for {@link TimeseriesQueryPlanner}. + *

+ * The conformance battery runs every query shape through the planner over a scan-only + * {@link InMemoryTimeseriesAdapter} and asserts exact, hand-computed results — proving the portable + * (scan + kernel) path is correct end-to-end and matches the values MongoDB produced live for the + * same data. The routing tests prove the planner delegates to the backend when it can push a + * bucketed aggregation down, and falls back to {@code scan} otherwise. + */ +public final class TimeseriesQueryPlannerTest { + + private static final ThingId THING = ThingId.of("org.eclipse.ditto.ts", "heat"); + private static final JsonPointer FLOW = + JsonPointer.of("/features/systemTemperatures/properties/flowTemperature"); + private static final JsonPointer RETURN = + JsonPointer.of("/features/systemTemperatures/properties/returnTemperature"); + private static final Instant T0 = Instant.parse("2024-06-01T00:00:00Z"); + private static final Instant FROM = T0.minusSeconds(60); + private static final Instant TO = T0.plusSeconds(120); + private static final Duration STEP_2S = Duration.ofSeconds(2); + + private InMemoryTimeseriesAdapter adapter; + private TimeseriesQueryPlanner planner; + + @Before + public void setUp() { + adapter = new InMemoryTimeseriesAdapter(); + // buckets (2s): :00=[10], :02=[12,14], gap, :10=[40,44] + adapter.ingest(THING, FLOW, T0, 10); + adapter.ingest(THING, FLOW, T0.plusSeconds(2), 12); + adapter.ingest(THING, FLOW, T0.plusSeconds(3), 14); + adapter.ingest(THING, FLOW, T0.plusSeconds(10), 40); + adapter.ingest(THING, FLOW, T0.plusSeconds(11), 44); + adapter.ingest(THING, RETURN, T0, 30); + adapter.ingest(THING, RETURN, T0.plusSeconds(2), 34); + adapter.ingest(THING, RETURN, T0.plusSeconds(10), 60); + planner = new TimeseriesQueryPlanner(adapter); + } + + // --- Conformance: portable (scan + kernel) path over a scan-only backend --- + + @Test + public void rawReadReturnsAllPointsAscending() { + final List data = single(planner.execute( + TimeseriesQuery.of(THING, List.of(FLOW), FROM, TO))); + + assertThat(values(data)).containsExactly(10.0, 12.0, 14.0, 40.0, 44.0); + } + + @Test + public void avgWithStepBucketsInKernel() { + final List data = single(planner.execute( + query(STEP_2S, Aggregation.AVG, null, null, FLOW))); + + assertThat(values(data)).containsExactly(10.0, 13.0, 42.0); // :00, :02 (avg 12,14), :10 (avg 40,44) + } + + @Test + public void fillLinearInterpolatesGapsInKernel() { + final List data = single(planner.execute( + query(STEP_2S, Aggregation.AVG, FillStrategy.LINEAR, null, FLOW))); + + assertThat(data).hasSize(6); + assertThat(value(data.get(2))).isCloseTo(20.25, within(1e-9)); // :04 + assertThat(value(data.get(3))).isCloseTo(27.5, within(1e-9)); // :06 + assertThat(value(data.get(4))).isCloseTo(34.75, within(1e-9)); // :08 + assertThat(data.get(0).isGap()).isFalse(); + assertThat(data.get(2).isGap()).isTrue(); + assertThat(data.get(5).isGap()).isFalse(); + } + + @Test + public void percentilePerBucketInKernel() { + final List data = single(planner.execute( + queryWithPercentile(STEP_2S, 95.0, FLOW))); + + assertThat(value(data.get(0))).isCloseTo(10.0, within(1e-9)); // [10] + assertThat(value(data.get(1))).isCloseTo(13.9, within(1e-9)); // [12,14] p95 + assertThat(value(data.get(2))).isCloseTo(43.8, within(1e-9)); // [40,44] p95 + } + + @Test + public void derivativeWholeSeriesInKernel() { + final List data = single(planner.execute( + query(null, Aggregation.DERIVATIVE, null, null, FLOW))); + + assertThat(data).hasSize(4); + assertThat(value(data.get(0))).isCloseTo(1.0, within(1e-9)); // (12-10)/2s + assertThat(value(data.get(1))).isCloseTo(2.0, within(1e-9)); // (14-12)/1s + assertThat(value(data.get(2))).isCloseTo(26.0 / 7.0, within(1e-9)); // (40-14)/7s + assertThat(value(data.get(3))).isCloseTo(4.0, within(1e-9)); // (44-40)/1s + } + + @Test + public void integralWholeSeriesInKernel() { + final List data = single(planner.execute( + query(null, Aggregation.INTEGRAL, null, null, FLOW))); + + // trapezoids: 22 + 13 + 189 + 42 = 266, stamped at the last point + assertThat(data).hasSize(1); + assertThat(value(data.get(0))).isCloseTo(266.0, within(1e-9)); + assertThat(data.get(0).getTimestamp()).isEqualTo(T0.plusSeconds(11)); + } + + @Test + public void multiPropertyReturnsOneResultPerPathInOrder() { + final List results = join(planner.execute( + query(STEP_2S, Aggregation.AVG, null, null, FLOW, RETURN))); + + assertThat(results).hasSize(2); + // JsonPointer is both Iterable and CharSequence -> cast to disambiguate assertThat. + assertThat((Object) results.get(0).getPath()).isEqualTo(FLOW); + assertThat(values(results.get(0).getData())).containsExactly(10.0, 13.0, 42.0); + assertThat((Object) results.get(1).getPath()).isEqualTo(RETURN); + assertThat(values(results.get(1).getData())).containsExactly(30.0, 34.0, 60.0); + } + + // --- Routing: native delegation vs portable (scan + kernel) --- + + @Test + public void nativeQueryAdapterIsDelegatedToNotScanned() { + final boolean[] scanned = {false}; + final TimeseriesQueryResult sentinel = TimeseriesQueryResult.of(THING, FLOW, + TimeseriesQuery.of(THING, List.of(FLOW), FROM, TO), + TimeseriesResultMeta.of(0, null, "null"), List.of()); + final TimeseriesQueryPlanner nativePlanner = new TimeseriesQueryPlanner( + capabilityAdapter(true, scanned, List.of(sentinel))); + + // A complete-native backend: the planner delegates the whole query to it, transparently. + final List results = join(nativePlanner.execute( + query(STEP_2S, Aggregation.AVG, null, null, FLOW))); + + assertThat(results).containsExactly(sentinel); // came from the backend's native query(...) + assertThat(scanned[0]).isFalse(); // native path: scan was not used + } + + @Test + public void scanOnlyAdapterUsesScanAndKernel() { + final boolean[] scanned = {false}; + final TimeseriesQueryPlanner portablePlanner = new TimeseriesQueryPlanner( + capabilityAdapter(false, scanned, List.of())); + + // A scan-only backend: the planner must fetch raw points via scan and use the kernel. + join(portablePlanner.execute(query(STEP_2S, Aggregation.AVG, null, null, FLOW))); + + assertThat(scanned[0]).isTrue(); + } + + @Test + public void scanOnlyAdapterRejectsTagFilterInsteadOfSilentlyIgnoringIt() { + final boolean[] scanned = {false}; + final TimeseriesQueryPlanner portablePlanner = new TimeseriesQueryPlanner( + capabilityAdapter(false, scanned, List.of())); + final TimeseriesQuery tagFiltered = TimeseriesQuery.of(THING, List.of(FLOW), FROM, TO) + .withTagFilters(Map.of("building", "A")); + + // The kernel path scans (timestamp, value) points that carry no tags, so it cannot honour a + // tag filter. The planner must fail loud rather than silently return unfiltered data — and + // must not scan. + assertThatThrownBy(() -> join(portablePlanner.execute(tagFiltered))) + .hasCauseInstanceOf(TimeseriesQueryInvalidException.class); + assertThat(scanned[0]).isFalse(); + } + + // --- helpers --- + + private static TimeseriesQuery query(final Duration step, final Aggregation aggregation, + final FillStrategy fill, final ZoneId tz, final JsonPointer... paths) { + return TimeseriesQuery.of(THING, List.of(paths), FROM, TO, step, aggregation, fill, null, tz); + } + + private static TimeseriesQuery queryWithPercentile(final Duration step, final double percentile, + final JsonPointer... paths) { + return TimeseriesQuery.of(THING, List.of(paths), FROM, TO, step, Aggregation.PERCENTILE, + null, null, null, percentile); + } + + private static List join( + final CompletionStage> stage) { + return stage.toCompletableFuture().join(); + } + + private static List single( + final CompletionStage> stage) { + final List results = join(stage); + assertThat(results).hasSize(1); + return results.get(0).getData(); + } + + private static List values(final List data) { + return data.stream().map(TimeseriesQueryPlannerTest::value).toList(); + } + + private static double value(final TimeseriesDataValue dataValue) { + return dataValue.getValue().orElseThrow().asDouble(); + } + + /** + * An adapter that declares {@code supportsNativeQuery = nativeQuery}, returns {@code nativeResult} + * from {@code query(...)}, and records whether {@code scan(...)} was invoked. + */ + private static TimeseriesAdapter capabilityAdapter(final boolean nativeQuery, + final boolean[] scanned, final List nativeResult) { + + return new TimeseriesAdapter() { + @Override + public Capabilities capabilities() { + return Capabilities.builder().supportsNativeQuery(nativeQuery).build(); + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + return CompletableFuture.completedFuture(nativeResult); + } + + @Override + public CompletionStage> scan(final ThingId thingId, + final JsonPointer path, final Instant from, final Instant to, final int limit) { + scanned[0] = true; + return CompletableFuture.completedFuture(List.of()); + } + }; + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsTest.java new file mode 100644 index 00000000000..1142499db3a --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/commands/IngestDataPointsTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api.commands; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.junit.Test; + +/** + * Unit tests for {@link IngestDataPoints}. Focuses on the cluster-message contract: JSON + * round-trip, the thingId-equality invariant on the batch, and the WithEntityId hook the shard + * router relies on. + */ +public final class IngestDataPointsTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final ThingId OTHER_THING_ID = ThingId.of("org.eclipse.ditto", "sensor-2"); + private static final JsonPointer PATH = JsonPointer.of("/features/env/properties/temperature"); + + @Test + public void getEntityIdReturnsThingIdForSharding() { + // The shard router uses WithEntityId#getEntityId to pick a shard. Each Thing must route + // to a single shard so per-Thing ordering holds; this test pins down that contract. + final IngestDataPoints command = IngestDataPoints.of(THING_ID, + List.of(samplePoint(THING_ID)), DittoHeaders.empty()); + + assertThat((Object) command.getEntityId()).isEqualTo(THING_ID); + } + + @Test + public void emptyBatchIsAllowed() { + // An empty batch is the documented no-op path that still acks back to the publisher. + final IngestDataPoints command = IngestDataPoints.of(THING_ID, + Collections.emptyList(), DittoHeaders.empty()); + + assertThat(command.getDataPoints()).isEmpty(); + } + + @Test + public void mismatchedThingIdInBatchRejected() { + // A single batch must not span Things — otherwise the shard router would have to split + // the batch, defeating the point of routing by ThingId. + assertThatThrownBy(() -> IngestDataPoints.of(THING_ID, + List.of(samplePoint(OTHER_THING_ID)), DittoHeaders.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(OTHER_THING_ID.toString()) + .hasMessageContaining(THING_ID.toString()); + } + + @Test + public void jsonRoundTripPreservesAllFields() { + // The cluster serializer round-trips Signal commands through JSON. If toJson/fromJson + // disagree the message fails to deserialize on the receiving node and triggers a + // DeadLetter. Pin the round-trip explicitly so a future field is not silently dropped. + final TimeseriesDataPoint dp = samplePoint(THING_ID); + final IngestDataPoints original = IngestDataPoints.of(THING_ID, List.of(dp), + DittoHeaders.newBuilder().correlationId("round-trip-1").build()); + + final IngestDataPoints parsed = IngestDataPoints.fromJson(original.toJson(), + original.getDittoHeaders()); + + assertThat(parsed).isEqualTo(original); + assertThat(parsed.getDataPoints()).containsExactly(dp); + } + + @Test + public void typePrefixDistinguishesIngestFromUserFacingCommands() { + // The user-facing namespace is "timeseries.commands:". Ingest uses + // "timeseries.ingest.commands:" to keep internal control messages from accidentally + // overlapping with public Ditto Protocol commands. + assertThat(IngestDataPoints.TYPE).startsWith("timeseries.ingest.commands:"); + assertThat(IngestDataPoints.TYPE).doesNotStartWith("timeseries.commands:"); + } + + @Test + public void setDittoHeadersReturnsCommandWithSameBatch() { + // Patterns.ask copies headers onto retries; verify setDittoHeaders does not lose the + // batch (a regression we'd otherwise only catch in the retry path). + final IngestDataPoints original = IngestDataPoints.of(THING_ID, + List.of(samplePoint(THING_ID)), DittoHeaders.empty()); + + final IngestDataPoints withHeaders = original.setDittoHeaders( + DittoHeaders.newBuilder().correlationId("retry-1").build()); + + assertThat(withHeaders.getDataPoints()).isEqualTo(original.getDataPoints()); + assertThat((Object) withHeaders.getEntityId()).isEqualTo(THING_ID); + assertThat(withHeaders.getDittoHeaders().getCorrelationId()).contains("retry-1"); + } + + private static TimeseriesDataPoint samplePoint(final ThingId thingId) { + return TimeseriesDataPoint.of(thingId, PATH, Instant.parse("2026-01-01T00:00:00Z"), + JsonValue.of(21.5), 1L, Collections.emptyMap(), "Cel"); + } +} diff --git a/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernelTest.java b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernelTest.java new file mode 100644 index 00000000000..2c767a90072 --- /dev/null +++ b/timeseries/api/src/test/java/org/eclipse/ditto/timeseries/api/compute/TimeseriesComputeKernelTest.java @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.api.compute; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.within; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.api.compute.TimeseriesComputeKernel.TimePoint; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.junit.Test; + +/** + * Unit tests for {@link TimeseriesComputeKernel} — the backend-neutral reference computations. + */ +public final class TimeseriesComputeKernelTest { + + private static final Instant T0 = Instant.parse("2024-06-01T00:00:00Z"); + + // --- stepUnitFor --- + + @Test + public void stepUnitForPicksCoarsestExactUnit() { + assertThat(TimeseriesComputeKernel.stepUnitFor(Duration.ofDays(2))) + .isEqualTo(new TimeseriesComputeKernel.StepUnit("day", 2)); + assertThat(TimeseriesComputeKernel.stepUnitFor(Duration.ofHours(3))) + .isEqualTo(new TimeseriesComputeKernel.StepUnit("hour", 3)); + assertThat(TimeseriesComputeKernel.stepUnitFor(Duration.ofMinutes(15))) + .isEqualTo(new TimeseriesComputeKernel.StepUnit("minute", 15)); + assertThat(TimeseriesComputeKernel.stepUnitFor(Duration.ofSeconds(5))) + .isEqualTo(new TimeseriesComputeKernel.StepUnit("second", 5)); + } + + @Test + public void stepUnitForNullThrows() { + assertThatNullPointerException() + .isThrownBy(() -> TimeseriesComputeKernel.stepUnitFor(null)); + } + + // --- nextBucket --- + + @Test + public void nextBucketWithoutZoneAddsFixedDuration() { + assertThat(TimeseriesComputeKernel.nextBucket(T0, Duration.ofMinutes(5), null)) + .isEqualTo(T0.plus(Duration.ofMinutes(5))); + } + + @Test + public void nextBucketWithZoneStepsBySpringForwardDstDay() { + // Europe/Berlin springs forward on 2024-03-31 (02:00 CET -> 03:00 CEST): that calendar day + // is only 23h long. A tz-aware "1 day" step must span 23h, not a fixed 24h. + final ZoneId berlin = ZoneId.of("Europe/Berlin"); + final Instant dayStart = ZonedDateTime.of(2024, 3, 31, 0, 0, 0, 0, berlin).toInstant(); + + final Instant next = TimeseriesComputeKernel.nextBucket(dayStart, Duration.ofDays(1), berlin); + + assertThat(Duration.between(dayStart, next)).isEqualTo(Duration.ofHours(23)); + } + + // --- bucketStart (parity with MongoDB $dateTrunc) --- + + @Test + public void bucketStartMatchesMongoDateTrunc() { + // Golden values captured from MongoDB's $dateTrunc on a live server, covering bin origin + // (binSize>1), sub-hour tz offsets, and DST transitions. If the kernel's in-memory bucketing + // ever drifts from MongoDB's, a scan-only adapter's results would silently diverge — these + // pin them together. + final ZoneId berlin = ZoneId.of("Europe/Berlin"); + final ZoneId kolkata = ZoneId.of("Asia/Kolkata"); + + assertThat(bucketStart("2024-06-01T00:03:30Z", Duration.ofMinutes(2), null)) + .isEqualTo(Instant.parse("2024-06-01T00:02:00Z")); + assertThat(bucketStart("2024-06-01T00:03:30Z", Duration.ofMinutes(7), null)) + .isEqualTo(Instant.parse("2024-06-01T00:00:00Z")); + assertThat(bucketStart("2024-06-01T13:20:00Z", Duration.ofHours(5), null)) + .isEqualTo(Instant.parse("2024-06-01T13:00:00Z")); + assertThat(bucketStart("2024-06-01T10:00:00Z", Duration.ofDays(1), berlin)) + .isEqualTo(Instant.parse("2024-05-31T22:00:00Z")); + assertThat(bucketStart("2024-06-01T10:00:00Z", Duration.ofDays(1), null)) + .isEqualTo(Instant.parse("2024-06-01T00:00:00Z")); + assertThat(bucketStart("2024-03-31T00:30:00Z", Duration.ofHours(3), berlin)) + .isEqualTo(Instant.parse("2024-03-30T23:00:00Z")); + assertThat(bucketStart("2024-06-01T13:20:00Z", Duration.ofHours(5), berlin)) + .isEqualTo(Instant.parse("2024-06-01T12:00:00Z")); + assertThat(bucketStart("2024-06-01T00:07:10Z", Duration.ofMinutes(15), kolkata)) + .isEqualTo(Instant.parse("2024-06-01T00:00:00Z")); + assertThat(bucketStart("2024-06-01T10:00:00Z", Duration.ofDays(2), berlin)) + .isEqualTo(Instant.parse("2024-05-31T22:00:00Z")); + // Berlin fall-back day (2024-10-27, 25h): both sides of local midnight bin to the same day. + assertThat(bucketStart("2024-10-27T23:30:00Z", Duration.ofDays(1), berlin)) + .isEqualTo(Instant.parse("2024-10-27T23:00:00Z")); + assertThat(bucketStart("2024-10-28T00:30:00Z", Duration.ofDays(1), berlin)) + .isEqualTo(Instant.parse("2024-10-27T23:00:00Z")); + assertThat(bucketStart("2024-06-01T00:00:37Z", Duration.ofSeconds(5), null)) + .isEqualTo(Instant.parse("2024-06-01T00:00:35Z")); + assertThat(bucketStart("2024-06-01T00:00:37Z", Duration.ofSeconds(10), berlin)) + .isEqualTo(Instant.parse("2024-06-01T00:00:30Z")); + // hour bins are elapsed-based: Berlin (whole-hour offset) yields whole UTC hours across DST. + assertThat(bucketStart("2024-03-31T01:30:00Z", Duration.ofHours(1), berlin)) + .isEqualTo(Instant.parse("2024-03-31T01:00:00Z")); + assertThat(bucketStart("2024-03-31T02:30:00Z", Duration.ofHours(1), berlin)) + .isEqualTo(Instant.parse("2024-03-31T02:00:00Z")); + } + + // --- aggregateBuckets (in-kernel downsampling) --- + + @Test + public void aggregateBucketsComputesPerBucketReductions() { + final Instant t0 = Instant.parse("2024-06-01T00:00:00Z"); + final List points = List.of( + new TimePoint(t0, 10.0), + new TimePoint(t0.plusSeconds(3), 20.0), // bucket :00 -> [10, 20] + new TimePoint(t0.plusSeconds(12), 30.0), + new TimePoint(t0.plusSeconds(15), 50.0)); // bucket :10 -> [30, 50] + final Duration step = Duration.ofSeconds(10); + final Instant b0 = t0; + final Instant b1 = t0.plusSeconds(10); + + final LinkedHashMap avg = + TimeseriesComputeKernel.aggregateBuckets(points, step, Aggregation.AVG, null, null); + assertThat(avg.get(b0).asDouble()).isEqualTo(15.0); + assertThat(avg.get(b1).asDouble()).isEqualTo(40.0); + assertThat(new ArrayList<>(avg.keySet())).containsExactly(b0, b1); // ascending + + assertThat(agg(points, step, Aggregation.COUNT, null).get(b0).asInt()).isEqualTo(2); + assertThat(agg(points, step, Aggregation.SUM, null).get(b1).asDouble()).isEqualTo(80.0); + assertThat(agg(points, step, Aggregation.MIN, null).get(b1).asDouble()).isEqualTo(30.0); + assertThat(agg(points, step, Aggregation.MAX, null).get(b0).asDouble()).isEqualTo(20.0); + assertThat(agg(points, step, Aggregation.FIRST, null).get(b1).asDouble()).isEqualTo(30.0); + assertThat(agg(points, step, Aggregation.LAST, null).get(b0).asDouble()).isEqualTo(20.0); + assertThat(TimeseriesComputeKernel.aggregateBuckets(points, step, Aggregation.PERCENTILE, null, 50.0) + .get(b0).asDouble()).isCloseTo(15.0, within(1e-9)); + assertThat(agg(points, step, Aggregation.STDDEV, null).get(b0).asDouble()) + .isCloseTo(Math.sqrt(50.0), within(1e-9)); // sample stddev of [10, 20] + } + + @Test + public void aggregateBucketsStdDevOfSinglePointIsNull() { + final Instant t0 = Instant.parse("2024-06-01T00:00:00Z"); + + final LinkedHashMap out = TimeseriesComputeKernel.aggregateBuckets( + List.of(new TimePoint(t0, 10.0)), Duration.ofSeconds(10), Aggregation.STDDEV, null, null); + + assertThat(out).containsKey(t0); + assertThat(out.get(t0)).isNull(); // matches $stdDevSamp -> null for n<2 (rendered as a gap) + } + + @Test + public void aggregateBucketsOutputFeedsFillBuckets() { + final Instant t0 = Instant.parse("2024-06-01T00:00:00Z"); + final List points = List.of( + new TimePoint(t0, 10.0), + new TimePoint(t0.plusSeconds(30), 40.0)); // buckets :10 and :20 are empty (10s step) + + final LinkedHashMap byBucket = TimeseriesComputeKernel.aggregateBuckets( + points, Duration.ofSeconds(10), Aggregation.AVG, null, null); + final List filled = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(10), FillStrategy.LINEAR, null); + + assertThat(filled).hasSize(4); // :00, :10(fill), :20(fill), :30 + assertThat(filled.get(1).isGap()).isTrue(); + } + + @Test + public void aggregateBucketsRejectsWholeSeriesAggregation() { + final List onePoint = + List.of(new TimePoint(Instant.parse("2024-06-01T00:00:00Z"), 1.0)); + + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + TimeseriesComputeKernel.aggregateBuckets(onePoint, Duration.ofSeconds(10), + Aggregation.INTEGRAL, null, null)); + } + + @Test + public void aggregateBucketsPercentileRequiresPercentileValue() { + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + TimeseriesComputeKernel.aggregateBuckets(List.of(), Duration.ofSeconds(10), + Aggregation.PERCENTILE, null, null)); + } + + private static Instant bucketStart(final String iso, final Duration step, final ZoneId zone) { + return TimeseriesComputeKernel.bucketStart(Instant.parse(iso), step, zone); + } + + private static LinkedHashMap agg(final List points, + final Duration step, final Aggregation aggregation, final ZoneId zone) { + return TimeseriesComputeKernel.aggregateBuckets(points, step, aggregation, zone, null); + } + + // --- fillBuckets --- + + @Test + public void fillBucketsWithoutStrategyEmitsOnlyPopulatedBuckets() { + final LinkedHashMap byBucket = new LinkedHashMap<>(); + byBucket.put(T0, JsonValue.of(1.0)); + byBucket.put(T0.plusSeconds(15), JsonValue.of(2.0)); + + final List result = + TimeseriesComputeKernel.fillBuckets(byBucket, Duration.ofSeconds(5), null, null); + + assertThat(result).hasSize(2); + assertThat(result.get(0).isGap()).isFalse(); + assertThat(result.get(1).getTimestamp()).isEqualTo(T0.plusSeconds(15)); + } + + @Test + public void fillBucketsLinearInterpolatesInteriorGaps() { + // Gap between T0=12.0 and T0+25s=41.0 with a 5s step -> four interpolated interior points on + // a 5.8/step ramp (matches the live-validated example). + final LinkedHashMap byBucket = new LinkedHashMap<>(); + byBucket.put(T0, JsonValue.of(12.0)); + byBucket.put(T0.plusSeconds(25), JsonValue.of(41.0)); + + final List result = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(5), FillStrategy.LINEAR, null); + + assertThat(result).hasSize(6); + assertThat(doubleAt(result, 1)).isCloseTo(17.8, within(1e-9)); + assertThat(doubleAt(result, 2)).isCloseTo(23.6, within(1e-9)); + assertThat(doubleAt(result, 3)).isCloseTo(29.4, within(1e-9)); + assertThat(doubleAt(result, 4)).isCloseTo(35.2, within(1e-9)); + // Interpolated points are flagged as gaps; the two real anchors are not. + assertThat(result.get(0).isGap()).isFalse(); + assertThat(result.get(1).isGap()).isTrue(); + assertThat(result.get(5).isGap()).isFalse(); + } + + @Test + public void fillBucketsPreviousAndZeroFillInteriorGaps() { + final LinkedHashMap byBucket = new LinkedHashMap<>(); + byBucket.put(T0, JsonValue.of(7.0)); + byBucket.put(T0.plusSeconds(10), JsonValue.of(9.0)); + + final List previous = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(5), FillStrategy.PREVIOUS, null); + assertThat(doubleAt(previous, 1)).isEqualTo(7.0); // carried forward + + final List zero = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(5), FillStrategy.ZERO, null); + assertThat(doubleAt(zero, 1)).isEqualTo(0.0); + + final List nul = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(5), FillStrategy.NULL, null); + assertThat(nul.get(1).isGap()).isTrue(); + assertThat(nul.get(1).getValue()).isEmpty(); + } + + @Test + public void fillBucketsLinearFallsBackToNullGapForNonNumericEndpoints() { + final LinkedHashMap byBucket = new LinkedHashMap<>(); + byBucket.put(T0, JsonValue.of("not-a-number")); + byBucket.put(T0.plusSeconds(10), JsonValue.of(9.0)); + + final List result = TimeseriesComputeKernel.fillBuckets( + byBucket, Duration.ofSeconds(5), FillStrategy.LINEAR, null); + + assertThat(result.get(1).isGap()).isTrue(); + assertThat(result.get(1).getValue()).isEmpty(); + } + + @Test + public void fillBucketsEmptyReturnsEmpty() { + assertThat(TimeseriesComputeKernel.fillBuckets( + new LinkedHashMap<>(), Duration.ofSeconds(5), FillStrategy.LINEAR, null)).isEmpty(); + } + + // --- derivative / rate --- + + @Test + public void derivativeComputesSlopePerSecondAndDropsFirstPoint() { + final List points = List.of( + new TimePoint(T0, 10.0), + new TimePoint(T0.plusSeconds(5), 20.0), // +10 over 5s -> 2.0 + new TimePoint(T0.plusSeconds(10), 20.0)); // 0 over 5s -> 0.0 + + final List result = TimeseriesComputeKernel.derivative(points, false); + + assertThat(result).hasSize(2); + assertThat(doubleAt(result, 0)).isCloseTo(2.0, within(1e-9)); + assertThat(doubleAt(result, 1)).isCloseTo(0.0, within(1e-9)); + assertThat(result.get(0).getTimestamp()).isEqualTo(T0.plusSeconds(5)); + } + + @Test + public void rateTreatsValueDecreaseAsCounterReset() { + final List points = List.of( + new TimePoint(T0, 100.0), + new TimePoint(T0.plusSeconds(10), 40.0)); // decrease -> reset: 40/10 = 4.0 + + final List result = TimeseriesComputeKernel.derivative(points, true); + + assertThat(doubleAt(result, 0)).isCloseTo(4.0, within(1e-9)); + } + + @Test + public void derivativeSkipsNonAdvancingTimestamps() { + final List points = List.of( + new TimePoint(T0, 10.0), + new TimePoint(T0, 20.0)); // dt == 0 -> skipped + + assertThat(TimeseriesComputeKernel.derivative(points, false)).isEmpty(); + } + + // --- integral --- + + @Test + public void integralComputesTrapezoidalAreaStampedAtLastPoint() { + final List points = List.of( + new TimePoint(T0, 2.0), + new TimePoint(T0.plusSeconds(10), 4.0)); // trapezoid: (2+4)/2 * 10 = 30 + + final List result = TimeseriesComputeKernel.integral(points); + + assertThat(result).hasSize(1); + assertThat(doubleAt(result, 0)).isCloseTo(30.0, within(1e-9)); + assertThat(result.get(0).getTimestamp()).isEqualTo(T0.plusSeconds(10)); + } + + @Test + public void integralOfEmptyIsEmpty() { + assertThat(TimeseriesComputeKernel.integral(List.of())).isEmpty(); + } + + // --- percentile --- + + @Test + public void percentileInterpolatesBetweenRanks() { + final List values = List.of(1.0, 2.0, 3.0, 4.0); + assertThat(TimeseriesComputeKernel.percentile(values, 50)).isCloseTo(2.5, within(1e-9)); + assertThat(TimeseriesComputeKernel.percentile(values, 0)).isCloseTo(1.0, within(1e-9)); + assertThat(TimeseriesComputeKernel.percentile(values, 100)).isCloseTo(4.0, within(1e-9)); + } + + @Test + public void percentileOfSingleValueIsThatValue() { + assertThat(TimeseriesComputeKernel.percentile(List.of(42.0), 95)).isEqualTo(42.0); + } + + @Test + public void percentileDoesNotMutateInput() { + final List values = new java.util.ArrayList<>(List.of(3.0, 1.0, 2.0)); + TimeseriesComputeKernel.percentile(values, 50); + assertThat(values).containsExactly(3.0, 1.0, 2.0); + } + + @Test + public void percentileOfEmptyThrows() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> TimeseriesComputeKernel.percentile(List.of(), 50)); + } + + private static double doubleAt(final List data, final int index) { + return data.get(index).getValue().orElseThrow().asDouble(); + } +} diff --git a/timeseries/model/pom.xml b/timeseries/model/pom.xml new file mode 100644 index 00000000000..d5c81766d13 --- /dev/null +++ b/timeseries/model/pom.xml @@ -0,0 +1,106 @@ + + + + + ditto-timeseries + org.eclipse.ditto + ${revision} + + 4.0.0 + + ditto-timeseries-model + bundle + Eclipse Ditto :: Timeseries :: Model + + + 1.8 + 1.8 + + + + + + org.eclipse.ditto + ditto-base-model + + + + org.eclipse.ditto + ditto-things-model + + + + + org.eclipse.ditto + ditto-base-model + test-jar + test + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-banned-dependencies + + enforce + + + + + + + org.slf4j + + + org.slf4j:*:*:jar:test + + + + true + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + + !org.eclipse.ditto.utils.jsr305.annotations, + org.eclipse.ditto.* + + + org.eclipse.ditto.timeseries.model.* + + + + + + + + diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/AggregatedTimeseriesResult.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/AggregatedTimeseriesResult.java new file mode 100644 index 00000000000..78b3c16a008 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/AggregatedTimeseriesResult.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; + +/** + * One aggregated series produced by a {@link CrossThingTimeseriesQuery}: the values for a single + * {@code (group, path)} combination. + *

+ * Unlike {@link TimeseriesQueryResult} this carries no {@code thingId} — a group generally spans + * many Things. When the query groups by {@link GroupBy#thingId()} the Thing ID appears as an + * ordinary entry in {@link #getGroup()} instead, which keeps the shape uniform across grouping + * choices rather than special-casing one dimension. + * + * @since 4.0.0 + */ +public interface AggregatedTimeseriesResult extends Jsonifiable { + + /** + * Returns a new {@code AggregatedTimeseriesResult}. + * + * @param group the group identity: one entry per {@code groupBy} dimension, keyed by + * {@link GroupBy#getGroupKey()}. Empty when the query declared no grouping. + * @param path the path this series belongs to. + * @param meta metadata about the data array. + * @param data the aggregated values in chronological order; may be empty. + * @return the new result. + * @throws NullPointerException if any argument is {@code null}. + */ + static AggregatedTimeseriesResult of(final Map group, + final JsonPointer path, + final TimeseriesResultMeta meta, + final List data) { + + return ImmutableAggregatedTimeseriesResult.of(group, path, meta, data); + } + + /** + * Parses an {@code AggregatedTimeseriesResult} from the given JSON object. + * + * @param jsonObject the JSON object. + * @return the parsed result. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + */ + static AggregatedTimeseriesResult fromJson(final JsonObject jsonObject) { + return ImmutableAggregatedTimeseriesResult.fromJson(jsonObject); + } + + /** + * @return the group identity; empty when the query declared no grouping dimensions. + */ + Map getGroup(); + + /** + * @return the path this series belongs to. + */ + JsonPointer getPath(); + + /** + * @return metadata about the data array. + */ + TimeseriesResultMeta getMeta(); + + /** + * @return the aggregated values in chronological order. + */ + List getData(); +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Aggregation.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Aggregation.java new file mode 100644 index 00000000000..e9b12849bb0 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Aggregation.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Optional; + +import javax.annotation.concurrent.Immutable; + +/** + * Aggregation functions supported by the Timeseries API for downsampling and bucketed summarisation + * of timeseries data points. + *

+ * Wire format uses the lowercase token (e.g. {@code avg}, {@code first}); see {@link #getName()}. + * + * @since 4.0.0 + */ +@Immutable +public enum Aggregation implements CharSequence { + + /** + * Arithmetic mean of values in each time bucket. + */ + AVG("avg"), + + /** + * Minimum value in each time bucket. + */ + MIN("min"), + + /** + * Maximum value in each time bucket. + */ + MAX("max"), + + /** + * Sum of values in each time bucket. + */ + SUM("sum"), + + /** + * Number of data points in each time bucket. + */ + COUNT("count"), + + /** + * First value in each time bucket (chronologically). + */ + FIRST("first"), + + /** + * Last value in each time bucket (chronologically). + */ + LAST("last"), + + /** + * Rate of change between consecutive values (per second). Useful for converting cumulative + * measurements to rates. + */ + DERIVATIVE("derivative"), + + /** + * Non-negative derivative — handles counter resets by treating a decreasing value as the start + * of a fresh count rather than producing a negative rate. + */ + RATE("rate"), + + /** + * Area under the curve (trapezoidal integration) in value-seconds. Useful for converting rate + * measurements to cumulative totals (e.g. power → energy). + */ + INTEGRAL("integral"), + + /** + * Sample standard deviation of values in each time bucket. + */ + STDDEV("stddev"), + + /** + * Nth percentile of values in each time bucket. The percentile (0–100) is supplied separately + * via the query parameter {@code percentile}. + */ + PERCENTILE("percentile"); + + /** The per-bucket aggregations — those expressible as a single {@code $group} accumulator. */ + private static final EnumSet BUCKETED = + EnumSet.of(AVG, MIN, MAX, SUM, COUNT, FIRST, LAST, STDDEV); + + private final String name; + + Aggregation(final String name) { + this.name = name; + } + + /** + * Returns the wire-format name of this aggregation. + * + * @return the name as used on the wire (lowercase token). + */ + public String getName() { + return name; + } + + /** + * Whether this aggregation is computed per {@code step} bucket via a single {@code $group} + * accumulator and therefore requires a {@code step} to be set. The window-style functions + * ({@link #DERIVATIVE}, {@link #RATE}, {@link #INTEGRAL}, {@link #PERCENTILE}) return + * {@code false} — they operate on raw points (and {@code step} is optional for them). + * + * @return {@code true} for the per-bucket aggregations (avg, min, max, sum, count, first, last, + * stddev); {@code false} for the window-style functions. + */ + public boolean requiresStep() { + return BUCKETED.contains(this); + } + + /** + * Returns the {@code Aggregation} for the given wire-format {@code name} if it exists. + * + * @param name the wire-format name. + * @return the matching {@code Aggregation} or an empty {@code Optional} if no match. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()) + .filter(a -> a.name.contentEquals(name)) + .findFirst(); + } + + @Override + public int length() { + return name.length(); + } + + @Override + public char charAt(final int index) { + return name.charAt(index); + } + + @Override + public CharSequence subSequence(final int start, final int end) { + return name.subSequence(start, end); + } + + @Override + public String toString() { + return name; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/CrossThingTimeseriesQuery.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/CrossThingTimeseriesQuery.java new file mode 100644 index 00000000000..5b7c894177f --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/CrossThingTimeseriesQuery.java @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; + +/** + * Describes a timeseries aggregation spanning many Things within a single namespace — + * the counterpart to the single-Thing {@link TimeseriesQuery}. + * + *

Why this is a separate type

+ * A cross-Thing query has no {@code thingId}, always aggregates (raw cross-Thing reads are + * unbounded by construction), and returns one series per group rather than per Thing. Those + * differences are load-bearing enough that overloading {@link TimeseriesQuery} — whose + * {@code getThingId()} is non-null and whose result type is keyed by Thing — would weaken both. + * + *

{@code filter} selects points, not Things

+ * Tag filters match the tags frozen at ingest on each data point, so they select points by + * the state of the world when the measurement was recorded. That is deliberately different from + * selecting Things by their current attributes: for a Thing moved from building A to + * building B, {@code tagFilter=building:A} returns only the points recorded while it was in A, + * whereas a state-based selection would return its whole history. Both are useful; they are not + * interchangeable. + * + *

Authorization is never encoded here

+ * The query carries no notion of who may read it. Access is decided against live policy state at + * execution time, so a subject granted access today sees history ingested before the grant, and a + * revoked subject immediately loses history it could previously read. Nothing about authorization is + * stored alongside the data points. + * + * @since 4.0.0 + */ +public interface CrossThingTimeseriesQuery extends Jsonifiable { + + /** + * Hard upper bound on {@code maxGroups}. A cross-Thing aggregation materialises one series per + * group in service heap, so the cap is bounded independently of what a caller asks for. + */ + int MAX_GROUPS_CEILING = 10_000; + + /** + * The {@code maxGroups} applied when a caller does not specify one. + */ + int DEFAULT_MAX_GROUPS = 1_000; + + /** + * Returns a new {@code CrossThingTimeseriesQuery}. + * + * @param namespace the namespace whose Things are aggregated. Cross-namespace queries are not + * supported; storage is partitioned per namespace. + * @param paths the paths within each Thing to aggregate. Must not be empty. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @param step the bucket width. Required — a cross-Thing query always downsamples. + * @param aggregation the per-bucket aggregation. Required, and must be one of the bucketed + * aggregations ({@link Aggregation#requiresStep()}). + * @param groupBy the grouping dimensions; may be empty, meaning one series per path across all + * matching Things. + * @param filter an RQL predicate over ingest-time tags, or {@code null} for no filtering. + * @param timezone timezone for calendar-aligned bucket boundaries; may be {@code null} for UTC. + * @param fillStrategy how empty buckets are filled; may be {@code null} for no filling. + * @param maxGroups a cap on the number of distinct groups returned; may be {@code null} for the + * backend default. Exceeding the cap fails the query rather than truncating it. + * @return the new query. + * @throws NullPointerException if a required argument is {@code null}. + * @throws TimeseriesQueryInvalidException if the combination of parameters is not valid. + */ + static CrossThingTimeseriesQuery of(final String namespace, + final List paths, + final Instant from, + final Instant to, + final Duration step, + final Aggregation aggregation, + final List groupBy, + @Nullable final String filter, + @Nullable final ZoneId timezone, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer maxGroups) { + + return ImmutableCrossThingTimeseriesQuery.of(namespace, paths, from, to, step, aggregation, + groupBy, filter, timezone, fillStrategy, maxGroups); + } + + /** + * Parses a {@code CrossThingTimeseriesQuery} from the given JSON object. + * + * @param jsonObject the JSON object. + * @return the parsed query. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if a field has an invalid value. + */ + static CrossThingTimeseriesQuery fromJson(final JsonObject jsonObject) { + return ImmutableCrossThingTimeseriesQuery.fromJson(jsonObject); + } + + /** + * @return the namespace whose Things are aggregated. + */ + String getNamespace(); + + /** + * @return the paths aggregated within each Thing; never empty. + */ + List getPaths(); + + /** + * @return inclusive lower bound of the time range. + */ + Instant getFrom(); + + /** + * @return exclusive upper bound of the time range. + */ + Instant getTo(); + + /** + * @return the bucket width; always present. + */ + Duration getStep(); + + /** + * @return the per-bucket aggregation; always present. + */ + Aggregation getAggregation(); + + /** + * @return the grouping dimensions, in declaration order; may be empty. + */ + List getGroupBy(); + + /** + * @return the ingest-time tag predicates, ANDed; may be empty. + */ + Optional getFilter(); + + /** + * @return the timezone for calendar-aligned bucketing, if set. + */ + Optional getTimezone(); + + /** + * @return the gap-fill strategy, if set. + */ + Optional getFillStrategy(); + + /** + * @return the caller-supplied cap on distinct groups, if set. + */ + Optional getMaxGroups(); +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/FillStrategy.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/FillStrategy.java new file mode 100644 index 00000000000..79a58bddec0 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/FillStrategy.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; + +import javax.annotation.concurrent.Immutable; + +/** + * Strategies for filling gaps in downsampled timeseries data when a time bucket contains no data + * points. + *

+ * Wire format uses the lowercase token (e.g. {@code null}, {@code previous}); see {@link #getName()}. + * + * @since 4.0.0 + */ +@Immutable +public enum FillStrategy implements CharSequence { + + /** + * Leaves empty buckets as JSON {@code null} values. Default behaviour when no fill strategy + * is requested. + */ + NULL("null"), + + /** + * Carries the most recent prior value forward into empty buckets. + */ + PREVIOUS("previous"), + + /** + * Linearly interpolates between the surrounding non-empty buckets. + */ + LINEAR("linear"), + + /** + * Fills empty buckets with the numeric value zero. + */ + ZERO("zero"); + + private final String name; + + FillStrategy(final String name) { + this.name = name; + } + + /** + * Returns the wire-format name of this fill strategy. + * + * @return the name as used on the wire (lowercase token). + */ + public String getName() { + return name; + } + + /** + * Returns the {@code FillStrategy} for the given wire-format {@code name} if it exists. + * + * @param name the wire-format name. + * @return the matching {@code FillStrategy} or an empty {@code Optional} if no match. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()) + .filter(s -> s.name.contentEquals(name)) + .findFirst(); + } + + @Override + public int length() { + return name.length(); + } + + @Override + public char charAt(final int index) { + return name.charAt(index); + } + + @Override + public CharSequence subSequence(final int start, final int end) { + return name.subSequence(start, end); + } + + @Override + public String toString() { + return name; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/GroupBy.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/GroupBy.java new file mode 100644 index 00000000000..b43f71308af --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/GroupBy.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +/** + * One grouping dimension of a cross-Thing timeseries aggregation. + *

+ * Three kinds are supported, all of which resolve against fields the backend already keeps in its + * indexed metadata, so grouping never requires reading the measurement values: + *

    + *
  • {@link Kind#THING_ID} — one series per Thing ({@code groupBy=thingId});
  • + *
  • {@link Kind#PATH} — one series per requested path ({@code groupBy=path}). Note that results + * are always reported per path, so this dimension is implicit; declaring it + * explicitly only affects the emitted group identity;
  • + *
  • {@link Kind#TAG} — one series per distinct value of an ingest-time tag + * ({@code groupBy=tag:building}).
  • + *
+ * The wire form is the same string used in the {@code groupBy} query parameter, so + * {@link #parse(String)} and {@link #toString()} round-trip. + *

+ * Grouping by a tag groups on the value that was frozen at ingest, not on the Thing's + * current attribute — see {@link CrossThingTimeseriesQuery} for why that differs from selecting + * Things by their present state. + * + * @since 4.0.0 + */ +@Immutable +public final class GroupBy { + + /** Wire prefix marking a tag dimension, e.g. {@code tag:building}. */ + private static final String TAG_PREFIX = "tag:"; + + private static final String THING_ID_TOKEN = "thingId"; + private static final String PATH_TOKEN = "path"; + + private static final GroupBy THING_ID_INSTANCE = new GroupBy(Kind.THING_ID, null); + private static final GroupBy PATH_INSTANCE = new GroupBy(Kind.PATH, null); + + private final Kind kind; + @Nullable private final String tagKey; + + private GroupBy(final Kind kind, @Nullable final String tagKey) { + this.kind = kind; + this.tagKey = tagKey; + } + + /** + * @return the dimension grouping by Thing ID. + */ + public static GroupBy thingId() { + return THING_ID_INSTANCE; + } + + /** + * @return the dimension grouping by requested path. + */ + public static GroupBy path() { + return PATH_INSTANCE; + } + + /** + * Returns the dimension grouping by the ingest-time tag with the given key. + * + * @param tagKey the tag key to group on. + * @return the tag dimension. + * @throws NullPointerException if {@code tagKey} is {@code null}. + * @throws TimeseriesQueryInvalidException if {@code tagKey} is empty. + */ + public static GroupBy tag(final String tagKey) { + checkNotNull(tagKey, "tagKey"); + final String trimmed = tagKey.trim(); + if (trimmed.isEmpty()) { + throw TimeseriesQueryInvalidException.newBuilder("A 'tag:' groupBy dimension requires a " + + "non-empty tag key, e.g. 'tag:building'.").build(); + } + return new GroupBy(Kind.TAG, trimmed); + } + + /** + * Parses a {@code groupBy} dimension from its wire form. + * + * @param value {@code thingId}, {@code path}, or a tag path such as {@code attributes/floor} + * (the tag key as declared in the WoT model). {@code tag:} is also accepted. + * @return the parsed dimension. + * @throws NullPointerException if {@code value} is {@code null}. + * @throws TimeseriesQueryInvalidException if {@code value} is not a known dimension. + */ + public static GroupBy parse(final String value) { + checkNotNull(value, "value"); + final String trimmed = value.trim(); + if (THING_ID_TOKEN.equals(trimmed)) { + return thingId(); + } + if (PATH_TOKEN.equals(trimmed)) { + return path(); + } + // Anything else is a tag dimension named by its full Thing path, e.g. "attributes/floor". + // The legacy "tag:" form is still accepted so a caller mid-migration is not broken. + if (trimmed.startsWith(TAG_PREFIX)) { + return tag(trimmed.substring(TAG_PREFIX.length())); + } + if (trimmed.isEmpty()) { + throw TimeseriesQueryInvalidException.newBuilder("A groupBy dimension must not be empty. " + + "Supported: 'thingId', 'path', or a tag path such as 'attributes/floor'.") + .build(); + } + return tag(trimmed); + } + + /** + * @return the kind of this dimension. + */ + public Kind getKind() { + return kind; + } + + /** + * @return the tag key, present only for {@link Kind#TAG}. + */ + public Optional getTagKey() { + return Optional.ofNullable(tagKey); + } + + /** + * Returns the key under which this dimension's value appears in an + * {@link AggregatedTimeseriesResult#getGroup() group identity}. For a tag dimension this is the + * bare tag key, so a group reads as {@code {"building": "A"}} rather than + * {@code {"tag:building": "A"}}. + * + * @return the group-identity key. + */ + public String getGroupKey() { + if (kind == Kind.TAG) { + return tagKey; + } + return kind == Kind.THING_ID ? THING_ID_TOKEN : PATH_TOKEN; + } + + @Override + public String toString() { + if (kind == Kind.TAG) { + return TAG_PREFIX + tagKey; + } + return kind == Kind.THING_ID ? THING_ID_TOKEN : PATH_TOKEN; + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final GroupBy that = (GroupBy) o; + return kind == that.kind && Objects.equals(tagKey, that.tagKey); + } + + @Override + public int hashCode() { + return Objects.hash(kind, tagKey); + } + + /** + * The kind of grouping dimension. + */ + public enum Kind { + + /** Group by Thing ID. */ + THING_ID, + + /** Group by requested path. */ + PATH, + + /** Group by the value of an ingest-time tag. */ + TAG + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResult.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResult.java new file mode 100644 index 00000000000..372e4e3c862 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResult.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonCollectors; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; + +/** + * Immutable implementation of {@link AggregatedTimeseriesResult}. + * + * @since 4.0.0 + */ +@Immutable +final class ImmutableAggregatedTimeseriesResult implements AggregatedTimeseriesResult { + + private final Map group; + private final JsonPointer path; + private final TimeseriesResultMeta meta; + private final List data; + + private ImmutableAggregatedTimeseriesResult(final Map group, + final JsonPointer path, + final TimeseriesResultMeta meta, + final List data) { + + this.group = Collections.unmodifiableMap(new LinkedHashMap<>(group)); + this.path = path; + this.meta = meta; + this.data = Collections.unmodifiableList(new ArrayList<>(data)); + } + + static AggregatedTimeseriesResult of(final Map group, + final JsonPointer path, + final TimeseriesResultMeta meta, + final List data) { + + checkNotNull(group, "group"); + checkNotNull(path, "path"); + checkNotNull(meta, "meta"); + checkNotNull(data, "data"); + return new ImmutableAggregatedTimeseriesResult(group, path, meta, data); + } + + static AggregatedTimeseriesResult fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final Map group = jsonObject.getValue(JsonFields.GROUP) + .map(ImmutableAggregatedTimeseriesResult::groupFromJson) + .orElseGet(Collections::emptyMap); + final JsonPointer path = JsonPointer.of(jsonObject.getValueOrThrow(JsonFields.PATH)); + final TimeseriesResultMeta meta = + TimeseriesResultMeta.fromJson(jsonObject.getValueOrThrow(JsonFields.META)); + final List data = dataFromJson(jsonObject.getValueOrThrow(JsonFields.DATA)); + + return of(group, path, meta, data); + } + + private static Map groupFromJson(final JsonObject groupJson) { + final Map result = new LinkedHashMap<>(); + for (final JsonField field : groupJson) { + final JsonValue value = field.getValue(); + result.put(field.getKeyName(), value.isString() ? value.asString() : value.formatAsString()); + } + return result; + } + + private static List dataFromJson(final JsonArray array) { + final List result = new ArrayList<>(array.getSize()); + for (final JsonValue value : array) { + result.add(TimeseriesDataValue.fromJson(value.asObject())); + } + return result; + } + + @Override + public Map getGroup() { + return group; + } + + @Override + public JsonPointer getPath() { + return path; + } + + @Override + public TimeseriesResultMeta getMeta() { + return meta; + } + + @Override + public List getData() { + return data; + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder(); + if (!group.isEmpty()) { + final JsonObjectBuilder groupBuilder = JsonFactory.newObjectBuilder(); + for (final Map.Entry entry : group.entrySet()) { + groupBuilder.set(JsonKey.of(entry.getKey()), entry.getValue()); + } + builder.set(JsonFields.GROUP, groupBuilder.build()); + } + return builder + .set(JsonFields.PATH, path.toString()) + .set(JsonFields.META, meta.toJson()) + .set(JsonFields.DATA, data.stream() + .map(TimeseriesDataValue::toJson) + .map(JsonValue.class::cast) + .collect(JsonCollectors.valuesToArray())) + .build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final ImmutableAggregatedTimeseriesResult that = (ImmutableAggregatedTimeseriesResult) o; + return Objects.equals(group, that.group) + && Objects.equals(path, that.path) + && Objects.equals(meta, that.meta) + && Objects.equals(data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(group, path, meta, data); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [group=" + group + + ", path=" + path + + ", meta=" + meta + + ", data=" + data + + "]"; + } + + /** + * JSON field definitions of an {@code AggregatedTimeseriesResult}. + */ + static final class JsonFields { + + static final JsonFieldDefinition GROUP = + JsonFactory.newJsonObjectFieldDefinition("group", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition PATH = + JsonFactory.newStringFieldDefinition("path", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition META = + JsonFactory.newJsonObjectFieldDefinition("result", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition DATA = + JsonFactory.newJsonArrayFieldDefinition("data", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQuery.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQuery.java new file mode 100644 index 00000000000..81c21b53823 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQuery.java @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonCollectors; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; + +/** + * Immutable implementation of {@link CrossThingTimeseriesQuery}. + *

+ * All semantic validation lives in {@link #of} so that it applies identically across HTTP, + * WebSocket and Connectivity — the same discipline {@code ImmutableTimeseriesQuery} follows. + * + * @since 4.0.0 + */ +@Immutable +final class ImmutableCrossThingTimeseriesQuery implements CrossThingTimeseriesQuery { + + private final String namespace; + private final List paths; + private final Instant from; + private final Instant to; + private final Duration step; + private final Aggregation aggregation; + private final List groupBy; + @Nullable private final String filter; + @Nullable private final ZoneId timezone; + @Nullable private final FillStrategy fillStrategy; + @Nullable private final Integer maxGroups; + + private ImmutableCrossThingTimeseriesQuery(final String namespace, + final List paths, + final Instant from, + final Instant to, + final Duration step, + final Aggregation aggregation, + final List groupBy, + @Nullable final String filter, + @Nullable final ZoneId timezone, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer maxGroups) { + + this.namespace = namespace; + // Deduplicated, order preserved: the authorization gate counts permitted paths as a Set, so + // a repeated path would otherwise be read as an unpermitted one. + this.paths = Collections.unmodifiableList(new ArrayList<>(new LinkedHashSet<>(paths))); + this.from = from; + this.to = to; + this.step = step; + this.aggregation = aggregation; + this.groupBy = Collections.unmodifiableList(new ArrayList<>(groupBy)); + this.filter = filter; + this.timezone = timezone; + this.fillStrategy = fillStrategy; + this.maxGroups = maxGroups; + } + + static CrossThingTimeseriesQuery of(final String namespace, + final List paths, + final Instant from, + final Instant to, + final Duration step, + final Aggregation aggregation, + final List groupBy, + @Nullable final String filter, + @Nullable final ZoneId timezone, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer maxGroups) { + + checkNotNull(namespace, "namespace"); + checkNotNull(paths, "paths"); + checkNotNull(from, "from"); + checkNotNull(to, "to"); + checkNotNull(step, "step"); + checkNotNull(aggregation, "aggregation"); + checkNotNull(groupBy, "groupBy"); + + if (namespace.trim().isEmpty()) { + throw invalid("A cross-Thing timeseries query requires a non-empty 'namespaces' value."); + } + if (paths.isEmpty()) { + throw invalid("A cross-Thing timeseries query requires at least one path in 'paths'."); + } + // Same bound the single-Thing query enforces. It matters more here, not less: each path is a + // separate grouped scan over every Thing in the namespace, so the path count multiplies a + // fan-out that is already namespace-wide. + if (paths.size() > TimeseriesQuery.MAX_PATHS) { + throw invalid("A query may request at most <" + TimeseriesQuery.MAX_PATHS + "> paths but <" + + paths.size() + "> were given. Split the request into smaller batches."); + } + if (!from.isBefore(to)) { + throw invalid("'from' (" + from + ") must be strictly before 'to' (" + to + ")."); + } + if (step.isZero() || step.isNegative()) { + throw invalid("'step' must be a positive duration, was <" + step + ">."); + } + if (step.getNano() != 0) { + throw invalid("The 'step' must be a whole number of seconds, but was <" + step + + ">. A fractional step cannot be expressed as a bucket width: the backend would " + + "truncate it to a zero-sized bin and the fill grid would never advance."); + } + // A cross-Thing read without downsampling would stream every raw point of every matching + // Thing through the service — unbounded by construction. Requiring a bucketed aggregation + // keeps the result size a function of the time range, not of the tenant's Thing count. + if (!aggregation.requiresStep()) { + throw invalid("Cross-Thing aggregation supports the per-bucket aggregations " + + "(avg, min, max, sum, count, first, last, stddev); <" + aggregation.getName() + + "> is a window function and is only available on single-Thing queries."); + } + validateNoDuplicateDimensions(groupBy); + final Integer validatedMaxGroups = validateMaxGroups(maxGroups); + + return new ImmutableCrossThingTimeseriesQuery(namespace.trim(), paths, from, to, step, + aggregation, groupBy, filter, timezone, fillStrategy, validatedMaxGroups); + } + + private static void validateNoDuplicateDimensions(final List groupBy) { + final Set seen = new LinkedHashSet<>(); + for (final GroupBy dimension : groupBy) { + if (!seen.add(dimension)) { + throw invalid("Duplicate groupBy dimension <" + dimension + ">."); + } + } + } + + @Nullable + private static Integer validateMaxGroups(@Nullable final Integer maxGroups) { + if (maxGroups == null) { + return null; + } + if (maxGroups <= 0) { + throw invalid("'maxGroups' must be positive, was <" + maxGroups + ">."); + } + if (maxGroups > CrossThingTimeseriesQuery.MAX_GROUPS_CEILING) { + throw invalid("'maxGroups' must not exceed " + CrossThingTimeseriesQuery.MAX_GROUPS_CEILING + ", was <" + + maxGroups + ">."); + } + return maxGroups; + } + + private static TimeseriesQueryInvalidException invalid(final String message) { + return TimeseriesQueryInvalidException.newBuilder(message).build(); + } + + static CrossThingTimeseriesQuery fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final String namespace = jsonObject.getValueOrThrow(JsonFields.NAMESPACE); + final List paths = pathsFromJson(jsonObject.getValueOrThrow(JsonFields.PATHS)); + final Instant from = parseInstant(jsonObject.getValueOrThrow(JsonFields.FROM), "from"); + final Instant to = parseInstant(jsonObject.getValueOrThrow(JsonFields.TO), "to"); + final Duration step = parseDuration(jsonObject.getValueOrThrow(JsonFields.STEP), "step"); + final Aggregation aggregation = parseAggregation(jsonObject.getValueOrThrow(JsonFields.AGGREGATION)); + final List groupBy = jsonObject.getValue(JsonFields.GROUP_BY) + .map(ImmutableCrossThingTimeseriesQuery::groupByFromJson) + .orElseGet(Collections::emptyList); + final String filter = jsonObject.getValue(JsonFields.FILTER).orElse(null); + final ZoneId timezone = jsonObject.getValue(JsonFields.TIMEZONE) + .map(ImmutableCrossThingTimeseriesQuery::parseZoneId) + .orElse(null); + final FillStrategy fillStrategy = jsonObject.getValue(JsonFields.FILL_STRATEGY) + .map(ImmutableCrossThingTimeseriesQuery::parseFillStrategy) + .orElse(null); + final Integer maxGroups = jsonObject.getValue(JsonFields.MAX_GROUPS).orElse(null); + + return of(namespace, paths, from, to, step, aggregation, groupBy, filter, timezone, + fillStrategy, maxGroups); + } + + private static List groupByFromJson(final JsonArray array) { + final List result = new ArrayList<>(array.getSize()); + for (final JsonValue value : array) { + result.add(GroupBy.parse(value.isString() ? value.asString() : value.formatAsString())); + } + return result; + } + + + private static List pathsFromJson(final JsonArray array) { + final List result = new ArrayList<>(array.getSize()); + for (final JsonValue value : array) { + result.add(JsonPointer.of(value.isString() ? value.asString() : value.formatAsString())); + } + return result; + } + + private static Instant parseInstant(final String raw, final String fieldName) { + try { + return Instant.parse(raw); + } catch (final DateTimeParseException e) { + throw invalid("Field '" + fieldName + "' is not a valid ISO-8601 instant: <" + raw + ">."); + } + } + + private static Duration parseDuration(final String raw, final String fieldName) { + try { + return Duration.parse(raw); + } catch (final DateTimeParseException e) { + throw invalid("Field '" + fieldName + "' is not a valid ISO-8601 duration: <" + raw + ">."); + } + } + + private static Aggregation parseAggregation(final String raw) { + return Aggregation.forName(raw) + .orElseThrow(() -> invalid("Unknown aggregation <" + raw + ">.")); + } + + private static FillStrategy parseFillStrategy(final String raw) { + return FillStrategy.forName(raw) + .orElseThrow(() -> invalid("Unknown fill strategy <" + raw + ">.")); + } + + private static ZoneId parseZoneId(final String raw) { + try { + return ZoneId.of(raw); + } catch (final DateTimeException e) { + throw invalid("Unknown timezone <" + raw + ">."); + } + } + + @Override + public String getNamespace() { + return namespace; + } + + @Override + public List getPaths() { + return paths; + } + + @Override + public Instant getFrom() { + return from; + } + + @Override + public Instant getTo() { + return to; + } + + @Override + public Duration getStep() { + return step; + } + + @Override + public Aggregation getAggregation() { + return aggregation; + } + + @Override + public List getGroupBy() { + return groupBy; + } + + @Override + public Optional getFilter() { + return Optional.ofNullable(filter); + } + + @Override + public Optional getTimezone() { + return Optional.ofNullable(timezone); + } + + @Override + public Optional getFillStrategy() { + return Optional.ofNullable(fillStrategy); + } + + @Override + public Optional getMaxGroups() { + return Optional.ofNullable(maxGroups); + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.NAMESPACE, namespace) + .set(JsonFields.PATHS, paths.stream() + .map(JsonPointer::toString) + .map(JsonValue::of) + .collect(JsonCollectors.valuesToArray())) + .set(JsonFields.FROM, from.toString()) + .set(JsonFields.TO, to.toString()) + .set(JsonFields.STEP, step.toString()) + .set(JsonFields.AGGREGATION, aggregation.getName()); + + if (!groupBy.isEmpty()) { + builder.set(JsonFields.GROUP_BY, groupBy.stream() + .map(GroupBy::toString) + .map(JsonValue::of) + .collect(JsonCollectors.valuesToArray())); + } + if (filter != null) { + builder.set(JsonFields.FILTER, filter); + } + if (timezone != null) { + builder.set(JsonFields.TIMEZONE, timezone.toString()); + } + if (fillStrategy != null) { + builder.set(JsonFields.FILL_STRATEGY, fillStrategy.getName()); + } + if (maxGroups != null) { + builder.set(JsonFields.MAX_GROUPS, maxGroups); + } + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final ImmutableCrossThingTimeseriesQuery that = (ImmutableCrossThingTimeseriesQuery) o; + return Objects.equals(namespace, that.namespace) + && Objects.equals(paths, that.paths) + && Objects.equals(from, that.from) + && Objects.equals(to, that.to) + && Objects.equals(step, that.step) + && aggregation == that.aggregation + && Objects.equals(groupBy, that.groupBy) + && Objects.equals(filter, that.filter) + && Objects.equals(timezone, that.timezone) + && fillStrategy == that.fillStrategy + && Objects.equals(maxGroups, that.maxGroups); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, paths, from, to, step, aggregation, groupBy, filter, + timezone, fillStrategy, maxGroups); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [namespace=" + namespace + + ", paths=" + paths + + ", from=" + from + + ", to=" + to + + ", step=" + step + + ", aggregation=" + aggregation + + ", groupBy=" + groupBy + + ", filter=" + filter + + ", timezone=" + timezone + + ", fillStrategy=" + fillStrategy + + ", maxGroups=" + maxGroups + + "]"; + } + + /** + * JSON field definitions of a {@code CrossThingTimeseriesQuery}. + */ + static final class JsonFields { + + static final JsonFieldDefinition NAMESPACE = + JsonFactory.newStringFieldDefinition("namespace", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition PATHS = + JsonFactory.newJsonArrayFieldDefinition("paths", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition FROM = + JsonFactory.newStringFieldDefinition("from", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition TO = + JsonFactory.newStringFieldDefinition("to", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition STEP = + JsonFactory.newStringFieldDefinition("step", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition AGGREGATION = + JsonFactory.newStringFieldDefinition("aggregation", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition GROUP_BY = + JsonFactory.newJsonArrayFieldDefinition("groupBy", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition FILTER = + JsonFactory.newStringFieldDefinition("filter", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition TIMEZONE = + JsonFactory.newStringFieldDefinition("timezone", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition FILL_STRATEGY = + JsonFactory.newStringFieldDefinition("fillStrategy", FieldType.REGULAR, JsonSchemaVersion.V_2); + + static final JsonFieldDefinition MAX_GROUPS = + JsonFactory.newIntFieldDefinition("maxGroups", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPoint.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPoint.java new file mode 100644 index 00000000000..9288e63a9b3 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPoint.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; + +/** + * An immutable implementation of {@link TimeseriesDataPoint}. + */ +@Immutable +final class ImmutableTimeseriesDataPoint implements TimeseriesDataPoint { + + private final ThingId thingId; + private final JsonPointer path; + private final Instant timestamp; + private final JsonValue value; + private final long revision; + private final Map tags; + @Nullable private final String unit; + + private ImmutableTimeseriesDataPoint(final ThingId thingId, + final JsonPointer path, + final Instant timestamp, + final JsonValue value, + final long revision, + final Map tags, + @Nullable final String unit) { + + this.thingId = thingId; + this.path = path; + this.timestamp = timestamp; + this.value = value; + this.revision = revision; + this.tags = tags; + this.unit = unit; + } + + static TimeseriesDataPoint of(final ThingId thingId, + final JsonPointer path, + final Instant timestamp, + final JsonValue value, + final long revision, + final Map tags, + @Nullable final String unit) { + + checkNotNull(thingId, "thingId"); + checkNotNull(path, "path"); + checkNotNull(timestamp, "timestamp"); + checkNotNull(value, "value"); + checkNotNull(tags, "tags"); + // Only scalar samples are storable. Null is excluded first because Ditto's JsonNull reports + // both isObject() and isArray() as true, and an absent reading is legitimate. + if (!value.isNull() && (value.isObject() || value.isArray())) { + throw new IllegalArgumentException( + "Timeseries values must be scalar (number/string/boolean/null); rejected " + + (value.isArray() ? "array" : "object") + " at path <" + path + "> for thing <" + + thingId + ">."); + } + + return new ImmutableTimeseriesDataPoint( + thingId, + path, + timestamp, + value, + revision, + Collections.unmodifiableMap(new LinkedHashMap<>(tags)), + unit); + } + + static TimeseriesDataPoint fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JsonFields.THING_ID)); + final JsonPointer path = JsonPointer.of(jsonObject.getValueOrThrow(JsonFields.PATH)); + final Instant timestamp = parseTimestamp(jsonObject.getValueOrThrow(JsonFields.TIMESTAMP)); + final JsonValue value = jsonObject.getValueOrThrow(JsonFields.VALUE); + final long revision = jsonObject.getValueOrThrow(JsonFields.REVISION); + final Map tags = jsonObject.getValue(JsonFields.TAGS) + .map(ImmutableTimeseriesDataPoint::tagsFromJson) + .orElseGet(Collections::emptyMap); + final String unit = jsonObject.getValue(JsonFields.UNIT).orElse(null); + + // Route through of() so the scalar-value invariant is enforced consistently with + // programmatic construction. fromJson is the path used when a TimeseriesDataPoint crosses + // a process boundary (cluster pub/sub, persistence read), and a malformed sender should + // never bypass the model-level guard. + return of(thingId, path, timestamp, value, revision, tags, unit); + } + + private static Instant parseTimestamp(final String raw) { + try { + return Instant.parse(raw); + } catch (final DateTimeParseException e) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Field <" + JsonFields.TIMESTAMP.getPointer() + + "> is not a valid ISO-8601 instant: <" + raw + ">.") + .description("Expected an ISO-8601 instant, e.g. \"2026-01-15T10:30:00Z\".") + .cause(e) + .build()); + } + } + + private static Map tagsFromJson(final JsonObject tagsJson) { + final Map result = new LinkedHashMap<>(tagsJson.getSize()); + for (final JsonField field : tagsJson) { + final JsonValue tagValue = field.getValue(); + if (!tagValue.isString()) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Tag value for key <" + field.getKeyName() + "> must be a JSON string.") + .build()); + } + result.put(field.getKeyName(), tagValue.asString()); + } + return result; + } + + @Override + public ThingId getThingId() { + return thingId; + } + + @Override + public JsonPointer getPath() { + return path; + } + + @Override + public Instant getTimestamp() { + return timestamp; + } + + @Override + public JsonValue getValue() { + return value; + } + + @Override + public long getRevision() { + return revision; + } + + @Override + public Map getTags() { + return tags; + } + + @Override + public Optional getUnit() { + return Optional.ofNullable(unit); + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.THING_ID, thingId.toString()) + .set(JsonFields.PATH, path.toString()) + .set(JsonFields.TIMESTAMP, timestamp.toString()) + .set(JsonFields.VALUE, value) + .set(JsonFields.REVISION, revision); + + if (!tags.isEmpty()) { + final JsonObjectBuilder tagsBuilder = JsonFactory.newObjectBuilder(); + for (final Map.Entry entry : tags.entrySet()) { + tagsBuilder.set(JsonKey.of(entry.getKey()), entry.getValue()); + } + builder.set(JsonFields.TAGS, tagsBuilder.build()); + } + + if (unit != null) { + builder.set(JsonFields.UNIT, unit); + } + + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableTimeseriesDataPoint)) { + return false; + } + final ImmutableTimeseriesDataPoint that = (ImmutableTimeseriesDataPoint) o; + return revision == that.revision && + Objects.equals(thingId, that.thingId) && + Objects.equals(path, that.path) && + Objects.equals(timestamp, that.timestamp) && + Objects.equals(value, that.value) && + Objects.equals(tags, that.tags) && + Objects.equals(unit, that.unit); + } + + @Override + public int hashCode() { + return Objects.hash(thingId, path, timestamp, value, revision, tags, unit); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "thingId=" + thingId + + ", path=" + path + + ", timestamp=" + timestamp + + ", value=" + value + + ", revision=" + revision + + ", tags=" + tags + + ", unit=" + unit + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValue.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValue.java new file mode 100644 index 00000000000..af71aaaf68f --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValue.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonValue; + +/** + * An immutable implementation of {@link TimeseriesDataValue}. + */ +@Immutable +final class ImmutableTimeseriesDataValue implements TimeseriesDataValue { + + private final Instant timestamp; + @Nullable private final JsonValue value; + private final boolean isGap; + + private ImmutableTimeseriesDataValue(final Instant timestamp, + @Nullable final JsonValue value, + final boolean isGap) { + + this.timestamp = timestamp; + this.value = value; + this.isGap = isGap; + } + + static TimeseriesDataValue of(final Instant timestamp, + @Nullable final JsonValue value, + final boolean isGap) { + + checkNotNull(timestamp, "timestamp"); + if (!isGap) { + checkNotNull(value, "value"); + } + return new ImmutableTimeseriesDataValue(timestamp, value, isGap); + } + + static TimeseriesDataValue fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final Instant timestamp = parseTimestamp(jsonObject.getValueOrThrow(JsonFields.TIMESTAMP)); + final boolean isGap = jsonObject.getValue(JsonFields.GAP).orElse(false); + final JsonValue value = jsonObject.getValue(JsonFields.VALUE) + .map(v -> v.isNull() ? null : v) + .orElse(null); + + return new ImmutableTimeseriesDataValue(timestamp, value, isGap); + } + + private static Instant parseTimestamp(final String raw) { + try { + return Instant.parse(raw); + } catch (final DateTimeParseException e) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Field is not a valid ISO-8601 instant: <" + raw + ">.") + .description("Expected an ISO-8601 instant, e.g. \"2026-01-15T10:30:00Z\".") + .cause(e) + .build()); + } + } + + @Override + public Instant getTimestamp() { + return timestamp; + } + + @Override + public Optional getValue() { + return Optional.ofNullable(value); + } + + @Override + public boolean isGap() { + return isGap; + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.TIMESTAMP, timestamp.toString()); + + if (value != null) { + builder.set(JsonFields.VALUE, value); + } else if (isGap) { + // Gap with FillStrategy.NULL — emit explicit JSON null so the timestamp slot is visible. + builder.set(JsonFields.VALUE, JsonValue.nullLiteral()); + } + if (isGap) { + builder.set(JsonFields.GAP, true); + } + + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableTimeseriesDataValue)) { + return false; + } + final ImmutableTimeseriesDataValue that = (ImmutableTimeseriesDataValue) o; + return isGap == that.isGap && + Objects.equals(timestamp, that.timestamp) && + Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(timestamp, value, isGap); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "timestamp=" + timestamp + + ", value=" + value + + ", isGap=" + isGap + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQuery.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQuery.java new file mode 100644 index 00000000000..25a472cdb4c --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQuery.java @@ -0,0 +1,521 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; + +/** + * An immutable implementation of {@link TimeseriesQuery}. + */ +@Immutable +final class ImmutableTimeseriesQuery implements TimeseriesQuery { + + /** + * Upper bound on the number of paths a single query may request. A multi-path read fans out to + * one scan per path, each bounded independently by the adapter's per-path ceiling, so the + * request-level memory is the path count times that ceiling. Shared with the cross-Thing query + * so both endpoints enforce the same bound. + */ + private static final int MAX_PATHS = TimeseriesQuery.MAX_PATHS; + + private final ThingId thingId; + private final List paths; + private final Instant from; + private final Instant to; + @Nullable private final Duration step; + @Nullable private final Aggregation aggregation; + @Nullable private final FillStrategy fillStrategy; + @Nullable private final Integer limit; + @Nullable private final ZoneId timezone; + @Nullable private final Double percentile; + @Nullable private final String cursor; + @Nullable private final SortOrder order; + private final Map tagFilters; + + private ImmutableTimeseriesQuery(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone, + @Nullable final Double percentile, + @Nullable final String cursor, + @Nullable final SortOrder order, + final Map tagFilters) { + + this.thingId = thingId; + this.paths = paths; + this.from = from; + this.to = to; + this.step = step; + this.aggregation = aggregation; + this.fillStrategy = fillStrategy; + this.limit = limit; + this.timezone = timezone; + this.percentile = percentile; + this.cursor = cursor; + this.order = order; + this.tagFilters = tagFilters; + } + + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone, + @Nullable final Double percentile, + @Nullable final String cursor, + @Nullable final SortOrder order) { + + checkNotNull(thingId, "thingId"); + checkNotNull(paths, "paths"); + checkNotNull(from, "from"); + checkNotNull(to, "to"); + validateSemantics(paths, aggregation, step, percentile, fillStrategy, cursor, order); + + final List defensivePaths = + Collections.unmodifiableList(new ArrayList<>(paths)); + + return new ImmutableTimeseriesQuery( + thingId, defensivePaths, from, to, step, aggregation, fillStrategy, limit, timezone, + percentile, cursor, order, Collections.emptyMap()); + } + + /** + * Enforces the semantic contract of the aggregation parameters at the model layer (so every + * transport rejects the same inputs identically) by throwing a {@link TimeseriesQueryInvalidException} + * (HTTP 400). Parse-level errors (bad enum names, malformed durations) are handled earlier, at the + * transport boundary. + */ + private static void validateSemantics(final List paths, + @Nullable final Aggregation aggregation, + @Nullable final Duration step, + @Nullable final Double percentile, + @Nullable final FillStrategy fillStrategy, + @Nullable final String cursor, + @Nullable final SortOrder order) { + + if (paths.size() > MAX_PATHS) { + throw TimeseriesQueryInvalidException.newBuilder( + "A query may request at most <" + MAX_PATHS + "> paths but <" + paths.size() + + "> were given. Split the request into smaller batches.").build(); + } + validateCursor(paths, aggregation, step, fillStrategy, cursor); + validateOrder(aggregation, step, fillStrategy, order); + if (percentile != null && (percentile < 0.0 || percentile > 100.0)) { + throw TimeseriesQueryInvalidException.newBuilder( + "Parameter must be between 0 and 100 but was <" + percentile + ">.") + .build(); + } + if (step != null && (step.isZero() || step.isNegative() || step.getNano() != 0)) { + // A sub-second or fractional step has no valid bucket width: TimeseriesComputeKernel + // .stepUnitFor derives its bin from Duration.getSeconds(), so PT0.5S becomes a zero-sized + // bin (division by zero / a fill cursor that never advances) and PT1.5S silently bins at + // 1s while the fill grid steps 1.5s. Rejecting here covers every transport at once. + throw TimeseriesQueryInvalidException.newBuilder( + "Parameter must be a positive whole number of seconds, but was <" + step + + ">.").build(); + } + if (aggregation != null) { + if (aggregation.requiresStep() && step == null) { + throw TimeseriesQueryInvalidException.newBuilder( + "Aggregation <" + aggregation.getName() + + "> requires a parameter for downsampling.").build(); + } + if (aggregation == Aggregation.PERCENTILE && percentile == null) { + throw TimeseriesQueryInvalidException.newBuilder( + "Aggregation requires a value between 0 and 100.") + .build(); + } + } + } + + /** + * Cursor pagination is keyset-based over a single raw series, so it is incompatible with + * downsampling (a {@code step}/{@code aggregation}/{@code fill} reshapes the series into buckets) + * and with multi-path reads (each path is an independent series with its own position). A + * malformed cursor is rejected here too, so every transport reports the same 400. Validation is + * skipped when no cursor is set. + */ + private static void validateCursor(final List paths, + @Nullable final Aggregation aggregation, + @Nullable final Duration step, + @Nullable final FillStrategy fillStrategy, + @Nullable final String cursor) { + + if (cursor == null) { + return; + } + if (aggregation != null || step != null || fillStrategy != null) { + throw TimeseriesQueryInvalidException.newBuilder( + "Cursor pagination is only supported for raw reads and cannot be combined with " + + ", or .").build(); + } + if (paths.size() != 1) { + throw TimeseriesQueryInvalidException.newBuilder( + "Cursor pagination requires exactly one path but <" + paths.size() + + "> were given.").build(); + } + // Reject a malformed cursor at the model layer (throws TimeseriesQueryInvalidException). + TimeseriesCursor.decode(cursor); + } + + /** + * Descending order reverses the raw scan; downsampled reads are always emitted in ascending + * bucket order, so {@code order=desc} is incompatible with {@code step}/{@code aggregation}/ + * {@code fill}. Ascending is the default and imposes no constraint. + */ + private static void validateOrder(@Nullable final Aggregation aggregation, + @Nullable final Duration step, + @Nullable final FillStrategy fillStrategy, + @Nullable final SortOrder order) { + + if (order == SortOrder.DESC && (aggregation != null || step != null || fillStrategy != null)) { + throw TimeseriesQueryInvalidException.newBuilder( + "Order is only supported for raw reads and cannot be combined with " + + ", or .").build(); + } + } + + static TimeseriesQuery fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JsonFields.THING_ID)); + final List paths = pathsFromJson(jsonObject.getValueOrThrow(JsonFields.PATHS)); + final Instant from = parseInstant(jsonObject.getValueOrThrow(JsonFields.FROM), "from"); + final Instant to = parseInstant(jsonObject.getValueOrThrow(JsonFields.TO), "to"); + + final Duration step = jsonObject.getValue(JsonFields.STEP) + .map(s -> parseDuration(s, "step")) + .orElse(null); + final Aggregation aggregation = jsonObject.getValue(JsonFields.AGGREGATION) + .map(s -> parseAggregation(s)) + .orElse(null); + final FillStrategy fillStrategy = jsonObject.getValue(JsonFields.FILL_STRATEGY) + .map(s -> parseFillStrategy(s)) + .orElse(null); + final Integer limit = jsonObject.getValue(JsonFields.LIMIT).orElse(null); + final ZoneId timezone = jsonObject.getValue(JsonFields.TIMEZONE) + .map(s -> parseZoneId(s)) + .orElse(null); + final Double percentile = jsonObject.getValue(JsonFields.PERCENTILE).orElse(null); + final String cursor = jsonObject.getValue(JsonFields.CURSOR).orElse(null); + final SortOrder order = jsonObject.getValue(JsonFields.ORDER) + .map(s -> parseSortOrder(s)) + .orElse(null); + final Map tagFilters = jsonObject.getValue(JsonFields.TAG_FILTERS) + .map(ImmutableTimeseriesQuery::tagFiltersFromJson) + .orElseGet(Collections::emptyMap); + + final TimeseriesQuery query = of(thingId, paths, from, to, step, aggregation, fillStrategy, + limit, timezone, percentile, cursor, order); + return tagFilters.isEmpty() ? query : query.withTagFilters(tagFilters); + } + + private static Map tagFiltersFromJson(final JsonObject tagFiltersJson) { + final Map result = new LinkedHashMap<>(); + for (final JsonField field : tagFiltersJson) { + final JsonValue value = field.getValue(); + result.put(field.getKeyName(), value.isString() ? value.asString() : value.formatAsString()); + } + return result; + } + + private static List pathsFromJson(final JsonArray array) { + final List result = new ArrayList<>(array.getSize()); + for (final JsonValue value : array) { + if (!value.isString()) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Element of must be a JSON string but was: " + value) + .build()); + } + result.add(JsonPointer.of(value.asString())); + } + return result; + } + + private static Instant parseInstant(final String raw, final String fieldName) { + try { + return Instant.parse(raw); + } catch (final DateTimeParseException e) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Field <" + fieldName + "> is not a valid ISO-8601 instant: <" + raw + ">.") + .description("Expected an ISO-8601 instant, e.g. \"2026-01-15T10:30:00Z\".") + .cause(e) + .build()); + } + } + + private static Duration parseDuration(final String raw, final String fieldName) { + try { + return Duration.parse(raw); + } catch (final DateTimeParseException e) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Field <" + fieldName + "> is not a valid ISO-8601 duration: <" + raw + ">.") + .description("Expected an ISO-8601 duration, e.g. \"PT1H\" or \"PT5M\".") + .cause(e) + .build()); + } + } + + private static Aggregation parseAggregation(final String raw) { + return Aggregation.forName(raw).orElseThrow(() -> new DittoJsonException(JsonParseException.newBuilder() + .message("Field has an unknown value: <" + raw + ">.") + .description("Expected one of the supported aggregation function names (e.g. \"avg\").") + .build())); + } + + private static FillStrategy parseFillStrategy(final String raw) { + return FillStrategy.forName(raw).orElseThrow(() -> new DittoJsonException(JsonParseException.newBuilder() + .message("Field has an unknown value: <" + raw + ">.") + .description("Expected one of: \"null\", \"previous\", \"linear\", \"zero\".") + .build())); + } + + private static ZoneId parseZoneId(final String raw) { + try { + return ZoneId.of(raw); + } catch (final DateTimeException e) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Field is not a valid time-zone ID: <" + raw + ">.") + .description("Expected an IANA time-zone ID, e.g. \"Europe/Berlin\" or \"UTC\".") + .cause(e) + .build()); + } + } + + private static SortOrder parseSortOrder(final String raw) { + return SortOrder.forName(raw).orElseThrow(() -> new DittoJsonException(JsonParseException.newBuilder() + .message("Field has an unknown value: <" + raw + ">.") + .description("Expected one of: \"asc\", \"desc\".") + .build())); + } + + @Override + public ThingId getThingId() { + return thingId; + } + + @Override + public List getPaths() { + return paths; + } + + @Override + public Instant getFrom() { + return from; + } + + @Override + public Instant getTo() { + return to; + } + + @Override + public Optional getStep() { + return Optional.ofNullable(step); + } + + @Override + public Optional getAggregation() { + return Optional.ofNullable(aggregation); + } + + @Override + public Optional getFillStrategy() { + return Optional.ofNullable(fillStrategy); + } + + @Override + public Optional getLimit() { + return Optional.ofNullable(limit); + } + + @Override + public Optional getTimezone() { + return Optional.ofNullable(timezone); + } + + @Override + public Optional getPercentile() { + return Optional.ofNullable(percentile); + } + + @Override + public Optional getCursor() { + return Optional.ofNullable(cursor); + } + + @Override + public Optional getOrder() { + return Optional.ofNullable(order); + } + + @Override + public Map getTagFilters() { + return tagFilters; + } + + @Override + public TimeseriesQuery withTagFilters(final Map newTagFilters) { + checkNotNull(newTagFilters, "tagFilters"); + return new ImmutableTimeseriesQuery(thingId, paths, from, to, step, aggregation, fillStrategy, + limit, timezone, percentile, cursor, order, + Collections.unmodifiableMap(new LinkedHashMap<>(newTagFilters))); + } + + @Override + public TimeseriesQuery withPaths(final List newPaths) { + checkNotNull(newPaths, "paths"); + if (newPaths.isEmpty()) { + throw TimeseriesQueryInvalidException.newBuilder( + "A timeseries query requires at least one path.").build(); + } + return new ImmutableTimeseriesQuery(thingId, + Collections.unmodifiableList(new ArrayList<>(newPaths)), from, to, step, aggregation, + fillStrategy, limit, timezone, percentile, cursor, order, tagFilters); + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.THING_ID, thingId.toString()) + .set(JsonFields.PATHS, pathsToJson()) + .set(JsonFields.FROM, from.toString()) + .set(JsonFields.TO, to.toString()); + + if (step != null) { + builder.set(JsonFields.STEP, step.toString()); + } + if (aggregation != null) { + builder.set(JsonFields.AGGREGATION, aggregation.getName()); + } + if (fillStrategy != null) { + builder.set(JsonFields.FILL_STRATEGY, fillStrategy.getName()); + } + if (limit != null) { + builder.set(JsonFields.LIMIT, limit); + } + if (timezone != null) { + builder.set(JsonFields.TIMEZONE, timezone.toString()); + } + if (percentile != null) { + builder.set(JsonFields.PERCENTILE, percentile); + } + if (cursor != null) { + builder.set(JsonFields.CURSOR, cursor); + } + if (order != null) { + builder.set(JsonFields.ORDER, order.getName()); + } + if (!tagFilters.isEmpty()) { + final JsonObjectBuilder tagFiltersBuilder = JsonFactory.newObjectBuilder(); + tagFilters.forEach(tagFiltersBuilder::set); + builder.set(JsonFields.TAG_FILTERS, tagFiltersBuilder.build()); + } + + return builder.build(); + } + + private JsonArray pathsToJson() { + final JsonArrayBuilder builder = JsonFactory.newArrayBuilder(); + for (final JsonPointer pointer : paths) { + builder.add(pointer.toString()); + } + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableTimeseriesQuery)) { + return false; + } + final ImmutableTimeseriesQuery that = (ImmutableTimeseriesQuery) o; + return Objects.equals(thingId, that.thingId) && + Objects.equals(paths, that.paths) && + Objects.equals(from, that.from) && + Objects.equals(to, that.to) && + Objects.equals(step, that.step) && + aggregation == that.aggregation && + fillStrategy == that.fillStrategy && + Objects.equals(limit, that.limit) && + Objects.equals(timezone, that.timezone) && + Objects.equals(percentile, that.percentile) && + Objects.equals(cursor, that.cursor) && + order == that.order && + Objects.equals(tagFilters, that.tagFilters); + } + + @Override + public int hashCode() { + return Objects.hash(thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, + percentile, cursor, order, tagFilters); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "thingId=" + thingId + + ", paths=" + paths + + ", from=" + from + + ", to=" + to + + ", step=" + step + + ", aggregation=" + aggregation + + ", fillStrategy=" + fillStrategy + + ", limit=" + limit + + ", timezone=" + timezone + + ", percentile=" + percentile + + ", cursor=" + cursor + + ", order=" + order + + ", tagFilters=" + tagFilters + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResult.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResult.java new file mode 100644 index 00000000000..826e04e266d --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResult.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; + +/** + * An immutable implementation of {@link TimeseriesQueryResult}. + */ +@Immutable +final class ImmutableTimeseriesQueryResult implements TimeseriesQueryResult { + + private final ThingId thingId; + private final JsonPointer path; + private final TimeseriesQuery query; + private final TimeseriesResultMeta meta; + private final List data; + + private ImmutableTimeseriesQueryResult(final ThingId thingId, + final JsonPointer path, + final TimeseriesQuery query, + final TimeseriesResultMeta meta, + final List data) { + + this.thingId = thingId; + this.path = path; + this.query = query; + this.meta = meta; + this.data = data; + } + + static TimeseriesQueryResult of(final ThingId thingId, + final JsonPointer path, + final TimeseriesQuery query, + final TimeseriesResultMeta meta, + final List data) { + + checkNotNull(thingId, "thingId"); + checkNotNull(path, "path"); + checkNotNull(query, "query"); + checkNotNull(meta, "meta"); + checkNotNull(data, "data"); + + return new ImmutableTimeseriesQueryResult( + thingId, path, query, meta, + Collections.unmodifiableList(new ArrayList<>(data))); + } + + static TimeseriesQueryResult fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JsonFields.THING_ID)); + final JsonPointer path = JsonPointer.of(jsonObject.getValueOrThrow(JsonFields.PATH)); + final TimeseriesQuery query = + TimeseriesQuery.fromJson(jsonObject.getValueOrThrow(JsonFields.QUERY)); + final TimeseriesResultMeta meta = + TimeseriesResultMeta.fromJson(jsonObject.getValueOrThrow(JsonFields.META)); + final List data = dataFromJson(jsonObject.getValueOrThrow(JsonFields.DATA)); + + return of(thingId, path, query, meta, data); + } + + private static List dataFromJson(final JsonArray array) { + final List result = new ArrayList<>(array.getSize()); + for (final JsonValue value : array) { + if (!value.isObject()) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Element of must be a JSON object but was: " + value) + .build()); + } + result.add(TimeseriesDataValue.fromJson(value.asObject())); + } + return result; + } + + @Override + public ThingId getThingId() { + return thingId; + } + + @Override + public JsonPointer getPath() { + return path; + } + + @Override + public TimeseriesQuery getQuery() { + return query; + } + + @Override + public TimeseriesResultMeta getMeta() { + return meta; + } + + @Override + public List getData() { + return data; + } + + @Override + public JsonObject toJson() { + final JsonArrayBuilder dataBuilder = JsonFactory.newArrayBuilder(); + for (final TimeseriesDataValue value : data) { + dataBuilder.add(value.toJson()); + } + + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.THING_ID, thingId.toString()) + .set(JsonFields.PATH, path.toString()) + .set(JsonFields.QUERY, query.toJson()) + .set(JsonFields.META, meta.toJson()) + .set(JsonFields.DATA, dataBuilder.build()); + + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableTimeseriesQueryResult)) { + return false; + } + final ImmutableTimeseriesQueryResult that = (ImmutableTimeseriesQueryResult) o; + return Objects.equals(thingId, that.thingId) && + Objects.equals(path, that.path) && + Objects.equals(query, that.query) && + Objects.equals(meta, that.meta) && + Objects.equals(data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(thingId, path, query, meta, data); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "thingId=" + thingId + + ", path=" + path + + ", query=" + query + + ", meta=" + meta + + ", data=" + data + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMeta.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMeta.java new file mode 100644 index 00000000000..a445a89a088 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMeta.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonValue; + +/** + * An immutable implementation of {@link TimeseriesResultMeta}. + */ +@Immutable +final class ImmutableTimeseriesResultMeta implements TimeseriesResultMeta { + + private final int count; + @Nullable private final String unit; + private final String dataType; + private final Map tags; + @Nullable private final Boolean hasMore; + @Nullable private final String nextCursor; + + private ImmutableTimeseriesResultMeta(final int count, + @Nullable final String unit, + final String dataType, + final Map tags, + @Nullable final Boolean hasMore, + @Nullable final String nextCursor) { + + this.count = count; + this.unit = unit; + this.dataType = dataType; + this.tags = tags; + this.hasMore = hasMore; + this.nextCursor = nextCursor; + } + + static TimeseriesResultMeta of(final int count, + @Nullable final String unit, + final String dataType, + final Map tags, + @Nullable final Boolean hasMore, + @Nullable final String nextCursor) { + + checkNotNull(dataType, "dataType"); + checkNotNull(tags, "tags"); + if (count < 0) { + throw new IllegalArgumentException("count must not be negative but was: " + count); + } + return new ImmutableTimeseriesResultMeta(count, unit, dataType, + Collections.unmodifiableMap(new LinkedHashMap<>(tags)), hasMore, nextCursor); + } + + static TimeseriesResultMeta fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final int count = jsonObject.getValueOrThrow(JsonFields.COUNT); + final String unit = jsonObject.getValue(JsonFields.UNIT).orElse(null); + final String dataType = jsonObject.getValueOrThrow(JsonFields.DATA_TYPE); + final Map tags = jsonObject.getValue(JsonFields.TAGS) + .map(ImmutableTimeseriesResultMeta::tagsFromJson) + .orElseGet(Collections::emptyMap); + final Boolean hasMore = jsonObject.getValue(JsonFields.HAS_MORE).orElse(null); + final String nextCursor = jsonObject.getValue(JsonFields.NEXT_CURSOR).orElse(null); + + return of(count, unit, dataType, tags, hasMore, nextCursor); + } + + private static Map tagsFromJson(final JsonObject tagsJson) { + final Map result = new LinkedHashMap<>(); + for (final JsonField field : tagsJson) { + final JsonValue value = field.getValue(); + result.put(field.getKeyName(), value.isString() ? value.asString() : value.formatAsString()); + } + return result; + } + + @Override + public int getCount() { + return count; + } + + @Override + public Optional getUnit() { + return Optional.ofNullable(unit); + } + + @Override + public String getDataType() { + return dataType; + } + + @Override + public Map getTags() { + return tags; + } + + @Override + public Optional getHasMore() { + return Optional.ofNullable(hasMore); + } + + @Override + public Optional getNextCursor() { + return Optional.ofNullable(nextCursor); + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.COUNT, count) + .set(JsonFields.DATA_TYPE, dataType); + + if (unit != null) { + builder.set(JsonFields.UNIT, unit); + } + if (!tags.isEmpty()) { + final JsonObjectBuilder tagsBuilder = JsonFactory.newObjectBuilder(); + tags.forEach(tagsBuilder::set); + builder.set(JsonFields.TAGS, tagsBuilder.build()); + } + if (hasMore != null) { + builder.set(JsonFields.HAS_MORE, hasMore); + } + if (nextCursor != null) { + builder.set(JsonFields.NEXT_CURSOR, nextCursor); + } + + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableTimeseriesResultMeta)) { + return false; + } + final ImmutableTimeseriesResultMeta that = (ImmutableTimeseriesResultMeta) o; + return count == that.count && + Objects.equals(unit, that.unit) && + Objects.equals(dataType, that.dataType) && + Objects.equals(tags, that.tags) && + Objects.equals(hasMore, that.hasMore) && + Objects.equals(nextCursor, that.nextCursor); + } + + @Override + public int hashCode() { + return Objects.hash(count, unit, dataType, tags, hasMore, nextCursor); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "count=" + count + + ", unit=" + unit + + ", dataType=" + dataType + + ", tags=" + tags + + ", hasMore=" + hasMore + + ", nextCursor=" + nextCursor + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotation.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotation.java new file mode 100644 index 00000000000..d64f4270aa1 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotation.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonParseException; +import org.eclipse.ditto.json.JsonValue; + +/** + * An immutable implementation of {@link WotTimeseriesAnnotation}. + */ +@Immutable +final class ImmutableWotTimeseriesAnnotation implements WotTimeseriesAnnotation { + + private final Ingest ingest; + private final Map tags; + + private ImmutableWotTimeseriesAnnotation(final Ingest ingest, final Map tags) { + this.ingest = ingest; + this.tags = tags; + } + + static WotTimeseriesAnnotation of(final Ingest ingest, final Map tags) { + checkNotNull(ingest, "ingest"); + checkNotNull(tags, "tags"); + return new ImmutableWotTimeseriesAnnotation( + ingest, + Collections.unmodifiableMap(new LinkedHashMap<>(tags))); + } + + static WotTimeseriesAnnotation fromJson(final JsonObject jsonObject) { + checkNotNull(jsonObject, "jsonObject"); + + final String rawIngest = jsonObject.getValueOrThrow(JsonFields.INGEST); + final Ingest ingest = Ingest.forName(rawIngest) + .orElseThrow(() -> new DittoJsonException(JsonParseException.newBuilder() + .message("Field <" + JsonFields.INGEST.getPointer() + + "> has an unknown value: <" + rawIngest + ">.") + .description("Expected one of: ALL, NONE.") + .build())); + + final Map tags = jsonObject.getValue(JsonFields.TAGS) + .map(ImmutableWotTimeseriesAnnotation::tagsFromJson) + .orElseGet(Collections::emptyMap); + + return new ImmutableWotTimeseriesAnnotation( + ingest, Collections.unmodifiableMap(new LinkedHashMap<>(tags))); + } + + static Optional findInProperty(final JsonObject propertySchema, + final CharSequence extensionKey) { + checkNotNull(propertySchema, "propertySchema"); + checkNotNull(extensionKey, "extensionKey"); + return propertySchema.getValue(extensionKey.toString()) + .filter(JsonValue::isObject) + .map(JsonValue::asObject) + .map(ImmutableWotTimeseriesAnnotation::fromJson); + } + + private static Map tagsFromJson(final JsonObject tagsJson) { + final Map result = new LinkedHashMap<>(tagsJson.getSize()); + for (final JsonField field : tagsJson) { + final JsonValue value = field.getValue(); + if (!value.isString()) { + throw new DittoJsonException(JsonParseException.newBuilder() + .message("Tag value for key <" + field.getKeyName() + + "> must be a JSON string.") + .build()); + } + result.put(field.getKeyName(), value.asString()); + } + return result; + } + + @Override + public Ingest getIngest() { + return ingest; + } + + @Override + public Map getTags() { + return tags; + } + + @Override + public JsonObject toJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder() + .set(JsonFields.INGEST, ingest.getName()); + + if (!tags.isEmpty()) { + final JsonObjectBuilder tagsBuilder = JsonFactory.newObjectBuilder(); + for (final Map.Entry entry : tags.entrySet()) { + tagsBuilder.set(JsonKey.of(entry.getKey()), entry.getValue()); + } + builder.set(JsonFields.TAGS, tagsBuilder.build()); + } + + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ImmutableWotTimeseriesAnnotation)) { + return false; + } + final ImmutableWotTimeseriesAnnotation that = (ImmutableWotTimeseriesAnnotation) o; + return ingest == that.ingest && Objects.equals(tags, that.tags); + } + + @Override + public int hashCode() { + return Objects.hash(ingest, tags); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "ingest=" + ingest + + ", tags=" + tags + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Ingest.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Ingest.java new file mode 100644 index 00000000000..b48987ab83b --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/Ingest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; + +import javax.annotation.concurrent.Immutable; + +/** + * Enumeration of ingestion modes declared on a WoT property via {@code ditto:timeseries.ingest}. + * + * @since 4.0.0 + */ +@Immutable +public enum Ingest implements CharSequence { + + /** + * Ingest every value change for this property into the timeseries database. + */ + ALL("ALL"), + + /** + * Disable ingestion for this property. Useful for temporarily pausing collection without + * removing the {@code ditto:timeseries} declaration entirely. + */ + NONE("NONE"); + + private final String name; + + Ingest(final String name) { + this.name = name; + } + + /** + * @return the wire-format name of this ingestion mode (uppercase token). + */ + public String getName() { + return name; + } + + /** + * Returns the {@code Ingest} for the given wire-format name. + * + * @param name the wire-format name (case-sensitive). + * @return the matching value or empty if unknown. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()).filter(i -> i.name.contentEquals(name)).findFirst(); + } + + @Override + public int length() { + return name.length(); + } + + @Override + public char charAt(final int index) { + return name.charAt(index); + } + + @Override + public CharSequence subSequence(final int start, final int end) { + return name.subSequence(start, end); + } + + @Override + public String toString() { + return name; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/SortOrder.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/SortOrder.java new file mode 100644 index 00000000000..7698799423f --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/SortOrder.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; + +import javax.annotation.concurrent.Immutable; + +/** + * The chronological order in which a raw timeseries read returns its data points. + *

+ * {@link #ASC} (oldest first) is the default and matches the natural bucket order of downsampled + * reads. {@link #DESC} (newest first) suits "scroll back into the past" UIs paging with a + * {@link TimeseriesCursor}; it is only supported for raw reads. Wire format uses the lowercase token + * (see {@link #getName()}). + * + * @since 4.0.0 + */ +@Immutable +public enum SortOrder { + + /** + * Ascending by timestamp — oldest data point first. The default. + */ + ASC("asc"), + + /** + * Descending by timestamp — newest data point first. + */ + DESC("desc"); + + private final String name; + + SortOrder(final String name) { + this.name = name; + } + + /** + * Returns the wire-format name of this sort order. + * + * @return the name as used on the wire (lowercase token). + */ + public String getName() { + return name; + } + + /** + * Returns the {@code SortOrder} for the given wire-format {@code name} if it exists. + * + * @param name the wire-format name. + * @return the matching {@code SortOrder} or an empty {@code Optional} if no match. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()) + .filter(order -> order.name.contentEquals(name)) + .findFirst(); + } + + @Override + public String toString() { + return name; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesAggregationForbiddenException.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesAggregationForbiddenException.java new file mode 100644 index 00000000000..0f983ef7f05 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesAggregationForbiddenException.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.net.URI; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import javax.annotation.concurrent.NotThreadSafe; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeExceptionBuilder; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.JsonParsableException; +import org.eclipse.ditto.json.JsonObject; + +/** + * Thrown when a cross-Thing timeseries aggregation is not authorized for the requesting subject. + *

+ * Unlike the single-Thing read path — which reports a denial as {@code 404 thing.notfound} so it + * cannot be used to probe for the existence of a Thing — this is a plain {@code 403}. There is + * nothing to conceal: the caller supplied the namespace themselves, so the response reveals no + * information they did not already have. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableException(errorCode = TimeseriesAggregationForbiddenException.ERROR_CODE) +public final class TimeseriesAggregationForbiddenException extends DittoRuntimeException { + + /** + * Error code of this exception. + */ + public static final String ERROR_CODE = "timeseries:aggregation.forbidden"; + + private static final String DEFAULT_DESCRIPTION = + "A cross-Thing aggregation requires a namespace-wide READ_TS grant, which is normally " + + "expressed through a namespace root policy. Per-Thing grants scattered across " + + "individual policies are not yet supported for cross-Thing queries; query those " + + "Things individually instead."; + + private static final URI DEFAULT_HREF = URI.create("https://github.com/eclipse-ditto/ditto/issues/2291"); + + private static final long serialVersionUID = 4718225913077413355L; + + private TimeseriesAggregationForbiddenException(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + + super(ERROR_CODE, HttpStatus.FORBIDDEN, dittoHeaders, message, description, cause, href); + } + + /** + * A mutable builder for a {@code TimeseriesAggregationForbiddenException}. + * + * @param message the detail message describing why the aggregation is not authorized. + * @return the builder. + */ + public static Builder newBuilder(final String message) { + return new Builder(message); + } + + /** + * Returns the exception for a subject lacking a namespace-wide grant on the given namespace. + * + * @param namespace the namespace the aggregation targeted. + * @param permission the permission that was required (e.g. {@code READ_TS}). + * @return the builder, pre-filled with a message. + */ + public static Builder forNamespace(final String namespace, final String permission) { + return new Builder("Not authorized to aggregate timeseries across namespace <" + namespace + + ">: no namespace-wide '" + permission + "' grant covers every requested path."); + } + + /** + * Constructs a new {@code TimeseriesAggregationForbiddenException} from the given message. + * + * @param message the detail message. + * @param dittoHeaders the headers of the command which resulted in this exception. + * @return the new exception. + * @throws NullPointerException if {@code dittoHeaders} is {@code null}. + */ + public static TimeseriesAggregationForbiddenException fromMessage(@Nullable final String message, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromMessage(message, dittoHeaders, new Builder()); + } + + /** + * Constructs a new {@code TimeseriesAggregationForbiddenException} from the message extracted + * from the given JSON object. + * + * @param jsonObject the JSON to read the message field from. + * @param dittoHeaders the headers of the command which resulted in this exception. + * @return the new exception. + * @throws NullPointerException if any argument is {@code null}. + */ + public static TimeseriesAggregationForbiddenException fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromJson(jsonObject, dittoHeaders, new Builder()); + } + + @Override + public DittoRuntimeException setDittoHeaders(final DittoHeaders dittoHeaders) { + return new Builder() + .message(getMessage()) + .description(getDescription().orElse(null)) + .cause(getCause()) + .href(getHref().orElse(null)) + .dittoHeaders(dittoHeaders) + .build(); + } + + /** + * A mutable builder with a fluent API for a {@link TimeseriesAggregationForbiddenException}. + */ + @NotThreadSafe + public static final class Builder + extends DittoRuntimeExceptionBuilder { + + private Builder() { + description(DEFAULT_DESCRIPTION); + href(DEFAULT_HREF); + } + + private Builder(final String message) { + this(); + message(message); + } + + @Override + protected TimeseriesAggregationForbiddenException doBuild(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + return new TimeseriesAggregationForbiddenException(dittoHeaders, message, description, + cause, href); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesCursor.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesCursor.java new file mode 100644 index 00000000000..655e05e7f72 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesCursor.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Base64; +import java.util.Objects; + +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; + +/** + * An opaque, resumable position within a raw timeseries read. Pagination is + * keyset-based: a cursor captures the {@code (timestamp, revision)} of the last data point + * of a page, and the next page returns the points that sort strictly after it. Because the time + * axis of an append-only series is immutable, a keyset cursor is stable against concurrent writes — + * unlike an offset, which shifts whenever a point is inserted. + *

+ * The pair {@code (timestamp, revision)} is a total order within a single path: one Thing event + * carries exactly one revision and one timestamp and changes a given property at most once, so no + * two data points of the same path share both. {@code revision} is therefore the tie-breaker that + * makes same-millisecond points paginable without skips or duplicates. + *

+ * The wire form is the Base64-URL encoding (no padding) of the compact JSON object + * {@code {"t":"","r":}}. It is deliberately opaque: callers must treat it as a + * blob obtained from {@link TimeseriesResultMeta#getNextCursor()} and echo it back verbatim, so the + * encoding can evolve without breaking clients. + * + * @since 4.0.0 + */ +@Immutable +public final class TimeseriesCursor { + + /** + * Field name for the ISO-8601 timestamp inside the decoded cursor payload. + */ + private static final String PAYLOAD_TIMESTAMP = "t"; + + /** + * Field name for the revision inside the decoded cursor payload. + */ + private static final String PAYLOAD_REVISION = "r"; + + private final Instant timestamp; + private final long revision; + + private TimeseriesCursor(final Instant timestamp, final long revision) { + this.timestamp = timestamp; + this.revision = revision; + } + + /** + * Returns a new {@code TimeseriesCursor} for the given keyset position. + * + * @param timestamp the timestamp of the last data point of a page. + * @param revision the Thing revision of the last data point of a page (tie-breaker). + * @return the new cursor. + * @throws NullPointerException if {@code timestamp} is {@code null}. + */ + public static TimeseriesCursor of(final Instant timestamp, final long revision) { + checkNotNull(timestamp, "timestamp"); + return new TimeseriesCursor(timestamp, revision); + } + + /** + * Decodes an opaque cursor token previously produced by {@link #encode()}. + * + * @param encoded the opaque token, e.g. from {@link TimeseriesResultMeta#getNextCursor()}. + * @return the decoded cursor. + * @throws NullPointerException if {@code encoded} is {@code null}. + * @throws TimeseriesQueryInvalidException if the token is not a valid cursor (HTTP 400). + */ + public static TimeseriesCursor decode(final String encoded) { + checkNotNull(encoded, "encoded"); + final JsonObject payload; + try { + // Base64 decode throws IllegalArgumentException, JSON parse throws JsonParseException — + // both are RuntimeExceptions, caught together as a single malformed-cursor 400. + final byte[] decoded = Base64.getUrlDecoder().decode(encoded); + payload = JsonFactory.newObject(new String(decoded, StandardCharsets.UTF_8)); + } catch (final RuntimeException e) { + throw invalidCursor(encoded, e); + } + final String rawTimestamp = payload.getValue(PAYLOAD_TIMESTAMP) + .filter(value -> value.isString()) + .map(value -> value.asString()) + .orElseThrow(() -> invalidCursor(encoded, null)); + final long revision = payload.getValue(PAYLOAD_REVISION) + .filter(value -> value.isNumber()) + .map(value -> value.asLong()) + .orElseThrow(() -> invalidCursor(encoded, null)); + try { + return new TimeseriesCursor(Instant.parse(rawTimestamp), revision); + } catch (final DateTimeParseException e) { + throw invalidCursor(encoded, e); + } + } + + private static TimeseriesQueryInvalidException invalidCursor(final String encoded, + final Throwable cause) { + return TimeseriesQueryInvalidException.newBuilder( + "The value <" + encoded + "> is not a valid pagination cursor.") + .description("Pass back the opaque 'nextCursor' value from a previous response " + + "unchanged, or omit it to start from the beginning of the range.") + .cause(cause) + .build(); + } + + /** + * @return the timestamp of the keyset position. + */ + public Instant getTimestamp() { + return timestamp; + } + + /** + * @return the Thing revision of the keyset position (tie-breaker for same-timestamp points). + */ + public long getRevision() { + return revision; + } + + /** + * Encodes this cursor into its opaque wire token. + * + * @return the Base64-URL (unpadded) token. + */ + public String encode() { + final JsonObject payload = JsonFactory.newObjectBuilder() + .set(PAYLOAD_TIMESTAMP, timestamp.toString()) + .set(PAYLOAD_REVISION, revision) + .build(); + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8)); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TimeseriesCursor)) { + return false; + } + final TimeseriesCursor that = (TimeseriesCursor) o; + return revision == that.revision && Objects.equals(timestamp, that.timestamp); + } + + @Override + public int hashCode() { + return Objects.hash(timestamp, revision); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "timestamp=" + timestamp + + ", revision=" + revision + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataPoint.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataPoint.java new file mode 100644 index 00000000000..3feaec8d134 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataPoint.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; + +/** + * A single timeseries data point — the unit of ingestion into a timeseries database. + *

+ * Captures the value of a Thing property at a given moment in time, along with the resolved tags and + * (optional) unit declared in the WoT ThingModel. Tags are resolved at ingestion time and stored + * alongside the data point so that historical values are not affected by subsequent changes to the + * Thing's attributes. + * + * @since 4.0.0 + */ +public interface TimeseriesDataPoint extends Jsonifiable { + + /** + * Returns a new {@code TimeseriesDataPoint}. + * + * @param thingId the Thing whose property the data point belongs to. + * @param path the Ditto Protocol path within the Thing (e.g. + * {@code /features/env/properties/temperature}). + * @param timestamp when the value was observed. + * @param value the observed value as a {@code JsonValue}. + * @param revision the Thing's revision counter at the time of observation. + * @param tags resolved tag values (stored verbatim with the data point); may be empty but never + * {@code null}. + * @param unit the unit of the value as declared in the WoT property's {@code unit} field, with + * any semantic prefix already stripped; may be {@code null} when not declared. + * @return the new data point. + * @throws NullPointerException if any non-{@code @Nullable} argument is {@code null}. + */ + static TimeseriesDataPoint of(final ThingId thingId, + final JsonPointer path, + final Instant timestamp, + final JsonValue value, + final long revision, + final Map tags, + @Nullable final String unit) { + + return ImmutableTimeseriesDataPoint.of(thingId, path, timestamp, value, revision, tags, unit); + } + + /** + * Parses a {@code TimeseriesDataPoint} from the given JSON object. + * + * @param jsonObject the JSON object representing a data point. + * @return the parsed data point. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if a field has the wrong type or an invalid + * value (e.g. an unparseable timestamp). + */ + static TimeseriesDataPoint fromJson(final JsonObject jsonObject) { + return ImmutableTimeseriesDataPoint.fromJson(jsonObject); + } + + /** + * @return the Thing this data point belongs to. + */ + ThingId getThingId(); + + /** + * @return the Ditto Protocol path within the Thing identifying the property whose value is + * captured (e.g. {@code /features/env/properties/temperature}). + */ + JsonPointer getPath(); + + /** + * @return the moment the value was observed. + */ + Instant getTimestamp(); + + /** + * @return the observed value. + */ + JsonValue getValue(); + + /** + * @return the Thing's revision counter at the time of observation. + */ + long getRevision(); + + /** + * @return resolved tags stored with the data point. Always non-null; may be empty. The returned + * map is unmodifiable. + */ + Map getTags(); + + /** + * @return the unit of the value (semantic prefix stripped) if one was declared in the WoT model, + * or empty otherwise. + */ + Optional getUnit(); + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link TimeseriesDataPoint}. + */ + final class JsonFields { + + /** + * The Thing ID. + */ + public static final JsonFieldDefinition THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The Ditto Protocol path within the Thing. + */ + public static final JsonFieldDefinition PATH = + JsonFactory.newStringFieldDefinition("path", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The ISO-8601 timestamp. + */ + public static final JsonFieldDefinition TIMESTAMP = + JsonFactory.newStringFieldDefinition("timestamp", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The captured value as raw JSON. + */ + public static final JsonFieldDefinition VALUE = + JsonFactory.newJsonValueFieldDefinition("value", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The Thing's revision counter. + */ + public static final JsonFieldDefinition REVISION = + JsonFactory.newLongFieldDefinition("revision", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * Resolved tags. Optional; omitted from the wire format when empty. + */ + public static final JsonFieldDefinition TAGS = + JsonFactory.newJsonObjectFieldDefinition("tags", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * Unit string. Optional; omitted from the wire format when absent. + */ + public static final JsonFieldDefinition UNIT = + JsonFactory.newStringFieldDefinition("unit", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataValue.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataValue.java new file mode 100644 index 00000000000..6e9c9a1ae51 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesDataValue.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.time.Instant; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; + +/** + * A single timestamped value in a timeseries query result. Either a real observation + * (value present, {@link #isGap()} is {@code false}) or a gap-filled placeholder produced by a + * {@link FillStrategy} during downsampling (value may be {@code null} if the strategy is + * {@link FillStrategy#NULL}, and {@link #isGap()} is {@code true}). + * + * @since 4.0.0 + */ +public interface TimeseriesDataValue extends Jsonifiable { + + /** + * Returns a non-gap data value. + * + * @param timestamp the time of the observation. + * @param value the observed value. + * @return the data value. + * @throws NullPointerException if any argument is {@code null}. + */ + static TimeseriesDataValue of(final Instant timestamp, final JsonValue value) { + return ImmutableTimeseriesDataValue.of(timestamp, value, false); + } + + /** + * Returns a data value at the given timestamp marked as a gap (produced by a fill strategy). + * + * @param timestamp the bucket timestamp. + * @param value the value supplied by the fill strategy, or {@code null} for + * {@link FillStrategy#NULL}. + * @return the gap data value. + * @throws NullPointerException if {@code timestamp} is {@code null}. + */ + static TimeseriesDataValue gap(final Instant timestamp, @Nullable final JsonValue value) { + return ImmutableTimeseriesDataValue.of(timestamp, value, true); + } + + /** + * Parses a {@code TimeseriesDataValue} from the given JSON object. The JSON shape uses short + * field names: {@code t} for timestamp (ISO-8601), {@code v} for value, and the optional + * {@code _gap} flag. + * + * @param jsonObject the JSON object. + * @return the parsed data value. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if {@code t} is missing. + * @throws org.eclipse.ditto.json.JsonParseException if {@code t} is not a valid ISO-8601 instant. + */ + static TimeseriesDataValue fromJson(final JsonObject jsonObject) { + return ImmutableTimeseriesDataValue.fromJson(jsonObject); + } + + /** + * @return the timestamp of this value. + */ + Instant getTimestamp(); + + /** + * @return the observed value if present, or empty when the value is {@code null} (e.g. a + * {@link FillStrategy#NULL} gap). + */ + Optional getValue(); + + /** + * @return {@code true} if this entry was produced by a fill strategy and does not represent an + * actual observation; {@code false} for real observations. + */ + boolean isGap(); + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link TimeseriesDataValue}. + */ + final class JsonFields { + + /** + * The timestamp ({@code t}). + */ + public static final JsonFieldDefinition TIMESTAMP = + JsonFactory.newStringFieldDefinition("t", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The value ({@code v}). + */ + public static final JsonFieldDefinition VALUE = + JsonFactory.newJsonValueFieldDefinition("v", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The gap flag ({@code _gap}). Optional; default {@code false}. + */ + public static final JsonFieldDefinition GAP = + JsonFactory.newBooleanFieldDefinition("_gap", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQuery.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQuery.java new file mode 100644 index 00000000000..957eb1235f2 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQuery.java @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; + +/** + * Describes a single-Thing timeseries query: which paths to retrieve, the time range, and the + * optional downsampling, aggregation, fill, limit and timezone parameters. + *

+ * Cross-Thing aggregation queries (with RQL filter and {@code groupBy}) are represented by a + * separate type and are not modelled here. + * + * @since 4.0.0 + */ +public interface TimeseriesQuery extends Jsonifiable { + + /** + * Hard upper bound on the number of paths a single query may request. Every path multiplies the + * per-path scan ceiling, so this keeps one request from multiplying that ceiling without limit; + * legitimate multi-property reads are well under it. Applies to cross-Thing queries too — those + * fan out over a whole namespace, so the bound matters there at least as much. + */ + int MAX_PATHS = 100; + + /** + * Returns a new {@code TimeseriesQuery} with the given fields. Optional fields may be + * {@code null}. + * + * @param thingId the Thing to query. + * @param paths the paths within the Thing whose timeseries are requested. Must not be + * {@code null}; may be empty. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @param step downsampling interval; may be {@code null} for raw queries. + * @param aggregation the aggregation function to apply per bucket; may be {@code null}. + * @param fillStrategy how empty buckets are filled when downsampling; may be {@code null}. + * @param limit a maximum number of data points to return; may be {@code null}. + * @param timezone the timezone used to align step boundaries; may be {@code null} (UTC is used). + * @return the new query. + * @throws NullPointerException if any non-{@code @Nullable} argument is {@code null}. + */ + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone) { + + return ImmutableTimeseriesQuery.of( + thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, null, null, + null); + } + + /** + * Returns a new {@code TimeseriesQuery} including the {@code percentile} parameter (required when + * {@code aggregation} is {@link Aggregation#PERCENTILE}). Optional fields may be {@code null}. + * + * @param thingId the Thing to query. + * @param paths the paths within the Thing whose timeseries are requested. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @param step downsampling interval; may be {@code null} for raw queries. + * @param aggregation the aggregation function to apply per bucket; may be {@code null}. + * @param fillStrategy how empty buckets are filled when downsampling; may be {@code null}. + * @param limit a maximum number of data points to return; may be {@code null}. + * @param timezone the timezone used to align step boundaries; may be {@code null} (UTC is used). + * @param percentile the percentile in {@code [0, 100]} for {@link Aggregation#PERCENTILE}; may be + * {@code null} for other aggregations. + * @return the new query. + * @throws NullPointerException if any non-{@code @Nullable} argument is {@code null}. + */ + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone, + @Nullable final Double percentile) { + + return ImmutableTimeseriesQuery.of( + thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, percentile, + null, null); + } + + /** + * Returns a new {@code TimeseriesQuery} including the {@code cursor} pagination parameter. + * Optional fields may be {@code null}. + *

+ * A {@code cursor} resumes a raw read after a previous page (keyset pagination) and is only valid + * for a single-path raw query — it must not be combined with {@code aggregation}, {@code step} or + * {@code fillStrategy}, nor with a multi-path request. Those combinations are rejected with + * {@link TimeseriesQueryInvalidException} (HTTP 400). + * + * @param thingId the Thing to query. + * @param paths the paths within the Thing whose timeseries are requested. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @param step downsampling interval; may be {@code null} for raw queries. + * @param aggregation the aggregation function to apply per bucket; may be {@code null}. + * @param fillStrategy how empty buckets are filled when downsampling; may be {@code null}. + * @param limit a maximum number of data points to return (the page size); may be {@code null}. + * @param timezone the timezone used to align step boundaries; may be {@code null} (UTC is used). + * @param percentile the percentile in {@code [0, 100]} for {@link Aggregation#PERCENTILE}; may be + * {@code null} for other aggregations. + * @param cursor an opaque pagination cursor from a previous response's {@code nextCursor}; may be + * {@code null} to start from the beginning of the range. + * @return the new query. + * @throws NullPointerException if any non-{@code @Nullable} argument is {@code null}. + */ + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone, + @Nullable final Double percentile, + @Nullable final String cursor) { + + return ImmutableTimeseriesQuery.of( + thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, percentile, + cursor, null); + } + + /** + * Returns a new {@code TimeseriesQuery} including the {@code order} parameter controlling the + * chronological direction of a raw read. Optional fields may be {@code null}. + *

+ * {@link SortOrder#DESC} (newest first) is only supported for raw reads — it must not be combined + * with {@code step}, {@code aggregation} or {@code fillStrategy}, which is rejected with + * {@link TimeseriesQueryInvalidException} (HTTP 400). {@code null} defaults to + * {@link SortOrder#ASC}. + * + * @param thingId the Thing to query. + * @param paths the paths within the Thing whose timeseries are requested. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @param step downsampling interval; may be {@code null} for raw queries. + * @param aggregation the aggregation function to apply per bucket; may be {@code null}. + * @param fillStrategy how empty buckets are filled when downsampling; may be {@code null}. + * @param limit a maximum number of data points to return (the page size); may be {@code null}. + * @param timezone the timezone used to align step boundaries; may be {@code null} (UTC is used). + * @param percentile the percentile in {@code [0, 100]} for {@link Aggregation#PERCENTILE}; may be + * {@code null} for other aggregations. + * @param cursor an opaque pagination cursor from a previous response's {@code nextCursor}; may be + * {@code null} to start from the beginning of the range. + * @param order the chronological order of a raw read; may be {@code null} for the default + * {@link SortOrder#ASC}. + * @return the new query. + * @throws NullPointerException if any non-{@code @Nullable} argument is {@code null}. + */ + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to, + @Nullable final Duration step, + @Nullable final Aggregation aggregation, + @Nullable final FillStrategy fillStrategy, + @Nullable final Integer limit, + @Nullable final ZoneId timezone, + @Nullable final Double percentile, + @Nullable final String cursor, + @Nullable final SortOrder order) { + + return ImmutableTimeseriesQuery.of( + thingId, paths, from, to, step, aggregation, fillStrategy, limit, timezone, percentile, + cursor, order); + } + + /** + * Returns a {@code TimeseriesQuery} with only the required fields populated (raw query). + * + * @param thingId the Thing to query. + * @param paths the paths within the Thing whose timeseries are requested. + * @param from inclusive lower bound of the time range. + * @param to exclusive upper bound of the time range. + * @return the new query. + * @throws NullPointerException if any argument is {@code null}. + */ + static TimeseriesQuery of(final ThingId thingId, + final List paths, + final Instant from, + final Instant to) { + + return ImmutableTimeseriesQuery.of( + thingId, paths, from, to, null, null, null, null, null, null, null, null); + } + + /** + * Parses a {@code TimeseriesQuery} from the given JSON object. + * + * @param jsonObject the JSON object representing a query. + * @return the parsed query. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if a field has an invalid value. + */ + static TimeseriesQuery fromJson(final JsonObject jsonObject) { + return ImmutableTimeseriesQuery.fromJson(jsonObject); + } + + /** + * @return the Thing this query targets. + */ + ThingId getThingId(); + + /** + * @return the paths within the Thing whose timeseries are requested. Always non-null; may be + * empty. The returned list is unmodifiable. + */ + List getPaths(); + + /** + * @return inclusive lower bound of the time range. + */ + Instant getFrom(); + + /** + * @return exclusive upper bound of the time range. + */ + Instant getTo(); + + /** + * @return the downsampling interval if set, or empty for a raw query. + */ + Optional getStep(); + + /** + * @return the aggregation function if set. + */ + Optional getAggregation(); + + /** + * @return the fill strategy for empty buckets if set. + */ + Optional getFillStrategy(); + + /** + * @return the maximum number of data points if set. + */ + Optional getLimit(); + + /** + * @return the timezone used for step alignment if set; UTC is the default when absent. + */ + Optional getTimezone(); + + /** + * @return the percentile in {@code [0, 100]} for {@link Aggregation#PERCENTILE}, if set. + */ + Optional getPercentile(); + + /** + * @return the opaque pagination cursor if set — a keyset position from a previous response's + * {@code nextCursor}, resuming a raw read after that page. Empty for the first page. See + * {@link TimeseriesCursor}. + */ + Optional getCursor(); + + /** + * @return the chronological order of a raw read if set. Empty means the default + * {@link SortOrder#ASC} (oldest first). + */ + Optional getOrder(); + + /** + * @return the tag filters restricting the query to points whose resolved tags match every + * given key/value pair (logical AND). Always non-null; may be empty (no tag filtering). The + * returned map is unmodifiable. + */ + Map getTagFilters(); + + /** + * Returns a copy of this query with the given tag filters. Only points whose stored tags match + * every key/value pair are returned. Modelled as a wither rather than another {@code of(...)} + * parameter to keep the factory signatures manageable. + * + * @param tagFilters the tag key/value pairs to match (logical AND); may be empty to clear. + * @return a copy of this query carrying the given tag filters. + * @throws NullPointerException if {@code tagFilters} is {@code null}. + */ + TimeseriesQuery withTagFilters(Map tagFilters); + + /** + * Returns a copy of this query restricted to {@code paths}. + *

+ * Used by the read path to narrow a multi-path query to the paths the caller may actually read, so + * a request for two properties where only one is granted returns that one rather than failing + * outright. + * + * @param paths the paths to keep; must be non-empty. + * @return the narrowed query. + * @throws NullPointerException if {@code paths} is {@code null}. + * @throws TimeseriesQueryInvalidException if {@code paths} is empty. + */ + TimeseriesQuery withPaths(List paths); + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link TimeseriesQuery}. + */ + final class JsonFields { + + /** + * The Thing ID. + */ + public static final JsonFieldDefinition THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The list of paths to retrieve, encoded as a JSON array of strings. + */ + public static final JsonFieldDefinition PATHS = + JsonFactory.newJsonArrayFieldDefinition("paths", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The ISO-8601 timestamp marking the inclusive start of the time range. + */ + public static final JsonFieldDefinition FROM = + JsonFactory.newStringFieldDefinition("from", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The ISO-8601 timestamp marking the exclusive end of the time range. + */ + public static final JsonFieldDefinition TO = + JsonFactory.newStringFieldDefinition("to", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The downsampling interval as an ISO-8601 duration string (e.g. {@code PT1H}). Optional. + */ + public static final JsonFieldDefinition STEP = + JsonFactory.newStringFieldDefinition("step", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The aggregation function name in wire format (e.g. {@code avg}). Optional. + */ + public static final JsonFieldDefinition AGGREGATION = + JsonFactory.newStringFieldDefinition("aggregation", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The fill-strategy name in wire format (e.g. {@code previous}). Optional. + */ + public static final JsonFieldDefinition FILL_STRATEGY = + JsonFactory.newStringFieldDefinition("fillStrategy", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The maximum number of data points to return. Optional. + */ + public static final JsonFieldDefinition LIMIT = + JsonFactory.newIntFieldDefinition("limit", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The timezone identifier for step alignment (e.g. {@code Europe/Berlin}). Optional. + */ + public static final JsonFieldDefinition TIMEZONE = + JsonFactory.newStringFieldDefinition("timezone", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The percentile value (0-100) for the {@code percentile} aggregation. Optional. + */ + public static final JsonFieldDefinition PERCENTILE = + JsonFactory.newDoubleFieldDefinition("percentile", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The opaque pagination cursor. Optional. + */ + public static final JsonFieldDefinition CURSOR = + JsonFactory.newStringFieldDefinition("cursor", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The sort order (wire form {@code asc}/{@code desc}). Optional; defaults to {@code asc}. + */ + public static final JsonFieldDefinition ORDER = + JsonFactory.newStringFieldDefinition("order", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The tag filters as a JSON object of key/value pairs. Optional. + */ + public static final JsonFieldDefinition TAG_FILTERS = + JsonFactory.newJsonObjectFieldDefinition("tagFilters", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryInvalidException.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryInvalidException.java new file mode 100644 index 00000000000..de7c09287d3 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryInvalidException.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.net.URI; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; +import javax.annotation.concurrent.NotThreadSafe; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeExceptionBuilder; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.JsonParsableException; +import org.eclipse.ditto.json.JsonObject; + +/** + * Thrown when a {@code TimeseriesQuery} is semantically invalid — for example a per-bucket + * aggregation without a {@code step}, or a {@code percentile} aggregation with a missing or + * out-of-range percentile value. Validation happens at the model layer so every transport + * (HTTP, WebSocket, Connectivity) rejects the same inputs with HTTP {@code 400}. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableException(errorCode = TimeseriesQueryInvalidException.ERROR_CODE) +public final class TimeseriesQueryInvalidException extends DittoRuntimeException { + + /** + * Error code of this exception. + */ + public static final String ERROR_CODE = "timeseries:query.invalid"; + + private static final String DEFAULT_DESCRIPTION = + "Check the timeseries query parameters (from, to, step, agg, fill, tz, percentile, limit)."; + + private static final URI DEFAULT_HREF = URI.create("https://github.com/eclipse-ditto/ditto/issues/2291"); + + private static final long serialVersionUID = -7034897190100176407L; + + private TimeseriesQueryInvalidException(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + + super(ERROR_CODE, HttpStatus.BAD_REQUEST, dittoHeaders, message, description, cause, href); + } + + /** + * A mutable builder for a {@code TimeseriesQueryInvalidException}. + * + * @param message the detail message describing what is invalid. + * @return the builder. + */ + public static Builder newBuilder(final String message) { + return new Builder(message); + } + + /** + * Constructs a new {@code TimeseriesQueryInvalidException} from the given message. + * + * @param message the detail message. + * @param dittoHeaders the headers of the command which resulted in this exception. + * @return the new exception. + * @throws NullPointerException if {@code dittoHeaders} is {@code null}. + */ + public static TimeseriesQueryInvalidException fromMessage(@Nullable final String message, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromMessage(message, dittoHeaders, new Builder()); + } + + /** + * Constructs a new {@code TimeseriesQueryInvalidException} from the message extracted from the + * given JSON object. + * + * @param jsonObject the JSON to read the message field from. + * @param dittoHeaders the headers of the command which resulted in this exception. + * @return the new exception. + * @throws NullPointerException if any argument is {@code null}. + */ + public static TimeseriesQueryInvalidException fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + return DittoRuntimeException.fromJson(jsonObject, dittoHeaders, new Builder()); + } + + @Override + public DittoRuntimeException setDittoHeaders(final DittoHeaders dittoHeaders) { + return new Builder() + .message(getMessage()) + .description(getDescription().orElse(null)) + .cause(getCause()) + .href(getHref().orElse(null)) + .dittoHeaders(dittoHeaders) + .build(); + } + + /** + * A mutable builder with a fluent API for a {@link TimeseriesQueryInvalidException}. + */ + @NotThreadSafe + public static final class Builder extends DittoRuntimeExceptionBuilder { + + private Builder() { + description(DEFAULT_DESCRIPTION); + href(DEFAULT_HREF); + } + + private Builder(final String message) { + this(); + message(message); + } + + @Override + protected TimeseriesQueryInvalidException doBuild(final DittoHeaders dittoHeaders, + @Nullable final String message, + @Nullable final String description, + @Nullable final Throwable cause, + @Nullable final URI href) { + return new TimeseriesQueryInvalidException(dittoHeaders, message, description, cause, href); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryResult.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryResult.java new file mode 100644 index 00000000000..6c52ef20920 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesQueryResult.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.util.List; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.things.model.ThingId; + +/** + * Result of a single-Thing, single-path timeseries query. + *

+ * Multi-path queries produce one {@code TimeseriesQueryResult} per path; the service-level + * response composes them into the multi-series wire shape described in the concept document. + * + * @since 4.0.0 + */ +public interface TimeseriesQueryResult extends Jsonifiable { + + /** + * Returns a new {@code TimeseriesQueryResult}. + * + * @param thingId the Thing the result belongs to. + * @param path the path within the Thing this result corresponds to. + * @param query the originating query. + * @param meta metadata about the data array. + * @param data the timeseries data values, in chronological order. May be empty. + * @return the new result. + * @throws NullPointerException if any argument is {@code null}. + */ + static TimeseriesQueryResult of(final ThingId thingId, + final org.eclipse.ditto.json.JsonPointer path, + final TimeseriesQuery query, + final TimeseriesResultMeta meta, + final List data) { + + return ImmutableTimeseriesQueryResult.of(thingId, path, query, meta, data); + } + + /** + * Parses a {@code TimeseriesQueryResult} from the given JSON object. + * + * @param jsonObject the JSON object. + * @return the parsed result. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if a field has an invalid value. + */ + static TimeseriesQueryResult fromJson(final JsonObject jsonObject) { + return ImmutableTimeseriesQueryResult.fromJson(jsonObject); + } + + /** + * @return the Thing the result belongs to. + */ + ThingId getThingId(); + + /** + * @return the path within the Thing this result corresponds to. + */ + org.eclipse.ditto.json.JsonPointer getPath(); + + /** + * @return the originating query. + */ + TimeseriesQuery getQuery(); + + /** + * @return metadata about the data array. + */ + TimeseriesResultMeta getMeta(); + + /** + * @return the timeseries data values, in chronological order. The returned list is unmodifiable. + */ + List getData(); + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link TimeseriesQueryResult}. + */ + final class JsonFields { + + /** + * The Thing ID. + */ + public static final JsonFieldDefinition THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The path within the Thing. + */ + public static final JsonFieldDefinition PATH = + JsonFactory.newStringFieldDefinition("path", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The originating query, as a JSON object. + */ + public static final JsonFieldDefinition QUERY = + JsonFactory.newJsonObjectFieldDefinition("query", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The result metadata, as a JSON object — note the wire-level field is {@code result}, + * matching the concept document. + */ + public static final JsonFieldDefinition META = + JsonFactory.newJsonObjectFieldDefinition("result", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The data array. + */ + public static final JsonFieldDefinition DATA = + JsonFactory.newJsonArrayFieldDefinition("data", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesResultMeta.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesResultMeta.java new file mode 100644 index 00000000000..6fc807fc650 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/TimeseriesResultMeta.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; + +/** + * Metadata about the {@code data} array in a {@link TimeseriesQueryResult}: how many points were + * returned, the unit (when available), and the JSON {@code dataType} of the values + * ({@code "number"}, {@code "string"}, {@code "boolean"}, etc.). + * + * @since 4.0.0 + */ +public interface TimeseriesResultMeta extends Jsonifiable { + + /** + * Returns a new {@code TimeseriesResultMeta}. + * + * @param count the number of data points returned. + * @param unit the unit of the values, or {@code null} if not declared. + * @param dataType a token describing the JSON type of values, e.g. {@code "number"}. + * @return the new meta. + * @throws NullPointerException if {@code dataType} is {@code null}. + * @throws IllegalArgumentException if {@code count} is negative. + */ + static TimeseriesResultMeta of(final int count, + @Nullable final String unit, + final String dataType) { + + return ImmutableTimeseriesResultMeta.of(count, unit, dataType, Collections.emptyMap(), null, null); + } + + /** + * Returns a new {@code TimeseriesResultMeta} including the resolved tags and pagination metadata. + * + * @param count the number of data points returned. + * @param unit the unit of the values, or {@code null} if not declared. + * @param dataType a token describing the JSON type of values, e.g. {@code "number"}. + * @param tags the resolved tags of the series (as stored with the points); may be empty but never + * {@code null}. Empty for aggregated reads, which group away the per-point metadata. + * @param hasMore whether more data matched the query than this page returned; {@code null} for a + * non-paginated result (e.g. an aggregated read), in which case it is omitted from the wire form. + * @param nextCursor an opaque cursor to fetch the next page, or {@code null} when the page is the + * last one. Present exactly when {@code hasMore} is {@code true}. + * @return the new meta. + * @throws NullPointerException if {@code dataType} or {@code tags} is {@code null}. + * @throws IllegalArgumentException if {@code count} is negative. + */ + static TimeseriesResultMeta of(final int count, + @Nullable final String unit, + final String dataType, + final Map tags, + @Nullable final Boolean hasMore, + @Nullable final String nextCursor) { + + return ImmutableTimeseriesResultMeta.of(count, unit, dataType, tags, hasMore, nextCursor); + } + + /** + * Parses a {@code TimeseriesResultMeta} from the given JSON object. + * + * @param jsonObject the JSON object. + * @return the parsed meta. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + */ + static TimeseriesResultMeta fromJson(final JsonObject jsonObject) { + return ImmutableTimeseriesResultMeta.fromJson(jsonObject); + } + + /** + * @return the number of data points returned. + */ + int getCount(); + + /** + * @return the unit string if present, or empty. + */ + Optional getUnit(); + + /** + * @return a token describing the JSON type of values, e.g. {@code "number"}. + */ + String getDataType(); + + /** + * @return the resolved tags of the series, as stored with the data points. Always non-null; may + * be empty (no declared tags, or an aggregated read). The returned map is unmodifiable. + */ + Map getTags(); + + /** + * @return whether more data matched the query than this page returned. Present only for + * paginated (raw) reads: {@code true} means another page follows (see {@link #getNextCursor()}), + * {@code false} means this is the last page. Empty for non-paginated results. + */ + Optional getHasMore(); + + /** + * @return the opaque cursor for the next page if more data matched than this page returned, or + * empty when this is the last page. Pass it back as the query's {@code cursor} to continue. See + * {@link TimeseriesCursor}. + */ + Optional getNextCursor(); + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link TimeseriesResultMeta}. + */ + final class JsonFields { + + /** + * The count of returned data points. + */ + public static final JsonFieldDefinition COUNT = + JsonFactory.newIntFieldDefinition("count", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The unit string. Optional. + */ + public static final JsonFieldDefinition UNIT = + JsonFactory.newStringFieldDefinition("unit", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The data-type token. + */ + public static final JsonFieldDefinition DATA_TYPE = + JsonFactory.newStringFieldDefinition("dataType", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The resolved tags of the series. Optional; omitted when empty. + */ + public static final JsonFieldDefinition TAGS = + JsonFactory.newJsonObjectFieldDefinition("tags", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * Whether more data is available. Optional; present only for paginated (raw) reads. + */ + public static final JsonFieldDefinition HAS_MORE = + JsonFactory.newBooleanFieldDefinition("hasMore", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The opaque cursor to fetch the next page. Optional; present only when more data is available. + */ + public static final JsonFieldDefinition NEXT_CURSOR = + JsonFactory.newStringFieldDefinition("nextCursor", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/WotTimeseriesAnnotation.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/WotTimeseriesAnnotation.java new file mode 100644 index 00000000000..b1b01fc8c7d --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/WotTimeseriesAnnotation.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import java.util.Map; +import java.util.Optional; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.json.Jsonifiable; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; + +/** + * Parsed representation of a {@code ditto:timeseries} extension annotation declared on a WoT + * property in a ThingModel. + *

+ * Tag values are stored verbatim as declared in the WoT model — placeholder expressions like + * {@code "{{ thing-json:attributes/building }}"} are not resolved here; that happens at ingestion + * time, on the Things-service side, against the live Thing JSON. + * + * @since 4.0.0 + */ +public interface WotTimeseriesAnnotation extends Jsonifiable { + + /** + * The local (un-prefixed) name of the WoT extension term under which this annotation lives in + * a property schema. The actual key is {@code :timeseries}, where {@code } is + * whatever the ThingModel's {@code @context} binds the Ditto WoT extension IRI to. + */ + String EXTENSION_LOCAL_NAME = "timeseries"; + + /** + * The WoT extension key under the conventional {@code ditto} prefix. Use + * {@link #findInProperty(org.eclipse.ditto.json.JsonObject, CharSequence)} with a prefix + * resolved from the model's {@code @context} when the prefix may differ. + */ + String EXTENSION_KEY = "ditto:" + EXTENSION_LOCAL_NAME; + + /** + * Returns a new {@code WotTimeseriesAnnotation}. + * + * @param ingest the ingestion mode. + * @param tags the declared tag map (keys to placeholder or constant string values). Must not + * be {@code null}; may be empty. + * @return the new annotation. + * @throws NullPointerException if any argument is {@code null}. + */ + static WotTimeseriesAnnotation of(final Ingest ingest, final Map tags) { + return ImmutableWotTimeseriesAnnotation.of(ingest, tags); + } + + /** + * Parses a {@code WotTimeseriesAnnotation} from the JSON object value of a {@code + * ditto:timeseries} extension entry. + * + * @param jsonObject the JSON object that is the value of {@code ditto:timeseries}. + * @return the parsed annotation. + * @throws NullPointerException if {@code jsonObject} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if {@code ingest} is missing. + * @throws org.eclipse.ditto.json.JsonParseException if {@code ingest} has an unknown value or + * a tag value is not a JSON string. + */ + static WotTimeseriesAnnotation fromJson(final JsonObject jsonObject) { + return ImmutableWotTimeseriesAnnotation.fromJson(jsonObject); + } + + /** + * Convenience: extract the {@code ditto:timeseries} annotation from the JSON of a WoT property + * (i.e. the value at {@code properties.}). Returns empty when the property has no + * {@code ditto:timeseries} declaration. + * + * @param propertySchema the WoT property schema JSON. + * @return the parsed annotation, or empty. + * @throws NullPointerException if {@code propertySchema} is {@code null}. + */ + static Optional findInProperty(final JsonObject propertySchema) { + return ImmutableWotTimeseriesAnnotation.findInProperty(propertySchema, EXTENSION_KEY); + } + + /** + * Like {@link #findInProperty(JsonObject)}, but reads the annotation under an explicit + * extension key. Use this with a key built from the prefix the ThingModel's {@code @context} + * binds the Ditto WoT extension to (e.g. {@code "ditto:timeseries"}), so models that alias the + * extension to a non-default prefix still resolve. + * + * @param propertySchema the WoT property schema JSON. + * @param extensionKey the fully-qualified extension key to look up. + * @return the parsed annotation, or empty. + * @throws NullPointerException if any argument is {@code null}. + */ + static Optional findInProperty(final JsonObject propertySchema, + final CharSequence extensionKey) { + return ImmutableWotTimeseriesAnnotation.findInProperty(propertySchema, extensionKey); + } + + /** + * @return the ingestion mode declared by this annotation. + */ + Ingest getIngest(); + + /** + * @return the declared tag map, keyed by tag name. Always non-null; may be empty. The returned + * map is unmodifiable. + */ + Map getTags(); + + /** + * @return {@code true} when {@link #getIngest()} is {@link Ingest#ALL}; {@code false} + * otherwise (i.e. {@link Ingest#NONE}). + */ + default boolean isIngestEnabled() { + return getIngest() == Ingest.ALL; + } + + @Override + default JsonSchemaVersion[] getSupportedSchemaVersions() { + return new JsonSchemaVersion[] {JsonSchemaVersion.V_2}; + } + + /** + * JSON field definitions for {@link WotTimeseriesAnnotation}. + */ + final class JsonFields { + + /** + * The ingest-mode token (e.g. {@code "ALL"}). + */ + public static final JsonFieldDefinition INGEST = + JsonFactory.newStringFieldDefinition("ingest", FieldType.REGULAR, JsonSchemaVersion.V_2); + + /** + * The tag map. Optional; absent or empty means no declared tags. + */ + public static final JsonFieldDefinition TAGS = + JsonFactory.newJsonObjectFieldDefinition("tags", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseries.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseries.java new file mode 100644 index 00000000000..9b4c7b7be9f --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseries.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommand; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.FeatureToggle; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommand; +import org.eclipse.ditto.base.model.signals.commands.CommandJsonDeserializer; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; + +/** + * Command retrieving a timeseries aggregation across many Things of one namespace. + *

+ * Deliberately not a {@code WithEntityId} command: it targets no single Thing, so it cannot + * be routed through the per-Thing timeseries shard region the way {@link RetrieveTimeseries} is. + * It is dispatched to the namespace-agnostic aggregate handler instead — the same shape thing-search + * uses for its query commands. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommand(typePrefix = TimeseriesCommand.TYPE_PREFIX, name = RetrieveAggregatedTimeseries.NAME) +public final class RetrieveAggregatedTimeseries extends AbstractCommand + implements TimeseriesCommand { + + /** + * Name of the {@code RetrieveAggregatedTimeseries} command. + */ + public static final String NAME = "retrieveAggregatedTimeseries"; + + /** + * Type of this command, used for routing. + */ + public static final String TYPE = TYPE_PREFIX + NAME; + + private static final JsonFieldDefinition JSON_QUERY = + JsonFactory.newJsonObjectFieldDefinition("query", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private final CrossThingTimeseriesQuery query; + + private RetrieveAggregatedTimeseries(final CrossThingTimeseriesQuery query, + final DittoHeaders dittoHeaders) { + + super(TYPE, FeatureToggle.checkTimeseriesFeatureEnabled(TYPE, dittoHeaders)); + this.query = query; + } + + /** + * Returns a new {@code RetrieveAggregatedTimeseries} command. + * + * @param query the cross-Thing query to execute. + * @param dittoHeaders the headers of the command. + * @return the command. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveAggregatedTimeseries of(final CrossThingTimeseriesQuery query, + final DittoHeaders dittoHeaders) { + + checkNotNull(query, "query"); + checkNotNull(dittoHeaders, "dittoHeaders"); + return new RetrieveAggregatedTimeseries(query, dittoHeaders); + } + + /** + * Creates a {@code RetrieveAggregatedTimeseries} from its JSON representation. + * + * @param jsonObject the JSON object. + * @param dittoHeaders the headers of the command. + * @return the parsed command. + * @throws NullPointerException if any argument is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if the {@code query} field is missing. + */ + public static RetrieveAggregatedTimeseries fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + return new CommandJsonDeserializer(TYPE, jsonObject).deserialize(() -> { + final JsonObject queryJson = jsonObject.getValueOrThrow(JSON_QUERY); + return new RetrieveAggregatedTimeseries(CrossThingTimeseriesQuery.fromJson(queryJson), + dittoHeaders); + }); + } + + /** + * @return the cross-Thing query carried by this command. + */ + public CrossThingTimeseriesQuery getQuery() { + return query; + } + + /** + * @return the namespace this command aggregates over. + */ + public String getNamespace() { + return query.getNamespace(); + } + + @Override + public String getTypePrefix() { + return TYPE_PREFIX; + } + + @Override + public Category getCategory() { + return Category.QUERY; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public RetrieveAggregatedTimeseries setDittoHeaders(final DittoHeaders dittoHeaders) { + return of(query, dittoHeaders); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + jsonObjectBuilder.set(JSON_QUERY, query.toJson(), schemaVersion.and(thePredicate)); + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final RetrieveAggregatedTimeseries that = (RetrieveAggregatedTimeseries) obj; + return that.canEqual(this) && Objects.equals(query, that.query) && super.equals(that); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof RetrieveAggregatedTimeseries; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), query); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + ", query=" + query + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponse.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponse.java new file mode 100644 index 00000000000..33d40bd0955 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponse.java @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommandResponse; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommandResponse; +import org.eclipse.ditto.base.model.signals.commands.WithEntity; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonKey; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; + +/** + * Response to {@link RetrieveAggregatedTimeseries}. Carries one + * {@link AggregatedTimeseriesResult} per {@code (group, path)} combination. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommandResponse(type = RetrieveAggregatedTimeseriesResponse.TYPE) +public final class RetrieveAggregatedTimeseriesResponse + extends AbstractCommandResponse + implements TimeseriesCommandResponse, + WithEntity { + + /** + * Type of this response. + */ + public static final String TYPE = + TimeseriesCommandResponse.TYPE_PREFIX + RetrieveAggregatedTimeseries.NAME; + + private static final JsonFieldDefinition JSON_NAMESPACE = + JsonFactory.newStringFieldDefinition("namespace", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_RESULTS = + JsonFactory.newJsonArrayFieldDefinition("results", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_AUTHORIZATION = + JsonFactory.newJsonObjectFieldDefinition("authorization", FieldType.REGULAR, + JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_CONTRIBUTING = + JsonFactory.newIntFieldDefinition("contributingThings", FieldType.REGULAR, + JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_EXCLUDED = + JsonFactory.newIntFieldDefinition("excludedThings", FieldType.REGULAR, + JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_PARTIAL = + JsonFactory.newBooleanFieldDefinition("partial", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_WITHHELD_BY_PATH = + JsonFactory.newJsonObjectFieldDefinition("withheldByPath", FieldType.REGULAR, + JsonSchemaVersion.V_2); + + private final String namespace; + private final List results; + private final int contributingThings; + private final int excludedThings; + private final Map withheldByPath; + + private RetrieveAggregatedTimeseriesResponse(final String namespace, + final List results, + final int contributingThings, + final int excludedThings, + final Map withheldByPath, + final DittoHeaders dittoHeaders) { + + super(TYPE, HttpStatus.OK, dittoHeaders); + this.namespace = namespace; + this.results = results; + this.contributingThings = contributingThings; + this.excludedThings = excludedThings; + this.withheldByPath = withheldByPath; + } + + /** + * Returns a new {@code RetrieveAggregatedTimeseriesResponse}. + * + * @param namespace the namespace that was aggregated. + * @param results one result per {@code (group, path)} combination. + * @param dittoHeaders the headers of the response. + * @return the response. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveAggregatedTimeseriesResponse of(final String namespace, + final List results, + final DittoHeaders dittoHeaders) { + + return of(namespace, results, 0, 0, Collections.emptyMap(), dittoHeaders); + } + + /** + * Returns a new {@code RetrieveAggregatedTimeseriesResponse} carrying the authorization summary. + * + * @param namespace the namespace that was aggregated. + * @param results one result per {@code (group, path)} combination. + * @param contributingThings how many Things had data matching the query and were permitted. + * @param excludedThings how many Things had matching data but were withheld because the caller + * may not read them. A non-zero value means the aggregates are computed over a subset — reported + * explicitly so a partial answer cannot be mistaken for a complete one. + * @param dittoHeaders the headers of the response. + * @return the response. + * @throws NullPointerException if {@code namespace}, {@code results} or {@code dittoHeaders} is + * {@code null}. + * @throws IllegalArgumentException if either count is negative. + */ + public static RetrieveAggregatedTimeseriesResponse of(final String namespace, + final List results, + final int contributingThings, + final int excludedThings, + final Map withheldByPath, + final DittoHeaders dittoHeaders) { + + checkNotNull(namespace, "namespace"); + checkNotNull(results, "results"); + checkNotNull(withheldByPath, "withheldByPath"); + checkNotNull(dittoHeaders, "dittoHeaders"); + if (contributingThings < 0 || excludedThings < 0) { + throw new IllegalArgumentException("Thing counts must not be negative, were <" + + contributingThings + "> and <" + excludedThings + ">."); + } + return new RetrieveAggregatedTimeseriesResponse(namespace, + Collections.unmodifiableList(new ArrayList<>(results)), contributingThings, + excludedThings, Collections.unmodifiableMap(new LinkedHashMap<>(withheldByPath)), + dittoHeaders); + } + + /** + * Creates a {@code RetrieveAggregatedTimeseriesResponse} from its JSON representation. + * + * @param jsonObject the JSON object. + * @param dittoHeaders the headers of the response. + * @return the parsed response. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveAggregatedTimeseriesResponse fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + checkNotNull(jsonObject, "jsonObject"); + checkNotNull(dittoHeaders, "dittoHeaders"); + + final String namespace = jsonObject.getValueOrThrow(JSON_NAMESPACE); + final JsonArray resultsArray = jsonObject.getValueOrThrow(JSON_RESULTS); + final List results = new ArrayList<>(resultsArray.getSize()); + for (final JsonValue value : resultsArray) { + results.add(AggregatedTimeseriesResult.fromJson(value.asObject())); + } + final JsonObject auth = jsonObject.getValue(JSON_AUTHORIZATION).orElseGet(JsonObject::empty); + return of(namespace, results, + auth.getValue(JSON_CONTRIBUTING).orElse(0), + auth.getValue(JSON_EXCLUDED).orElse(0), + withheldFromJson(auth.getValue(JSON_WITHHELD_BY_PATH).orElseGet(JsonObject::empty)), + dittoHeaders); + } + + /** + * @return the aggregated results. The list is unmodifiable. + */ + public List getResults() { + return results; + } + + /** + * @return the namespace that was aggregated. + */ + public String getNamespace() { + return namespace; + } + + /** + * @return how many Things contributed data to these aggregates. + */ + public int getContributingThings() { + return contributingThings; + } + + /** + * @return how many Things had matching data but were withheld for lack of permission. + */ + public int getExcludedThings() { + return excludedThings; + } + + /** + * @return whether the aggregates cover only a subset of the matching data, i.e. at least one + * Thing was excluded for lack of permission. + */ + public boolean isPartial() { + return !withheldByPath.isEmpty(); + } + + /** + * @return per requested path, how many Things had matching data but were withheld from that path. + * Only non-zero entries are present. Because {@code READ_TS} is grantable per property, a Thing can + * appear here for one path while still contributing to another. + */ + public Map getWithheldByPath() { + return withheldByPath; + } + + private JsonObject authorizationJson() { + return JsonFactory.newObjectBuilder() + .set(JSON_CONTRIBUTING, contributingThings) + .set(JSON_EXCLUDED, excludedThings) + .set(JSON_PARTIAL, isPartial()) + .set(JSON_WITHHELD_BY_PATH, withheldByPathJson()) + .build(); + } + + private JsonObject withheldByPathJson() { + final JsonObjectBuilder builder = JsonFactory.newObjectBuilder(); + for (final Map.Entry entry : withheldByPath.entrySet()) { + // JsonKey.of, never a raw String: the keys are JSON pointers, and + // JsonObjectBuilder.set(CharSequence, ...) interprets a slash-bearing key as a *pointer*, + // which would silently nest "/features/x/y" into {"features":{"x":{"y":…}}} instead of + // keeping it a flat key. Same reason ReadGrant.toJson() wraps its path keys. + builder.set(JsonKey.of(entry.getKey()), entry.getValue()); + } + return builder.build(); + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public RetrieveAggregatedTimeseriesResponse setDittoHeaders(final DittoHeaders dittoHeaders) { + return of(namespace, results, contributingThings, excludedThings, withheldByPath, + dittoHeaders); + } + + @Override + public JsonValue getEntity(final JsonSchemaVersion schemaVersion) { + // Unlike the single-Thing endpoint (whose body is a bare array), a cross-Thing body is an + // object so it can carry the authorization summary alongside the series. An aggregate computed + // over a permitted subset must not be indistinguishable from one computed over everything — + // the caller cannot detect that from the numbers alone, so it is stated. + return JsonFactory.newObjectBuilder() + .set(JSON_RESULTS, resultsToArray()) + .set(JSON_AUTHORIZATION, authorizationJson()) + .build(); + } + + @Override + public RetrieveAggregatedTimeseriesResponse setEntity(final JsonValue entity) { + checkNotNull(entity, "entity"); + if (!entity.isObject()) { + throw new IllegalArgumentException("Expected a JSON object for " + + "RetrieveAggregatedTimeseriesResponse entity, got <" + entity + ">."); + } + final JsonObject entityObject = entity.asObject(); + final JsonArray resultsArray = entityObject.getValue(JSON_RESULTS).orElseGet(JsonArray::empty); + final List newResults = new ArrayList<>(resultsArray.getSize()); + for (final JsonValue value : resultsArray) { + newResults.add(AggregatedTimeseriesResult.fromJson(value.asObject())); + } + final JsonObject auth = entityObject.getValue(JSON_AUTHORIZATION).orElseGet(JsonObject::empty); + return of(namespace, newResults, + auth.getValue(JSON_CONTRIBUTING).orElse(contributingThings), + auth.getValue(JSON_EXCLUDED).orElse(excludedThings), + auth.getValue(JSON_WITHHELD_BY_PATH) + .map(RetrieveAggregatedTimeseriesResponse::withheldFromJson) + .orElse(withheldByPath), + getDittoHeaders()); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(JSON_NAMESPACE, namespace, predicate); + jsonObjectBuilder.set(JSON_RESULTS, resultsToArray(), predicate); + jsonObjectBuilder.set(JSON_AUTHORIZATION, authorizationJson(), predicate); + } + + private static Map withheldFromJson(final JsonObject json) { + final Map result = new LinkedHashMap<>(); + for (final JsonField field : json) { + final JsonValue value = field.getValue(); + if (value.isNumber()) { + result.put(field.getKeyName(), value.asInt()); + } + } + return result; + } + + private JsonArray resultsToArray() { + final JsonArrayBuilder builder = JsonFactory.newArrayBuilder(); + for (final AggregatedTimeseriesResult result : results) { + builder.add(result.toJson()); + } + return builder.build(); + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final RetrieveAggregatedTimeseriesResponse that = (RetrieveAggregatedTimeseriesResponse) obj; + return that.canEqual(this) + && Objects.equals(namespace, that.namespace) + && Objects.equals(results, that.results) + && contributingThings == that.contributingThings + && excludedThings == that.excludedThings + && Objects.equals(withheldByPath, that.withheldByPath) + && super.equals(that); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof RetrieveAggregatedTimeseriesResponse; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), namespace, results, contributingThings, excludedThings, withheldByPath); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", namespace=" + namespace + + ", results=" + results + + ", contributingThings=" + contributingThings + + ", excludedThings=" + excludedThings + + ", withheldByPath=" + withheldByPath + + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseries.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseries.java new file mode 100644 index 00000000000..953d59302d5 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseries.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.entity.id.WithEntityId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommand; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.FeatureToggle; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommand; +import org.eclipse.ditto.base.model.signals.commands.CommandJsonDeserializer; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; + +/** + * Command issued against the Timeseries service to retrieve historical data for a single Thing + * over a specified time range, optionally with downsampling, aggregation and a fill strategy. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommand(typePrefix = TimeseriesCommand.TYPE_PREFIX, name = RetrieveTimeseries.NAME) +public final class RetrieveTimeseries extends AbstractCommand + implements TimeseriesCommand, WithEntityId { + + /** + * Name of the {@code RetrieveTimeseries} command. + */ + public static final String NAME = "retrieveTimeseries"; + + /** + * Type of this command, used for routing. + */ + public static final String TYPE = TYPE_PREFIX + NAME; + + private static final JsonFieldDefinition JSON_QUERY = + JsonFactory.newJsonObjectFieldDefinition("query", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private final TimeseriesQuery query; + + private RetrieveTimeseries(final TimeseriesQuery query, final DittoHeaders dittoHeaders) { + super(TYPE, FeatureToggle.checkTimeseriesFeatureEnabled(TYPE, dittoHeaders)); + this.query = query; + } + + /** + * Returns a new {@code RetrieveTimeseries} command. + * + * @param query the timeseries query to execute. + * @param dittoHeaders the headers of the command. + * @return the command. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveTimeseries of(final TimeseriesQuery query, final DittoHeaders dittoHeaders) { + checkNotNull(query, "query"); + checkNotNull(dittoHeaders, "dittoHeaders"); + return new RetrieveTimeseries(query, dittoHeaders); + } + + /** + * Creates a {@code RetrieveTimeseries} from its JSON representation. + * + * @param jsonObject the JSON object. + * @param dittoHeaders the headers of the command. + * @return the parsed command. + * @throws NullPointerException if {@code jsonObject} or {@code dittoHeaders} is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if the {@code query} field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if {@code query} cannot be parsed. + */ + public static RetrieveTimeseries fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + return new CommandJsonDeserializer(TYPE, jsonObject).deserialize(() -> { + final JsonObject queryJson = jsonObject.getValueOrThrow(JSON_QUERY); + final TimeseriesQuery parsedQuery = TimeseriesQuery.fromJson(queryJson); + return new RetrieveTimeseries(parsedQuery, dittoHeaders); + }); + } + + /** + * @return the timeseries query carried by this command. + */ + public TimeseriesQuery getQuery() { + return query; + } + + @Override + public String getTypePrefix() { + return TYPE_PREFIX; + } + + @Override + public ThingId getEntityId() { + return query.getThingId(); + } + + @Override + public JsonPointer getResourcePath() { + // Fine-grained policy enforcement uses each path's resource separately at the service + // level. The command's overall resource path stays at root; per-path enforcement is the + // service's responsibility. + return JsonPointer.empty(); + } + + @Override + public Category getCategory() { + return Category.QUERY; + } + + @Override + public RetrieveTimeseries setDittoHeaders(final DittoHeaders dittoHeaders) { + // Via the public factory, so the feature toggle is re-checked here as in + // MergeThing.setDittoHeaders. + return of(query, dittoHeaders); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(JSON_QUERY, query.toJson(), predicate); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof RetrieveTimeseries; + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final RetrieveTimeseries that = (RetrieveTimeseries) obj; + return that.canEqual(this) && Objects.equals(query, that.query) && super.equals(that); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), query); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + ", query=" + query + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponse.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponse.java new file mode 100644 index 00000000000..90b9c0e8532 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponse.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.entity.id.WithEntityId; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonParsableCommandResponse; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.AbstractCommandResponse; +import org.eclipse.ditto.base.model.signals.commands.WithEntity; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonArrayBuilder; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonField; +import org.eclipse.ditto.json.JsonFieldDefinition; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; + +/** + * Response to {@link RetrieveTimeseries}. Carries one {@link TimeseriesQueryResult} per requested + * path, in the same order the paths appeared in the originating query. + * + * @since 4.0.0 + */ +@Immutable +@JsonParsableCommandResponse(type = RetrieveTimeseriesResponse.TYPE) +public final class RetrieveTimeseriesResponse extends AbstractCommandResponse + implements TimeseriesCommandResponse, WithEntityId, + WithEntity { + + /** + * Type of this response. + */ + public static final String TYPE = TimeseriesCommandResponse.TYPE_PREFIX + RetrieveTimeseries.NAME; + + private static final JsonFieldDefinition JSON_THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private static final JsonFieldDefinition JSON_RESULTS = + JsonFactory.newJsonArrayFieldDefinition("results", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private final ThingId thingId; + private final List results; + + private RetrieveTimeseriesResponse(final ThingId thingId, + final List results, + final DittoHeaders dittoHeaders) { + + super(TYPE, HttpStatus.OK, dittoHeaders); + this.thingId = thingId; + this.results = results; + } + + /** + * Returns a new {@code RetrieveTimeseriesResponse}. + * + * @param thingId the Thing the results belong to. + * @param results the per-path query results, in chronological order within each path. + * @param dittoHeaders the headers of the response. + * @return the response. + * @throws NullPointerException if any argument is {@code null}. + */ + public static RetrieveTimeseriesResponse of(final ThingId thingId, + final List results, + final DittoHeaders dittoHeaders) { + + checkNotNull(thingId, "thingId"); + checkNotNull(results, "results"); + checkNotNull(dittoHeaders, "dittoHeaders"); + return new RetrieveTimeseriesResponse( + thingId, + Collections.unmodifiableList(new ArrayList<>(results)), + dittoHeaders); + } + + /** + * Parses a {@code RetrieveTimeseriesResponse} from JSON. + * + * @param jsonObject the JSON object. + * @param dittoHeaders the headers of the response. + * @return the parsed response. + * @throws NullPointerException if any argument is {@code null}. + * @throws org.eclipse.ditto.json.JsonMissingFieldException if a required field is missing. + * @throws org.eclipse.ditto.json.JsonParseException if a value is malformed. + */ + public static RetrieveTimeseriesResponse fromJson(final JsonObject jsonObject, + final DittoHeaders dittoHeaders) { + + checkNotNull(jsonObject, "jsonObject"); + checkNotNull(dittoHeaders, "dittoHeaders"); + + final ThingId thingId = ThingId.of(jsonObject.getValueOrThrow(JSON_THING_ID)); + final JsonArray resultsArray = jsonObject.getValueOrThrow(JSON_RESULTS); + final List results = new ArrayList<>(resultsArray.getSize()); + for (final JsonValue value : resultsArray) { + results.add(TimeseriesQueryResult.fromJson(value.asObject())); + } + return of(thingId, results, dittoHeaders); + } + + /** + * @return the per-path query results. The list is unmodifiable. + */ + public List getResults() { + return results; + } + + @Override + public ThingId getEntityId() { + return thingId; + } + + @Override + public JsonPointer getResourcePath() { + return JsonPointer.empty(); + } + + @Override + public RetrieveTimeseriesResponse setDittoHeaders(final DittoHeaders dittoHeaders) { + return of(thingId, results, dittoHeaders); + } + + @Override + public JsonValue getEntity(final JsonSchemaVersion schemaVersion) { + // For HTTP we expose only the per-path results array as the entity body — the thingId is + // already in the URL and the headers carry correlation info, so duplicating them here + // just bloats the payload. Clients (UI, curl) receive `[{thingId, path, query, result, data}, ...]`. + final JsonArrayBuilder resultsBuilder = JsonFactory.newArrayBuilder(); + for (final TimeseriesQueryResult result : results) { + resultsBuilder.add(result.toJson()); + } + return resultsBuilder.build(); + } + + @Override + public RetrieveTimeseriesResponse setEntity(final JsonValue entity) { + // Used by Pekko HTTP / akka-http content negotiation pipelines that want to swap the + // entity for a serialized form. We round-trip through fromJson on a synthesised envelope + // so the parsing rules stay in one place. + checkNotNull(entity, "entity"); + if (!entity.isArray()) { + throw new IllegalArgumentException( + "Expected a JSON array for RetrieveTimeseriesResponse entity, got <" + entity + ">."); + } + final List newResults = new ArrayList<>(entity.asArray().getSize()); + for (final JsonValue value : entity.asArray()) { + newResults.add(TimeseriesQueryResult.fromJson(value.asObject())); + } + return of(thingId, newResults, getDittoHeaders()); + } + + @Override + protected void appendPayload(final JsonObjectBuilder jsonObjectBuilder, + final JsonSchemaVersion schemaVersion, + final Predicate thePredicate) { + + final Predicate predicate = schemaVersion.and(thePredicate); + jsonObjectBuilder.set(JSON_THING_ID, thingId.toString(), predicate); + + final JsonArrayBuilder resultsBuilder = JsonFactory.newArrayBuilder(); + for (final TimeseriesQueryResult result : results) { + resultsBuilder.add(result.toJson()); + } + jsonObjectBuilder.set(JSON_RESULTS, resultsBuilder.build(), predicate); + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final RetrieveTimeseriesResponse that = (RetrieveTimeseriesResponse) obj; + return that.canEqual(this) && + Objects.equals(thingId, that.thingId) && + Objects.equals(results, that.results) && + super.equals(that); + } + + @Override + protected boolean canEqual(@Nullable final Object other) { + return other instanceof RetrieveTimeseriesResponse; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), thingId, results); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + super.toString() + + ", thingId=" + thingId + ", results=" + results + "]"; + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommand.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommand.java new file mode 100644 index 00000000000..2882559a453 --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommand.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import org.eclipse.ditto.base.model.json.FieldType; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldDefinition; + +/** + * Aggregate marker interface for all commands handled by the Timeseries service. Commands target + * Thing resources for policy enforcement (resource type {@value #RESOURCE_TYPE}) but are dispatched + * to the Timeseries service via their {@code timeseries.commands:} type prefix. + * + * @param the type of the implementing class. + * @since 4.0.0 + */ +public interface TimeseriesCommand> extends Command { + + /** + * Resource type used by the policy enforcer to look up the relevant {@code thing:/...} + * resources. Timeseries commands grant/deny on the same resource tree as Thing commands. + */ + String RESOURCE_TYPE = "thing"; + + /** + * Type prefix shared by all Timeseries commands (e.g. + * {@code timeseries.commands:retrieveTimeseries}). + */ + String TYPE_PREFIX = "timeseries." + Command.TYPE_QUALIFIER + ":"; + + @Override + default String getResourceType() { + return RESOURCE_TYPE; + } + + /** + * JSON field definitions shared by Timeseries commands. + */ + final class JsonFields extends Command.JsonFields { + + /** + * The Thing ID a timeseries command targets. + */ + public static final JsonFieldDefinition JSON_THING_ID = + JsonFactory.newStringFieldDefinition("thingId", FieldType.REGULAR, JsonSchemaVersion.V_2); + + private JsonFields() { + throw new AssertionError(); + } + } +} diff --git a/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommandResponse.java b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommandResponse.java new file mode 100644 index 00000000000..92da40dd8db --- /dev/null +++ b/timeseries/model/src/main/java/org/eclipse/ditto/timeseries/model/signals/commands/TimeseriesCommandResponse.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import org.eclipse.ditto.base.model.signals.commands.CommandResponse; + +/** + * Aggregate marker interface for all command responses produced by the Timeseries service. + * Mirrors {@link TimeseriesCommand} on the response side: same {@value TimeseriesCommand#RESOURCE_TYPE} + * resource type, type prefix {@code timeseries.responses:}. + * + * @param the type of the implementing class. + * @since 4.0.0 + */ +public interface TimeseriesCommandResponse> + extends CommandResponse { + + /** + * Type prefix shared by all Timeseries command responses. + */ + String TYPE_PREFIX = "timeseries." + CommandResponse.TYPE_QUALIFIER + ":"; + + @Override + default String getResourceType() { + return TimeseriesCommand.RESOURCE_TYPE; + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/AggregationTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/AggregationTest.java new file mode 100644 index 00000000000..44412830a1f --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/AggregationTest.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.Optional; + +import org.junit.Test; + +/** + * Unit tests for {@link Aggregation}. + */ +public final class AggregationTest { + + @Test + public void wireFormatNamesUseLowercaseTokens() { + assertThat(Aggregation.AVG.getName()).isEqualTo("avg"); + assertThat(Aggregation.MIN.getName()).isEqualTo("min"); + assertThat(Aggregation.MAX.getName()).isEqualTo("max"); + assertThat(Aggregation.SUM.getName()).isEqualTo("sum"); + assertThat(Aggregation.COUNT.getName()).isEqualTo("count"); + assertThat(Aggregation.FIRST.getName()).isEqualTo("first"); + assertThat(Aggregation.LAST.getName()).isEqualTo("last"); + assertThat(Aggregation.DERIVATIVE.getName()).isEqualTo("derivative"); + assertThat(Aggregation.RATE.getName()).isEqualTo("rate"); + assertThat(Aggregation.INTEGRAL.getName()).isEqualTo("integral"); + assertThat(Aggregation.STDDEV.getName()).isEqualTo("stddev"); + assertThat(Aggregation.PERCENTILE.getName()).isEqualTo("percentile"); + } + + @Test + public void toStringReturnsWireFormatName() { + assertThat(Aggregation.AVG.toString()).isEqualTo("avg"); + assertThat(Aggregation.PERCENTILE.toString()).isEqualTo("percentile"); + } + + @Test + public void forNameMatchesWireFormat() { + assertThat(Aggregation.forName("avg")).contains(Aggregation.AVG); + assertThat(Aggregation.forName("percentile")).contains(Aggregation.PERCENTILE); + } + + @Test + public void forNameIsCaseSensitive() { + // Wire format is lowercase by contract; uppercase must NOT silently match. + assertThat(Aggregation.forName("AVG")).isEmpty(); + assertThat(Aggregation.forName("Avg")).isEmpty(); + } + + @Test + public void forNameReturnsEmptyForUnknownToken() { + assertThat(Aggregation.forName("median")).isEmpty(); + assertThat(Aggregation.forName("")).isEmpty(); + } + + @Test + public void forNameRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> Aggregation.forName(null)); + } + + @Test + public void charSequenceContractDelegatesToName() { + final Aggregation underTest = Aggregation.STDDEV; + + assertThat(underTest.length()).isEqualTo("stddev".length()); + assertThat(underTest.charAt(0)).isEqualTo('s'); + assertThat(underTest.subSequence(0, 3)).isEqualTo("std"); + } + + @Test + public void allWireFormatNamesAreUnique() { + final long distinct = java.util.Arrays.stream(Aggregation.values()) + .map(Aggregation::getName) + .distinct() + .count(); + assertThat(distinct).isEqualTo(Aggregation.values().length); + } + + @Test + public void everyEnumValueResolvesViaForName() { + for (final Aggregation aggregation : Aggregation.values()) { + final Optional resolved = Aggregation.forName(aggregation.getName()); + assertThat(resolved).contains(aggregation); + } + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/FillStrategyTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/FillStrategyTest.java new file mode 100644 index 00000000000..bb2df280609 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/FillStrategyTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.Optional; + +import org.junit.Test; + +/** + * Unit tests for {@link FillStrategy}. + */ +public final class FillStrategyTest { + + @Test + public void wireFormatNamesUseLowercaseTokens() { + assertThat(FillStrategy.NULL.getName()).isEqualTo("null"); + assertThat(FillStrategy.PREVIOUS.getName()).isEqualTo("previous"); + assertThat(FillStrategy.LINEAR.getName()).isEqualTo("linear"); + assertThat(FillStrategy.ZERO.getName()).isEqualTo("zero"); + } + + @Test + public void toStringReturnsWireFormatName() { + assertThat(FillStrategy.PREVIOUS.toString()).isEqualTo("previous"); + } + + @Test + public void forNameMatchesWireFormat() { + assertThat(FillStrategy.forName("null")).contains(FillStrategy.NULL); + assertThat(FillStrategy.forName("previous")).contains(FillStrategy.PREVIOUS); + assertThat(FillStrategy.forName("linear")).contains(FillStrategy.LINEAR); + assertThat(FillStrategy.forName("zero")).contains(FillStrategy.ZERO); + } + + @Test + public void forNameIsCaseSensitive() { + assertThat(FillStrategy.forName("NULL")).isEmpty(); + assertThat(FillStrategy.forName("Previous")).isEmpty(); + } + + @Test + public void forNameReturnsEmptyForUnknownToken() { + assertThat(FillStrategy.forName("backfill")).isEmpty(); + assertThat(FillStrategy.forName("")).isEmpty(); + } + + @Test + public void forNameRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> FillStrategy.forName(null)); + } + + @Test + public void charSequenceContractDelegatesToName() { + final FillStrategy underTest = FillStrategy.LINEAR; + + assertThat(underTest.length()).isEqualTo("linear".length()); + assertThat(underTest.charAt(0)).isEqualTo('l'); + assertThat(underTest.subSequence(0, 3)).isEqualTo("lin"); + } + + @Test + public void allWireFormatNamesAreUnique() { + final long distinct = java.util.Arrays.stream(FillStrategy.values()) + .map(FillStrategy::getName) + .distinct() + .count(); + assertThat(distinct).isEqualTo(FillStrategy.values().length); + } + + @Test + public void everyEnumValueResolvesViaForName() { + for (final FillStrategy strategy : FillStrategy.values()) { + final Optional resolved = FillStrategy.forName(strategy.getName()); + assertThat(resolved).contains(strategy); + } + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/GroupByTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/GroupByTest.java new file mode 100644 index 00000000000..af8363ae019 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/GroupByTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link GroupBy}. + */ +public final class GroupByTest { + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(GroupBy.class) + .usingGetClass() + .verify(); + } + + @Test + public void parsesThingIdDimension() { + final GroupBy underTest = GroupBy.parse("thingId"); + + assertThat(underTest.getKind()).isEqualTo(GroupBy.Kind.THING_ID); + assertThat(underTest.getTagKey()).isEmpty(); + assertThat(underTest).isEqualTo(GroupBy.thingId()); + } + + @Test + public void parsesPathDimension() { + final GroupBy underTest = GroupBy.parse("path"); + + assertThat(underTest.getKind()).isEqualTo(GroupBy.Kind.PATH); + assertThat(underTest.getTagKey()).isEmpty(); + assertThat(underTest).isEqualTo(GroupBy.path()); + } + + @Test + public void parsesTagDimension() { + final GroupBy underTest = GroupBy.parse("tag:building"); + + assertThat(underTest.getKind()).isEqualTo(GroupBy.Kind.TAG); + assertThat(underTest.getTagKey()).contains("building"); + assertThat(underTest).isEqualTo(GroupBy.tag("building")); + } + + @Test + public void parseTrimsSurroundingWhitespace() { + assertThat(GroupBy.parse(" thingId ")).isEqualTo(GroupBy.thingId()); + assertThat(GroupBy.parse(" tag:building ")).isEqualTo(GroupBy.tag("building")); + } + + @Test + public void wireFormRoundTrips() { + assertThat(GroupBy.parse(GroupBy.thingId().toString())).isEqualTo(GroupBy.thingId()); + assertThat(GroupBy.parse(GroupBy.path().toString())).isEqualTo(GroupBy.path()); + assertThat(GroupBy.parse(GroupBy.tag("floor").toString())).isEqualTo(GroupBy.tag("floor")); + } + + @Test + public void groupKeyIsBareTagKeyForTagDimension() { + // A group reads as {"building": "A"} rather than {"tag:building": "A"} — the "tag:" prefix + // is request syntax, not part of the result identity. + assertThat(GroupBy.tag("building").getGroupKey()).isEqualTo("building"); + assertThat(GroupBy.thingId().getGroupKey()).isEqualTo("thingId"); + assertThat(GroupBy.path().getGroupKey()).isEqualTo("path"); + } + + /** + * A non-reserved value is a tag dimension named by its Thing path — the form the design document + * specifies ({@code groupBy=attributes/floor}). Only {@code thingId} and {@code path} are reserved. + */ + @Test + public void parseTreatsAnyOtherValueAsATagPath() { + final GroupBy parsed = GroupBy.parse("attributes/floor"); + assertThat(parsed.getKind()).isEqualTo(GroupBy.Kind.TAG); + assertThat(parsed.getTagKey()).contains("attributes/floor"); + assertThat(parsed.getGroupKey()).isEqualTo("attributes/floor"); + } + + @Test + public void parseStillAcceptsTheLegacyTagPrefix() { + assertThat(GroupBy.parse("tag:building")).isEqualTo(GroupBy.tag("building")); + } + + @Test + public void parseRejectsAnEmptyDimension() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> GroupBy.parse(" ")) + .withMessageContaining("must not be empty"); + } + + @Test + public void parseRejectsTagWithoutKey() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> GroupBy.parse("tag:")) + .withMessageContaining("non-empty tag key"); + } + + @Test + public void tagRejectsBlankKey() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> GroupBy.tag(" ")); + } + + @Test + public void parseRejectsNull() { + assertThatNullPointerException().isThrownBy(() -> GroupBy.parse(null)); + } + + @Test + public void tagDimensionsWithDifferentKeysAreNotEqual() { + assertThat(GroupBy.tag("building")).isNotEqualTo(GroupBy.tag("floor")); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResultTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResultTest.java new file mode 100644 index 00000000000..d841e92b8ab --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableAggregatedTimeseriesResultTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableAggregatedTimeseriesResult}. + */ +public final class ImmutableAggregatedTimeseriesResultTest { + + private static final JsonPointer PATH = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + private static final TimeseriesResultMeta META = TimeseriesResultMeta.of(2, "cel", "number"); + private static final List DATA = Arrays.asList( + TimeseriesDataValue.of(Instant.parse("2026-07-01T00:00:00Z"), JsonValue.of(21.5)), + TimeseriesDataValue.of(Instant.parse("2026-07-01T01:00:00Z"), JsonValue.of(22.0))); + + private static Map group() { + final Map group = new LinkedHashMap<>(); + group.put("building", "A"); + return group; + } + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableAggregatedTimeseriesResult.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithAllFields() { + final AggregatedTimeseriesResult underTest = + AggregatedTimeseriesResult.of(group(), PATH, META, DATA); + + assertThat(underTest.getGroup()).containsExactlyEntriesOf(group()); + assertThat((Object) underTest.getPath()).isEqualTo(PATH); + assertThat(underTest.getMeta()).isEqualTo(META); + assertThat(underTest.getData()).containsExactlyElementsOf(DATA); + } + + @Test + public void jsonRoundTripsWithGroup() { + final AggregatedTimeseriesResult underTest = + AggregatedTimeseriesResult.of(group(), PATH, META, DATA); + + assertThat(AggregatedTimeseriesResult.fromJson(underTest.toJson())).isEqualTo(underTest); + } + + @Test + public void jsonRoundTripsWithoutGroup() { + final AggregatedTimeseriesResult underTest = AggregatedTimeseriesResult.of( + Collections.emptyMap(), PATH, META, DATA); + + assertThat(AggregatedTimeseriesResult.fromJson(underTest.toJson())).isEqualTo(underTest); + } + + @Test + public void jsonRoundTripsWithEmptyData() { + final AggregatedTimeseriesResult underTest = AggregatedTimeseriesResult.of(group(), PATH, + TimeseriesResultMeta.of(0, null, "number"), + Collections.emptyList()); + + assertThat(AggregatedTimeseriesResult.fromJson(underTest.toJson())).isEqualTo(underTest); + } + + @Test + public void toJsonOmitsEmptyGroup() { + final JsonObject json = AggregatedTimeseriesResult.of(Collections.emptyMap(), + PATH, META, DATA).toJson(); + + assertThat(json.getValue("group")).isEmpty(); + } + + @Test + public void toJsonUsesResultKeyForMeta() { + // The meta object is exposed as "result" for symmetry with TimeseriesQueryResult, whose + // per-path entries already use that key — clients parse both with the same code. + final JsonObject json = AggregatedTimeseriesResult.of(group(), PATH, META, DATA).toJson(); + + assertThat(json.getValue("result")).isPresent(); + assertThat(json.getValue("path")).isPresent(); + assertThat(json.getValue("data")).isPresent(); + } + + @Test + public void rejectsNullArguments() { + assertThatNullPointerException() + .isThrownBy(() -> AggregatedTimeseriesResult.of(null, PATH, META, DATA)); + assertThatNullPointerException() + .isThrownBy(() -> AggregatedTimeseriesResult.of(group(), null, META, DATA)); + assertThatNullPointerException() + .isThrownBy(() -> AggregatedTimeseriesResult.of(group(), PATH, null, DATA)); + assertThatNullPointerException() + .isThrownBy(() -> AggregatedTimeseriesResult.of(group(), PATH, META, null)); + } + + @Test + public void returnedCollectionsAreUnmodifiable() { + final AggregatedTimeseriesResult underTest = + AggregatedTimeseriesResult.of(group(), PATH, META, DATA); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> underTest.getGroup().put("floor", "2")); + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> underTest.getData().clear()); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQueryTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQueryTest.java new file mode 100644 index 00000000000..b6681d8cc2b --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableCrossThingTimeseriesQueryTest.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableCrossThingTimeseriesQuery}. + */ +public final class ImmutableCrossThingTimeseriesQueryTest { + + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final List PATHS = Collections.singletonList( + JsonPointer.of("/features/circuit/properties/flowTemperature")); + private static final Instant FROM = Instant.parse("2026-07-01T00:00:00Z"); + private static final Instant TO = Instant.parse("2026-07-02T00:00:00Z"); + private static final Duration STEP = Duration.ofHours(1); + private static final Aggregation AGG = Aggregation.AVG; + + private static CrossThingTimeseriesQuery minimal() { + return CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, null); + } + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableCrossThingTimeseriesQuery.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithAllFields() { + final String filter = "eq(attributes/building,'A')"; + final List groupBy = Arrays.asList(GroupBy.tag("building"), GroupBy.thingId()); + + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, AGG, groupBy, filter, ZoneId.of("Europe/Berlin"), + FillStrategy.LINEAR, 42); + + assertThat(underTest.getNamespace()).isEqualTo(NAMESPACE); + assertThat(underTest.getPaths()).containsExactlyElementsOf(PATHS); + assertThat(underTest.getFrom()).isEqualTo(FROM); + assertThat(underTest.getTo()).isEqualTo(TO); + assertThat(underTest.getStep()).isEqualTo(STEP); + assertThat((Object) underTest.getAggregation()).isEqualTo(AGG); + assertThat(underTest.getGroupBy()).containsExactlyElementsOf(groupBy); + assertThat(underTest.getFilter()).contains(filter); + assertThat(underTest.getTimezone()).contains(ZoneId.of("Europe/Berlin")); + assertThat(underTest.getFillStrategy()).contains(FillStrategy.LINEAR); + assertThat(underTest.getMaxGroups()).contains(42); + } + + @Test + public void optionalFieldsAreAbsentOnMinimalQuery() { + final CrossThingTimeseriesQuery underTest = minimal(); + + assertThat(underTest.getGroupBy()).isEmpty(); + assertThat(underTest.getFilter()).isEmpty(); + assertThat(underTest.getTimezone()).isEmpty(); + assertThat(underTest.getFillStrategy()).isEmpty(); + assertThat(underTest.getMaxGroups()).isEmpty(); + } + + @Test + public void namespaceIsTrimmed() { + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(" " + NAMESPACE + " ", + PATHS, FROM, TO, STEP, AGG, Collections.emptyList(), + null, null, null, null); + + assertThat(underTest.getNamespace()).isEqualTo(NAMESPACE); + } + + @Test + public void jsonRoundTripsWithAllFields() { + final String filter = "and(eq(attributes/building,'A'),eq(attributes/floor,'2'))"; + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, AGG, Arrays.asList(GroupBy.tag("building"), GroupBy.thingId()), + filter, ZoneId.of("Europe/Berlin"), FillStrategy.PREVIOUS, 7); + + final JsonObject json = underTest.toJson(); + + assertThat(CrossThingTimeseriesQuery.fromJson(json)).isEqualTo(underTest); + } + + @Test + public void jsonRoundTripsMinimalQuery() { + final CrossThingTimeseriesQuery underTest = minimal(); + + assertThat(CrossThingTimeseriesQuery.fromJson(underTest.toJson())).isEqualTo(underTest); + } + + @Test + public void toJsonOmitsAbsentOptionalFields() { + final JsonObject json = minimal().toJson(); + + assertThat(json.getValue("groupBy")).isEmpty(); + assertThat(json.getValue("filter")).isEmpty(); + assertThat(json.getValue("timezone")).isEmpty(); + assertThat(json.getValue("fillStrategy")).isEmpty(); + assertThat(json.getValue("maxGroups")).isEmpty(); + } + + @Test + public void rejectsBlankNamespace() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(" ", PATHS, FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, null)) + .withMessageContaining("non-empty 'namespaces'"); + } + + @Test + public void rejectsEmptyPaths() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, + Collections.emptyList(), FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, null)) + .withMessageContaining("at least one path"); + } + + /** + * The same bound the single-Thing query enforces. It matters more here: every path is a separate + * grouped scan over the whole namespace, so the path count multiplies an already namespace-wide + * fan-out. + */ + @Test + public void rejectsMoreThanMaxPaths() { + final List tooMany = new ArrayList<>(); + for (int i = 0; i <= TimeseriesQuery.MAX_PATHS; i++) { + tooMany.add(JsonPointer.of("/features/f/properties/p" + i)); + } + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, tooMany, FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, null)) + .withMessageContaining("at most <" + TimeseriesQuery.MAX_PATHS + "> paths"); + } + + @Test + public void acceptsExactlyMaxPaths() { + final List exactly = new ArrayList<>(); + for (int i = 0; i < TimeseriesQuery.MAX_PATHS; i++) { + exactly.add(JsonPointer.of("/features/f/properties/p" + i)); + } + + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, exactly, + FROM, TO, STEP, AGG, Collections.emptyList(), null, null, null, null); + + assertThat(underTest.getPaths()).hasSize(TimeseriesQuery.MAX_PATHS); + } + + @Test + public void rejectsFromNotBeforeTo() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, TO, FROM, STEP, AGG, + Collections.emptyList(), null, + null, null, null)) + .withMessageContaining("must be strictly before"); + } + + @Test + public void rejectsNonPositiveStep() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, + Duration.ZERO, AGG, Collections.emptyList(), + null, null, null, null)) + .withMessageContaining("positive duration"); + } + + /** + * A cross-Thing read without a bucketed aggregation would stream every raw point of every + * matching Thing — the exact fan-out this endpoint exists to bound. + */ + @Test + public void rejectsWindowFunctionAggregations() { + for (final Aggregation windowFunction : Arrays.asList(Aggregation.DERIVATIVE, + Aggregation.RATE, Aggregation.INTEGRAL, Aggregation.PERCENTILE)) { + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .as("aggregation <%s> must be rejected", windowFunction) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, STEP, + windowFunction, Collections.emptyList(), + null, null, null, null)) + .withMessageContaining("window function"); + } + } + + @Test + public void acceptsEveryBucketedAggregation() { + for (final Aggregation aggregation : Aggregation.values()) { + if (aggregation.requiresStep()) { + final CrossThingTimeseriesQuery query = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, aggregation, Collections.emptyList(), + null, null, null, null); + + assertThat((Object) query.getAggregation()).isEqualTo(aggregation); + } + } + } + + @Test + public void rejectsDuplicateGroupByDimensions() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, STEP, AGG, + Arrays.asList(GroupBy.tag("building"), GroupBy.tag("building")), + null, null, null, null)) + .withMessageContaining("Duplicate groupBy dimension"); + } + + @Test + public void allowsDistinctTagDimensions() { + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, AGG, Arrays.asList(GroupBy.tag("building"), GroupBy.tag("floor")), + null, null, null, null); + + assertThat(underTest.getGroupBy()).hasSize(2); + } + + @Test + public void rejectsNonPositiveMaxGroups() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, 0)) + .withMessageContaining("must be positive"); + } + + @Test + public void rejectsMaxGroupsAboveCeiling() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, FROM, TO, STEP, AGG, + Collections.emptyList(), null, + null, null, CrossThingTimeseriesQuery.MAX_GROUPS_CEILING + 1)) + .withMessageContaining("must not exceed"); + } + + @Test + public void acceptsMaxGroupsAtCeiling() { + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, AGG, Collections.emptyList(), + null, null, null, + CrossThingTimeseriesQuery.MAX_GROUPS_CEILING); + + assertThat(underTest.getMaxGroups()).contains(CrossThingTimeseriesQuery.MAX_GROUPS_CEILING); + } + + @Test + public void rejectsNullRequiredArguments() { + assertThatNullPointerException().isThrownBy(() -> CrossThingTimeseriesQuery.of(null, PATHS, + FROM, TO, STEP, AGG, Collections.emptyList(), + null, null, null, null)); + assertThatNullPointerException().isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, + null, FROM, TO, STEP, AGG, Collections.emptyList(), + null, null, null, null)); + assertThatNullPointerException().isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, + PATHS, FROM, TO, null, AGG, Collections.emptyList(), + null, null, null, null)); + assertThatNullPointerException().isThrownBy(() -> CrossThingTimeseriesQuery.of(NAMESPACE, + PATHS, FROM, TO, STEP, null, Collections.emptyList(), + null, null, null, null)); + } + + @Test + public void returnedCollectionsAreUnmodifiable() { + final CrossThingTimeseriesQuery underTest = CrossThingTimeseriesQuery.of(NAMESPACE, PATHS, + FROM, TO, STEP, AGG, Collections.singletonList(GroupBy.thingId()), + "eq(attributes/building,'A')", null, null, null); + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> underTest.getPaths().add(JsonPointer.of("/attributes/x"))); + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> underTest.getGroupBy().add(GroupBy.path())); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPointTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPointTest.java new file mode 100644 index 00000000000..c567e05ee10 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataPointTest.java @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonArray; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableTimeseriesDataPoint}. + */ +public final class ImmutableTimeseriesDataPointTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + private static final Instant TIMESTAMP = Instant.parse("2026-01-15T10:30:00Z"); + private static final JsonValue VALUE = JsonValue.of(23.5); + private static final long REVISION = 42L; + private static final Map TAGS = sampleTags(); + private static final String UNIT = "cel"; + + private static Map sampleTags() { + final Map map = new LinkedHashMap<>(); + map.put("attributes/building", "A"); + map.put("attributes/floor", "2"); + return Collections.unmodifiableMap(map); + } + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableTimeseriesDataPoint.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithAllFields() { + final TimeseriesDataPoint underTest = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT); + + assertThat((Object) underTest.getThingId()).isEqualTo(THING_ID); + assertThat((Object) underTest.getPath()).isEqualTo(PATH); + assertThat(underTest.getTimestamp()).isEqualTo(TIMESTAMP); + assertThat((Object) underTest.getValue()).isEqualTo(VALUE); + assertThat(underTest.getRevision()).isEqualTo(REVISION); + final Map actualTags = underTest.getTags(); + assertThat(actualTags).containsAllEntriesOf(TAGS); + final java.util.Optional actualUnit = underTest.getUnit(); + assertThat(actualUnit).contains(UNIT); + } + + @Test + public void factoryAcceptsNullUnit() { + final TimeseriesDataPoint underTest = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, null); + + assertThat(underTest.getUnit()).isEmpty(); + } + + @Test + public void factoryAcceptsEmptyTags() { + final TimeseriesDataPoint underTest = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, Collections.emptyMap(), null); + + assertThat(underTest.getTags()).isEmpty(); + } + + @Test + public void factoryRejectsNullThingId() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.of( + null, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT)); + } + + @Test + public void factoryRejectsNullPath() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.of( + THING_ID, null, TIMESTAMP, VALUE, REVISION, TAGS, UNIT)); + } + + @Test + public void factoryRejectsNullTimestamp() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.of( + THING_ID, PATH, null, VALUE, REVISION, TAGS, UNIT)); + } + + @Test + public void factoryRejectsNullValue() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, null, REVISION, TAGS, UNIT)); + } + + @Test + public void factoryRejectsNullTags() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, null, UNIT)); + } + + @Test + public void factoryRejectsObjectValue() { + // The MongoDB Time Series collection's column-store layout cannot represent compound + // values without flattening — and the aggregation path in later phases assumes one scalar + // sample per timestamp. Catching this at the model boundary fails fast instead of letting + // a misuse propagate to the adapter / wire format. + final JsonValue objectValue = JsonObject.newBuilder().set("foo", "bar").build(); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, objectValue, REVISION, TAGS, UNIT)) + .withMessageContaining("scalar") + .withMessageContaining("object"); + } + + @Test + public void factoryRejectsArrayValue() { + final JsonValue arrayValue = JsonArray.newBuilder().add(1).add(2).build(); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, arrayValue, REVISION, TAGS, UNIT)) + .withMessageContaining("scalar") + .withMessageContaining("array"); + } + + @Test + public void factoryAcceptsScalarValueTypes() { + // String / boolean / null are valid (e.g. categorical or status timeseries) — only + // arrays and objects are rejected. + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, JsonValue.of("UP"), REVISION, TAGS, UNIT); + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, JsonValue.of(true), REVISION, TAGS, UNIT); + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, JsonValue.nullLiteral(), REVISION, TAGS, UNIT); + } + + @Test + public void getTagsReturnsAnUnmodifiableMap() { + final TimeseriesDataPoint underTest = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT); + + assertThat(underTest.getTags()).isUnmodifiable(); + } + + @Test + public void factoryDefensivelyCopiesTags() { + final Map mutable = new LinkedHashMap<>(); + mutable.put("attributes/building", "A"); + + final TimeseriesDataPoint underTest = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, mutable, null); + + mutable.put("attributes/floor", "9"); + + // The data point must not see the late-added entry. + assertThat(underTest.getTags()).containsOnlyKeys("attributes/building"); + } + + @Test + public void toJsonContainsAllNonDefaultFields() { + final TimeseriesDataPoint underTest = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("thingId")).contains(JsonValue.of(THING_ID.toString())); + assertThat(json.getValue("path")).contains(JsonValue.of(PATH.toString())); + assertThat(json.getValue("timestamp")).contains(JsonValue.of("2026-01-15T10:30:00Z")); + assertThat(json.getValue("value")).contains(VALUE); + assertThat(json.getValue("revision")).contains(JsonValue.of(REVISION)); + assertThat(json.getValue("unit")).contains(JsonValue.of(UNIT)); + assertThat(json.getValue("tags")).isPresent(); + } + + @Test + public void toJsonOmitsTagsWhenEmpty() { + final TimeseriesDataPoint underTest = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, Collections.emptyMap(), UNIT); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("tags")).isFalse(); + } + + @Test + public void tagKeyContainingASlashStaysAFlatKey() { + // Tag keys come from the WoT model and are not pattern-constrained, so "site/zone" is legal. + // JsonObjectBuilder.set(CharSequence, ...) would read such a key as a JSON *pointer* and nest + // it into {"site":{"zone":…}}, which fromJson then reads back as a tag named "site" whose + // value is an object -> DittoJsonException. It must stay one literal key instead. + final Map slashTags = Collections.singletonMap("site/zone", "north"); + final TimeseriesDataPoint underTest = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, slashTags, UNIT); + + final JsonObject tagsJson = underTest.toJson().getValue("tags").orElseThrow().asObject(); + assertThat(tagsJson.getKeys()).extracting(Object::toString).containsExactly("site/zone"); + assertThat(TimeseriesDataPoint.fromJson(underTest.toJson()).getTags()).isEqualTo(slashTags); + } + + @Test + public void toJsonOmitsUnitWhenNull() { + final TimeseriesDataPoint underTest = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, null); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("unit")).isFalse(); + } + + @Test + public void roundTripPreservesAllFields() { + final TimeseriesDataPoint original = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT); + + final JsonObject json = original.toJson(); + final TimeseriesDataPoint reconstructed = TimeseriesDataPoint.fromJson(json); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripWithoutOptionalFieldsPreservesEquality() { + final TimeseriesDataPoint original = TimeseriesDataPoint.of( + THING_ID, PATH, TIMESTAMP, VALUE, REVISION, Collections.emptyMap(), null); + + final JsonObject json = original.toJson(); + final TimeseriesDataPoint reconstructed = TimeseriesDataPoint.fromJson(json); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getTags()).isEmpty(); + assertThat(reconstructed.getUnit()).isEmpty(); + } + + @Test + public void fromJsonAppliesDefaultsForOptionalFields() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("thingId", THING_ID.toString()) + .set("path", PATH.toString()) + .set("timestamp", TIMESTAMP.toString()) + .set("value", VALUE) + .set("revision", REVISION) + .build(); + + final TimeseriesDataPoint underTest = TimeseriesDataPoint.fromJson(json); + + assertThat(underTest.getTags()).isEmpty(); + assertThat(underTest.getUnit()).isEmpty(); + } + + @Test + public void fromJsonRejectsMissingRequiredField() { + final JsonObject jsonWithoutThingId = JsonFactory.newObjectBuilder() + .set("path", PATH.toString()) + .set("timestamp", TIMESTAMP.toString()) + .set("value", VALUE) + .set("revision", REVISION) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> TimeseriesDataPoint.fromJson(jsonWithoutThingId)); + } + + @Test + public void fromJsonRejectsInvalidTimestamp() { + final JsonObject jsonWithBadTimestamp = JsonFactory.newObjectBuilder() + .set("thingId", THING_ID.toString()) + .set("path", PATH.toString()) + .set("timestamp", "not-an-instant") + .set("value", VALUE) + .set("revision", REVISION) + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesDataPoint.fromJson(jsonWithBadTimestamp)); + } + + @Test + public void fromJsonRejectsNonStringTagValue() { + final JsonObject tagsJson = JsonFactory.newObjectBuilder() + .set("attributes/floor", JsonValue.of(2)) + .build(); + final JsonObject json = JsonFactory.newObjectBuilder() + .set("thingId", THING_ID.toString()) + .set("path", PATH.toString()) + .set("timestamp", TIMESTAMP.toString()) + .set("value", VALUE) + .set("revision", REVISION) + .set("tags", tagsJson) + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesDataPoint.fromJson(json)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataPoint.fromJson(null)); + } + + @Test + public void toStringIncludesAllFields() { + final TimeseriesDataPoint underTest = + TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, VALUE, REVISION, TAGS, UNIT); + + final String s = underTest.toString(); + + assertThat(s) + .contains(THING_ID.toString()) + .contains(PATH.toString()) + .contains(TIMESTAMP.toString()) + .contains(VALUE.toString()) + .contains(String.valueOf(REVISION)) + .contains(UNIT); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValueTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValueTest.java new file mode 100644 index 00000000000..d4032b533a8 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesDataValueTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableTimeseriesDataValue}. + */ +public final class ImmutableTimeseriesDataValueTest { + + private static final Instant TIMESTAMP = Instant.parse("2026-01-15T10:30:00Z"); + private static final JsonValue VALUE = JsonValue.of(23.5); + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableTimeseriesDataValue.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesNonGapValue() { + final TimeseriesDataValue underTest = TimeseriesDataValue.of(TIMESTAMP, VALUE); + + assertThat(underTest.getTimestamp()).isEqualTo(TIMESTAMP); + assertThat(underTest.getValue()).contains(VALUE); + assertThat(underTest.isGap()).isFalse(); + } + + @Test + public void gapFactoryCreatesGapWithValue() { + final JsonValue filledValue = JsonValue.of(0); + + final TimeseriesDataValue underTest = TimeseriesDataValue.gap(TIMESTAMP, filledValue); + + assertThat(underTest.getTimestamp()).isEqualTo(TIMESTAMP); + assertThat(underTest.getValue()).contains(filledValue); + assertThat(underTest.isGap()).isTrue(); + } + + @Test + public void gapFactoryAcceptsNullValueForFillStrategyNull() { + final TimeseriesDataValue underTest = TimeseriesDataValue.gap(TIMESTAMP, null); + + assertThat(underTest.getValue()).isEmpty(); + assertThat(underTest.isGap()).isTrue(); + } + + @Test + public void nonGapFactoryRejectsNullValue() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataValue.of(TIMESTAMP, null)); + } + + @Test + public void nonGapFactoryRejectsNullTimestamp() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataValue.of(null, VALUE)); + } + + @Test + public void gapFactoryRejectsNullTimestamp() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataValue.gap(null, VALUE)); + } + + @Test + public void toJsonContainsTimestampAndValue() { + final TimeseriesDataValue underTest = TimeseriesDataValue.of(TIMESTAMP, VALUE); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("t")).contains(JsonValue.of(TIMESTAMP.toString())); + assertThat(json.getValue("v")).contains(VALUE); + } + + @Test + public void toJsonOmitsGapFlagForNonGapValue() { + final TimeseriesDataValue underTest = TimeseriesDataValue.of(TIMESTAMP, VALUE); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("_gap")).isFalse(); + } + + @Test + public void toJsonIncludesGapFlagForGapValue() { + final TimeseriesDataValue underTest = TimeseriesDataValue.gap(TIMESTAMP, JsonValue.of(0)); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("_gap")).contains(JsonValue.of(true)); + } + + @Test + public void toJsonEmitsExplicitNullForFillStrategyNullGap() { + final TimeseriesDataValue underTest = TimeseriesDataValue.gap(TIMESTAMP, null); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("v")).contains(JsonValue.nullLiteral()); + assertThat(json.getValue("_gap")).contains(JsonValue.of(true)); + } + + @Test + public void roundTripPreservesNonGapValue() { + final TimeseriesDataValue original = TimeseriesDataValue.of(TIMESTAMP, VALUE); + + final TimeseriesDataValue reconstructed = TimeseriesDataValue.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripPreservesGapWithValue() { + final TimeseriesDataValue original = TimeseriesDataValue.gap(TIMESTAMP, JsonValue.of(0)); + + final TimeseriesDataValue reconstructed = TimeseriesDataValue.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripPreservesNullGap() { + final TimeseriesDataValue original = TimeseriesDataValue.gap(TIMESTAMP, null); + + final TimeseriesDataValue reconstructed = TimeseriesDataValue.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonRejectsInvalidTimestamp() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("t", "not-an-instant") + .set("v", VALUE) + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesDataValue.fromJson(json)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesDataValue.fromJson(null)); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResultTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResultTest.java new file mode 100644 index 00000000000..eceb2f5b743 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryResultTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableTimeseriesQueryResult}. + */ +public final class ImmutableTimeseriesQueryResultTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + private static final TimeseriesQuery QUERY = TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + private static final TimeseriesResultMeta META = TimeseriesResultMeta.of(2, "cel", "number"); + private static final List DATA = Collections.unmodifiableList(Arrays.asList( + TimeseriesDataValue.of(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.3)), + TimeseriesDataValue.of(Instant.parse("2026-01-14T11:00:00Z"), JsonValue.of(22.1)))); + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableTimeseriesQueryResult.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithAllFields() { + final TimeseriesQueryResult underTest = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, DATA); + + assertThat((Object) underTest.getThingId()).isEqualTo(THING_ID); + assertThat((Object) underTest.getPath()).isEqualTo(PATH); + assertThat(underTest.getQuery()).isEqualTo(QUERY); + assertThat(underTest.getMeta()).isEqualTo(META); + final List actualData = underTest.getData(); + assertThat(actualData).containsExactlyElementsOf(DATA); + } + + @Test + public void factoryAcceptsEmptyData() { + final TimeseriesResultMeta emptyMeta = TimeseriesResultMeta.of(0, null, "number"); + + final TimeseriesQueryResult underTest = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, emptyMeta, Collections.emptyList()); + + assertThat(underTest.getData()).isEmpty(); + } + + @Test + public void factoryRejectsNullThingId() { + assertThatNullPointerException().isThrownBy(() -> + TimeseriesQueryResult.of(null, PATH, QUERY, META, DATA)); + } + + @Test + public void factoryRejectsNullPath() { + assertThatNullPointerException().isThrownBy(() -> + TimeseriesQueryResult.of(THING_ID, null, QUERY, META, DATA)); + } + + @Test + public void factoryRejectsNullQuery() { + assertThatNullPointerException().isThrownBy(() -> + TimeseriesQueryResult.of(THING_ID, PATH, null, META, DATA)); + } + + @Test + public void factoryRejectsNullMeta() { + assertThatNullPointerException().isThrownBy(() -> + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, null, DATA)); + } + + @Test + public void factoryRejectsNullData() { + assertThatNullPointerException().isThrownBy(() -> + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, null)); + } + + @Test + public void getDataReturnsAnUnmodifiableList() { + final TimeseriesQueryResult underTest = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, DATA); + + assertThat(underTest.getData()).isUnmodifiable(); + } + + @Test + public void factoryDefensivelyCopiesData() { + final List mutable = new ArrayList<>(DATA); + + final TimeseriesQueryResult underTest = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, mutable); + + mutable.add(TimeseriesDataValue.of(Instant.now(), JsonValue.of(99))); + + assertThat(underTest.getData()).hasSize(2); + } + + @Test + public void toJsonContainsAllFields() { + final TimeseriesQueryResult underTest = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, DATA); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("thingId")).contains(JsonValue.of(THING_ID.toString())); + assertThat(json.getValue("path")).contains(JsonValue.of(PATH.toString())); + assertThat(json.getValue("query")).isPresent(); + assertThat(json.getValue("result")).isPresent(); + assertThat(json.getValue("data")).isPresent(); + } + + @Test + public void roundTripPreservesAllFields() { + final TimeseriesQueryResult original = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, DATA); + + final TimeseriesQueryResult reconstructed = TimeseriesQueryResult.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripWithEmptyDataPreservesEquality() { + final TimeseriesResultMeta emptyMeta = TimeseriesResultMeta.of(0, null, "number"); + final TimeseriesQueryResult original = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, emptyMeta, Collections.emptyList()); + + final TimeseriesQueryResult reconstructed = TimeseriesQueryResult.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonRejectsNonObjectDataElement() { + final TimeseriesQueryResult original = + TimeseriesQueryResult.of(THING_ID, PATH, QUERY, META, DATA); + final JsonObject json = original.toJson(); + final JsonObject corrupted = JsonFactory.newObjectBuilder(json) + .set("data", JsonFactory.newArrayBuilder().add("not-an-object").build()) + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQueryResult.fromJson(corrupted)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQueryResult.fromJson(null)); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryTest.java new file mode 100644 index 00000000000..1cad6f36a7d --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesQueryTest.java @@ -0,0 +1,573 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableTimeseriesQuery}. + */ +public final class ImmutableTimeseriesQueryTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final List PATHS = Collections.unmodifiableList(Arrays.asList( + JsonPointer.of("/features/environment/properties/temperature"), + JsonPointer.of("/features/environment/properties/humidity"))); + private static final List SINGLE_PATH = Collections.singletonList( + JsonPointer.of("/features/environment/properties/temperature")); + private static final Instant FROM = Instant.parse("2026-01-14T00:00:00Z"); + private static final Instant TO = Instant.parse("2026-01-15T00:00:00Z"); + private static final Duration STEP = Duration.ofHours(1); + private static final Aggregation AGGREGATION = Aggregation.AVG; + private static final FillStrategy FILL_STRATEGY = FillStrategy.PREVIOUS; + private static final Integer LIMIT = 1000; + private static final ZoneId TIMEZONE = ZoneId.of("Europe/Berlin"); + private static final Double PERCENTILE = 95.0; + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableTimeseriesQuery.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithAllFields() { + final TimeseriesQuery underTest = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE); + + assertThat((Object) underTest.getThingId()).isEqualTo(THING_ID); + final List actualPaths = underTest.getPaths(); + assertThat(actualPaths).containsExactlyElementsOf(PATHS); + assertThat(underTest.getFrom()).isEqualTo(FROM); + assertThat(underTest.getTo()).isEqualTo(TO); + assertThat(underTest.getStep()).contains(STEP); + final Optional agg = underTest.getAggregation(); + assertThat(agg).contains(AGGREGATION); + final Optional fill = underTest.getFillStrategy(); + assertThat(fill).contains(FILL_STRATEGY); + final Optional lim = underTest.getLimit(); + assertThat(lim).contains(LIMIT); + assertThat(underTest.getTimezone()).contains(TIMEZONE); + } + + @Test + public void shortFactoryCreatesRawQuery() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.getStep()).isEmpty(); + assertThat(underTest.getAggregation()).isEmpty(); + assertThat(underTest.getFillStrategy()).isEmpty(); + assertThat(underTest.getLimit()).isEmpty(); + assertThat(underTest.getTimezone()).isEmpty(); + } + + @Test + public void factoryAcceptsEmptyPaths() { + final TimeseriesQuery underTest = + TimeseriesQuery.of(THING_ID, Collections.emptyList(), FROM, TO); + + assertThat(underTest.getPaths()).isEmpty(); + } + + @Test + public void factoryAcceptsAllNullableFieldsAsNull() { + final TimeseriesQuery underTest = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, null, null, null, null, null); + + assertThat(underTest.getStep()).isEmpty(); + assertThat(underTest.getAggregation()).isEmpty(); + assertThat(underTest.getFillStrategy()).isEmpty(); + assertThat(underTest.getLimit()).isEmpty(); + assertThat(underTest.getTimezone()).isEmpty(); + } + + @Test + public void factoryRejectsNullThingId() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQuery.of( + null, PATHS, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE)); + } + + @Test + public void factoryRejectsNullPaths() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQuery.of( + THING_ID, null, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE)); + } + + @Test + public void factoryRejectsNullFrom() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQuery.of( + THING_ID, PATHS, null, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE)); + } + + @Test + public void factoryRejectsNullTo() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQuery.of( + THING_ID, PATHS, FROM, null, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE)); + } + + @Test + public void getPathsReturnsAnUnmodifiableList() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.getPaths()).isUnmodifiable(); + } + + @Test + public void factoryDefensivelyCopiesPaths() { + final List mutable = new ArrayList<>(); + mutable.add(JsonPointer.of("/features/env/properties/temperature")); + + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, mutable, FROM, TO); + + mutable.add(JsonPointer.of("/features/env/properties/humidity")); + + assertThat(underTest.getPaths()).hasSize(1); + } + + @Test + public void toJsonContainsAllNonDefaultFields() { + final TimeseriesQuery underTest = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("thingId")).contains(JsonValue.of(THING_ID.toString())); + assertThat(json.getValue("paths")).isPresent(); + assertThat(json.getValue("from")).contains(JsonValue.of("2026-01-14T00:00:00Z")); + assertThat(json.getValue("to")).contains(JsonValue.of("2026-01-15T00:00:00Z")); + assertThat(json.getValue("step")).contains(JsonValue.of("PT1H")); + assertThat(json.getValue("aggregation")).contains(JsonValue.of("avg")); + assertThat(json.getValue("fillStrategy")).contains(JsonValue.of("previous")); + assertThat(json.getValue("limit")).contains(JsonValue.of(LIMIT)); + assertThat(json.getValue("timezone")).contains(JsonValue.of("Europe/Berlin")); + } + + @Test + public void toJsonOmitsAllOptionalFieldsWhenAbsent() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("step")).isFalse(); + assertThat(json.contains("aggregation")).isFalse(); + assertThat(json.contains("fillStrategy")).isFalse(); + assertThat(json.contains("limit")).isFalse(); + assertThat(json.contains("timezone")).isFalse(); + } + + @Test + public void roundTripPreservesAllFields() { + final TimeseriesQuery original = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE); + + final JsonObject json = original.toJson(); + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripWithoutOptionalFieldsPreservesEquality() { + final TimeseriesQuery original = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + final JsonObject json = original.toJson(); + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonAppliesDefaultsForOptionalFields() { + final JsonObject json = baseJson().build(); + + final TimeseriesQuery underTest = TimeseriesQuery.fromJson(json); + + assertThat(underTest.getStep()).isEmpty(); + assertThat(underTest.getAggregation()).isEmpty(); + assertThat(underTest.getFillStrategy()).isEmpty(); + assertThat(underTest.getLimit()).isEmpty(); + assertThat(underTest.getTimezone()).isEmpty(); + } + + @Test + public void fromJsonRejectsMissingThingId() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("paths", JsonFactory.newArrayBuilder().add("/foo").build()) + .set("from", FROM.toString()) + .set("to", TO.toString()) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsInvalidFrom() { + final JsonObject json = baseJson().set("from", "not-an-instant").build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsInvalidStep() { + final JsonObject json = baseJson().set("step", "1 hour").build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsUnknownAggregation() { + final JsonObject json = baseJson().set("aggregation", "bogus").build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsUnknownFillStrategy() { + final JsonObject json = baseJson().set("fillStrategy", "interpolate-magic").build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsInvalidTimezone() { + final JsonObject json = baseJson().set("timezone", "Mars/Olympus_Mons").build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsNonStringPathElement() { + final JsonObject json = baseJson().set("paths", JsonFactory.newArrayBuilder().add(42).build()).build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesQuery.fromJson(null)); + } + + @Test + public void toStringIncludesAllFields() { + final TimeseriesQuery underTest = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, AGGREGATION, FILL_STRATEGY, LIMIT, TIMEZONE); + + final String s = underTest.toString(); + + assertThat(s) + .contains(THING_ID.toString()) + .contains(FROM.toString()) + .contains(TO.toString()) + .contains(STEP.toString()) + .contains(AGGREGATION.toString()) + .contains(FILL_STRATEGY.toString()) + .contains(String.valueOf(LIMIT)) + .contains(TIMEZONE.toString()); + } + + @Test + public void factoryCreatesInstanceWithPercentile() { + final TimeseriesQuery underTest = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, Aggregation.PERCENTILE, null, null, null, PERCENTILE); + + assertThat(underTest.getPercentile()).contains(PERCENTILE); + assertThat(underTest.getAggregation()).contains(Aggregation.PERCENTILE); + } + + @Test + public void percentileRoundTripsThroughJson() { + final TimeseriesQuery original = TimeseriesQuery.of( + THING_ID, PATHS, FROM, TO, STEP, Aggregation.PERCENTILE, null, null, null, PERCENTILE); + + final JsonObject json = original.toJson(); + assertThat(json.getValue("percentile")).contains(JsonValue.of(PERCENTILE)); + + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getPercentile()).contains(PERCENTILE); + } + + @Test + public void toJsonOmitsPercentileWhenAbsent() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.toJson().contains("percentile")).isFalse(); + } + + @Test + public void factoryRejectsPerBucketAggregationWithoutStep() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, null, Aggregation.AVG, null, null, null, null)); + } + + @Test + public void factoryRejectsPercentileAggregationWithoutValue() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, STEP, Aggregation.PERCENTILE, null, null, null, null)); + } + + @Test + public void factoryRejectsOutOfRangePercentile() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, STEP, Aggregation.PERCENTILE, null, null, null, 150.0)); + } + + @Test + public void fromJsonRejectsPerBucketAggregationWithoutStep() { + final JsonObject json = baseJson().set("aggregation", "avg").build(); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesQuery.fromJson(json)); + } + + @Test + public void factoryAcceptsLinearFill() { + final TimeseriesQuery underTest = + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, STEP, Aggregation.AVG, FillStrategy.LINEAR, + null, null, null); + + assertThat(underTest.getFillStrategy()).contains(FillStrategy.LINEAR); + } + + @Test + public void factoryRejectsTooManyPaths() { + final List tooMany = new ArrayList<>(); + for (int i = 0; i <= 100; i++) { + tooMany.add(JsonPointer.of("/features/env/properties/p" + i)); + } + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, tooMany, FROM, TO, null, null, null, null, null, null)); + } + + @Test + public void factoryAcceptsCursorForSinglePathRawRead() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, + null, null, null, null, null, null, cursor); + + assertThat(underTest.getCursor()).contains(cursor); + } + + @Test + public void cursorRoundTripsThroughJson() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + final TimeseriesQuery original = TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, + null, null, null, null, null, null, cursor); + + final JsonObject json = original.toJson(); + assertThat(json.getValue("cursor")).contains(JsonValue.of(cursor)); + + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getCursor()).contains(cursor); + } + + @Test + public void toJsonOmitsCursorWhenAbsent() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.toJson().contains("cursor")).isFalse(); + } + + @Test + public void factoryRejectsCursorWithAggregation() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, STEP, Aggregation.AVG, null, null, + null, null, cursor)); + } + + @Test + public void factoryRejectsCursorWithStep() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, STEP, null, null, null, null, null, + cursor)); + } + + @Test + public void factoryRejectsCursorWithFill() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, null, null, FillStrategy.PREVIOUS, + null, null, null, cursor)); + } + + @Test + public void factoryRejectsCursorWithMultiplePaths() { + final String cursor = TimeseriesCursor.of(FROM, 7L).encode(); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, null, null, null, null, null, null, + cursor)); + } + + @Test + public void factoryRejectsMalformedCursor() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, SINGLE_PATH, FROM, TO, null, null, null, null, null, null, + "not-a-valid-cursor !!!")); + } + + @Test + public void factoryAcceptsDescOrderForRawRead() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, + null, null, null, null, null, null, null, SortOrder.DESC); + + assertThat(underTest.getOrder()).contains(SortOrder.DESC); + } + + @Test + public void orderIsEmptyByDefault() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.getOrder()).isEmpty(); + } + + @Test + public void orderRoundTripsThroughJson() { + final TimeseriesQuery original = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, + null, null, null, null, null, null, null, SortOrder.DESC); + + final JsonObject json = original.toJson(); + assertThat(json.getValue("order")).contains(JsonValue.of("desc")); + + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getOrder()).contains(SortOrder.DESC); + } + + @Test + public void toJsonOmitsOrderWhenAbsent() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO); + + assertThat(underTest.toJson().contains("order")).isFalse(); + } + + @Test + public void factoryAcceptsAscOrderWithAggregation() { + // ASC is the default direction of aggregated reads, so it imposes no constraint. + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, + STEP, Aggregation.AVG, null, null, null, null, null, SortOrder.ASC); + + assertThat(underTest.getOrder()).contains(SortOrder.ASC); + } + + @Test + public void factoryRejectsDescOrderWithAggregation() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, STEP, Aggregation.AVG, null, null, null, + null, null, SortOrder.DESC)); + } + + @Test + public void factoryRejectsDescOrderWithStep() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, STEP, null, null, null, null, null, null, + SortOrder.DESC)); + } + + @Test + public void factoryRejectsDescOrderWithFill() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class).isThrownBy(() -> + TimeseriesQuery.of(THING_ID, PATHS, FROM, TO, null, null, FillStrategy.PREVIOUS, null, + null, null, null, SortOrder.DESC)); + } + + @Test + public void tagFiltersAreEmptyByDefault() { + assertThat(TimeseriesQuery.of(THING_ID, PATHS, FROM, TO).getTagFilters()).isEmpty(); + } + + @Test + public void withTagFiltersRetainsThem() { + final TimeseriesQuery underTest = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO) + .withTagFilters(tagMap("building", "A")); + + assertThat(underTest.getTagFilters()).containsEntry("building", "A"); + } + + @Test + public void tagFiltersRoundTripThroughJson() { + final TimeseriesQuery original = TimeseriesQuery.of(THING_ID, PATHS, FROM, TO) + .withTagFilters(tagMap("building", "A", "floor", "2")); + + final JsonObject json = original.toJson(); + final TimeseriesQuery reconstructed = TimeseriesQuery.fromJson(json); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getTagFilters()).containsEntry("building", "A").containsEntry("floor", "2"); + } + + @Test + public void toJsonOmitsTagFiltersWhenEmpty() { + assertThat(TimeseriesQuery.of(THING_ID, PATHS, FROM, TO).toJson().contains("tagFilters")).isFalse(); + } + + private static Map tagMap(final String... keyValues) { + final Map map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < keyValues.length; i += 2) { + map.put(keyValues[i], keyValues[i + 1]); + } + return map; + } + + private static JsonObjectBuilder baseJson() { + return JsonFactory.newObjectBuilder() + .set("thingId", THING_ID.toString()) + .set("paths", JsonFactory.newArrayBuilder() + .add("/features/environment/properties/temperature") + .add("/features/environment/properties/humidity") + .build()) + .set("from", FROM.toString()) + .set("to", TO.toString()); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMetaTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMetaTest.java new file mode 100644 index 00000000000..71e520c5854 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableTimeseriesResultMetaTest.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableTimeseriesResultMeta}. + */ +public final class ImmutableTimeseriesResultMetaTest { + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableTimeseriesResultMeta.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithUnit() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number"); + + assertThat(underTest.getCount()).isEqualTo(24); + assertThat(underTest.getUnit()).contains("cel"); + assertThat(underTest.getDataType()).isEqualTo("number"); + } + + @Test + public void factoryAcceptsNullUnit() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(0, null, "string"); + + assertThat(underTest.getUnit()).isEmpty(); + } + + @Test + public void factoryAcceptsZeroCount() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(0, null, "number"); + + assertThat(underTest.getCount()).isZero(); + } + + @Test + public void factoryRejectsNegativeCount() { + assertThatIllegalArgumentException() + .isThrownBy(() -> TimeseriesResultMeta.of(-1, null, "number")); + } + + @Test + public void factoryRejectsNullDataType() { + assertThatNullPointerException() + .isThrownBy(() -> TimeseriesResultMeta.of(0, null, null)); + } + + @Test + public void toJsonContainsCountAndDataType() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number"); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("count")).contains(JsonValue.of(24)); + assertThat(json.getValue("dataType")).contains(JsonValue.of("number")); + assertThat(json.getValue("unit")).contains(JsonValue.of("cel")); + } + + @Test + public void toJsonOmitsUnitWhenNull() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, null, "number"); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("unit")).isFalse(); + } + + @Test + public void roundTripPreservesAllFields() { + final TimeseriesResultMeta original = TimeseriesResultMeta.of(24, "cel", "number"); + + final TimeseriesResultMeta reconstructed = TimeseriesResultMeta.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripWithoutUnitPreservesEquality() { + final TimeseriesResultMeta original = TimeseriesResultMeta.of(0, null, "string"); + + final TimeseriesResultMeta reconstructed = TimeseriesResultMeta.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonRejectsMissingCount() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("dataType", "number") + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> TimeseriesResultMeta.fromJson(json)); + } + + @Test + public void fromJsonRejectsMissingDataType() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("count", 0) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> TimeseriesResultMeta.fromJson(json)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesResultMeta.fromJson(null)); + } + + @Test + public void paginationAndTagsAreEmptyByDefault() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number"); + + assertThat(underTest.getNextCursor()).isEmpty(); + assertThat(underTest.getHasMore()).isEmpty(); + assertThat(underTest.getTags()).isEmpty(); + } + + @Test + public void factoryRetainsTagsAndPaginationMeta() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number", + tagMap("building", "A"), true, "abc123"); + + assertThat(underTest.getTags()).containsEntry("building", "A"); + assertThat(underTest.getHasMore()).contains(true); + assertThat(underTest.getNextCursor()).contains("abc123"); + } + + @Test + public void toJsonContainsTagsAndPaginationMetaWhenPresent() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number", + tagMap("building", "A"), true, "abc123"); + + final JsonObject json = underTest.toJson(); + assertThat(json.getValue("tags").map(JsonValue::asObject).flatMap(o -> o.getValue("building"))) + .contains(JsonValue.of("A")); + assertThat(json.getValue("hasMore")).contains(JsonValue.of(true)); + assertThat(json.getValue("nextCursor")).contains(JsonValue.of("abc123")); + } + + @Test + public void toJsonOmitsTagsWhenEmpty() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number", + tagMap(), false, null); + + assertThat(underTest.toJson().contains("tags")).isFalse(); + } + + @Test + public void toJsonOmitsPaginationMetaForNonPaginatedResult() { + final TimeseriesResultMeta underTest = TimeseriesResultMeta.of(24, "cel", "number", + tagMap(), null, null); + + final JsonObject json = underTest.toJson(); + assertThat(json.contains("hasMore")).isFalse(); + assertThat(json.contains("nextCursor")).isFalse(); + } + + @Test + public void roundTripPreservesTagsAndPaginationMeta() { + final TimeseriesResultMeta original = TimeseriesResultMeta.of(24, "cel", "number", + tagMap("building", "A", "floor", "2"), true, "abc123"); + + final TimeseriesResultMeta reconstructed = TimeseriesResultMeta.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getTags()).containsEntry("building", "A").containsEntry("floor", "2"); + } + + @Test + public void factoryRejectsNullTags() { + assertThatNullPointerException() + .isThrownBy(() -> TimeseriesResultMeta.of(0, null, "number", null, null, null)); + } + + private static Map tagMap(final String... keyValues) { + final Map map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < keyValues.length; i += 2) { + map.put(keyValues[i], keyValues[i + 1]); + } + return map; + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotationTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotationTest.java new file mode 100644 index 00000000000..e2d8ea35889 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/ImmutableWotTimeseriesAnnotationTest.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.ditto.base.model.exceptions.DittoJsonException; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonValue; +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link ImmutableWotTimeseriesAnnotation}. + */ +public final class ImmutableWotTimeseriesAnnotationTest { + + private static Map sampleTags() { + final Map map = new LinkedHashMap<>(); + map.put("attributes/building", "{{ thing-json:attributes/building }}"); + map.put("environment", "production"); + return map; + } + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(ImmutableWotTimeseriesAnnotation.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithIngestAndTags() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.ALL, sampleTags()); + + assertThat((Object) underTest.getIngest()).isEqualTo(Ingest.ALL); + assertThat(underTest.isIngestEnabled()).isTrue(); + final Map tags = underTest.getTags(); + assertThat(tags).containsAllEntriesOf(sampleTags()); + } + + @Test + public void factoryAcceptsEmptyTags() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.NONE, Collections.emptyMap()); + + assertThat(underTest.getTags()).isEmpty(); + assertThat(underTest.isIngestEnabled()).isFalse(); + } + + @Test + public void factoryRejectsNullIngest() { + assertThatNullPointerException().isThrownBy(() -> + WotTimeseriesAnnotation.of(null, Collections.emptyMap())); + } + + @Test + public void factoryRejectsNullTags() { + assertThatNullPointerException().isThrownBy(() -> + WotTimeseriesAnnotation.of(Ingest.ALL, null)); + } + + @Test + public void getTagsReturnsAnUnmodifiableMap() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.ALL, sampleTags()); + + final Map tags = underTest.getTags(); + assertThat(tags).isUnmodifiable(); + } + + @Test + public void factoryDefensivelyCopiesTags() { + final Map mutable = new LinkedHashMap<>(); + mutable.put("attributes/building", "A"); + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.ALL, mutable); + + mutable.put("attributes/floor", "9"); + + assertThat(underTest.getTags()).containsOnlyKeys("attributes/building"); + } + + // --- toJson / fromJson round-trip --- + + @Test + public void toJsonContainsIngestAndTags() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.ALL, sampleTags()); + + final JsonObject json = underTest.toJson(); + + assertThat(json.getValue("ingest")).contains(JsonValue.of("ALL")); + assertThat(json.getValue("tags")).isPresent(); + } + + @Test + public void toJsonOmitsTagsWhenEmpty() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.NONE, Collections.emptyMap()); + + final JsonObject json = underTest.toJson(); + + assertThat(json.contains("tags")).isFalse(); + } + + @Test + public void roundTripPreservesAllFields() { + final WotTimeseriesAnnotation original = + WotTimeseriesAnnotation.of(Ingest.ALL, sampleTags()); + + final WotTimeseriesAnnotation reconstructed = + WotTimeseriesAnnotation.fromJson(original.toJson()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonRejectsMissingIngest() { + final JsonObject json = JsonFactory.newObjectBuilder().build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> WotTimeseriesAnnotation.fromJson(json)); + } + + @Test + public void fromJsonRejectsUnknownIngestValue() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("ingest", "OPPORTUNISTIC") + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> WotTimeseriesAnnotation.fromJson(json)) + .withMessageContaining("OPPORTUNISTIC"); + } + + @Test + public void fromJsonRejectsNonStringTagValue() { + final JsonObject tagsJson = JsonFactory.newObjectBuilder().set("attributes/floor", 2).build(); + final JsonObject json = JsonFactory.newObjectBuilder() + .set("ingest", "ALL") + .set("tags", tagsJson) + .build(); + + assertThatExceptionOfType(DittoJsonException.class) + .isThrownBy(() -> WotTimeseriesAnnotation.fromJson(json)); + } + + @Test + public void fromJsonRejectsNullInput() { + assertThatNullPointerException() + .isThrownBy(() -> WotTimeseriesAnnotation.fromJson(null)); + } + + @Test + public void fromJsonAcceptsAnnotationWithoutTags() { + final JsonObject json = JsonFactory.newObjectBuilder().set("ingest", "ALL").build(); + + final WotTimeseriesAnnotation underTest = WotTimeseriesAnnotation.fromJson(json); + + assertThat(underTest.getTags()).isEmpty(); + } + + // --- findInProperty --- + + @Test + public void findInPropertyExtractsAnnotationFromPropertySchema() { + final JsonObject propertySchema = JsonFactory.newObjectBuilder() + .set("type", "number") + .set("unit", "cel") + .set(WotTimeseriesAnnotation.EXTENSION_KEY, JsonFactory.newObjectBuilder() + .set("ingest", "ALL") + .build()) + .build(); + + final Optional found = + WotTimeseriesAnnotation.findInProperty(propertySchema); + + assertThat(found).isPresent(); + assertThat((Object) found.get().getIngest()).isEqualTo(Ingest.ALL); + } + + @Test + public void findInPropertyReturnsEmptyWhenAnnotationAbsent() { + final JsonObject propertySchema = JsonFactory.newObjectBuilder() + .set("type", "number") + .build(); + + final Optional found = + WotTimeseriesAnnotation.findInProperty(propertySchema); + + assertThat(found).isEmpty(); + } + + @Test + public void findInPropertyIgnoresNonObjectAnnotationValue() { + // A malformed model where the extension key is set to a string. We treat this as + // "no annotation" rather than throwing, since the ThingModel-validator catches that case + // separately. + final JsonObject propertySchema = JsonFactory.newObjectBuilder() + .set(WotTimeseriesAnnotation.EXTENSION_KEY, "not-an-object") + .build(); + + final Optional found = + WotTimeseriesAnnotation.findInProperty(propertySchema); + + assertThat(found).isEmpty(); + } + + @Test + public void findInPropertyRejectsNullInput() { + assertThatNullPointerException() + .isThrownBy(() -> WotTimeseriesAnnotation.findInProperty(null)); + } + + @Test + public void toStringIncludesAllFields() { + final WotTimeseriesAnnotation underTest = + WotTimeseriesAnnotation.of(Ingest.ALL, sampleTags()); + + final String s = underTest.toString(); + + assertThat(s).contains("ALL").contains("attributes/building"); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/IngestTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/IngestTest.java new file mode 100644 index 00000000000..9cfbf438b92 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/IngestTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.Test; + +/** + * Unit tests for {@link Ingest}. + */ +public final class IngestTest { + + @Test + public void wireFormatNamesUseUppercaseTokens() { + assertThat(Ingest.ALL.getName()).isEqualTo("ALL"); + assertThat(Ingest.NONE.getName()).isEqualTo("NONE"); + } + + @Test + public void forNameMatchesWireFormat() { + assertThat(Ingest.forName("ALL")).contains(Ingest.ALL); + assertThat(Ingest.forName("NONE")).contains(Ingest.NONE); + } + + @Test + public void forNameIsCaseSensitive() { + assertThat(Ingest.forName("all")).isEmpty(); + assertThat(Ingest.forName("All")).isEmpty(); + } + + @Test + public void forNameReturnsEmptyForUnknownToken() { + assertThat(Ingest.forName("CONDITIONAL")).isEmpty(); + assertThat(Ingest.forName("")).isEmpty(); + } + + @Test + public void forNameRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> Ingest.forName(null)); + } + + @Test + public void toStringReturnsWireFormatName() { + assertThat(Ingest.ALL.toString()).isEqualTo("ALL"); + } + + @Test + public void charSequenceContractDelegatesToName() { + assertThat(Ingest.ALL.length()).isEqualTo(3); + assertThat(Ingest.NONE.charAt(0)).isEqualTo('N'); + assertThat(Ingest.NONE.subSequence(0, 2)).isEqualTo("NO"); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/SortOrderTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/SortOrderTest.java new file mode 100644 index 00000000000..6fe1691ff40 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/SortOrderTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.Test; + +/** + * Unit tests for {@link SortOrder}. + */ +public final class SortOrderTest { + + @Test + public void wireFormatNamesUseLowercaseTokens() { + assertThat(SortOrder.ASC.getName()).isEqualTo("asc"); + assertThat(SortOrder.DESC.getName()).isEqualTo("desc"); + } + + @Test + public void toStringReturnsWireFormatName() { + assertThat(SortOrder.DESC.toString()).isEqualTo("desc"); + } + + @Test + public void forNameMatchesWireFormat() { + assertThat(SortOrder.forName("asc")).contains(SortOrder.ASC); + assertThat(SortOrder.forName("desc")).contains(SortOrder.DESC); + } + + @Test + public void forNameIsCaseSensitive() { + assertThat(SortOrder.forName("ASC")).isEmpty(); + assertThat(SortOrder.forName("Desc")).isEmpty(); + } + + @Test + public void forNameReturnsEmptyForUnknownToken() { + assertThat(SortOrder.forName("ascending")).isEmpty(); + assertThat(SortOrder.forName("")).isEmpty(); + } + + @Test + public void forNameRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> SortOrder.forName(null)); + } + + @Test + public void everyEnumValueResolvesViaForName() { + for (final SortOrder order : SortOrder.values()) { + assertThat(SortOrder.forName(order.getName())).contains(order); + } + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/TimeseriesCursorTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/TimeseriesCursorTest.java new file mode 100644 index 00000000000..f3a1684e7fc --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/TimeseriesCursorTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Base64; + +import org.junit.Test; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link TimeseriesCursor}. + */ +public final class TimeseriesCursorTest { + + private static final Instant TIMESTAMP = Instant.parse("2026-01-15T10:30:00.123Z"); + private static final long REVISION = 42L; + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(TimeseriesCursor.class) + .usingGetClass() + .verify(); + } + + @Test + public void encodeThenDecodeRoundTrips() { + final TimeseriesCursor original = TimeseriesCursor.of(TIMESTAMP, REVISION); + + final TimeseriesCursor reconstructed = TimeseriesCursor.decode(original.encode()); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getTimestamp()).isEqualTo(TIMESTAMP); + assertThat(reconstructed.getRevision()).isEqualTo(REVISION); + } + + @Test + public void encodedTokenIsUrlSafeAndUnpadded() { + final String encoded = TimeseriesCursor.of(TIMESTAMP, REVISION).encode(); + + // Base64-URL alphabet only, no '+', '/', or '=' padding — safe as a bare query-string value. + assertThat(encoded).doesNotContain("+", "/", "="); + } + + @Test + public void factoryRejectsNullTimestamp() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesCursor.of(null, REVISION)); + } + + @Test + public void decodeRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesCursor.decode(null)); + } + + @Test + public void decodeRejectsNonBase64() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesCursor.decode("not base64 !!!")); + } + + @Test + public void decodeRejectsBase64OfNonJson() { + final String garbage = base64Url("this is not json"); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesCursor.decode(garbage)); + } + + @Test + public void decodeRejectsMissingTimestampField() { + final String noTimestamp = base64Url("{\"r\":42}"); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesCursor.decode(noTimestamp)); + } + + @Test + public void decodeRejectsMissingRevisionField() { + final String noRevision = base64Url("{\"t\":\"2026-01-15T10:30:00Z\"}"); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesCursor.decode(noRevision)); + } + + @Test + public void decodeRejectsUnparseableTimestamp() { + final String badTimestamp = base64Url("{\"t\":\"not-a-timestamp\",\"r\":42}"); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesCursor.decode(badTimestamp)); + } + + private static String base64Url(final String raw) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponseTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponseTest.java new file mode 100644 index 00000000000..c5c3f6b67b1 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesResponseTest.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.json.JsonSchemaVersion; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.junit.Test; + +/** + * Unit tests for {@link RetrieveAggregatedTimeseriesResponse}, with particular attention to the + * authorization summary surviving the cluster round-trip: the response is built on the timeseries + * service and re-serialized on the gateway, so anything dropped by {@code toJson}/{@code fromJson} + * silently degrades a partial result into one that looks complete. + */ +public final class RetrieveAggregatedTimeseriesResponseTest { + + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final JsonPointer PATH = JsonPointer.of("/features/circuit/properties/flow"); + private static final JsonPointer OTHER = JsonPointer.of("/features/circuit/properties/ret"); + + private static List results() { + return List.of(AggregatedTimeseriesResult.of(Map.of("building", "A"), PATH, + TimeseriesResultMeta.of(0, "cel", "number"), List.of())); + } + + private static Map withheld() { + final Map w = new LinkedHashMap<>(); + w.put(OTHER.toString(), 1); + return w; + } + + @Test + public void withheldByPathSurvivesJsonRoundTrip() { + final RetrieveAggregatedTimeseriesResponse original = RetrieveAggregatedTimeseriesResponse.of( + NAMESPACE, results(), 4, 0, withheld(), DittoHeaders.empty()); + + final JsonObject json = original.toJson(); + final RetrieveAggregatedTimeseriesResponse parsed = + RetrieveAggregatedTimeseriesResponse.fromJson(json, DittoHeaders.empty()); + + assertThat(parsed.getWithheldByPath()).containsExactlyEntriesOf(withheld()); + assertThat(parsed.isPartial()).isTrue(); + assertThat(parsed.getContributingThings()).isEqualTo(4); + assertThat(parsed.getExcludedThings()).isZero(); + assertThat(parsed).isEqualTo(original); + } + + @Test + public void withheldByPathIsPresentInTheHttpEntity() { + final RetrieveAggregatedTimeseriesResponse response = RetrieveAggregatedTimeseriesResponse.of( + NAMESPACE, results(), 4, 0, withheld(), DittoHeaders.empty()); + + final JsonValue entity = response.getEntity(JsonSchemaVersion.V_2); + + assertThat(entity.isObject()).isTrue(); + final JsonObject auth = entity.asObject().getValue("authorization") + .orElseThrow().asObject(); + assertThat(auth.getValue("partial").orElseThrow().asBoolean()).isTrue(); + // Look the key up literally, by iterating fields: getValue() would + // interpret it as a pointer and could pass even if the key had been wrongly nested. + final JsonObject withheldJson = auth.getValue("withheldByPath").orElseThrow().asObject(); + assertThat(withheldJson.getKeys()).extracting(Object::toString) + .containsExactly(OTHER.toString()); + assertThat(withheldJson.stream() + .filter(f -> f.getKeyName().equals(OTHER.toString())) + .findFirst().orElseThrow().getValue().asInt()).isEqualTo(1); + } + + @Test + public void partialIsFalseAndMapEmptyWhenNothingWithheld() { + final RetrieveAggregatedTimeseriesResponse response = RetrieveAggregatedTimeseriesResponse.of( + NAMESPACE, results(), 4, 0, Collections.emptyMap(), DittoHeaders.empty()); + + assertThat(response.isPartial()).isFalse(); + assertThat(response.getWithheldByPath()).isEmpty(); + assertThat(RetrieveAggregatedTimeseriesResponse.fromJson(response.toJson(), + DittoHeaders.empty()).getWithheldByPath()).isEmpty(); + } + + @Test + public void setEntityPreservesWithheldByPath() { + final RetrieveAggregatedTimeseriesResponse response = RetrieveAggregatedTimeseriesResponse.of( + NAMESPACE, results(), 4, 0, withheld(), DittoHeaders.empty()); + + final RetrieveAggregatedTimeseriesResponse reset = + response.setEntity(response.getEntity(JsonSchemaVersion.V_2)); + + assertThat(reset.getWithheldByPath()).containsExactlyEntriesOf(withheld()); + assertThat(reset.isPartial()).isTrue(); + } + + @Test + public void setDittoHeadersPreservesWithheldByPath() { + final RetrieveAggregatedTimeseriesResponse response = RetrieveAggregatedTimeseriesResponse.of( + NAMESPACE, results(), 4, 0, withheld(), DittoHeaders.empty()); + + final RetrieveAggregatedTimeseriesResponse rehomed = response.setDittoHeaders( + DittoHeaders.newBuilder().correlationId("x").build()); + + assertThat(rehomed.getWithheldByPath()).containsExactlyEntriesOf(withheld()); + assertThat(rehomed.isPartial()).isTrue(); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesTest.java new file mode 100644 index 00000000000..f6eb713843a --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveAggregatedTimeseriesTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.junit.Test; + +/** + * Unit tests for {@link RetrieveAggregatedTimeseries}. + */ +public final class RetrieveAggregatedTimeseriesTest { + + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final JsonPointer PATH = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + + private static CrossThingTimeseriesQuery sampleQuery() { + final List groupBy = Arrays.asList(GroupBy.tag("building"), GroupBy.thingId()); + return CrossThingTimeseriesQuery.of(NAMESPACE, Collections.singletonList(PATH), + Instant.parse("2026-07-01T00:00:00Z"), + Instant.parse("2026-07-02T00:00:00Z"), + Duration.ofHours(1), + Aggregation.AVG, + groupBy, + "eq(attributes/building,'A')", + null, null, null); + } + + private static DittoHeaders sampleHeaders() { + return DittoHeaders.newBuilder() + .correlationId("test-correlation-id") + .responseRequired(true) + .build(); + } + + @Test + public void typeUsesTimeseriesPrefix() { + assertThat(RetrieveAggregatedTimeseries.TYPE) + .isEqualTo("timeseries.commands:retrieveAggregatedTimeseries"); + assertThat(RetrieveAggregatedTimeseries.NAME).isEqualTo("retrieveAggregatedTimeseries"); + } + + @Test + public void factoryRetainsQueryAndHeaders() { + final RetrieveAggregatedTimeseries underTest = + RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.getQuery()).isEqualTo(sampleQuery()); + assertThat(underTest.getNamespace()).isEqualTo(NAMESPACE); + assertThat(underTest.getDittoHeaders()).isEqualTo(sampleHeaders()); + } + + @Test + public void categoryIsQuery() { + assertThat(RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()).getCategory()) + .isEqualTo(Command.Category.QUERY); + } + + /** + * A cross-Thing command targets no single Thing, so it must not present itself as an + * entity-scoped signal — otherwise the edge forwarder would try to route it through the + * per-Thing timeseries shard region. + */ + @Test + public void isNotEntityScoped() { + final RetrieveAggregatedTimeseries underTest = + RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest).isNotInstanceOf( + org.eclipse.ditto.base.model.entity.id.WithEntityId.class); + assertThat((Object) underTest.getResourcePath()).isEqualTo(JsonPointer.empty()); + } + + @Test + public void resourceTypeIsThing() { + // Enforcement resolves thing:/... resource keys, so the resource type must stay "thing". + assertThat(RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()).getResourceType()) + .isEqualTo("thing"); + } + + @Test + public void jsonRoundTrips() { + final RetrieveAggregatedTimeseries underTest = + RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()); + + final JsonObject json = underTest.toJson(); + + assertThat(RetrieveAggregatedTimeseries.fromJson(json, sampleHeaders())).isEqualTo(underTest); + } + + @Test + public void setDittoHeadersReplacesHeaders() { + final RetrieveAggregatedTimeseries underTest = + RetrieveAggregatedTimeseries.of(sampleQuery(), sampleHeaders()); + final DittoHeaders newHeaders = DittoHeaders.newBuilder().correlationId("other").build(); + + final RetrieveAggregatedTimeseries updated = underTest.setDittoHeaders(newHeaders); + + assertThat(updated.getDittoHeaders()).isEqualTo(newHeaders); + assertThat(updated.getQuery()).isEqualTo(underTest.getQuery()); + } + + @Test + public void rejectsNullArguments() { + assertThatNullPointerException() + .isThrownBy(() -> RetrieveAggregatedTimeseries.of(null, sampleHeaders())); + assertThatNullPointerException() + .isThrownBy(() -> RetrieveAggregatedTimeseries.of(sampleQuery(), null)); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponseTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponseTest.java new file mode 100644 index 00000000000..f428031889f --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesResponseTest.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.eclipse.ditto.base.model.common.HttpStatus; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.junit.Test; + +/** + * Unit tests for {@link RetrieveTimeseriesResponse}. + */ +public final class RetrieveTimeseriesResponseTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + + private static TimeseriesQuery sampleQuery() { + return TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + } + + private static TimeseriesQueryResult sampleResult() { + return TimeseriesQueryResult.of( + THING_ID, + PATH, + sampleQuery(), + TimeseriesResultMeta.of(2, "cel", "number"), + Arrays.asList( + TimeseriesDataValue.of(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.3)), + TimeseriesDataValue.of(Instant.parse("2026-01-14T11:00:00Z"), JsonValue.of(22.1)))); + } + + private static DittoHeaders sampleHeaders() { + return DittoHeaders.newBuilder().correlationId("test-correlation-id").build(); + } + + @Test + public void typeUsesTimeseriesResponsesPrefix() { + assertThat(RetrieveTimeseriesResponse.TYPE) + .isEqualTo("timeseries.responses:retrieveTimeseries"); + } + + @Test + public void factoryCreatesResponseWithSingleResult() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + + assertThat((Object) underTest.getEntityId()).isEqualTo(THING_ID); + final List results = underTest.getResults(); + assertThat(results).hasSize(1); + assertThat(underTest.getHttpStatus()).isEqualTo(HttpStatus.OK); + } + + @Test + public void factoryAcceptsEmptyResults() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.emptyList(), sampleHeaders()); + + assertThat(underTest.getResults()).isEmpty(); + } + + @Test + public void factoryRejectsNullThingId() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseriesResponse.of(null, + Collections.singletonList(sampleResult()), sampleHeaders())); + } + + @Test + public void factoryRejectsNullResults() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseriesResponse.of(THING_ID, null, sampleHeaders())); + } + + @Test + public void factoryRejectsNullHeaders() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), null)); + } + + @Test + public void getResultsReturnsAnUnmodifiableList() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + + assertThat(underTest.getResults()).isUnmodifiable(); + } + + @Test + public void getResourceTypeIsThing() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + + assertThat(underTest.getResourceType()).isEqualTo("thing"); + } + + @Test + public void setDittoHeadersReturnsNewResponseWithSwappedHeaders() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + final DittoHeaders newHeaders = DittoHeaders.newBuilder().correlationId("other-id").build(); + + final RetrieveTimeseriesResponse swapped = underTest.setDittoHeaders(newHeaders); + + assertThat(swapped.getDittoHeaders().getCorrelationId()).contains("other-id"); + assertThat(swapped.getResults()).isEqualTo(underTest.getResults()); + } + + @Test + public void roundTripPreservesAllFields() { + final RetrieveTimeseriesResponse original = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + + final JsonObject json = original.toJson(); + final RetrieveTimeseriesResponse reconstructed = + RetrieveTimeseriesResponse.fromJson(json, original.getDittoHeaders()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void roundTripPreservesEmptyResults() { + final RetrieveTimeseriesResponse original = RetrieveTimeseriesResponse.of( + THING_ID, Collections.emptyList(), sampleHeaders()); + + final RetrieveTimeseriesResponse reconstructed = + RetrieveTimeseriesResponse.fromJson(original.toJson(), original.getDittoHeaders()); + + assertThat(reconstructed).isEqualTo(original); + } + + @Test + public void fromJsonRejectsMissingThingId() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("type", RetrieveTimeseriesResponse.TYPE) + .set("status", 200) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> RetrieveTimeseriesResponse.fromJson(json, sampleHeaders())); + } + + @Test + public void fromJsonRejectsMissingResults() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("type", RetrieveTimeseriesResponse.TYPE) + .set("status", 200) + .set("thingId", THING_ID.toString()) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> RetrieveTimeseriesResponse.fromJson(json, sampleHeaders())); + } + + @Test + public void fromJsonRejectsNullJsonObject() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseriesResponse.fromJson(null, sampleHeaders())); + } + + @Test + public void equalsHonoursThingIdAndResultsAndHeaders() { + final RetrieveTimeseriesResponse a = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + final RetrieveTimeseriesResponse b = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + final RetrieveTimeseriesResponse different = RetrieveTimeseriesResponse.of( + THING_ID, Collections.emptyList(), sampleHeaders()); + + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(different); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + public void toStringIncludesThingId() { + final RetrieveTimeseriesResponse underTest = RetrieveTimeseriesResponse.of( + THING_ID, Collections.singletonList(sampleResult()), sampleHeaders()); + + assertThat(underTest.toString()).contains(THING_ID.toString()); + } +} diff --git a/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesTest.java b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesTest.java new file mode 100644 index 00000000000..8b754842713 --- /dev/null +++ b/timeseries/model/src/test/java/org/eclipse/ditto/timeseries/model/signals/commands/RetrieveTimeseriesTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.model.signals.commands; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Collections; + +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.base.model.signals.commands.Command; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonMissingFieldException; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.junit.Test; + +/** + * Unit tests for {@link RetrieveTimeseries}. + */ +public final class RetrieveTimeseriesTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + + private static TimeseriesQuery sampleQuery() { + return TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + } + + private static DittoHeaders sampleHeaders() { + return DittoHeaders.newBuilder() + .correlationId("test-correlation-id") + .responseRequired(true) + .build(); + } + + @Test + public void typeUsesTimeseriesPrefix() { + assertThat(RetrieveTimeseries.TYPE).isEqualTo("timeseries.commands:retrieveTimeseries"); + assertThat(RetrieveTimeseries.NAME).isEqualTo("retrieveTimeseries"); + } + + @Test + public void factoryCreatesCommand() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.getQuery()).isEqualTo(sampleQuery()); + assertThat((Object) underTest.getEntityId()).isEqualTo(THING_ID); + assertThat(underTest.getDittoHeaders().getCorrelationId()).contains("test-correlation-id"); + } + + @Test + public void factoryRejectsNullQuery() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseries.of(null, sampleHeaders())); + } + + @Test + public void factoryRejectsNullHeaders() { + assertThatNullPointerException().isThrownBy(() -> + RetrieveTimeseries.of(sampleQuery(), null)); + } + + @Test + public void getEntityIdReturnsThingIdFromQuery() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat((Object) underTest.getEntityId()).isEqualTo(THING_ID); + } + + @Test + public void getResourceTypeIsThing() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.getResourceType()).isEqualTo("thing"); + } + + @Test + public void getResourcePathIsRoot() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + final JsonPointer path = underTest.getResourcePath(); + assertThat((Object) path).isEqualTo(JsonPointer.empty()); + } + + @Test + public void categoryIsQuery() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.getCategory()).isEqualTo(Command.Category.QUERY); + } + + @Test + public void typePrefixUsesTimeseriesNamespace() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.getTypePrefix()).isEqualTo("timeseries.commands:"); + } + + @Test + public void setDittoHeadersReturnsNewCommandWithSwappedHeaders() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + final DittoHeaders newHeaders = DittoHeaders.newBuilder() + .correlationId("other-id") + .responseRequired(true) + .build(); + + final RetrieveTimeseries swapped = underTest.setDittoHeaders(newHeaders); + + assertThat(swapped.getQuery()).isEqualTo(sampleQuery()); + assertThat(swapped.getDittoHeaders().getCorrelationId()).contains("other-id"); + } + + @Test + public void roundTripPreservesAllFields() { + final RetrieveTimeseries original = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + final JsonObject json = original.toJson(); + final RetrieveTimeseries reconstructed = + RetrieveTimeseries.fromJson(json, original.getDittoHeaders()); + + assertThat(reconstructed).isEqualTo(original); + assertThat(reconstructed.getQuery()).isEqualTo(original.getQuery()); + } + + @Test + public void fromJsonRejectsMissingQuery() { + final JsonObject json = JsonFactory.newObjectBuilder() + .set("type", RetrieveTimeseries.TYPE) + .build(); + + assertThatExceptionOfType(JsonMissingFieldException.class) + .isThrownBy(() -> RetrieveTimeseries.fromJson(json, sampleHeaders())); + } + + @Test + public void equalsHonoursQueryAndHeaders() { + final RetrieveTimeseries a = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + final RetrieveTimeseries b = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + final RetrieveTimeseries differentQuery = RetrieveTimeseries.of( + TimeseriesQuery.of(THING_ID, Collections.singletonList(PATH), + Instant.parse("2026-01-13T00:00:00Z"), + Instant.parse("2026-01-14T00:00:00Z")), + sampleHeaders()); + + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(differentQuery); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + public void toStringIncludesQuery() { + final RetrieveTimeseries underTest = RetrieveTimeseries.of(sampleQuery(), sampleHeaders()); + + assertThat(underTest.toString()).contains("query=").contains(THING_ID.toString()); + } +} diff --git a/timeseries/mongodb/pom.xml b/timeseries/mongodb/pom.xml new file mode 100644 index 00000000000..e7814fb1f2e --- /dev/null +++ b/timeseries/mongodb/pom.xml @@ -0,0 +1,80 @@ + + + + + ditto-timeseries + org.eclipse.ditto + ${revision} + + 4.0.0 + + ditto-timeseries-mongodb + Eclipse Ditto :: Timeseries :: MongoDB Adapter + + + + org.eclipse.ditto + ditto-rql-parser + + + org.eclipse.ditto + ditto-rql-model + + + org.eclipse.ditto + ditto-timeseries-api + + + org.eclipse.ditto + ditto-internal-utils-config + + + + org.eclipse.ditto + ditto-internal-utils-persistence + + + + com.typesafe + config + + + + org.mongodb + mongodb-driver-core + + + org.mongodb + mongodb-driver-reactivestreams + + + + + org.eclipse.ditto + ditto-internal-utils-test + test + + + org.mockito + mockito-core + test + + + + diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfig.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfig.java new file mode 100644 index 00000000000..83840833a12 --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfig.java @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigUtil; +import com.typesafe.config.ConfigValue; + +/** + * Default implementation of {@link MongoDbTimeseriesAdapterConfig}. + *

+ * Connection settings (URI / IAM / pool / SSL) come from the shared {@link MongoDbConfig} that + * the sibling Ditto services also use. Only the time-series-specific tuning + * ({@code collection-prefix}, {@code granularity}, {@code retention}) is loaded from the + * {@code ditto.timeseries.adapter.mongodb} sub-tree of HOCON. + */ +@Immutable +public final class DefaultMongoDbTimeseriesAdapterConfig implements MongoDbTimeseriesAdapterConfig { + + /** + * Default collection-name prefix when {@code collection-prefix} is not set in HOCON. + */ + public static final String DEFAULT_COLLECTION_PREFIX = "ts_"; + + /** + * Default granularity when {@code granularity} is not set in HOCON. + */ + public static final Granularity DEFAULT_GRANULARITY = Granularity.SECONDS; + + /** + * Default scan ceiling (per path) when {@code max-query-result-size} is not set in HOCON. + */ + public static final int DEFAULT_MAX_QUERY_RESULT_SIZE = 1_000_000; + + /** + * Default per-read server-side time budget when {@code query-timeout} is not set in HOCON. + */ + public static final Duration DEFAULT_QUERY_TIMEOUT = Duration.ofSeconds(60); + + /** + * Aggregations MongoDB pushes down to the DB engine by default: the single-accumulator group + * aggregations (version-independent) plus {@code derivative} and {@code integral} (native window + * operators on 5.0+, exact matches for the kernel and bounded server-side). {@code percentile} + * is intentionally absent — native {@code $percentile} is approximate, so it is opt-in — and + * {@code rate} has no native operator. + */ + public static final Set DEFAULT_PUSHABLE_AGGREGATIONS = Collections.unmodifiableSet( + EnumSet.of(Aggregation.AVG, Aggregation.MIN, Aggregation.MAX, Aggregation.SUM, + Aggregation.COUNT, Aggregation.FIRST, Aggregation.LAST, Aggregation.STDDEV, + Aggregation.DERIVATIVE, Aggregation.INTEGRAL)); + + /** + * Fill strategies MongoDB applies natively by default via {@code $densify}/{@code $fill} (5.3+): + * {@code linear} and {@code previous}, both exact matches for the kernel. + */ + public static final Set DEFAULT_NATIVE_FILL_STRATEGIES = Collections.unmodifiableSet( + EnumSet.of(FillStrategy.LINEAR, FillStrategy.PREVIOUS)); + + /** + * Capabilities for a modern MongoDB (5.0+) when the {@code capabilities} block is absent: a + * complete native query, the {@link #DEFAULT_PUSHABLE_AGGREGATIONS} and the + * {@link #DEFAULT_NATIVE_FILL_STRATEGIES}. Kept in sync with the shipped {@code timeseries.conf} + * defaults so tests and production agree. + */ + public static final Capabilities DEFAULT_CAPABILITIES = Capabilities.builder() + .supportsNativeQuery(true) + .supportsNativeCrossThingQuery(true) + .pushableAggregations(DEFAULT_PUSHABLE_AGGREGATIONS) + .nativeFillStrategies(DEFAULT_NATIVE_FILL_STRATEGIES) + .build(); + + private static final String KEY_COLLECTION_PREFIX = "collection-prefix"; + private static final String KEY_GRANULARITY = "granularity"; + private static final String KEY_RETENTION = "retention"; + private static final String KEY_RETENTION_OVERRIDES = "retention-overrides"; + private static final String KEY_MAX_QUERY_RESULT_SIZE = "max-query-result-size"; + private static final String KEY_QUERY_TIMEOUT = "query-timeout"; + private static final String KEY_CAPABILITIES = "capabilities"; + private static final String KEY_CAP_NATIVE_QUERY = "native-query"; + private static final String KEY_CAP_CROSS_THING_QUERY = "cross-thing-query"; + private static final String KEY_CAP_PUSHABLE_AGGREGATIONS = "pushable-aggregations"; + private static final String KEY_CAP_NATIVE_FILL = "native-fill-strategies"; + + /** Sentinel values that disable expiration (mapped to no {@code expireAfter} / {@code collMod off}). */ + private static final java.util.Set UNLIMITED_TOKENS = + java.util.Set.of("", "unlimited", "off", "none"); + + private final MongoDbConfig mongoDbConfig; + private final String collectionPrefix; + private final Granularity granularity; + @Nullable private final Duration retention; + private final Map retentionOverrides; + private final int maxQueryResultSize; + private final Duration queryTimeout; + private final Capabilities capabilities; + + private DefaultMongoDbTimeseriesAdapterConfig(final MongoDbConfig mongoDbConfig, + final String collectionPrefix, + final Granularity granularity, + @Nullable final Duration retention, + final Map retentionOverrides, + final int maxQueryResultSize, + final Duration queryTimeout, + final Capabilities capabilities) { + + this.mongoDbConfig = mongoDbConfig; + this.collectionPrefix = collectionPrefix; + this.granularity = granularity; + this.retention = retention; + this.retentionOverrides = Map.copyOf(retentionOverrides); + this.maxQueryResultSize = maxQueryResultSize; + this.queryTimeout = queryTimeout; + this.capabilities = capabilities; + } + + /** + * Returns a config with the given values and no retention configured. + * + * @param mongoDbConfig the shared Ditto MongoDB connection config (URI, IAM, pool, SSL). + * @param collectionPrefix the collection-name prefix. + * @param granularity the time-series granularity. + * @return the config. + * @throws NullPointerException if any argument is {@code null}. + */ + public static DefaultMongoDbTimeseriesAdapterConfig of(final MongoDbConfig mongoDbConfig, + final String collectionPrefix, + final Granularity granularity) { + + return new DefaultMongoDbTimeseriesAdapterConfig( + checkNotNull(mongoDbConfig, "mongoDbConfig"), + checkNotNull(collectionPrefix, "collectionPrefix"), + checkNotNull(granularity, "granularity"), + null, + Map.of(), + DEFAULT_MAX_QUERY_RESULT_SIZE, + DEFAULT_QUERY_TIMEOUT, + DEFAULT_CAPABILITIES); + } + + /** + * Returns a config with the given values, including an explicit retention duration. + * + * @param mongoDbConfig the shared Ditto MongoDB connection config. + * @param collectionPrefix the collection-name prefix. + * @param granularity the time-series granularity. + * @param retention retention duration; {@code null} disables expiration. + * @return the config. + * @throws NullPointerException if {@code mongoDbConfig}, {@code collectionPrefix} or + * {@code granularity} is {@code null}. + */ + public static DefaultMongoDbTimeseriesAdapterConfig of(final MongoDbConfig mongoDbConfig, + final String collectionPrefix, + final Granularity granularity, + @Nullable final Duration retention) { + + return new DefaultMongoDbTimeseriesAdapterConfig( + checkNotNull(mongoDbConfig, "mongoDbConfig"), + checkNotNull(collectionPrefix, "collectionPrefix"), + checkNotNull(granularity, "granularity"), + retention, + Map.of(), + DEFAULT_MAX_QUERY_RESULT_SIZE, + DEFAULT_QUERY_TIMEOUT, + DEFAULT_CAPABILITIES); + } + + /** + * Loads the config from the {@code ditto.timeseries.adapter.mongodb} sub-tree, applying + * defaults for any absent keys. + * + * @param mongoDbConfig the shared Ditto MongoDB connection config (built from + * {@code ditto.mongodb} by {@code DefaultMongoDbConfig.of(…)}). + * @param adapterConfig the {@code mongodb} sub-tree of {@code ditto.timeseries.adapter}. + * @return the loaded config. + * @throws NullPointerException if either argument is {@code null}. + * @throws DittoConfigError if {@code granularity} is set to an unknown value. + */ + public static DefaultMongoDbTimeseriesAdapterConfig of(final MongoDbConfig mongoDbConfig, + final Config adapterConfig) { + + checkNotNull(mongoDbConfig, "mongoDbConfig"); + checkNotNull(adapterConfig, "adapterConfig"); + + final String collectionPrefix = + stringOrDefault(adapterConfig, KEY_COLLECTION_PREFIX, DEFAULT_COLLECTION_PREFIX); + final Granularity granularity = parseGranularity(adapterConfig); + final Duration retention = parseRetention(adapterConfig, KEY_RETENTION); + final Map retentionOverrides = parseRetentionOverrides(adapterConfig); + final int maxQueryResultSize = adapterConfig.hasPath(KEY_MAX_QUERY_RESULT_SIZE) + ? adapterConfig.getInt(KEY_MAX_QUERY_RESULT_SIZE) + : DEFAULT_MAX_QUERY_RESULT_SIZE; + final Duration queryTimeout = adapterConfig.hasPath(KEY_QUERY_TIMEOUT) + ? adapterConfig.getDuration(KEY_QUERY_TIMEOUT) + : DEFAULT_QUERY_TIMEOUT; + + // Both are safety ceilings, and both have a value that MongoDB reinterprets as "no limit" + // (limit(0) returns all documents; maxTime(0) disables the time budget). Reject those — a + // mis-set guard that silently turns itself off is worse than a clear startup failure. + if (maxQueryResultSize <= 0) { + throw new DittoConfigError("Configuration <" + KEY_MAX_QUERY_RESULT_SIZE + + "> must be a positive number of data points but was <" + maxQueryResultSize + ">."); + } + if (queryTimeout.isZero() || queryTimeout.isNegative()) { + throw new DittoConfigError("Configuration <" + KEY_QUERY_TIMEOUT + + "> must be a positive duration but was <" + queryTimeout + ">."); + } + + return new DefaultMongoDbTimeseriesAdapterConfig(mongoDbConfig, collectionPrefix, + granularity, retention, retentionOverrides, maxQueryResultSize, queryTimeout, + parseCapabilities(adapterConfig)); + } + + /** + * Parses the {@code capabilities} block. Absent block → {@link #DEFAULT_CAPABILITIES}; an + * absent key within the block falls back to that key's default. + */ + private static Capabilities parseCapabilities(final Config adapterConfig) { + if (!adapterConfig.hasPath(KEY_CAPABILITIES)) { + return DEFAULT_CAPABILITIES; + } + final Config config = adapterConfig.getConfig(KEY_CAPABILITIES); + return Capabilities.builder() + .supportsNativeQuery(booleanOrDefault(config, KEY_CAP_NATIVE_QUERY, true)) + .supportsNativeCrossThingQuery( + booleanOrDefault(config, KEY_CAP_CROSS_THING_QUERY, true)) + .pushableAggregations(parsePushableAggregations(config)) + .nativeFillStrategies(parseNativeFillStrategies(config)) + .build(); + } + + private static boolean booleanOrDefault(final Config config, final String key, + final boolean fallback) { + return config.hasPath(key) ? config.getBoolean(key) : fallback; + } + + private static Set parsePushableAggregations(final Config capabilitiesConfig) { + if (!capabilitiesConfig.hasPath(KEY_CAP_PUSHABLE_AGGREGATIONS)) { + return DEFAULT_PUSHABLE_AGGREGATIONS; + } + final EnumSet set = EnumSet.noneOf(Aggregation.class); + for (final String raw : capabilitiesConfig.getStringList(KEY_CAP_PUSHABLE_AGGREGATIONS)) { + set.add(Aggregation.forName(raw).orElseThrow(() -> new DittoConfigError( + "Unknown timeseries.adapter.mongodb." + KEY_CAPABILITIES + "." + + KEY_CAP_PUSHABLE_AGGREGATIONS + " entry <" + raw + ">."))); + } + return set; + } + + private static Set parseNativeFillStrategies(final Config capabilitiesConfig) { + if (!capabilitiesConfig.hasPath(KEY_CAP_NATIVE_FILL)) { + return DEFAULT_NATIVE_FILL_STRATEGIES; + } + final EnumSet set = EnumSet.noneOf(FillStrategy.class); + for (final String raw : capabilitiesConfig.getStringList(KEY_CAP_NATIVE_FILL)) { + set.add(FillStrategy.forName(raw).orElseThrow(() -> new DittoConfigError( + "Unknown timeseries.adapter.mongodb." + KEY_CAPABILITIES + "." + + KEY_CAP_NATIVE_FILL + " entry <" + raw + ">."))); + } + return set; + } + + private static String stringOrDefault(final Config config, final String key, + final String fallback) { + + return config.hasPath(key) ? config.getString(key) : fallback; + } + + /** + * Parses the default retention. Absent or a sentinel token ({@code unlimited}/{@code off}/ + * {@code none}/empty) means no expiration ({@code null}); otherwise a HOCON duration that must + * be strictly positive (MongoDB treats {@code expireAfterSeconds: 0} as "expire immediately"). + */ + @Nullable + private static Duration parseRetention(final Config config, final String key) { + if (!config.hasPath(key)) { + return null; + } + if (isUnlimitedToken(config, key)) { + return null; + } + final Duration duration = config.getDuration(key); + requirePositiveRetention(key, duration); + return duration; + } + + private static Map parseRetentionOverrides(final Config adapterConfig) { + if (!adapterConfig.hasPath(KEY_RETENTION_OVERRIDES)) { + return Map.of(); + } + final Config overridesConfig = adapterConfig.getConfig(KEY_RETENTION_OVERRIDES); + final Map overrides = new LinkedHashMap<>(); + for (final Map.Entry entry : overridesConfig.root().entrySet()) { + // Namespace keys contain dots, which HOCON treats as path separators — quote them so the + // whole namespace is read as a single key rather than a nested path. + final String namespace = entry.getKey(); + final String path = ConfigUtil.quoteString(namespace); + final String overrideKey = KEY_RETENTION_OVERRIDES + "." + namespace; + if (isUnlimitedToken(overridesConfig, path)) { + throw new DittoConfigError("Configuration <" + overrideKey + "> must be a positive " + + "duration; per-namespace \"unlimited\" is not supported — set the default " + + "<" + KEY_RETENTION + "> to unlimited instead."); + } + final Duration duration = overridesConfig.getDuration(path); + requirePositiveRetention(overrideKey, duration); + overrides.put(namespace, duration); + } + return Map.copyOf(overrides); + } + + private static boolean isUnlimitedToken(final Config config, final String path) { + try { + return UNLIMITED_TOKENS.contains(config.getString(path).trim().toLowerCase(java.util.Locale.ROOT)); + } catch (final com.typesafe.config.ConfigException.WrongType e) { + // Not a string (e.g. a number-of-seconds) — let the duration parser handle it. + return false; + } + } + + private static void requirePositiveRetention(final String key, final Duration duration) { + if (duration.isZero() || duration.isNegative()) { + throw new DittoConfigError("Configuration <" + key + "> must be a positive duration " + + "(or \"unlimited\" to disable expiration) but was <" + duration + ">."); + } + } + + private static Granularity parseGranularity(final Config config) { + if (!config.hasPath(KEY_GRANULARITY)) { + return DEFAULT_GRANULARITY; + } + final String raw = config.getString(KEY_GRANULARITY); + return Granularity.forName(raw).orElseThrow(() -> new DittoConfigError( + "Unknown timeseries.adapter.mongodb.granularity <" + raw + ">. " + + "Expected one of: seconds, minutes, hours.")); + } + + @Override + public MongoDbConfig getMongoDbConfig() { + return mongoDbConfig; + } + + @Override + public String getCollectionPrefix() { + return collectionPrefix; + } + + @Override + public Granularity getGranularity() { + return granularity; + } + + @Override + public Optional getRetention() { + return Optional.ofNullable(retention); + } + + @Override + public Map getRetentionOverrides() { + return retentionOverrides; + } + + @Override + public int getMaxQueryResultSize() { + return maxQueryResultSize; + } + + @Override + public Duration getQueryTimeout() { + return queryTimeout; + } + + @Override + public Capabilities getCapabilities() { + return capabilities; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DefaultMongoDbTimeseriesAdapterConfig)) { + return false; + } + final DefaultMongoDbTimeseriesAdapterConfig that = (DefaultMongoDbTimeseriesAdapterConfig) o; + return Objects.equals(mongoDbConfig, that.mongoDbConfig) && + Objects.equals(collectionPrefix, that.collectionPrefix) && + granularity == that.granularity && + Objects.equals(retention, that.retention) && + Objects.equals(retentionOverrides, that.retentionOverrides) && + maxQueryResultSize == that.maxQueryResultSize && + Objects.equals(queryTimeout, that.queryTimeout) && + Objects.equals(capabilities, that.capabilities); + } + + @Override + public int hashCode() { + return Objects.hash(mongoDbConfig, collectionPrefix, granularity, retention, + retentionOverrides, maxQueryResultSize, queryTimeout, capabilities); + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [" + + "mongoDbConfig=" + mongoDbConfig + + ", collectionPrefix=" + collectionPrefix + + ", granularity=" + granularity + + ", retention=" + retention + + ", retentionOverrides=" + retentionOverrides + + ", maxQueryResultSize=" + maxQueryResultSize + + ", queryTimeout=" + queryTimeout + + ", capabilities=" + capabilities + + "]"; + } +} diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/Granularity.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/Granularity.java new file mode 100644 index 00000000000..c77f8bdb74a --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/Granularity.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.util.Arrays; +import java.util.Optional; + +import javax.annotation.concurrent.Immutable; + +/** + * Granularity of a MongoDB Time Series collection. Selects the bucketing strategy MongoDB applies + * internally. Match this to the dominant ingestion cadence: + *

    + *
  • {@link #SECONDS} for sub-minute sampling (high-frequency sensors, high-cadence telemetry).
  • + *
  • {@link #MINUTES} for samples on the order of minutes.
  • + *
  • {@link #HOURS} for samples on the order of hours and slower (attribute timeseries, + * maintenance dates).
  • + *
+ *

+ * Granularity is configured at collection creation and cannot be changed afterwards. + */ +@Immutable +public enum Granularity { + + SECONDS("seconds"), + MINUTES("minutes"), + HOURS("hours"); + + private final String name; + + Granularity(final String name) { + this.name = name; + } + + /** + * @return the MongoDB-compatible token (lowercase) for this granularity. + */ + public String getName() { + return name; + } + + /** + * Returns the {@code Granularity} for the given MongoDB token. + * + * @param name the lowercase token (e.g. {@code "seconds"}). + * @return the matching value or empty if unknown. + * @throws NullPointerException if {@code name} is {@code null}. + */ + public static Optional forName(final CharSequence name) { + checkNotNull(name, "name"); + return Arrays.stream(values()).filter(g -> g.name.contentEquals(name)).findFirst(); + } + + @Override + public String toString() { + return name; + } +} diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapter.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapter.java new file mode 100644 index 00000000000..e62aa054da5 --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapter.java @@ -0,0 +1,1906 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.ThreadSafe; + +import org.bson.Document; +import org.bson.conversions.Bson; +import org.eclipse.ditto.internal.utils.persistence.mongo.DittoMongoClient; +import org.eclipse.ditto.internal.utils.persistence.mongo.MongoClientWrapper; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.api.compute.TimeseriesComputeKernel; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.SortOrder; +import org.eclipse.ditto.timeseries.model.TimeseriesCursor; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.mongodb.client.model.Accumulators; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.BsonField; +import com.mongodb.client.model.CreateCollectionOptions; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Sorts; +import com.mongodb.client.model.TimeSeriesGranularity; +import com.mongodb.client.model.TimeSeriesOptions; +import com.mongodb.reactivestreams.client.FindPublisher; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.MongoDatabase; + +/** + * Default Ditto-shipped {@link TimeseriesAdapter} backed by MongoDB Time Series collections. + *

+ * Phase 1 implements the full lifecycle ({@link #initialize}, {@link #shutdown}, + * {@link #getHealth}), the ingestion paths ({@link #write}, {@link #writeBatch}) and the raw + * read path ({@link #query}). Per-namespace collections are created on first use as native + * MongoDB Time Series collections (i.e. {@code createCollection} with {@code TimeSeriesOptions}) + * — once a collection exists with that storage layout MongoDB applies bucketed columnar storage + * which is what we actually want for timeseries reads. The granularity is configured globally + * (see {@link MongoDbTimeseriesAdapterConfig#getGranularity}); a future phase will pick it + * per-(namespace, cadence) once we have ingestion-side cadence detection. + */ +@ThreadSafe +public final class MongoDbTimeseriesAdapter implements TimeseriesAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(MongoDbTimeseriesAdapter.class); + + /** + * MongoDB field name for the time-series timestamp axis. Stored as the {@code timeField} in the + * collection's TimeSeriesOptions so MongoDB knows which field to bucket on. Mirrors the value + * already in use by {@link TimeseriesBsonMapper#FIELD_TIMESTAMP}. + */ + static final String TIME_FIELD = TimeseriesBsonMapper.FIELD_TIMESTAMP; + + /** + * MongoDB field name for the meta document. Stored as the {@code metaField} in the + * TimeSeriesOptions so MongoDB co-locates points with the same {@code thingId}/{@code path} + * tuple in the same bucket — which is what makes per-thing reads fast. + */ + static final String META_FIELD = "meta"; + + /** {@code _id} sub-field holding the {@code $dateTrunc}ed bucket start of a cross-Thing group. */ + private static final String GROUP_FIELD_BUCKET = "b"; + + /** {@code _id} sub-field holding the series path of a cross-Thing group. */ + private static final String GROUP_FIELD_PATH = "p"; + + /** {@code _id} sub-field holding the Thing ID when grouping by {@code thingId}. */ + private static final String GROUP_FIELD_THING_ID = "t"; + + /** + * Prefix for {@code _id} sub-fields holding a tag dimension. Tag keys are sanitised into this + * namespace because a raw Ditto tag key may contain {@code .} or {@code $}, neither of which is + * legal in a Mongo {@code _id} sub-field name. + */ + private static final String GROUP_FIELD_TAG_PREFIX = "g_"; + + /** Applied when a cross-Thing query does not specify {@code maxGroups}. */ + private static final int DEFAULT_CROSS_THING_MAX_GROUPS = + CrossThingTimeseriesQuery.DEFAULT_MAX_GROUPS; + + private static final int MAX_GROUPS_CEILING = CrossThingTimeseriesQuery.MAX_GROUPS_CEILING; + + private final AtomicReference state = new AtomicReference<>(State.notInitialized()); + + /** + * Per-collection-name "ensured" cache. The first writer for a given namespace triggers an + * idempotent {@code createCollection} with the appropriate {@link TimeSeriesOptions}; concurrent + * writers attach to the same in-flight {@link CompletionStage}. Failed creates are evicted so + * the next caller can retry. + */ + private final ConcurrentHashMap> ensuredCollections = + new ConcurrentHashMap<>(); + + @Override + public Capabilities capabilities() { + // Config-driven: the capabilities block declares what this deployment's MongoDB supports + // (native query, downsampling, pushed-down aggregations, native fill/retention). Defaults + // (DEFAULT_CAPABILITIES) match a modern MongoDB 5.0+. Falls back to the default before the + // adapter is initialised. + final MongoDbTimeseriesAdapterConfig config = state.get().config; + return config != null + ? config.getCapabilities() + : DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_CAPABILITIES; + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + checkNotNull(config, "config"); + if (!(config instanceof MongoDbTimeseriesAdapterConfig)) { + return failedStage(new IllegalArgumentException( + "MongoDbTimeseriesAdapter requires a MongoDbTimeseriesAdapterConfig but got: " + + config.getClass().getName())); + } + final MongoDbTimeseriesAdapterConfig mongoConfig = (MongoDbTimeseriesAdapterConfig) config; + + // MongoClientWrapper gives IAM auth, pool sizing and SSL from the same config the sibling + // services use. The database name is the URI's path segment — there is no separate + // `ditto.mongodb.database` key — and is exposed via getDefaultDatabase(). + final DittoMongoClient client; + final MongoDatabase database; + try { + client = MongoClientWrapper.newInstance(mongoConfig.getMongoDbConfig()); + database = client.getDefaultDatabase(); + } catch (final RuntimeException e) { + return failedStage(e); + } + // MongoClients construction is lazy, so without this probe an unreachable backend would + // still report UP and keep the readiness probe green. + return asVoidStage(database.runCommand(new Document("ping", 1))) + .whenComplete((ignored, throwable) -> { + if (throwable == null) { + state.set(State.initialized(mongoConfig, client, database)); + } else { + try { + client.close(); + } catch (final RuntimeException ignoredClose) { + // Best-effort close — the ping failure is the surfaceable error. + } + } + }); + } + + @Override + public CompletionStage shutdown() { + final State previous = state.getAndSet(State.shutDown()); + if (previous.client != null) { + try { + previous.client.close(); + } catch (final RuntimeException e) { + return failedStage(e); + } + } + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return state.get().health; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + checkNotNull(dataPoint, "dataPoint"); + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + + final Document document; + try { + document = TimeseriesBsonMapper.toDocument(dataPoint); + } catch (final RuntimeException e) { + return failedStage(e); + } + + return ensureCollection(current, dataPoint.getThingId()) + .thenCompose(collection -> asVoidStage(collection.insertOne(document))); + } + + @Override + public CompletionStage writeBatch(final List dataPoints) { + checkNotNull(dataPoints, "dataPoints"); + if (dataPoints.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + + // Group by collection (i.e. by Thing namespace) and issue one insertMany per collection. + // For the MVP we expect callers to batch by Thing already; the loop preserves correctness + // for mixed-namespace batches by walking once and grouping inline. + final java.util.LinkedHashMap> grouped = new java.util.LinkedHashMap<>(); + final java.util.LinkedHashMap firstThingIdByCollection = new java.util.LinkedHashMap<>(); + for (final TimeseriesDataPoint dp : dataPoints) { + checkNotNull(dp, "dataPoint in batch"); + final String name = collectionNameFor(current.config, dp.getThingId()); + final Document document; + try { + document = TimeseriesBsonMapper.toDocument(dp); + } catch (final RuntimeException e) { + return failedStage(e); + } + grouped.computeIfAbsent(name, k -> new ArrayList<>()).add(document); + firstThingIdByCollection.putIfAbsent(name, dp.getThingId()); + } + + CompletionStage chain = CompletableFuture.completedFuture(null); + for (final java.util.Map.Entry> entry : grouped.entrySet()) { + final ThingId firstThingId = firstThingIdByCollection.get(entry.getKey()); + final List docs = entry.getValue(); + chain = chain.thenCompose(ignored -> ensureCollection(current, firstThingId) + .thenCompose(coll -> asVoidStage(coll.insertMany(docs)))); + } + return chain; + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + checkNotNull(query, "query"); + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + + final List paths = query.getPaths(); + if (paths.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + // One query per path, run concurrently and collected in request order; each is bounded + // independently by max-query-result-size. + final List> perPath = new ArrayList<>(paths.size()); + for (final JsonPointer path : paths) { + perPath.add(queryOnePath(current, query, path)); + } + return collectInOrder(perPath); + } + + @Override + public CompletionStage> queryCrossThing( + final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + checkNotNull(query, "query"); + // Required, never a sentinel for "unrestricted" — an absent allow-list would mean an + // unfiltered namespace scan, the one failure mode this parameter exists to prevent. + checkNotNull(permittedThingsPerPath, "permittedThingsPerPath"); + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + // No path with any permitted Thing means the caller established that nothing is readable. + // Treating that as "no filter" would leak the whole namespace, so short-circuit explicitly + // rather than letting an empty match fall through to the pipeline. + if (readablePaths(query, permittedThingsPerPath).isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + final int maxGroups = query.getMaxGroups().orElse(DEFAULT_CROSS_THING_MAX_GROUPS); + final List pipeline = crossThingPipeline(query, permittedThingsPerPath, maxGroups); + final MongoCollection collection = current.database.getCollection( + collectionNameForNamespace(current.config, query.getNamespace()), Document.class); + + // The request-shape line is logged by the calling actor, which has the correlation id in + // scope; what only this layer knows is how long the backend actually took, so that is what + // is reported here (same shape as the single-Thing "query cost" line). + final long startNanos = System.nanoTime(); + return collectAll(collection.aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + // Series assembly + gap fill is CPU work; keep it off the reactive driver thread. + .thenApplyAsync(seriesDocs -> { + LOGGER.debug("Cross-Thing query cost: namespace=<{}> paths={} step=<{}> agg=<{}> " + + "groupBy={} tagFilters={} access=<{}> series=<{}> elapsedMs=<{}>", + query.getNamespace(), query.getPaths(), query.getStep(), + query.getAggregation().getName(), query.getGroupBy(), + query.getFilter().orElse("-"), + describePerPathAccess(query, permittedThingsPerPath), + seriesDocs.size(), (System.nanoTime() - startNanos) / 1_000_000L); + return buildCrossThingResults(query, seriesDocs, maxGroups, + current.config.getMaxQueryResultSize()); + }); + } + + @Override + public CompletionStage>> discoverContributors( + final CrossThingTimeseriesQuery query, final int limit) { + + checkNotNull(query, "query"); + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + + // Same filter as the aggregation but namespace-wide: this call runs BEFORE authorization and + // its whole purpose is to discover what the decision has to be taken about. + final List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(discoveryFilter(query))); + // Group by (path, thingId) so the caller learns which combinations actually carry data, and + // can therefore report exactly which were withheld rather than counting a Thing against a + // path it would have contributed nothing to. + pipeline.add(Aggregates.group(new Document(GROUP_FIELD_PATH, + "$" + TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_PATH) + .append(GROUP_FIELD_THING_ID, + "$" + TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_THING_ID))); + // Thing-major, NOT path-major. The bound below is on rows, but the contract is on distinct + // Things; sorting by path first would let the row limit cut off entire paths, hiding Things + // that only carry data on a later one. + pipeline.add(Aggregates.sort(Sorts.ascending( + "_id." + GROUP_FIELD_THING_ID, "_id." + GROUP_FIELD_PATH))); + // Bound server-side, or a large namespace exhausts heap before the ceiling is checked. + // Rows are Thing-major and a Thing yields at most one row per path, so (limit + 1) * paths + // rows contain limit + 1 distinct Things if they exist. + final long rowBound = ((long) limit + 1) * Math.max(1, query.getPaths().size()); + pipeline.add(Aggregates.limit((int) Math.min(Integer.MAX_VALUE, rowBound))); + + final MongoCollection collection = current.database.getCollection( + collectionNameForNamespace(current.config, query.getNamespace()), Document.class); + + final long startNanos = System.nanoTime(); + return collectAll(collection.aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + .thenApply(docs -> { + final Map> perPath = new LinkedHashMap<>(); + final java.util.Set distinct = new java.util.LinkedHashSet<>(); + for (final Document doc : docs) { + final Document id = doc.get("_id", Document.class); + final Object rawPath = id == null ? null : id.get(GROUP_FIELD_PATH); + final Object rawThing = id == null ? null : id.get(GROUP_FIELD_THING_ID); + if (rawPath == null || rawThing == null) { + continue; + } + final ThingId thingId = ThingId.of(String.valueOf(rawThing)); + // Bound on DISTINCT Things, not on rows: one Thing spanning five paths is one + // Thing to authorize. Allow limit + 1 through so the caller can tell "at the + // ceiling" from "over it". The pipeline's $limit above bounds the rows that + // reach this loop; this bounds the Things that reach the allow-list. + if (!distinct.contains(thingId) && distinct.size() > limit) { + continue; + } + distinct.add(thingId); + perPath.computeIfAbsent(JsonPointer.of(String.valueOf(rawPath)), + k -> new ArrayList<>()).add(thingId); + } + LOGGER.debug("Cross-Thing discovery cost: namespace=<{}> paths={} " + + "distinctThings=<{}> combinations=<{}> elapsedMs=<{}>", + query.getNamespace(), query.getPaths(), distinct.size(), docs.size(), + (System.nanoTime() - startNanos) / 1_000_000L); + return perPath; + }); + } + + /** + * Builds the two-stage aggregation: bucket-and-aggregate per {@code (group, bucket)}, then fold + * each group's buckets into one document per series. Doing the fold in MongoDB means the driver + * returns one document per series rather than one per bucket, and lets the {@code $limit} below + * detect a group-cap overflow without materialising the overflowing series. + */ + // Package-private so the pipeline shape can be asserted without a live MongoDB. + static List crossThingPipeline(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath, + final int maxGroups) { + + final Document bucketKey = new Document(GROUP_FIELD_BUCKET, + dateTruncSpec(query.getStep(), query.getTimezone().map(Object::toString).orElse(null))); + // Results are always reported per path, so the path is part of the series key regardless of + // whether the caller declared `groupBy=path` explicitly. + bucketKey.append(GROUP_FIELD_PATH, "$" + TimeseriesBsonMapper.FIELD_META + "." + + TimeseriesBsonMapper.META_PATH); + for (final GroupBy dimension : query.getGroupBy()) { + if (dimension.getKind() != GroupBy.Kind.PATH) { + bucketKey.append(groupFieldFor(dimension), groupExpressionFor(dimension)); + } + } + + final List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(crossThingFilter(query, permittedThingsPerPath))); + // Pre-sort so $first/$last reflect chronological order rather than storage order. + pipeline.add(Aggregates.sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP))); + pipeline.add(Aggregates.group(bucketKey, + accumulatorFor(query.getAggregation(), "v", "$" + TimeseriesBsonMapper.FIELD_VALUE), + Accumulators.first("unit", "$" + TimeseriesBsonMapper.FIELD_META + "." + + TimeseriesBsonMapper.META_UNIT))); + // Ascending buckets before the fold so each series' pushed points come out chronological. + pipeline.add(Aggregates.sort(Sorts.ascending("_id." + GROUP_FIELD_BUCKET))); + + // Fold: series key = the bucket key minus the bucket itself. + final Document seriesKey = new Document(GROUP_FIELD_PATH, "$_id." + GROUP_FIELD_PATH); + for (final GroupBy dimension : query.getGroupBy()) { + if (dimension.getKind() != GroupBy.Kind.PATH) { + final String field = groupFieldFor(dimension); + seriesKey.append(field, "$_id." + field); + } + } + pipeline.add(Aggregates.group(seriesKey, + Accumulators.push("points", new Document("t", "$_id." + GROUP_FIELD_BUCKET) + .append("v", "$v")), + Accumulators.first("unit", "$unit"))); + pipeline.add(Aggregates.sort(Sorts.ascending("_id"))); + // +1 so an overflow is detectable and can be reported instead of silently truncated. + pipeline.add(Aggregates.limit(maxGroups + 1)); + return pipeline; + } + + /** + * The discovery-phase filter: time range, requested paths, tag predicate — but no Thing + * restriction, because this runs before authorization and its whole purpose is to find out which + * Things a decision has to be taken about. + *

+ * A separate method rather than a {@code null} allow-list. "Pre-authorization discovery" and + * "authorized query" are different operations, and expressing the difference as a nullable + * parameter made an unfiltered namespace scan reachable from the query path by passing + * {@code null} — which is exactly the bypass {@link #queryCrossThing} must not have. + */ + private static Bson discoveryFilter(final CrossThingTimeseriesQuery query) { + return crossThingFilter(query, allPathsUnrestricted(query)); + } + + /** Every requested path, no Thing restriction. Only for {@link #discoveryFilter}. */ + private static Bson allPathsUnrestricted(final CrossThingTimeseriesQuery query) { + return Filters.in(TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_PATH, + query.getPaths().stream().map(JsonPointer::toString).collect(Collectors.toList())); + } + + private static Bson crossThingFilter(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + return crossThingFilter(query, pathAccessFilter(query, permittedThingsPerPath)); + } + + private static Bson crossThingFilter(final CrossThingTimeseriesQuery query, + final Bson pathAccessFilter) { + + final List filters = new ArrayList<>(); + filters.add(Filters.gte(TimeseriesBsonMapper.FIELD_TIMESTAMP, query.getFrom())); + filters.add(Filters.lt(TimeseriesBsonMapper.FIELD_TIMESTAMP, query.getTo())); + filters.add(pathAccessFilter); + // Tag predicates live in the Time Series collection's metaField, so they are index-supported. + // The filter is RQL — Ditto's query language — translated to a filter over meta.tags only, so + // it cannot reach meta.thingId / meta.path and sidestep the per-path allow-list above. + query.getFilter().ifPresent(rql -> filters.add(TimeseriesRqlTranslator.translate(rql))); + filters.add(numericValueFilter()); + return Filters.and(filters); + } + + /** + * Restricts the scan to the {@code (path, Thing)} combinations the caller may read. + *

+ * Permission is path-granular, so this is an {@code $or} of one clause per readable path rather + * than a single Thing-level {@code $in}: a Thing withheld from one property must still contribute + * to another it is entitled to. Both {@code meta.path} and {@code meta.thingId} live in the Time + * Series {@code metaField}, so every clause stays index-supported. + */ + private static Bson pathAccessFilter(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + final String metaPath = TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_PATH; + final String metaThing = TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_THING_ID; + + final List perPath = new ArrayList<>(); + for (final JsonPointer path : readablePaths(query, permittedThingsPerPath)) { + // getOrDefault, never get(): an absent entry means "nothing permitted", never + // "unrestricted". Reading it the other way round would be a data leak. + final Collection permitted = + permittedThingsPerPath.getOrDefault(path, Collections.emptyList()); + perPath.add(Filters.and( + Filters.eq(metaPath, path.toString()), + Filters.in(metaThing, permitted.stream() + .map(ThingId::toString) + .collect(Collectors.toList())))); + } + // Callers short-circuit before reaching here when nothing is readable; this stays defensive so + // an empty $or can never degenerate into "match everything". + return perPath.isEmpty() ? Filters.expr(new Document("$eq", Arrays.asList(1, 0))) + : Filters.or(perPath); + } + + /** The requested paths that have at least one permitted Thing, in request order. */ + private static List readablePaths(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + final List readable = new ArrayList<>(); + for (final JsonPointer path : query.getPaths()) { + final Collection permitted = permittedThingsPerPath.get(path); + if (permitted != null && !permitted.isEmpty()) { + readable.add(path); + } + } + return readable; + } + + /** Compact per-path access summary for the cost log, e.g. {@code flowTemperature=4,return=3}. */ + private static String describePerPathAccess(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) { + + final List parts = new ArrayList<>(); + for (final JsonPointer path : query.getPaths()) { + parts.add(path + "=" + + permittedThingsPerPath.getOrDefault(path, Collections.emptyList()).size()); + } + return String.join(",", parts); + } + + + /** Mongo-safe field name for a grouping dimension inside the {@code _id} sub-document. */ + private static String groupFieldFor(final GroupBy dimension) { + if (dimension.getKind() == GroupBy.Kind.THING_ID) { + return GROUP_FIELD_THING_ID; + } + // A tag key may contain dots (they are legal Ditto pointers) which are not legal as a + // Mongo _id sub-field name, so index tag dimensions positionally and map back afterwards. + return GROUP_FIELD_TAG_PREFIX + dimension.getTagKey().orElseThrow().replace('.', '_') + .replace('$', '_'); + } + + private static String groupExpressionFor(final GroupBy dimension) { + if (dimension.getKind() == GroupBy.Kind.THING_ID) { + return "$" + TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_THING_ID; + } + // A tag key is a Thing path (e.g. "attributes/floor"): it contains '/' and never '.', so it + // cannot be misread as a nested field reference. Tolerate a leading slash for symmetry with + // the RQL filter, which accepts both "/attributes/floor" and "attributes/floor". + final String tagKey = dimension.getTagKey().orElseThrow(); + final String normalised = tagKey.startsWith("/") ? tagKey.substring(1) : tagKey; + return "$" + TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_TAGS + "." + + normalised; + } + + + /** + * Turns the folded series documents into {@link AggregatedTimeseriesResult}s, applying gap fill + * per series in the shared compute kernel so a cross-Thing result fills identically to a + * single-Thing one. + */ + // Package-private so the fold, group cap and gap fill can be unit-tested without a live MongoDB + // (mirrors buildAggregatedResult above). + static List buildCrossThingResults( + final CrossThingTimeseriesQuery query, + final List seriesDocs, + final int maxGroups, + final int maxPoints) { + + if (seriesDocs.size() > maxGroups) { + // Fail loudly. Silently returning the first N series would look like a complete answer + // and quietly misreport any aggregate the caller computes on top of it. + throw TimeseriesQueryInvalidException.newBuilder("The query matches more than " + + maxGroups + " distinct groups. Narrow it with 'filter', fewer " + + "'groupBy' dimensions or a smaller namespace, or raise 'maxGroups' " + + "(ceiling " + MAX_GROUPS_CEILING + ").") + .build(); + } + + final ZoneId zone = query.getTimezone().orElse(null); + final FillStrategy fill = query.getFillStrategy().orElse(null); + final List results = new ArrayList<>(seriesDocs.size()); + + for (final Document seriesDoc : seriesDocs) { + final Document id = seriesDoc.get("_id", Document.class); + final JsonPointer path = JsonPointer.of(id.getString(GROUP_FIELD_PATH)); + + final LinkedHashMap byBucket = new LinkedHashMap<>(); + final List points = seriesDoc.getList("points", Document.class, + Collections.emptyList()); + // The pipeline sorts buckets before pushing, but $group output order is not contractual, + // so sort defensively here — the fill grid depends on ascending order being real. + final List sorted = new ArrayList<>(points); + sorted.sort(Comparator.comparing(doc -> toInstant(doc.get("t")))); + for (final Document point : sorted) { + byBucket.put(toInstant(point.get("t")), toJsonValue(point.get("v"))); + } + + if (fill != null) { + // Same ceiling the single-Thing path applies: fill materialises the entire interior + // grid in memory, so a tiny step over a broad span would otherwise exhaust the heap — + // and here it would do so once per series, up to maxGroups times. + ensureFillGridWithinCeiling(byBucket, query.getStep(), maxPoints, path); + } + final List data = + TimeseriesComputeKernel.fillBuckets(byBucket, query.getStep(), fill, zone); + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), + seriesDoc.getString("unit"), inferAggregatedDataType(data)); + results.add(AggregatedTimeseriesResult.of( + groupIdentityFrom(query, id), path, meta, data)); + } + return results; + } + + /** + * Reconstructs the caller-facing group identity from the Mongo {@code _id}: keys are the original + * {@link GroupBy#getGroupKey()} values, not the sanitised Mongo field names. + */ + private static Map groupIdentityFrom(final CrossThingTimeseriesQuery query, + final Document id) { + + final Map group = new LinkedHashMap<>(); + for (final GroupBy dimension : query.getGroupBy()) { + if (dimension.getKind() == GroupBy.Kind.PATH) { + group.put(dimension.getGroupKey(), id.getString(GROUP_FIELD_PATH)); + } else { + final Object raw = id.get(groupFieldFor(dimension)); + // A point that carries no such tag groups under null; surface it as an explicit + // empty string rather than dropping the key, so every series has the same shape. + group.put(dimension.getGroupKey(), raw == null ? "" : String.valueOf(raw)); + } + } + return group; + } + + private static Instant toInstant(@Nullable final Object raw) { + if (raw instanceof Instant instant) { + return instant; + } + if (raw instanceof java.util.Date date) { + return date.toInstant(); + } + throw new IllegalStateException("Unexpected bucket timestamp type: " + raw); + } + + private static JsonValue toJsonValue(@Nullable final Object raw) { + if (raw == null) { + return JsonValue.nullLiteral(); + } + if (raw instanceof Integer i) { + return JsonValue.of(i); + } + if (raw instanceof Long l) { + return JsonValue.of(l); + } + if (raw instanceof Number n) { + return JsonValue.of(n.doubleValue()); + } + return JsonValue.of(String.valueOf(raw)); + } + + @Override + public CompletionStage> scan(final ThingId thingId, + final JsonPointer path, final Instant from, final Instant to, final int limit) { + + checkNotNull(thingId, "thingId"); + checkNotNull(path, "path"); + checkNotNull(from, "from"); + checkNotNull(to, "to"); + final State current = state.get(); + if (current.health != HealthStatus.UP) { + return failedStage(new IllegalStateException( + "MongoDbTimeseriesAdapter is not initialised.")); + } + + // Same heap-guard semantics as the raw read path: cap at the smaller of the caller's limit + // and the configured ceiling; a non-positive limit means "the ceiling". getCollection (not + // ensureCollection) so a scan never provisions a collection — a find on a missing collection + // yields an empty cursor, which is the desired "no data yet". + final int maxPoints = current.config.getMaxQueryResultSize(); + final int effectiveLimit = (limit <= 0) ? maxPoints : Math.min(limit, maxPoints); + final Bson filter = rangeFilter(thingId, path, from, to); + final FindPublisher find = getCollection(current, thingId).find(filter) + .sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP)) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS) + .limit(effectiveLimit); + return collectAll(find).thenApply(docs -> { + final List out = new ArrayList<>(docs.size()); + for (final Document doc : docs) { + out.add(TimeseriesBsonMapper.toDataValue(doc)); + } + return Collections.unmodifiableList(out); + }); + } + + private static Bson pathFilter(final ThingId thingId, final JsonPointer path, + final TimeseriesQuery query) { + final Bson range = rangeFilter(thingId, path, query.getFrom(), query.getTo()); + final Map tagFilters = query.getTagFilters(); + if (tagFilters.isEmpty()) { + return range; + } + // AND each tag filter as a match on the stored meta.tags sub-field. meta.tags is part of the + // Time Series collection's metaField, so these predicates are index-supported. + final List filters = new ArrayList<>(tagFilters.size() + 1); + filters.add(range); + tagFilters.forEach((key, value) -> + filters.add(Filters.eq("meta." + TimeseriesBsonMapper.META_TAGS + "." + key, value))); + return Filters.and(filters); + } + + private static Bson rangeFilter(final ThingId thingId, final JsonPointer path, + final Instant from, final Instant to) { + return Filters.and( + Filters.eq("meta." + TimeseriesBsonMapper.META_THING_ID, thingId.toString()), + Filters.eq("meta." + TimeseriesBsonMapper.META_PATH, path.toString()), + Filters.gte(TimeseriesBsonMapper.FIELD_TIMESTAMP, Date.from(from)), + Filters.lt(TimeseriesBsonMapper.FIELD_TIMESTAMP, Date.from(to))); + } + + private CompletionStage queryOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path) { + + final long startNanos = System.nanoTime(); + return runQueryStrategy(current, query, path) + .whenComplete((result, error) -> logQueryCost(query, path, result, error, startNanos)); + } + + /** + * Picks and runs the read strategy for a single path: a raw {@code find()} when no + * step/aggregation is set, the {@code $dateTrunc}/{@code $group} downsampling pipeline for a + * bucketed aggregation, or a point-derived window aggregation + * ({@code derivative}/{@code rate}/{@code integral}/{@code percentile}). + */ + private CompletionStage runQueryStrategy(final State current, + final TimeseriesQuery query, + final JsonPointer path) { + + final Optional stepOpt = query.getStep(); + final Optional aggOpt = query.getAggregation(); + if (aggOpt.isPresent()) { + final Aggregation agg = aggOpt.get(); + // group-aggregation-requires-step is enforced at the model layer (TimeseriesQuery.of); + // by the time a command reaches here the invariant holds, so stepOpt is present. + if (agg.requiresStep() && stepOpt.isPresent()) { + return aggregateOnePath(current, query, path, stepOpt.get(), agg); + } + // derivative / rate / integral / percentile — computed from fetched points. + return aggregateAdvancedOnePath(current, query, path, agg); + } + return queryOnePathRaw(current, query, path); + } + + /** + * Emits a per-path query-cost line at DEBUG: the read strategy, query shape (range, step, + * aggregation, fill), the result size and the wall-clock latency. This is the evidence base for + * spotting expensive reads (broad ranges, tiny steps, heavy window aggregations) without + * guessing, and it feeds the push-down/planner decisions of later phases. It is gated on DEBUG + * so it adds no overhead in production unless explicitly enabled; the scan volume of + * the memory-bound window aggregations is logged separately in {@link #fetchRawTimedValues}. + */ + private static void logQueryCost(final TimeseriesQuery query, final JsonPointer path, + @Nullable final TimeseriesQueryResult result, @Nullable final Throwable error, + final long startNanos) { + + if (!LOGGER.isDebugEnabled()) { + return; + } + final long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000L; + final long rangeSeconds = Duration.between(query.getFrom(), query.getTo()).getSeconds(); + final String strategy = query.getAggregation() + .map(agg -> agg.requiresStep() && query.getStep().isPresent() + ? "group-agg" : "advanced-agg") + .orElse("raw"); + final String agg = query.getAggregation().map(Aggregation::getName).orElse("none"); + final String step = query.getStep().map(Duration::toString).orElse("none"); + final String fill = query.getFillStrategy().map(FillStrategy::getName).orElse("none"); + if (error != null) { + LOGGER.debug("Timeseries query cost: thing=<{}> path=<{}> strategy=<{}> agg=<{}> " + + "step=<{}> fill=<{}> rangeSeconds=<{}> elapsedMs=<{}> outcome=", + query.getThingId(), path, strategy, agg, step, fill, rangeSeconds, elapsedMs, + error.getClass().getSimpleName()); + } else { + final long resultPoints = result != null ? result.getMeta().getCount() : 0L; + LOGGER.debug("Timeseries query cost: thing=<{}> path=<{}> strategy=<{}> agg=<{}> " + + "step=<{}> fill=<{}> rangeSeconds=<{}> resultPoints=<{}> elapsedMs=<{}> " + + "outcome=", + query.getThingId(), path, strategy, agg, step, fill, rangeSeconds, resultPoints, + elapsedMs); + } + } + + private CompletionStage queryOnePathRaw(final State current, + final TimeseriesQuery query, + final JsonPointer path) { + + final ThingId thingId = query.getThingId(); + final boolean descending = query.getOrder().orElse(SortOrder.ASC) == SortOrder.DESC; + + // Cursor pagination is keyset-based: when a cursor is present, restrict to points that sort + // strictly after it under the query's order. The model layer has already validated that a + // cursor only reaches here for a single-path raw read, and rejects a malformed one — so + // decode cannot fail at this point. + final Optional cursorOpt = query.getCursor().map(TimeseriesCursor::decode); + final Bson filter = cursorOpt + .map(cursor -> Filters.and(pathFilter(thingId, path, query), + keysetFilter(cursor, descending))) + .orElseGet(() -> pathFilter(thingId, path, query)); + final Optional limitOpt = query.getLimit(); + + // getCollection(), never ensureCollection(): a read of a never-written namespace must not + // provision a collection. find() on a missing collection yields an empty cursor, which is + // the semantics wanted here. Page size is the lesser of the caller's limit and the ceiling. + final int maxPoints = current.config.getMaxQueryResultSize(); + final int pageSize = limitOpt.map(l -> Math.min(l, maxPoints)).orElse(maxPoints); + final MongoCollection collection = getCollection(current, thingId); + final FindPublisher findPublisher = collection.find(filter) + // Deterministic total order (timestamp, then revision as tie-breaker) so a keyset + // cursor resumes exactly after the last returned point — no skipped or repeated + // same-timestamp points. Descending flips both keys so a `desc` read pages from + // newest to oldest. + .sort(orderSort(descending)) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS) + // Fetch one extra row to detect a further page (standard keyset technique); pageSize + // is capped at maxPoints so pageSize + 1 stays within a safe int range. + .limit(pageSize + 1); + return collectAll(findPublisher) + .thenApply(documents -> { + final boolean hasMore = documents.size() > pageSize; + final List page = hasMore ? documents.subList(0, pageSize) : documents; + // When the page is capped (by the caller's limit or the ceiling) and more data + // matches, emit a cursor so the truncation is explicit and resumable rather than + // a silent drop of the remaining points. + final String nextCursor = hasMore ? nextCursorFrom(page).encode() : null; + return buildResult(thingId, path, query, page, hasMore, nextCursor); + }); + } + + /** The {@code (timestamp, revision)} sort in the requested direction. */ + private static Bson orderSort(final boolean descending) { + return descending + ? Sorts.descending(TimeseriesBsonMapper.FIELD_TIMESTAMP, TimeseriesBsonMapper.FIELD_REVISION) + : Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP, TimeseriesBsonMapper.FIELD_REVISION); + } + + /** + * A keyset predicate keeping only points that sort strictly after {@code cursor} under the + * {@code (timestamp, revision)} total order used by the raw read: greater-than for ascending + * reads, less-than for descending ones. + */ + private static Bson keysetFilter(final TimeseriesCursor cursor, final boolean descending) { + final Date timestamp = Date.from(cursor.getTimestamp()); + if (descending) { + return Filters.or( + Filters.lt(TimeseriesBsonMapper.FIELD_TIMESTAMP, timestamp), + Filters.and( + Filters.eq(TimeseriesBsonMapper.FIELD_TIMESTAMP, timestamp), + Filters.lt(TimeseriesBsonMapper.FIELD_REVISION, cursor.getRevision()))); + } + return Filters.or( + Filters.gt(TimeseriesBsonMapper.FIELD_TIMESTAMP, timestamp), + Filters.and( + Filters.eq(TimeseriesBsonMapper.FIELD_TIMESTAMP, timestamp), + Filters.gt(TimeseriesBsonMapper.FIELD_REVISION, cursor.getRevision()))); + } + + /** Builds the cursor pointing just past the last point of a (non-empty) page. */ + private static TimeseriesCursor nextCursorFrom(final List page) { + final Document last = page.get(page.size() - 1); + final Instant timestamp = ((Date) last.get(TimeseriesBsonMapper.FIELD_TIMESTAMP)).toInstant(); + final long revision = ((Number) last.get(TimeseriesBsonMapper.FIELD_REVISION)).longValue(); + return TimeseriesCursor.of(timestamp, revision); + } + + /** + * Downsamples one path into {@code step}-sized buckets using a {@code $dateTrunc} + {@code $group} + * aggregation pipeline, then applies the requested {@link FillStrategy} to interior gaps. + */ + private CompletionStage aggregateOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path, + final Duration step, + final Aggregation agg) { + + final ThingId thingId = query.getThingId(); + final Bson filter = pathFilter(thingId, path, query); + final Document dateTrunc = + dateTruncSpec(step, query.getTimezone().map(Object::toString).orElse(null)); + final BsonField valueAccumulator = + accumulatorFor(agg, "v", "$" + TimeseriesBsonMapper.FIELD_VALUE); + final BsonField unitAccumulator = + Accumulators.first("unit", "$meta." + TimeseriesBsonMapper.META_UNIT); + + final List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(filter)); + // Pre-sort so $first/$last reflect chronological order rather than storage order. + pipeline.add(Aggregates.sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP))); + pipeline.add(Aggregates.group(dateTrunc, valueAccumulator, unitAccumulator)); + pipeline.add(Aggregates.sort(Sorts.ascending("_id"))); + + final int maxPoints = current.config.getMaxQueryResultSize(); + final MongoCollection collection = getCollection(current, thingId); + if (usesNativeFill(current, query)) { + appendNativeFillStages(pipeline, step, query.getFillStrategy().orElseThrow(), maxPoints); + return collectAll(collection.aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + .thenApplyAsync(buckets -> { + // A tiny step over a broad range densifies a huge grid server-side; bound the + // rows pulled into service heap (mirrors the raw/derivative scan ceiling). + if (buckets.size() > maxPoints) { + throw fillGridExceeded(maxPoints, path); + } + return buildNativelyFilledResult(thingId, path, query, buckets); + }); + } + return collectAll(collection.aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + // Bucket assembly + gap fill is CPU work — keep it off the reactive driver thread. + .thenApplyAsync(buckets -> buildAggregatedResult(thingId, path, query, step, buckets, maxPoints)); + } + + /** + * Whether gap-fill should be pushed to MongoDB ({@code $densify}+{@code $fill}) rather than the + * kernel. Only {@code LINEAR} (→ {@code $fill} method {@code linear}) and {@code PREVIOUS} + * (→ {@code locf}) map to native methods, and only when the capabilities declare the + * strategy native. Timezone-aligned queries stay in the kernel because {@code $densify} steps by + * a fixed duration with no timezone parameter, so a calendar (DST-aware) day grid could diverge; + * the kernel's {@code nextBucket} handles that correctly. A genuinely-null aggregate value (e.g. + * single-point {@code STDDEV}) is preserved as a gap via the {@code _wasNull} marker, so no + * aggregation needs to be excluded. + */ + private static boolean usesNativeFill(final State current, final TimeseriesQuery query) { + + final FillStrategy fill = query.getFillStrategy().orElse(null); + return fill != null + && query.getTimezone().isEmpty() + && (fill == FillStrategy.LINEAR || fill == FillStrategy.PREVIOUS) + && current.config.getCapabilities().canFillNatively(fill); + } + + /** + * Appends the native gap-fill stages: tag the real buckets with {@code _real}, {@code $densify} + * the interior grid at {@code step} (bounds {@code "full"} = data min–max, so only interior + * gaps are created — matching the kernel), mark rows that were null before filling + * ({@code _wasNull}), then {@code $fill} the value. A trailing {@code $sort} makes the order + * deterministic and {@code $limit} bounds the rows transferred to the service heap. Densified + * rows lack {@code _real} (rendered as gaps); a real row with {@code _wasNull} is a genuine-null + * bucket and is also a gap — see {@link #buildNativelyFilledResult}. + */ + private static void appendNativeFillStages(final List pipeline, final Duration step, + final FillStrategy fill, final int maxPoints) { + + final TimeseriesComputeKernel.StepUnit stepUnit = TimeseriesComputeKernel.stepUnitFor(step); + pipeline.add(new Document("$addFields", new Document("_real", true))); + pipeline.add(new Document("$densify", new Document("field", "_id") + .append("range", new Document("step", stepUnit.binSize()) + .append("unit", stepUnit.unit()) + .append("bounds", "full")))); + if (fill == FillStrategy.LINEAR) { + // $fill linear raises a TypeMismatch on a non-numeric value; coerce non-numeric (or + // missing) to null so it is filled/treated as a gap — matching the kernel's linearFill, + // which returns a null gap for non-numeric endpoints rather than erroring. + pipeline.add(new Document("$addFields", new Document(TimeseriesBsonMapper.FIELD_VALUE, + new Document("$cond", Arrays.asList( + new Document("$isNumber", "$" + TimeseriesBsonMapper.FIELD_VALUE), + "$" + TimeseriesBsonMapper.FIELD_VALUE, null))))); + } + // Capture null-ness BEFORE $fill: densified rows and any genuine-null real bucket (e.g. a + // single-point STDDEV or an all-null AVG). $fill would otherwise fabricate a value for them. + pipeline.add(new Document("$addFields", + new Document("_wasNull", new Document("$eq", Arrays.asList("$v", null))))); + final String method = fill == FillStrategy.LINEAR ? "linear" : "locf"; + pipeline.add(new Document("$fill", new Document("sortBy", new Document("_id", 1)) + .append("output", new Document("v", new Document("method", method))))); + pipeline.add(Aggregates.sort(Sorts.ascending("_id"))); + pipeline.add(Aggregates.limit(maxPoints + 1)); + } + + /** + * A filter keeping only numeric-valued points, mirroring the kernel's {@code isNumber} filter so + * the native window operators ({@code $derivative}/{@code $integral}) do not raise a + * {@code TypeMismatch} on a string/boolean series (they would surface as HTTP 500). + */ + private static Bson numericValueFilter() { + return new Document("$expr", + new Document("$isNumber", "$" + TimeseriesBsonMapper.FIELD_VALUE)); + } + + /** + * Builds the result from buckets that MongoDB already gap-filled via {@code $densify}+{@code + * $fill}. Rows carrying {@code _real=true} are real aggregated buckets; the rest were densified + * (filled) and are flagged as gaps, so the output matches the kernel's fill exactly. + */ + // Package-private for unit testing the _real-marker gap reconstruction without a live MongoDB. + static TimeseriesQueryResult buildNativelyFilledResult(final ThingId thingId, + final JsonPointer path, final TimeseriesQuery query, final List buckets) { + + final List data = new ArrayList<>(buckets.size()); + String unit = null; + for (final Document bucket : buckets) { + final Object id = bucket.get("_id"); + if (!(id instanceof Date)) { + continue; + } + final Instant t = ((Date) id).toInstant(); + final Document shaped = new Document(TimeseriesBsonMapper.FIELD_TIMESTAMP, id) + .append(TimeseriesBsonMapper.FIELD_VALUE, bucket.get("v")); + // A JSON-null value counts as "no value" (a gap), matching the kernel's toValue: a real + // bucket with a null aggregate is a gap, not a fabricated null data point. + final JsonValue value = TimeseriesBsonMapper.toDataValue(shaped).getValue() + .filter(jsonValue -> !jsonValue.isNull()) + .orElse(null); + if (Boolean.TRUE.equals(bucket.get("_real"))) { + // A real bucket that was null before $fill (_wasNull) is a gap, not the value $fill + // fabricated from its neighbours; a real bucket with a value is a real data point. + final boolean wasNull = Boolean.TRUE.equals(bucket.get("_wasNull")); + data.add(wasNull || value == null + ? TimeseriesDataValue.gap(t, null) + : TimeseriesDataValue.of(t, value)); + if (unit == null) { + unit = bucket.getString("unit"); + } + } else { + data.add(TimeseriesDataValue.gap(t, value)); // densified -> flagged as a gap + } + } + final TimeseriesResultMeta meta = + TimeseriesResultMeta.of(data.size(), unit, inferAggregatedDataType(data)); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + } + + /** + * Builds the {@code $dateTrunc} expression for a step. The {@code (unit, binSize)} pair is derived + * from the step duration; {@code timezone} (when set) aligns bucket boundaries to that zone. + */ + private static Document dateTruncSpec(final Duration step, @Nullable final String timezone) { + final TimeseriesComputeKernel.StepUnit stepUnit = TimeseriesComputeKernel.stepUnitFor(step); + final Document spec = new Document("date", "$" + TimeseriesBsonMapper.FIELD_TIMESTAMP) + .append("unit", stepUnit.unit()) + .append("binSize", stepUnit.binSize()); + if (timezone != null) { + spec.append("timezone", timezone); + } + return new Document("$dateTrunc", spec); + } + + private static BsonField accumulatorFor(final Aggregation agg, final String field, + final String valueExpr) { + return switch (agg) { + case AVG -> Accumulators.avg(field, valueExpr); + case MIN -> Accumulators.min(field, valueExpr); + case MAX -> Accumulators.max(field, valueExpr); + case SUM -> Accumulators.sum(field, valueExpr); + case COUNT -> Accumulators.sum(field, 1); + case FIRST -> Accumulators.first(field, valueExpr); + case LAST -> Accumulators.last(field, valueExpr); + case STDDEV -> Accumulators.stdDevSamp(field, valueExpr); + default -> throw new IllegalStateException("Unsupported group aggregation: " + agg); + }; + } + + private static TimeseriesQueryResult buildAggregatedResult(final ThingId thingId, + final JsonPointer path, + final TimeseriesQuery query, + final Duration step, + final List buckets, + final int maxPoints) { + + // Bucket-start -> aggregated value, preserving Mongo's $dateTrunc alignment (incl. timezone). + final LinkedHashMap byBucket = new LinkedHashMap<>(); + String unit = null; + for (final Document bucket : buckets) { + final Object id = bucket.get("_id"); + if (!(id instanceof Date)) { + continue; + } + final Document shaped = new Document(TimeseriesBsonMapper.FIELD_TIMESTAMP, id) + .append(TimeseriesBsonMapper.FIELD_VALUE, bucket.get("v")); + // A JSON-null aggregate (e.g. single-point STDDEV) is "no value" -> a gap, matching the + // kernel. Without the filter, toDataValue yields a present JSON-null which fillBuckets + // would render as a non-gap value — the round-2 divergence this closes. + final JsonValue value = TimeseriesBsonMapper.toDataValue(shaped).getValue() + .filter(jsonValue -> !jsonValue.isNull()) + .orElse(null); + byBucket.put(((Date) id).toInstant(), value); + if (unit == null) { + unit = bucket.getString("unit"); + } + } + + final FillStrategy fill = query.getFillStrategy().orElse(null); + final ZoneId zone = query.getTimezone().orElse(null); + if (fill != null) { + // Fill materialises the entire interior grid in memory; bound it so a tiny step over a + // broad data span cannot exhaust the heap. + ensureFillGridWithinCeiling(byBucket, step, maxPoints, path); + } + final List data = + TimeseriesComputeKernel.fillBuckets(byBucket, step, fill, zone); + final TimeseriesResultMeta meta = + TimeseriesResultMeta.of(data.size(), unit, inferAggregatedDataType(data)); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + } + + /** + * Refuses (HTTP 400) when a {@code fill} would materialise more than {@code maxPoints} buckets — + * the populated buckets' span divided by the step (an upper bound on the interior grid). + */ + private static void ensureFillGridWithinCeiling(final LinkedHashMap byBucket, + final Duration step, final int maxPoints, final JsonPointer path) { + + if (byBucket.isEmpty()) { + return; + } + final List keys = new ArrayList<>(byBucket.keySet()); + final long spanSeconds = + keys.get(keys.size() - 1).getEpochSecond() - keys.get(0).getEpochSecond(); + final long stepSeconds = Math.max(1L, step.getSeconds()); + if (spanSeconds / stepSeconds > maxPoints) { + throw fillGridExceeded(maxPoints, path); + } + } + + private static TimeseriesQueryInvalidException fillGridExceeded(final int maxPoints, + final JsonPointer path) { + + return TimeseriesQueryInvalidException + .newBuilder("The gap-filled result for <" + path + "> would exceed the maximum of " + + maxPoints + " data points.") + .description("Increase the 'step', narrow the time range (from/to), or omit 'fill'.") + .build(); + } + + private static String inferAggregatedDataType(final List data) { + for (final TimeseriesDataValue v : data) { + final Optional type = inferDataType(v); + if (type.isPresent()) { + return type.get(); + } + } + return "number"; + } + + /** + * Dispatches the window-function-style aggregations that are computed from fetched points rather + * than a single {@code $group} accumulator: {@code derivative}, {@code rate}, {@code integral}, + * {@code percentile}. Semantics follow the design doc (§7.3). + */ + private CompletionStage aggregateAdvancedOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path, + final Aggregation agg) { + + return switch (agg) { + case DERIVATIVE -> derivativeOnePath(current, query, path, false); + case RATE -> derivativeOnePath(current, query, path, true); + case INTEGRAL -> integralOnePath(current, query, path); + case PERCENTILE -> percentileOnePath(current, query, path); + default -> failedStage(new UnsupportedOperationException( + "Aggregation <" + agg.getName() + "> is not supported.")); + }; + } + + /** + * {@code derivative} = (v[n]-v[n-1])/dt (seconds); {@code rate} is the non-negative variant that + * treats a value decrease as a counter reset ({@code v[n]/dt}). When {@code step} is present the + * series is first downsampled to the {@code last} value per bucket, then differenced between + * buckets; otherwise consecutive raw points are differenced. The first point has no predecessor, + * so the result has one fewer point than the source. + */ + private CompletionStage derivativeOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path, + final boolean rate) { + + final ThingId thingId = query.getThingId(); + // Only the plain derivative over raw points is pushed down: `rate` has no native operator + // and the stepped variant stays in the kernel. + if (!rate && query.getStep().isEmpty() + && current.config.getCapabilities().canPushDown(Aggregation.DERIVATIVE)) { + return nativeDerivativeOnePath(current, query, path); + } + final CompletionStage> pointsStage = + query.getStep().isPresent() + ? fetchBucketLast(current, query, path, query.getStep().get()) + : fetchRawTimedValues(current, query, path, rate ? "rate" : "derivative"); + // Differencing is CPU work over the fetched points — keep it off the reactive driver thread. + return pointsStage.thenApplyAsync(points -> { + final List data = TimeseriesComputeKernel.derivative(points, rate); + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + }); + } + + /** + * Native {@code derivative}: a {@code $setWindowFields} pipeline with the {@code $derivative} + * window operator (per-second) over a 2-point trailing window. The first point has no + * predecessor and gets a {@code null} derivative, which is filtered out so the shape matches the + * kernel (one fewer point than the source), each stamped at the later observation. + */ + private CompletionStage nativeDerivativeOnePath(final State current, + final TimeseriesQuery query, final JsonPointer path) { + + final ThingId thingId = query.getThingId(); + final Bson filter = pathFilter(thingId, path, query); + // ~One row per input point, so the same ceiling as the kernel path applies; hitting it is a + // 400, because a derivative over a truncated series silently drops its tail. + final int maxPoints = current.config.getMaxQueryResultSize(); + final List pipeline = List.of( + Aggregates.match(filter), + // Keep only numeric points, mirroring the kernel's toTimePoints filter — $derivative + // raises a TypeMismatch on a non-numeric value (a string series would 500 otherwise). + Aggregates.match(numericValueFilter()), + new Document("$setWindowFields", new Document("sortBy", + new Document(TimeseriesBsonMapper.FIELD_TIMESTAMP, 1)) + .append("output", new Document("d", new Document("$derivative", + new Document("input", "$" + TimeseriesBsonMapper.FIELD_VALUE) + .append("unit", "second")) + .append("window", new Document("documents", List.of(-1, 0)))))), + Aggregates.match(Filters.ne("d", null)), + Aggregates.sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP)), + Aggregates.limit(maxPoints + 1)); + return collectAll(getCollection(current, thingId).aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + .thenApply(docs -> { + if (docs.size() > maxPoints) { + throw scanCeilingExceeded("derivative", maxPoints, path); + } + final List data = new ArrayList<>(docs.size()); + for (final Document doc : docs) { + final Object t = doc.get(TimeseriesBsonMapper.FIELD_TIMESTAMP); + final Object d = doc.get("d"); + if (t instanceof Date && d instanceof Number number) { + data.add(TimeseriesDataValue.of(((Date) t).toInstant(), + JsonValue.of(number.doubleValue()))); + } + } + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + }); + } + + /** + * {@code integral} = trapezoidal area under the curve over consecutive raw points, in + * value-seconds. A single whole-range result is emitted, stamped at the last observation. + * (The design doc reports it as {@code result.integral}; here it is the single data point.) + */ + private CompletionStage integralOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path) { + + final ThingId thingId = query.getThingId(); + if (current.config.getCapabilities().canPushDown(Aggregation.INTEGRAL)) { + return nativeIntegralOnePath(current, query, path); + } + return fetchRawTimedValues(current, query, path, "integral").thenApplyAsync(points -> { + final List data = TimeseriesComputeKernel.integral(points); + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + }); + } + + /** + * Native {@code integral}: a {@code $setWindowFields} pipeline with the {@code $integral} window + * operator (value-seconds) accumulated over the whole series; the last document's running total + * is the result, stamped at the last observation — matching the kernel's trapezoidal integral. + */ + private CompletionStage nativeIntegralOnePath(final State current, + final TimeseriesQuery query, final JsonPointer path) { + + final ThingId thingId = query.getThingId(); + final Bson filter = pathFilter(thingId, path, query); + final List pipeline = List.of( + Aggregates.match(filter), + // Keep only numeric points (matches the kernel); $integral TypeMismatches otherwise. + Aggregates.match(numericValueFilter()), + new Document("$setWindowFields", new Document("sortBy", + new Document(TimeseriesBsonMapper.FIELD_TIMESTAMP, 1)) + .append("output", new Document("ig", new Document("$integral", + new Document("input", "$" + TimeseriesBsonMapper.FIELD_VALUE) + .append("unit", "second")) + .append("window", new Document("documents", + List.of("unbounded", "unbounded")))))), + Aggregates.sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP)), + Aggregates.group(null, + new BsonField("ig", new Document("$last", "$ig")), + new BsonField("t", new Document("$last", + "$" + TimeseriesBsonMapper.FIELD_TIMESTAMP)))); + return collectAll(getCollection(current, thingId).aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + .thenApply(docs -> { + final List data = new ArrayList<>(1); + if (!docs.isEmpty()) { + final Document doc = docs.get(0); + final Object t = doc.get("t"); + final Object ig = doc.get("ig"); + if (t instanceof Date && ig instanceof Number number) { + data.add(TimeseriesDataValue.of(((Date) t).toInstant(), + JsonValue.of(number.doubleValue()))); + } + } + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + }); + } + + /** + * {@code percentile} = the Nth percentile of values per {@code step} bucket (or the whole range + * when no {@code step} is given). Values are gathered per bucket with {@code $push}; the + * percentile is computed in memory via linear interpolation, which is portable across MongoDB + * versions (the {@code $percentile} accumulator is only available on 7.0+). + */ + private CompletionStage percentileOnePath(final State current, + final TimeseriesQuery query, + final JsonPointer path) { + + final ThingId thingId = query.getThingId(); + // Defensive: the model layer (TimeseriesQuery.of) already guarantees a percentile is present + // for the percentile aggregation, so this maps to a 400 rather than ever surfacing a 500. + final double p = query.getPercentile().orElseThrow(() -> TimeseriesQueryInvalidException.newBuilder( + "The percentile aggregation requires a percentile value (0-100).").build()); + final Bson filter = pathFilter(thingId, path, query); + final Object groupId = query.getStep() + .map(step -> (Object) dateTruncSpec(step, + query.getTimezone().map(Object::toString).orElse(null))) + .orElse(null); + + // $percentile (MongoDB 7.0+) is t-digest approximate, so it can differ slightly from the + // kernel's exact interpolation; otherwise gather with $push and compute exactly. + final boolean nativePercentile = + current.config.getCapabilities().canPushDown(Aggregation.PERCENTILE); + final BsonField accumulator = nativePercentile + ? new BsonField("p", new Document("$percentile", + new Document("input", "$" + TimeseriesBsonMapper.FIELD_VALUE) + .append("p", List.of(p / 100.0)) + .append("method", "approximate"))) + : Accumulators.push("vals", "$" + TimeseriesBsonMapper.FIELD_VALUE); + + final List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(filter)); + pipeline.add(Aggregates.group(groupId, accumulator)); + pipeline.add(Aggregates.sort(Sorts.ascending("_id"))); + + // allowDiskUse lifts the 100 MB pipeline memory limit for the $push/$group; the per-bucket + // value array can still hit the 16 MB BSON document cap for an enormous single bucket — a + // step bounds it. Percentile sorting runs off the driver thread via thenApplyAsync. + return collectAll(getCollection(current, thingId).aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)).thenApplyAsync(buckets -> { + final Optional stepOpt = query.getStep(); + if (stepOpt.isPresent()) { + // Bucketed percentile: assemble bucket-start -> value, then apply the fill strategy — + // same as the other bucketed aggregations and the kernel reference path, so gap + // interpolation is not silently dropped. + final LinkedHashMap byBucket = new LinkedHashMap<>(); + for (final Document bucket : buckets) { + final Object id = bucket.get("_id"); + if (!(id instanceof Date)) { + continue; + } + final Double value = nativePercentile + ? nativePercentileValue(bucket) + : kernelPercentileValue(bucket, p); + byBucket.put(((Date) id).toInstant(), value == null ? null : JsonValue.of(value)); + } + final List data = TimeseriesComputeKernel.fillBuckets(byBucket, + stepOpt.get(), query.getFillStrategy().orElse(null), + query.getTimezone().orElse(null)); + final TimeseriesResultMeta meta = + TimeseriesResultMeta.of(data.size(), null, inferAggregatedDataType(data)); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + } + // Whole-range percentile: a single value stamped at the range start; fill does not apply. + final List data = new ArrayList<>(1); + for (final Document bucket : buckets) { + final Double value = nativePercentile + ? nativePercentileValue(bucket) + : kernelPercentileValue(bucket, p); + if (value != null) { + data.add(TimeseriesDataValue.of(query.getFrom(), JsonValue.of(value))); + } + } + final TimeseriesResultMeta meta = TimeseriesResultMeta.of(data.size(), null, "number"); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + }); + } + + /** Reads the value from a native {@code $percentile} bucket (an array with one element). */ + @Nullable + private static Double nativePercentileValue(final Document bucket) { + final List percentiles = bucket.get("p", List.class); + if (percentiles != null && !percentiles.isEmpty() && percentiles.get(0) instanceof Number number) { + return number.doubleValue(); + } + return null; + } + + /** Computes the exact percentile in the kernel from a bucket's {@code $push}ed values. */ + @Nullable + private static Double kernelPercentileValue(final Document bucket, final double p) { + final List rawVals = bucket.get("vals", List.class); + final List nums = new ArrayList<>(); + if (rawVals != null) { + for (final Object o : rawVals) { + if (o instanceof Number number) { + nums.add(number.doubleValue()); + } + } + } + return nums.isEmpty() ? null : TimeseriesComputeKernel.percentile(nums, p); + } + + /** + * Fetches the raw numeric points for a path (ascending by timestamp) to feed a point-derived + * aggregation named {@code aggregationLabel} (e.g. {@code "integral"}, {@code "derivative"}). + *

+ * Unlike the raw read path — which may legitimately return a partial, truncated series — an + * aggregation computed over a truncated series would return a confidently wrong scalar + * (a clipped {@code integral}, a {@code derivative}/{@code rate} missing its tail) under an + * HTTP 200. So when the scan reaches the {@code max-query-result-size} ceiling this + * refuses with a {@link TimeseriesQueryInvalidException} (HTTP 400) instructing the + * caller to narrow the range or downsample with a {@code step}, rather than silently computing + * over incomplete data. Detection is conservative (a result of exactly the ceiling is treated as + * truncated) so an inaccurate answer is never returned; the memory footprint is unchanged from + * the previous bounded scan. + */ + private CompletionStage> fetchRawTimedValues( + final State current, + final TimeseriesQuery query, + final JsonPointer path, + final String aggregationLabel) { + + final ThingId thingId = query.getThingId(); + final int maxPoints = current.config.getMaxQueryResultSize(); + final Bson filter = pathFilter(thingId, path, query); + final FindPublisher find = getCollection(current, thingId).find(filter) + .sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP)) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS) + .limit(maxPoints); + return collectAll(find).thenApplyAsync(docs -> { + if (docs.size() >= maxPoints) { + throw scanCeilingExceeded(aggregationLabel, maxPoints, path); + } + final List out = new ArrayList<>(docs.size()); + for (final Document d : docs) { + final Object ts = d.get(TimeseriesBsonMapper.FIELD_TIMESTAMP); + final Object v = d.get(TimeseriesBsonMapper.FIELD_VALUE); + if (ts instanceof Date && v instanceof Number) { + out.add(new TimeseriesComputeKernel.TimePoint(((Date) ts).toInstant(), + ((Number) v).doubleValue())); + } + } + if (out.size() < docs.size()) { + LOGGER.debug("Skipped {} non-numeric point(s) computing the {} aggregation for " + + "thing <{}> path <{}>.", docs.size() - out.size(), aggregationLabel, thingId, path); + } + // Scan-volume signal for the memory-bound window aggregations: the number of raw points + // pulled into the service (which the small result size hides). Pairs with the per-query + // cost line from logQueryCost to reveal a cheap-looking result that scanned a lot. + LOGGER.debug("Timeseries {} aggregation scanned {} raw point(s) for thing <{}> path <{}>.", + aggregationLabel, docs.size(), thingId, path); + return out; + }); + } + + /** + * Builds the HTTP 400 raised when a point-derived aggregation would have to run over a series + * that reached the {@code max-query-result-size} scan ceiling — i.e. would be computed over + * truncated, incomplete data. + */ + private static TimeseriesQueryInvalidException scanCeilingExceeded(final String aggregationLabel, + final int maxPoints, final JsonPointer path) { + + return TimeseriesQueryInvalidException + .newBuilder("The '" + aggregationLabel + "' aggregation for <" + path + "> reached " + + "the maximum of " + maxPoints + " scanned data points and cannot be computed " + + "accurately over a truncated series.") + .description("Narrow the time range (from/to), or add a 'step' to downsample the " + + "series before aggregating.") + .build(); + } + + /** Downsamples a path to the {@code last} numeric value per {@code step} bucket, ascending. */ + private CompletionStage> fetchBucketLast( + final State current, + final TimeseriesQuery query, + final JsonPointer path, + final Duration step) { + + final Bson filter = pathFilter(query.getThingId(), path, query); + final Document dateTrunc = + dateTruncSpec(step, query.getTimezone().map(Object::toString).orElse(null)); + final List pipeline = new ArrayList<>(); + pipeline.add(Aggregates.match(filter)); + pipeline.add(Aggregates.sort(Sorts.ascending(TimeseriesBsonMapper.FIELD_TIMESTAMP))); + pipeline.add(Aggregates.group(dateTrunc, + Accumulators.last("v", "$" + TimeseriesBsonMapper.FIELD_VALUE))); + pipeline.add(Aggregates.sort(Sorts.ascending("_id"))); + return collectAll(getCollection(current, query.getThingId()).aggregate(pipeline) + .allowDiskUse(true) + .maxTime(current.config.getQueryTimeout().toMillis(), TimeUnit.MILLISECONDS)) + .thenApplyAsync(docs -> { + final List out = new ArrayList<>(); + for (final Document d : docs) { + final Object id = d.get("_id"); + final Object v = d.get("v"); + if (id instanceof Date && v instanceof Number) { + out.add(new TimeseriesComputeKernel.TimePoint(((Date) id).toInstant(), + ((Number) v).doubleValue())); + } + } + return out; + }); + } + + private static TimeseriesQueryResult buildResult(final ThingId thingId, + final JsonPointer path, + final TimeseriesQuery query, + final List documents, + final boolean hasMore, + @Nullable final String nextCursor) { + + final List data = new ArrayList<>(documents.size()); + String unit = null; + String dataType = null; + for (final Document doc : documents) { + final TimeseriesDataValue value = TimeseriesBsonMapper.toDataValue(doc); + data.add(value); + if (unit == null) { + unit = TimeseriesBsonMapper.getStoredUnit(doc).orElse(null); + } + if (dataType == null) { + dataType = inferDataType(value).orElse(null); + } + } + if (dataType == null) { + dataType = "null"; + } + + // Surface the series' tags (representative: the first point's), mirroring how unit is taken. + final Map tags = documents.isEmpty() + ? Map.of() + : TimeseriesBsonMapper.getStoredTags(documents.get(0)); + + final TimeseriesResultMeta meta = + TimeseriesResultMeta.of(documents.size(), unit, dataType, tags, hasMore, nextCursor); + return TimeseriesQueryResult.of(thingId, path, query, meta, data); + } + + private static Optional inferDataType(final TimeseriesDataValue value) { + return value.getValue().map(MongoDbTimeseriesAdapter::dataTypeOf); + } + + private static String dataTypeOf(final JsonValue value) { + if (value.isNumber()) { + return "number"; + } + if (value.isString()) { + return "string"; + } + if (value.isBoolean()) { + return "boolean"; + } + if (value.isNull()) { + return "null"; + } + // Object / array values cannot be ingested (write path rejects them); this branch is a + // defensive fallback. + return "object"; + } + + /** + * Returns the MongoDB collection name used for the Thing's namespace. Collection names are + * derived as {@code } where dots in the namespace are + * replaced with underscores (MongoDB collection-name compatibility). + *

+ * Operator constraint: two namespaces that differ only in {@code .} versus {@code _} + * (e.g. {@code foo.bar} and {@code foo_bar}) collide on the same MongoDB collection name. Ditto + * namespaces in practice use dotted reverse-DNS, so this collision is pathological — keep the + * naming convention dotted-only and the path is unambiguous. + */ + static String collectionNameFor(final MongoDbTimeseriesAdapterConfig config, + final ThingId thingId) { + + return collectionNameForNamespace(config, thingId.getNamespace()); + } + + /** + * Namespace-keyed variant of {@link #collectionNameFor}. Cross-Thing queries know only the + * namespace, never a Thing, so they resolve their collection directly from it — which is exactly + * what makes a within-namespace cross-Thing aggregation a single-collection operation. + */ + static String collectionNameForNamespace(final MongoDbTimeseriesAdapterConfig config, + final String namespace) { + + return config.getCollectionPrefix() + namespace.replace('.', '_'); + } + + private MongoCollection getCollection(final State current, final ThingId thingId) { + final String name = collectionNameFor(current.config, thingId); + return current.database.getCollection(name, Document.class); + } + + /** + * Returns the MongoDB collection for the given Thing, creating it as a native MongoDB + * Time Series collection on first access. Concurrent callers for the same name share one + * in-flight create; failed creates are evicted from the cache so a subsequent call can retry. + *

+ * Idempotency note: {@code createCollection} will fail with NamespaceExists if the collection + * already exists from a previous JVM. We treat that error as success since it just means the + * collection is already configured the way we want — we still cache the "ensured" state so + * the listing/check happens at most once per name per JVM. + */ + private CompletionStage> ensureCollection(final State current, + final ThingId thingId) { + + final String name = collectionNameFor(current.config, thingId); + final String namespace = thingId.getNamespace(); + final MongoCollection coll = current.database.getCollection(name, Document.class); + final CompletionStage ensure = ensuredCollections.computeIfAbsent(name, n -> + ensureCollectionConfigured(current, n, namespace) + .whenComplete((v, ex) -> { + if (ex != null) { + ensuredCollections.remove(n); + } + })); + return ensure.thenApply(ignored -> coll); + } + + /** + * Ensures the per-namespace collection exists and its retention matches the configured + * (per-namespace, else default) value. A missing collection is created with the right + * {@code expireAfter}; an existing one is reconciled via {@code collMod} so a changed retention + * config takes effect without a manual migration. Both branches are idempotent and run at most + * once per collection per JVM (gated by the {@code ensuredCollections} cache). + */ + private static CompletionStage ensureCollectionConfigured(final State current, + final String name, final String namespace) { + + final Optional retention = current.config.getRetention(namespace); + return collectAllNames(current.database).thenCompose(existing -> { + if (existing.contains(name)) { + return reconcileRetention(current, name, retention); + } + final TimeSeriesOptions tsOptions = new TimeSeriesOptions(TIME_FIELD) + .metaField(META_FIELD) + .granularity(toDriverGranularity(current.config.getGranularity())); + final CreateCollectionOptions opts = new CreateCollectionOptions().timeSeriesOptions(tsOptions); + retention.map(Duration::toSeconds) + .ifPresent(seconds -> opts.expireAfter(seconds, java.util.concurrent.TimeUnit.SECONDS)); + return asVoidStage(current.database.createCollection(name, opts)) + // Tolerate the "another writer beat us to it" race — the collection now exists. + // Reconcile its retention so the loser still converges to the configured value; + // other errors propagate so the cache entry is evicted and the caller can retry. + .handle((ignored, throwable) -> throwable) + .thenCompose(throwable -> { + if (throwable == null) { + return CompletableFuture.completedFuture(null); + } + final Throwable cause = throwable instanceof java.util.concurrent.CompletionException + && throwable.getCause() != null ? throwable.getCause() : throwable; + if (cause instanceof com.mongodb.MongoCommandException mce + && (mce.getErrorCode() == 48 || mce.getErrorCode() == 17399)) { + return reconcileRetention(current, name, retention); + } + return CompletableFuture.failedFuture(cause); + }); + }); + } + + /** + * Aligns an existing collection's {@code expireAfter} with {@code retention} via {@code collMod}. + * A present duration sets {@code expireAfterSeconds}; an empty one passes {@code "off"} to remove + * any TTL. Idempotent — re-applying the same value is a no-op on the server. + *

+ * Best-effort: retention reconciliation is maintenance, never a precondition for reading or + * writing data. A failure (e.g. the DB user lacks the {@code collMod} privilege) leaves the + * existing retention untouched, is logged at WARN, and resolves to success so it cannot fail — + * or worse, retry-storm — the ingest/read path that triggered the ensure. The next process + * restart retries it once the cause is fixed. + */ + private static CompletionStage reconcileRetention(final State current, final String name, + final Optional retention) { + + final Object expireAfterSeconds = retention.map(d -> (Object) d.toSeconds()).orElse("off"); + final Document collMod = new Document("collMod", name).append("expireAfterSeconds", expireAfterSeconds); + LOGGER.info("Reconciling timeseries collection <{}> retention to expireAfterSeconds=<{}>.", + name, expireAfterSeconds); + return asVoidStage(current.database.runCommand(collMod)) + .exceptionally(throwable -> { + final Throwable cause = + throwable instanceof java.util.concurrent.CompletionException + && throwable.getCause() != null ? throwable.getCause() : throwable; + LOGGER.warn("Failed to reconcile retention for timeseries collection <{}> to " + + "expireAfterSeconds=<{}>; leaving the existing retention unchanged and " + + "continuing (data flow is unaffected). Cause: {}", + name, expireAfterSeconds, cause.getMessage()); + return null; + }); + } + + private static CompletionStage> collectAllNames(final MongoDatabase database) { + return collectAll(database.listCollectionNames()); + } + + private static TimeSeriesGranularity toDriverGranularity(final Granularity granularity) { + return switch (granularity) { + case SECONDS -> TimeSeriesGranularity.SECONDS; + case MINUTES -> TimeSeriesGranularity.MINUTES; + case HOURS -> TimeSeriesGranularity.HOURS; + }; + } + + /** + * Visible for tests — allows injecting a mock MongoClient + MongoDatabase, bypassing the real + * connection setup performed in {@link #initialize}. + */ + static MongoDbTimeseriesAdapter forTesting(final MongoClient client, + final MongoDatabase database, + final MongoDbTimeseriesAdapterConfig config) { + + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + adapter.state.set(State.initialized(checkNotNull(config, "config"), client, database)); + return adapter; + } + + /** + * Visible for tests — exposes the loaded configuration once {@link #initialize} has completed. + * + * @return the active config, or {@code null} if not initialised. + */ + @Nullable + MongoDbTimeseriesAdapterConfig getConfigOrNull() { + return state.get().config; + } + + /** + * Drains a {@link Publisher} into a {@link CompletionStage} that completes with the list of + * emitted items on {@code onComplete} or exceptionally on {@code onError}. + */ + private static CompletionStage> collectAll(final Publisher publisher) { + final CompletableFuture> future = new CompletableFuture<>(); + final List collected = new ArrayList<>(); + publisher.subscribe(new Subscriber() { + + @Override + public void onSubscribe(final Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(final T item) { + collected.add(item); + } + + @Override + public void onError(final Throwable t) { + future.completeExceptionally(t); + } + + @Override + public void onComplete() { + future.complete(Collections.unmodifiableList(collected)); + } + }); + return future; + } + + /** + * Combines a list of {@link CompletionStage}s into a single stage that completes with the + * results in input order. Failure of any input stage fails the combined stage with the same + * cause. + */ + private static CompletionStage> collectInOrder( + final List> stages) { + + if (stages.isEmpty()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + final CompletableFuture[] array = stages.stream() + .map(CompletionStage::toCompletableFuture) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(array).thenApply(ignored -> { + final List ordered = new ArrayList<>(stages.size()); + for (final CompletionStage stage : stages) { + ordered.add(stage.toCompletableFuture().join()); + } + return Collections.unmodifiableList(ordered); + }); + } + + /** + * Drains a single-completion {@link Publisher} into a {@link CompletionStage} that completes + * with {@code null} on {@code onComplete} or exceptionally on {@code onError}. + */ + private static CompletionStage asVoidStage(final Publisher publisher) { + final CompletableFuture future = new CompletableFuture<>(); + publisher.subscribe(new Subscriber() { + + @Override + public void onSubscribe(final Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(final Object item) { + // discard — the operation is fire-and-forget. + } + + @Override + public void onError(final Throwable t) { + future.completeExceptionally(t); + } + + @Override + public void onComplete() { + future.complete(null); + } + }); + return future; + } + + private static CompletionStage failedStage(final Throwable throwable) { + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(throwable); + return future; + } + + /** + * Snapshot of the adapter's lifecycle state. + */ + private static final class State { + + private final HealthStatus health; + @Nullable private final MongoDbTimeseriesAdapterConfig config; + @Nullable private final MongoClient client; + @Nullable private final MongoDatabase database; + + private State(final HealthStatus health, + @Nullable final MongoDbTimeseriesAdapterConfig config, + @Nullable final MongoClient client, + @Nullable final MongoDatabase database) { + + this.health = health; + this.config = config; + this.client = client; + this.database = database; + } + + static State notInitialized() { + return new State(HealthStatus.DOWN, null, null, null); + } + + static State initialized(final MongoDbTimeseriesAdapterConfig config, + final MongoClient client, + final MongoDatabase database) { + + return new State(HealthStatus.UP, config, client, database); + } + + static State shutDown() { + return new State(HealthStatus.DOWN, null, null, null); + } + } +} diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterConfig.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterConfig.java new file mode 100644 index 00000000000..1281f90cb79 --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterConfig.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; + +/** + * Configuration for the MongoDB Time Series adapter — the default Ditto-shipped implementation of + * {@link org.eclipse.ditto.timeseries.api.TimeseriesAdapter}. + *

+ * The connection itself (URI, IAM auth, pool sizing, SSL) is delegated to {@link MongoDbConfig} + * via {@link #getMongoDbConfig()} — same code path as Ditto's sibling services (things, policies, + * connectivity, …) which feed the {@code pekko-persistence-mongodb} plugin from the same source. + * Only the timeseries-specific tuning (database name lives on {@code MongoDbConfig#getMongoDbDatabaseName()}, + * collection prefix, granularity, retention) is surfaced here. + */ +public interface MongoDbTimeseriesAdapterConfig extends TimeseriesAdapterConfig { + + /** + * @return the shared Ditto MongoDB connection configuration. Carries the URI, IAM settings, + * pool / circuit-breaker / SSL options and is consumed by {@code MongoClientWrapper.newInstance(…)} + * to build the actual MongoDB client. + */ + MongoDbConfig getMongoDbConfig(); + + /** + * @return the prefix used when deriving a per-namespace collection name + * (default {@code "ts_"}). + */ + String getCollectionPrefix(); + + /** + * @return the {@link Granularity} used for new MongoDB Time Series collections. + */ + Granularity getGranularity(); + + /** + * @return the default retention applied to MongoDB Time Series collections, mapped to + * {@code TimeSeriesOptions.expireAfter(seconds)}. Empty disables expiration — documents are + * kept until manually purged. An existing collection is reconciled to this value via + * {@code collMod} the first time its namespace is written to after a restart, so a changed + * value takes effect without manual migration — but not before that namespace next ingests. + * Per-namespace overrides take precedence; see {@link #getRetentionOverrides()} and + * {@link #getRetention(CharSequence)}. + */ + Optional getRetention(); + + /** + * @return per-namespace retention overrides, keyed by Thing namespace (e.g. + * {@code "com.acme.hifreq"}). A namespace present here uses its own retention instead of the + * default from {@link #getRetention()}; absent namespaces fall back to the default. Each value + * is a positive duration. Never {@code null}; may be empty. + */ + Map getRetentionOverrides(); + + /** + * Resolves the effective retention for a Thing namespace: the per-namespace override from + * {@link #getRetentionOverrides()} if present, otherwise the default {@link #getRetention()}. + * + * @param namespace the Thing namespace (the part of the collection identity that selects an + * override). + * @return the effective retention, or empty when neither an override nor a default is set + * (i.e. expiration disabled for that namespace). + */ + default Optional getRetention(final CharSequence namespace) { + final Duration override = getRetentionOverrides().get(String.valueOf(namespace)); + return override != null ? Optional.of(override) : getRetention(); + } + + /** + * @return the maximum number of data points pulled into application memory per path on the raw + * and window-function read paths. Acts as a safety ceiling so an over-broad time range cannot + * exhaust the heap; results that hit it are truncated (and logged). Callers should narrow the + * range, add a {@code limit}, or downsample with a {@code step}. + */ + int getMaxQueryResultSize(); + + /** + * @return the server-side time budget for a single read (find or aggregation), applied as the + * MongoDB {@code maxTime}. Bounds the blast radius of a pathological query. + */ + Duration getQueryTimeout(); + + /** + * @return the capabilities this adapter advertises to the {@code TimeseriesQueryPlanner} — + * native-query support, native downsampling, the aggregations pushed down to the DB engine, the + * fill strategies applied natively, and native retention. Loaded from the {@code capabilities} + * config block; the defaults match a modern MongoDB (5.0+). Override for an older or + * feature-limited MongoDB so the planner routes accordingly. + */ + Capabilities getCapabilities(); +} diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapper.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapper.java new file mode 100644 index 00000000000..858c1a90705 --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapper.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Instant; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import org.bson.Document; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; + +/** + * Maps a {@link TimeseriesDataPoint} into the BSON {@link Document} stored in a MongoDB Time Series + * collection. + *

+ * Document shape (matches the concept document, section 8.3.1): + *

+ * {
+ *   "timestamp": ISODate("2026-01-15T10:30:00.000Z"),
+ *   "meta": {
+ *     "thingId": "org.eclipse.ditto:sensor-1",
+ *     "path": "/features/env/properties/temperature",
+ *     "tags": { "attributes/building": "A" },
+ *     "unit": "cel"
+ *   },
+ *   "value": 23.5,
+ *   "revision": 42
+ * }
+ * 
+ *

+ * Per the per-series type-binding rule from the pre-implementation review (issue #2291 comment), + * only scalar JSON values (number, string, boolean, null) are accepted; objects and arrays are + * rejected with an {@link IllegalArgumentException}, since timeseries backends require fixed types + * per series. + */ +@Immutable +public final class TimeseriesBsonMapper { + + /** Top-level field name for the BSON Date timestamp. */ + static final String FIELD_TIMESTAMP = "timestamp"; + + /** Top-level field name for the metadata sub-document. */ + static final String FIELD_META = "meta"; + + /** Top-level field name for the captured value. */ + static final String FIELD_VALUE = "value"; + + /** Top-level field name for the Thing's revision counter. */ + static final String FIELD_REVISION = "revision"; + + /** Field name for the Thing ID inside {@code meta}. */ + static final String META_THING_ID = "thingId"; + + /** Field name for the Ditto Protocol path inside {@code meta}. */ + static final String META_PATH = "path"; + + /** Field name for the resolved-tags sub-document inside {@code meta}. */ + static final String META_TAGS = "tags"; + + /** Field name for the unit string inside {@code meta}. */ + static final String META_UNIT = "unit"; + + private TimeseriesBsonMapper() { + throw new AssertionError(); + } + + /** + * Converts a {@link TimeseriesDataPoint} to its BSON document representation. + * + * @param dataPoint the data point to convert. + * @return the BSON document ready for insertion into the MongoDB Time Series collection. + * @throws NullPointerException if {@code dataPoint} is {@code null}. + * @throws IllegalArgumentException if the data point's value is a JSON array or JSON object + * (only scalar values are stored in a timeseries). + */ + public static Document toDocument(final TimeseriesDataPoint dataPoint) { + checkNotNull(dataPoint, "dataPoint"); + + final Document meta = buildMeta(dataPoint); + + final Document document = new Document(); + document.append(FIELD_TIMESTAMP, Date.from(dataPoint.getTimestamp())); + document.append(FIELD_META, meta); + document.append(FIELD_VALUE, convertScalarValue(dataPoint.getValue())); + document.append(FIELD_REVISION, dataPoint.getRevision()); + return document; + } + + private static Document buildMeta(final TimeseriesDataPoint dataPoint) { + final Document meta = new Document(); + meta.append(META_THING_ID, dataPoint.getThingId().toString()); + meta.append(META_PATH, dataPoint.getPath().toString()); + + final Map tags = dataPoint.getTags(); + if (!tags.isEmpty()) { + // LinkedHashMap to preserve declared order in the BSON document. + meta.append(META_TAGS, new Document(new LinkedHashMap<>(tags))); + } + + dataPoint.getUnit().ifPresent(unit -> meta.append(META_UNIT, unit)); + + return meta; + } + + /** + * Converts a stored {@link Document} (read from a MongoDB Time Series collection) back into a + * {@link TimeseriesDataValue}. The {@code _gap} flag is never set on a stored value: gaps are + * synthesised at query time by a {@link org.eclipse.ditto.timeseries.model.FillStrategy}, not + * persisted. + * + * @param document the BSON document. + * @return the corresponding data value. + * @throws NullPointerException if {@code document} is {@code null}. + * @throws IllegalArgumentException if the document is missing the required fields or stores a + * value type that cannot be expressed as a {@link JsonValue}. + */ + public static TimeseriesDataValue toDataValue(final Document document) { + checkNotNull(document, "document"); + + final Object timestamp = document.get(FIELD_TIMESTAMP); + if (!(timestamp instanceof Date)) { + throw new IllegalArgumentException( + "Document is missing or has a non-Date <" + FIELD_TIMESTAMP + "> field."); + } + final Instant instant = ((Date) timestamp).toInstant(); + + final Object raw = document.get(FIELD_VALUE); + final JsonValue value = bsonValueToJsonValue(raw); + return TimeseriesDataValue.of(instant, value); + } + + /** + * Returns the {@code meta.unit} value from a stored document, if present. + */ + public static Optional getStoredUnit(final Document document) { + checkNotNull(document, "document"); + final Document meta = (Document) document.get(FIELD_META); + if (meta == null) { + return Optional.empty(); + } + return Optional.ofNullable(meta.getString(META_UNIT)); + } + + /** + * Returns the {@code meta.path} value from a stored document, or {@code null} if absent. + */ + @Nullable + public static String getStoredPath(final Document document) { + checkNotNull(document, "document"); + final Document meta = (Document) document.get(FIELD_META); + return meta == null ? null : meta.getString(META_PATH); + } + + /** + * Returns the {@code meta.tags} of a stored document as a string map, preserving order; empty + * when the document carries no tags. + */ + public static Map getStoredTags(final Document document) { + checkNotNull(document, "document"); + final Document meta = (Document) document.get(FIELD_META); + if (meta == null || !(meta.get(META_TAGS) instanceof Document tagsDoc)) { + return Map.of(); + } + final Map result = new LinkedHashMap<>(); + for (final Map.Entry entry : tagsDoc.entrySet()) { + if (entry.getValue() != null) { + result.put(entry.getKey(), entry.getValue().toString()); + } + } + return result; + } + + private static JsonValue bsonValueToJsonValue(@Nullable final Object raw) { + if (raw == null) { + return JsonFactory.nullLiteral(); + } + if (raw instanceof Boolean) { + return JsonValue.of((boolean) raw); + } + if (raw instanceof Integer) { + return JsonValue.of((int) raw); + } + if (raw instanceof Long) { + return JsonValue.of((long) raw); + } + if (raw instanceof Double || raw instanceof Float) { + return JsonValue.of(((Number) raw).doubleValue()); + } + if (raw instanceof Number) { + // BigDecimal / BigInteger / Decimal128 — fall back to double precision; lossless storage + // requires the application to choose its scalar type up front. + return JsonValue.of(((Number) raw).doubleValue()); + } + if (raw instanceof CharSequence) { + return JsonValue.of(raw.toString()); + } + throw new IllegalArgumentException( + "Unsupported stored timeseries value type: " + raw.getClass().getName()); + } + + /** + * Converts a scalar {@link JsonValue} (number, string, boolean, null) into a BSON-compatible + * Java primitive. Object and array values are rejected. + */ + private static Object convertScalarValue(final JsonValue value) { + if (value.isNull()) { + return null; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isNumber()) { + if (value.isInt()) { + return value.asInt(); + } + if (value.isLong()) { + return value.asLong(); + } + return value.asDouble(); + } + if (value.isString()) { + return value.asString(); + } + // Object or array — reject. Per the per-series type-binding rule, complex values do not + // belong in a timeseries; they belong in the event log. + throw new IllegalArgumentException( + "Cannot store timeseries value of complex JSON type (object or array): " + value + + ". Only number, string, boolean and null values are supported."); + } +} diff --git a/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslator.java b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslator.java new file mode 100644 index 00000000000..ab5ea88a0d5 --- /dev/null +++ b/timeseries/mongodb/src/main/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslator.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import org.bson.conversions.Bson; +import org.eclipse.ditto.rql.model.ParserException; +import org.eclipse.ditto.rql.model.predicates.ast.ExistsNode; +import org.eclipse.ditto.rql.model.predicates.ast.LogicalNode; +import org.eclipse.ditto.rql.model.predicates.ast.MultiComparisonNode; +import org.eclipse.ditto.rql.model.predicates.ast.Node; +import org.eclipse.ditto.rql.model.predicates.ast.RootNode; +import org.eclipse.ditto.rql.model.predicates.ast.SingleComparisonNode; +import org.eclipse.ditto.rql.parser.RqlPredicateParser; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; + +import com.mongodb.client.model.Filters; + +/** + * Translates an RQL predicate over ingest-time tags into a MongoDB filter on the time-series + * {@code metaField}. + *

+ * RQL is Ditto's query language — the same one {@code /api/2/search/things} accepts — so a cross-Thing + * timeseries filter uses it too rather than inventing a second syntax. Field references are the tag + * keys as declared in the WoT model, which are full Thing paths (e.g. {@code attributes/building}), + * and each resolves to {@code meta.tags.}. + *

+ * Two properties of that mapping are worth stating, because they are what make it safe: + *

    + *
  • A tag key is a Thing path, so it contains {@code /} and never {@code .}. It therefore cannot + * be misread by MongoDB as a nested field reference — which is exactly the trap a bare, unvalidated + * tag name would fall into.
  • + *
  • Only the {@code meta.tags} sub-document is addressable. A caller cannot reach + * {@code meta.thingId}, {@code meta.path} or the measurement value through the filter, so the + * filter cannot be used to sidestep the per-path authorization allow-list.
  • + *
+ * Tags are frozen on each data point at ingest, so this selects points by the state of the + * world when they were recorded — not Things by their current state. + * + * @since 4.0.0 + */ +final class TimeseriesRqlTranslator { + + private static final String TAG_FIELD_PREFIX = + TimeseriesBsonMapper.FIELD_META + "." + TimeseriesBsonMapper.META_TAGS + "."; + + private TimeseriesRqlTranslator() { + throw new AssertionError(); + } + + /** + * Parses {@code rql} and translates it into a MongoDB filter. + * + * @param rql the RQL predicate, e.g. {@code and(eq(attributes/building,'A'),ge(attributes/floor,2))}. + * @return the equivalent filter over {@code meta.tags.*}. + * @throws TimeseriesQueryInvalidException if {@code rql} is unparseable or uses an unsupported + * operator. + */ + static Bson translate(final String rql) { + return toFilter(parse(rql)); + } + + private static RootNode parse(final String rql) { + try { + return RqlPredicateParser.getInstance().parse(rql); + } catch (final ParserException e) { + throw TimeseriesQueryInvalidException + .newBuilder("The 'filter' is not a valid RQL predicate: " + e.getMessage()) + .description("Use RQL as on /api/2/search/things, e.g. " + + "eq(attributes/building,'A') or and(eq(a,'x'),ge(b,2)).") + .build(); + } + } + + private static Bson toFilter(final Node node) { + if (node instanceof RootNode) { + final List children = ((RootNode) node).getChildren(); + if (children.isEmpty()) { + // An empty predicate selects everything; represented as an empty $and so callers can + // compose it unconditionally. + return Filters.and(new ArrayList<>()); + } + return children.size() == 1 ? toFilter(children.get(0)) : Filters.and(map(children)); + } + if (node instanceof LogicalNode) { + final LogicalNode logical = (LogicalNode) node; + final List children = map(logical.getChildren()); + switch (logical.getType()) { + case AND: + return Filters.and(children); + case OR: + return Filters.or(children); + case NOT: + if (children.size() != 1) { + throw unsupported("not() takes exactly one argument"); + } + return Filters.nor(children); + default: + throw unsupported("logical operator <" + logical.getName() + ">"); + } + } + if (node instanceof SingleComparisonNode) { + return single((SingleComparisonNode) node); + } + if (node instanceof MultiComparisonNode) { + final MultiComparisonNode multi = (MultiComparisonNode) node; + // IN is the only multi-comparison RQL defines. + return Filters.in(tagField(multi.getComparisonProperty()), multi.getComparisonValue()); + } + if (node instanceof ExistsNode) { + return Filters.exists(tagField(((ExistsNode) node).getProperty())); + } + throw unsupported("expression <" + node.getClass().getSimpleName() + ">"); + } + + private static Bson single(final SingleComparisonNode node) { + final String field = tagField(node.getComparisonProperty()); + final Object value = node.getComparisonValue(); + switch (node.getComparisonType()) { + case EQ: + return Filters.eq(field, value); + case NE: + return Filters.ne(field, value); + case GT: + return Filters.gt(field, value); + case GE: + return Filters.gte(field, value); + case LT: + return Filters.lt(field, value); + case LE: + return Filters.lte(field, value); + case LIKE: + return Filters.regex(field, likeToRegex(String.valueOf(value))); + case ILIKE: + return Filters.regex(field, likeToRegex(String.valueOf(value)), + // 'i' for case-insensitive, matching RQL's ilike semantics. + "i"); + default: + throw unsupported("comparison operator <" + node.getComparisonType() + ">"); + } + } + + /** + * Converts RQL's {@code like} wildcards to a regex. {@code *} matches any run of characters and + * {@code ?} a single one; everything else is quoted, so a value containing regex metacharacters + * cannot smuggle a pattern in. + */ + private static String likeToRegex(final String like) { + final StringBuilder regex = new StringBuilder("^"); + final StringBuilder literal = new StringBuilder(); + for (int i = 0; i < like.length(); i++) { + final char c = like.charAt(i); + if (c == '*' || c == '?') { + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + literal.setLength(0); + } + regex.append(c == '*' ? ".*" : "."); + } else { + literal.append(c); + } + } + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + } + return regex.append('$').toString(); + } + + private static List map(final List nodes) { + final List filters = new ArrayList<>(nodes.size()); + for (final Node child : nodes) { + filters.add(toFilter(child)); + } + return filters; + } + + /** + * Maps an RQL field reference onto the stored tag. Leading slashes are tolerated so + * {@code /attributes/building} and {@code attributes/building} address the same tag, matching how + * Ditto treats pointer-ish field names elsewhere. + */ + private static String tagField(final String property) { + final String key = property.startsWith("/") ? property.substring(1) : property; + if (key.isEmpty()) { + throw unsupported("an empty field reference"); + } + if (key.indexOf('.') >= 0 || key.indexOf('$') >= 0) { + // Would be read by MongoDB as a nested path / operator rather than a tag key. + throw unsupported("field reference <" + property + ">: '.' and '$' are not allowed in a tag key"); + } + return TAG_FIELD_PREFIX + key; + } + + private static TimeseriesQueryInvalidException unsupported(final String what) { + return TimeseriesQueryInvalidException + .newBuilder("The 'filter' uses " + what + ", which is not supported for timeseries tags.") + .description("Supported: eq, ne, gt, ge, lt, le, in, like, ilike, exists, and, or, not " + + "over tag keys declared in the WoT model.") + .build(); + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfigTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfigTest.java new file mode 100644 index 00000000000..0cc008745fd --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/DefaultMongoDbTimeseriesAdapterConfigTest.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.Mockito.mock; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.junit.Test; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +import nl.jqno.equalsverifier.EqualsVerifier; + +/** + * Unit tests for {@link DefaultMongoDbTimeseriesAdapterConfig}. + */ +public final class DefaultMongoDbTimeseriesAdapterConfigTest { + + @Test + public void hashCodeAndEqualsContract() { + EqualsVerifier.forClass(DefaultMongoDbTimeseriesAdapterConfig.class) + .usingGetClass() + .verify(); + } + + @Test + public void factoryCreatesInstanceWithGivenValues() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, "x_", Granularity.MINUTES); + + assertThat(underTest.getMongoDbConfig()).isSameAs(mongoDbConfig); + assertThat(underTest.getCollectionPrefix()).isEqualTo("x_"); + assertThat(underTest.getGranularity()).isEqualTo(Granularity.MINUTES); + assertThat(underTest.getRetention()).isEmpty(); + assertThat(underTest.getMaxQueryResultSize()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_MAX_QUERY_RESULT_SIZE); + assertThat(underTest.getQueryTimeout()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_QUERY_TIMEOUT); + } + + @Test + public void factoryRejectsNullMongoDbConfig() { + assertThatNullPointerException().isThrownBy(() -> + DefaultMongoDbTimeseriesAdapterConfig.of(null, "ts_", Granularity.SECONDS)); + } + + @Test + public void factoryRejectsNullCollectionPrefix() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + assertThatNullPointerException().isThrownBy(() -> + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, null, Granularity.SECONDS)); + } + + @Test + public void factoryRejectsNullGranularity() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + assertThatNullPointerException().isThrownBy(() -> + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, "ts_", null)); + } + + @Test + public void ofConfigAppliesDefaultsWhenAdapterConfigIsEmpty() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.empty(); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig); + + assertThat(underTest.getMongoDbConfig()).isSameAs(mongoDbConfig); + assertThat(underTest.getCollectionPrefix()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_COLLECTION_PREFIX); + assertThat(underTest.getGranularity()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_GRANULARITY); + assertThat(underTest.getRetention()).isEmpty(); + assertThat(underTest.getMaxQueryResultSize()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_MAX_QUERY_RESULT_SIZE); + assertThat(underTest.getQueryTimeout()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_QUERY_TIMEOUT); + } + + @Test + public void ofConfigReadsAllValuesWhenPresent() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Map map = new HashMap<>(); + map.put("collection-prefix", "tsd_"); + map.put("granularity", "hours"); + map.put("retention", "30d"); + map.put("max-query-result-size", 500_000); + map.put("query-timeout", "30s"); + final Config adapterConfig = ConfigFactory.parseMap(map); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig); + + assertThat(underTest.getCollectionPrefix()).isEqualTo("tsd_"); + assertThat(underTest.getGranularity()).isEqualTo(Granularity.HOURS); + assertThat(underTest.getRetention()).isPresent(); + assertThat(underTest.getMaxQueryResultSize()).isEqualTo(500_000); + assertThat(underTest.getQueryTimeout()).isEqualTo(Duration.ofSeconds(30)); + } + + @Test + public void ofConfigDefaultsCapabilitiesWhenBlockAbsent() { + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mock(MongoDbConfig.class), ConfigFactory.empty()); + + assertThat(underTest.getCapabilities()) + .isEqualTo(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_CAPABILITIES); + } + + @Test + public void ofConfigReadsCapabilitiesBlock() { + final Config adapterConfig = ConfigFactory.parseString( + "capabilities { native-query = false, " + + "pushable-aggregations = [\"avg\", \"percentile\"], " + + "native-fill-strategies = [\"linear\"] }"); + + final Capabilities caps = + DefaultMongoDbTimeseriesAdapterConfig.of(mock(MongoDbConfig.class), adapterConfig) + .getCapabilities(); + + assertThat(caps.supportsNativeQuery()).isFalse(); + assertThat(caps.canPushDown(Aggregation.PERCENTILE)).isTrue(); + assertThat(caps.canPushDown(Aggregation.AVG)).isTrue(); + assertThat(caps.canPushDown(Aggregation.SUM)).isFalse(); // replaced the default set + assertThat(caps.canFillNatively(FillStrategy.LINEAR)).isTrue(); + } + + @Test + public void ofConfigRejectsUnknownPushableAggregation() { + final Config adapterConfig = ConfigFactory.parseString( + "capabilities { pushable-aggregations = [\"avg\", \"bogus\"] }"); + + assertThatExceptionOfType(DittoConfigError.class).isThrownBy(() -> + DefaultMongoDbTimeseriesAdapterConfig.of(mock(MongoDbConfig.class), adapterConfig)); + } + + @Test + public void ofConfigRejectsNonPositiveMaxQueryResultSize() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString("max-query-result-size = 0"); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("max-query-result-size"); + } + + @Test + public void ofConfigRejectsNonPositiveQueryTimeout() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString("query-timeout = \"0s\""); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("query-timeout"); + } + + @Test + public void ofConfigRejectsUnknownGranularity() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString("granularity = \"days\""); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("days"); + } + + @Test + public void ofConfigTreatsUnlimitedRetentionAsNoExpiration() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString("retention = \"unlimited\""); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig); + + assertThat(underTest.getRetention()).isEmpty(); + } + + @Test + public void ofConfigRejectsNonPositiveRetention() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString("retention = \"0s\""); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("retention"); + } + + @Test + public void ofConfigReadsRetentionOverridesAndResolvesPerNamespace() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = ConfigFactory.parseString( + "retention = 90d\n" + + "retention-overrides { \"com.acme.hifreq\" = 7d, \"org.eclipse.ditto\" = 365d }"); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig); + + assertThat(underTest.getRetentionOverrides()) + .containsOnly(entry("com.acme.hifreq", Duration.ofDays(7)), + entry("org.eclipse.ditto", Duration.ofDays(365))); + // Override wins for a listed namespace; others fall back to the default. + assertThat(underTest.getRetention("com.acme.hifreq")).contains(Duration.ofDays(7)); + assertThat(underTest.getRetention("org.eclipse.ditto")).contains(Duration.ofDays(365)); + assertThat(underTest.getRetention("other.namespace")).contains(Duration.ofDays(90)); + } + + @Test + public void getRetentionForNamespaceIsEmptyWhenNeitherOverrideNorDefaultSet() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, ConfigFactory.empty()); + + assertThat(underTest.getRetentionOverrides()).isEmpty(); + assertThat(underTest.getRetention("any.namespace")).isEmpty(); + } + + @Test + public void ofConfigRejectsNonPositiveRetentionOverride() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = + ConfigFactory.parseString("retention-overrides { \"com.acme\" = \"0s\" }"); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("com.acme"); + } + + @Test + public void ofConfigRejectsPerNamespaceUnlimitedOverride() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + final Config adapterConfig = + ConfigFactory.parseString("retention-overrides { \"com.acme\" = \"unlimited\" }"); + + assertThatExceptionOfType(DittoConfigError.class) + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, adapterConfig)) + .withMessageContaining("unlimited"); + } + + @Test + public void ofConfigRejectsNullMongoDbConfig() { + assertThatNullPointerException() + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(null, ConfigFactory.empty())); + } + + @Test + public void ofConfigRejectsNullAdapterConfig() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + assertThatNullPointerException() + .isThrownBy(() -> DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, (Config) null)); + } + + @Test + public void toStringIncludesAllFields() { + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + + final DefaultMongoDbTimeseriesAdapterConfig underTest = + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, "b", Granularity.SECONDS); + + final String s = underTest.toString(); + + assertThat(s).contains("collectionPrefix=b").contains("granularity=seconds"); + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/GranularityTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/GranularityTest.java new file mode 100644 index 00000000000..75d72861e79 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/GranularityTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import org.junit.Test; + +/** + * Unit tests for {@link Granularity}. + */ +public final class GranularityTest { + + @Test + public void wireFormatNamesUseLowercaseTokens() { + assertThat(Granularity.SECONDS.getName()).isEqualTo("seconds"); + assertThat(Granularity.MINUTES.getName()).isEqualTo("minutes"); + assertThat(Granularity.HOURS.getName()).isEqualTo("hours"); + } + + @Test + public void forNameMatchesWireFormat() { + assertThat(Granularity.forName("seconds")).contains(Granularity.SECONDS); + assertThat(Granularity.forName("hours")).contains(Granularity.HOURS); + } + + @Test + public void forNameIsCaseSensitive() { + assertThat(Granularity.forName("SECONDS")).isEmpty(); + } + + @Test + public void forNameReturnsEmptyForUnknownToken() { + assertThat(Granularity.forName("days")).isEmpty(); + assertThat(Granularity.forName("")).isEmpty(); + } + + @Test + public void forNameRejectsNullInput() { + assertThatNullPointerException().isThrownBy(() -> Granularity.forName(null)); + } + + @Test + public void toStringReturnsWireFormatName() { + assertThat(Granularity.SECONDS.toString()).isEqualTo("seconds"); + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbItUris.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbItUris.java new file mode 100644 index 00000000000..cd6adba4004 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbItUris.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +/** + * Builds the MongoDB connection string the integration tests hand to {@code MongoClientWrapper}. + *

+ * Necessary because the database name has to travel inside the URI. Ditto's + * {@code DefaultMongoDbConfig} reads only {@code ditto.mongodb.uri}; there is no + * {@code ditto.mongodb.database} setting, and {@code MongoClientWrapper.getDefaultDatabase()} + * resolves the database purely from the URI's path segment. Supplying the name as a separate config + * key silently does nothing, and the adapter then fails with {@code IllegalArgumentException: name + * can not be null} on the first database access. + */ +final class MongoDbItUris { + + private MongoDbItUris() { + throw new AssertionError(); + } + + /** + * Returns {@code uri} with its database path segment replaced by {@code database}, preserving the + * scheme, host list and any query string. + * + * @param uri a MongoDB connection string, with or without a database path and query string. + * @param database the database the test should use. + * @return the connection string pointing at {@code database}. + */ + static String withDatabase(final String uri, final String database) { + final int queryStart = uri.indexOf('?'); + final String beforeQuery = queryStart < 0 ? uri : uri.substring(0, queryStart); + final String query = queryStart < 0 ? "" : uri.substring(queryStart); + + final int schemeEnd = beforeQuery.indexOf("://"); + if (schemeEnd < 0) { + throw new IllegalArgumentException("Not a MongoDB connection string: <" + uri + ">"); + } + final int hostsStart = schemeEnd + "://".length(); + // Everything up to the first '/' after the scheme is the (possibly comma-separated) host + // list; anything after it is the database path this method replaces. + final int pathStart = beforeQuery.indexOf('/', hostsStart); + final String schemeAndHosts = + pathStart < 0 ? beforeQuery : beforeQuery.substring(0, pathStart); + + return schemeAndHosts + "/" + database + query; + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingIT.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingIT.java new file mode 100644 index 00000000000..7f348af0ff5 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingIT.java @@ -0,0 +1,750 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.annotation.Nullable; + +import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.DefaultMongoDbConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.internal.utils.test.docker.mongo.MongoDbResource; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.mongodb.ConnectionString; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +/** + * Integration test for {@link MongoDbTimeseriesAdapter}'s cross-Thing surface + * ({@code queryCrossThing} and {@code discoverContributors}) against a real MongoDB. + *

+ * This exists because {@code MongoDbTimeseriesAdapterCrossThingTest} can only assert the + * shape of the aggregation pipeline against a mocked driver. It cannot prove that the + * two-stage {@code $group} actually folds buckets into series correctly, that the computed values are + * right, or — most importantly — that the per-path authorization filter really excludes the data it + * claims to. Those are exactly the properties whose failure would be invisible: a wrong filter still + * returns a plausible number. + *

+ * Runs against a throwaway MongoDB container; set + * {@code TIMESERIES_MONGODB_TEST_URI=mongodb://localhost:27017} to use an existing instance instead. + * Cross-Thing queries span a whole namespace, so unlike + * {@code MongoDbTimeseriesAdapterIT} — which isolates tests by using a random Thing name + * inside one shared namespace — every test here gets its own namespace, and therefore its own + * {@code ts_} collection. Sharing a namespace would let one test's leftover Things + * contribute to another test's aggregate. The database is dropped in {@code @AfterClass}. + */ +public final class MongoDbTimeseriesAdapterCrossThingIT { + + private static final String ENV_VAR = "TIMESERIES_MONGODB_TEST_URI"; + private static final String DATABASE = "ditto_ts_crossthing_it"; + + private static final JsonPointer FLOW = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + private static final JsonPointer RETURN = + JsonPointer.of("/features/circuit/properties/returnTemperature"); + + private static final Instant HOUR = Instant.parse("2026-01-14T10:00:00Z"); + private static final Instant HOUR_END = Instant.parse("2026-01-14T11:00:00Z"); + private static final Duration ONE_HOUR = Duration.ofHours(1); + + private static final String UNIT = "cel"; + + /** Optional override pointing at an existing MongoDB; when set, no container is started. */ + private static final String URI_OVERRIDE = System.getenv(ENV_VAR); + /** + * Null when {@value ENV_VAR} is set. Constructed lazily like this because + * {@code new MongoDbResource()} already reaches for the Docker daemon, so an unconditional field + * would fail class initialisation on a machine without Docker even though the override means no + * container is needed. + */ + @Nullable + private static final MongoDbResource CONTAINER = isOverridden() ? null : new MongoDbResource(); + + private static String uri; + + private MongoDbTimeseriesAdapter adapter; + private String namespace; + private ThingId t1; + private ThingId t2; + private ThingId t3; + + /** + * Starts a throwaway MongoDB container for the class. Replaced by a no-op rule — so no container + * is started at all — when {@value ENV_VAR} points at an already-running instance. + *

+ * Running by default is deliberate. This IT previously required {@value ENV_VAR} to be set, + * which meant CI always skipped it; the harness then broke unnoticed (the database name was + * passed via a config key nothing reads) and every case here errored the moment it was enabled. + * A test that only runs when someone remembers to opt in provides no regression protection. + */ + @ClassRule + public static final TestRule MONGO = CONTAINER == null ? (base, description) -> base : CONTAINER; + + @BeforeClass + public static void resolveUri() { + uri = isOverridden() + ? URI_OVERRIDE + : "mongodb://" + requireContainer().getBindIp() + ":" + requireContainer().getPort(); + } + + private static boolean isOverridden() { + return URI_OVERRIDE != null && !URI_OVERRIDE.isEmpty(); + } + + private static MongoDbResource requireContainer() { + if (CONTAINER == null) { + throw new IllegalStateException("No container was started; " + ENV_VAR + " is set."); + } + return CONTAINER; + } + + @Before + public void setUp() throws Exception { + adapter = new MongoDbTimeseriesAdapter(); + // One namespace per test => one collection per test => no cross-test contamination of an + // aggregate that is, by definition, namespace-wide. + namespace = "it.ts.n" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + t1 = ThingId.of(namespace, "sensor-1"); + t2 = ThingId.of(namespace, "sensor-2"); + t3 = ThingId.of(namespace, "sensor-3"); + + // The database name must be a path segment of the URI: DefaultMongoDbConfig reads only + // `ditto.mongodb.uri`, so a separate `ditto.mongodb.database` key is silently ignored and + // getDefaultDatabase() then resolves to null. + final Config rootConfig = ConfigFactory.parseString(String.format( + "ditto.mongodb.uri = \"%s\"\n", MongoDbItUris.withDatabase(uri, DATABASE))); + final MongoDbConfig mongoDbConfig = + DefaultMongoDbConfig.of(DefaultScopedConfig.dittoScoped(rootConfig)); + final MongoDbTimeseriesAdapterConfig config = DefaultMongoDbTimeseriesAdapterConfig.of( + mongoDbConfig, "ts_", Granularity.SECONDS); + adapter.initialize(config).toCompletableFuture().get(); + } + + @After + public void tearDown() throws Exception { + if (adapter != null) { + adapter.shutdown().toCompletableFuture().get(); + } + } + + @AfterClass + public static void dropTestDatabase() throws Exception { + if (uri == null || uri.isEmpty()) { + return; + } + final MongoClient client = MongoClients.create(new ConnectionString(uri)); + try { + final java.util.concurrent.CompletableFuture done = + new java.util.concurrent.CompletableFuture<>(); + client.getDatabase(DATABASE).drop().subscribe(new Subscriber() { + @Override public void onSubscribe(final Subscription s) { s.request(Long.MAX_VALUE); } + @Override public void onNext(final Void item) { /* discard */ } + @Override public void onError(final Throwable t) { done.completeExceptionally(t); } + @Override public void onComplete() { done.complete(null); } + }); + done.get(); + } finally { + client.close(); + } + } + + @Test + public void adapterAdvertisesNativeCrossThingSupport() { + assertThat(adapter.capabilities().supportsNativeCrossThingQuery()).isTrue(); + } + + // --------------------------------------------------------------------------------------------- + // Folding across Things — what the mocked pipeline test cannot compute + // --------------------------------------------------------------------------------------------- + + /** + * Without {@code groupBy} the points of all Things must collapse into a single series. + * This is the two-stage {@code $group} doing its job for real: bucket first, then fold the + * buckets of one series into one document. + */ + @Test + public void aggregatesPointsOfSeveralThingsIntoOneSeries() throws Exception { + writeSpreadAcrossThreeThings(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG), allPermitted(FLOW)); + + assertThat(results).hasSize(1); + assertThat((Object) results.get(0).getPath()).isEqualTo(FLOW); + assertThat(results.get(0).getGroup()).isEmpty(); + assertThat(results.get(0).getData()).hasSize(1); + // 7 points across 3 Things: 10,20,30,40,50,60,90 -> 300/7. + assertThat(doubleAt(results.get(0), 0)).isCloseTo(42.857142857142854, within(1e-9)); + } + + /** + * Every bucketed aggregation, checked against the arithmetic rather than against another + * MongoDB expression. {@code first}/{@code last} are the interesting pair: they depend on the + * pipeline sorting by timestamp across Things, so they would silently return an + * arbitrary Thing's value if the sort were dropped. + */ + @Test + public void computesEveryBucketedAggregationCorrectlyAcrossThings() throws Exception { + writeSpreadAcrossThreeThings(); + + assertThat(singleValue(Aggregation.COUNT)).isCloseTo(7.0, within(1e-9)); + assertThat(singleValue(Aggregation.SUM)).isCloseTo(300.0, within(1e-9)); + assertThat(singleValue(Aggregation.AVG)).isCloseTo(42.857142857142854, within(1e-9)); + assertThat(singleValue(Aggregation.MIN)).isCloseTo(10.0, within(1e-9)); + assertThat(singleValue(Aggregation.MAX)).isCloseTo(90.0, within(1e-9)); + // Earliest point overall is t1's at :00; latest is t3's at :30. + assertThat(singleValue(Aggregation.FIRST)).isCloseTo(10.0, within(1e-9)); + assertThat(singleValue(Aggregation.LAST)).isCloseTo(90.0, within(1e-9)); + // Sample standard deviation ($stdDevSamp), not population. + assertThat(singleValue(Aggregation.STDDEV)).isCloseTo(26.90370836538197, within(1e-9)); + } + + @Test + public void groupByThingIdYieldsOneSeriesPerThingWithItsOwnValues() throws Exception { + writeSpreadAcrossThreeThings(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.singletonList(GroupBy.thingId()), + null, null, null), + allPermitted(FLOW)); + + final Map byThing = new LinkedHashMap<>(); + for (final AggregatedTimeseriesResult r : results) { + byThing.put(r.getGroup().get("thingId"), r); + } + assertThat(byThing.keySet()).containsExactlyInAnyOrder( + t1.toString(), t2.toString(), t3.toString()); + assertThat(doubleAt(byThing.get(t1.toString()), 0)).isCloseTo(20.0, within(1e-9)); // 10,20,30 + assertThat(doubleAt(byThing.get(t2.toString()), 0)).isCloseTo(50.0, within(1e-9)); // 40,50,60 + assertThat(doubleAt(byThing.get(t3.toString()), 0)).isCloseTo(90.0, within(1e-9)); // 90 + } + + @Test + public void reportsOnePerPathSeriesForAMultiPathQuery() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 10.0), + point(t2, FLOW, 10, 30.0), + point(t1, RETURN, 0, 100.0), + point(t2, RETURN, 10, 200.0))).toCompletableFuture().get(); + + final List results = run( + query(Arrays.asList(FLOW, RETURN), Aggregation.AVG), allPermitted(FLOW, RETURN)); + + assertThat(results).hasSize(2); + assertThat(doubleAt(seriesFor(results, FLOW), 0)).isCloseTo(20.0, within(1e-9)); + assertThat(doubleAt(seriesFor(results, RETURN), 0)).isCloseTo(150.0, within(1e-9)); + } + + // --------------------------------------------------------------------------------------------- + // Authorization: the per-path allow-list, exercised through a real $match + // --------------------------------------------------------------------------------------------- + + /** + * {@code null} means "the caller may read every Thing in the namespace" — the only case where an + * unfiltered scan is correct. + */ + @Test + public void anExplicitAllowListOfEveryThingReadsTheWholeNamespace() throws Exception { + writeSpreadAcrossThreeThings(); + + // There is no "unrestricted" sentinel any more: reading the whole namespace means + // enumerating it. Passing null used to mean the same thing and made an unfiltered scan + // reachable from the query path, so the adapter now rejects it outright. + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.COUNT), allPermitted(FLOW)); + + assertThat(doubleAt(results.get(0), 0)).isCloseTo(7.0, within(1e-9)); + } + + @Test + public void aNullAllowListIsRejectedRatherThanTreatedAsUnrestricted() { + assertThatThrownBy(() -> adapter.queryCrossThing( + query(Collections.singletonList(FLOW), Aggregation.COUNT), null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("permittedThingsPerPath"); + } + + /** + * The core of the feature: {@code READ_TS} is grantable per property, so a Thing permitted on one + * requested path and denied another must contribute to the first and not the second. Asserted on + * the values, because that is the only way to see that the denied Thing's points were + * genuinely excluded from the fold rather than merely absent from some label. + */ + @Test + public void restrictsEachPathToTheThingsPermittedForThatPath() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 10.0), + point(t1, FLOW, 10, 20.0), + point(t2, FLOW, 20, 3000.0), // t2 denied on FLOW -> must not shift the average + point(t1, RETURN, 0, 100.0), + point(t2, RETURN, 10, 200.0) // t2 permitted on RETURN + )).toCompletableFuture().get(); + + final Map> allowed = new LinkedHashMap<>(); + allowed.put(FLOW, Collections.singletonList(t1)); + allowed.put(RETURN, Arrays.asList(t1, t2)); + + final List results = run( + query(Arrays.asList(FLOW, RETURN), Aggregation.AVG), allowed); + + assertThat(results).hasSize(2); + // avg(10,20) = 15 — NOT avg(10,20,3000). + assertThat(doubleAt(seriesFor(results, FLOW), 0)).isCloseTo(15.0, within(1e-9)); + assertThat(doubleAt(seriesFor(results, RETURN), 0)).isCloseTo(150.0, within(1e-9)); + } + + /** + * Fail-closed rule 1: a path absent from the map contributes nothing. Reading an absent + * entry as "unrestricted" is the dangerous misinterpretation, so it gets its own test. + */ + @Test + public void aPathAbsentFromTheAllowListContributesNothing() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 10.0), + point(t1, RETURN, 0, 100.0))).toCompletableFuture().get(); + + final Map> onlyFlow = new LinkedHashMap<>(); + onlyFlow.put(FLOW, Collections.singletonList(t1)); + // RETURN deliberately not mentioned at all. + + final List results = run( + query(Arrays.asList(FLOW, RETURN), Aggregation.AVG), onlyFlow); + + assertThat(results).hasSize(1); + assertThat((Object) results.get(0).getPath()).isEqualTo(FLOW); + } + + /** Fail-closed rule 2: a path mapped to an empty collection contributes nothing. */ + @Test + public void aPathMappedToNoThingsContributesNothing() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 10.0), + point(t1, RETURN, 0, 100.0))).toCompletableFuture().get(); + + final Map> emptyForReturn = new LinkedHashMap<>(); + emptyForReturn.put(FLOW, Collections.singletonList(t1)); + emptyForReturn.put(RETURN, Collections.emptyList()); + + final List results = run( + query(Arrays.asList(FLOW, RETURN), Aggregation.AVG), emptyForReturn); + + assertThat(results).hasSize(1); + assertThat((Object) results.get(0).getPath()).isEqualTo(FLOW); + } + + /** + * Fail-closed rule 3, and the one that matters most: when no path has any permitted Thing the + * result must be empty. If the {@code $or} of per-path clauses collapsed to "no filter" instead + * of "match nothing", this would quietly return the entire namespace. + */ + @Test + public void anEmptyAllowListLeaksNothing() throws Exception { + writeSpreadAcrossThreeThings(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG), + new LinkedHashMap>()); + + assertThat(results).isEmpty(); + } + + /** An allow-list naming a Thing that has no data must not invent a series. */ + @Test + public void permittingAThingWithNoDataYieldsNoSeries() throws Exception { + adapter.write(point(t1, FLOW, 0, 10.0)).toCompletableFuture().get(); + + final Map> allowed = new LinkedHashMap<>(); + allowed.put(FLOW, Collections.singletonList(t3)); + + assertThat(run(query(Collections.singletonList(FLOW), Aggregation.AVG), allowed)).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // discoverContributors — the input the withheld-count accuracy depends on + // --------------------------------------------------------------------------------------------- + + /** + * Contributors are grouped per path, not flattened. A Thing must appear only under the paths it + * actually has data for, otherwise the caller would report it as "withheld" from a path it would + * have contributed nothing to anyway. + */ + @Test + public void discoverContributorsGroupsThingsByPath() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 1.0), + point(t1, RETURN, 0, 2.0), + point(t2, FLOW, 0, 3.0), // t2: FLOW only + point(t3, RETURN, 0, 4.0) // t3: RETURN only + )).toCompletableFuture().get(); + + final Map> contributors = adapter.discoverContributors( + query(Arrays.asList(FLOW, RETURN), Aggregation.AVG), 100) + .toCompletableFuture().get(); + + assertThat(contributors.get(FLOW)).containsExactlyInAnyOrder(t1, t2); + assertThat(contributors.get(RETURN)).containsExactlyInAnyOrder(t1, t3); + } + + /** A Thing whose only points fall outside the window is not a contributor. */ + @Test + public void discoverContributorsHonoursTheTimeRange() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 1.0), + // t2's point is a day later — outside [HOUR, HOUR_END). + TimeseriesDataPoint.of(t2, FLOW, HOUR.plus(1, ChronoUnit.DAYS), + JsonValue.of(2.0), 1L, Collections.emptyMap(), UNIT) + )).toCompletableFuture().get(); + + final Map> contributors = adapter.discoverContributors( + query(Collections.singletonList(FLOW), Aggregation.AVG), 100) + .toCompletableFuture().get(); + + assertThat(contributors.get(FLOW)).containsExactly(t1); + } + + @Test + public void discoverContributorsHonoursTagFilters() throws Exception { + adapter.writeBatch(Arrays.asList( + pointTagged(t1, FLOW, 0, 1.0, "building", "A"), + pointTagged(t2, FLOW, 0, 2.0, "building", "B"))).toCompletableFuture().get(); + + final Map> contributors = adapter.discoverContributors( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.emptyList(), + "eq(building,'A')", null, null), 100) + .toCompletableFuture().get(); + + assertThat(contributors.get(FLOW)).containsExactly(t1); + } + + /** + * The cap must let {@code limit + 1} distinct Things through. Returning exactly {@code limit} + * would make "at the ceiling" indistinguishable from "over it", and the caller would authorize a + * truncated contributor set — silently admitting whatever fell off the end. + */ + @Test + public void discoverContributorsAllowsOneThingBeyondTheLimitSoOverflowIsDetectable() + throws Exception { + + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 1.0), + point(t2, FLOW, 1, 2.0), + point(t3, FLOW, 2, 3.0))).toCompletableFuture().get(); + + final Map> contributors = adapter.discoverContributors( + query(Collections.singletonList(FLOW), Aggregation.AVG), 2) + .toCompletableFuture().get(); + + assertThat(distinct(contributors)).hasSize(3); + } + + // --------------------------------------------------------------------------------------------- + // Guard rails, tags and rendering + // --------------------------------------------------------------------------------------------- + + /** Exceeding {@code maxGroups} must fail rather than truncate — through a real pipeline. */ + @Test + public void exceedingMaxGroupsFailsInsteadOfTruncating() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 1.0), + point(t2, FLOW, 1, 2.0), + point(t3, FLOW, 2, 3.0))).toCompletableFuture().get(); + + final CrossThingTimeseriesQuery threeGroupsCapAtTwo = query( + Collections.singletonList(FLOW), Aggregation.AVG, + Collections.singletonList(GroupBy.thingId()), + null, null, 2); + + assertThatThrownBy(() -> adapter.queryCrossThing(threeGroupsCapAtTwo, allPermitted(FLOW)) + .toCompletableFuture().get()) + .hasRootCauseInstanceOf(TimeseriesQueryInvalidException.class); + } + + @Test + public void groupsAtExactlyTheMaxGroupsLimitSucceed() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 1.0), + point(t2, FLOW, 1, 2.0), + point(t3, FLOW, 2, 3.0))).toCompletableFuture().get(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.singletonList(GroupBy.thingId()), + null, null, 3), + allPermitted(FLOW)); + + assertThat(results).hasSize(3); + } + + /** + * Tags are frozen on each point at ingest, so grouping by a tag partitions by the state of the + * world when recorded. Both series here belong to the same Thing, which "moved" from + * building A to B mid-window — proving the semantics are point-in-time and not a lookup of the + * Thing's current attributes. + */ + @Test + public void groupByTagPartitionsByTheTagValueFrozenOnEachPoint() throws Exception { + adapter.writeBatch(Arrays.asList( + pointTagged(t1, FLOW, 0, 10.0, "building", "A"), + pointTagged(t1, FLOW, 10, 20.0, "building", "A"), + pointTagged(t1, FLOW, 20, 90.0, "building", "B"))).toCompletableFuture().get(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.singletonList(GroupBy.tag("building")), + null, null, null), + allPermitted(FLOW)); + + final Map byBuilding = new LinkedHashMap<>(); + for (final AggregatedTimeseriesResult r : results) { + byBuilding.put(r.getGroup().get("building"), r); + } + assertThat(byBuilding.keySet()).containsExactlyInAnyOrder("A", "B"); + assertThat(doubleAt(byBuilding.get("A"), 0)).isCloseTo(15.0, within(1e-9)); + assertThat(doubleAt(byBuilding.get("B"), 0)).isCloseTo(90.0, within(1e-9)); + } + + @Test + public void tagFilterSelectsPointsAcrossThings() throws Exception { + adapter.writeBatch(Arrays.asList( + pointTagged(t1, FLOW, 0, 10.0, "building", "A"), + pointTagged(t2, FLOW, 10, 20.0, "building", "A"), + pointTagged(t2, FLOW, 20, 9000.0, "building", "B"))).toCompletableFuture().get(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.emptyList(), + "eq(building,'A')", null, null), + allPermitted(FLOW)); + + assertThat(results).hasSize(1); + assertThat(doubleAt(results.get(0), 0)).isCloseTo(15.0, within(1e-9)); + } + + /** + * A group spans many points whose tags may differ, so an aggregated result reports no tags at + * all — the group identity is the right place to look instead. + */ + @Test + public void aggregatedResultsCarryTheUnitButNoTags() throws Exception { + adapter.writeBatch(Arrays.asList( + pointTagged(t1, FLOW, 0, 10.0, "building", "A"), + pointTagged(t2, FLOW, 10, 20.0, "building", "B"))).toCompletableFuture().get(); + + final AggregatedTimeseriesResult result = run( + query(Collections.singletonList(FLOW), Aggregation.AVG), allPermitted(FLOW)).get(0); + + assertThat(result.getMeta().getUnit()).contains(UNIT); + assertThat(result.getMeta().getTags()).isEmpty(); + assertThat(result.getMeta().getCount()).isEqualTo(result.getData().size()); + } + + /** Buckets with no data across any Thing are interpolated and flagged, not silently omitted. */ + @Test + public void fillLinearInterpolatesEmptyBucketsAndFlagsThem() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 5, 10.0), + point(t2, FLOW, 45, 40.0))).toCompletableFuture().get(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.emptyList(), null, + FillStrategy.LINEAR, null, + Duration.ofMinutes(20)), + allPermitted(FLOW)); + + final List data = results.get(0).getData(); + assertThat(data).hasSize(3); + assertThat(data.get(0).isGap()).isFalse(); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(2).isGap()).isFalse(); + // Halfway between 10 and 40. + assertThat(doubleAt(results.get(0), 1)).isCloseTo(25.0, within(1e-9)); + } + + @Test + public void withoutFillEmptyBucketsAreAbsent() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 5, 10.0), + point(t2, FLOW, 45, 40.0))).toCompletableFuture().get(); + + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG, + Collections.emptyList(), null, + null, null, Duration.ofMinutes(20)), + allPermitted(FLOW)); + + assertThat(results.get(0).getData()).hasSize(2); + } + + @Test + public void aNamespaceWithNoDataYieldsNoSeriesRatherThanAnError() throws Exception { + final List results = run( + query(Collections.singletonList(FLOW), Aggregation.AVG), allPermitted(FLOW)); + + assertThat(results).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // Fixtures & helpers + // --------------------------------------------------------------------------------------------- + + /** t1: 10,20,30 at :00,:10,:20 — t2: 40,50,60 at :05,:15,:25 — t3: 90 at :30. */ + private void writeSpreadAcrossThreeThings() throws Exception { + adapter.writeBatch(Arrays.asList( + point(t1, FLOW, 0, 10.0), + point(t1, FLOW, 10, 20.0), + point(t1, FLOW, 20, 30.0), + point(t2, FLOW, 5, 40.0), + point(t2, FLOW, 15, 50.0), + point(t2, FLOW, 25, 60.0), + point(t3, FLOW, 30, 90.0))).toCompletableFuture().get(); + } + + private double singleValue(final Aggregation aggregation) throws Exception { + final List results = + run(query(Collections.singletonList(FLOW), aggregation), allPermitted(FLOW)); + assertThat(results).hasSize(1); + return doubleAt(results.get(0), 0); + } + + /** + * Every fixture Thing permitted on each of the given paths. The adapter takes no "unrestricted" + * sentinel — the allow-list is always explicit — so a test that isn't about authorization still + * has to state that everything is readable. + */ + private Map> allPermitted(final JsonPointer... paths) { + final Map> permitted = new LinkedHashMap<>(); + for (final JsonPointer path : paths) { + permitted.put(path, Arrays.asList(t1, t2, t3)); + } + return permitted; + } + + private List run(final CrossThingTimeseriesQuery query, + final Map> permittedThingsPerPath) + throws Exception { + + return adapter.queryCrossThing(query, permittedThingsPerPath).toCompletableFuture().get(); + } + + private CrossThingTimeseriesQuery query(final List paths, + final Aggregation aggregation) { + + return query(paths, aggregation, Collections.emptyList(), + null, null, null); + } + + private CrossThingTimeseriesQuery query(final List paths, + final Aggregation aggregation, + final List groupBy, + @Nullable final String filter, + @Nullable final FillStrategy fill, + @Nullable final Integer maxGroups) { + + return query(paths, aggregation, groupBy, filter, fill, maxGroups, ONE_HOUR); + } + + private CrossThingTimeseriesQuery query(final List paths, + final Aggregation aggregation, + final List groupBy, + @Nullable final String filter, + @Nullable final FillStrategy fill, + @Nullable final Integer maxGroups, + final Duration step) { + + return CrossThingTimeseriesQuery.of(namespace, paths, HOUR, HOUR_END, step, aggregation, + groupBy, filter, null, fill, maxGroups); + } + + private TimeseriesDataPoint point(final ThingId thingId, final JsonPointer path, + final int minute, final double value) { + + return TimeseriesDataPoint.of(thingId, path, HOUR.plus(minute, ChronoUnit.MINUTES), + JsonValue.of(value), 1L, Collections.emptyMap(), UNIT); + } + + private TimeseriesDataPoint pointTagged(final ThingId thingId, final JsonPointer path, + final int minute, final double value, final String tagKey, final String tagValue) { + + return TimeseriesDataPoint.of(thingId, path, HOUR.plus(minute, ChronoUnit.MINUTES), + JsonValue.of(value), 1L, Collections.singletonMap(tagKey, tagValue), UNIT); + } + + private static AggregatedTimeseriesResult seriesFor( + final List results, final JsonPointer path) { + + for (final AggregatedTimeseriesResult result : results) { + if (result.getPath().equals(path)) { + return result; + } + } + throw new AssertionError("No series for path <" + path + "> in " + results); + } + + private static double doubleAt(final AggregatedTimeseriesResult result, final int index) { + return result.getData().get(index).getValue() + .orElseThrow(() -> new AssertionError("Bucket " + index + " carries no value")) + .asDouble(); + } + + private static List distinct(final Map> contributors) { + final List all = new ArrayList<>(); + for (final List perPath : contributors.values()) { + for (final ThingId thingId : perPath) { + if (!all.contains(thingId)) { + all.add(thingId); + } + } + } + return all; + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingTest.java new file mode 100644 index 00000000000..4161752d73e --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterCrossThingTest.java @@ -0,0 +1,402 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Collection; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nullable; + +import org.bson.Document; +import org.bson.conversions.Bson; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.junit.Test; + +/** + * Unit tests for {@link MongoDbTimeseriesAdapter}'s cross-Thing aggregation — the pipeline shape and + * the service-side fold of the returned series documents. Exercised without a live MongoDB; the + * end-to-end behaviour against a real Time Series collection is covered by + * {@code MongoDbTimeseriesAdapterIT}. + */ +public final class MongoDbTimeseriesAdapterCrossThingTest { + + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final JsonPointer PATH = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + private static final Instant FROM = Instant.parse("2026-07-01T00:00:00Z"); + private static final Instant TO = Instant.parse("2026-07-01T06:00:00Z"); + private static final Duration STEP = Duration.ofHours(1); + /** The single Thing these pipeline-shape tests authorize on {@link #PATH}. */ + private static final ThingId PERMITTED_THING = ThingId.of(NAMESPACE, "circuit-1"); + /** Stand-in for the adapter's configured max-query-result-size; high enough not to interfere. */ + private static final int MAX_POINTS = 100_000; + + private static CrossThingTimeseriesQuery query(final List groupBy, + @Nullable final String filter, + @Nullable final FillStrategy fill, + @Nullable final Integer maxGroups) { + + return CrossThingTimeseriesQuery.of(NAMESPACE, Collections.singletonList(PATH), FROM, TO, + STEP, Aggregation.AVG, groupBy, filter, null, fill, maxGroups); + } + + // --------------------------------------------------------------------------------------------- + // Pipeline shape + // --------------------------------------------------------------------------------------------- + + /** + * The pipeline must be: match → sort → group(bucket) → sort → group(series) → sort → limit. + * The second group is what makes the driver return one document per series instead of one per + * bucket, and the trailing limit is what makes a group-cap overflow detectable. + */ + @Test + public void pipelineFoldsBucketsIntoSeriesAndBoundsGroups() { + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, 5), + permitted(), 5); + + assertThat(pipeline).hasSize(7); + final String rendered = render(pipeline); + assertThat(rendered).contains("$match"); + assertThat(rendered).contains("$group"); + assertThat(rendered).contains("$limit"); + // maxGroups + 1: one extra row so an overflow can be reported rather than truncated. + assertThat(render(pipeline.get(6))).contains("6"); + } + + @Test + public void pipelineFiltersByRequestedPath() { + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline( + query(Collections.emptyList(), null, + null, null), + permitted(), 1000); + + assertThat(render(pipeline.get(0))).contains(PATH.toString()); + } + + @Test + public void pipelineFiltersByTagsInMetaField() { + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline( + query(Collections.emptyList(), + "eq(building,'A')", null, null), + permitted(), 1000); + + // Tags live in the Time Series metaField, so the predicate must address meta.tags. + // to stay index-supported. + assertThat(render(pipeline.get(0))).contains("meta.tags.building"); + } + + @Test + public void pipelineAppliesPerPathAllowListWhenGiven() { + final Map> allow = Map.of(PATH, + Arrays.asList(ThingId.of(NAMESPACE, "heatsource-1"), + ThingId.of(NAMESPACE, "heatsource-2"))); + + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline( + query(Collections.emptyList(), null, + null, null), + allow, 1000); + + final String match = render(pipeline.get(0)); + assertThat(match).contains("meta.thingId"); + assertThat(match).contains("heatsource-1"); + assertThat(match).contains("heatsource-2"); + assertThat(match).contains(PATH.toString()); + } + + /** + * The core of path-granular access: each readable path gets its own {@code (path, thingIds)} clause, + * OR'd together — so a Thing withheld from one path still contributes to another. + */ + @Test + public void pipelineEmitsOneClausePerPathWithItsOwnThings() { + final JsonPointer other = JsonPointer.of("/features/circuit/properties/returnTemperature"); + final ThingId a = ThingId.of(NAMESPACE, "heatsource-a"); + final ThingId b = ThingId.of(NAMESPACE, "heatsource-b"); + final Map> allow = new LinkedHashMap<>(); + allow.put(PATH, Arrays.asList(a, b)); + allow.put(other, Collections.singletonList(a)); + + final CrossThingTimeseriesQuery q = CrossThingTimeseriesQuery.of(NAMESPACE, + Arrays.asList(PATH, other), FROM, TO, STEP, Aggregation.AVG, + Collections.emptyList(), null, + null, null, null); + + final String match = render(MongoDbTimeseriesAdapter.crossThingPipeline(q, allow, 1000).get(0)); + assertThat(match).contains("$or"); + assertThat(match).contains(PATH.toString()); + assertThat(match).contains(other.toString()); + assertThat(match).contains("heatsource-a"); + assertThat(match).contains("heatsource-b"); + } + + /** A path with no permitted Things must contribute no clause at all — never an unfiltered one. */ + @Test + public void pipelineOmitsPathsWithNoPermittedThings() { + final JsonPointer other = JsonPointer.of("/features/circuit/properties/returnTemperature"); + final Map> allow = new LinkedHashMap<>(); + allow.put(PATH, Collections.singletonList(ThingId.of(NAMESPACE, "heatsource-a"))); + allow.put(other, Collections.emptyList()); + + final CrossThingTimeseriesQuery q = CrossThingTimeseriesQuery.of(NAMESPACE, + Arrays.asList(PATH, other), FROM, TO, STEP, Aggregation.AVG, + Collections.emptyList(), null, + null, null, null); + + final String match = render(MongoDbTimeseriesAdapter.crossThingPipeline(q, allow, 1000).get(0)); + assertThat(match).contains(PATH.toString()); + assertThat(match).doesNotContain(other.toString()); + } + + @Test + public void pipelineAlwaysConstrainsThingIdsToTheAllowList() { + // There is no "namespace-wide" mode: the allow-list is required, so the $match must always + // carry an explicit meta.thingId constraint. A pipeline without one would scan the whole + // namespace regardless of what the caller was authorized for. + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline( + query(Collections.emptyList(), null, + null, null), + permitted(), 1000); + + final String match = render(pipeline.get(0)); + assertThat(match).contains("meta.thingId"); + assertThat(match).contains(PERMITTED_THING.toString()); + } + + // --------------------------------------------------------------------------------------------- + // Fold of returned series documents + // --------------------------------------------------------------------------------------------- + + @Test + public void foldBuildsOneResultPerSeriesWithGroupIdentity() { + final List seriesDocs = Arrays.asList( + seriesDoc("A", Arrays.asList(bucket(FROM, 21.0), bucket(FROM.plus(STEP), 22.0))), + seriesDoc("B", Collections.singletonList(bucket(FROM, 30.0)))); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, null), + seriesDocs, 1000, MAX_POINTS); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getGroup()).containsEntry("building", "A"); + assertThat((Object) results.get(0).getPath()).isEqualTo(PATH); + assertThat(results.get(0).getData()).hasSize(2); + assertThat(results.get(0).getMeta().getUnit()).contains("cel"); + assertThat(results.get(1).getGroup()).containsEntry("building", "B"); + assertThat(results.get(1).getData()).hasSize(1); + } + + /** + * {@code $group} output order is not contractual, so the fold must sort each series' buckets + * itself — the fill grid depends on ascending order actually holding. + */ + @Test + public void foldSortsBucketsChronologicallyEvenIfMongoReturnsThemOutOfOrder() { + final List seriesDocs = Collections.singletonList(seriesDoc("A", Arrays.asList( + bucket(FROM.plus(Duration.ofHours(2)), 23.0), + bucket(FROM, 21.0), + bucket(FROM.plus(STEP), 22.0)))); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, null), + seriesDocs, 1000, MAX_POINTS); + + final List timestamps = new ArrayList<>(); + for (final TimeseriesDataValue value : results.get(0).getData()) { + timestamps.add(value.getTimestamp()); + } + assertThat(timestamps).containsExactly(FROM, FROM.plus(STEP), FROM.plus(Duration.ofHours(2))); + } + + @Test + public void foldAppliesGapFillPerSeries() { + // Buckets 0 and 3 present, 1 and 2 missing → linear fill must interpolate the two gaps. + final List seriesDocs = Collections.singletonList(seriesDoc("A", Arrays.asList( + bucket(FROM, 10.0), + bucket(FROM.plus(Duration.ofHours(3)), 40.0)))); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, FillStrategy.LINEAR, null), + seriesDocs, 1000, MAX_POINTS); + + final List data = results.get(0).getData(); + assertThat(data).hasSize(4); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(2).isGap()).isTrue(); + assertThat(data.get(1).getValue().map(JsonValue::asDouble)).contains(20.0); + assertThat(data.get(2).getValue().map(JsonValue::asDouble)).contains(30.0); + } + + @Test + public void foldWithoutGroupByProducesSingleSeriesWithEmptyGroup() { + final List seriesDocs = Collections.singletonList( + new Document("_id", new Document("p", PATH.toString())) + .append("points", Collections.singletonList(bucket(FROM, 21.0))) + .append("unit", "cel")); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.emptyList(), null, + null, null), + seriesDocs, 1000, MAX_POINTS); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getGroup()).isEmpty(); + } + + /** + * A silently truncated result set would look like a complete answer and misreport anything the + * caller computes on top of it, so exceeding the cap must fail. + */ + @Test + public void foldRejectsMoreGroupsThanTheCap() { + final List seriesDocs = Arrays.asList( + seriesDoc("A", Collections.singletonList(bucket(FROM, 1.0))), + seriesDoc("B", Collections.singletonList(bucket(FROM, 2.0))), + seriesDoc("C", Collections.singletonList(bucket(FROM, 3.0)))); + + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, 2), + seriesDocs, 2, MAX_POINTS)) + .withMessageContaining("more than 2 distinct groups"); + } + + @Test + public void foldAcceptsExactlyTheCap() { + final List seriesDocs = Arrays.asList( + seriesDoc("A", Collections.singletonList(bucket(FROM, 1.0))), + seriesDoc("B", Collections.singletonList(bucket(FROM, 2.0)))); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, 2), + seriesDocs, 2, MAX_POINTS); + + assertThat(results).hasSize(2); + } + + /** + * A point that carries no value for a grouped tag must still produce a series with the same set + * of keys, so clients can rely on a uniform shape. + */ + @Test + public void foldSurfacesMissingTagValueAsEmptyString() { + final List seriesDocs = Collections.singletonList( + new Document("_id", new Document("p", PATH.toString()).append("g_building", null)) + .append("points", Collections.singletonList(bucket(FROM, 21.0))) + .append("unit", "cel")); + + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.singletonList(GroupBy.tag("building")), + null, null, null), + seriesDocs, 1000, MAX_POINTS); + + assertThat(results.get(0).getGroup()).containsEntry("building", ""); + } + + @Test + public void foldHandlesEmptySeriesList() { + final List results = + MongoDbTimeseriesAdapter.buildCrossThingResults( + query(Collections.emptyList(), null, + null, null), + Collections.emptyList(), 1000, MAX_POINTS); + + assertThat(results).isEmpty(); + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + private static Document seriesDoc(final String building, final List points) { + return new Document("_id", + new Document("p", PATH.toString()).append("g_building", building)) + .append("points", points) + .append("unit", "cel"); + } + + private static Document bucket(final Instant timestamp, final double value) { + // The driver hands back BSON dates as java.util.Date. + return new Document("t", Date.from(timestamp)).append("v", value); + } + + private static String render(final List pipeline) { + final StringBuilder builder = new StringBuilder(); + for (final Bson stage : pipeline) { + builder.append(render(stage)).append('\n'); + } + return builder.toString(); + } + + private static String render(final Bson stage) { + return stage.toBsonDocument(Document.class, + com.mongodb.MongoClientSettings.getDefaultCodecRegistry()).toJson(); + } + + /** Guards against the ZoneId overload silently changing bucket alignment. */ + @Test + public void pipelineCarriesTimezoneIntoDateTrunc() { + final CrossThingTimeseriesQuery tzQuery = CrossThingTimeseriesQuery.of(NAMESPACE, + Collections.singletonList(PATH), FROM, TO, Duration.ofDays(1), Aggregation.AVG, + Collections.emptyList(), null, + ZoneId.of("Europe/Berlin"), null, null); + + final List pipeline = MongoDbTimeseriesAdapter.crossThingPipeline(tzQuery, permitted(), 1000); + + assertThat(render(pipeline)).contains("Europe/Berlin"); + } + + /** + * The allow-list these pipeline-shape tests run with: one Thing permitted on {@link #PATH}. + * The adapter takes no "unrestricted" sentinel, so even a test that is not about authorization + * has to say what is readable. + */ + private static Map> permitted() { + final Map> allowed = new LinkedHashMap<>(); + allowed.put(PATH, Collections.singletonList(PERMITTED_THING)); + return allowed; + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterIT.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterIT.java new file mode 100644 index 00000000000..5086448c2d4 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterIT.java @@ -0,0 +1,748 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.bson.Document; +import javax.annotation.Nullable; + +import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.DefaultMongoDbConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.internal.utils.test.docker.mongo.MongoDbResource; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.SortOrder; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.mongodb.ConnectionString; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.MongoDatabase; +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +/** + * Integration test for {@link MongoDbTimeseriesAdapter} against a real MongoDB instance. + *

+ * Runs against a throwaway MongoDB container. Set + * {@code TIMESERIES_MONGODB_TEST_URI=mongodb://localhost:27017} to point it at an existing instance + * instead, which skips the container. Each test uses a fresh randomly-named demo Thing namespace so test runs + * do not collide; the test database is dropped in {@code @AfterClass}. + */ +public final class MongoDbTimeseriesAdapterIT { + + private static final String ENV_VAR = "TIMESERIES_MONGODB_TEST_URI"; + private static final String DATABASE = "ditto_ts_it"; + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + + /** Optional override pointing at an existing MongoDB; when set, no container is started. */ + private static final String URI_OVERRIDE = System.getenv(ENV_VAR); + /** + * Null when {@value ENV_VAR} is set. Constructed lazily like this because + * {@code new MongoDbResource()} already reaches for the Docker daemon, so an unconditional field + * would fail class initialisation on a machine without Docker even though the override means no + * container is needed. + */ + @Nullable + private static final MongoDbResource CONTAINER = isOverridden() ? null : new MongoDbResource(); + + private static String uri; + private MongoDbTimeseriesAdapter adapter; + private ThingId thingId; + + /** + * Starts a throwaway MongoDB container for the class. Replaced by a no-op rule — so no container + * is started at all — when {@value ENV_VAR} points at an already-running instance. + *

+ * Running by default is deliberate. This IT previously required {@value ENV_VAR} to be set, + * which meant CI always skipped it; the harness then broke unnoticed (the database name was + * passed via a config key nothing reads) and every case here errored the moment it was enabled. + * A test that only runs when someone remembers to opt in provides no regression protection. + */ + @ClassRule + public static final TestRule MONGO = CONTAINER == null ? (base, description) -> base : CONTAINER; + + @BeforeClass + public static void resolveUri() { + uri = isOverridden() + ? URI_OVERRIDE + : "mongodb://" + requireContainer().getBindIp() + ":" + requireContainer().getPort(); + } + + private static boolean isOverridden() { + return URI_OVERRIDE != null && !URI_OVERRIDE.isEmpty(); + } + + private static MongoDbResource requireContainer() { + if (CONTAINER == null) { + throw new IllegalStateException("No container was started; " + ENV_VAR + " is set."); + } + return CONTAINER; + } + + @Before + public void setUp() throws Exception { + adapter = new MongoDbTimeseriesAdapter(); + // Random suffix lives in the name part (which permits digits and hyphens); the namespace + // is fixed and follows Ditto's namespace convention. + thingId = ThingId.of("it.timeseries.test", + "sensor-" + UUID.randomUUID().toString().substring(0, 8)); + + // Build a MongoDbConfig from a Typesafe Config rooted at "ditto" — same shape as + // DefaultMongoDbConfig consumes at runtime. Production code reads `ditto.mongodb.uri` + // and `ditto.mongodb.database`; the IT does the same here. + // The database name must be a path segment of the URI: DefaultMongoDbConfig reads only + // `ditto.mongodb.uri`, so a separate `ditto.mongodb.database` key is silently ignored and + // getDefaultDatabase() then resolves to null. + final Config rootConfig = ConfigFactory.parseString(String.format( + "ditto.mongodb.uri = \"%s\"\n", MongoDbItUris.withDatabase(uri, DATABASE))); + final MongoDbConfig mongoDbConfig = + DefaultMongoDbConfig.of(DefaultScopedConfig.dittoScoped(rootConfig)); + final MongoDbTimeseriesAdapterConfig config = DefaultMongoDbTimeseriesAdapterConfig.of( + mongoDbConfig, "ts_", Granularity.SECONDS); + adapter.initialize(config).toCompletableFuture().get(); + } + + @After + public void tearDown() throws Exception { + if (adapter != null) { + adapter.shutdown().toCompletableFuture().get(); + } + } + + @AfterClass + public static void dropTestDatabase() throws Exception { + if (uri == null || uri.isEmpty()) { + return; + } + final MongoClient client = MongoClients.create(new ConnectionString(uri)); + try { + blockUntilComplete(client.getDatabase(DATABASE).drop()); + } finally { + client.close(); + } + } + + @Test + public void writeThenQueryReturnsTheStoredDataPoint() throws Exception { + final TimeseriesDataPoint dp = dataPoint(Instant.parse("2026-01-14T10:00:00Z"), 22.5); + + adapter.write(dp).toCompletableFuture().get(); + + final List results = adapter.query(buildQuery( + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"))).toCompletableFuture().get(); + + assertThat(results).hasSize(1); + final TimeseriesQueryResult result = results.get(0); + assertThat(result.getData()).hasSize(1); + final TimeseriesDataValue value = result.getData().get(0); + assertThat(value.getTimestamp()).isEqualTo(dp.getTimestamp()); + assertThat(value.getValue()).contains(JsonValue.of(22.5)); + } + + @Test + public void writeBatchPersistsAllDataPointsInChronologicalOrder() throws Exception { + final List batch = new ArrayList<>(); + Instant t = Instant.parse("2026-01-14T10:00:00Z"); + final double[] values = {22.0, 22.5, 23.0, 23.5, 24.0}; + for (final double v : values) { + batch.add(dataPoint(t, v)); + t = t.plus(5, ChronoUnit.MINUTES); + } + + adapter.writeBatch(batch).toCompletableFuture().get(); + + final List results = adapter.query(buildQuery( + Instant.parse("2026-01-14T09:00:00Z"), + Instant.parse("2026-01-14T11:00:00Z"))).toCompletableFuture().get(); + + assertThat(results.get(0).getData()).hasSize(values.length); + for (int i = 0; i < values.length; i++) { + assertThat(results.get(0).getData().get(i).getValue()).contains(JsonValue.of(values[i])); + } + } + + @Test + public void queryEmptyTimeRangeReturnsEmptyData() throws Exception { + adapter.write(dataPoint(Instant.parse("2026-01-14T10:00:00Z"), 22.5)) + .toCompletableFuture().get(); + + final List results = adapter.query(buildQuery( + Instant.parse("2026-02-01T00:00:00Z"), + Instant.parse("2026-02-02T00:00:00Z"))).toCompletableFuture().get(); + + assertThat(results.get(0).getData()).isEmpty(); + assertThat(results.get(0).getMeta().getCount()).isZero(); + } + + @Test + public void queryAppliesLimitWhenSet() throws Exception { + final List batch = new ArrayList<>(); + Instant t = Instant.parse("2026-01-14T10:00:00Z"); + for (int i = 0; i < 10; i++) { + batch.add(dataPoint(t, 20.0 + i)); + t = t.plus(1, ChronoUnit.MINUTES); + } + adapter.writeBatch(batch).toCompletableFuture().get(); + + final TimeseriesQuery limitedQuery = TimeseriesQuery.of( + thingId, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T09:00:00Z"), + Instant.parse("2026-01-14T11:00:00Z"), + null, null, null, 3, null); + + final List results = + adapter.query(limitedQuery).toCompletableFuture().get(); + + assertThat(results.get(0).getData()).hasSize(3); + } + + @Test + public void paginationWalksEntireRangeWithoutGapsOrDuplicates() throws Exception { + final List batch = new ArrayList<>(); + Instant t = Instant.parse("2026-01-14T10:00:00Z"); + for (int i = 0; i < 10; i++) { + batch.add(dataPoint(t, 20.0 + i)); + t = t.plus(1, ChronoUnit.MINUTES); + } + adapter.writeBatch(batch).toCompletableFuture().get(); + + final Instant from = Instant.parse("2026-01-14T09:00:00Z"); + final Instant to = Instant.parse("2026-01-14T11:00:00Z"); + final List collected = new ArrayList<>(); + String cursor = null; + int pages = 0; + do { + final TimeseriesQuery page = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + from, to, null, null, null, 3, null, null, cursor); + final TimeseriesQueryResult result = adapter.query(page).toCompletableFuture().get().get(0); + for (final TimeseriesDataValue value : result.getData()) { + collected.add(value.getValue().orElseThrow().asDouble()); + } + cursor = result.getMeta().getNextCursor().orElse(null); + pages++; + assertThat(pages).as("pagination must terminate").isLessThanOrEqualTo(10); + } while (cursor != null); + + // Every point returned exactly once, in chronological order — no gaps, no duplicates. + assertThat(collected) + .containsExactly(20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0); + assertThat(pages).isEqualTo(4); // 3 + 3 + 3 + 1 + } + + @Test + public void descendingPaginationWalksRangeNewestFirst() throws Exception { + final List batch = new ArrayList<>(); + Instant t = Instant.parse("2026-01-14T10:00:00Z"); + for (int i = 0; i < 10; i++) { + batch.add(dataPoint(t, 20.0 + i)); + t = t.plus(1, ChronoUnit.MINUTES); + } + adapter.writeBatch(batch).toCompletableFuture().get(); + + final Instant from = Instant.parse("2026-01-14T09:00:00Z"); + final Instant to = Instant.parse("2026-01-14T11:00:00Z"); + final List collected = new ArrayList<>(); + String cursor = null; + int pages = 0; + do { + final TimeseriesQuery page = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + from, to, null, null, null, 3, null, null, cursor, SortOrder.DESC); + final TimeseriesQueryResult result = adapter.query(page).toCompletableFuture().get().get(0); + for (final TimeseriesDataValue value : result.getData()) { + collected.add(value.getValue().orElseThrow().asDouble()); + } + cursor = result.getMeta().getNextCursor().orElse(null); + pages++; + assertThat(pages).as("pagination must terminate").isLessThanOrEqualTo(10); + } while (cursor != null); + + // Newest first, every point exactly once. + assertThat(collected) + .containsExactly(29.0, 28.0, 27.0, 26.0, 25.0, 24.0, 23.0, 22.0, 21.0, 20.0); + } + + @Test + public void paginationTieBreaksSameTimestampOnRevision() throws Exception { + // Two points share a timestamp but differ in revision: keyset paging with page size 1 must + // return both exactly once (revision is the tie-breaker), never skipping or repeating one. + final Instant ts = Instant.parse("2026-01-14T10:00:00Z"); + adapter.writeBatch(Arrays.asList( + dataPoint(ts, 1.0, PATH, 1L), + dataPoint(ts, 2.0, PATH, 2L))).toCompletableFuture().get(); + + final Instant from = Instant.parse("2026-01-14T09:00:00Z"); + final Instant to = Instant.parse("2026-01-14T11:00:00Z"); + final List collected = new ArrayList<>(); + String cursor = null; + int pages = 0; + do { + final TimeseriesQuery page = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + from, to, null, null, null, 1, null, null, cursor); + final TimeseriesQueryResult result = adapter.query(page).toCompletableFuture().get().get(0); + result.getData().forEach(v -> collected.add(v.getValue().orElseThrow().asDouble())); + cursor = result.getMeta().getNextCursor().orElse(null); + pages++; + assertThat(pages).as("pagination must terminate").isLessThanOrEqualTo(3); + } while (cursor != null); + + assertThat(collected).containsExactly(1.0, 2.0); + } + + @Test + public void tagsAreReturnedInResultMeta() throws Exception { + adapter.writeBatch(Collections.singletonList( + dataPoint(Instant.parse("2026-01-14T10:00:00Z"), 22.5))).toCompletableFuture().get(); + + final TimeseriesQuery query = buildQuery( + Instant.parse("2026-01-14T09:00:00Z"), Instant.parse("2026-01-14T11:00:00Z")); + final List results = adapter.query(query).toCompletableFuture().get(); + + assertThat(results.get(0).getMeta().getTags()) + .containsEntry("attributes/building", "A") + .containsEntry("attributes/floor", "2"); + } + + @Test + public void tagFilterSelectsMatchingPointsOnly() throws Exception { + final Instant t = Instant.parse("2026-01-14T10:00:00Z"); + adapter.writeBatch(Arrays.asList( + dataPointWithTags(t, 1.0, Map.of("building", "A")), + dataPointWithTags(t.plusSeconds(1), 2.0, Map.of("building", "B")), + dataPointWithTags(t.plusSeconds(2), 3.0, Map.of("building", "A")))).toCompletableFuture().get(); + + final TimeseriesQuery query = buildQuery( + Instant.parse("2026-01-14T09:00:00Z"), Instant.parse("2026-01-14T11:00:00Z")) + .withTagFilters(Map.of("building", "A")); + final List data = + adapter.query(query).toCompletableFuture().get().get(0).getData(); + + final List values = new ArrayList<>(); + for (final TimeseriesDataValue v : data) { + values.add(v.getValue().orElseThrow().asDouble()); + } + // Only the two building=A points, in time order; building=B is filtered out. + assertThat(values).containsExactly(1.0, 3.0); + } + + @Test + public void queryReturnsOneResultPerPath() throws Exception { + final JsonPointer humidityPath = JsonPointer.of("/features/environment/properties/humidity"); + final Instant ts = Instant.parse("2026-01-14T10:00:00Z"); + + adapter.writeBatch(Arrays.asList( + dataPoint(ts, 22.5, PATH), + dataPoint(ts, 65.2, humidityPath))).toCompletableFuture().get(); + + final TimeseriesQuery multiPath = TimeseriesQuery.of( + thingId, + Arrays.asList(PATH, humidityPath), + Instant.parse("2026-01-14T09:00:00Z"), + Instant.parse("2026-01-14T11:00:00Z")); + + final List results = + adapter.query(multiPath).toCompletableFuture().get(); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getData()).hasSize(1); + assertThat(results.get(1).getData()).hasSize(1); + assertThat(results.get(0).getData().get(0).getValue()).contains(JsonValue.of(22.5)); + assertThat(results.get(1).getData().get(0).getValue()).contains(JsonValue.of(65.2)); + } + + // ----- Phase 2: downsampling + aggregation ------------------------------------------------- + // Deterministic fixture: 6 points inside one UTC hour [10:00, 11:00), values 10..60. + private static final Instant BUCKET_HOUR = Instant.parse("2026-01-14T10:00:00Z"); + private static final Instant BUCKET_HOUR_END = Instant.parse("2026-01-14T11:00:00Z"); + + @Test + public void downsampleAvgReturnsBucketMean() throws Exception { + writeRampSeries(); + final List data = aggregate(Duration.ofHours(1), Aggregation.AVG); + assertThat(data).hasSize(1); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isEqualTo(35.0); + assertThat(data.get(0).getTimestamp()).isEqualTo(BUCKET_HOUR); + } + + @Test + public void downsampleMinMaxSumCount() throws Exception { + writeRampSeries(); + assertThat(aggregate(Duration.ofHours(1), Aggregation.MIN).get(0).getValue().orElseThrow().asDouble()) + .isEqualTo(10.0); + assertThat(aggregate(Duration.ofHours(1), Aggregation.MAX).get(0).getValue().orElseThrow().asDouble()) + .isEqualTo(60.0); + assertThat(aggregate(Duration.ofHours(1), Aggregation.SUM).get(0).getValue().orElseThrow().asDouble()) + .isEqualTo(210.0); + assertThat(aggregate(Duration.ofHours(1), Aggregation.COUNT).get(0).getValue().orElseThrow().asInt()) + .isEqualTo(6); + } + + @Test + public void downsampleStddevIsSampleStandardDeviation() throws Exception { + writeRampSeries(); + final double stddev = aggregate(Duration.ofHours(1), Aggregation.STDDEV) + .get(0).getValue().orElseThrow().asDouble(); + assertThat(stddev).isCloseTo(18.708286, within(1e-5)); + } + + @Test + public void derivativeReturnsPerSecondRateBetweenPoints() throws Exception { + writeRampSeries(); + // No step: differenced between consecutive raw points; 6 points -> 5 derivatives. + // (20-10) / 600s = 0.0166667 per second. + final List data = aggregate(null, Aggregation.DERIVATIVE); + assertThat(data).hasSize(5); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isCloseTo(0.0166667, within(1e-6)); + } + + @Test + public void rateTreatsCounterDecreaseAsReset() throws Exception { + // 95000 -> 100000 (5000 over 600s) then reset to 2000 (treated as 2000 over 600s, not negative). + adapter.writeBatch(Arrays.asList( + dataPoint(BUCKET_HOUR.plus(0, ChronoUnit.MINUTES), 95000.0), + dataPoint(BUCKET_HOUR.plus(10, ChronoUnit.MINUTES), 100000.0), + dataPoint(BUCKET_HOUR.plus(20, ChronoUnit.MINUTES), 2000.0))).toCompletableFuture().get(); + + final List data = aggregate(null, Aggregation.RATE); + assertThat(data).hasSize(2); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isCloseTo(5000.0 / 600.0, within(1e-6)); + assertThat(data.get(1).getValue().orElseThrow().asDouble()).isCloseTo(2000.0 / 600.0, within(1e-6)); + } + + @Test + public void integralReturnsTrapezoidalArea() throws Exception { + writeRampSeries(); + // Trapezoidal over 6 points: (15+25+35+45+55) * 600 = 105000 value-seconds, single point. + final List data = aggregate(null, Aggregation.INTEGRAL); + assertThat(data).hasSize(1); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isCloseTo(105000.0, within(1e-3)); + } + + @Test + public void percentileInterpolatesPerBucket() throws Exception { + writeRampSeries(); + assertThat(percentile(Duration.ofHours(1), 50.0).get(0).getValue().orElseThrow().asDouble()) + .isEqualTo(35.0); + assertThat(percentile(Duration.ofHours(1), 95.0).get(0).getValue().orElseThrow().asDouble()) + .isEqualTo(57.5); + } + + @Test + public void fillPreviousCarriesValueAcrossInteriorGap() throws Exception { + // Points in bucket1 [10:00,10:20) and bucket3 [10:40,11:00); bucket2 [10:20,10:40) is empty. + adapter.writeBatch(Arrays.asList( + dataPoint(BUCKET_HOUR.plus(5, ChronoUnit.MINUTES), 10.0), + dataPoint(BUCKET_HOUR.plus(15, ChronoUnit.MINUTES), 20.0), + dataPoint(BUCKET_HOUR.plus(45, ChronoUnit.MINUTES), 50.0), + dataPoint(BUCKET_HOUR.plus(55, ChronoUnit.MINUTES), 60.0))).toCompletableFuture().get(); + + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + BUCKET_HOUR, BUCKET_HOUR_END, Duration.ofMinutes(20), Aggregation.AVG, + FillStrategy.PREVIOUS, null, null); + final List data = + adapter.query(query).toCompletableFuture().get().get(0).getData(); + + assertThat(data).hasSize(3); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isEqualTo(15.0); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(1).getValue().orElseThrow().asDouble()).isEqualTo(15.0); + assertThat(data.get(2).getValue().orElseThrow().asDouble()).isEqualTo(55.0); + } + + @Test + public void fillLinearInterpolatesAcrossInteriorGap() throws Exception { + // Buckets: bucket1 [10:00,10:20)=avg(10,20)=15, bucket2 [10:20,10:40) empty, + // bucket3 [10:40,11:00)=avg(50,60)=55. Linear fill of the midpoint bucket = (15+55)/2 = 35. + adapter.writeBatch(Arrays.asList( + dataPoint(BUCKET_HOUR.plus(5, ChronoUnit.MINUTES), 10.0), + dataPoint(BUCKET_HOUR.plus(15, ChronoUnit.MINUTES), 20.0), + dataPoint(BUCKET_HOUR.plus(45, ChronoUnit.MINUTES), 50.0), + dataPoint(BUCKET_HOUR.plus(55, ChronoUnit.MINUTES), 60.0))).toCompletableFuture().get(); + + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + BUCKET_HOUR, BUCKET_HOUR_END, Duration.ofMinutes(20), Aggregation.AVG, + FillStrategy.LINEAR, null, null); + final List data = + adapter.query(query).toCompletableFuture().get().get(0).getData(); + + assertThat(data).hasSize(3); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isEqualTo(15.0); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(1).getValue().orElseThrow().asDouble()).isEqualTo(35.0); + assertThat(data.get(2).getValue().orElseThrow().asDouble()).isEqualTo(55.0); + } + + @Test + public void fillWithTimezoneDayStepAlignsBucketsAcrossDstTransition() throws Exception { + // Europe/Berlin springs forward on 2026-03-29 (01:00 UTC), making that local day 23h long. + // Daily buckets must stay pinned to local midnight, so their UTC instants shift from +01:00 + // to +02:00 across the transition. A point on 2026-03-28 and 2026-03-31 leaves the 03-29 and + // 03-30 local days empty; previous-fill carries 10 forward onto both gap days. + final ZoneId berlin = ZoneId.of("Europe/Berlin"); + adapter.writeBatch(Arrays.asList( + dataPoint(Instant.parse("2026-03-28T12:00:00Z"), 10.0), + dataPoint(Instant.parse("2026-03-31T12:00:00Z"), 40.0))).toCompletableFuture().get(); + + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + Instant.parse("2026-03-27T00:00:00Z"), Instant.parse("2026-04-01T00:00:00Z"), + Duration.ofDays(1), Aggregation.AVG, FillStrategy.PREVIOUS, null, berlin); + final List data = + adapter.query(query).toCompletableFuture().get().get(0).getData(); + + // Local-midnight bucket starts: 03-28 → 03-27T23:00Z (+01:00), 03-29 → 03-28T23:00Z (+01:00), + // 03-30 → 03-29T22:00Z (+02:00, post-DST), 03-31 → 03-30T22:00Z (+02:00). A fixed-24h stepper + // would place the third bucket at 03-29T23:00Z and never realign — the assertion below pins + // it to the tz-correct 03-29T22:00Z. + assertThat(data).hasSize(4); + assertThat(data.get(0).getTimestamp()).isEqualTo(Instant.parse("2026-03-27T23:00:00Z")); + assertThat(data.get(0).getValue().orElseThrow().asDouble()).isEqualTo(10.0); + assertThat(data.get(1).getTimestamp()).isEqualTo(Instant.parse("2026-03-28T23:00:00Z")); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(1).getValue().orElseThrow().asDouble()).isEqualTo(10.0); + assertThat(data.get(2).getTimestamp()).isEqualTo(Instant.parse("2026-03-29T22:00:00Z")); + assertThat(data.get(2).isGap()).isTrue(); + assertThat(data.get(2).getValue().orElseThrow().asDouble()).isEqualTo(10.0); + assertThat(data.get(3).getTimestamp()).isEqualTo(Instant.parse("2026-03-30T22:00:00Z")); + assertThat(data.get(3).getValue().orElseThrow().asDouble()).isEqualTo(40.0); + } + + @Test + public void fillLinearWithTimezoneDayStepInterpolatesByActualBucketSpacing() throws Exception { + // Same DST setup, linear fill. Interpolation weights by the real wall-clock spacing of the + // tz-aligned bucket starts, not a uniform 1/3 per bucket. Spans from 03-27T23:00Z: gap1 is + // +24h, gap2 is +47h (the 03-29 day is only 23h), total span 71h. So the gap values are + // 10+30*(24/71)≈20.14 and 10+30*(47/71)≈29.86 — non-uniform, which proves DST-aware spacing. + final ZoneId berlin = ZoneId.of("Europe/Berlin"); + adapter.writeBatch(Arrays.asList( + dataPoint(Instant.parse("2026-03-28T12:00:00Z"), 10.0), + dataPoint(Instant.parse("2026-03-31T12:00:00Z"), 40.0))).toCompletableFuture().get(); + + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + Instant.parse("2026-03-27T00:00:00Z"), Instant.parse("2026-04-01T00:00:00Z"), + Duration.ofDays(1), Aggregation.AVG, FillStrategy.LINEAR, null, berlin); + final List data = + adapter.query(query).toCompletableFuture().get().get(0).getData(); + + assertThat(data).hasSize(4); + assertThat(data.get(1).isGap()).isTrue(); + assertThat(data.get(1).getValue().orElseThrow().asDouble()).isCloseTo(20.14, within(0.01)); + assertThat(data.get(2).isGap()).isTrue(); + assertThat(data.get(2).getValue().orElseThrow().asDouble()).isCloseTo(29.86, within(0.01)); + } + + @Test + public void retentionAppliesOnCreateAndReconcilesExistingCollectionOnRestart() throws Exception { + final String collectionName = MongoDbTimeseriesAdapter.collectionNameFor( + DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig(), "ts_", Granularity.SECONDS), + thingId); + + // First adapter run with a 1h retention: creates the collection (or reconciles an existing + // one) so expireAfterSeconds == 3600. + final MongoDbTimeseriesAdapter first = newAdapterWithRetention(Duration.ofHours(1)); + try { + first.write(dataPoint(Instant.parse("2026-01-14T10:00:00Z"), 1.0)) + .toCompletableFuture().get(); + } finally { + first.shutdown().toCompletableFuture().get(); + } + assertThat(readExpireAfterSeconds(collectionName)).isEqualTo(3600L); + + // Second adapter run (fresh instance = fresh ensured-collection cache) with a 2h retention: + // the collection now exists, so it is reconciled via collMod to expireAfterSeconds == 7200. + final MongoDbTimeseriesAdapter second = newAdapterWithRetention(Duration.ofHours(2)); + try { + second.write(dataPoint(Instant.parse("2026-01-14T10:05:00Z"), 2.0)) + .toCompletableFuture().get(); + } finally { + second.shutdown().toCompletableFuture().get(); + } + assertThat(readExpireAfterSeconds(collectionName)).isEqualTo(7200L); + } + + private MongoDbConfig mongoDbConfig() { + // The database name must be a path segment of the URI: DefaultMongoDbConfig reads only + // `ditto.mongodb.uri`, so a separate `ditto.mongodb.database` key is silently ignored and + // getDefaultDatabase() then resolves to null. + final Config rootConfig = ConfigFactory.parseString(String.format( + "ditto.mongodb.uri = \"%s\"\n", MongoDbItUris.withDatabase(uri, DATABASE))); + return DefaultMongoDbConfig.of(DefaultScopedConfig.dittoScoped(rootConfig)); + } + + private MongoDbTimeseriesAdapter newAdapterWithRetention(final Duration retention) + throws Exception { + final MongoDbTimeseriesAdapterConfig config = DefaultMongoDbTimeseriesAdapterConfig.of( + mongoDbConfig(), "ts_", Granularity.SECONDS, retention); + final MongoDbTimeseriesAdapter newAdapter = new MongoDbTimeseriesAdapter(); + newAdapter.initialize(config).toCompletableFuture().get(); + return newAdapter; + } + + private static Long readExpireAfterSeconds(final String collectionName) throws Exception { + final MongoClient client = MongoClients.create(new ConnectionString(uri)); + try { + final List infos = collectDocuments(client.getDatabase(DATABASE) + .listCollections() + .filter(com.mongodb.client.model.Filters.eq("name", collectionName))); + assertThat(infos).hasSize(1); + final Document info = infos.get(0); + // TTL on a time series collection surfaces as expireAfterSeconds, either top-level or + // under options depending on server version — accept whichever carries it. + final Object topLevel = info.get("expireAfterSeconds"); + if (topLevel instanceof Number number) { + return number.longValue(); + } + final Document options = info.get("options", Document.class); + final Object inOptions = options == null ? null : options.get("expireAfterSeconds"); + return inOptions instanceof Number number ? number.longValue() : null; + } finally { + client.close(); + } + } + + private static List collectDocuments(final org.reactivestreams.Publisher pub) + throws Exception { + final List collected = new ArrayList<>(); + final java.util.concurrent.CompletableFuture done = + new java.util.concurrent.CompletableFuture<>(); + pub.subscribe(new Subscriber() { + @Override public void onSubscribe(final Subscription s) { s.request(Long.MAX_VALUE); } + @Override public void onNext(final Document d) { collected.add(d); } + @Override public void onError(final Throwable t) { done.completeExceptionally(t); } + @Override public void onComplete() { done.complete(null); } + }); + done.get(); + return collected; + } + + private void writeRampSeries() throws Exception { + final int[] minutes = {5, 15, 25, 35, 45, 55}; + final double[] values = {10, 20, 30, 40, 50, 60}; + final List batch = new ArrayList<>(); + for (int i = 0; i < minutes.length; i++) { + batch.add(dataPoint(BUCKET_HOUR.plus(minutes[i], ChronoUnit.MINUTES), values[i])); + } + adapter.writeBatch(batch).toCompletableFuture().get(); + } + + private List aggregate(final Duration step, final Aggregation agg) + throws Exception { + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + BUCKET_HOUR, BUCKET_HOUR_END, step, agg, null, null, null); + return adapter.query(query).toCompletableFuture().get().get(0).getData(); + } + + private List percentile(final Duration step, final double pct) + throws Exception { + final TimeseriesQuery query = TimeseriesQuery.of(thingId, Collections.singletonList(PATH), + BUCKET_HOUR, BUCKET_HOUR_END, step, Aggregation.PERCENTILE, null, null, null, pct); + return adapter.query(query).toCompletableFuture().get().get(0).getData(); + } + + @Test + public void healthIsUpAfterInitialise() { + assertThat(adapter.getHealth()) + .isEqualTo(org.eclipse.ditto.timeseries.api.HealthStatus.UP); + } + + private TimeseriesQuery buildQuery(final Instant from, final Instant to) { + return TimeseriesQuery.of( + thingId, Collections.singletonList(PATH), from, to); + } + + private TimeseriesDataPoint dataPoint(final Instant timestamp, final double value) { + return dataPoint(timestamp, value, PATH); + } + + private TimeseriesDataPoint dataPoint(final Instant timestamp, final double value, + final JsonPointer path) { + + return dataPoint(timestamp, value, path, 1L); + } + + private TimeseriesDataPoint dataPoint(final Instant timestamp, final double value, + final JsonPointer path, final long revision) { + + final Map tags = new LinkedHashMap<>(); + tags.put("attributes/building", "A"); + tags.put("attributes/floor", "2"); + return TimeseriesDataPoint.of( + thingId, path, timestamp, JsonValue.of(value), revision, tags, "cel"); + } + + private TimeseriesDataPoint dataPointWithTags(final Instant timestamp, final double value, + final Map tags) { + return TimeseriesDataPoint.of( + thingId, PATH, timestamp, JsonValue.of(value), 1L, tags, "cel"); + } + + /** Drains a publisher synchronously — used only for the AfterClass DB-drop. */ + private static void blockUntilComplete(final org.reactivestreams.Publisher publisher) + throws Exception { + + final java.util.concurrent.CompletableFuture future = + new java.util.concurrent.CompletableFuture<>(); + publisher.subscribe(new Subscriber() { + + @Override + public void onSubscribe(final Subscription s) { s.request(Long.MAX_VALUE); } + @Override + public void onNext(final Object item) { /* discard */ } + @Override + public void onError(final Throwable t) { future.completeExceptionally(t); } + @Override + public void onComplete() { future.complete(null); } + }); + future.get(); + } + + @SuppressWarnings("unused") + private static Document anyDoc() { + // Suppress import-purge: keeps the Document import explicit so future authors don't + // have to remember to add it when extending the test. + return new Document(); + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterTest.java new file mode 100644 index 00000000000..ccf11fcaf01 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/MongoDbTimeseriesAdapterTest.java @@ -0,0 +1,922 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.List; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; + +import org.bson.Document; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.FillStrategy; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesDataValue; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import com.mongodb.client.result.InsertManyResult; +import com.mongodb.client.result.InsertOneResult; +import com.mongodb.reactivestreams.client.FindPublisher; +import com.mongodb.reactivestreams.client.ListCollectionNamesPublisher; +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoCollection; +import com.mongodb.reactivestreams.client.MongoDatabase; +import com.typesafe.config.ConfigFactory; + +import org.bson.conversions.Bson; + +/** + * Unit tests for {@link MongoDbTimeseriesAdapter}. + *

+ * Lifecycle, mapping invocation and the MongoDB-driver call shape are exercised against a mocked + * driver chain (MongoClient → MongoDatabase → MongoCollection); the publishers returned by + * insertOne / insertMany are stubbed to complete immediately so the adapter's + * {@code CompletionStage} returns synchronously inside the test. + */ +public final class MongoDbTimeseriesAdapterTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final ThingId OTHER_NAMESPACE_THING = ThingId.of("acme.fleet", "sensor-9"); + private static final JsonPointer PATH = JsonPointer.of("/features/env/properties/temperature"); + + private MongoDbTimeseriesAdapterConfig config; + private MongoClient mongoClient; + private MongoDatabase mongoDatabase; + @SuppressWarnings("unchecked") + private final MongoCollection mongoCollection = mock(MongoCollection.class); + @SuppressWarnings("unchecked") + private final MongoCollection otherCollection = mock(MongoCollection.class); + + @Before + public void setUp() { + // Adapter never calls into the mongoDbConfig during these tests — initialize is bypassed + // via forTesting() — so a Mockito mock is sufficient. + final MongoDbConfig mongoDbConfig = mock(MongoDbConfig.class); + config = DefaultMongoDbTimeseriesAdapterConfig.of(mongoDbConfig, "ts_", Granularity.SECONDS); + + mongoClient = mock(MongoClient.class); + mongoDatabase = mock(MongoDatabase.class); + when(mongoDatabase.getCollection(eq("ts_org_eclipse_ditto"), eq(Document.class))) + .thenReturn(mongoCollection); + when(mongoDatabase.getCollection(eq("ts_acme_fleet"), eq(Document.class))) + .thenReturn(otherCollection); + // Adapter calls listCollectionNames() / createCollection(...) on first access per + // namespace to lazily create native MongoDB Time Series collections. Stub them so the + // unit-test mocks don't NPE — the IT covers the actual MongoDB behaviour. + @SuppressWarnings("unchecked") + final ListCollectionNamesPublisher names = mock(ListCollectionNamesPublisher.class); + doAnswer(invocation -> { + final Subscriber sub = invocation.getArgument(0); + sub.onSubscribe(new Subscription() { + @Override public void request(final long n) { sub.onComplete(); } + @Override public void cancel() { /* no-op */ } + }); + return null; + }).when(names).subscribe(any()); + when(mongoDatabase.listCollectionNames()).thenReturn(names); + when(mongoDatabase.createCollection(any(String.class), any())) + .thenReturn(completingPublisher(null)); + when(mongoCollection.insertOne(any(Document.class))) + .thenReturn(completingPublisher(mock(InsertOneResult.class))); + when(mongoCollection.insertMany(any())) + .thenReturn(completingPublisher(mock(InsertManyResult.class))); + when(otherCollection.insertOne(any(Document.class))) + .thenReturn(completingPublisher(mock(InsertOneResult.class))); + when(otherCollection.insertMany(any())) + .thenReturn(completingPublisher(mock(InsertManyResult.class))); + } + + private MongoDbTimeseriesAdapter newInitialisedAdapter() { + return MongoDbTimeseriesAdapter.forTesting(mongoClient, mongoDatabase, config); + } + + // --- Lifecycle --- + + @Test + public void healthIsDownBeforeInitialize() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + assertThat(adapter.getHealth()).isEqualTo(HealthStatus.DOWN); + } + + @Test + public void healthIsUpAfterForTesting() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + assertThat(adapter.getHealth()).isEqualTo(HealthStatus.UP); + } + + @Test + public void shutdownTransitionsToDown() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.shutdown().toCompletableFuture().join(); + + assertThat(adapter.getHealth()).isEqualTo(HealthStatus.DOWN); + } + + @Test + public void shutdownClosesMongoClient() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.shutdown().toCompletableFuture().join(); + + verify(mongoClient).close(); + } + + @Test + public void shutdownIsIdempotent() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.shutdown().toCompletableFuture().join(); + adapter.shutdown().toCompletableFuture().join(); + + verify(mongoClient, atLeast(1)).close(); + assertThat(adapter.getHealth()).isEqualTo(HealthStatus.DOWN); + } + + @Test + public void initializeRejectsNullConfig() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + assertThatNullPointerException().isThrownBy(() -> adapter.initialize(null)); + } + + @Test + public void initializeRejectsWrongConfigType() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + final TimeseriesAdapterConfig wrongType = new TimeseriesAdapterConfig() { }; + + assertThatExceptionOfType(ExecutionException.class) + .isThrownBy(() -> adapter.initialize(wrongType).toCompletableFuture().get()) + .withCauseInstanceOf(IllegalArgumentException.class); + } + + // --- Write path --- + + @Test + public void writeRoutesToCorrectCollectionByNamespace() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.write(sampleDataPoint(THING_ID)).toCompletableFuture().join(); + + verify(mongoDatabase).getCollection("ts_org_eclipse_ditto", Document.class); + } + + @Test + public void writeInsertsExpectedDocument() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.write(sampleDataPoint(THING_ID)).toCompletableFuture().join(); + + final ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + verify(mongoCollection).insertOne(captor.capture()); + final Document inserted = captor.getValue(); + final Document meta = (Document) inserted.get(TimeseriesBsonMapper.FIELD_META); + assertThat(meta.getString(TimeseriesBsonMapper.META_THING_ID)).isEqualTo(THING_ID.toString()); + assertThat(meta.getString(TimeseriesBsonMapper.META_PATH)).isEqualTo(PATH.toString()); + assertThat(inserted.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo(23.5); + } + + @Test + public void writeFailsWhenAdapterNotInitialised() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.write(sampleDataPoint(THING_ID)) + .toCompletableFuture().join()) + .withCauseInstanceOf(IllegalStateException.class); + } + + @Test + public void writePropagatesPublisherError() { + final RuntimeException backendError = new RuntimeException("backend down"); + when(mongoCollection.insertOne(any(Document.class))) + .thenReturn(failingPublisher(backendError)); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.write(sampleDataPoint(THING_ID)) + .toCompletableFuture().join()) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("backend down"); + } + + // Note: complex-value rejection at the write-path used to live here. Since IOT-495 the + // invariant is enforced at the model layer (ImmutableTimeseriesDataPoint.of), so the data + // point can't be constructed at all — the model's own test covers it + // (ImmutableTimeseriesDataPointTest#factoryRejectsObjectValue / ...RejectsArrayValue). + + @Test + public void writeRejectsNullDataPoint() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + assertThatNullPointerException().isThrownBy(() -> adapter.write(null)); + } + + @Test + public void writeSucceedsEvenWhenRetentionReconcileFails() { + // The collection already exists -> the ensure path reconciles retention via collMod. Make + // collMod (runCommand) fail, as it does when the DB user lacks the privilege, and assert the + // write still completes: retention reconciliation is best-effort and must never break — or + // retry-storm — the data path. + stubListCollectionNamesReturning("ts_org_eclipse_ditto"); + when(mongoDatabase.runCommand(any(Bson.class))) + .thenReturn(failingPublisher(new RuntimeException("not authorized to execute command collMod"))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.write(sampleDataPoint(THING_ID)).toCompletableFuture().join(); + + verify(mongoCollection).insertOne(any(Document.class)); + } + + // --- Write-batch path --- + + @Test + public void writeBatchInsertsManyIntoSingleCollection() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final List batch = Arrays.asList( + sampleDataPoint(THING_ID), sampleDataPoint(THING_ID)); + + adapter.writeBatch(batch).toCompletableFuture().join(); + + verify(mongoCollection).insertMany(any()); + } + + @Test + public void writeBatchGroupsByCollection() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final List batch = Arrays.asList( + sampleDataPoint(THING_ID), + sampleDataPoint(OTHER_NAMESPACE_THING), + sampleDataPoint(THING_ID)); + + adapter.writeBatch(batch).toCompletableFuture().join(); + + verify(mongoCollection).insertMany(any()); + verify(otherCollection).insertMany(any()); + } + + @Test + public void writeBatchEmptyListIsNoOp() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.writeBatch(Collections.emptyList()).toCompletableFuture().join(); + + verify(mongoCollection, org.mockito.Mockito.never()).insertMany(any()); + } + + @Test + public void writeBatchFailsWhenAdapterNotInitialised() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.writeBatch( + Collections.singletonList(sampleDataPoint(THING_ID))) + .toCompletableFuture().join()) + .withCauseInstanceOf(IllegalStateException.class); + } + + @Test + public void writeBatchRejectsNullList() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + assertThatNullPointerException().isThrownBy(() -> adapter.writeBatch(null)); + } + + @Test + public void writeBatchRejectsNullElement() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final List withNull = Arrays.asList(sampleDataPoint(THING_ID), null); + + assertThatNullPointerException().isThrownBy(() -> adapter.writeBatch(withNull)); + } + + // --- Query path --- + + @Test + public void queryReturnsOneResultPerRequestedPath() { + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.0)), + docFor(Instant.parse("2026-01-14T10:01:00Z"), JsonValue.of(22.5))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(buildQuery()).toCompletableFuture().join(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData()).hasSize(2); + } + + @Test + public void queryReturnsEmptyDataWhenNoMatchingDocuments() { + stubFindReturning(mongoCollection /* no docs */); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(buildQuery()).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).isEmpty(); + assertThat(results.get(0).getMeta().getCount()).isZero(); + assertThat(results.get(0).getMeta().getDataType()).isEqualTo("null"); + } + + @Test + public void queryAppliesLimitWhenSet() { + final FindPublisher finder = stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final org.eclipse.ditto.timeseries.model.TimeseriesQuery query = limitedQuery(5); + + adapter.query(query).toCompletableFuture().join(); + + // Page size is the caller's limit; one extra row is fetched to detect a further page. + verify(finder).limit(6); + } + + @Test + public void queryAppliesScanCeilingWhenNoLimitSet() { + final FindPublisher finder = stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(22.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.query(buildQuery()).toCompletableFuture().join(); + + // No caller limit -> the configured safety ceiling is the page size so an over-broad range + // cannot exhaust the heap; one extra row is fetched to detect a further page. + verify(finder).limit(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_MAX_QUERY_RESULT_SIZE + 1); + } + + @Test + public void integralRefusesWhenScanCeilingReached() { + // A raw read may return a partial series, but an integral over a truncated series would be a + // confidently wrong scalar. So on hitting the scan ceiling the adapter refuses (HTTP 400) + // rather than computing over incomplete data. + final MongoDbTimeseriesAdapter adapter = newAdapterWithMaxResultSize(3); + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0)), + docFor(Instant.parse("2026-01-14T10:00:01Z"), JsonValue.of(2.0)), + docFor(Instant.parse("2026-01-14T10:00:02Z"), JsonValue.of(3.0))); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.query(advancedQuery(Aggregation.INTEGRAL)) + .toCompletableFuture().join()) + .withCauseInstanceOf(TimeseriesQueryInvalidException.class); + } + + @Test + public void derivativeWithoutStepRefusesWhenScanCeilingReached() { + final MongoDbTimeseriesAdapter adapter = newAdapterWithMaxResultSize(2); + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0)), + docFor(Instant.parse("2026-01-14T10:00:01Z"), JsonValue.of(2.0))); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.query(advancedQuery(Aggregation.DERIVATIVE)) + .toCompletableFuture().join()) + .withCauseInstanceOf(TimeseriesQueryInvalidException.class); + } + + @Test + public void integralSucceedsBelowScanCeiling() { + // Below the ceiling the aggregation is computed normally — the refusal must not over-fire. + final MongoDbTimeseriesAdapter adapter = newAdapterWithMaxResultSize(5); + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(2.0)), + docFor(Instant.parse("2026-01-14T10:00:10Z"), JsonValue.of(4.0))); + + final List results = + adapter.query(advancedQuery(Aggregation.INTEGRAL)).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(1); + } + + @Test + public void queryReturnsEmptyResultListForEmptyPaths() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final org.eclipse.ditto.timeseries.model.TimeseriesQuery emptyPaths = + org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, Collections.emptyList(), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + + final List results = + adapter.query(emptyPaths).toCompletableFuture().join(); + + assertThat(results).isEmpty(); + verify(mongoCollection, never()).find(any(Bson.class)); + } + + @Test + public void queryInfersDataTypeFromFirstValue() { + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of("ok"))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(buildQuery()).toCompletableFuture().join(); + + assertThat(results.get(0).getMeta().getDataType()).isEqualTo("string"); + } + + // --- Tags (return + filter) --- + + @Test + public void rawReadReturnsStoredTagsInMeta() { + stubFindReturning(mongoCollection, docForWithTags( + Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0), Map.of("building", "A", "floor", "2"))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(buildQuery()).toCompletableFuture().join(); + + assertThat(results.get(0).getMeta().getTags()) + .containsEntry("building", "A") + .containsEntry("floor", "2"); + } + + @Test + public void tagFilterQueryExecutesAndReturnsData() { + // Exercises the meta.tags.* filter branch end-to-end (the mock returns the matching point). + stubFindReturning(mongoCollection, docForWithTags( + Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0), Map.of("building", "A"))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final org.eclipse.ditto.timeseries.model.TimeseriesQuery query = + buildQuery().withTagFilters(Map.of("building", "A")); + + final List results = + adapter.query(query).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(1); + } + + // --- Cursor pagination (raw reads) --- + + @Test + public void rawReadEmitsNextCursorWhenMoreDataThanLimit() { + // Three points match but the page size is two: the adapter fetches limit+1, trims to the + // page, and returns a cursor pointing just past the last returned point. + final FindPublisher finder = stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0)), + docFor(Instant.parse("2026-01-14T10:01:00Z"), JsonValue.of(2.0)), + docFor(Instant.parse("2026-01-14T10:02:00Z"), JsonValue.of(3.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(limitedQuery(2)).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(2); + assertThat(results.get(0).getMeta().getHasMore()).contains(true); + // Fetches one extra row (page size + 1) to detect the further page. + verify(finder).limit(3); + final String nextCursor = results.get(0).getMeta().getNextCursor().orElseThrow(); + final org.eclipse.ditto.timeseries.model.TimeseriesCursor decoded = + org.eclipse.ditto.timeseries.model.TimeseriesCursor.decode(nextCursor); + assertThat(decoded.getTimestamp()).isEqualTo(Instant.parse("2026-01-14T10:01:00Z")); + } + + @Test + public void rawReadOmitsNextCursorOnLastPage() { + // Fewer points than the page size: no further page, so no cursor. + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0)), + docFor(Instant.parse("2026-01-14T10:01:00Z"), JsonValue.of(2.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(limitedQuery(2)).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(2); + assertThat(results.get(0).getMeta().getNextCursor()).isEmpty(); + assertThat(results.get(0).getMeta().getHasMore()).contains(false); + } + + @Test + public void rawReadDescendingOrderReturnsDataNewestFirst() { + // The mock returns docs in the order given; with order=desc the adapter requests a descending + // sort from Mongo, so this exercises the desc sort + (empty) keyset path end-to-end. + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:02:00Z"), JsonValue.of(3.0)), + docFor(Instant.parse("2026-01-14T10:01:00Z"), JsonValue.of(2.0)), + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List results = + adapter.query(descQuery()).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(3); + assertThat(results.get(0).getData().get(0).getValue().orElseThrow().asDouble()).isEqualTo(3.0); + } + + @Test + public void rawReadResumesFromCursorWithoutError() { + // A follow-up page: the query carries a cursor, which the adapter decodes and turns into a + // keyset filter. Exercises the decode + filter-construction path end-to-end. + stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:03:00Z"), JsonValue.of(4.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + final String cursor = org.eclipse.ditto.timeseries.model.TimeseriesCursor.of( + Instant.parse("2026-01-14T10:02:00Z"), 1L).encode(); + + final List results = + adapter.query(cursorQuery(cursor)).toCompletableFuture().join(); + + assertThat(results.get(0).getData()).hasSize(1); + assertThat(results.get(0).getData().get(0).getValue().orElseThrow().asDouble()).isEqualTo(4.0); + } + + @Test + public void queryFailsWhenAdapterNotInitialised() { + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> adapter.query(buildQuery()).toCompletableFuture().join()) + .withCauseInstanceOf(IllegalStateException.class); + } + + @Test + public void queryRejectsNullQuery() { + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + assertThatNullPointerException().isThrownBy(() -> adapter.query(null)); + } + + // --- scan (planner primitive) --- + + @Test + public void scanReturnsOrderedPointsForRange() { + final FindPublisher finder = stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0)), + docFor(Instant.parse("2026-01-14T10:00:01Z"), JsonValue.of(2.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + final List points = adapter.scan(THING_ID, PATH, + Instant.parse("2026-01-14T00:00:00Z"), Instant.parse("2026-01-15T00:00:00Z"), 0) + .toCompletableFuture().join(); + + assertThat(points).hasSize(2); + assertThat(points.get(0).getValue().orElseThrow().asDouble()).isEqualTo(1.0); + // A non-positive limit falls back to the configured scan ceiling. + verify(finder).limit(DefaultMongoDbTimeseriesAdapterConfig.DEFAULT_MAX_QUERY_RESULT_SIZE); + } + + @Test + public void scanAppliesCallerLimit() { + final FindPublisher finder = stubFindReturning(mongoCollection, + docFor(Instant.parse("2026-01-14T10:00:00Z"), JsonValue.of(1.0))); + final MongoDbTimeseriesAdapter adapter = newInitialisedAdapter(); + + adapter.scan(THING_ID, PATH, Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), 7).toCompletableFuture().join(); + + verify(finder).limit(7); + } + + // --- Native fill result mapping (_real-marker gap reconstruction) --- + + @Test + public void buildNativelyFilledResultFlagsDensifiedRowsAsGaps() { + final Instant t0 = Instant.parse("2026-01-14T00:00:00Z"); + final List buckets = Arrays.asList( + bucketDoc(t0, 10.0, true, "degC"), + bucketDoc(t0.plusSeconds(2), 13.0, true, "degC"), + bucketDoc(t0.plusSeconds(4), 20.25, false, null), // densified -> gap + bucketDoc(t0.plusSeconds(6), 42.0, true, "degC")); + + final org.eclipse.ditto.timeseries.model.TimeseriesQueryResult result = + MongoDbTimeseriesAdapter.buildNativelyFilledResult(THING_ID, PATH, buildQuery(), buckets); + final List data = result.getData(); + + assertThat(data).hasSize(4); + assertThat(data.get(0).isGap()).isFalse(); + assertThat(data.get(2).isGap()).isTrue(); + assertThat(data.get(2).getValue().orElseThrow().asDouble()).isEqualTo(20.25); + assertThat(data.get(3).isGap()).isFalse(); + assertThat(result.getMeta().getUnit()).contains("degC"); + } + + @Test + public void buildNativelyFilledResultKeepsNullRealBucketAsGap() { + final Instant t0 = Instant.parse("2026-01-14T00:00:00Z"); + + final org.eclipse.ditto.timeseries.model.TimeseriesQueryResult result = + MongoDbTimeseriesAdapter.buildNativelyFilledResult(THING_ID, PATH, buildQuery(), + Arrays.asList(bucketDoc(t0, null, true, "degC"))); + + final TimeseriesDataValue dataValue = result.getData().get(0); + assertThat(dataValue.isGap()).isTrue(); // a genuine-null real bucket stays a gap + assertThat(dataValue.getValue()).isEmpty(); + } + + @Test + public void buildNativelyFilledResultTreatsWasNullRealBucketAsGap() { + final Instant t0 = Instant.parse("2026-01-14T00:00:00Z"); + // A real bucket whose aggregate was null but $fill fabricated a value from its neighbours: + // the _wasNull marker must keep it a gap, not emit the fabricated value. + final org.bson.Document nullReal = new org.bson.Document("_id", java.util.Date.from(t0)) + .append("v", 25.0) + .append("_real", Boolean.TRUE) + .append("_wasNull", Boolean.TRUE) + .append("unit", "degC"); + + final org.eclipse.ditto.timeseries.model.TimeseriesQueryResult result = + MongoDbTimeseriesAdapter.buildNativelyFilledResult(THING_ID, PATH, buildQuery(), + Arrays.asList(nullReal)); + + final TimeseriesDataValue dataValue = result.getData().get(0); + assertThat(dataValue.isGap()).isTrue(); + assertThat(dataValue.getValue()).isEmpty(); + } + + private static org.bson.Document bucketDoc(final Instant t, final Double value, final boolean real, + final String unit) { + final org.bson.Document doc = new org.bson.Document("_id", java.util.Date.from(t)) + .append("v", value); + if (real) { + doc.append("_real", Boolean.TRUE); + } + if (unit != null) { + doc.append("unit", unit); + } + return doc; + } + + // --- Capabilities --- + + @Test + public void declaresPushDownCapabilities() { + final Capabilities caps = newInitialisedAdapter().capabilities(); + + assertThat(caps.supportsNativeQuery()).isTrue(); // query(...) is a complete executor + // Group aggregations plus derivative/integral (exact native operators) push down... + assertThat(caps.canPushDown(Aggregation.AVG)).isTrue(); + assertThat(caps.canPushDown(Aggregation.STDDEV)).isTrue(); + assertThat(caps.canPushDown(Aggregation.DERIVATIVE)).isTrue(); + assertThat(caps.canPushDown(Aggregation.INTEGRAL)).isTrue(); + // ...percentile is opt-in (native $percentile is approximate). + assertThat(caps.canPushDown(Aggregation.PERCENTILE)).isFalse(); + // linear/previous fill are native by default. + assertThat(caps.getNativeFillStrategies()) + .containsExactlyInAnyOrder(FillStrategy.LINEAR, FillStrategy.PREVIOUS); + } + + // --- Collection-name derivation --- + + @Test + public void collectionNameForReplacesDotsInNamespace() { + final String name = MongoDbTimeseriesAdapter.collectionNameFor(config, THING_ID); + + assertThat(name).isEqualTo("ts_org_eclipse_ditto"); + } + + @Test + public void collectionNameForUsesConfiguredPrefix() { + final MongoDbTimeseriesAdapterConfig customPrefix = DefaultMongoDbTimeseriesAdapterConfig.of( + mock(MongoDbConfig.class), "x_", Granularity.SECONDS); + + final String name = + MongoDbTimeseriesAdapter.collectionNameFor(customPrefix, THING_ID); + + assertThat(name).isEqualTo("x_org_eclipse_ditto"); + } + + // --- Helpers --- + + private static org.eclipse.ditto.timeseries.model.TimeseriesQuery buildQuery() { + return org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z")); + } + + private MongoDbTimeseriesAdapter newAdapterWithMaxResultSize(final int max) { + // Exclude derivative/integral from push-down so these ops take the kernel (scan) path where + // the max-query-result-size ceiling and its refusal apply — the native window operators run + // server-side and have their own (separate) bound. + final MongoDbTimeseriesAdapterConfig small = DefaultMongoDbTimeseriesAdapterConfig.of( + mock(MongoDbConfig.class), + ConfigFactory.parseString("max-query-result-size = " + max + "\n" + + "capabilities { pushable-aggregations = [\"avg\"], native-fill-strategies = [] }")); + return MongoDbTimeseriesAdapter.forTesting(mongoClient, mongoDatabase, small); + } + + private static org.eclipse.ditto.timeseries.model.TimeseriesQuery advancedQuery( + final Aggregation aggregation) { + return org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + null, aggregation, null, null, null); + } + + private static org.eclipse.ditto.timeseries.model.TimeseriesQuery limitedQuery(final int limit) { + return org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + null, null, null, limit, null); + } + + private static org.eclipse.ditto.timeseries.model.TimeseriesQuery cursorQuery(final String cursor) { + return org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + null, null, null, null, null, null, cursor); + } + + private static org.eclipse.ditto.timeseries.model.TimeseriesQuery descQuery() { + return org.eclipse.ditto.timeseries.model.TimeseriesQuery.of( + THING_ID, + Collections.singletonList(PATH), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-15T00:00:00Z"), + null, null, null, null, null, null, null, + org.eclipse.ditto.timeseries.model.SortOrder.DESC); + } + + private static org.bson.Document docFor(final Instant timestamp, final JsonValue value) { + return TimeseriesBsonMapper.toDocument(TimeseriesDataPoint.of( + THING_ID, PATH, timestamp, value, 1L, Collections.emptyMap(), null)); + } + + private static org.bson.Document docForWithTags(final Instant timestamp, final JsonValue value, + final Map tags) { + return TimeseriesBsonMapper.toDocument(TimeseriesDataPoint.of( + THING_ID, PATH, timestamp, value, 1L, tags, null)); + } + + @SuppressWarnings("unchecked") + private static FindPublisher stubFindReturning( + final MongoCollection collection, + final org.bson.Document... docs) { + + final FindPublisher finder = mock(FindPublisher.class); + when(finder.sort(any())).thenReturn(finder); + when(finder.maxTime(anyLong(), any(java.util.concurrent.TimeUnit.class))).thenReturn(finder); + when(finder.limit(anyInt())).thenReturn(finder); + doAnswer(invocation -> { + final org.reactivestreams.Subscriber sub = invocation.getArgument(0); + sub.onSubscribe(new org.reactivestreams.Subscription() { + private boolean delivered = false; + + @Override + public void request(final long n) { + if (delivered) { + return; + } + delivered = true; + for (final org.bson.Document d : docs) { + sub.onNext(d); + } + sub.onComplete(); + } + + @Override + public void cancel() { } + }); + return null; + }).when(finder).subscribe(any(org.reactivestreams.Subscriber.class)); + when(collection.find(any(Bson.class))).thenReturn(finder); + return finder; + } + + private void stubListCollectionNamesReturning(final String... names) { + @SuppressWarnings("unchecked") + final ListCollectionNamesPublisher publisher = mock(ListCollectionNamesPublisher.class); + doAnswer(invocation -> { + final Subscriber sub = invocation.getArgument(0); + sub.onSubscribe(new Subscription() { + private boolean delivered = false; + + @Override + public void request(final long n) { + if (delivered) { + return; + } + delivered = true; + for (final String name : names) { + sub.onNext(name); + } + sub.onComplete(); + } + + @Override + public void cancel() { /* no-op */ } + }); + return null; + }).when(publisher).subscribe(any()); + when(mongoDatabase.listCollectionNames()).thenReturn(publisher); + } + + private static TimeseriesDataPoint sampleDataPoint(final ThingId thingId) { + return TimeseriesDataPoint.of( + thingId, + PATH, + Instant.parse("2026-01-15T10:30:00Z"), + JsonValue.of(23.5), + 42L, + Collections.emptyMap(), + null); + } + + private static Publisher completingPublisher(final T value) { + return s -> s.onSubscribe(new Subscription() { + private boolean delivered = false; + + @Override + public void request(final long n) { + if (delivered) { + return; + } + delivered = true; + s.onNext(value); + s.onComplete(); + } + + @Override + public void cancel() { + // no-op + } + }); + } + + private static Publisher failingPublisher(final Throwable error) { + return s -> s.onSubscribe(new Subscription() { + private boolean delivered = false; + + @Override + public void request(final long n) { + if (delivered) { + return; + } + delivered = true; + s.onError(error); + } + + @Override + public void cancel() { + // no-op + } + }); + } + + @SuppressWarnings("unused") + private static Subscriber noopSubscriber() { + return new Subscriber() { + @Override + public void onSubscribe(final Subscription s) { s.request(Long.MAX_VALUE); } + @Override + public void onNext(final T t) { } + @Override + public void onError(final Throwable t) { } + @Override + public void onComplete() { } + }; + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesAdapterDemo.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesAdapterDemo.java new file mode 100644 index 00000000000..f5cd8ab6a38 --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesAdapterDemo.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.DefaultMongoDbConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; + +import com.typesafe.config.Config; +import com.typesafe.config.ConfigFactory; + +/** + * Standalone demo for exercising {@link MongoDbTimeseriesAdapter} against a real local MongoDB. + *

+ * Intended for hand-running while developing the timeseries feature — writes a handful of fake + * temperature readings for a unique demo Thing, queries them back, prints the result, and drops + * the data on the way out. + * + *

Running

+ * Default URI is {@code mongodb://localhost:27017}; override via the + * {@code TIMESERIES_MONGODB_TEST_URI} environment variable. + *
+ *   # from the project root
+ *   mvn -pl timeseries/mongodb compile test-compile
+ *   mvn -pl timeseries/mongodb exec:java \
+ *       -Dexec.classpathScope=test \
+ *       -Dexec.mainClass=org.eclipse.ditto.timeseries.mongodb.TimeseriesAdapterDemo
+ * 
+ * Or run the {@code main} method directly from your IDE. + * + *

Each invocation uses a fresh, unique demo Thing namespace ({@code demo.<timestamp>}) so + * runs do not collide with each other. The adapter writes into a regular MongoDB collection — no + * {@code timeseries} options on createCollection — so the demo works against MongoDB versions + * older than 5.0 too. + */ +public final class TimeseriesAdapterDemo { + + private static final String DEFAULT_URI = "mongodb://localhost:27017"; + private static final String DATABASE = "ditto_ts_demo"; + + private TimeseriesAdapterDemo() { + throw new AssertionError(); + } + + public static void main(final String[] args) throws Exception { + final String uri = System.getenv().getOrDefault("TIMESERIES_MONGODB_TEST_URI", DEFAULT_URI); + + final ThingId thingId = ThingId.of("demo.timeseries", + "sensor-" + UUID.randomUUID().toString().substring(0, 8)); + final JsonPointer path = JsonPointer.of("/features/environment/properties/temperature"); + + final Config rootConfig = ConfigFactory.parseString(String.format( + "ditto.mongodb.uri = \"%s\"\nditto.mongodb.database = \"%s\"\n", uri, DATABASE)); + final MongoDbConfig mongoDbConfig = + DefaultMongoDbConfig.of(DefaultScopedConfig.dittoScoped(rootConfig)); + final MongoDbTimeseriesAdapterConfig config = DefaultMongoDbTimeseriesAdapterConfig.of( + mongoDbConfig, "ts_", Granularity.SECONDS); + final MongoDbTimeseriesAdapter adapter = new MongoDbTimeseriesAdapter(); + + System.out.println("=== Connecting to MongoDB at " + uri + " ==="); + adapter.initialize(config).toCompletableFuture().get(); + System.out.println("Adapter health: " + adapter.getHealth()); + + try { + final List writes = sampleData(thingId, path); + System.out.println(); + System.out.println("=== Writing " + writes.size() + " data points for " + thingId + " ==="); + adapter.writeBatch(writes).toCompletableFuture().get(); + System.out.println("Write batch complete."); + + // Slight pause for write-acknowledgement determinism on slower drivers. + Thread.sleep(150); + + final TimeseriesQuery query = TimeseriesQuery.of( + thingId, + Collections.singletonList(path), + Instant.parse("2026-01-14T00:00:00Z"), + Instant.parse("2026-01-14T01:00:00Z")); + + System.out.println(); + System.out.println("=== Querying the data back ==="); + System.out.println("from=" + query.getFrom() + " to=" + query.getTo()); + final List results = + adapter.query(query).toCompletableFuture().get(); + + System.out.println("Got " + results.size() + " result(s)."); + for (final TimeseriesQueryResult result : results) { + System.out.println(" path=" + result.getPath()); + System.out.println(" meta=" + result.getMeta()); + result.getData().forEach(value -> + System.out.println(" " + value.getTimestamp() + " -> " + value.getValue().orElse(null))); + } + + System.out.println(); + System.out.println("=== Done ==="); + } finally { + adapter.shutdown().toCompletableFuture().get(); + System.out.println("Adapter shut down."); + } + } + + private static List sampleData(final ThingId thingId, final JsonPointer path) { + final Map tags = new LinkedHashMap<>(); + tags.put("attributes/building", "A"); + tags.put("attributes/floor", "2"); + + final List points = new ArrayList<>(); + Instant t = Instant.parse("2026-01-14T00:00:00Z"); + final double[] temperatures = {22.0, 22.3, 22.5, 22.4, 22.2}; + for (int i = 0; i < temperatures.length; i++) { + points.add(TimeseriesDataPoint.of( + thingId, + path, + t, + JsonValue.of(temperatures[i]), + 100L + i, + tags, + "cel")); + t = t.plus(10, ChronoUnit.MINUTES); + } + return points; + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapperTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapperTest.java new file mode 100644 index 00000000000..f1f6ec5552c --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesBsonMapperTest.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.bson.Document; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.junit.Test; + +/** + * Unit tests for {@link TimeseriesBsonMapper}. + */ +public final class TimeseriesBsonMapperTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = + JsonPointer.of("/features/environment/properties/temperature"); + private static final Instant TIMESTAMP = Instant.parse("2026-01-15T10:30:00Z"); + private static final long REVISION = 42L; + + private static TimeseriesDataPoint dp(final JsonValue value, final Map tags, + final String unit) { + + return TimeseriesDataPoint.of(THING_ID, PATH, TIMESTAMP, value, REVISION, tags, unit); + } + + @Test + public void documentContainsAllTopLevelFields() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(23.5), Collections.emptyMap(), null)); + + assertThat(doc.containsKey(TimeseriesBsonMapper.FIELD_TIMESTAMP)).isTrue(); + assertThat(doc.containsKey(TimeseriesBsonMapper.FIELD_META)).isTrue(); + assertThat(doc.containsKey(TimeseriesBsonMapper.FIELD_VALUE)).isTrue(); + assertThat(doc.containsKey(TimeseriesBsonMapper.FIELD_REVISION)).isTrue(); + } + + @Test + public void timestampIsBsonDateMillisecondPrecision() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), null)); + + final Object ts = doc.get(TimeseriesBsonMapper.FIELD_TIMESTAMP); + assertThat(ts).isInstanceOf(Date.class); + assertThat(((Date) ts).toInstant()).isEqualTo(TIMESTAMP); + } + + @Test + public void metaContainsThingIdAndPath() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), null)); + + final Document meta = (Document) doc.get(TimeseriesBsonMapper.FIELD_META); + assertThat(meta.getString(TimeseriesBsonMapper.META_THING_ID)).isEqualTo(THING_ID.toString()); + assertThat(meta.getString(TimeseriesBsonMapper.META_PATH)).isEqualTo(PATH.toString()); + } + + @Test + public void metaOmitsTagsWhenEmpty() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), null)); + + final Document meta = (Document) doc.get(TimeseriesBsonMapper.FIELD_META); + assertThat(meta.containsKey(TimeseriesBsonMapper.META_TAGS)).isFalse(); + } + + @Test + public void metaContainsTagsWhenPresent() { + final Map tags = new LinkedHashMap<>(); + tags.put("attributes/building", "A"); + tags.put("attributes/floor", "2"); + + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), tags, null)); + + final Document meta = (Document) doc.get(TimeseriesBsonMapper.FIELD_META); + final Document tagsDoc = (Document) meta.get(TimeseriesBsonMapper.META_TAGS); + assertThat(tagsDoc.getString("attributes/building")).isEqualTo("A"); + assertThat(tagsDoc.getString("attributes/floor")).isEqualTo("2"); + } + + @Test + public void metaOmitsUnitWhenAbsent() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), null)); + + final Document meta = (Document) doc.get(TimeseriesBsonMapper.FIELD_META); + assertThat(meta.containsKey(TimeseriesBsonMapper.META_UNIT)).isFalse(); + } + + @Test + public void metaContainsUnitWhenPresent() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), "cel")); + + final Document meta = (Document) doc.get(TimeseriesBsonMapper.FIELD_META); + assertThat(meta.getString(TimeseriesBsonMapper.META_UNIT)).isEqualTo("cel"); + } + + @Test + public void revisionIsStoredAsLong() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(0), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_REVISION)).isEqualTo(REVISION); + } + + // --- Value type preservation --- + + @Test + public void doubleValueIsStoredAsDouble() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(23.5), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo(23.5); + } + + @Test + public void intValueIsStoredAsInteger() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(42), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo(42); + } + + @Test + public void longValueIsStoredAsLong() { + final long bigValue = 9_000_000_000L; + + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(bigValue), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo(bigValue); + } + + @Test + public void stringValueIsStoredAsString() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of("ok"), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo("ok"); + } + + @Test + public void booleanValueIsStoredAsBoolean() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.of(true), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isEqualTo(true); + } + + @Test + public void nullValueIsStoredAsNullField() { + final Document doc = TimeseriesBsonMapper.toDocument( + dp(JsonValue.nullLiteral(), Collections.emptyMap(), null)); + + assertThat(doc.get(TimeseriesBsonMapper.FIELD_VALUE)).isNull(); + } + + // --- Reject complex values (per per-series type-binding rule) --- + + // Note: array/object value rejection used to live in TimeseriesBsonMapper and was tested + // here. Since IOT-495, the model layer (ImmutableTimeseriesDataPoint.of) enforces the same + // invariant — non-scalar values can't even reach this mapper because the data point can't + // be constructed. The model-level coverage lives in + // ImmutableTimeseriesDataPointTest#factoryRejectsArrayValue / factoryRejectsObjectValue. + + @Test + public void toDocumentRejectsNullDataPoint() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesBsonMapper.toDocument(null)); + } + + // --- toDataValue (read path) --- + + @Test + public void toDataValueRoundTripsViaMappingPair() { + final TimeseriesDataPoint dp = dp(JsonValue.of(23.5), Collections.emptyMap(), null); + + final org.bson.Document doc = TimeseriesBsonMapper.toDocument(dp); + final org.eclipse.ditto.timeseries.model.TimeseriesDataValue value = + TimeseriesBsonMapper.toDataValue(doc); + + assertThat(value.getTimestamp()).isEqualTo(dp.getTimestamp()); + assertThat(value.getValue()).contains(dp.getValue()); + assertThat(value.isGap()).isFalse(); + } + + @Test + public void toDataValueRoundTripsNullValueAsExplicitNullLiteral() { + // A null JSON value at ingestion (sensor reported JSON null) round-trips back as a + // non-gap data value whose value is the JSON null literal — distinct from a gap, which + // is a value entirely absent (Optional.empty()). + final TimeseriesDataPoint dp = + dp(JsonValue.nullLiteral(), Collections.emptyMap(), null); + + final org.eclipse.ditto.timeseries.model.TimeseriesDataValue value = + TimeseriesBsonMapper.toDataValue(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(value.getValue()).contains(JsonValue.nullLiteral()); + assertThat(value.isGap()).isFalse(); + } + + @Test + public void toDataValueRoundTripsBooleanValue() { + final TimeseriesDataPoint dp = dp(JsonValue.of(true), Collections.emptyMap(), null); + + final org.eclipse.ditto.timeseries.model.TimeseriesDataValue value = + TimeseriesBsonMapper.toDataValue(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(value.getValue()).contains(JsonValue.of(true)); + } + + @Test + public void toDataValueRoundTripsStringValue() { + final TimeseriesDataPoint dp = dp(JsonValue.of("ok"), Collections.emptyMap(), null); + + final org.eclipse.ditto.timeseries.model.TimeseriesDataValue value = + TimeseriesBsonMapper.toDataValue(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(value.getValue()).contains(JsonValue.of("ok")); + } + + @Test + public void toDataValueRejectsMissingTimestamp() { + final org.bson.Document docWithoutTimestamp = new org.bson.Document() + .append(TimeseriesBsonMapper.FIELD_VALUE, 42); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> TimeseriesBsonMapper.toDataValue(docWithoutTimestamp)) + .withMessageContaining("timestamp"); + } + + @Test + public void toDataValueRejectsNullDocument() { + assertThatNullPointerException().isThrownBy(() -> TimeseriesBsonMapper.toDataValue(null)); + } + + @Test + public void getStoredUnitReturnsValueWhenPresent() { + final TimeseriesDataPoint dp = dp(JsonValue.of(0), Collections.emptyMap(), "cel"); + + final java.util.Optional unit = + TimeseriesBsonMapper.getStoredUnit(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(unit).contains("cel"); + } + + @Test + public void getStoredUnitReturnsEmptyWhenAbsent() { + final TimeseriesDataPoint dp = dp(JsonValue.of(0), Collections.emptyMap(), null); + + final java.util.Optional unit = + TimeseriesBsonMapper.getStoredUnit(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(unit).isEmpty(); + } + + @Test + public void getStoredPathReturnsTheDittoProtocolPath() { + final TimeseriesDataPoint dp = dp(JsonValue.of(0), Collections.emptyMap(), null); + + final String path = TimeseriesBsonMapper.getStoredPath(TimeseriesBsonMapper.toDocument(dp)); + + assertThat(path).isEqualTo(PATH.toString()); + } +} diff --git a/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslatorTest.java b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslatorTest.java new file mode 100644 index 00000000000..b60a86cd52b --- /dev/null +++ b/timeseries/mongodb/src/test/java/org/eclipse/ditto/timeseries/mongodb/TimeseriesRqlTranslatorTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import org.bson.BsonDocument; +import org.bson.codecs.configuration.CodecRegistries; +import org.bson.codecs.configuration.CodecRegistry; +import org.bson.conversions.Bson; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.junit.Test; + +import com.mongodb.MongoClientSettings; + +/** + * Unit tests for {@link TimeseriesRqlTranslator}: RQL in, MongoDB filter over {@code meta.tags.*} out. + */ +public final class TimeseriesRqlTranslatorTest { + + private static final CodecRegistry CODECS = CodecRegistries.fromRegistries( + MongoClientSettings.getDefaultCodecRegistry()); + + private static String render(final Bson filter) { + return filter.toBsonDocument(BsonDocument.class, CODECS).toJson(); + } + + @Test + public void eqTargetsTheTagSubDocument() { + assertThat(render(TimeseriesRqlTranslator.translate("eq(attributes/building,'A')"))) + .isEqualTo("{\"meta.tags.attributes/building\": \"A\"}"); + } + + @Test + public void aLeadingSlashAddressesTheSameTag() { + assertThat(render(TimeseriesRqlTranslator.translate("eq(/attributes/building,'A')"))) + .isEqualTo(render(TimeseriesRqlTranslator.translate("eq(attributes/building,'A')"))); + } + + @Test + public void comparisonOperatorsMapToTheirMongoEquivalents() { + assertThat(render(TimeseriesRqlTranslator.translate("ne(attributes/floor,2)"))).contains("$ne"); + assertThat(render(TimeseriesRqlTranslator.translate("gt(attributes/floor,2)"))).contains("$gt"); + assertThat(render(TimeseriesRqlTranslator.translate("ge(attributes/floor,2)"))).contains("$gte"); + assertThat(render(TimeseriesRqlTranslator.translate("lt(attributes/floor,2)"))).contains("$lt"); + assertThat(render(TimeseriesRqlTranslator.translate("le(attributes/floor,2)"))).contains("$lte"); + } + + @Test + public void inBecomesAnInFilter() { + final String rendered = render(TimeseriesRqlTranslator.translate( + "in(attributes/floor,'1','2','3')")); + assertThat(rendered).contains("$in").contains("\"1\"").contains("\"3\""); + } + + @Test + public void logicalOperatorsNest() { + assertThat(render(TimeseriesRqlTranslator.translate( + "and(eq(attributes/building,'A'),ge(attributes/floor,2))"))).contains("$and"); + assertThat(render(TimeseriesRqlTranslator.translate( + "or(eq(attributes/building,'A'),eq(attributes/building,'B'))"))).contains("$or"); + // not() has no direct Mongo operator; $nor over a single clause is the equivalent. + assertThat(render(TimeseriesRqlTranslator.translate("not(eq(attributes/building,'A'))"))) + .contains("$nor"); + } + + /** + * {@code like} wildcards become a regex, but literal segments are quoted — a value containing regex + * metacharacters must not be able to smuggle a pattern past the filter. + */ + @Test + public void likeQuotesRegexMetacharactersInLiteralSegments() { + final String wildcard = render(TimeseriesRqlTranslator.translate( + "like(attributes/building,'Building-*')")); + assertThat(wildcard).contains("$regularExpression"); + // Literal part quoted with \Q...\E, wildcard translated to .* + assertThat(wildcard).contains("Q" + "Building-").contains(".*"); + // The '.' in a value must be quoted, not left meaning "any character". + final String dotted = render(TimeseriesRqlTranslator.translate( + "like(attributes/building,'a.c')")); + assertThat(dotted).contains("Qa.c"); + } + + @Test + public void existsChecksForThePresenceOfTheTag() { + assertThat(render(TimeseriesRqlTranslator.translate("exists(attributes/building)"))) + .contains("$exists"); + } + + /** + * A tag key is a Thing path and so contains no {@code .} or {@code $}. Rejecting those keeps the + * filter from being read as a nested field reference or a Mongo operator. + */ + @Test + public void aFieldReferenceContainingDotOrDollarIsRejected() { + for (final String rql : new String[] {"eq(a.b,'x')", "eq($where,'x')"}) { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesRqlTranslator.translate(rql)); + } + } + + @Test + public void unparseableRqlIsRejectedWithAnActionableMessage() { + assertThatExceptionOfType(TimeseriesQueryInvalidException.class) + .isThrownBy(() -> TimeseriesRqlTranslator.translate("this is not rql")) + .withMessageContaining("not a valid RQL predicate"); + } +} diff --git a/timeseries/pom.xml b/timeseries/pom.xml new file mode 100644 index 00000000000..b10e4311313 --- /dev/null +++ b/timeseries/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + + org.eclipse.ditto + ditto-bom + ${revision} + ../bom + + + ditto-timeseries + pom + Eclipse Ditto :: Timeseries + + + model + api + service + mongodb + + + + + com.google.code.findbugs + jsr305 + provided + + + org.eclipse.ditto + ditto-utils-jsr305 + provided + + + + + org.assertj + assertj-core + test + + + junit + junit + test + + + nl.jqno.equalsverifier + equalsverifier + test + + + org.eclipse.ditto + ditto-json + test-jar + test + + + org.skyscreamer + jsonassert + test + + + + diff --git a/timeseries/service/pom.xml b/timeseries/service/pom.xml new file mode 100644 index 00000000000..585beac6eb7 --- /dev/null +++ b/timeseries/service/pom.xml @@ -0,0 +1,192 @@ + + + + + ditto-timeseries + org.eclipse.ditto + ${revision} + + 4.0.0 + + ditto-timeseries-service + Eclipse Ditto :: Timeseries :: Service + + + + org.eclipse.ditto + ditto-rql-parser + + + org.eclipse.ditto + ditto-rql-query + + + org.eclipse.ditto + ditto-rql-model + + + org.eclipse.ditto + ditto-base-service + + + org.eclipse.ditto + ditto-timeseries-api + + + org.eclipse.ditto + ditto-timeseries-mongodb + + + org.eclipse.ditto + ditto-internal-utils-pekko + + + org.eclipse.ditto + ditto-internal-utils-cluster + + + org.eclipse.ditto + ditto-internal-utils-health + + + org.eclipse.ditto + ditto-internal-utils-config + + + + org.eclipse.ditto + ditto-internal-utils-persistence + + + org.eclipse.ditto + ditto-things-api + + + org.eclipse.ditto + ditto-things-model + + + org.eclipse.ditto + ditto-policies-model + + + org.eclipse.ditto + ditto-policies-enforcement + + + + + org.codehaus.janino + janino + + + net.logstash.logback + logstash-logback-encoder + runtime + + + + + org.apache.pekko + pekko-testkit_${scala.version} + test + + + org.mockito + mockito-core + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + ${shade.phase} + + shade + + + true + allinone + false + + + *:* + + + + + *:* + + LICENSE + LICENSE.txt + NOTICE + THIRD-PARTY.txt + README.md + rootdoc.txt + META-INF/lmdbjava-LICENSE.txt + META-INF/lmdbjava-THIRD-PARTY.txt + META-INF/NOTICE + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + reference.conf + + + ditto-service-extension.conf + + + + org.eclipse.ditto.timeseries.service.starter.TimeseriesService + true + + + + + + + + + org.atteo.classindex + classindex-transformer + ${classindex.version} + + + + + + + diff --git a/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActor.java b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActor.java new file mode 100644 index 00000000000..2af12daf41a --- /dev/null +++ b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActor.java @@ -0,0 +1,832 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.handlers; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; + +import org.apache.pekko.actor.AbstractActor; +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.Props; +import org.apache.pekko.actor.Status; +import org.apache.pekko.japi.pf.ReceiveBuilder; +import org.apache.pekko.pattern.Patterns; +import org.eclipse.ditto.base.model.auth.AuthorizationContext; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoLoggerFactory; +import org.eclipse.ditto.internal.utils.pekko.logging.ThreadSafeDittoLogger; +import org.eclipse.ditto.json.JsonFactory; +import org.eclipse.ditto.json.JsonFieldSelector; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; +import org.eclipse.ditto.policies.enforcement.config.NamespacePoliciesConfig; +import org.eclipse.ditto.policies.model.ImportableType; +import org.eclipse.ditto.policies.model.PoliciesModelFactory; +import org.eclipse.ditto.policies.model.PoliciesResourceType; +import org.eclipse.ditto.policies.model.Permissions; +import org.eclipse.ditto.policies.model.Policy; +import org.eclipse.ditto.policies.model.PolicyEntry; +import org.eclipse.ditto.policies.model.PolicyId; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.policies.model.enforcers.Enforcer; +import org.eclipse.ditto.policies.model.enforcers.PolicyEnforcers; +import org.eclipse.ditto.rql.model.ParserException; +import org.eclipse.ditto.rql.model.predicates.ast.RootNode; +import org.eclipse.ditto.rql.parser.RqlPredicateParser; +import org.eclipse.ditto.rql.query.things.FieldNamesPredicateVisitor; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.things.api.Permission; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThing; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThingResponse; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesAggregationForbiddenException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; + +/** + * Handles {@link RetrieveAggregatedTimeseries} — timeseries aggregations spanning many Things of one + * namespace. + * + *

Why this is not the per-Thing shard entity

+ * {@link org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries} is routed through + * the timeseries shard region keyed on its {@code thingId}. A cross-Thing query has no Thing to key + * on, so it is delivered to this per-node actor by path via pub/sub — the same shape thing-search + * uses for its query commands. Registering a well-known path rather than inventing a synthetic + * entity ID keeps the shard region meaning exactly one thing: per-Thing work. + * + *

Enforcement: live, and never read from storage

+ * Authorization is a statement about now, while data points are historical. A subject + * granted access today must see history ingested before the grant, and a revoked subject must + * immediately lose history it could previously read. Any snapshot of grants written alongside the + * data points would be wrong in at least one of those directions, and — because the store is + * append-only — there would be no reindex path to repair it. So nothing about authorization is + * stored: every request is decided against current policy state. + * + *

Two stages, and why both are needed

+ *
    + *
  1. The gate. The subject must hold the required permission namespace-wide, + * via a {@linkplain NamespacePoliciesConfig namespace root policy}. One policy, one check. Without + * it the request is rejected with {@link TimeseriesAggregationForbiddenException} — which also + * bounds the work an unauthorized caller can provoke, since nothing downstream runs. + *

    + * A caller whose grants are instead scattered across individual Thing policies is rejected here + * too. Serving them needs the readable Things enumerated from a live, policy-aware source + * (thing-search, whose grants are reindexed on policy change); until then a 403 beats silently + * returning a partial answer.

  2. + *
  3. Per-Thing, per-path narrowing. Passing the gate does not mean every Thing + * grants: root entries are merged additively, so a Thing's own policy can still revoke, and + * a revoke wins. So every Thing that has matching data is verified against its own live policy, + * per requested path.
  4. + *
+ * This costs one {@code SudoRetrieveThing} plus one (cached) enforcer load per contributing + * Thing, on every request. There is no O(1) shortcut: the additive merge above is precisely why + * checking the root policy alone cannot settle the namespace. That per-Thing cost is what + * {@code ditto.timeseries.max-verified-things} bounds, and why exceeding it fails the request rather + * than authorizing a truncated set. + * + * @since 4.0.0 + */ +public final class TimeseriesAggregateActor extends AbstractActor { + + /** + * Name of this actor. + */ + public static final String ACTOR_NAME = TimeseriesMessagingConstants.AGGREGATE_ACTOR_NAME; + + private static final ThreadSafeDittoLogger LOGGER = + DittoLoggerFactory.getThreadSafeLogger(TimeseriesAggregateActor.class); + + /** Timeout for resolving a contributing Thing's policy id, mirroring TimeseriesIngestActor. */ + private static final Duration THING_LOOKUP_TIMEOUT = Duration.ofSeconds(10); + + /** + * The only field the per-Thing authorization check reads. Keeps {@code SudoRetrieveThing} from + * transferring whole Things when all that is wanted is which policy governs them. + */ + private static final JsonFieldSelector POLICY_ID_SELECTOR = + JsonFactory.newFieldSelector(Thing.JsonFields.POLICY_ID); + + /** Cap on how many Thing IDs a single log line will name. */ + private static final int LOGGED_THING_ID_LIMIT = 10; + + /** Applied when the operator configures no ceiling on Things authorized per request. */ + public static final int DEFAULT_MAX_VERIFIED_THINGS = 1_000; + + private final TimeseriesAdapter adapter; + private final ActorRef thingsShardRegion; + private final PolicyEnforcerProvider policyEnforcerProvider; + private final NamespacePoliciesConfig namespacePoliciesConfig; + private final int maxVerifiedThings; + + /** + * Mirrors {@code TimeseriesIngestActor}: when {@code false} (the default, strict) the check + * requires {@link Permission#READ_TS}; when {@code true} plain {@link Permission#READ} suffices. + */ + private final boolean simplifiedReadPermission; + + private TimeseriesAggregateActor(final TimeseriesAdapter adapter, + @Nullable final ActorRef thingsShardRegion, + @Nullable final PolicyEnforcerProvider policyEnforcerProvider, + final NamespacePoliciesConfig namespacePoliciesConfig, + final boolean simplifiedReadPermission, + final int maxVerifiedThings) { + + this.adapter = adapter; + this.thingsShardRegion = thingsShardRegion; + this.policyEnforcerProvider = policyEnforcerProvider; + this.namespacePoliciesConfig = namespacePoliciesConfig; + this.simplifiedReadPermission = simplifiedReadPermission; + // No silent correction: a non-positive ceiling means "authorize nothing", and quietly + // substituting 1000 would be the opposite of what the operator wrote. Validated here as well + // as in TimeseriesRootActor so direct construction cannot bypass it. + if (maxVerifiedThings <= 0) { + throw new IllegalArgumentException( + "maxVerifiedThings must be positive, but was <" + maxVerifiedThings + ">."); + } + this.maxVerifiedThings = maxVerifiedThings; + } + + /** + * Creates {@link Props} for a fully-wired instance. + * + * @param adapter the configured timeseries backend. + * @param policyEnforcerProvider provider of the (cached) policy enforcers. + * @param namespacePoliciesConfig the namespace-root policy mapping. + * @param simplifiedReadPermission whether plain {@code READ} suffices instead of {@code READ_TS}. + * @return the Props. + * @throws NullPointerException if any argument is {@code null}. + */ + public static Props props(final TimeseriesAdapter adapter, + final ActorRef thingsShardRegion, + final PolicyEnforcerProvider policyEnforcerProvider, + final NamespacePoliciesConfig namespacePoliciesConfig, + final boolean simplifiedReadPermission, + final int maxVerifiedThings) { + + checkNotNull(adapter, "adapter"); + checkNotNull(thingsShardRegion, "thingsShardRegion"); + checkNotNull(policyEnforcerProvider, "policyEnforcerProvider"); + checkNotNull(namespacePoliciesConfig, "namespacePoliciesConfig"); + return Props.create(TimeseriesAggregateActor.class, + () -> new TimeseriesAggregateActor(adapter, thingsShardRegion, policyEnforcerProvider, + namespacePoliciesConfig, simplifiedReadPermission, maxVerifiedThings)); + } + + + @Override + public Receive createReceive() { + return ReceiveBuilder.create() + .match(RetrieveAggregatedTimeseries.class, this::handleRetrieveAggregated) + .matchAny(message -> { + LOGGER.warn("Received unknown message <{}>.", message); + unhandled(message); + }) + .build(); + } + + private void handleRetrieveAggregated(final RetrieveAggregatedTimeseries command) { + // Capture the sender before any async hop — getSender() is meaningless inside a callback. + final ActorRef sender = getSender(); + Patterns.pipe(authorizeAndQuery(command), getContext().getDispatcher()).to(sender); + } + + private CompletionStage authorizeAndQuery(final RetrieveAggregatedTimeseries command) { + final CompletionStage stage; + try { + requireCrossThingCapability(command); + // Gate on a namespace-wide grant, then narrow to the Things the caller may actually + // read: the gate bounds the work an unauthorized caller can provoke, the narrowing is + // what makes the answer correct. + stage = authorizeFilterAndGroupByFields(command) + .thenCompose(ignored -> authorizeNamespaceWide(command)) + .thenCompose(ignored -> resolvePermittedThings(command)) + .thenCompose(d -> runQuery(command, d.allowedPerPath(), + d.contributingThings(), d.fullyExcludedThings(), + d.withheldByPath())); + } catch (final DittoRuntimeException e) { + return CompletableFuture.completedFuture(e.setDittoHeaders(command.getDittoHeaders())); + } + return stage.exceptionally(throwable -> toFailure(command, throwable)); + } + + private void requireCrossThingCapability(final RetrieveAggregatedTimeseries command) { + if (!adapter.capabilities().supportsNativeCrossThingQuery()) { + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Rejecting cross-Thing aggregation: backend <{}> does not advertise " + + "cross-Thing support.", adapter.getClass().getSimpleName()); + // No kernel fallback: computing the grouping portably would scan every matching series + // into heap, the fan-out the guard rails exist to prevent. + throw TimeseriesQueryInvalidException.newBuilder( + "The configured timeseries backend does not support cross-Thing " + + "aggregation.") + .dittoHeaders(command.getDittoHeaders()) + .build(); + } + } + + /** + * Gate: requires a namespace-wide grant from the namespace root policies before any storage is + * touched. This is an entry condition, not the authorization decision — a caller with no + * namespace-level standing is rejected here without provoking a discovery query or a single + * policy load per Thing. Completes normally when every requested path is granted namespace-wide; + * fails with {@link TimeseriesAggregationForbiddenException} otherwise. + */ + + /** + * Authorization layer 1: the caller must hold {@code READ} on every Thing field the query + * selects or groups by, independently of the {@code READ_TS} check on the requested paths. + *

+ * Filtering by a tag discloses which points carry which value, and grouping by one discloses the + * distinct values — so a subject who may not read {@code attributes/building} must not be able to + * slice a timeseries by it. Tag keys are the Thing paths declared in the WoT model, which is + * precisely what makes them checkable as policy resources. + *

+ * Evaluated against the namespace root policy, the same enforcer the namespace-wide gate uses: + * this is a statement about the caller's entitlement to the dimension, not about any one + * Thing's data, and it runs before discovery so an unauthorized slice provokes no work. + */ + private CompletionStage authorizeFilterAndGroupByFields( + final RetrieveAggregatedTimeseries command) { + + final Set fields = new LinkedHashSet<>(); + command.getQuery().getFilter().ifPresent(rql -> fields.addAll(filterFieldNames(rql, command))); + for (final GroupBy dimension : command.getQuery().getGroupBy()) { + if (dimension.getKind() == GroupBy.Kind.TAG) { + dimension.getTagKey().ifPresent(fields::add); + } + } + if (fields.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + final DittoHeaders headers = command.getDittoHeaders(); + final List rootPolicyIds = namespacePoliciesConfig + .getRootPoliciesForNamespace(command.getQuery().getNamespace()); + if (rootPolicyIds.isEmpty()) { + return CompletableFuture.failedFuture(fieldsForbidden(fields, command)); + } + return anyRootPolicyGrantsReadOnFields(rootPolicyIds, fields, command) + .thenCompose(granted -> granted + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(fieldsForbidden(fields, command))); + } + + private CompletionStage anyRootPolicyGrantsReadOnFields(final List rootPolicyIds, + final Set fields, final RetrieveAggregatedTimeseries command) { + + final DittoHeaders headers = command.getDittoHeaders(); + final Permissions read = Permissions.newInstance(Permission.READ); + CompletionStage combined = CompletableFuture.completedFuture(false); + for (final PolicyId rootPolicyId : rootPolicyIds) { + final CompletionStage check = policyEnforcerProvider.getPolicyEnforcer(rootPolicyId) + .thenApply(enforcerOpt -> enforcerOpt + .map(policyEnforcer -> grantsReadOnAllFields(policyEnforcer, fields, + command.getQuery().getNamespace(), headers, read)) + .orElse(false)); + combined = combined.thenCombine(check, (a, b) -> a || b); + } + return combined; + } + + private static boolean grantsReadOnAllFields(final PolicyEnforcer policyEnforcer, + final Set fields, final String namespace, final DittoHeaders headers, + final Permissions read) { + + if (policyEnforcer.getPolicy().isEmpty()) { + return false; + } + final Enforcer enforcer = policyEnforcer.forNamespace(namespace).getEnforcer(); + for (final String field : fields) { + final String pointer = field.startsWith("/") ? field : "/" + field; + final ResourceKey resourceKey = + PoliciesResourceType.thingResource(JsonPointer.of(pointer)); + if (!enforcer.hasUnrestrictedPermissions(resourceKey, headers.getAuthorizationContext(), + read)) { + return false; + } + } + return true; + } + + /** + * Extracts the field references from an RQL filter. Reuses the same visitor + * {@code ThingCommandEnforcement} uses for the {@code condition} header, so a filter is authorized + * the same way a condition is. + */ + private static Set filterFieldNames(final String rql, + final RetrieveAggregatedTimeseries command) { + + final RootNode rootNode; + try { + rootNode = RqlPredicateParser.getInstance().parse(rql); + } catch (final ParserException e) { + throw TimeseriesQueryInvalidException + .newBuilder("The 'filter' is not a valid RQL predicate: " + e.getMessage()) + .dittoHeaders(command.getDittoHeaders()) + .build(); + } + final FieldNamesPredicateVisitor visitor = FieldNamesPredicateVisitor.getNewInstance(); + visitor.visit(rootNode); + return visitor.getFieldNames(); + } + + private static DittoRuntimeException fieldsForbidden(final Set fields, + final RetrieveAggregatedTimeseries command) { + + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Subject denied 'READ' on one of the filter/groupBy fields {} of a cross-Thing " + + "aggregation on namespace <{}>.", fields, command.getQuery().getNamespace()); + return TimeseriesAggregationForbiddenException + .forNamespace(command.getQuery().getNamespace(), Permission.READ) + .dittoHeaders(command.getDittoHeaders()) + .build(); + } + + private CompletionStage authorizeNamespaceWide(final RetrieveAggregatedTimeseries command) { + final CrossThingTimeseriesQuery query = command.getQuery(); + final String namespace = query.getNamespace(); + final DittoHeaders headers = command.getDittoHeaders(); + + final List rootPolicyIds = + namespacePoliciesConfig.getRootPoliciesForNamespace(namespace); + if (rootPolicyIds.isEmpty()) { + LOGGER.withCorrelationId(headers) + .info("Rejecting cross-Thing aggregation on <{}>: no namespace root policy is " + + "configured for that namespace.", namespace); + return CompletableFuture.failedFuture( + TimeseriesAggregationForbiddenException.forNamespace(namespace, requiredPermission()) + .dittoHeaders(headers) + .build()); + } + + // Any single root policy granting every requested path passes the gate. Note this does NOT + // establish that every Thing grants: the merge is additive, so a Thing's own policy can still + // revoke — which resolvePermittedThings() below is what actually handles. + return anyRootPolicyGrantsAllPaths(rootPolicyIds, query, headers) + .thenCompose(granted -> { + if (granted) { + return CompletableFuture.completedFuture(null); + } + LOGGER.withCorrelationId(headers) + .info("Subject <{}> denied cross-Thing '{}' on namespace <{}> paths {}.", + headers.getAuthorizationContext().getAuthorizationSubjectIds(), + requiredPermission(), namespace, query.getPaths()); + return CompletableFuture.failedFuture( + TimeseriesAggregationForbiddenException + .forNamespace(namespace, requiredPermission()) + .dittoHeaders(headers) + .build()); + }); + } + + private CompletionStage anyRootPolicyGrantsAllPaths(final List rootPolicyIds, + final CrossThingTimeseriesQuery query, + final DittoHeaders headers) { + + final List> checks = new ArrayList<>(rootPolicyIds.size()); + for (final PolicyId rootPolicyId : rootPolicyIds) { + checks.add(policyEnforcerProvider.getPolicyEnforcer(rootPolicyId) + .thenApply(enforcerOpt -> enforcerOpt + .map(policyEnforcer -> implicitEntriesGrantAllPaths(policyEnforcer, + rootPolicyId, query, headers)) + .orElseGet(() -> { + LOGGER.withCorrelationId(headers) + .warn("Namespace root policy <{}> could not be loaded; " + + "treating it as granting nothing.", rootPolicyId); + return false; + }))); + } + + CompletionStage combined = CompletableFuture.completedFuture(false); + for (final CompletionStage check : checks) { + combined = combined.thenCombine(check, (a, b) -> a || b); + } + return combined; + } + + /** + * Evaluates the root policy's implicit entries only. + *

+ * This is the crux of why checking one policy may stand in for the whole namespace: + * {@code PolicyImporter.mergeImplicitNamespaceRootEntries} merges an entry into each Thing's + * policy only when its {@code importable} type is {@link ImportableType#IMPLICIT}. An + * {@code explicit} or {@code never} entry is never merged, so a grant it carries confers nothing + * on any Thing in the namespace — evaluating the root policy's full enforcer would + * therefore authorize an aggregation the per-Thing policies do not actually permit. + *

+ * Fails closed: if the {@link Policy} itself is unavailable (a {@code PolicyEnforcer} built via + * {@code embed(...)} carries only the pre-built {@link Enforcer}), importability cannot be + * verified and the grant is not honoured. + */ + private boolean implicitEntriesGrantAllPaths(final PolicyEnforcer policyEnforcer, + final PolicyId rootPolicyId, + final CrossThingTimeseriesQuery query, + final DittoHeaders headers) { + + final Optional policyOpt = policyEnforcer.getPolicy(); + if (policyOpt.isEmpty()) { + LOGGER.withCorrelationId(headers) + .warn("Namespace root policy <{}> resolved to an enforcer without its Policy; " + + "cannot verify that its entries are implicitly importable, so treating " + + "it as granting nothing.", rootPolicyId); + return false; + } + + final List implicitEntries = new ArrayList<>(); + for (final PolicyEntry entry : policyOpt.get()) { + // Two independent conditions, and both are required: + // - IMPLICIT, because only implicit entries are merged into the namespace's Things; + // - appliesToNamespace, because an entry may be scoped to a subset of namespaces and + // PolicyImporter preserves that scoping when it injects the entry. Honouring a grant + // scoped to another namespace would authorize a read the single-Thing path denies. + if (ImportableType.IMPLICIT.equals(entry.getImportableType()) + && entry.appliesToNamespace(query.getNamespace())) { + implicitEntries.add(entry); + } + } + if (implicitEntries.isEmpty()) { + LOGGER.withCorrelationId(headers) + .info("Namespace root policy <{}> has no implicitly-importable entries applying " + + "to namespace <{}>; it grants nothing namespace-wide there.", + rootPolicyId, query.getNamespace()); + return false; + } + + // defaultEvaluator takes Iterable; wrapping the list in a Policy only to have it + // iterated straight back out adds a failure surface for nothing. + return grantsAllPaths(PolicyEnforcers.defaultEvaluator(implicitEntries), + headers.getAuthorizationContext(), query.getPaths()); + } + + /** + * Narrows the namespace-wide gate to the Things the caller may actually read. + *

+ * Passing the gate proves the namespace root grants the permission; it does not prove no + * Thing revokes it. Namespace-root entries are merged additively into each Thing's + * policy, and a local revoke beats an injected grant — so a Thing can be unreadable through the + * single-Thing endpoint while its measurements would still shape an unfiltered aggregate. This + * step closes that gap by discovering the Things that would contribute and checking each against + * live policy before any value is aggregated. + *

+ * Filtering afterwards is impossible: once values are folded into a bucket average, one Thing's + * contribution cannot be subtracted back out. + */ + private CompletionStage resolvePermittedThings( + final RetrieveAggregatedTimeseries command) { + + final DittoHeaders headers = command.getDittoHeaders(); + final long discoveryStart = System.nanoTime(); + return adapter.discoverContributors(command.getQuery(), maxVerifiedThings) + .thenCompose(contributorsPerPath -> { + final Set distinct = new LinkedHashSet<>(); + contributorsPerPath.values().forEach(distinct::addAll); + LOGGER.withCorrelationId(headers) + .debug("Cross-Thing discovery on <{}> found {} distinct contributing " + + "Thing(s) across {} path(s) in {}ms (ceiling {}).", + command.getQuery().getNamespace(), distinct.size(), + contributorsPerPath.size(), elapsedMs(discoveryStart), + maxVerifiedThings); + if (distinct.size() > maxVerifiedThings) { + LOGGER.withCorrelationId(headers) + .warn("Rejecting cross-Thing aggregation on <{}>: it spans more than " + + "the {} Things this service will authorize per request.", + command.getQuery().getNamespace(), maxVerifiedThings); + // Verifying a truncated set would silently authorize whatever fell off the + // end. Fail loudly instead, as the group cap does. + return CompletableFuture.failedFuture(TimeseriesQueryInvalidException + .newBuilder("The query spans more than " + maxVerifiedThings + + " Things, which exceeds the number this service will " + + "authorize per request. Narrow it with 'filter' or a " + + "shorter time range.") + .dittoHeaders(headers) + .build()); + } + if (distinct.isEmpty()) { + return CompletableFuture.completedFuture(AccessDecision.nothingMatched()); + } + return verifyEach(contributorsPerPath, distinct, command); + }); + } + + /** + * Resolves, for every discovered Thing, which of the requested paths it may be read on, + * then intersects that with the paths it actually has data for. + *

+ * Path-granular on purpose. {@code READ_TS} can be granted per property, so a Thing may be + * readable for one requested path and withheld from another; excluding it from the whole query + * would silently drop data the caller is entitled to. This mirrors how thing-search authorizes an + * RQL predicate per field rather than per document. + */ + private CompletionStage verifyEach( + final Map> contributorsPerPath, + final Set distinct, + final RetrieveAggregatedTimeseries command) { + + final long verifyStart = System.nanoTime(); + final List>>> checks = + new ArrayList<>(distinct.size()); + for (final ThingId thingId : distinct) { + checks.add(permittedPathsFor(thingId, command) + .thenApply(paths -> Map.entry(thingId, paths))); + } + + CompletionStage>> combined = + CompletableFuture.completedFuture(new LinkedHashMap<>()); + for (final CompletionStage>> check : checks) { + combined = combined.thenCombine(check, (acc, entry) -> { + acc.put(entry.getKey(), entry.getValue()); + return acc; + }); + } + + return combined.thenApply(permittedPathsByThing -> { + final Map> allowed = new LinkedHashMap<>(); + final Map> withheld = new LinkedHashMap<>(); + for (final Map.Entry> entry : contributorsPerPath.entrySet()) { + final JsonPointer path = entry.getKey(); + for (final ThingId thingId : entry.getValue()) { + if (permittedPathsByThing.getOrDefault(thingId, Set.of()).contains(path)) { + allowed.computeIfAbsent(path, k -> new ArrayList<>()).add(thingId); + } else { + withheld.computeIfAbsent(path, k -> new ArrayList<>()).add(thingId); + } + } + } + + final Set contributing = new LinkedHashSet<>(); + allowed.values().forEach(contributing::addAll); + final Set fullyExcluded = new LinkedHashSet<>(distinct); + fullyExcluded.removeAll(contributing); + + LOGGER.withCorrelationId(command.getDittoHeaders()) + .debug("Verified {} contributing Thing(s) on <{}> in {}ms.", + distinct.size(), command.getQuery().getNamespace(), + elapsedMs(verifyStart)); + if (!withheld.isEmpty()) { + // Name what was withheld, per path, at INFO: an operator asking "why is this average + // lower than I expect?" needs the identities, and by the time they can enable DEBUG the + // policy may already have changed. + final List perPath = new ArrayList<>(); + withheld.forEach((path, things) -> perPath.add(path + " -> " + abbreviate(things))); + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Cross-Thing aggregation on <{}> withheld data for lack of '{}'; the " + + "result is partial. Withheld per path: {}.", + command.getQuery().getNamespace(), requiredPermission(), + String.join("; ", perPath)); + } + return AccessDecision.of(allowed, contributing.size(), fullyExcluded.size(), + countsByPath(withheld)); + }); + } + + /** Milliseconds since the given {@code System.nanoTime()} reading. */ + private static long elapsedMs(final long startNanos) { + return (System.nanoTime() - startNanos) / 1_000_000L; + } + + /** + * Renders a Thing-ID list for a log line, capping it so one pathological request cannot emit an + * unbounded line. + */ + private static String abbreviate(final List thingIds) { + if (thingIds.size() <= LOGGED_THING_ID_LIMIT) { + return thingIds.toString(); + } + return thingIds.subList(0, LOGGED_THING_ID_LIMIT) + " …and " + + (thingIds.size() - LOGGED_THING_ID_LIMIT) + " more"; + } + + private static Map countsByPath(final Map> byPath) { + final Map counts = new LinkedHashMap<>(); + byPath.forEach((path, things) -> counts.put(path, things.size())); + return counts; + } + + /** + * Resolves the Thing's governing policy and checks the required permission on every requested + * path against it. Any failure — Thing missing, no policy, enforcer unavailable — excludes the + * Thing rather than including it: fail closed. + */ + private CompletionStage> permittedPathsFor(final ThingId thingId, + final RetrieveAggregatedTimeseries command) { + + final DittoHeaders headers = command.getDittoHeaders(); + // Only the policyId is needed. Without a selector this ships every attribute and feature of + // every contributing Thing across the wire, once per Thing per request, to read one field. + final SudoRetrieveThing sudo = + SudoRetrieveThing.of(thingId, POLICY_ID_SELECTOR, headers); + return Patterns.ask(thingsShardRegion, sudo, THING_LOOKUP_TIMEOUT) + .>thenCompose(reply -> { + if (!(reply instanceof SudoRetrieveThingResponse response)) { + LOGGER.withCorrelationId(headers) + .warn("Could not resolve Thing <{}> while authorizing a cross-Thing " + + "aggregation; excluding it. Reply was <{}>.", thingId, reply); + return CompletableFuture.completedFuture(Set.of()); + } + final Optional policyIdOpt = response.getThing().getPolicyId(); + if (policyIdOpt.isEmpty()) { + LOGGER.withCorrelationId(headers) + .warn("Thing <{}> has no policyId; excluding it from the aggregation.", + thingId); + return CompletableFuture.completedFuture(Set.of()); + } + final PolicyId policyId = policyIdOpt.get(); + return policyEnforcerProvider.getPolicyEnforcer(policyId) + .thenApply(enforcerOpt -> { + if (enforcerOpt.isEmpty()) { + LOGGER.withCorrelationId(headers) + .warn("Policy <{}> of Thing <{}> could not be loaded; " + + "excluding it from the aggregation.", + policyId, thingId); + return Set.of(); + } + final PolicyEnforcer policyEnforcer = enforcerOpt.get(); + // Fail closed when the Policy itself is absent: forNamespace cannot + // filter what it cannot see, and returns `this` unfiltered in that + // case — which would honour a grant scoped to another namespace. + if (policyEnforcer.getPolicy().isEmpty()) { + LOGGER.withCorrelationId(headers) + .warn("Policy <{}> of Thing <{}> resolved to an enforcer " + + "without its Policy, so per-entry namespace " + + "scoping cannot be applied; excluding it.", + policyId, thingId); + return Set.of(); + } + final Set permitted = permittedPaths( + policyEnforcer.forNamespace(thingId.getNamespace()) + .getEnforcer(), + headers.getAuthorizationContext(), + command.getQuery().getPaths()); + if (permitted.size() < command.getQuery().getPaths().size()) { + // The namespace root granted, but this Thing's own policy does not + final List denied = + new ArrayList<>(command.getQuery().getPaths()); + denied.removeAll(permitted); + LOGGER.withCorrelationId(headers) + .debug("Thing <{}> (policy <{}>): '{}' granted on {}, " + + "denied on {}.", + thingId, policyId, requiredPermission(), + permitted, denied); + } + return permitted; + }); + }) + .exceptionally(throwable -> { + LOGGER.withCorrelationId(headers) + .warn("Authorization check for Thing <{}> failed ({}); excluding it.", + thingId, unwrap(throwable).getMessage()); + return Set.of(); + }); + } + + /** + * Outcome of the per-Thing, per-path narrowing. + * + * @param allowedPerPath the Things the caller may read, per requested path. + * @param contributingThings distinct Things contributing to at least one path. + * @param fullyExcludedThings Things withheld from every requested path. + * @param withheldByPath per path, how many discovered Things were withheld from it. + */ + private record AccessDecision(Map> allowedPerPath, + int contributingThings, + int fullyExcludedThings, + Map withheldByPath) { + + static AccessDecision of(final Map> allowedPerPath, + final int contributingThings, final int fullyExcludedThings, + final Map withheldByPath) { + return new AccessDecision(allowedPerPath, contributingThings, fullyExcludedThings, + withheldByPath); + } + + /** + * Nothing matched the query, so there is nothing to authorize and nothing to return. + *

+ * An empty allow-list, never an "unrestricted" marker: the adapter short-circuits on it, so + * no second query runs at all. + */ + static AccessDecision nothingMatched() { + return of(Map.of(), 0, 0, Map.of()); + } + } + + /** The gate's all-or-nothing test: every requested path must be granted namespace-wide. */ + private boolean grantsAllPaths(final Enforcer enforcer, + final AuthorizationContext authorizationContext, + final List paths) { + + return permittedPaths(enforcer, authorizationContext, paths).size() == paths.size(); + } + + /** + * Returns the subset of {@code paths} the subject may read. + *

+ * {@code hasUnrestrictedPermissions} per path, not {@code hasPartialPermissions}: a timeseries path + * resolves to a scalar leaf, so there is no sub-structure left to filter — either the whole value + * is readable or none of it is, and a revoke deeper in the tree must still count. This computes + * the same grant/revoke resolution that thing-search pushes into its per-field index filter. + */ + private Set permittedPaths(final Enforcer enforcer, + final AuthorizationContext authorizationContext, + final List paths) { + + final Permissions required = Permissions.newInstance(requiredPermission()); + final Set permitted = new LinkedHashSet<>(); + for (final JsonPointer path : paths) { + final ResourceKey resourceKey = PoliciesResourceType.thingResource(path); + if (enforcer.hasUnrestrictedPermissions(resourceKey, authorizationContext, required)) { + permitted.add(path); + } + } + return permitted; + } + + private String requiredPermission() { + return simplifiedReadPermission ? Permission.READ : Permission.READ_TS; + } + + /** + * @param allowedPerPath the Things permitted per path. Always present: the adapter takes no + * "unrestricted" sentinel, so reading a whole namespace means enumerating it. + */ + private CompletionStage runQuery(final RetrieveAggregatedTimeseries command, + final Map> allowedPerPath, + final int contributingThings, + final int fullyExcludedThings, + final Map withheldByPath) { + + final CrossThingTimeseriesQuery query = command.getQuery(); + LOGGER.withCorrelationId(command.getDittoHeaders()) + .debug("Running cross-Thing aggregation on <{}>: paths={}, step={}, agg={}, " + + "groupBy={}, filter={}, allowList={}, maxGroups={}.", + query.getNamespace(), query.getPaths(), query.getStep(), + query.getAggregation().getName(), query.getGroupBy(), + query.getFilter().orElse("-"), + allowedPerPath, + query.getMaxGroups().map(String::valueOf).orElse("")); + final long aggregateStart = System.nanoTime(); + return adapter.queryCrossThing(command.getQuery(), allowedPerPath) + .thenApply(results -> { + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Cross-Thing aggregation on <{}> returned {} series from {} " + + "Thing(s) in {}ms; {} fully excluded, withheldByPath={} " + + "(partial={}).", + command.getQuery().getNamespace(), results.size(), + contributingThings, elapsedMs(aggregateStart), + fullyExcludedThings, withheldByPath, !withheldByPath.isEmpty()); + final Map withheld = new LinkedHashMap<>(); + withheldByPath.forEach((path, count) -> withheld.put(path.toString(), count)); + return RetrieveAggregatedTimeseriesResponse.of( + command.getQuery().getNamespace(), results, contributingThings, + fullyExcludedThings, withheld, command.getDittoHeaders()); + }); + } + + private Object toFailure(final RetrieveAggregatedTimeseries command, final Throwable throwable) { + final Throwable cause = unwrap(throwable); + LOGGER.withCorrelationId(command.getDittoHeaders()) + .warn("RetrieveAggregatedTimeseries on namespace <{}> failed: {}", + command.getQuery().getNamespace(), cause.getMessage()); + if (cause instanceof DittoRuntimeException dre) { + return dre.setDittoHeaders(command.getDittoHeaders()); + } + return new Status.Failure(cause); + } + + private static Throwable unwrap(@Nullable final Throwable throwable) { + if (throwable instanceof CompletionException && throwable.getCause() != null) { + return throwable.getCause(); + } + return throwable == null ? new IllegalStateException("Unknown failure") : throwable; + } +} diff --git a/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActor.java b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActor.java new file mode 100644 index 00000000000..b16456a8935 --- /dev/null +++ b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActor.java @@ -0,0 +1,575 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.handlers; + +import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; + +import org.apache.pekko.actor.AbstractActorWithTimers; +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.PoisonPill; +import org.apache.pekko.actor.Props; +import org.apache.pekko.actor.Status; +import org.apache.pekko.cluster.sharding.ShardRegion; +import org.apache.pekko.pattern.Patterns; +import org.eclipse.ditto.base.model.auth.AuthorizationContext; +import org.eclipse.ditto.base.model.exceptions.DittoRuntimeException; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.internal.utils.metrics.DittoMetrics; +import org.eclipse.ditto.internal.utils.metrics.instruments.counter.Counter; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoDiagnosticLoggingAdapter; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoLoggerFactory; +import org.eclipse.ditto.internal.utils.pekko.logging.ThreadSafeDittoLogger; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; +import org.eclipse.ditto.policies.model.PoliciesResourceType; +import org.eclipse.ditto.policies.model.Permissions; +import org.eclipse.ditto.policies.model.PolicyId; +import org.eclipse.ditto.policies.model.ResourceKey; +import org.eclipse.ditto.policies.model.enforcers.Enforcer; +import org.eclipse.ditto.things.api.Permission; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThing; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThingResponse; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesQueryPlanner; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; + +/** + * Per-Thing sharded entity that forwards each {@link IngestDataPoints} batch to the + * configured {@link TimeseriesAdapter} and serves {@link RetrieveTimeseries} reads for + * the same Thing. + * + *

No Pekko Persistence

+ * Unlike {@code ThingPersistenceActor}, this actor has no entity state that evolves over + * events: the durable truth is the MongoDB Time Series collection itself. The publisher + * ({@code TimeseriesIngestPublisher}) already retries failed batches with bounded + * {@code MAX_ATTEMPTS}, which covers the same crash window an event-sourced journal + * would have protected. Pekko Persistence was therefore intentionally dropped to avoid + * paying an extra MongoDB write per batch plus snapshot churn for redundant protection. + * + *

Write path

+ *
    + *
  1. Receive {@link IngestDataPoints}. If empty, ack immediately without calling the + * adapter.
  2. + *
  3. If the {@code correlation-id} is in the {@link #recentlyApplied} ring, the batch + * was already written; ack idempotently (covers the race where the publisher's + * ask times out at 5s but our adapter write completed in the same tick).
  4. + *
  5. If the {@code correlation-id} is already in {@link #liveSenders}, a write is + * in flight for that batch; replace the sender reference so the eventual reply + * reaches the most recent retry, but do not start a second write.
  6. + *
  7. Otherwise register the sender and call {@code adapter.writeBatch}.
  8. + *
  9. On {@code writeBatch} success: ack the sender, record the correlation-id in + * {@link #recentlyApplied}, and clear the live-sender entry.
  10. + *
  11. On {@code writeBatch} failure: clear the live-sender entry and reply with + * {@link Status.Failure}; the publisher's retry timer fires and re-asks with the + * same correlation-id.
  12. + *
+ * + *

Idempotency window — and its limit

+ * The bounded {@link #recentlyApplied} LRU is the only dedup in the write path, and it + * covers the duplicate-on-success window only within the current actor lifetime. After passivation + * or actor restart the ring is empty, so a duplicate retry that lands across a restart can produce + * one duplicate row in the time-series collection. + *

+ * There is deliberately no adapter-side fallback. A MongoDB Time Series collection does not accept + * a unique index on measurement fields, so a {@code (thingId, path, timestamp)} uniqueness + * constraint is not expressible there, and a read-before-write on the ingest hot path would cost + * more than the duplicates it prevents. Delivery is therefore at-least-once, and a duplicate + * point skews {@code avg}, {@code sum} and {@code count} for the bucket it lands in. + *

+ * Sizing is the mitigation: {@link #APPLIED_RING_CAPACITY} entries per Thing must exceed the number + * of distinct batches one Thing can produce inside the publisher's retry window + * ({@code MAX_ATTEMPTS} × ask-timeout). At 1024 entries that is roughly 68 writes/second for a + * single Thing — well above any expected device rate. Raise it before onboarding a device class + * that writes faster than that. + * + *

Read path

+ * Co-located on the same per-Thing entity so the edge forwarder can route + * {@link RetrieveTimeseries} via the timeseries shard region with + * {@code AskWithRetryCommandForwarder} (same shape as {@code forwardToThings}). The + * read path resolves the Thing's policy id via {@link SudoRetrieveThing}, loads the + * cached enforcer, verifies the configured permission on every requested path, then + * asks the adapter for results. Authorization failures surface as + * {@link ThingNotAccessibleException} (404-not-403). + * + *

Passivation

+ * The actor passivates after {@value #PASSIVATION_TIMEOUT_SECONDS}s of idle (no + * in-flight writes). Cluster sharding re-creates it on the next message for this + * Thing. + */ +public final class TimeseriesIngestActor extends AbstractActorWithTimers { + + /** + * Thread-safe logger for the read path: the enforcement chain + * ({@code Patterns.ask(thingsShardRegion, ...)}, {@code policyEnforcerProvider.getPolicyEnforcer(...)}, + * {@code adapter.query(...)}) executes on stages off the actor's mailbox thread, so any log + * statement reached from one of those callbacks must use a thread-safe logger rather than + * the inherited single-threaded {@code log} ({@code DittoDiagnosticLoggingAdapter}). The + * inherited {@code log} is still used for on-actor-thread call sites (receive handlers) + * where its MDC integration is helpful. + */ + private static final ThreadSafeDittoLogger LOGGER = + DittoLoggerFactory.getThreadSafeLogger(TimeseriesIngestActor.class); + + /** + * Idle period before the entity passivates. Cluster sharding re-creates it on the + * next inbound message, so passivation is a memory hint, not a correctness + * concern. + */ + static final long PASSIVATION_TIMEOUT_SECONDS = 300; + + private static final Object PASSIVATE_TICK = new Object(); + + /** + * Counter incremented every time {@code adapter.writeBatch} fails for a batch. Visible + * in the Kamon dashboard as {@code timeseries_ingest_write_failed} so a struggling + * MongoDB backend (write concern timeouts, replica unavailability) produces a + * monitorable signal rather than just a WARN log per failure. + */ + private static final Counter WRITE_FAILURES = + DittoMetrics.counter("timeseries_ingest_write_failed"); + + /** + * Bound on the in-memory ring of recently-applied correlation-ids. Sized for typical + * publisher retry windows: with MAX_ATTEMPTS=3 retries × 5 s ask-timeout the publisher + * can re-ask up to ~15 s after the original send. At 1024 entries the ring covers ~68 + * sustained events/sec for that whole window — enough headroom for a single Thing + * pushing ~50 properties/sec each at 1 Hz, which exceeds realistic IoT workloads. The + * memory cost is bounded: ~1024 × (UUID-string + Boolean) ≈ ~100 KB per entity. + *

+ * If an entity sustains a higher rate than that, retries arriving after the entry has + * been evicted fall through to a fresh {@code triggerWrite}, producing one duplicate + * row in MongoDB for the affected batch. Cross-passivation duplicates have the same + * effect. Both are accepted: see the class javadoc for why no adapter-side dedup backs + * this up, and raise this bound before onboarding a faster-writing device class. + */ + private static final int APPLIED_RING_CAPACITY = 1024; + + /** + * Timeout for the {@link SudoRetrieveThing} ask. Kept conservative: this is an in-cluster + * request to a sharded actor that should respond promptly; longer timeouts only mask issues. + */ + private static final Duration THING_LOOKUP_TIMEOUT = Duration.ofSeconds(5); + + private final DittoDiagnosticLoggingAdapter log = + DittoLoggerFactory.getDiagnosticLoggingAdapter(this); + + private final TimeseriesAdapter adapter; + private final TimeseriesQueryPlanner queryPlanner; + private final ThingId thingId; + + /** Things-shard proxy used by the read path to resolve the thing's policyId via SudoRetrieveThing. */ + @Nullable private final ActorRef thingsShardRegion; + /** Provider of the (cached) policy enforcer used to check the configured read-permission. */ + @Nullable private final PolicyEnforcerProvider policyEnforcerProvider; + /** + * When {@code false} (default, strict): the per-path enforcement check requires + * {@link Permission#READ_TS}. When {@code true} (simplified mode): the check requires + * {@link Permission#READ} on the resource instead. Two-mode contract — see + * {@code TimeseriesRootActor.SIMPLIFIED_READ_PERMISSION_CONFIG_PATH}. + */ + private final boolean simplifiedReadPermission; + + /** + * Senders awaiting acks for in-flight adapter writes, keyed by correlation-id. A + * publisher retry that arrives while a write is still in flight replaces the entry + * (most recent sender wins) rather than triggering a second write. + */ + private final Map liveSenders = new HashMap<>(); + + /** + * Bounded LRU of correlation-ids whose batches have been successfully written during + * the current actor lifetime. Covers the duplicate-on-success race where the + * publisher's {@code Patterns.ask} times out before our reply arrives, the publisher + * retries with the same id, and we'd otherwise issue a second {@code writeBatch}. The + * ring is in-memory only — across passivation / restart this protection is lost, and + * nothing downstream closes that window (see the class javadoc). + */ + private final LinkedHashMap recentlyApplied = + new LinkedHashMap<>(APPLIED_RING_CAPACITY + 1, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry eldest) { + return size() > APPLIED_RING_CAPACITY; + } + }; + + @SuppressWarnings("unused") + private TimeseriesIngestActor(final TimeseriesAdapter adapter, + @Nullable final ActorRef thingsShardRegion, + @Nullable final PolicyEnforcerProvider policyEnforcerProvider, + final boolean simplifiedReadPermission) { + this.adapter = checkNotNull(adapter, "adapter"); + this.queryPlanner = new TimeseriesQueryPlanner(adapter); + this.thingsShardRegion = thingsShardRegion; + this.policyEnforcerProvider = policyEnforcerProvider; + this.simplifiedReadPermission = simplifiedReadPermission; + // The shard-region extractor names entity actors after their entityId (the + // ThingId). URL-decode because cluster sharding URL-encodes entity names that + // contain reserved characters. + this.thingId = ThingId.of(URLDecoder.decode(getSelf().path().name(), StandardCharsets.UTF_8)); + } + + /** + * Test-only {@code Props} variant that skips authorization (read path returns adapter results + * directly). Package-private so production wiring cannot reach it; production must use + * {@link #props(TimeseriesAdapter, ActorRef, PolicyEnforcerProvider, boolean)}, which requires + * a shard region + enforcer provider and enforces the configured read permission per resource + * path. + * + * @param adapter the timeseries adapter shared by all entities on this node. + * @return the props. + */ + static Props propsForTest(final TimeseriesAdapter adapter) { + return Props.create(TimeseriesIngestActor.class, adapter, null, null, false); + } + + /** + * Returns Pekko {@code Props} for the full wiring used in production. The same + * {@link TimeseriesAdapter} instance is shared across all entities on a node — the SPI + * contract requires thread-safety, so sharing avoids paying for one MongoDB connection + * pool per entity. + * + * @param adapter the timeseries adapter shared by all entities on this node. + * @param thingsShardRegion proxy actor for the {@code thing} shard region — used to + * resolve the requested Thing's policy id via {@link SudoRetrieveThing} during read-path + * authorization. + * @param policyEnforcerProvider provider that loads the (cached) + * {@link PolicyEnforcer} for the Thing's policy id. + * @param simplifiedReadPermission when {@code false} the per-path enforcement check requires + * {@link Permission#READ_TS}; when {@code true} the check requires {@link Permission#READ}. + * @return the props. + */ + public static Props props(final TimeseriesAdapter adapter, + final ActorRef thingsShardRegion, + final PolicyEnforcerProvider policyEnforcerProvider, + final boolean simplifiedReadPermission) { + return Props.create(TimeseriesIngestActor.class, + adapter, + checkNotNull(thingsShardRegion, "thingsShardRegion"), + checkNotNull(policyEnforcerProvider, "policyEnforcerProvider"), + simplifiedReadPermission); + } + + @Override + public void preStart() throws Exception { + super.preStart(); + getTimers().startTimerWithFixedDelay(PASSIVATE_TICK, PASSIVATE_TICK, + Duration.ofSeconds(PASSIVATION_TIMEOUT_SECONDS)); + } + + @Override + public Receive createReceive() { + return receiveBuilder() + .match(IngestDataPoints.class, this::handleIngest) + .match(RetrieveTimeseries.class, this::handleRetrieveTimeseries) + .match(WriteCompleted.class, this::handleWriteCompleted) + .matchEquals(PASSIVATE_TICK, t -> maybePassivate()) + .build(); + } + + private void handleIngest(final IngestDataPoints command) { + if (command.getDataPoints().isEmpty()) { + // Empty batch is a no-op — ack without touching the adapter (can happen if + // the publisher's WoT logic produces an empty result list). + getSender().tell(IngestDataPointsResponse.of(thingId, command.getDittoHeaders()), getSelf()); + return; + } + + final String corrId = correlationIdOf(command); + if (recentlyApplied.containsKey(corrId)) { + // Duplicate retry landing after we already wrote + acked this batch — the + // publisher's ask must have raced with the response. Ack idempotently + // without re-writing. + getSender().tell(IngestDataPointsResponse.of(thingId, command.getDittoHeaders()), getSelf()); + return; + } + + if (liveSenders.containsKey(corrId)) { + // Retry while the original write is still in flight — keep one writeBatch + // call open and ack the most recent retry when it completes. + liveSenders.put(corrId, getSender()); + return; + } + + liveSenders.put(corrId, getSender()); + triggerWrite(corrId, command); + } + + private void triggerWrite(final String correlationId, final IngestDataPoints command) { + // Capture the correlation-id locally so the CompletionStage callback doesn't + // close over actor state (which is forbidden by Ditto's actor-concurrency rules). + adapter.writeBatch(command.getDataPoints()).whenComplete((ignored, throwable) -> + getSelf().tell(new WriteCompleted(correlationId, command.getDittoHeaders(), throwable), + getSelf())); + } + + private void handleWriteCompleted(final WriteCompleted msg) { + final ActorRef sender = liveSenders.remove(msg.correlationId()); + if (msg.failure() != null) { + final Throwable cause = unwrap(msg.failure()); + WRITE_FAILURES.increment(); + log.warning("Failed to persist data points for thing <{}>: {}", + thingId, cause.getMessage()); + if (sender != null) { + sender.tell(new Status.Failure(cause), getSelf()); + } + return; + } + recentlyApplied.put(msg.correlationId(), Boolean.TRUE); + if (sender != null) { + sender.tell(IngestDataPointsResponse.of(thingId, msg.headers()), getSelf()); + } + } + + private void maybePassivate() { + // Don't passivate while writes are in flight — the WriteCompleted callback + // would arrive at a stopped actor and end up in dead letters. + if (liveSenders.isEmpty()) { + getContext().getParent().tell(new ShardRegion.Passivate(PoisonPill.getInstance()), getSelf()); + } + } + + private static String correlationIdOf(final IngestDataPoints command) { + // Synthetic correlation-id when the publisher (or a test) didn't set one. + // Unique-per-message so two batches without an id don't collide in the + // in-flight map. + return command.getDittoHeaders().getCorrelationId().orElseGet(() -> UUID.randomUUID().toString()); + } + + @Nullable + private static Throwable unwrap(@Nullable final Throwable throwable) { + if (throwable instanceof CompletionException ce && ce.getCause() != null) { + return ce.getCause(); + } + return throwable; + } + + // --------------------------------------------------------------------------------------------- + // Read path: RetrieveTimeseries — resolve the policyId, verify the configured permission on each + // requested path, then ask the adapter. + // --------------------------------------------------------------------------------------------- + + private void handleRetrieveTimeseries(final RetrieveTimeseries command) { + // Capture sender BEFORE any asynchronous call — getSender() inside an async callback + // returns the wrong reference (or none) once the call completes. + final ActorRef sender = getSender(); + final CompletionStage future = authorizeAndQuery(command); + Patterns.pipe(future, getContext().getDispatcher()).to(sender); + } + + private CompletionStage authorizeAndQuery(final RetrieveTimeseries command) { + // Single error-handling boundary: enforcement or adapter errors both surface through + // toFailure() — Ditto runtime exceptions go back as themselves so the gateway maps them + // to the right HTTP envelope, anything else becomes Status.Failure for the publisher. + final CompletionStage stage = + (thingsShardRegion == null || policyEnforcerProvider == null) + // Test-only path: no enforcement wired, run the adapter directly. + ? runAdapterQuery(command, command.getQuery().getPaths()) + : enforce(command).thenCompose(allowed -> runAdapterQuery(command, allowed)); + return stage.exceptionally(throwable -> toFailure(command, throwable)); + } + + private CompletionStage runAdapterQuery(final RetrieveTimeseries command, + final List permittedPaths) { + // The planner delegates to the adapter or drives scan + kernel, identically either way. + // Narrowed to the readable subset so no unreadable path is ever scanned. + final TimeseriesQuery query = permittedPaths.size() == command.getQuery().getPaths().size() + ? command.getQuery() + : command.getQuery().withPaths(permittedPaths); + return queryPlanner.execute(query) + .thenApply(results -> RetrieveTimeseriesResponse.of( + command.getEntityId(), results, command.getDittoHeaders())); + } + + /** + * Resolves the Thing's policy id, loads the enforcer, and returns the subset of requested paths the + * caller may read. + *

+ * Partial by design: {@code READ_TS} is grantable per property, so a request for two properties + * where only one is granted returns that one rather than failing the whole read. Dropping a denied + * path is safe to do silently here — unlike an aggregate, a missing series is visibly missing, which + * is the same reasoning behind Ditto's {@code buildJsonView} for Thing queries. + *

+ * {@link ThingNotAccessibleException} (404-not-403) is raised only when no requested path is + * readable, so nothing is disclosed about a Thing the caller cannot read at all. + */ + private CompletionStage> enforce(final RetrieveTimeseries command) { + final ThingId queryThingId = command.getEntityId(); + final DittoHeaders headers = command.getDittoHeaders(); + final SudoRetrieveThing sudo = SudoRetrieveThing.of(queryThingId, headers); + return Patterns.ask(thingsShardRegion, sudo, THING_LOOKUP_TIMEOUT) + .thenCompose(reply -> resolveSudoReply(reply, queryThingId, headers)) + .thenCompose(thing -> loadEnforcer(thing, queryThingId, headers)) + .thenApply(enforcerWithContext -> permittedPaths(enforcerWithContext, command)); + } + + private CompletionStage resolveSudoReply(final Object reply, final ThingId queryThingId, + final DittoHeaders headers) { + if (reply instanceof SudoRetrieveThingResponse response) { + return CompletableFuture.completedFuture(response.getThing()); + } + if (reply instanceof DittoRuntimeException dre) { + return CompletableFuture.failedFuture(dre); + } + if (reply instanceof Throwable t) { + return CompletableFuture.failedFuture(t); + } + LOGGER.withCorrelationId(headers) + .warn("Unexpected reply <{}> for SudoRetrieveThing of <{}>; treating as not-found.", + reply, queryThingId); + return CompletableFuture.failedFuture( + ThingNotAccessibleException.newBuilder(queryThingId).dittoHeaders(headers).build()); + } + + private CompletionStage loadEnforcer(final Thing thing, + final ThingId queryThingId, + final DittoHeaders headers) { + final Optional policyIdOpt = thing.getPolicyId(); + if (policyIdOpt.isEmpty()) { + // A Thing without a policy is a configuration anomaly; deny access in line with the + // 404-not-403 discipline rather than leaking an internal error. + LOGGER.withCorrelationId(headers) + .warn("Thing <{}> has no policyId; denying timeseries access.", queryThingId); + return CompletableFuture.failedFuture( + ThingNotAccessibleException.newBuilder(queryThingId).dittoHeaders(headers).build()); + } + final PolicyId policyId = policyIdOpt.get(); + return policyEnforcerProvider.getPolicyEnforcer(policyId) + .thenCompose(opt -> opt + .>map(pe -> { + // Narrow to the Thing's namespace first, as ThingEnforcerActor does: + // entries can be namespace-scoped, so an unfiltered enforcer would honour + // a grant meant for elsewhere. Fail closed when the Policy is absent. + if (pe.getPolicy().isEmpty()) { + LOGGER.withCorrelationId(headers) + .warn("PolicyEnforcer for policy <{}> on thing <{}> carries no " + + "Policy, so per-entry namespace scoping cannot be " + + "applied; denying timeseries access.", + policyId, queryThingId); + return CompletableFuture.failedFuture( + ThingNotAccessibleException.newBuilder(queryThingId) + .dittoHeaders(headers).build()); + } + return CompletableFuture.completedFuture(new EnforcerWithContext( + pe.forNamespace(queryThingId.getNamespace()).getEnforcer(), + headers.getAuthorizationContext())); + }) + .orElseGet(() -> { + LOGGER.withCorrelationId(headers) + .warn("PolicyEnforcer for policy <{}> on thing <{}> could not " + + "be loaded; denying timeseries access.", policyId, queryThingId); + return CompletableFuture.failedFuture( + ThingNotAccessibleException.newBuilder(queryThingId) + .dittoHeaders(headers).build()); + })); + } + + private List permittedPaths(final EnforcerWithContext enforcerWithContext, + final RetrieveTimeseries command) { + // Two-mode contract per `simplifiedReadPermission`: strict (default) checks READ_TS; + // simplified checks READ. The selection is fixed for the lifetime of this entity (it's + // a constructor-injected boolean) so there's no per-request branching surprise. + final String requiredPermission = + simplifiedReadPermission ? Permission.READ : Permission.READ_TS; + final Permissions required = Permissions.newInstance(requiredPermission); + final List requested = command.getQuery().getPaths(); + final List permitted = new ArrayList<>(requested.size()); + final List denied = new ArrayList<>(); + for (final JsonPointer path : requested) { + final ResourceKey resourceKey = PoliciesResourceType.thingResource(path); + if (enforcerWithContext.enforcer.hasUnrestrictedPermissions( + resourceKey, enforcerWithContext.authorizationContext, required)) { + permitted.add(path); + } else { + denied.add(path); + } + } + if (permitted.isEmpty()) { + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Subject <{}> denied <{}> on every requested path {} for thing <{}>.", + enforcerWithContext.authorizationContext.getAuthorizationSubjectIds(), + requiredPermission, requested, command.getEntityId()); + throw ThingNotAccessibleException.newBuilder(command.getEntityId()) + .dittoHeaders(command.getDittoHeaders()) + .build(); + } + if (!denied.isEmpty()) { + LOGGER.withCorrelationId(command.getDittoHeaders()) + .info("Subject <{}> denied <{}> on {} of {} requested path(s) for thing <{}>; " + + "returning the permitted ones {}. Denied: {}.", + enforcerWithContext.authorizationContext.getAuthorizationSubjectIds(), + requiredPermission, denied.size(), requested.size(), + command.getEntityId(), permitted, denied); + } + return permitted; + } + + private Object toFailure(final RetrieveTimeseries command, final Throwable throwable) { + final Throwable cause = unwrap(throwable); + LOGGER.withCorrelationId(command.getDittoHeaders()) + .warn("RetrieveTimeseries for thing <{}> failed: {}", + command.getEntityId(), cause.getMessage()); + // Ditto convention: surface DittoRuntimeException directly so gateway maps it to an HTTP + // error envelope; non-Ditto throwables fall back to Status.Failure for visibility. + if (cause instanceof DittoRuntimeException dre) { + return dre.setDittoHeaders(command.getDittoHeaders()); + } + return new Status.Failure(cause); + } + + /** + * Pair of loaded {@link Enforcer} with the originating {@link AuthorizationContext}, carried + * through the enforcement pipeline so the per-path permission check has both pieces in scope + * without re-fetching headers. + */ + private static final class EnforcerWithContext { + + private final Enforcer enforcer; + private final AuthorizationContext authorizationContext; + + EnforcerWithContext(final Enforcer enforcer, final AuthorizationContext authorizationContext) { + this.enforcer = enforcer; + this.authorizationContext = authorizationContext; + } + } + + /** + * Internal callback message piped from the {@link TimeseriesAdapter}'s + * {@link java.util.concurrent.CompletionStage} back to the actor thread. + */ + private record WriteCompleted(String correlationId, DittoHeaders headers, + @Nullable Throwable failure) {} +} diff --git a/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesRootActor.java b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesRootActor.java new file mode 100644 index 00000000000..e69df815410 --- /dev/null +++ b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesRootActor.java @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.starter; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; + +import org.apache.pekko.Done; +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.CoordinatedShutdown; +import org.apache.pekko.actor.Props; +import org.apache.pekko.pattern.Patterns; +import org.eclipse.ditto.base.service.actors.DittoRootActor; +import org.eclipse.ditto.base.service.config.DittoServiceConfig; +import org.eclipse.ditto.internal.utils.cluster.DistPubSubAccess; +import org.eclipse.ditto.internal.utils.cluster.ShardRegionCreator; +import org.eclipse.ditto.internal.utils.cluster.ShardRegionProxyActorFactory; +import org.eclipse.ditto.internal.utils.config.DefaultScopedConfig; +import org.eclipse.ditto.internal.utils.config.DittoConfigError; +import org.eclipse.ditto.internal.utils.health.DefaultHealthCheckingActorFactory; +import org.eclipse.ditto.internal.utils.health.HealthCheckingActorOptions; +import org.eclipse.ditto.internal.utils.health.config.DefaultHealthCheckConfig; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoDiagnosticLoggingAdapter; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.DefaultMongoDbConfig; +import org.eclipse.ditto.internal.utils.persistence.mongo.config.MongoDbConfig; +import org.eclipse.ditto.internal.utils.pekko.logging.DittoLoggerFactory; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; +import org.eclipse.ditto.policies.enforcement.config.DefaultNamespacePoliciesConfig; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProviderExtension; +import org.eclipse.ditto.things.api.Permission; +import org.eclipse.ditto.things.api.ThingsMessagingConstants; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.eclipse.ditto.timeseries.mongodb.DefaultMongoDbTimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.mongodb.MongoDbTimeseriesAdapter; +import org.eclipse.ditto.timeseries.mongodb.MongoDbTimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.service.handlers.TimeseriesAggregateActor; +import org.eclipse.ditto.timeseries.service.handlers.TimeseriesIngestActor; + +import com.typesafe.config.Config; + +/** + * Root actor of the Timeseries service. Constructs the configured timeseries adapter, kicks off + * its asynchronous initialization on startup, and starts the cluster-sharded + * {@link TimeseriesIngestActor} region that handles both the ingest write path and the + * {@code RetrieveTimeseries} read path for each Thing. + */ +public final class TimeseriesRootActor extends DittoRootActor { + + /** + * The name of this actor in the actor system. + */ + public static final String ACTOR_NAME = TimeseriesMessagingConstants.ROOT_ACTOR_NAME; + + /** + * Config path used by the default MongoDB adapter to load its connection settings. + */ + static final String MONGODB_CONFIG_PATH = "ditto.timeseries.adapter.mongodb"; + + /** + * Config path used to look up whether timeseries reads accept the standard {@code READ} + * permission as a stand-in for {@code READ_TS}. Two-mode contract: + *

    + *
  • {@code false} (default): the read-path enforcer requires an explicit + * {@link Permission#READ_TS READ_TS} grant on every requested path. This is the + * fine-grained model — historical data access can be granted separately from live + * access.
  • + *
  • {@code true}: the enforcer instead checks {@link Permission#READ}. Useful for + * deployments preferring "if you can read the current value, you can read its + * history" semantics, without rewriting existing policies to add READ_TS grants.
  • + *
+ * A boolean rather than a free-form permission-name string deliberately constrains the + * config surface to the two modes the design contract permits — any other permission value + * would have been a deployment footgun (typo'd or unrelated permissions would be silently + * treated as "no permission" by the enforcer, locking every caller out — see auth-authz.md + * "Mistyped names are silently ignored — the #1 footgun"). + */ + static final String SIMPLIFIED_READ_PERMISSION_CONFIG_PATH = + "ditto.timeseries.simplified-read-permission"; + + /** + * Config path capping how many Things a single cross-Thing aggregation will authorize. Per-Thing + * verification is what makes the aggregation correct in the presence of Thing-level revokes, and + * its cost scales with the number of Things contributing data to the query. Exceeding the cap + * fails the request rather than authorizing a truncated set. + */ + static final String MAX_VERIFIED_THINGS_CONFIG_PATH = "ditto.timeseries.max-verified-things"; + + private final DittoDiagnosticLoggingAdapter log = DittoLoggerFactory.getDiagnosticLoggingAdapter(this); + + private final TimeseriesAdapter adapter; + + @SuppressWarnings("unused") + private TimeseriesRootActor(final DittoServiceConfig timeseriesConfig, + final ActorRef pubSubMediator) { + + final Config rootConfig = getContext().system().settings().config(); + // Before any side effect: a config error raised later would tear down the root actor + // while leaving the node in the cluster. + final int maxVerifiedThings = resolveMaxVerifiedThings(rootConfig); + adapter = createAdapter(rootConfig); + + // Closes the adapter's MongoDB pool at SIGTERM; without it the driver's threads are + // interrupted mid-flight on every rolling restart. + final TimeseriesAdapter adapterForShutdown = adapter; + CoordinatedShutdown.get(getContext().getSystem()) + .addTask(CoordinatedShutdown.PhaseBeforeActorSystemTerminate(), + "close_timeseries_adapter", + () -> adapterForShutdown.shutdown() + .thenApply(ignored -> Done.done()) + .exceptionally(throwable -> { + log.error(throwable, + "Error closing timeseries adapter; proceeding " + + "with shutdown regardless."); + return Done.done(); + })); + + // Used by the read path to resolve a Thing's policy id. Built via the lower-level + // factory to avoid a module dependency on edge/service. + final ActorRef thingsShardRegion = ShardRegionProxyActorFactory + .newInstance(getContext().system(), timeseriesConfig.getClusterConfig()) + .getShardRegionProxyActor(ThingsMessagingConstants.CLUSTER_ROLE, + ThingsMessagingConstants.SHARD_REGION); + + // Resolve the (cached) policy enforcer provider once at root-actor construction; it pulls + // policies from the policies shard region under the hood. + final PolicyEnforcerProvider policyEnforcerProvider = + PolicyEnforcerProviderExtension.get(getContext().system()).getPolicyEnforcerProvider(); + + // Resolve the read-permission mode (default: false → require READ_TS). + final boolean simplifiedReadPermission = resolveSimplifiedReadPermission(rootConfig); + + // Per-Thing entities serving both the write and the read path, so the edge forwarder can + // route reads through the same shard region. + final int numberOfShards = timeseriesConfig.getClusterConfig().getNumberOfShards(); + ShardRegionCreator.start(getContext().getSystem(), + TimeseriesMessagingConstants.SHARD_REGION, + TimeseriesIngestActor.props(adapter, thingsShardRegion, policyEnforcerProvider, + simplifiedReadPermission), + numberOfShards, + TimeseriesMessagingConstants.CLUSTER_ROLE); + + // Cross-Thing aggregations have no thingId to shard on: a per-node handler addressed by + // path, as SearchRootActor does for search. + final ActorRef aggregateActor = startChildActor( + TimeseriesMessagingConstants.AGGREGATE_ACTOR_NAME, + TimeseriesAggregateActor.props(adapter, thingsShardRegion, policyEnforcerProvider, + DefaultNamespacePoliciesConfig.of(rootConfig), simplifiedReadPermission, + maxVerifiedThings)); + pubSubMediator.tell(DistPubSubAccess.put(aggregateActor), getSelf()); + + // Null persistence checker: this service has no shared MongoDB persistence, the adapter + // owns its own connection. + final var healthCheckConfig = DefaultHealthCheckConfig.of(timeseriesConfig); + final var healthCheckingActorOptions = HealthCheckingActorOptions + .getBuilder(healthCheckConfig.isEnabled(), healthCheckConfig.getInterval()) + .build(); + final ActorRef healthCheckingActor = startChildActor(DefaultHealthCheckingActorFactory.ACTOR_NAME, + DefaultHealthCheckingActorFactory.props(healthCheckingActorOptions, null)); + bindHttpStatusRoute(timeseriesConfig.getHttpConfig(), healthCheckingActor); + + // Piped back as an internal message so init failures surface in the log without blocking + // the actor thread. Connection settings come from `ditto.mongodb.*` as in the sibling + // services; timeseries tuning from `ditto.timeseries.adapter.mongodb`. + final var dittoScopedConfig = DefaultScopedConfig.dittoScoped(rootConfig); + final MongoDbConfig mongoDbConfig = DefaultMongoDbConfig.of(dittoScopedConfig); + final MongoDbTimeseriesAdapterConfig mongoConfig = DefaultMongoDbTimeseriesAdapterConfig.of( + mongoDbConfig, rootConfig.getConfig(MONGODB_CONFIG_PATH)); + final CompletionStage initStage = adapter.initialize(mongoConfig) + .thenApply(ignored -> AdapterInitialised.INSTANCE) + .exceptionally(AdapterInitFailed::new); + Patterns.pipe(initStage, getContext().getDispatcher()).to(getSelf()); + } + + /** + * Creates Pekko configuration for this actor. + * + * @param timeseriesConfig the resolved service configuration. + * @param pubSubMediator the actor reference of the Pekko pub/sub mediator. + * @return the Pekko configuration object. + */ + public static Props props(final DittoServiceConfig timeseriesConfig, final ActorRef pubSubMediator) { + return Props.create(TimeseriesRootActor.class, timeseriesConfig, pubSubMediator); + } + + @Override + public Receive createReceive() { + return receiveBuilder() + .match(AdapterInitialised.class, msg -> + log.info("Timeseries adapter initialised; health: {}", adapter.getHealth())) + .match(AdapterInitFailed.class, msg -> + log.error(msg.getCause(), + "Timeseries adapter initialisation failed; health remains: {}", + adapter.getHealth())) + .build() + .orElse(super.createReceive()); + } + + /** + * Reads {@value #MAX_VERIFIED_THINGS_CONFIG_PATH} from the root config, falling back to + * {@link TimeseriesAggregateActor#DEFAULT_MAX_VERIFIED_THINGS} when the section is absent. + *

+ * A value that is present but not positive is rejected at start-up rather than silently + * corrected: {@code 0} or a negative number reads as "no aggregation may be authorized", but + * silently substituting the default would instead authorize up to a thousand Things — the + * opposite of what the operator wrote. Failing here surfaces the typo while it is still cheap. + */ + private static int resolveMaxVerifiedThings(final Config rootConfig) { + if (!rootConfig.hasPath(MAX_VERIFIED_THINGS_CONFIG_PATH)) { + return TimeseriesAggregateActor.DEFAULT_MAX_VERIFIED_THINGS; + } + final int configured = rootConfig.getInt(MAX_VERIFIED_THINGS_CONFIG_PATH); + if (configured <= 0) { + // DittoConfigError, not IllegalArgumentException: supervision treats the latter as + // recoverable, which would leave the node in the cluster but without a shard region or + // a bound /status route. + throw new DittoConfigError(MAX_VERIFIED_THINGS_CONFIG_PATH + + " must be a positive number of Things, but was <" + configured + ">."); + } + return configured; + } + + private static boolean resolveSimplifiedReadPermission(final Config rootConfig) { + return rootConfig.hasPath(SIMPLIFIED_READ_PERMISSION_CONFIG_PATH) + && rootConfig.getBoolean(SIMPLIFIED_READ_PERMISSION_CONFIG_PATH); + } + + /** + * Constructs the configured adapter. The default Ditto distribution ships only the MongoDB + * Time Series adapter; custom adapters land in Phase 3 alongside the SPI-finalisation work. + */ + private static TimeseriesAdapter createAdapter(final Config rootConfig) { + final String type = rootConfig.hasPath("ditto.timeseries.adapter.type") + ? rootConfig.getString("ditto.timeseries.adapter.type") + : "mongodb"; + if (!"mongodb".equals(type)) { + // Extends Error, so bad start-up config fails fast instead of being absorbed by + // Pekko's restart loop. + throw new DittoConfigError( + "Unsupported timeseries adapter type: <" + type + ">. Only \"mongodb\" is " + + "supported in Phase 1."); + } + return new MongoDbTimeseriesAdapter(); + } + + /** + * Internal signal piped to {@link #getSelf()} when the adapter's initialise stage completes + * successfully. + */ + private static final class AdapterInitialised { + + static final AdapterInitialised INSTANCE = new AdapterInitialised(); + + private AdapterInitialised() { + // singleton + } + } + + /** + * Internal signal piped to {@link #getSelf()} when the adapter's initialise stage completes + * exceptionally. Carries the original cause for logging. + */ + private static final class AdapterInitFailed { + + @Nullable private final Throwable cause; + + AdapterInitFailed(final Throwable cause) { + this.cause = cause; + } + + @Nullable + Throwable getCause() { + return cause; + } + + @Override + public boolean equals(@Nullable final Object o) { + return o instanceof AdapterInitFailed && Objects.equals(cause, ((AdapterInitFailed) o).cause); + } + + @Override + public int hashCode() { + return Objects.hashCode(cause); + } + } + + /** + * Visible for tests — allows asserting the adapter health from outside the actor. + * + * @return the current adapter health. + */ + HealthStatus adapterHealth() { + return adapter.getHealth(); + } +} diff --git a/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesService.java b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesService.java new file mode 100644 index 00000000000..e1d8afad580 --- /dev/null +++ b/timeseries/service/src/main/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesService.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.starter; + +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.Props; +import org.eclipse.ditto.base.service.DittoService; +import org.eclipse.ditto.base.service.config.DittoServiceConfig; +import org.eclipse.ditto.internal.utils.config.ScopedConfig; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Entry point of the Timeseries service. + */ +public final class TimeseriesService extends DittoService { + + /** + * Name of the Timeseries service (used for {@code ditto.service-name} HOCON and logging). + */ + public static final String SERVICE_NAME = TimeseriesMessagingConstants.SERVICE_NAME; + + private static final Logger LOGGER = LoggerFactory.getLogger(TimeseriesService.class); + + private TimeseriesService() { + super(LOGGER, SERVICE_NAME, TimeseriesRootActor.ACTOR_NAME); + } + + /** + * Starts the Timeseries service. + * + * @param args command-line arguments (currently ignored). + */ + public static void main(final String[] args) { + final TimeseriesService timeseriesService = new TimeseriesService(); + timeseriesService.start(); + } + + @Override + protected DittoServiceConfig getServiceSpecificConfig(final ScopedConfig dittoConfig) { + return DittoServiceConfig.of(dittoConfig, SERVICE_NAME); + } + + @Override + protected Props getMainRootActorProps(final DittoServiceConfig timeseriesConfig, + final ActorRef pubSubMediator) { + + return TimeseriesRootActor.props(timeseriesConfig, pubSubMediator); + } +} diff --git a/timeseries/service/src/main/resources/kamon.conf b/timeseries/service/src/main/resources/kamon.conf new file mode 100644 index 00000000000..e9e9c363515 --- /dev/null +++ b/timeseries/service/src/main/resources/kamon.conf @@ -0,0 +1,8 @@ +include "ditto-kamon.conf" + +kamon { + environment { + service = "ditto-timeseries" + service = ${?TIMESERIES_KAMON_IDENTIFIER} + } +} diff --git a/timeseries/service/src/main/resources/logback.xml b/timeseries/service/src/main/resources/logback.xml new file mode 100644 index 00000000000..ef79b8243a0 --- /dev/null +++ b/timeseries/service/src/main/resources/logback.xml @@ -0,0 +1,123 @@ + + + + + + + + + System.err + + %date{ISO8601} %-5level [%X{correlation-id}][%X{traceparent-trace-id}] %logger{20} %X{pekkoSource} - %msg%n + + + ERROR + + + + + + + + + + + + + %date{ISO8601} %-5level [%X{correlation-id}][%X{traceparent-trace-id}] %logger{20} %X{pekkoSource} - %msg%n + + + + + + + + + %date{ISO8601} %-5level [%X{correlation-id}][%X{traceparent-trace-id}] %logger{20} %X{pekkoSource} - %msg%n + + + + + + + + + ${DITTO_LOGGING_LOGSTASH_SERVER} + 1 second + ${LOGSTASH_WRITE_BUFFER_SIZE:-8192} + ${LOGSTASH_RING_BUFFER_SIZE:-8192} + + + + {"appname":"things","instance_index":"${INSTANCE_INDEX}"} + + + + + + + + + + + + + ${DITTO_LOGGING_FILE_APPENDER_THRESHOLD:-info} + + + + + ${DITTO_LOGGING_FILE_NAME_PATTERN:-/var/log/ditto/timeseries.log.%d{yyyy-MM-dd}.gz} + + + ${DITTO_LOGGING_MAX_LOG_FILE_HISTORY:-10} + ${DITTO_LOGGING_TOTAL_LOG_FILE_SIZE:-1GB} + ${DITTO_LOGGING_CLEAN_HISTORY_ON_START:-false} + + + {"appname":"things","instance_index":"${INSTANCE_INDEX}"} + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/timeseries/service/src/main/resources/timeseries-dev.conf b/timeseries/service/src/main/resources/timeseries-dev.conf new file mode 100644 index 00000000000..62ede2a5db1 --- /dev/null +++ b/timeseries/service/src/main/resources/timeseries-dev.conf @@ -0,0 +1,72 @@ +ditto { + http { + hostname = "localhost" + port = 8095 + } + + metrics.prometheus.port = 9016 + + # Local-dev namespace root policies. Cross-Thing aggregation is gated on a namespace-wide + # READ_TS grant, which is only expressible through a root policy — without an entry here the + # endpoint denies every request for the namespace. Mirrors what a deployment sets via + # `global.namespacePolicies`. + namespace-policies { + "ts.demo" = ["ts.demo:root"] + } + + # Local-dev MongoDB connection (consumed by DefaultMongoDbConfig + MongoClientWrapper — + # same path the sibling services use). The path segment IS the default database name: + # the Mongo driver populates DittoMongoClient#getDefaultDatabase from ConnectionString, + # so without `/` the adapter would see a null default database and initialize() + # would fail. Keep the database segment present in any URI override. + mongodb { + uri = "mongodb://localhost:27017/ditto_ts" + uri = ${?MONGO_DB_URI} + } + + timeseries { + # Dev default: strict mode — read-path enforces READ_TS. Flip to true to test the + # simplified mode (a plain READ grant unlocks timeseries reads on the resource). + simplified-read-permission = false + simplified-read-permission = ${?TIMESERIES_SIMPLIFIED_READ_PERMISSION} + + adapter { + type = "mongodb" + mongodb { + collection-prefix = "ts_" + granularity = "seconds" + } + } + } +} + +pekko { + management.http.port = 25640 + + remote { + watch-failure-detector { + expected-response-after = 120s + } + + artery { + canonical.hostname = "127.0.0.1" + canonical.port = 2563 + canonical.port = ${?REMOTE_PORT} + bind.bind-timeout = 6s + bind.bind-timeout = ${?ARTERY_BIND_TIMEOUT} + } + } + + cluster { + failure-detector { + expected-response-after = 120s + threshold = 16.0 + acceptable-heartbeat-pause = 120s + } + + seed-nodes = [ + "pekko://ditto-cluster@127.0.0.1:2552", # port of the policies service to join in cluster + "pekko://ditto-cluster@"${pekko.remote.artery.canonical.hostname}":"${pekko.remote.artery.canonical.port} + ] + } +} diff --git a/timeseries/service/src/main/resources/timeseries.conf b/timeseries/service/src/main/resources/timeseries.conf new file mode 100644 index 00000000000..a008e5d485e --- /dev/null +++ b/timeseries/service/src/main/resources/timeseries.conf @@ -0,0 +1,186 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 + +ditto { + service-name = "timeseries" + mapping-strategy.implementation = "org.eclipse.ditto.timeseries.api.TimeseriesMappingStrategies" + + cluster { + # required for Pekko sharding/cluster bootstrap + } + + # MongoDB connection settings — read by DefaultMongoDbConfig and consumed by + # MongoClientWrapper. Mirrors the sibling-service convention (things.conf + # `ditto.mongodb.database = "things"`, policies.conf `database = "policies"`, …). + # MongoClientWrapper handles AWS IAM (IRSA), pool sizing, SSL, and credential + # refresh internally based on `options.use-aws-iam-role` etc.; the timeseries + # adapter just hands `MongoDbConfig` to the wrapper and gets a configured + # MongoClient back. + mongodb { + database = "ditto_ts" + database = ${?MONGO_DB_DATABASE} + } + + timeseries { + # Read-permission mode (two-mode contract per issue #2291 Section 6.1): + # - false (default, strict): the read-path enforcer requires an explicit READ_TS grant + # on every requested path. Historical data access can be granted separately from live + # data access — the fine-grained model. + # - true (simplified): the enforcer instead checks the standard READ permission. Useful + # for deployments preferring "if you can read the current value, you can read its + # history" semantics, without rewriting existing policies to add READ_TS grants. + # A boolean rather than a free-form permission name — any other value would be a + # deployment footgun (typo'd permissions are silently treated as "no permission" by the + # policy enforcer). + simplified-read-permission = false + simplified-read-permission = ${?TIMESERIES_SIMPLIFIED_READ_PERMISSION} + + # Ceiling on how many Things a single cross-Thing aggregation will authorize. + # A namespace-wide grant does not imply every Thing grants: namespace-root policy entries are + # merged additively, so a Thing's own policy can still revoke the permission — and a revoke wins. + # Each contributing Thing is therefore verified against live policy before any value is + # aggregated, which costs one Thing lookup per contributing Thing (enforcers themselves are + # cached per policy). Cost scales with how many Things have data in the requested window, so this + # bounds it. Exceeding the ceiling fails the request rather than authorizing a truncated set. + max-verified-things = 1000 + max-verified-things = ${?TIMESERIES_MAX_VERIFIED_THINGS} + + # Adapter selection — concrete adapter configuration is read by the adapter implementation + # itself once the MongoDB / IoTDB / TimescaleDB adapter modules are wired in. + adapter { + type = "mongodb" + type = ${?TIMESERIES_ADAPTER_TYPE} + + # MongoDB Time Series adapter — Ditto's default. The connection itself + # (URI / IAM / pool sizing / SSL) comes from `ditto.mongodb.*` above and is + # resolved by `DefaultMongoDbConfig` + `MongoClientWrapper` — same path the + # sibling services use. The block below carries only the bits specific to + # the time-series adapter (per-namespace collection naming + granularity + + # retention). + mongodb { + collection-prefix = "ts_" + collection-prefix = ${?TIMESERIES_MONGODB_COLLECTION_PREFIX} + + granularity = "seconds" + granularity = ${?TIMESERIES_MONGODB_GRANULARITY} + + # Default retention applied to MongoDB Time Series collections, mapped to + # TimeSeriesOptions.expireAfter(seconds). Timeseries is append-only, so without a retention + # storage grows unbounded — hence a sane default of 90 days rather than "keep forever". + # Set to "unlimited" (or "off"/"none") to disable expiration. EXISTING collections are + # reconciled to this value via collMod on the first write to that namespace after a + # restart, so changing it takes effect without a manual migration. Note this is lazy, not + # a startup sweep: a namespace that has stopped ingesting keeps its old retention. + retention = 90d + retention = ${?TIMESERIES_MONGODB_RETENTION} + + # Per-namespace retention overrides, keyed by Thing namespace. A listed namespace uses its + # own retention instead of the default above; others fall back to the default. Values are + # positive HOCON durations (per-namespace "unlimited" is not supported — set the default to + # "unlimited" instead). Reconciled onto existing collections on first write, as above. + # In Helm these are rendered from `timeseries.config.adapter.mongodb.retentionOverrides`. + retention-overrides { + # "com.acme.hifreq" = 7d + # "org.eclipse.ditto" = 365d + } + + # Safety ceiling on the number of data points pulled into application memory per path on + # the raw and window-function (derivative/rate/integral/percentile) read paths, so an + # over-broad time range cannot exhaust the heap. Results that hit it are truncated and a + # warning is logged; narrow the range, add a `limit`, or downsample with a `step`. + max-query-result-size = 1000000 + max-query-result-size = ${?TIMESERIES_MONGODB_MAX_QUERY_RESULT_SIZE} + + # Server-side time budget for a single read (find or aggregation), applied as MongoDB + # `maxTime`. Bounds the blast radius of a pathological query. + query-timeout = 60s + query-timeout = ${?TIMESERIES_MONGODB_QUERY_TIMEOUT} + + # Capabilities this adapter advertises to the TimeseriesQueryPlanner. The planner routes each + # query to the backend's native path or the portable scan()+compute-kernel path based on + # these. Defaults match a modern MongoDB (5.0+) and rarely need changing; override them when + # running an older / feature-limited MongoDB, or to deliberately force behaviour. + capabilities { + # native-query: whether MongoDB's query() is a complete executor, so the planner delegates + # whole queries to it (the fast path, preserving unit metadata). KEEP true for MongoDB — + # setting false forces every query through the portable scan()+kernel path. + native-query = true + native-query = ${?TIMESERIES_MONGODB_CAP_NATIVE_QUERY} + + # pushable-aggregations: aggregations computed IN the DB engine, rather than in the compute + # kernel. The group accumulators are version-independent. The advanced ones are gated by + # MongoDB version / semantics: + # - derivative : native $derivative window operator (5.0+); EXACT match to the kernel and + # scales server-side (no scan-ceiling limit). Default ON. `rate` has no + # native operator and always stays in the kernel. + # - integral : native $integral window operator (5.0+); EXACT match, server-side. + # Default ON. + # - percentile : native $percentile accumulator (7.0+). APPROXIMATE (t-digest) — its + # value differs slightly from the kernel's exact percentile, so it is + # OFF by default. Add "percentile" here to opt in on MongoDB 7.0+. + # A list, so it can't come from an env var — in Helm it renders from + # `timeseries.config.adapter.mongodb.capabilities.pushableAggregations`. + pushable-aggregations = ["avg", "min", "max", "sum", "count", "first", "last", "stddev", + "derivative", "integral"] + + # native-fill-strategies: gap-fill strategies the backend applies natively via + # $densify/$fill (MongoDB 5.3+). Only "linear" (-> $fill linear) and "previous" (-> locf) + # are pushable; "zero"/"null" and any timezone-aligned query stay in the kernel. Empty by + # default keeps all fill in the kernel (identical across backends, and marks gaps). A list; + # rendered in Helm from `timeseries.config.adapter.mongodb.capabilities.nativeFillStrategies`. + native-fill-strategies = ["linear", "previous"] + } + } + } + } +} + +pekko.cluster { + roles = [ + ${ditto.service-name}, + # Required so BlockedNamespaces.of(actorSystem) — invoked transitively by + # CachingPolicyEnforcerProvider — can join the ddata replicator group. + # Without this role, the policy-enforcer cache never receives PolicyTag + # invalidations, so policy updates (e.g. granting READ_TS) silently fail + # to take effect on the timeseries-service until restart. + # Other Ditto services (things, policies, connectivity, …) include this + # role for the same reason; see policies/enforcement BlockedNamespaces.java + # and the inline comment in connectivity.conf cluster.roles. + "blocked-namespaces-aware" + ] +} + +# Required by org.eclipse.ditto.internal.utils.namespaces.BlockedNamespaces, which is +# instantiated indirectly by CachingPolicyEnforcerProvider when the timeseries enforcement +# chain resolves the PolicyEnforcerProviderExtension. Other Ditto services declare an +# identical block in their own service.conf — we follow the same pattern rather than +# moving the dispatcher into ditto-service-base.conf so the change stays scoped to the +# timeseries module. +blocked-namespaces-dispatcher { + type = Dispatcher + executor = "org.eclipse.ditto.internal.utils.metrics.service.executor.InstrumentedForkJoinExecutorServiceConfigurator" + fork-join-executor { + parallelism-min = 4 + parallelism-factor = 3.0 + parallelism-max = 32 + parallelism-max = ${?DEFAULT_DISPATCHER_PARALLELISM_MAX} + } + throughput = 5 +} + +include "kamon.conf" +# The timeseries service performs policy enforcement (READ_TS on the read path, and the +# namespace-wide grant check for cross-Thing aggregations), so it needs the same namespace-root +# policy mapping as policies / things / thingsearch. Without this include the config path +# `ditto.namespace-policies` is absent entirely and every cross-Thing aggregation is denied. +include "ditto-namespace-policies.conf" +include "timeseries-extension.conf" +include file("/opt/ditto/timeseries-extension.conf") diff --git a/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActorEnforcementTest.java b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActorEnforcementTest.java new file mode 100644 index 00000000000..c2a8a6c507a --- /dev/null +++ b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesAggregateActorEnforcementTest.java @@ -0,0 +1,993 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; + +import javax.annotation.Nullable; + +import org.apache.pekko.actor.AbstractActor; +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.actor.Props; +import org.apache.pekko.japi.pf.ReceiveBuilder; +import org.apache.pekko.testkit.javadsl.TestKit; +import org.eclipse.ditto.base.model.auth.AuthorizationContext; +import org.eclipse.ditto.base.model.auth.AuthorizationSubject; +import org.eclipse.ditto.base.model.auth.DittoAuthorizationContextType; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.internal.utils.cache.entry.Entry; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; +import org.eclipse.ditto.policies.enforcement.config.DefaultNamespacePoliciesConfig; +import org.eclipse.ditto.policies.enforcement.config.NamespacePoliciesConfig; +import org.eclipse.ditto.policies.model.ImportableType; +import org.eclipse.ditto.policies.model.PoliciesModelFactory; +import org.eclipse.ditto.policies.model.Policy; +import org.eclipse.ditto.policies.model.PolicyId; +import org.eclipse.ditto.policies.model.enforcers.PolicyEnforcers; +import org.eclipse.ditto.things.api.Permission; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThing; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThingResponse; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; +import org.eclipse.ditto.timeseries.api.Capabilities; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.model.AggregatedTimeseriesResult; +import org.eclipse.ditto.timeseries.model.Aggregation; +import org.eclipse.ditto.timeseries.model.CrossThingTimeseriesQuery; +import org.eclipse.ditto.timeseries.model.GroupBy; +import org.eclipse.ditto.timeseries.model.TimeseriesAggregationForbiddenException; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryInvalidException; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.TimeseriesResultMeta; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveAggregatedTimeseriesResponse; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.typesafe.config.ConfigFactory; + +/** + * Unit tests for {@link TimeseriesAggregateActor}'s two-stage enforcement. + * + *

Stage 1 — the namespace gate

+ * A namespace-wide grant from a namespace-root policy is the entry condition. It bounds the work an + * unauthorized caller can provoke: no discovery query, no per-Thing policy loads. + * + *

Stage 2 — the per-Thing narrowing

+ * Passing the gate proves the root grants; it does not prove no Thing revokes. + * Namespace-root entries merge additively, and a local revoke beats an injected grant — so a Thing can + * be unreadable through the single-Thing endpoint while its measurements would still shape an + * unfiltered aggregate. Every contributing Thing is therefore verified against live policy before any + * value is aggregated, and the response states how many were excluded so a partial answer cannot pass + * for a complete one. + *

+ * Nothing about authorization is read from storage: the decision is taken against current policy state + * on every request, which is why a subject granted access today sees older history and a revoked one + * immediately loses it. + */ +public final class TimeseriesAggregateActorEnforcementTest { + + private static final String NAMESPACE = "io.beyonnex.smartheating"; + private static final PolicyId ROOT_POLICY_ID = PolicyId.of(NAMESPACE, "namespace-root"); + private static final JsonPointer PATH = + JsonPointer.of("/features/circuit/properties/flowTemperature"); + private static final JsonPointer OTHER_PATH = + JsonPointer.of("/features/circuit/properties/returnTemperature"); + private static final String SUBJECT_ID = "integration:ditto"; + private static final Instant FROM = Instant.parse("2026-07-01T00:00:00Z"); + private static final Instant TO = Instant.parse("2026-07-02T00:00:00Z"); + + private static final ThingId THING_A = ThingId.of(NAMESPACE, "heatsource-a"); + private static final ThingId THING_B = ThingId.of(NAMESPACE, "heatsource-b"); + + private static ActorSystem actorSystem; + private static int actorCounter; + + @BeforeClass + public static void beforeClass() { + actorSystem = ActorSystem.create("TimeseriesAggregateActorEnforcementTest", + ConfigFactory.load("test.conf")); + } + + @AfterClass + public static void afterClass() { + if (actorSystem != null) { + TestKit.shutdownActorSystem(actorSystem); + actorSystem = null; + } + } + + // ============================================================================================= + // Stage 1 — namespace gate + // ============================================================================================= + + @Test + public void namespaceWideGrantIsAuthorized() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.start("gate-granted"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(response.getNamespace()).isEqualTo(NAMESPACE); + assertThat(f.adapter.invoked).isTrue(); + assertThat(response.getContributingThings()).isEqualTo(2); + assertThat(response.getExcludedThings()).isZero(); + assertThat(response.isPartial()).isFalse(); + }}; + } + + @Test + public void grantOnParentPathCoversNestedRequestedPath() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ_TS, "thing:/features", "implicit"); + f.start("gate-parent"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.invoked).isTrue(); + }}; + } + + @Test + public void missingNamespaceRootPolicyIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.namespacePolicies = DefaultNamespacePoliciesConfig.of(ConfigFactory.empty()); + f.start("gate-no-root"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + // The gate must reject before touching storage — that is what bounds the work an + // unauthorized caller can provoke. + assertThat(f.adapter.discoveryInvoked).isFalse(); + assertThat(f.adapter.invoked).isFalse(); + }}; + } + + @Test + public void rootPolicyWithoutReadTsIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ, "thing:/", "implicit"); + f.start("gate-read-only"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + @Test + public void plainReadSufficesInSimplifiedMode() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ, "thing:/", "implicit"); + f.thingPolicy = grantingPolicy(Permission.READ, "thing:/", "never"); + f.simplifiedReadPermission = true; + f.start("gate-simplified"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.invoked).isTrue(); + }}; + } + + @Test + public void grantCoveringOnlySomePathsIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ_TS, "thing:" + PATH, "implicit"); + f.start("gate-partial-paths"); + f.ask(this, List.of(PATH, OTHER_PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + @Test + public void revokeBelowGrantedRootIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = PoliciesModelFactory.newPolicy(policyJson(ROOT_POLICY_ID, "DEFAULT", + Map.of("thing:/", grant(Permission.READ_TS), + "thing:" + PATH, revoke(Permission.READ_TS)), + "implicit")); + f.start("gate-revoked"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + }}; + } + + @Test + public void unloadableRootPolicyIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.enforcers = policyId -> CompletableFuture.completedFuture(Optional.empty()); + f.start("gate-unloadable"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + }}; + } + + @Test + public void unrelatedSubjectIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.start("gate-other-subject"); + f.actor.tell(command(List.of(PATH), headersFor("integration:someone-else", "other")), + getRef()); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + @Test + public void wildcardNamespacePatternResolves() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.namespacePolicies = namespacePolicies("io.beyonnex.*", ROOT_POLICY_ID); + f.start("gate-wildcard"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + }}; + } + + /** + * Only {@link ImportableType#IMPLICIT} entries are merged into the namespace's Thing policies, so a + * grant carried by an {@code explicit} or {@code never} entry confers nothing on any Thing and must + * not pass the gate either. + */ + @Test + public void nonImplicitRootEntryDoesNotGrantNamespaceWide() { + for (final String importable : List.of("never", "explicit")) { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ_TS, "thing:/", importable); + f.start("gate-importable-" + importable); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + } + + /** + * A policy entry may be scoped to a subset of namespaces, and + * {@code PolicyImporter.mergeImplicitNamespaceRootEntries} preserves that scope when it + * injects the entry into a Thing's policy. So an implicit root entry scoped to another namespace + * confers nothing here, and the gate must not honour it — otherwise this endpoint reads data the + * single-Thing path denies. + */ + @Test + public void rootEntryScopedToAnotherNamespaceDoesNotGrantNamespaceWide() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = scopedGrantingPolicy(List.of("some.other.namespace")); + f.start("gate-ns-scoped-elsewhere"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + @Test + public void rootEntryScopedToTheQueriedNamespaceStillGrants() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = scopedGrantingPolicy(List.of(NAMESPACE)); + f.start("gate-ns-scoped-here"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.invoked).isTrue(); + }}; + } + + /** + * The same scoping has to be honoured at the per-Thing stage, not just at the gate: a Thing whose + * own policy grants only for another namespace must be excluded rather than trusted. + */ + @Test + public void thingPolicyEntryScopedToAnotherNamespaceExcludesTheThing() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.perThingPolicies.put(policyIdOf(THING_B), + PoliciesModelFactory.newPolicy(scopedPolicyJson(policyIdOf(THING_B), "owner", + Map.of("thing:/", grant(Permission.READ_TS)), "implicit", + List.of("some.other.namespace")))); + f.start("perthing-ns-scoped-elsewhere"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.allowList.get()).containsExactly(Map.entry(PATH, List.of(THING_A))); + }}; + } + + @Test + public void implicitEntryAlongsideNonImportableStillGrants() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = PoliciesModelFactory.newPolicy("{" + + "\"policyId\":\"" + ROOT_POLICY_ID + "\",\"entries\":{" + + "\"LOCAL\":{\"subjects\":{\"" + SUBJECT_ID + "\":{\"type\":\"t\"}}," + + "\"resources\":{\"policy:/\":{\"grant\":[\"READ\"],\"revoke\":[]}}," + + "\"importable\":\"never\"}," + + "\"SHARED\":{\"subjects\":{\"" + SUBJECT_ID + "\":{\"type\":\"t\"}}," + + "\"resources\":{\"thing:/\":{\"grant\":[\"" + Permission.READ_TS + + "\"],\"revoke\":[]}}," + + "\"importable\":\"implicit\"}}}"); + f.start("gate-mixed-importable"); + f.ask(this, List.of(PATH)); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + }}; + } + + /** + * A {@code PolicyEnforcer} built via {@code embed(...)} carries no {@link Policy}, so importability + * cannot be verified — fail closed rather than honour the grant. + */ + @Test + public void enforcerWithoutPolicyFailsClosed() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + final PolicyEnforcer withoutPolicy = PolicyEnforcer + .embed(Entry.of(1L, PolicyEnforcers.defaultEvaluator(f.rootPolicy))) + .getValueOrThrow(); + f.enforcers = policyId -> CompletableFuture.completedFuture(Optional.of(withoutPolicy)); + f.start("gate-no-policy"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + }}; + } + + @Test + public void backendWithoutCrossThingSupportIsRejected() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.adapter.capabilities = Capabilities.minimal(); + f.start("gate-no-capability"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesQueryInvalidException.class); + assertThat(f.adapter.invoked).isFalse(); + }}; + } + + // ============================================================================================= + // Stage 2 — per-Thing narrowing (the data-leak dimension) + // ============================================================================================= + + /** + * The core regression: a Thing whose own policy revokes the permission must be dropped from the + * aggregation even though the namespace root grants it. + */ + @Test + public void thingRevokingPermissionIsExcludedFromAggregate() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.perThingPolicies.put(policyIdOf(THING_B), + PoliciesModelFactory.newPolicy(policyJson(policyIdOf(THING_B), "owner", + Map.of("thing:/", revoke(Permission.READ_TS)), "never"))); + f.start("narrow-revoked"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.allowList.get()).containsExactly(Map.entry(PATH, List.of(THING_A))); + assertThat(response.getContributingThings()).isEqualTo(1); + assertThat(response.getExcludedThings()).isEqualTo(1); + assertThat(response.isPartial()).isTrue(); + assertThat(response.getWithheldByPath()).containsExactly(Map.entry(PATH.toString(), 1)); + }}; + } + + @Test + public void allThingsRevokingYieldsEmptyAllowListNotUnfilteredScan() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + for (final ThingId thingId : List.of(THING_A, THING_B)) { + f.perThingPolicies.put(policyIdOf(thingId), + PoliciesModelFactory.newPolicy(policyJson(policyIdOf(thingId), "owner", + Map.of("thing:/", revoke(Permission.READ_TS)), "never"))); + } + f.start("narrow-all-revoked"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + // A non-null, path-empty allow-list must reach the adapter — null would mean "no filter" + // and leak the whole namespace. + assertThat(f.adapter.allowList.get()).isNotNull().isEmpty(); + assertThat(response.getExcludedThings()).isEqualTo(2); + assertThat(response.isPartial()).isTrue(); + assertThat(response.getWithheldByPath()).containsExactly(Map.entry(PATH.toString(), 2)); + }}; + } + + @Test + public void thingWithoutPolicyIdIsExcluded() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.thingsWithoutPolicy = Set.of(THING_B); + f.start("narrow-no-policyid"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.allowList.get()).containsExactly(Map.entry(PATH, List.of(THING_A))); + assertThat(response.getExcludedThings()).isEqualTo(1); + }}; + } + + @Test + public void unresolvableThingIsExcluded() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.unknownThings = Set.of(THING_B); + f.start("narrow-unresolvable"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.allowList.get()).containsExactly(Map.entry(PATH, List.of(THING_A))); + assertThat(response.getExcludedThings()).isEqualTo(1); + }}; + } + + @Test + public void thingWithoutLoadableEnforcerIsExcluded() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.unloadablePolicies = Set.of(policyIdOf(THING_B)); + f.start("narrow-unloadable-thing"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(f.adapter.allowList.get()).containsExactly(Map.entry(PATH, List.of(THING_A))); + assertThat(response.getExcludedThings()).isEqualTo(1); + }}; + } + + /** + * Verifying a truncated contributor set would silently authorize whatever fell off the end, so + * exceeding the ceiling fails the request — the same discipline as the group cap. + */ + @Test + public void tooManyContributingThingsFailsLoudly() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.maxVerifiedThings = 1; + f.start("narrow-cap"); + f.ask(this, List.of(PATH)); + + expectMsgClass(TimeseriesQueryInvalidException.class); + assertThat(f.adapter.invoked).isFalse(); + }}; + } + + @Test + public void contributorsExactlyAtCapAreVerified() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.maxVerifiedThings = 2; + f.start("narrow-cap-exact"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + assertThat(response.getContributingThings()).isEqualTo(2); + }}; + } + + @Test + public void nothingMatchingTheQueryNeverRunsUnfiltered() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.adapter.defaultContributors = List.of(); + f.start("narrow-nothing"); + f.ask(this, List.of(PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + // An earlier version signalled "nothing matched" with an allow-all marker, which made the + // aggregation run with NO filter — safe only because discovery and the aggregation share + // the same predicate, and still open to anything ingested between the two round trips. + // The allow-list must therefore be present and empty, never null. + assertThat(f.adapter.allowList.get()).isNotNull(); + assertThat(f.adapter.allowList.get()).isEmpty(); + assertThat(response.getExcludedThings()).isZero(); + assertThat(response.isPartial()).isFalse(); + }}; + } + + @Test + public void verificationHappensBeforeAggregation() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ, "thing:/", "implicit"); + f.adapter.failOnQuery = true; + f.start("narrow-order"); + f.ask(this, List.of(PATH)); + + // If the adapter ran first its failure would surface instead of the 403. + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.invoked).isFalse(); + }}; + } + + // ============================================================================================= + // Stage 2b — path-granular permission (a Thing readable on one path, withheld from another) + // ============================================================================================= + + /** + * The regression for path-granular access: {@code READ_TS} can be granted per property, so a Thing + * denied one requested path must still contribute to the paths it is entitled to. Excluding it from + * the whole query would silently drop data the caller may read. + */ + @Test + public void thingDeniedOnOnePathStillContributesToTheOther() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + // THING_B may read PATH but not OTHER_PATH; THING_A may read both. + f.perThingPolicies.put(policyIdOf(THING_B), + PoliciesModelFactory.newPolicy(policyJson(policyIdOf(THING_B), "owner", + Map.of("thing:/", grant(Permission.READ_TS), + "thing:" + OTHER_PATH, revoke(Permission.READ_TS)), "never"))); + f.start("path-granular"); + f.ask(this, List.of(PATH, OTHER_PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + final Map> allow = f.adapter.allowList.get(); + assertThat(allow).isNotNull(); + assertThat(allow.get(PATH)).containsExactly(THING_A, THING_B); + assertThat(allow.get(OTHER_PATH)).containsExactly(THING_A); + // Nothing is fully excluded — THING_B still contributes to PATH. + assertThat(response.getExcludedThings()).isZero(); + assertThat(response.isPartial()).isTrue(); + assertThat(response.getWithheldByPath()) + .containsExactly(Map.entry(OTHER_PATH.toString(), 1)); + }}; + } + + /** A Thing denied on every requested path is fully excluded and counted as such. */ + @Test + public void thingDeniedOnAllPathsIsFullyExcluded() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.perThingPolicies.put(policyIdOf(THING_B), + PoliciesModelFactory.newPolicy(policyJson(policyIdOf(THING_B), "owner", + Map.of("thing:/", revoke(Permission.READ_TS)), "never"))); + f.start("path-granular-all"); + f.ask(this, List.of(PATH, OTHER_PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + final Map> allow = f.adapter.allowList.get(); + assertThat(allow.get(PATH)).containsExactly(THING_A); + assertThat(allow.get(OTHER_PATH)).containsExactly(THING_A); + assertThat(response.getExcludedThings()).isEqualTo(1); + assertThat(response.getWithheldByPath()) + .containsOnly(Map.entry(PATH.toString(), 1), Map.entry(OTHER_PATH.toString(), 1)); + }}; + } + + /** + * A Thing is only counted as withheld from a path it would actually have contributed to — which is + * why discovery is grouped per path rather than flattened. + */ + @Test + public void withheldCountIgnoresPathsTheThingHasNoDataFor() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + // THING_B has data only for PATH; it is denied everything. + f.adapter.contributors = Map.of(PATH, List.of(THING_A, THING_B), + OTHER_PATH, List.of(THING_A)); + f.perThingPolicies.put(policyIdOf(THING_B), + PoliciesModelFactory.newPolicy(policyJson(policyIdOf(THING_B), "owner", + Map.of("thing:/", revoke(Permission.READ_TS)), "never"))); + f.start("path-granular-nodata"); + f.ask(this, List.of(PATH, OTHER_PATH)); + + final RetrieveAggregatedTimeseriesResponse response = + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + // Only PATH counts it: THING_B would have contributed nothing to OTHER_PATH anyway. + assertThat(response.getWithheldByPath()) + .containsExactly(Map.entry(PATH.toString(), 1)); + }}; + } + + // ============================================================================================= + // Fixture + // ============================================================================================= + + /** Assembles the actor with configurable policies, Thing resolution and adapter behaviour. */ + private static final class Fixture { + + private final RecordingAdapter adapter = new RecordingAdapter(); + private final Map perThingPolicies = new LinkedHashMap<>(); + private Policy rootPolicy = PoliciesModelFactory.newPolicy(policyJson(ROOT_POLICY_ID, + "DEFAULT", Map.of("thing:/", "{\"grant\":[\"READ\",\"READ_TS\"],\"revoke\":[]}"), + "implicit")); + private Policy thingPolicy = grantingPolicy(Permission.READ_TS, "thing:/", "never"); + private NamespacePoliciesConfig namespacePolicies = + namespacePolicies(NAMESPACE, ROOT_POLICY_ID); + private boolean simplifiedReadPermission = false; + private int maxVerifiedThings = 100; + private Set unknownThings = Set.of(); + private Set thingsWithoutPolicy = Set.of(); + private Set unloadablePolicies = Set.of(); + @Nullable private PolicyEnforcerProvider enforcers; + private ActorRef actor; + + void start(final String name) { + final int seq = actorCounter++; + final Set unknown = unknownThings; + final Set noPolicy = thingsWithoutPolicy; + final ActorRef things = actorSystem.actorOf( + Props.create(FakeThings.class, () -> new FakeThings(unknown, noPolicy)), + "things-" + name + "-" + seq); + final PolicyEnforcerProvider provider = enforcers != null ? enforcers : policyId -> { + if (unloadablePolicies.contains(policyId)) { + return CompletableFuture.completedFuture(Optional.empty()); + } + final Policy policy = ROOT_POLICY_ID.equals(policyId) + ? rootPolicy + : perThingPolicies.getOrDefault(policyId, thingPolicy); + return CompletableFuture.completedFuture(Optional.of(PolicyEnforcer.of(policy))); + }; + actor = actorSystem.actorOf(TimeseriesAggregateActor.props(adapter, things, provider, + namespacePolicies, simplifiedReadPermission, maxVerifiedThings), + "aggregate-" + name + "-" + seq); + } + + void ask(final TestKit kit, final List paths) { + actor.tell(command(paths, headersFor(SUBJECT_ID, "cid")), kit.getRef()); + } + } + + /** Stands in for the things shard region, answering {@code SudoRetrieveThing}. */ + private static final class FakeThings extends AbstractActor { + + private final Set unknown; + private final Set withoutPolicy; + + private FakeThings(final Set unknown, final Set withoutPolicy) { + this.unknown = unknown; + this.withoutPolicy = withoutPolicy; + } + + @Override + public Receive createReceive() { + return ReceiveBuilder.create() + .match(SudoRetrieveThing.class, sudo -> { + final ThingId thingId = ThingId.of(sudo.getEntityId()); + if (unknown.contains(thingId)) { + getSender().tell(ThingNotAccessibleException.newBuilder(thingId) + .dittoHeaders(sudo.getDittoHeaders()).build(), getSelf()); + return; + } + final Thing thing = withoutPolicy.contains(thingId) + ? ThingsModelFactory.newThingBuilder().setId(thingId).build() + : ThingsModelFactory.newThingBuilder().setId(thingId) + .setPolicyId(policyIdOf(thingId)).build(); + getSender().tell(SudoRetrieveThingResponse.of(thing.toJson(), + sudo.getDittoHeaders()), getSelf()); + }) + .matchAny(m -> { }) + .build(); + } + } + + // ============================================================================================= + // Helpers + // ============================================================================================= + + /** Convention used by {@link FakeThings}: a Thing's policy id mirrors its Thing id. */ + private static PolicyId policyIdOf(final ThingId thingId) { + return PolicyId.of(thingId); + } + + + + // --------------------------------------------------------------------------------------------- + // Authorization layer 1: READ on the fields the query filters on / groups by + // --------------------------------------------------------------------------------------------- + + /** + * Grouping by a tag discloses the tag's distinct values, so it requires {@code READ} on the Thing + * field the tag is keyed by — independently of {@code READ_TS} on the data paths. A root policy + * granting only {@code READ_TS} is therefore not enough to slice by {@code attributes/building}. + */ + @Test + public void groupByTagFieldWithoutReadIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ_TS, "thing:/", "implicit"); + f.start("layer1-groupby-denied"); + f.actor.tell(commandWithFields(List.of(PATH), + List.of(GroupBy.tag("attributes/building")), null, headersFor(SUBJECT_ID, "cid")), getRef()); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + // Rejected before any backend work is provoked. + assertThat(f.adapter.discoveryInvoked).isFalse(); + assertThat(f.adapter.invoked).isFalse(); + }}; + } + + @Test + public void groupByTagFieldWithReadIsAllowed() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.start("layer1-groupby-allowed"); + f.actor.tell(commandWithFields(List.of(PATH), + List.of(GroupBy.tag("attributes/building")), null, headersFor(SUBJECT_ID, "cid")), getRef()); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + }}; + } + + /** Same rule for an RQL filter: filtering by a tag discloses which points carry which value. */ + @Test + public void filterFieldWithoutReadIsForbidden() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.rootPolicy = grantingPolicy(Permission.READ_TS, "thing:/", "implicit"); + f.start("layer1-filter-denied"); + f.actor.tell(commandWithFields(List.of(PATH), List.of(GroupBy.thingId()), + "eq(attributes/building,'A')", headersFor(SUBJECT_ID, "cid")), getRef()); + + expectMsgClass(TimeseriesAggregationForbiddenException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + @Test + public void filterFieldWithReadIsAllowed() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.start("layer1-filter-allowed"); + f.actor.tell(commandWithFields(List.of(PATH), List.of(GroupBy.thingId()), + "and(eq(attributes/building,'A'),ge(attributes/floor,2))", headersFor(SUBJECT_ID, "cid")), getRef()); + + expectMsgClass(RetrieveAggregatedTimeseriesResponse.class); + }}; + } + + @Test + public void unparseableFilterIsRejectedAsInvalid() { + new TestKit(actorSystem) {{ + final Fixture f = new Fixture(); + f.start("layer1-filter-garbage"); + f.actor.tell(commandWithFields(List.of(PATH), List.of(GroupBy.thingId()), + "this is not rql", headersFor(SUBJECT_ID, "cid")), getRef()); + + expectMsgClass(TimeseriesQueryInvalidException.class); + assertThat(f.adapter.discoveryInvoked).isFalse(); + }}; + } + + /** A command whose groupBy and filter reference tag fields, for authorization layer 1. */ + private static RetrieveAggregatedTimeseries commandWithFields(final List paths, + final List groupBy, @Nullable final String filter, final DittoHeaders headers) { + + final CrossThingTimeseriesQuery query = CrossThingTimeseriesQuery.of(NAMESPACE, paths, FROM, + TO, Duration.ofHours(1), Aggregation.AVG, groupBy, filter, null, null, null); + return RetrieveAggregatedTimeseries.of(query, headers); + } + + private static RetrieveAggregatedTimeseries command(final List paths, + final DittoHeaders headers) { + + final CrossThingTimeseriesQuery query = CrossThingTimeseriesQuery.of(NAMESPACE, paths, FROM, + TO, Duration.ofHours(1), Aggregation.AVG, + List.of(GroupBy.thingId()), null, null, null, null); + return RetrieveAggregatedTimeseries.of(query, headers); + } + + private static DittoHeaders headersFor(final String subjectId, final String correlationId) { + return DittoHeaders.newBuilder() + .correlationId(correlationId + "-" + subjectId) + .authorizationContext(AuthorizationContext.newInstance( + DittoAuthorizationContextType.UNSPECIFIED, + AuthorizationSubject.newInstance(subjectId))) + .build(); + } + + private static NamespacePoliciesConfig namespacePolicies(final String pattern, + final PolicyId rootPolicyId) { + + return DefaultNamespacePoliciesConfig.of(ConfigFactory.parseString( + "ditto.namespace-policies { \"" + pattern + "\" = [\"" + rootPolicyId + "\"] }")); + } + + private static String grant(final String permission) { + return "{\"grant\":[\"" + permission + "\"],\"revoke\":[]}"; + } + + private static String revoke(final String permission) { + return "{\"grant\":[],\"revoke\":[\"" + permission + "\"]}"; + } + + private static String policyJson(final PolicyId policyId, final String label, + final Map resources, final String importable) { + + final StringBuilder res = new StringBuilder(); + for (final Map.Entry entry : resources.entrySet()) { + if (res.length() > 0) { + res.append(','); + } + res.append('"').append(entry.getKey()).append("\":").append(entry.getValue()); + } + return "{\"policyId\":\"" + policyId + "\",\"entries\":{\"" + label + "\":{" + + "\"subjects\":{\"" + SUBJECT_ID + "\":{\"type\":\"t\"}}," + + "\"resources\":{" + res + "}," + + "\"importable\":\"" + importable + "\"}}}"; + } + + private static Policy grantingPolicy(final String permission, final String resource, + final String importable) { + + return PoliciesModelFactory.newPolicy(policyJson(ROOT_POLICY_ID, "DEFAULT", + Map.of(resource, grant(permission)), importable)); + } + + /** + * Same as {@link #policyJson} but with a per-entry {@code namespaces} scope, which + * {@code PolicyImporter} preserves when it merges a root entry into a Thing's policy. + */ + private static String scopedPolicyJson(final PolicyId policyId, final String label, + final Map resources, final String importable, + final List namespaces) { + + final StringBuilder res = new StringBuilder(); + for (final Map.Entry entry : resources.entrySet()) { + if (res.length() > 0) { + res.append(','); + } + res.append('"').append(entry.getKey()).append("\":").append(entry.getValue()); + } + final StringBuilder ns = new StringBuilder(); + for (final String namespace : namespaces) { + if (ns.length() > 0) { + ns.append(','); + } + ns.append('"').append(namespace).append('"'); + } + return "{\"policyId\":\"" + policyId + "\",\"entries\":{\"" + label + "\":{" + + "\"subjects\":{\"" + SUBJECT_ID + "\":{\"type\":\"t\"}}," + + "\"resources\":{" + res + "}," + + "\"namespaces\":[" + ns + "]," + + "\"importable\":\"" + importable + "\"}}}"; + } + + private static Policy scopedGrantingPolicy(final List namespaces) { + return PoliciesModelFactory.newPolicy(scopedPolicyJson(ROOT_POLICY_ID, "DEFAULT", + Map.of("thing:/", grant(Permission.READ_TS)), "implicit", namespaces)); + } + + /** Adapter double recording discovery, the allow-list handed down, and whether the query ran. */ + private static final class RecordingAdapter implements TimeseriesAdapter { + + private volatile boolean invoked = false; + private volatile boolean discoveryInvoked = false; + private final AtomicReference>> allowList = + new AtomicReference<>(); + /** path -> Things having data for it. Defaults to both Things on every requested path. */ + private volatile Map> contributors = null; + private volatile List defaultContributors = List.of(THING_A, THING_B); + private volatile Capabilities capabilities = Capabilities.builder() + .supportsNativeQuery(true) + .supportsNativeCrossThingQuery(true) + .build(); + private volatile boolean failOnQuery = false; + + @Override + public Capabilities capabilities() { + return capabilities; + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + @Override + public CompletionStage>> discoverContributors( + final CrossThingTimeseriesQuery query, final int limit) { + + discoveryInvoked = true; + if (contributors != null) { + return CompletableFuture.completedFuture(contributors); + } + final Map> perPath = new LinkedHashMap<>(); + for (final JsonPointer path : query.getPaths()) { + final List all = defaultContributors; + // Mirror the contract: at most limit + 1 distinct, so an overflow stays detectable. + perPath.put(path, all.size() > limit + 1 ? all.subList(0, limit + 1) : all); + } + return CompletableFuture.completedFuture(perPath); + } + + @Override + public CompletionStage> queryCrossThing( + final CrossThingTimeseriesQuery query, + @Nullable final Map> permittedThingsPerPath) { + + invoked = true; + allowList.set(permittedThingsPerPath); + if (failOnQuery) { + return CompletableFuture.failedFuture(new IllegalStateException("must not run")); + } + return CompletableFuture.completedFuture(List.of(AggregatedTimeseriesResult.of( + Map.of("building", "A"), query.getPaths().get(0), + TimeseriesResultMeta.of(0, "cel", "number"), List.of()))); + } + } +} diff --git a/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorEnforcementTest.java b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorEnforcementTest.java new file mode 100644 index 00000000000..7e4dae590d1 --- /dev/null +++ b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorEnforcementTest.java @@ -0,0 +1,472 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import javax.annotation.Nullable; + +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.actor.Props; +import org.apache.pekko.testkit.TestProbe; +import org.apache.pekko.testkit.javadsl.TestKit; +import org.eclipse.ditto.base.model.auth.AuthorizationContext; +import org.eclipse.ditto.base.model.auth.AuthorizationSubject; +import org.eclipse.ditto.base.model.auth.DittoAuthorizationContextType; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.internal.utils.cache.entry.Entry; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcer; +import org.eclipse.ditto.policies.enforcement.PolicyEnforcerProvider; +import org.eclipse.ditto.policies.model.PoliciesModelFactory; +import org.eclipse.ditto.policies.model.Policy; +import org.eclipse.ditto.policies.model.PolicyId; +import org.eclipse.ditto.policies.model.enforcers.Enforcer; +import org.eclipse.ditto.policies.model.enforcers.PolicyEnforcers; +import org.eclipse.ditto.things.api.Permission; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThing; +import org.eclipse.ditto.things.api.commands.sudo.SudoRetrieveThingResponse; +import org.eclipse.ditto.things.model.Thing; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.things.model.ThingsModelFactory; +import org.eclipse.ditto.things.model.signals.commands.exceptions.ThingNotAccessibleException; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries; +import org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.typesafe.config.ConfigFactory; + +/** + * Unit tests for the {@link TimeseriesIngestActor} read-path enforcement chain + * — {@link RetrieveTimeseries} → {@code SudoRetrieveThing} → {@link PolicyEnforcer} lookup → + * per-path permission verification. The persistent ingest path is covered separately by + * {@link TimeseriesIngestActorTest}; here we only exercise the production-wiring branch where + * both {@code thingsShardRegion} and {@code policyEnforcerProvider} are non-null. + *

+ * Each scenario is one row of the truth table the enforcement code traverses + * ({@code TimeseriesIngestActor#authorizeAndQuery} → {@code enforce} → + * {@code resolveSudoReply} / {@code loadEnforcer} / {@code verifyPaths}). + */ +public final class TimeseriesIngestActorEnforcementTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final PolicyId POLICY_ID = PolicyId.of("org.eclipse.ditto", "sensor-policy"); + private static final JsonPointer PATH = JsonPointer.of("/features/env/properties/temperature"); + private static final String SUBJECT_ID = "integration:ditto"; + private static final Instant FROM = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant TO = Instant.parse("2026-01-02T00:00:00Z"); + + private static ActorSystem actorSystem; + + @BeforeClass + public static void beforeClass() { + actorSystem = ActorSystem.create("TimeseriesIngestActorEnforcementTest", + ConfigFactory.load("test.conf")); + } + + @AfterClass + public static void afterClass() { + if (actorSystem != null) { + TestKit.shutdownActorSystem(actorSystem); + actorSystem = null; + } + } + + @Test + public void grantedSubjectReceivesResponse() { + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = stubProvider(policyEnforcerWithGrant(Permission.READ_TS)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "granted-thing"); + + actor.tell(retrieveTimeseries(authHeaders("granted-cid")), getRef()); + + // Respond to the SudoRetrieveThing ask with a Thing that references the test policy. + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + // The adapter returns empty results; what matters is that enforcement let us through. + final RetrieveTimeseriesResponse response = expectMsgClass(RetrieveTimeseriesResponse.class); + assertThat((Object) response.getEntityId()).isEqualTo(THING_ID); + assertThat(response.getDittoHeaders().getCorrelationId()).contains("granted-cid"); + }}; + } + + @Test + public void subjectWithoutRequiredPermissionGets404() { + // The hard rule (auth-authz.md): authorization failure surfaces as 404 ThingNotAccessibleException, + // never 403 — 403 would leak existence to a subject that can't read the resource. + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + // Policy grants READ but not READ_TS — under strict mode (default) this is denial. + final PolicyEnforcerProvider provider = stubProvider(policyEnforcerWithGrant(Permission.READ)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "denied-thing"); + + actor.tell(retrieveTimeseries(authHeaders("denied-cid")), getRef()); + + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + final ThingNotAccessibleException denial = expectMsgClass(ThingNotAccessibleException.class); + assertThat(denial.getMessage()).contains(THING_ID.toString()); + assertThat(denial.getDittoHeaders().getCorrelationId()).contains("denied-cid"); + }}; + } + + @Test + public void simplifiedReadPermissionUnlocksTimeseriesWhenConfigured() { + // When the deployment opts into simplifiedReadPermission=true, a plain READ grant + // suffices — the "simplified" mode in the two-mode contract. Verifies that the + // boolean toggle actually drives the check (no hard-coded READ_TS). + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = stubProvider(policyEnforcerWithGrant(Permission.READ)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + true, "simplified-thing"); + + actor.tell(retrieveTimeseries(authHeaders("simplified-cid")), getRef()); + + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + expectMsgClass(RetrieveTimeseriesResponse.class); + }}; + } + + @Test + public void thingNotFoundProducesNotAccessible() { + // SudoRetrieveThing reply is the not-accessible exception itself — the actor must surface it + // back to the caller as-is (with the right correlation id). + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = stubProvider(policyEnforcerWithGrant(Permission.READ_TS)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "not-found-thing"); + + actor.tell(retrieveTimeseries(authHeaders("nf-cid")), getRef()); + + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(ThingNotAccessibleException.newBuilder(THING_ID) + .dittoHeaders(authHeaders("nf-cid")).build()); + + final ThingNotAccessibleException reply = expectMsgClass(ThingNotAccessibleException.class); + assertThat(reply.getDittoHeaders().getCorrelationId()).contains("nf-cid"); + }}; + } + + @Test + public void thingWithoutPolicyIdDeniesAccess() { + // Configuration anomaly — but it must not leak that the thing exists. Deny with 404 in line + // with the 404-not-403 discipline. + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = stubProvider(policyEnforcerWithGrant(Permission.READ_TS)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "no-policy-thing"); + + actor.tell(retrieveTimeseries(authHeaders("nopol-cid")), getRef()); + + thingsShard.expectMsgClass(SudoRetrieveThing.class); + // Thing without policyId — the loadEnforcer branch denies on the empty Optional. + thingsShard.reply(SudoRetrieveThingResponse.of( + ThingsModelFactory.newThingBuilder().setId(THING_ID).build().toJson(), + DittoHeaders.empty())); + + expectMsgClass(ThingNotAccessibleException.class); + }}; + } + + @Test + public void enforcerProviderReturnsEmptyDeniesAccess() { + // Cache miss + load failure on the policies-service side. The actor must not surface + // "policy can't be loaded" — that's an existence leak; instead deny with 404. + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + // Provider returns Optional.empty() — same shape as a policy that no longer exists. + final PolicyEnforcerProvider provider = + policyId -> CompletableFuture.completedFuture(Optional.empty()); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "empty-enforcer-thing"); + + actor.tell(retrieveTimeseries(authHeaders("empty-cid")), getRef()); + + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + expectMsgClass(ThingNotAccessibleException.class); + }}; + } + + // --------------------------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------------------------- + + private ActorRef startEntity(final ActorRef thingsShard, + final PolicyEnforcerProvider provider, + final TimeseriesAdapter adapter, + final boolean simplifiedReadPermission, + final String actorNameSuffix) { + + // Cluster sharding sets the entity actor name to the entityId, and the actor URL-decodes + // its own name to recover the ThingId — so the actor name must be a valid namespaced ID. + // Each scenario gets a unique suffix so the test fixtures stay isolated. + final ThingId entityId = ThingId.of("org.eclipse.ditto", "sensor-" + actorNameSuffix); + return actorSystem.actorOf( + Props.create(TimeseriesIngestActor.class, adapter, thingsShard, provider, simplifiedReadPermission), + entityId.toString()); + } + + + + // --------------------------------------------------------------------------------------------- + // Partial reads: READ_TS is per-property, so a denied path narrows the result rather than + // failing the request (design doc section 6.3). + // --------------------------------------------------------------------------------------------- + + /** + * Two paths requested, one granted: the granted one comes back and the denied one is simply absent. + * A missing series is visibly missing, so — unlike an aggregate — dropping it silently does not + * misrepresent the answer. + */ + @Test + public void aDeniedPathIsDroppedRatherThanFailingTheWholeRead() { + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = + stubProvider(policyEnforcerGrantingOnlyFirstPath(Permission.READ_TS)); + final PathCapturingAdapter adapter = new PathCapturingAdapter(); + final ActorRef actor = startEntity(thingsShard.ref(), provider, adapter, + false, "partial-read"); + + actor.tell(retrieveTimeseries(authHeaders("partial-cid"), List.of(PATH, OTHER_PATH)), + getRef()); + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + expectMsgClass(RetrieveTimeseriesResponse.class); + // The backend must never be asked for the path the caller may not read. + assertThat(adapter.queriedPaths).containsExactly(PATH); + }}; + } + + /** When no requested path is readable the read still 404s, disclosing nothing. */ + @Test + public void allPathsDeniedStillProduces404() { + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = + stubProvider(policyEnforcerGrantingOnlyFirstPath(Permission.READ_TS)); + final ActorRef actor = startEntity(thingsShard.ref(), provider, recordingAdapter(), + false, "partial-none"); + + actor.tell(retrieveTimeseries(authHeaders("none-cid"), List.of(OTHER_PATH)), getRef()); + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + expectMsgClass(ThingNotAccessibleException.class); + }}; + } + + /** All paths granted: the query reaches the backend unnarrowed. */ + @Test + public void allPathsGrantedQueriesEveryRequestedPath() { + new TestKit(actorSystem) {{ + final TestProbe thingsShard = new TestProbe(actorSystem); + final PolicyEnforcerProvider provider = + stubProvider(policyEnforcerWithGrant(Permission.READ_TS)); + final PathCapturingAdapter adapter = new PathCapturingAdapter(); + final ActorRef actor = startEntity(thingsShard.ref(), provider, adapter, + false, "partial-all"); + + actor.tell(retrieveTimeseries(authHeaders("all-cid"), List.of(PATH, OTHER_PATH)), getRef()); + thingsShard.expectMsgClass(SudoRetrieveThing.class); + thingsShard.reply(SudoRetrieveThingResponse.of( + thingWithPolicy(THING_ID, POLICY_ID).toJson(), DittoHeaders.empty())); + + expectMsgClass(RetrieveTimeseriesResponse.class); + assertThat(adapter.queriedPaths).containsExactly(PATH, OTHER_PATH); + }}; + } + + /** Records the paths the adapter was actually asked for. */ + private static final class PathCapturingAdapter implements TimeseriesAdapter { + + private volatile List queriedPaths = List.of(); + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + queriedPaths = List.copyOf(query.getPaths()); + return CompletableFuture.completedFuture(List.of()); + } + } + + private static final JsonPointer OTHER_PATH = + JsonPointer.of("/features/env/properties/humidity"); + + private static RetrieveTimeseries retrieveTimeseries(final DittoHeaders headers, + final List paths) { + + final TimeseriesQuery query = TimeseriesQuery.of(THING_ID, paths, FROM, TO, + null, null, null, null, null); + return RetrieveTimeseries.of(query, headers); + } + + /** Grants {@code permission} on {@code PATH} only; {@code OTHER_PATH} is left ungranted. */ + private static PolicyEnforcer policyEnforcerGrantingOnlyFirstPath(final String permission) { + final Policy policy = PoliciesModelFactory.newPolicy(""" + { + "policyId": "org.eclipse.ditto:sensor-policy", + "entries": { + "DEFAULT": { + "subjects": { "%s": { "type": "test" } }, + "resources": { + "thing:%s": { "grant": ["%s"], "revoke": [] } + } + } + } + } + """.formatted(SUBJECT_ID, PATH, permission)); + return PolicyEnforcer.of(policy); + } + + private static RetrieveTimeseries retrieveTimeseries(final DittoHeaders headers) { + final TimeseriesQuery query = TimeseriesQuery.of(THING_ID, List.of(PATH), FROM, TO, + null, null, null, null, null); + return RetrieveTimeseries.of(query, headers); + } + + private static DittoHeaders authHeaders(final String correlationId) { + return DittoHeaders.newBuilder() + .correlationId(correlationId) + .authorizationContext(AuthorizationContext.newInstance( + DittoAuthorizationContextType.UNSPECIFIED, + AuthorizationSubject.newInstance(SUBJECT_ID))) + .build(); + } + + private static Thing thingWithPolicy(final ThingId thingId, final PolicyId policyId) { + return ThingsModelFactory.newThingBuilder() + .setId(thingId) + .setPolicyId(policyId) + .build(); + } + + private static PolicyEnforcerProvider stubProvider(final PolicyEnforcer enforcer) { + return policyId -> CompletableFuture.completedFuture(Optional.of(enforcer)); + } + + /** + * Builds a {@link PolicyEnforcer} whose evaluated policy grants {@code permission} on + * {@code thing:/} for {@code integration:ditto}. Use the produced enforcer with + * {@link #stubProvider} to drive the production code path of {@code loadEnforcer}. + */ + private static PolicyEnforcer policyEnforcerWithGrant(final String permission) { + final Policy policy = PoliciesModelFactory.newPolicy(""" + { + "policyId": "org.eclipse.ditto:sensor-policy", + "entries": { + "DEFAULT": { + "subjects": { "%s": { "type": "test" } }, + "resources": { + "thing:/": { "grant": ["%s"], "revoke": [] } + } + } + } + } + """.formatted(SUBJECT_ID, permission)); + // of(policy), NOT embed(enforcer): embed keeps only the pre-built Enforcer and drops the + // Policy, and the read path needs the Policy to apply per-entry `namespaces` scoping + // (forNamespace). Without it enforcement fails closed — correct in production, but it would + // make this fixture deny everything. PolicyEnforcerCacheLoader likewise builds instances that + // carry their resolved Policy, so of(...) is the faithful double. + return PolicyEnforcer.of(policy); + } + + private static TimeseriesAdapter recordingAdapter() { + return new TimeseriesAdapter() { + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage writeBatch(@Nullable final List dataPoints) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + }; + } +} diff --git a/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorTest.java b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorTest.java new file mode 100644 index 00000000000..9ac7dd98078 --- /dev/null +++ b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/handlers/TimeseriesIngestActorTest.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.apache.pekko.actor.ActorRef; +import org.apache.pekko.actor.ActorSystem; +import org.apache.pekko.actor.Status; +import org.apache.pekko.testkit.javadsl.TestKit; +import org.eclipse.ditto.base.model.headers.DittoHeaders; +import org.eclipse.ditto.json.JsonPointer; +import org.eclipse.ditto.json.JsonValue; +import org.eclipse.ditto.things.model.ThingId; +import org.eclipse.ditto.timeseries.api.HealthStatus; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapter; +import org.eclipse.ditto.timeseries.api.TimeseriesAdapterConfig; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPoints; +import org.eclipse.ditto.timeseries.api.commands.IngestDataPointsResponse; +import org.eclipse.ditto.timeseries.model.TimeseriesDataPoint; +import org.eclipse.ditto.timeseries.model.TimeseriesQuery; +import org.eclipse.ditto.timeseries.model.TimeseriesQueryResult; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.typesafe.config.ConfigFactory; + +/** + * Unit tests for the sharded {@link TimeseriesIngestActor}. The actor is intentionally + * non-persistent — writes go directly to the configured adapter; the publisher's + * retry loop covers crash windows. Each test exercises one slice of the contract: + * happy-path ack, empty-batch no-op, adapter failure surfaces as Status.Failure, + * idempotent ack for duplicate retries, and the read path. + */ +public final class TimeseriesIngestActorTest { + + private static final ThingId THING_ID = ThingId.of("org.eclipse.ditto", "sensor-1"); + private static final JsonPointer PATH = JsonPointer.of("/features/env/properties/temperature"); + + private static ActorSystem actorSystem; + + @BeforeClass + public static void beforeClass() { + actorSystem = ActorSystem.create("TimeseriesIngestActorTest", + ConfigFactory.load("test.conf")); + } + + @AfterClass + public static void afterClass() { + if (actorSystem != null) { + TestKit.shutdownActorSystem(actorSystem); + actorSystem = null; + } + } + + @Test + public void successfulBatchProducesAck() { + new TestKit(actorSystem) {{ + final RecordingAdapter adapter = new RecordingAdapter(); + final ActorRef ingest = startEntity(adapter, THING_ID); + + final IngestDataPoints command = IngestDataPoints.of(THING_ID, sampleBatch(THING_ID), + DittoHeaders.newBuilder().correlationId("ack-1").build()); + ingest.tell(command, getRef()); + + final IngestDataPointsResponse ack = expectMsgClass(IngestDataPointsResponse.class); + assertThat((Object) ack.getEntityId()).isEqualTo(THING_ID); + assertThat(ack.getDittoHeaders().getCorrelationId()).contains("ack-1"); + assertThat(adapter.writtenBatches).hasSize(1); + assertThat(adapter.writtenBatches.get(0)).hasSize(2); + }}; + } + + @Test + public void emptyBatchAcksWithoutWriting() { + // Empty batches ack without calling the adapter (can happen if the publisher's + // WoT logic produces an empty result list). + new TestKit(actorSystem) {{ + final RecordingAdapter adapter = new RecordingAdapter(); + final ActorRef ingest = startEntity(adapter, ThingId.of("org.eclipse.ditto", "empty-1")); + + ingest.tell(IngestDataPoints.of(ThingId.of("org.eclipse.ditto", "empty-1"), + Collections.emptyList(), + DittoHeaders.newBuilder().correlationId("empty").build()), getRef()); + + final IngestDataPointsResponse ack = expectMsgClass(IngestDataPointsResponse.class); + assertThat(ack.getDittoHeaders().getCorrelationId()).contains("empty"); + assertThat(adapter.writtenBatches).isEmpty(); + }}; + } + + @Test + public void adapterFailureSurfacesAsStatusFailure() { + // On adapter failure the actor replies with Status.Failure — the publisher's retry + // signal. A follow-up retry with the same correlation-id triggers another adapter + // call (the failed write cleared the in-flight slot, so we don't dedupe). + new TestKit(actorSystem) {{ + final RuntimeException backendError = new RuntimeException("mongo unavailable"); + final RecordingAdapter adapter = new RecordingAdapter().failingWith(backendError); + final ThingId tid = ThingId.of("org.eclipse.ditto", "failing-1"); + final ActorRef ingest = startEntity(adapter, tid); + + final String corrId = UUID.randomUUID().toString(); + ingest.tell(IngestDataPoints.of(tid, sampleBatch(tid), + DittoHeaders.newBuilder().correlationId(corrId).build()), getRef()); + final Status.Failure failure = expectMsgClass(Status.Failure.class); + assertThat(failure.cause().getMessage()).contains("mongo unavailable"); + assertThat(adapter.writtenBatches).hasSize(1); + + // Publisher's retry with same correlation-id triggers a fresh adapter call. + adapter.clearFailure(); + ingest.tell(IngestDataPoints.of(tid, sampleBatch(tid), + DittoHeaders.newBuilder().correlationId(corrId).build()), getRef()); + expectMsgClass(IngestDataPointsResponse.class); + assertThat(adapter.writtenBatches).hasSize(2); + }}; + } + + @Test + public void retryAfterApplyIsAckedIdempotentlyWithoutRewriting() { + // The ack-lost-in-transit dedup window: first batch is applied (we get the ack), + // then the publisher re-sends with the same correlation-id (its Patterns.ask raced + // with a GC pause / network reorder). The bounded in-memory recently-applied ring + // recognises the replay and acks without a second adapter call. + new TestKit(actorSystem) {{ + final RecordingAdapter adapter = new RecordingAdapter(); + final ThingId tid = ThingId.of("org.eclipse.ditto", "applied-retry-1"); + final ActorRef ingest = startEntity(adapter, tid); + + final String corrId = "applied-retry-" + UUID.randomUUID(); + final IngestDataPoints command = IngestDataPoints.of(tid, sampleBatch(tid), + DittoHeaders.newBuilder().correlationId(corrId).build()); + + ingest.tell(command, getRef()); + expectMsgClass(IngestDataPointsResponse.class); + assertThat(adapter.writtenBatches).hasSize(1); + + // Same correlation-id, same batch — the in-memory ring should ack without + // a second adapter call. + ingest.tell(command, getRef()); + expectMsgClass(IngestDataPointsResponse.class); + assertThat(adapter.writtenBatches).hasSize(1); + }}; + } + + @Test + public void retrieveTimeseriesReturnsAdapterResults() { + // The merged actor handles RetrieveTimeseries directly (no separate query-handler). + // The test-only Props variant skips enforcement, so the response is whatever the + // adapter's query() returns. RecordingAdapter returns an empty list — we assert the + // response is shaped correctly and reaches the sender. + new TestKit(actorSystem) {{ + final RecordingAdapter adapter = new RecordingAdapter(); + final ThingId tid = ThingId.of("org.eclipse.ditto", "query-1"); + final ActorRef ingest = startEntity(adapter, tid); + + final org.eclipse.ditto.timeseries.model.TimeseriesQuery query = + org.eclipse.ditto.timeseries.model.TimeseriesQuery.of(tid, + Collections.singletonList(PATH), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z")); + final org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries cmd = + org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries.of(query, + DittoHeaders.newBuilder().correlationId("query-test").build()); + + ingest.tell(cmd, getRef()); + final org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse response = + expectMsgClass(org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseriesResponse.class); + assertThat((Object) response.getEntityId()).isEqualTo(tid); + assertThat(response.getResults()).isEmpty(); + }}; + } + + @Test + public void retrieveTimeseriesSurfacesAdapterFailureAsStatusFailure() { + // Adapter throwing on query() must surface as Status.Failure — the gateway then maps + // that to a 5XX rather than the success envelope. + new TestKit(actorSystem) {{ + final RuntimeException backendError = new RuntimeException("mongo unavailable"); + final RecordingAdapter adapter = new RecordingAdapter().failingQueryWith(backendError); + final ThingId tid = ThingId.of("org.eclipse.ditto", "query-fail-1"); + final ActorRef ingest = startEntity(adapter, tid); + + final org.eclipse.ditto.timeseries.model.TimeseriesQuery query = + org.eclipse.ditto.timeseries.model.TimeseriesQuery.of(tid, + Collections.singletonList(PATH), + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z")); + final org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries cmd = + org.eclipse.ditto.timeseries.model.signals.commands.RetrieveTimeseries.of(query, + DittoHeaders.newBuilder().correlationId("query-fail-test").build()); + + ingest.tell(cmd, getRef()); + final Status.Failure failure = expectMsgClass(Status.Failure.class); + assertThat(failure.cause().getMessage()).contains("mongo unavailable"); + }}; + } + + private ActorRef startEntity(final TimeseriesAdapter adapter, final ThingId thingId) { + // Cluster sharding sets the entity actor name to the entityId; we mimic that + // for tests so the actor's thingId is consistent with production wiring. + return actorSystem.actorOf(TimeseriesIngestActor.propsForTest(adapter), thingId.toString()); + } + + private static List sampleBatch(final ThingId tid) { + return List.of( + TimeseriesDataPoint.of(tid, PATH, Instant.parse("2026-01-01T00:00:00Z"), + JsonValue.of(21.5), 1L, Collections.emptyMap(), "Cel"), + TimeseriesDataPoint.of(tid, PATH, Instant.parse("2026-01-01T00:00:30Z"), + JsonValue.of(21.6), 2L, Collections.emptyMap(), "Cel")); + } + + /** + * Test double for {@link TimeseriesAdapter}. Records every batch and lets the test + * seed (and clear) a synthetic backend failure for the failure-then-recover paths. + */ + private static final class RecordingAdapter implements TimeseriesAdapter { + + final List> writtenBatches = new ArrayList<>(); + private volatile Throwable cannedFailure = null; + private volatile Throwable cannedQueryFailure = null; + + RecordingAdapter failingWith(final Throwable t) { + cannedFailure = t; + return this; + } + + RecordingAdapter failingQueryWith(final Throwable t) { + cannedQueryFailure = t; + return this; + } + + void clearFailure() { + cannedFailure = null; + } + + @Override + public CompletionStage initialize(final TimeseriesAdapterConfig config) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public HealthStatus getHealth() { + return HealthStatus.UP; + } + + @Override + public CompletionStage write(final TimeseriesDataPoint dataPoint) { + return writeBatch(List.of(dataPoint)); + } + + @Override + public CompletionStage writeBatch(final List dataPoints) { + synchronized (writtenBatches) { + writtenBatches.add(new ArrayList<>(dataPoints)); + } + if (cannedFailure != null) { + final CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(cannedFailure); + return future; + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage> query(final TimeseriesQuery query) { + if (cannedQueryFailure != null) { + final CompletableFuture> future = new CompletableFuture<>(); + future.completeExceptionally(cannedQueryFailure); + return future; + } + return CompletableFuture.completedFuture(Collections.emptyList()); + } + } +} diff --git a/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesServiceStarterTest.java b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesServiceStarterTest.java new file mode 100644 index 00000000000..889a1638f6b --- /dev/null +++ b/timeseries/service/src/test/java/org/eclipse/ditto/timeseries/service/starter/TimeseriesServiceStarterTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.timeseries.service.starter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.pekko.actor.Props; +import org.eclipse.ditto.timeseries.api.TimeseriesMessagingConstants; +import org.junit.Test; + +/** + * Smoke tests for the Timeseries service starter classes — verifying that the constants and + * Props factory exposed by the runtime are wired to the api-side messaging constants. + */ +public final class TimeseriesServiceStarterTest { + + @Test + public void serviceNameMatchesMessagingConstant() { + assertThat(TimeseriesService.SERVICE_NAME) + .isEqualTo(TimeseriesMessagingConstants.SERVICE_NAME); + } + + @Test + public void rootActorNameMatchesMessagingConstant() { + assertThat(TimeseriesRootActor.ACTOR_NAME) + .isEqualTo(TimeseriesMessagingConstants.ROOT_ACTOR_NAME); + } + + @Test + public void rootActorPropsHaveExpectedActorClass() { + final Props props = TimeseriesRootActor.props(null, null); + + assertThat(props.actorClass().getName()).isEqualTo(TimeseriesRootActor.class.getName()); + } +} diff --git a/timeseries/service/src/test/resources/test.conf b/timeseries/service/src/test/resources/test.conf new file mode 100644 index 00000000000..218f323129e --- /dev/null +++ b/timeseries/service/src/test/resources/test.conf @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Eclipse Public License 2.0 which is available at +# http://www.eclipse.org/legal/epl-2.0 +# +# SPDX-License-Identifier: EPL-2.0 + +ditto { + mapping-strategy.implementation = "org.eclipse.ditto.timeseries.api.TimeseriesMappingStrategies" +} + +pekko { + loglevel = "WARNING" + loggers = ["org.apache.pekko.event.slf4j.Slf4jLogger"] + + actor { + provider = "local" + serializers { + cbor = "org.eclipse.ditto.internal.utils.cluster.CborJsonifiableSerializer" + } + serialization-bindings { + "org.eclipse.ditto.base.model.json.Jsonifiable" = cbor + } + } +} diff --git a/ui/main.ts b/ui/main.ts index 3f3e677a547..e1ed3f52c7e 100644 --- a/ui/main.ts +++ b/ui/main.ts @@ -45,6 +45,7 @@ import * as Things from './modules/things/things.js'; import * as ThingsCRUD from './modules/things/thingsCRUD.js'; import * as ThingsSearch from './modules/things/thingsSearch.js'; import * as ThingsSSE from './modules/things/thingsSSE.js'; +import * as Timeseries from './modules/things/timeseries.js'; import { SubDiff } from './modules/things/subDiff.js'; import { ThingsDiff } from './modules/things/thingsDiff.js'; import { WoTDescription } from './modules/things/wotDescription.js'; @@ -69,6 +70,7 @@ document.addEventListener('DOMContentLoaded', async function() { await Fields.ready(); await SearchFilter.ready(); Features.ready(); + Timeseries.ready(); await FeatureMessages.ready(); Policies.ready(); PoliciesJSON.ready(); diff --git a/ui/modules/things/features.html b/ui/modules/things/features.html index 52e4ee726ee..a1b10b1eb54 100644 --- a/ui/modules/things/features.html +++ b/ui/modules/things/features.html @@ -31,6 +31,9 @@

+
@@ -50,6 +53,75 @@
+
+
+ + +
+
+ + + + + + + +
+
+ + + + + + + + +
+
+ +
+
diff --git a/ui/modules/things/timeseries.ts b/ui/modules/things/timeseries.ts new file mode 100644 index 00000000000..370186c40dc --- /dev/null +++ b/ui/modules/things/timeseries.ts @@ -0,0 +1,456 @@ +/* eslint-disable require-jsdoc */ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import * as API from '../api.js'; +import * as Utils from '../utils.js'; +import * as Features from './features.js'; +import * as Things from './things.js'; + +/** + * Backing module for the "Timeseries" tab inside the feature pane. + *

+ * Wires up the tab's controls (path(s) / from / to / limit / aggregation / step / fill / + * percentile) and a Retrieve button. A single path fetches the first-class single-property + * resource {@code GET /api/2/timeseries/things//features//properties/

}; comma-separated + * paths fetch the multi-property endpoint {@code GET /api/2/timeseries/things/?paths=...}. + * Each returned series renders as its own stacked block (heading + hand-rolled SVG sparkline + + * (timestamp, value) table) inside a scrollable results region, so several paths don't fight over + * one chart and no chart library is pulled in. + *

+ * The path input is auto-prefilled with {@code /features//properties/} on + * feature selection so the typical case is a single keystroke ({@code temperature}, etc.). The + * from/to fields default to "the last 24h" relative to the user's clock — good enough for + * exploratory use; precise queries land in the URL. + */ + +const dom = { + inputTimeseriesPath: null as HTMLInputElement | null, + inputTimeseriesFrom: null as HTMLInputElement | null, + inputTimeseriesTo: null as HTMLInputElement | null, + inputTimeseriesLimit: null as HTMLInputElement | null, + selectTimeseriesAgg: null as HTMLSelectElement | null, + selectTimeseriesStep: null as HTMLSelectElement | null, + selectTimeseriesFill: null as HTMLSelectElement | null, + inputTimeseriesPercentile: null as HTMLInputElement | null, + labelTimeseriesPercentile: null as HTMLElement | null, + buttonTimeseriesRetrieve: null as HTMLButtonElement | null, + timeseriesStatus: null as HTMLElement | null, + timeseriesResults: null as HTMLElement | null, +}; + +/** Aggregations computed as one $group accumulator per bucket — these require a Step. */ +const GROUP_AGGREGATIONS = ['avg', 'min', 'max', 'sum', 'count', 'first', 'last', 'stddev']; + +/** Last feature the tab rendered for; used to drop stale series only on an actual context switch. */ +let lastFeatureId: string | null = null; + +interface TimeseriesPoint { + t: string; + v: unknown; + _gap?: boolean; +} + +interface TimeseriesSeries { + thingId: string; + path: string; + result?: { count?: number; unit?: string | null; dataType?: string | null }; + data?: TimeseriesPoint[]; +} + +export function ready(): void { + Utils.getAllElementsById(dom); + + // Defaults: from = now-24h, to = now. Datetime-local inputs use the local timezone for display + // but we read them back as if they were UTC (see toUtcIso), which is the convention the rest + // of the Ditto Protocol uses for time arguments. + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (dom.inputTimeseriesFrom && !dom.inputTimeseriesFrom.value) { + dom.inputTimeseriesFrom.value = toLocalInputValue(yesterday); + } + if (dom.inputTimeseriesTo && !dom.inputTimeseriesTo.value) { + dom.inputTimeseriesTo.value = toLocalInputValue(now); + } + + dom.buttonTimeseriesRetrieve?.addEventListener('click', onRetrieveClick); + + // Show the percentile input only when the percentile aggregation is selected, so the row stays + // uncluttered for every other function. + dom.selectTimeseriesAgg?.addEventListener('change', updatePercentileVisibility); + updatePercentileVisibility(); + + Features.addChangeListener(onFeatureChanged); + // Clear the previous thing's results when a different thing is selected — otherwise the + // sparkline + table + status text from the previous thing carry over and mislead the user + // until they click Retrieve again. The isNewThingId flag (second arg) is true only when the + // selected thingId actually changed, so an in-place refresh (e.g. after an edit) keeps the + // view in place. + Things.addChangeListener((_thing: unknown, isNewThingId: boolean) => { + if (isNewThingId) { + clearResults(); + setStatus(''); + } + }); +} + +function updatePercentileVisibility(): void { + const show = (dom.selectTimeseriesAgg?.value ?? '') === 'percentile'; + if (dom.labelTimeseriesPercentile) dom.labelTimeseriesPercentile.hidden = !show; + if (dom.inputTimeseriesPercentile) dom.inputTimeseriesPercentile.hidden = !show; +} + +function onFeatureChanged(featureId: string): void { + // Drop stale series only when the feature actually changes (incl. when a different Thing is + // selected) — NOT on every in-place refresh, so a live-updating graph the user is watching is + // not wiped each time new data arrives via SSE. + if (featureId !== lastFeatureId) { + lastFeatureId = featureId; + clearResults(); + setStatus(''); + } + if (!dom.inputTimeseriesPath) return; + const currentValue = dom.inputTimeseriesPath.value; + const looksLikePrefix = !currentValue || /^\/features\/[^/]*\/properties\/?$/.test(currentValue); + // Only auto-update when the field is empty or still holds an auto-generated prefix from the + // previous selection — never clobber a path the user has actually typed into. + if (looksLikePrefix) { + dom.inputTimeseriesPath.value = featureId ? `/features/${featureId}/properties/` : ''; + } +} + +async function onRetrieveClick(): Promise { + Utils.assert(Things.theThing, 'No Thing selected'); + const path = (dom.inputTimeseriesPath?.value ?? '').trim(); + const fromLocal = dom.inputTimeseriesFrom?.value ?? ''; + const toLocal = dom.inputTimeseriesTo?.value ?? ''; + const limit = (dom.inputTimeseriesLimit?.value ?? '').trim(); + const agg = (dom.selectTimeseriesAgg?.value ?? '').trim(); + const step = (dom.selectTimeseriesStep?.value ?? '').trim(); + const fill = (dom.selectTimeseriesFill?.value ?? '').trim(); + const percentile = (dom.inputTimeseriesPercentile?.value ?? '').trim(); + Utils.assert(path, 'Path must not be empty', dom.inputTimeseriesPath); + Utils.assert(fromLocal, 'From must not be empty', dom.inputTimeseriesFrom); + Utils.assert(toLocal, 'To must not be empty', dom.inputTimeseriesTo); + // Per-bucket aggregations are computed over Step-sized windows, so a Step is required for them; + // the advanced functions (derivative/rate/integral/percentile) also work on raw points. + Utils.assert(!(GROUP_AGGREGATIONS.includes(agg) && !step), + `Aggregation "${agg}" needs a Step (pick e.g. 1h).`, dom.selectTimeseriesStep); + Utils.assert(!(agg === 'percentile' && !percentile), + 'The percentile aggregation needs a percentile value (0-100).', dom.inputTimeseriesPercentile); + + // Comma-separated paths -> several series in one call (multi-property endpoint); a single path + // keeps the original single-property URL shape. Each path must be a full Ditto pointer. + const paths = path.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + for (const p of paths) { + Utils.assert(/^\/(features\/[^/]+\/properties|attributes)\/.+$/.test(p), + `Path "${p}" must look like /features//properties/ or /attributes/`, + dom.inputTimeseriesPath); + } + + const params = new URLSearchParams(); + params.append('from', toUtcIso(fromLocal)); + params.append('to', toUtcIso(toLocal)); + if (limit) { + params.append('limit', limit); + } + if (agg) { + params.append('agg', agg); + } + if (step) { + params.append('step', step); + } + if (fill) { + params.append('fill', fill); + } + if (agg === 'percentile' && percentile) { + params.append('percentile', percentile); + } + + const thingId = encodeURIComponent(Things.theThing.thingId); + const split = paths.length === 1 ? splitFullPath(paths[0]) : null; + let url: string; + if (split) { + // Single feature property -> first-class single-property resource. + url = `/timeseries/things/${thingId}/features/${encodeURIComponent(split.featureId)}` + + `/properties/${encodePointer(split.propertyPointer)}?${params.toString()}`; + } else { + // Multiple paths, or a single attribute path -> multi-property endpoint with ?paths=. + params.append('paths', paths.join(',')); + url = `/timeseries/things/${thingId}?${params.toString()}`; + } + + setStatus('Loading…'); + clearResults(); + + try { + const response = await API.callDittoREST('GET', url); + renderResults(response); + } catch (err) { + // callDittoREST already toasts the error via Utils.showError; we just blank the status. + setStatus(''); + } +} + +/** + * Splits a full Ditto pointer of the form /features/{featureId}/properties/{rest} into its parts. + * Returns null if the input doesn't have that shape — the caller surfaces a UI-side validation + * error rather than letting the gateway 400 on it. + */ +function splitFullPath(fullPath: string): { featureId: string; propertyPointer: string } | null { + const match = /^\/features\/([^/]+)\/properties\/(.+)$/.exec(fullPath); + if (!match) return null; + return { featureId: match[1], propertyPointer: match[2] }; +} + +/** + * Encodes a slash-separated property pointer for inclusion in a URL path. We percent-encode each + * segment individually so segment-level slashes survive (the gateway expects multi-segment property + * pointers like {@code temperature/avg}), then join with raw slashes. + */ +function encodePointer(pointer: string): string { + return pointer.split('/').map(encodeURIComponent).join('/'); +} + +function renderResults(response: unknown): void { + // The timeseries-service returns a JSON array of per-path series (one entry per requested path); + // tolerate both a bare array and a `{results: [...]}` envelope. Each series renders as its own + // stacked block (heading + sparkline + table) so multiple paths don't fight over one chart. + const seriesList: TimeseriesSeries[] = Array.isArray(response) + ? (response as TimeseriesSeries[]) + : (((response as { results?: TimeseriesSeries[] }).results) ?? []); + clearResults(); + if (seriesList.length === 0) { + setStatus('Empty response.'); + return; + } + let totalPoints = 0; + for (const series of seriesList) { + totalPoints += (series.data ?? []).length; + renderSeriesBlock(series); + } + setStatus(`${seriesList.length} series · ${totalPoints} point(s)`); +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function renderSeriesBlock(series: TimeseriesSeries): void { + if (!dom.timeseriesResults) return; + const points = series.data ?? []; + + const block = document.createElement('div'); + block.className = 'mb-2'; + + const heading = document.createElement('div'); + heading.className = 'small fw-semibold text-truncate'; + heading.textContent = series.path ?? '(unknown path)'; + heading.title = series.path ?? ''; + block.appendChild(heading); + + const meta = document.createElement('div'); + meta.className = 'text-muted small mb-1'; + const m = series.result; + meta.textContent = `${points.length} point(s)` + + (m?.unit ? ` · unit ${m.unit}` : '') + + (m?.dataType ? ` · type ${m.dataType}` : ''); + block.appendChild(meta); + + const svgWrap = document.createElement('div'); + svgWrap.className = 'border rounded p-1 mb-1'; + svgWrap.style.backgroundColor = 'var(--bs-body-bg)'; + const svg = document.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '120'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.setAttribute('viewBox', '0 0 600 120'); + svg.style.display = 'block'; + svgWrap.appendChild(svg); + block.appendChild(svgWrap); + + const tableWrap = document.createElement('div'); + tableWrap.className = 'table-wrap'; + // Keep each per-series table compact so several series' charts stay visible in the scroll + // region rather than one long table pushing the next chart out of view. + tableWrap.style.maxHeight = '140px'; + tableWrap.style.overflowY = 'auto'; + const table = document.createElement('table'); + table.className = 'table table-striped table-hover table-sm'; + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + const thT = document.createElement('th'); + thT.style.width = '50%'; + thT.textContent = 'Timestamp (UTC)'; + const thV = document.createElement('th'); + thV.textContent = 'Value'; + headRow.appendChild(thT); + headRow.appendChild(thV); + thead.appendChild(headRow); + const tbody = document.createElement('tbody'); + table.appendChild(thead); + table.appendChild(tbody); + tableWrap.appendChild(table); + block.appendChild(tableWrap); + + dom.timeseriesResults.appendChild(block); + + renderSparklineInto(svg, points); + renderTableInto(tbody, points); +} + +function renderTableInto(tbody: HTMLTableSectionElement, points: TimeseriesPoint[]): void { + // textContent on every cell — no innerHTML — so user-controlled values can't escape the cell. + const rows = points.map((p) => { + const tr = document.createElement('tr'); + const tdT = document.createElement('td'); + tdT.textContent = String(p.t); + const tdV = document.createElement('td'); + tdV.textContent = formatValue(p.v); + if (p._gap) { + // Gap-filled point (fill strategy): mute the row and tag it so it's distinguishable from a + // real observation. + tr.classList.add('text-muted', 'fst-italic'); + const badge = document.createElement('span'); + badge.className = 'badge bg-secondary ms-1'; + badge.textContent = 'gap'; + tdV.appendChild(document.createTextNode(' ')); + tdV.appendChild(badge); + } + tr.appendChild(tdT); + tr.appendChild(tdV); + return tr; + }); + tbody.replaceChildren(...rows); +} + +function renderSparklineInto(svg: SVGSVGElement, points: TimeseriesPoint[]): void { + // Inline SVG path — works for numeric data; non-numeric / gap (null) points produce NaN, which + // we filter out. ViewBox is fixed at 600x120 and the SVG uses preserveAspectRatio="none" so it + // stretches to the container. + const numeric: { t: number; v: number }[] = points + .map((p) => ({ t: Date.parse(String(p.t)), v: Number(p.v) })) + .filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v)); + svg.replaceChildren(); + if (numeric.length === 0) { + return; + } + if (numeric.length === 1) { + // A single aggregated value (e.g. one downsample bucket) can't form a line — draw a labelled + // dot so the panel isn't blank and the user still sees the value plotted. + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', '300'); + circle.setAttribute('cy', '60'); + circle.setAttribute('r', '3'); + circle.setAttribute('fill', 'var(--bs-primary, #0d6efd)'); + svg.appendChild(circle); + const lbl = document.createElementNS(SVG_NS, 'text'); + lbl.setAttribute('x', '309'); + lbl.setAttribute('y', '63'); + lbl.setAttribute('font-size', '9'); + lbl.setAttribute('fill', 'currentColor'); + lbl.textContent = formatNumeric(numeric[0].v); + svg.appendChild(lbl); + return; + } + const minT = numeric[0].t; + const maxT = numeric[numeric.length - 1].t; + let minV = Infinity; + let maxV = -Infinity; + for (const p of numeric) { + if (p.v < minV) minV = p.v; + if (p.v > maxV) maxV = p.v; + } + const xSpan = maxT - minT || 1; + const ySpan = maxV - minV || 1; + const w = 600; + const h = 120; + const padX = 4; + const padY = 8; + const innerW = w - 2 * padX; + const innerH = h - 2 * padY; + const d = numeric.map((p, i) => { + const x = padX + ((p.t - minT) / xSpan) * innerW; + const y = padY + innerH - ((p.v - minV) / ySpan) * innerH; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', 'var(--bs-primary, #0d6efd)'); + path.setAttribute('stroke-width', '1.5'); + svg.appendChild(path); + // Min/max value labels at top-left and bottom-left so the user can read the range without + // adding axis ticks (which would balloon scope for what's meant to be a sparkline). + const lblMax = document.createElementNS(SVG_NS, 'text'); + lblMax.setAttribute('x', String(padX)); + lblMax.setAttribute('y', String(padY + 4)); + lblMax.setAttribute('font-size', '9'); + lblMax.setAttribute('fill', 'currentColor'); + lblMax.textContent = formatNumeric(maxV); + svg.appendChild(lblMax); + const lblMin = document.createElementNS(SVG_NS, 'text'); + lblMin.setAttribute('x', String(padX)); + lblMin.setAttribute('y', String(h - padY)); + lblMin.setAttribute('font-size', '9'); + lblMin.setAttribute('fill', 'currentColor'); + lblMin.textContent = formatNumeric(minV); + svg.appendChild(lblMin); +} + +function clearResults(): void { + if (dom.timeseriesResults) dom.timeseriesResults.replaceChildren(); +} + +function setStatus(text: string): void { + if (dom.timeseriesStatus) dom.timeseriesStatus.textContent = text; +} + +function formatValue(v: unknown): string { + if (v === null || v === undefined) return ''; + if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function formatNumeric(n: number): string { + // Three significant digits is enough for a sparkline label without overlapping the line. + if (!Number.isFinite(n)) return ''; + const abs = Math.abs(n); + if (abs >= 1000 || abs < 0.01) return n.toExponential(2); + return n.toFixed(2); +} + +/** + * Converts a value from a {@code } (e.g. "2026-05-08T10:00") into a + * UTC ISO-8601 instant ("2026-05-08T10:00:00Z"). The browser stores datetime-local in the user's + * local clock, but we want the wire value to be UTC — so we treat the input as if the wall-clock + * time the user typed was already UTC. This matches how operators usually think about + * timeseries windows ("show me 10:00–11:00 UTC") and keeps the conversion logic-free. + */ +function toUtcIso(local: string): string { + if (!local) return ''; + const trimmed = local.length === 16 ? `${local}:00` : local; + return trimmed.endsWith('Z') ? trimmed : `${trimmed}Z`; +} + +function toLocalInputValue(date: Date): string { + // datetime-local expects "YYYY-MM-DDTHH:MM" without seconds/zone — but with step="1" we keep + // seconds for precision. Render as if the date's UTC components were the local clock so the + // user sees / writes UTC consistently across the form. + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; +}