entityIds) {
+ for (int i = 0; i < entityIds.size(); i++) {
+ EntityId entityId = Objects.requireNonNull(entityIds.get(i), "entityId");
+ if (i > 0) {
+ sql.append(" OR ");
+ }
+ sql.append("(entity_type=")
+ .append(sqlString(entityId.getEntityType().name()))
+ .append(" AND entity_id=")
+ .append(sqlString(entityId.getId().toString()))
+ .append(")");
+ }
+ }
+
+ private String buildKeysByTenantSql(TenantId tenantId) {
+ return "SELECT DISTINCT key FROM "
+ + TABLE_NAME
+ + " WHERE tenant_id="
+ + sqlString(tenantId.getId().toString())
+ + " ORDER BY key";
+ }
+
+ private String buildScopeKeyByEntitySql(TenantId tenantId, EntityId entityId) {
+ return "SELECT DISTINCT attribute_scope, key FROM "
+ + TABLE_NAME
+ + " WHERE "
+ + entityPredicate(tenantId, entityId)
+ + " ORDER BY attribute_scope, key";
+ }
+
+ private String buildDeleteByEntitySql(TenantId tenantId, EntityId entityId) {
+ return "DELETE FROM " + TABLE_NAME + " WHERE " + entityPredicate(tenantId, entityId);
+ }
+
+ private String identityPredicate(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope, String key) {
+ return scopePredicate(tenantId, entityId, attributeScope) + " AND key=" + sqlString(key);
+ }
+
+ private String scopePredicate(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope) {
+ return entityPredicate(tenantId, entityId)
+ + " AND attribute_scope="
+ + sqlString(attributeScope.name());
+ }
+
+ // ---- mapping + helpers ----
+
+ private Lock identityLock(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope, String key) {
+ return identityLocks.get(
+ tenantId.getId().toString()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getEntityType().name()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getId().toString()
+ + LOCK_KEY_SEPARATOR
+ + attributeScope.name()
+ + LOCK_KEY_SEPARATOR
+ + key);
+ }
+
+ private ReadWriteLock entityLock(TenantId tenantId, EntityId entityId) {
+ return entityLocks.get(
+ tenantId.getId().toString()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getEntityType().name()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getId().toString());
+ }
+
+ private static String requireKey(String key) {
+ return requireKey(key, "Attribute");
+ }
+
+ private static void requireClusterModeAcknowledged(String clusterMode) {
+ String mode = clusterMode == null ? null : clusterMode.trim();
+ if (mode == null || (!mode.equals("sticky-routing") && !mode.equals("disabled"))) {
+ throw new IllegalStateException(
+ "iotdb.attributes.cluster_mode must be explicitly set to 'sticky-routing' or 'disabled' "
+ + "when the IoTDB Table Mode attribute DAO is active; got '"
+ + clusterMode
+ + "'. The attribute write path (delete-then-insert under a per-identity in-JVM lock) "
+ + "converges only within a single JVM, so cross-node single-writer safety must be "
+ + "acknowledged.");
+ }
+ }
+
+ // ---- bounded IO executor (shared base mechanism; iotdb.attributes.executor.*) ----
+
+ @Override
+ public void destroy() {
+ if (!destroyed.compareAndSet(false, true)) {
+ return;
+ }
+ shutdownReadExecutor();
}
}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
new file mode 100644
index 00000000..50dc5155
--- /dev/null
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+
+/**
+ * Enables the IoTDB Table Mode entity-attribute backend only when the ThingsBoard attribute
+ * selector {@code database.attributes.type=iotdb-table} is set (case-insensitively, trimmed).
+ *
+ * This selector is INDEPENDENT of {@code database.ts.type} / {@code database.ts_latest.type}:
+ * the attribute DAO routes separately from the time-series DAOs (a piggy-back on the timeseries
+ * selector was deliberately rejected). Because upstream ThingsBoard does not expose an {@code
+ * AttributesDao} selector yet, no real Phase-1 deployment sets {@code database.attributes.type};
+ * the property is therefore absent in practice, this condition returns false, the attribute bean is
+ * never instantiated, and attributes keep flowing to the host entity-DB {@code AttributesDao}. The
+ * DAO is thus inert by default and only activates when an operator opts in explicitly.
+ */
+final class IoTDBTableAttributesEnabledCondition implements Condition {
+ private static final String SELECTOR_PROPERTY = "database.attributes.type";
+ private static final String SELECTOR_VALUE = "iotdb-table";
+
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ String selector = context.getEnvironment().getProperty(SELECTOR_PROPERTY);
+ return selector != null && SELECTOR_VALUE.equalsIgnoreCase(selector.trim());
+ }
+}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableBaseDao.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableBaseDao.java
index cca44013..d28a6592 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableBaseDao.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableBaseDao.java
@@ -22,25 +22,235 @@
import org.apache.iotdb.isession.pool.ITableSessionPool;
import org.apache.iotdb.rpc.StatementExecutionException;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DoubleDataEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.LongDataEntry;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Base class for IoTDB Table Mode DAOs; not a Spring bean itself. Concrete DAOs declare
* {@code @Repository} and the activation conditional. Holds the shared {@code ITableSessionPool}
- * (wired via constructor injection) and provides type-mapping helpers used by concrete DAOs
- * (TimeseriesDao, LatestDao, AttributesDao, LabelDao).
+ * (wired via constructor injection) and provides type-mapping helpers, SQL-literal escaping,
+ * blank-key validation, entity predicates, and the shared bounded read/task executor used by
+ * concrete DAOs (TimeseriesDao, LatestDao, AttributesDao, LabelDao).
+ *
+ *
The concrete DAOs opt into the bounded executor by calling {@link #initReadExecutor} from
+ * their constructor and draining it via {@link #shutdownReadExecutor()} from {@code destroy()}; a
+ * DAO that only maps rows (or a bare base instance) leaves the executor unset.
*
- *
Strategy F keeps this class free of ThingsBoard imports and interface clauses; concrete DAOs
- * bind to the ThingsBoard SPI types directly.
+ *
Shared read/write executor caveat. A DAO that runs BOTH reads and writes through this
+ * single bounded executor (the latest overlay does: {@code saveLatest}/{@code removeLatest} and the
+ * dashboard {@code findLatest}/{@code findAllLatest} share it) can have a write burst that fills
+ * the queue reject a concurrent read with an {@link IoTDBTableReadQueueFullException}. The
+ * rejection message names this explicitly rather than splitting the executors; size the queue
+ * capacity for the combined read + write load.
*/
@Slf4j
public class IoTDBTableBaseDao {
protected final ITableSessionPool tableSessionPool;
+ // Shared bounded read/task executor, initialized lazily by initReadExecutor() so a base instance
+ // (or a read-only mapper DAO) that never calls it carries no executor. accepting/destroyed are
+ // protected so a subclass can gate its own enqueue/shutdown fast-paths (e.g. save()).
+ private ThreadPoolExecutor readExecutor;
+ private final Set> readTasks = ConcurrentHashMap.newKeySet();
+ protected final AtomicBoolean accepting = new AtomicBoolean(true);
+ protected final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private long shutdownDrainTimeoutMs;
+ private String queueFullMessage;
+ private String shuttingDownMessage;
+
public IoTDBTableBaseDao(ITableSessionPool tableSessionPool) {
this.tableSessionPool = tableSessionPool;
}
+ /**
+ * Initializes the shared bounded read/task executor: a fixed-size {@link ThreadPoolExecutor}
+ * backed by a bounded {@link ArrayBlockingQueue} with an {@link ThreadPoolExecutor.AbortPolicy}
+ * rejection policy, so a saturated queue fails a submitted task fast rather than growing without
+ * bound. Called by concrete DAOs from their constructor.
+ *
+ * @param threads fixed core/max worker count
+ * @param queueCapacity bounded task-queue capacity
+ * @param shutdownDrainTimeoutMs how long {@link #shutdownReadExecutor()} waits for in-flight
+ * tasks
+ * @param threadNamePrefix worker thread-name prefix (a sequence number is appended)
+ * @param queueFullMessage message for the {@link IoTDBTableReadQueueFullException} on rejection.
+ * For a DAO that shares this executor between reads and writes (the latest overlay), it
+ * should make clear that write-path saturation can reject a read
+ * @param shuttingDownMessage message for the {@link IoTDBTableDaoShuttingDownException} raised
+ * once the DAO stops accepting work
+ */
+ protected void initReadExecutor(
+ int threads,
+ int queueCapacity,
+ long shutdownDrainTimeoutMs,
+ String threadNamePrefix,
+ String queueFullMessage,
+ String shuttingDownMessage) {
+ this.shutdownDrainTimeoutMs = shutdownDrainTimeoutMs;
+ this.queueFullMessage = Objects.requireNonNull(queueFullMessage, "queueFullMessage");
+ this.shuttingDownMessage = Objects.requireNonNull(shuttingDownMessage, "shuttingDownMessage");
+ this.readExecutor =
+ new ThreadPoolExecutor(
+ threads,
+ threads,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(queueCapacity),
+ readThreadFactory(threadNamePrefix),
+ new ThreadPoolExecutor.AbortPolicy());
+ }
+
+ /**
+ * Submits a task to the bounded read executor and returns a {@link ListenableFuture} for its
+ * result. Fails the future fast (never enqueues) once the DAO stops accepting work, and fails it
+ * with an {@link IoTDBTableReadQueueFullException} when the bounded queue is full. Mirrors the
+ * shutdown/queue-full races handled inline: a task the executor accepts but that races a
+ * concurrent shutdown is removed and failed, so no submitted task is left hanging.
+ */
+ protected ListenableFuture submitReadTask(Callable callable) {
+ if (!accepting.get()) {
+ return Futures.immediateFailedFuture(shuttingDownException());
+ }
+ ReadTask task = new ReadTask<>(callable);
+ readTasks.add(task);
+ try {
+ readExecutor.execute(task);
+ } catch (RejectedExecutionException e) {
+ if (!accepting.get() || readExecutor.isShutdown()) {
+ task.fail(shuttingDownException());
+ } else {
+ task.fail(new IoTDBTableReadQueueFullException(queueFullMessage, e));
+ }
+ readTasks.remove(task);
+ return task.future();
+ }
+ if (!accepting.get() && readExecutor.remove(task)) {
+ task.fail(shuttingDownException());
+ readTasks.remove(task);
+ }
+ return task.future();
+ }
+
+ /**
+ * Stops accepting new work and drains the read executor: dropped queued tasks and any still
+ * in-flight after the drain timeout are failed with the shutting-down exception. Idempotent guard
+ * ({@link #destroyed}) is the caller's responsibility (each DAO's {@code destroy()} runs it
+ * once).
+ */
+ protected void shutdownReadExecutor() {
+ accepting.set(false);
+ IoTDBTableDaoShuttingDownException failure = shuttingDownException();
+ for (Runnable dropped : readExecutor.shutdownNow()) {
+ failDroppedReadTask(dropped, failure);
+ }
+ try {
+ readExecutor.awaitTermination(shutdownDrainTimeoutMs, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ for (ReadTask> task : readTasks) {
+ task.fail(failure);
+ }
+ }
+
+ private void failDroppedReadTask(Runnable dropped, IoTDBTableDaoShuttingDownException failure) {
+ if (dropped instanceof ReadTask> task) {
+ task.fail(failure);
+ readTasks.remove(task);
+ }
+ }
+
+ protected IoTDBTableDaoShuttingDownException shuttingDownException() {
+ return new IoTDBTableDaoShuttingDownException(shuttingDownMessage);
+ }
+
+ private static ThreadFactory readThreadFactory(String threadNamePrefix) {
+ AtomicInteger sequence = new AtomicInteger();
+ return runnable -> {
+ Thread thread = new Thread(runnable, threadNamePrefix + sequence.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ };
+ }
+
+ /**
+ * Single-quote SQL string literal. This relies on the IoTDB Table-Mode STRING-literal grammar
+ * (RelationalSql.g4): the single quote is the only special character and is escaped by doubling
+ * it (''), with NO backslash escaping. If a future IoTDB lexer adds backslash/other escapes, this
+ * doubling alone would no longer be sufficient and must be revisited.
+ */
+ protected static String sqlString(String value) {
+ return "'" + Objects.requireNonNull(value, "value").replace("'", "''") + "'";
+ }
+
+ /** Rejects a null/blank telemetry key, mirroring each DAO's fail-fast contract. */
+ protected static String requireTelemetryKey(String key) {
+ return requireKey(key, "Telemetry");
+ }
+
+ /**
+ * Rejects a null/blank key, naming the subject ({@code "Telemetry"} / {@code "Attribute"}) in the
+ * message so each DAO keeps its own fail-fast wording.
+ */
+ protected static String requireKey(String key, String subject) {
+ if (key == null || key.trim().isEmpty()) {
+ throw new IllegalArgumentException(subject + " key must not be blank");
+ }
+ return key;
+ }
+
+ /** Full-identity WHERE fragment shared by the SQL builders (tenant + entity type + entity id). */
+ protected String entityPredicate(TenantId tenantId, EntityId entityId) {
+ return "tenant_id="
+ + sqlString(tenantId.getId().toString())
+ + " AND entity_type="
+ + sqlString(entityId.getEntityType().name())
+ + " AND entity_id="
+ + sqlString(entityId.getId().toString());
+ }
+
+ /** Maps a resolved single-typed telemetry value to the matching ThingsBoard {@link KvEntry}. */
+ protected KvEntry kvEntry(String key, TypedKvValue value) {
+ if (value.booleanValue() != null) {
+ return new BooleanDataEntry(key, value.booleanValue());
+ }
+ if (value.longValue() != null) {
+ return new LongDataEntry(key, value.longValue());
+ }
+ if (value.doubleValue() != null) {
+ return new DoubleDataEntry(key, value.doubleValue());
+ }
+ if (value.stringValue() != null) {
+ return new StringDataEntry(key, value.stringValue());
+ }
+ if (value.jsonValue() != null) {
+ return new JsonDataEntry(key, value.jsonValue());
+ }
+ throw new IllegalArgumentException("Row does not contain a typed value");
+ }
+
/**
* Maps a single IoTDB Table Mode telemetry row's 5 typed FIELD columns to a TypedKvValue. Exactly
* one typed value column may be non-null because the telemetry schema stores exactly one typed
@@ -88,4 +298,32 @@ public TypedKvValue getEntry(SessionDataSet.DataIterator row) throws StatementEx
}
return TypedKvValue.ofJson(row.getString("json_v"));
}
+
+ private final class ReadTask implements Runnable {
+ private final Callable callable;
+ private final SettableFuture future = SettableFuture.create();
+
+ private ReadTask(Callable callable) {
+ this.callable = Objects.requireNonNull(callable, "callable");
+ }
+
+ @Override
+ public void run() {
+ try {
+ future.set(callable.call());
+ } catch (Throwable t) {
+ future.setException(t);
+ } finally {
+ readTasks.remove(this);
+ }
+ }
+
+ private ListenableFuture future() {
+ return future;
+ }
+
+ private void fail(Throwable t) {
+ future.setException(t);
+ }
+ }
}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfig.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfig.java
index 19fe1a5f..2155f0c4 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfig.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfig.java
@@ -77,6 +77,10 @@ public class IoTDBTableConfig {
@Valid private Ts ts = new Ts();
+ @Valid private Attributes attributes = new Attributes();
+
+ @Valid private TsLatest tsLatest = new TsLatest();
+
@Data
public static class Ts {
/**
@@ -90,6 +94,48 @@ public static class Ts {
@Valid private Read read = new Read();
}
+ /**
+ * Entity-attribute DAO configuration, bound from {@code iotdb.attributes.*}. Independent of
+ * {@code iotdb.ts.*} because the attribute DAO routes separately from the time-series DAOs.
+ */
+ @Data
+ public static class Attributes {
+ /**
+ * Cluster routing acknowledgement for the entity-attribute DAO. The IoTDB Table Mode attribute
+ * write path is delete-then-insert under a per-identity in-JVM lock, which converges only
+ * inside a single JVM; cross-node single-writer safety is the operator's responsibility. When
+ * the DAO is activated this must be set explicitly to one of {@code sticky-routing} (all writes
+ * for a given identity are pinned to one node) or {@code disabled} (single-node / acknowledged
+ * best-effort); any other value (including the empty default) fails fast at construction.
+ */
+ private String clusterMode = "";
+
+ /**
+ * IO-executor sizing for the attribute DAO, bound from {@code iotdb.attributes.executor.*}. The
+ * attribute DAO activates independently of the time-series selector, so it owns its own
+ * executor config rather than borrowing {@code iotdb.ts.read.*}. The defaults equal the {@code
+ * iotdb.ts.read.*} defaults, so behavior is unchanged unless an operator tunes them.
+ */
+ @Valid private Executor executor = new Executor();
+ }
+
+ /**
+ * Latest-telemetry overlay DAO configuration, bound from {@code iotdb.ts_latest.*}. Independent
+ * of the {@code Attributes} block so the latest overlay can be acknowledged on its own terms.
+ */
+ @Data
+ public static class TsLatest {
+ /**
+ * Cluster routing acknowledgement for the latest overlay DAO, symmetric with {@code
+ * iotdb.attributes.cluster_mode}. The overlay write path is delete-then-insert under a
+ * per-identity in-JVM lock, which converges only inside a single JVM; cross-node single-writer
+ * safety is the operator's responsibility. When the DAO is activated this must be set
+ * explicitly to one of {@code sticky-routing} or {@code disabled}; any other value (including
+ * the empty default) fails fast at construction.
+ */
+ private String clusterMode = "";
+ }
+
@Data
public static class Read {
@Min(1)
@@ -99,6 +145,20 @@ public static class Read {
private int queueCapacity = 10000;
}
+ /**
+ * Bounded IO-executor sizing (worker thread count + task-queue capacity), reused by DAO blocks
+ * that own their own executor. Defaults mirror {@link Read} so the shape and defaults match the
+ * {@code iotdb.ts.read.*} executor config.
+ */
+ @Data
+ public static class Executor {
+ @Min(1)
+ private int threads = 4;
+
+ @Min(1)
+ private int queueCapacity = 10000;
+ }
+
@Data
public static class Save {
@Min(1)
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
index c3977045..6384400d 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
@@ -68,8 +68,14 @@
public class IoTDBTableConfiguration {
static final String IOTDB_TABLE_SESSION_POOL_BEAN_NAME = "iotdbThingsboardTableSessionPool";
static final String IOTDB_TABLE_TIMESERIES_DAO_BEAN_NAME = "ioTDBTableTimeseriesDao";
+ static final String IOTDB_TABLE_LATEST_DAO_BEAN_NAME = "ioTDBTableLatestDao";
+ static final String IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME = "ioTDBTableAttributesDao";
static final String TIMESERIES_DAO_CLASS_NAME =
"org.thingsboard.server.dao.timeseries.TimeseriesDao";
+ static final String TIMESERIES_LATEST_DAO_CLASS_NAME =
+ "org.thingsboard.server.dao.timeseries.TimeseriesLatestDao";
+ static final String ATTRIBUTES_DAO_CLASS_NAME =
+ "org.thingsboard.server.dao.attributes.AttributesDao";
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = TIMESERIES_DAO_CLASS_NAME)
@@ -77,33 +83,16 @@ public class IoTDBTableConfiguration {
@EnableConfigurationProperties(IoTDBTableConfig.class)
static class EnabledRawOnlyConfiguration {
- // This module implements only the timeseries backend. The latest-telemetry
- // (database.ts_latest.type) and label (iotdb.labels.enabled) selectors are intentionally NOT
- // included here: those DAOs do not exist yet, so they must not spin up a session pool or schema
- // bootstrap for a backend that has not shipped. Those conditions return when the corresponding
- // DAOs are implemented.
+ // The session pool, writer and schema bootstrap are owned by the timeseries backend
+ // (database.ts.type=iotdb-table + iotdb.ts.experimental-raw-only). The derived-latest DAO
+ // (database.ts_latest.type) is registered below and REUSES this pool; it adds the latest
+ // selector on top of the timeseries selector (see IoTDBTableLatestEnabledCondition), so it can
+ // never activate without the writer that populates the telemetry table it reads. The label
+ // (iotdb.labels.enabled) selector returns when that DAO is implemented.
@Bean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME, destroyMethod = "close")
@ConditionalOnMissingBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
ITableSessionPool tableSessionPool(IoTDBTableConfig config) {
- String nodeUrl = config.getHost() + ":" + config.getPort();
- ITableSessionPool pool =
- new TableSessionPoolBuilder()
- .nodeUrls(List.of(nodeUrl))
- .user(config.getUsername())
- .password(config.getPassword())
- .database(config.getDatabase())
- .maxSize(config.getSessionPoolSize())
- .connectionTimeoutInMs(config.getConnectionTimeoutMs())
- .enableCompression(config.isEnableCompression())
- .build();
- log.info(
- "IoTDB Table Mode session pool initialized: nodeUrl={}, database={}, poolSize={}, compression={}, defaultTtlMs(storageAccountingOnly)={}",
- nodeUrl,
- config.getDatabase(),
- config.getSessionPoolSize(),
- config.isEnableCompression(),
- config.getDefaultTtlMs());
- return pool;
+ return buildSessionPool(config);
}
@Bean
@@ -137,6 +126,39 @@ IoTDBTableTimeseriesDao ioTDBTableTimeseriesDao(
return new IoTDBTableTimeseriesDao(tableSessionPool, timeseriesWriter, config);
}
+ /**
+ * Fails startup (only when the latest selector is on) if the IoTDB latest backend is enabled
+ * but a conflicting non-IoTDB {@code TimeseriesLatestDao} is present, mirroring the fail-fast
+ * behavior of {@link #timeseriesDaoConflictGuard()} for the historical DAO so the latest path
+ * does not silently back off to a different backend while the timeseries path runs on IoTDB.
+ */
+ @Bean
+ @ConditionalOnClass(name = TIMESERIES_LATEST_DAO_CLASS_NAME)
+ @Conditional(IoTDBTableLatestEnabledCondition.class)
+ static BeanFactoryPostProcessor timeseriesLatestDaoConflictGuard() {
+ return new TimeseriesLatestDaoConflictGuard();
+ }
+
+ /**
+ * Registers the derived-latest DAO. It is gated by {@link IoTDBTableLatestEnabledCondition}
+ * (the timeseries selector plus {@code database.ts_latest.type=iotdb-table}) so it only
+ * activates when the IoTDB writer that populates the telemetry table is also active, and reuses
+ * the module-owned named session pool. A conflicting host {@code TimeseriesLatestDao} fails
+ * startup fast via {@link #timeseriesLatestDaoConflictGuard()} rather than silently shadowing
+ * this DAO; the string-based missing-bean guard keeps auto-config metadata evaluation from
+ * loading ThingsBoard classes.
+ */
+ @Bean
+ @ConditionalOnClass(name = TIMESERIES_LATEST_DAO_CLASS_NAME)
+ @ConditionalOnBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+ @Conditional(IoTDBTableLatestEnabledCondition.class)
+ @ConditionalOnMissingBean(type = TIMESERIES_LATEST_DAO_CLASS_NAME)
+ IoTDBTableLatestDao ioTDBTableLatestDao(
+ @Qualifier(IOTDB_TABLE_SESSION_POOL_BEAN_NAME) ITableSessionPool tableSessionPool,
+ IoTDBTableConfig config) {
+ return new IoTDBTableLatestDao(tableSessionPool, config);
+ }
+
/**
* Idempotent startup schema bootstrap. Only registered when the IoTDB Table Mode backend is
* selected and explicitly enabled (same activation guard as the pool/DAO), the session pool
@@ -154,6 +176,128 @@ IoTDBTableSchemaBootstrap schemaBootstrap(
IoTDBTableConfig config) {
return new IoTDBTableSchemaBootstrap(tableSessionPool, config);
}
+
+ /**
+ * Second idempotent startup schema bootstrap that creates the {@code telemetry_latest} overlay
+ * table from {@code schema-iotdb-table-latest.sql}. It is registered ONLY when the
+ * derived-latest DAO is active (same {@link IoTDBTableLatestEnabledCondition} guard, the {@code
+ * TimeseriesLatestDao} class and the module pool are present) and {@code
+ * iotdb.schema.bootstrap} is not disabled. It deliberately carries NO
+ * {@code @ConditionalOnMissingBean} so it always runs alongside {@link #schemaBootstrap()} (a
+ * distinct bean name); both resources are self-contained ({@code CREATE DATABASE IF NOT EXISTS}
+ * + {@code USE} + {@code CREATE TABLE IF NOT EXISTS}), so the two bootstrap beans are
+ * order-independent and idempotent. When the latest selector is off, the overlay table is never
+ * created (latest path stays inert).
+ */
+ @Bean
+ @ConditionalOnClass(name = TIMESERIES_LATEST_DAO_CLASS_NAME)
+ @ConditionalOnBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+ @Conditional(IoTDBTableLatestEnabledCondition.class)
+ @ConditionalOnProperty(
+ name = "iotdb.schema.bootstrap",
+ havingValue = "true",
+ matchIfMissing = true)
+ IoTDBTableSchemaBootstrap latestSchemaBootstrap(
+ @Qualifier(IOTDB_TABLE_SESSION_POOL_BEAN_NAME) ITableSessionPool tableSessionPool,
+ IoTDBTableConfig config) {
+ return new IoTDBTableSchemaBootstrap(
+ tableSessionPool, config, IoTDBTableSchemaBootstrap.LATEST_SCHEMA_RESOURCE);
+ }
+ }
+
+ /**
+ * Entity-attribute backend, activated INDEPENDENTLY by {@code
+ * database.attributes.type=iotdb-table} (see {@link IoTDBTableAttributesEnabledCondition}). It is
+ * a separate inner configuration from {@link EnabledRawOnlyConfiguration} because the attribute
+ * DAO routes separately from the time-series DAOs: it must be able to activate on its own
+ * (attributes selector set, ts selectors unset) and must stay inert when no attributes selector
+ * is present. Because no shipped ThingsBoard release exposes {@code database.attributes.type},
+ * the default Phase-1 deployment leaves it unset, this configuration is skipped, no session pool
+ * or attribute bean is created, and attributes keep flowing to the host entity-DB {@code
+ * AttributesDao} (inert by default).
+ *
+ * The session pool / schema bootstrap beans here reuse the same bean name as {@link
+ * EnabledRawOnlyConfiguration} and carry {@code @ConditionalOnMissingBean(name=...)}, so when
+ * both the timeseries and attributes selectors are on exactly one shared pool/bootstrap is
+ * created; when only the attributes selector is on this configuration brings them up on its own.
+ */
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnClass(name = ATTRIBUTES_DAO_CLASS_NAME)
+ @Conditional(IoTDBTableAttributesEnabledCondition.class)
+ @EnableConfigurationProperties(IoTDBTableConfig.class)
+ static class EnabledAttributesConfiguration {
+
+ @Bean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME, destroyMethod = "close")
+ @ConditionalOnMissingBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+ ITableSessionPool tableSessionPool(IoTDBTableConfig config) {
+ return buildSessionPool(config);
+ }
+
+ /**
+ * Fails startup before any IoTDB pool/bootstrap singleton is created if the explicit IoTDB
+ * attribute backend selection conflicts with a host-provided {@code AttributesDao}, mirroring
+ * {@code timeseriesDaoConflictGuard()} so the attribute path does not silently shadow a
+ * different backend.
+ */
+ @Bean
+ static BeanFactoryPostProcessor attributesDaoConflictGuard() {
+ return new AttributesDaoConflictGuard();
+ }
+
+ /**
+ * Registers the entity-attribute DAO. The bean name {@code ioTDBTableAttributesDao} matches the
+ * default component-scan name, the string-based missing-bean guard avoids loading ThingsBoard
+ * classes while evaluating auto-configuration metadata, and the {@code @Bean} destroy method
+ * drains the DAO's IO executor on shutdown.
+ */
+ @Bean(name = IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME, destroyMethod = "destroy")
+ @ConditionalOnBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+ @ConditionalOnMissingBean(type = ATTRIBUTES_DAO_CLASS_NAME)
+ IoTDBTableAttributesDao ioTDBTableAttributesDao(
+ @Qualifier(IOTDB_TABLE_SESSION_POOL_BEAN_NAME) ITableSessionPool tableSessionPool,
+ IoTDBTableConfig config) {
+ return new IoTDBTableAttributesDao(tableSessionPool, config);
+ }
+
+ /**
+ * Idempotent startup schema bootstrap for the attribute path. Shares the bean name with the
+ * timeseries configuration via {@code @ConditionalOnMissingBean}, so it only registers when the
+ * timeseries path has not already registered it.
+ */
+ @Bean
+ @ConditionalOnBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+ @ConditionalOnMissingBean(IoTDBTableSchemaBootstrap.class)
+ @ConditionalOnProperty(
+ name = "iotdb.schema.bootstrap",
+ havingValue = "true",
+ matchIfMissing = true)
+ IoTDBTableSchemaBootstrap attributesSchemaBootstrap(
+ @Qualifier(IOTDB_TABLE_SESSION_POOL_BEAN_NAME) ITableSessionPool tableSessionPool,
+ IoTDBTableConfig config) {
+ return new IoTDBTableSchemaBootstrap(tableSessionPool, config);
+ }
+ }
+
+ private static ITableSessionPool buildSessionPool(IoTDBTableConfig config) {
+ String nodeUrl = config.getHost() + ":" + config.getPort();
+ ITableSessionPool pool =
+ new TableSessionPoolBuilder()
+ .nodeUrls(List.of(nodeUrl))
+ .user(config.getUsername())
+ .password(config.getPassword())
+ .database(config.getDatabase())
+ .maxSize(config.getSessionPoolSize())
+ .connectionTimeoutInMs(config.getConnectionTimeoutMs())
+ .enableCompression(config.isEnableCompression())
+ .build();
+ log.info(
+ "IoTDB Table Mode session pool initialized: nodeUrl={}, database={}, poolSize={}, compression={}, defaultTtlMs(storageAccountingOnly)={}",
+ nodeUrl,
+ config.getDatabase(),
+ config.getSessionPoolSize(),
+ config.isEnableCompression(),
+ config.getDefaultTtlMs());
+ return pool;
}
private static final class TimeseriesDaoConflictGuard implements BeanFactoryPostProcessor {
@@ -207,4 +351,109 @@ private static Class> resolveTimeseriesDaoClass(ConfigurableListableBeanFactor
}
}
}
+
+ private static final class TimeseriesLatestDaoConflictGuard implements BeanFactoryPostProcessor {
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
+ throws BeansException {
+ Class> latestDaoType = resolveTimeseriesLatestDaoClass(beanFactory);
+ for (String beanName : beanFactory.getBeanNamesForType(latestDaoType, true, false)) {
+ if (!isIoTDBLatestDaoBean(beanFactory, beanName)) {
+ throw new IllegalStateException(
+ "database.ts_latest.type=iotdb-table with the IoTDB timeseries backend enabled, but a "
+ + "non-IoTDB TimeseriesLatestDao bean '"
+ + beanName
+ + "' is present; remove it or unset the IoTDB latest selector");
+ }
+ }
+ }
+
+ private static boolean isIoTDBLatestDaoBean(
+ ConfigurableListableBeanFactory beanFactory, String beanName) {
+ Class> beanType = resolveBeanType(beanFactory, beanName);
+ if (beanType == null) {
+ throw new IllegalStateException(
+ "database.ts_latest.type=iotdb-table with the IoTDB timeseries backend enabled, but "
+ + "TimeseriesLatestDao bean '"
+ + beanName
+ + "' has no resolvable type; expose a concrete IoTDBTableLatestDao type or "
+ + "remove the bean");
+ }
+ // beanType is guaranteed non-null here (the null case throws above).
+ return IoTDBTableLatestDao.class.isAssignableFrom(beanType);
+ }
+
+ private static Class> resolveBeanType(
+ ConfigurableListableBeanFactory beanFactory, String beanName) {
+ Class> beanType = beanFactory.getType(beanName, false);
+ if (beanType != null || !beanFactory.containsBeanDefinition(beanName)) {
+ return beanType;
+ }
+ BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
+ ResolvableType resolvableType = beanDefinition.getResolvableType();
+ return resolvableType == ResolvableType.NONE ? null : resolvableType.resolve();
+ }
+
+ private static Class> resolveTimeseriesLatestDaoClass(
+ ConfigurableListableBeanFactory beanFactory) {
+ try {
+ return ClassUtils.forName(
+ TIMESERIES_LATEST_DAO_CLASS_NAME, beanFactory.getBeanClassLoader());
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException(
+ "IoTDB Table Mode backend was enabled but TimeseriesLatestDao is not on the classpath",
+ e);
+ }
+ }
+ }
+
+ private static final class AttributesDaoConflictGuard implements BeanFactoryPostProcessor {
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
+ throws BeansException {
+ Class> attributesDaoType = resolveAttributesDaoClass(beanFactory);
+ for (String beanName : beanFactory.getBeanNamesForType(attributesDaoType, true, false)) {
+ if (!isIoTDBAttributesDaoBean(beanFactory, beanName)) {
+ throw new IllegalStateException(
+ "database.attributes.type=iotdb-table, but a non-IoTDB AttributesDao bean '"
+ + beanName
+ + "' is present; remove it or unset the IoTDB attributes selector");
+ }
+ }
+ }
+
+ private static boolean isIoTDBAttributesDaoBean(
+ ConfigurableListableBeanFactory beanFactory, String beanName) {
+ Class> beanType = resolveBeanType(beanFactory, beanName);
+ if (beanType == null) {
+ throw new IllegalStateException(
+ "database.attributes.type=iotdb-table, but AttributesDao bean '"
+ + beanName
+ + "' has no resolvable type; expose a concrete IoTDBTableAttributesDao type or "
+ + "remove the bean");
+ }
+ // beanType is guaranteed non-null here (the null case throws above).
+ return IoTDBTableAttributesDao.class.isAssignableFrom(beanType);
+ }
+
+ private static Class> resolveBeanType(
+ ConfigurableListableBeanFactory beanFactory, String beanName) {
+ Class> beanType = beanFactory.getType(beanName, false);
+ if (beanType != null || !beanFactory.containsBeanDefinition(beanName)) {
+ return beanType;
+ }
+ BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName);
+ ResolvableType resolvableType = beanDefinition.getResolvableType();
+ return resolvableType == ResolvableType.NONE ? null : resolvableType.resolve();
+ }
+
+ private static Class> resolveAttributesDaoClass(ConfigurableListableBeanFactory beanFactory) {
+ try {
+ return ClassUtils.forName(ATTRIBUTES_DAO_CLASS_NAME, beanFactory.getBeanClassLoader());
+ } catch (ClassNotFoundException e) {
+ throw new IllegalStateException(
+ "IoTDB Table Mode backend was enabled but AttributesDao is not on the classpath", e);
+ }
+ }
+ }
}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java
index 60b0c2b2..47865e1d 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java
@@ -18,25 +18,760 @@
package org.apache.iotdb.extras.thingsboard.table;
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
import org.apache.iotdb.isession.pool.ITableSessionPool;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.Striped;
import lombok.extern.slf4j.Slf4j;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.enums.TSDataType;
+import org.apache.tsfile.write.record.Tablet;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.stereotype.Repository;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.DataType;
+import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
+import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao;
+
+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 java.util.concurrent.locks.Lock;
/**
- * Latest telemetry DAO skeleton for the IoTDB Table Mode backend.
+ * Latest telemetry DAO for the IoTDB Table Mode backend.
+ *
+ *
Spring activation ({@link IoTDBTableLatestEnabledCondition}): inert unless ALL of {@code
+ * database.ts.type=iotdb-table}, {@code database.ts_latest.type=iotdb-table} and {@code
+ * iotdb.ts.experimental-raw-only=true} are set. Requiring {@code database.ts.type=iotdb-table} (not
+ * just the latest selector) closes the split-config gap: the derived latest reads the {@code
+ * telemetry} table that only the IoTDB writer populates, so it must never activate without that
+ * writer.
+ *
+ *
Design (derived latest + a minimal per-key latest overlay): the latest value for an entity/key
+ * is read from BOTH the historical {@code telemetry} table (derived) AND a small {@code
+ * telemetry_latest} overlay table, merged by the maximum timestamp per key (the overlay wins an
+ * exact tie). The derived read stays primary and is engine-accelerated by IoTDB Table Mode's native
+ * last cache ({@code ORDER BY time DESC LIMIT 1} and {@code LAST_BY(col, time)}); the overlay
+ * supplements it.
+ *
+ *
Why the overlay is written on EVERY {@code saveLatest}: the module's historical {@code
+ * save()} write path is ASYNCHRONOUS and batched, so at the moment {@code saveLatest} runs it
+ * cannot see whether a paired {@code telemetry} row will be written (a normal full-save) or not (a
+ * latest-only write, e.g. the EntityView telemetry-copy {@code LATEST_AND_WS} / {@code
+ * saveTs=false} path). It therefore cannot distinguish latest-only from full-save and writes the
+ * overlay on every save (delete-then-insert, one row per identity), except a backdated write
+ * strictly older than the current latest, which the max-ts guard skips. This closes the data-loss
+ * gap for latest-only writes that the earlier no-shadow-table design left open, at the cost of one
+ * extra overlay write per latest update (equivalent to the standard ThingsBoard latest-table
+ * behavior). The overlay is effectively a latest table, so this partially reverses the Phase-1 "no
+ * shadow table" choice.
+ *
+ *
+ * - {@link #saveLatest} performs a tag-only {@code DELETE} of the identity in {@code
+ * telemetry_latest} followed by an {@code INSERT} at {@code time = tsKvEntry.getTs()} with
+ * exactly one typed FIELD set, both under a per-identity in-JVM lock so concurrent
+ * same-identity writes converge to a single overlay row. It returns a {@code null} version.
+ *
- {@link #findLatestOpt}/{@link #findLatest}/{@link #findAllLatest} read the derived latest
+ * and the overlay and merge them by max timestamp per key (overlay wins on tie). The merge is
+ * not additive (max-by-ts), so a key present in both stores is never double-counted.
+ *
- {@link #removeLatest} reads derived and overlay separately under the per-identity lock;
+ * when the merged latest is inside the half-open {@code [startTs, endTs)} delete window it
+ * deletes the overlay row ONLY if the overlay's own ts is in-window (an out-of-window overlay
+ * value survives), and — if {@code rewriteLatestIfDeleted} is set — resurrects the next-older
+ * value across BOTH stores ({@code telemetry} where {@code time < startTs} and an overlay
+ * value older than the window; max-ts-wins) back into the overlay and returns it as {@code
+ * getData()} (so {@code onTimeSeriesDelete} emits a WS UPDATE rather than a DELETE).
+ *
+ *
+ * Residual Phase-1 limitations (documented):
*
- *
This class is intentionally not annotated as a Spring bean. The latest-telemetry SPI binding
- * is not implemented here, so registering it now would expose a {@code
- * database.ts_latest.type=iotdb-table} selector that ThingsBoard cannot bind to a working DAO.
+ *
+ * - {@code version} is always {@code null}. IoTDB has no SQL sequence (same as the
+ * Cassandra backend); type-correct and contract-legal, but the TB EDQS notifications that key
+ * off a non-null version are not driven in Phase-1.
+ *
- Telemetry-derived race residual. The overlay-backed value is race-free under the
+ * per-identity lock, but the historical {@code remove} runs as a SEPARATE future from {@code
+ * removeLatest}; a purely telemetry-derived (full-save) latest can transiently still be read
+ * from {@code telemetry} until that historical delete commits. Eventually consistent.
+ *
- Non-atomic overlay write (delete-then-insert). The overlay write deletes the
+ * identity then inserts the new row, and IoTDB has no multi-statement transaction.
+ * Delete-first is REQUIRED so a same-timestamp type change converges to one typed column (an
+ * insert at an existing {@code (tags, time)} merges columns), so the order cannot be reversed
+ * to insert-first. Consequently an {@code INSERT} failure after the {@code DELETE} commits
+ * loses a latest-only (overlay-only) value (a derived full-save value is still recoverable
+ * from {@code telemetry}); the {@code saveLatest} future fails loud and the caller retries.
+ *
- Telemetry-only writes (saveWithoutLatest) surface via the derived read. The latest
+ * is MAX-ts over derived {@code telemetry} and the overlay, so a history-only write ({@code
+ * saveWithoutLatest} / "skip latest persistence") that bumps {@code telemetry} above the last
+ * {@code saveLatest} surfaces as the latest even though no {@code saveLatest} ran. Making the
+ * overlay strictly authoritative when present is an option (not done in Phase-1 to keep the
+ * engine-accelerated derived read primary).
+ *
- removeLatest honesty + overlapping deletes. {@code removeLatest} deletes only an
+ * overlay row whose OWN ts is inside the delete window and (on rewrite) resurrects the
+ * next-older value across BOTH stores, so an out-of-window overlay value is never wiped.
+ * {@code removed=true} still reflects the in-window latest being removed even when that value
+ * is derived-only (deleted by the separate historical future); and a value resurrected by one
+ * rewrite delete that a SECOND overlapping concurrent delete window also covers can
+ * transiently persist in the overlay until the next write. Eventually consistent.
+ *
- Overlay growth. {@code telemetry_latest} is {@code TTL='INF'} with no entity-level
+ * cleanup, so under unbounded key cardinality it grows without bound (one row per identity;
+ * bounded for normal key sets).
+ *
- Single-JVM overlay convergence. The per-identity lock serializing overlay writes is
+ * in-JVM (Striped), so the delete-then-insert converges to one row per identity only within a
+ * node. In a clustered ThingsBoard deployment two nodes writing the same identity
+ * concurrently can transiently leave two overlay rows until the next write (the max-ts read
+ * merge still returns the newer value). This is gated by an explicit {@code
+ * iotdb.ts_latest.cluster_mode} acknowledgement at construction, symmetric with the sibling
+ * AttributesDao's {@code iotdb.attributes.cluster_mode} gate: the operator must set it to
+ * {@code sticky-routing} or {@code disabled} so the cross-node single-writer decision is made
+ * deliberately.
+ *
- Same-timestamp cross-store type change. The overlay wins an exact-ts tie, continuing
+ * the documented same-timestamp two-type-column limitation (B1).
+ *
*
- * Strategy F keeps this class free of ThingsBoard imports and interface clauses until the
- * latest-telemetry path is implemented.
+ *
The key-discovery SPI methods ({@link #findAllKeysByEntityIds}, {@link
+ * #findAllKeysByEntityIdsAsync}; full derived discovery is a follow-up) and the batch {@link
+ * #findLatestByEntityIds}/{@link #findLatestByEntityIdsAsync} pair (new in ThingsBoard v4.3.1.2;
+ * derived batch read is a follow-up) return an empty list rather than throwing, because they are
+ * reachable in normal operation — {@code findAllKeysByEntityIdsAsync}/{@code
+ * findLatestByEntityIdsAsync} back the dashboard {@code POST /api/entitiesQuery/find/keys} lookup
+ * (DefaultEntityQueryService#fetchTimeseriesKeys), and the sync {@code findAllKeysByEntityIds}
+ * backs entity-delete housekeeping — where a thrown {@link UnsupportedOperationException} would
+ * surface as an HTTP 500 / a failed cleanup task. This matches the official {@code
+ * CassandraBaseTimeseriesLatestDao}, which returns empty for all four, and {@link
+ * #findAllKeysByDeviceProfileId}.
*/
@Slf4j
-public class IoTDBTableLatestDao extends IoTDBTableBaseDao {
- // Not annotated @Repository yet: auto-registering it now would advertise
- // database.ts_latest.type=iotdb-table with no working DAO behind it.
- public IoTDBTableLatestDao(ITableSessionPool tableSessionPool) {
+@Repository
+@ConditionalOnBean(name = IoTDBTableConfiguration.IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
+@Conditional(IoTDBTableLatestEnabledCondition.class)
+public class IoTDBTableLatestDao extends IoTDBTableBaseDao
+ implements TimeseriesLatestDao, DisposableBean {
+ private static final String TABLE_NAME = IoTDBTableTimeseriesWriter.TABLE_NAME;
+ static final String TABLE_LATEST = "telemetry_latest";
+ private static final String SELECT_TYPED_COLUMNS =
+ "time, bool_v, long_v, double_v, str_v, json_v";
+
+ // NUL is the identity-lock key separator: it cannot appear in any tenant/entity UUID,
+ // entity-type enum name, or telemetry key, so distinct identities can never collide into the same
+ // Striped lock stripe by string concatenation.
+ private static final char LOCK_KEY_SEPARATOR = '\u0000';
+
+ // The three parallel arrays below follow the telemetry_latest DDL tag order
+ // (schema-iotdb-table-latest.sql): entity_type, tenant_id, key, entity_id (TAGs), then bool_v,
+ // long_v, double_v, str_v, json_v (FIELDs) — the SAME shape as the historical telemetry table, so
+ // getEntry()/kvEntry() row mapping is reused. They must stay positionally aligned and cover
+ // exactly the 9 non-time columns; the `time TIMESTAMP TIME` column is written through
+ // Tablet#addTimestamp (NOT a ColumnCategory.TIME entry). Rebuilding with a different tag order is
+ // a correctness bug (TAG-order rot).
+ private static final List COLUMN_NAMES =
+ List.of(
+ "entity_type",
+ "tenant_id",
+ "key",
+ "entity_id",
+ "bool_v",
+ "long_v",
+ "double_v",
+ "str_v",
+ "json_v");
+ private static final List DATA_TYPES =
+ List.of(
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.BOOLEAN,
+ TSDataType.INT64,
+ TSDataType.DOUBLE,
+ TSDataType.STRING,
+ TSDataType.TEXT);
+ private static final List COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ // Per-identity write/snapshot serialization for the overlay (single-JVM convergence). saveLatest,
+ // removeLatest and the single-key point reads (findLatest/findLatestOpt) take it; only the
+ // multi-key findAllLatest stays best-effort/unlocked like the derived reads.
+ private final Striped identityLocks = Striped.lock(256);
+
+ public IoTDBTableLatestDao(ITableSessionPool tableSessionPool, IoTDBTableConfig config) {
super(tableSessionPool);
+ // Cluster opt-in validator, symmetric with the sibling IoTDBTableAttributesDao: when the latest
+ // overlay is active the operator must acknowledge cluster routing explicitly, because the
+ // overlay delete-then-insert write path serializes on a per-identity in-JVM Striped lock that
+ // converges only within a single JVM. The race is benign (two nodes writing the same identity
+ // can transiently leave two overlay rows, but the max-ts read merge still returns the newer
+ // value, and a B1 two-type overlay row self-heals via the B1-lenient guard/remove reads), but
+ // it is acknowledged for parity so an operator makes the cross-node single-writer decision
+ // deliberately. Fail fast at construction on an absent/invalid value.
+ requireClusterModeAcknowledged(config.getTsLatest().getClusterMode());
+ // The overlay write path (saveLatest/removeLatest) shares this SAME bounded read executor and
+ // queue (sized by iotdb.ts.read.*) with the dashboard findLatest/findAllLatest reads, so a
+ // write
+ // burst that fills the queue can reject a concurrent read; the rejection message names this
+ // rather than splitting the executors. See the base initReadExecutor javadoc.
+ initReadExecutor(
+ config.getTs().getRead().getThreads(),
+ config.getTs().getRead().getQueueCapacity(),
+ config.getTs().getSave().getShutdownDrainTimeoutMs(),
+ "iotdb-table-latest-read-worker-",
+ "IoTDB Table Mode latest queue is full (the latest overlay write path shares this "
+ + "read-configured executor/queue, so a saveLatest/removeLatest write burst can reject a "
+ + "concurrent findLatest/findAllLatest read)",
+ "IoTDB Table Mode latest DAO is shutting down");
+ }
+
+ @Override
+ public ListenableFuture> findLatestOpt(
+ TenantId tenantId, EntityId entityId, String key) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ String telemetryKey = requireTelemetryKey(key);
+ return submitReadTask(() -> lockedFindLatest(tenantId, entityId, telemetryKey));
+ }
+
+ @Override
+ public ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ String telemetryKey = requireTelemetryKey(key);
+ return submitReadTask(
+ () ->
+ lockedFindLatest(tenantId, entityId, telemetryKey)
+ .orElseGet(() -> nullEntry(telemetryKey)));
+ }
+
+ @Override
+ public ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ return submitReadTask(() -> doFindAllLatest(tenantId, entityId));
+ }
+
+ @Override
+ public ListenableFuture saveLatest(
+ TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ Objects.requireNonNull(tsKvEntry, "tsKvEntry");
+ String key = requireTelemetryKey(tsKvEntry.getKey());
+ // The async/batched historical save() cannot be observed here, so saveLatest cannot tell a
+ // latest-only write from a full-save and writes the per-key overlay (delete-then-insert under a
+ // per-identity lock) on every call EXCEPT when a strictly newer latest already exists (max-ts
+ // guard below). This closes the latest-only data-loss gap (e.g. EntityView LATEST_AND_WS /
+ // saveTs=false) that a no-shadow-table no-op would drop. See the class javadoc (overlay
+ // rationale).
+ // Executor note: this write runs on the SAME bounded read executor/queue (iotdb.ts.read.*) as
+ // the dashboard findLatest/findAllLatest reads, so a saveLatest/removeLatest write burst that
+ // fills the queue can reject a concurrent read with IoTDBTableReadQueueFullException. The
+ // executors are deliberately shared (not split); size the read queue for the combined load.
+ Lock lock = identityLock(tenantId, entityId, key);
+ return submitReadTask(
+ () -> {
+ lock.lock();
+ try {
+ // Max-ts-wins, not last-write-wins: skip a backdated saveLatest so an out-of-order
+ // write never regresses the latest below a newer value already stored (matches the
+ // ThingsBoard ts_kv_latest default ON CONFLICT ... WHERE ts <= excluded.ts). Strict '>'
+ // keeps the documented overlay-wins-on-exact-tie (B1) rule. saveLatest already holds
+ // the
+ // identity lock, so the snapshot reads run directly (no re-lock); currentLatestForGuard
+ // is the B1-lenient merge so an ambiguous derived row never blocks every saveLatest.
+ Optional current = currentLatestForGuard(tenantId, entityId, key);
+ if (current.isEmpty() || current.get().getTs() <= tsKvEntry.getTs()) {
+ upsertOverlay(tenantId, entityId, tsKvEntry, key);
+ }
+ } finally {
+ lock.unlock();
+ }
+ // IoTDB has no sequence; Phase-1 returns a null version (see class javadoc).
+ return null;
+ });
+ }
+
+ @Override
+ public ListenableFuture removeLatest(
+ TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ Objects.requireNonNull(query, "query");
+ String telemetryKey = requireTelemetryKey(query.getKey());
+ // Overlay-aware remove under the per-identity lock: snapshot the merged latest, and when it is
+ // inside the half-open [startTs, endTs) delete window delete the overlay row (and optionally
+ // resurrect the next-older historical value into the overlay). The result is HONEST about
+ // whether a latest value was actually affected, because TB consumes isRemoved() / getData() as
+ // real delete/update signals (DefaultTelemetrySubscriptionService.onTimeSeriesDelete).
+ Lock lock = identityLock(tenantId, entityId, telemetryKey);
+ return submitReadTask(
+ () -> {
+ lock.lock();
+ try {
+ return doRemoveLatest(tenantId, entityId, query, telemetryKey);
+ } finally {
+ lock.unlock();
+ }
+ });
+ }
+
+ @Override
+ public List findAllKeysByDeviceProfileId(
+ TenantId tenantId, DeviceProfileId deviceProfileId) {
+ // Config-time UI key enumeration (GET /api/deviceProfile/devices/keys/timeseries,
+ // TENANT_ADMIN). Return empty rather than throwing so the endpoint degrades gracefully instead
+ // of returning 500 — matching the sibling IoTDBTableAttributesDao and the non-relational
+ // ThingsBoard backends (e.g. CassandraBaseTimeseriesLatestDao.findAllKeysByDeviceProfileId
+ // returns an empty list). Tenant-wide DISTINCT-key discovery (the null-deviceProfileId branch)
+ // is a follow-up.
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) {
+ // Reachable in normal operation (entity-delete housekeeping, TelemetryDeletionTaskProcessor),
+ // so degrade gracefully rather than throw: the official CassandraBaseTimeseriesLatestDao also
+ // returns an empty list. Full derived DISTINCT-key discovery over the telemetry table
+ // is a follow-up.
+ return Collections.emptyList();
+ }
+
+ @Override
+ public ListenableFuture> findAllKeysByEntityIdsAsync(
+ TenantId tenantId, List entityIds) {
+ // Backs POST /api/entitiesQuery/find/keys (DefaultEntityQueryService#fetchTimeseriesKeys), the
+ // dashboard "available telemetry keys" lookup — a synchronous throw here would surface as an
+ // HTTP 500. Mirror CassandraBaseTimeseriesLatestDao and return an empty future; full derived
+ // DISTINCT-key discovery is a follow-up.
+ return Futures.immediateFuture(Collections.emptyList());
+ }
+
+ @Override
+ public List findLatestByEntityIds(TenantId tenantId, List entityIds) {
+ // Batch latest read (new in ThingsBoard v4.3.1.2). Return empty (graceful) rather than throw,
+ // matching CassandraBaseTimeseriesLatestDao.findLatestByEntityIds; the derived batch
+ // read is a follow-up.
+ return Collections.emptyList();
+ }
+
+ @Override
+ public ListenableFuture> findLatestByEntityIdsAsync(
+ TenantId tenantId, List entityIds) {
+ // Backs the includeSamples branch of POST /api/entitiesQuery/find/keys
+ // (DefaultEntityQueryService#fetchTimeseriesKeys); a synchronous throw would surface as an HTTP
+ // 500. Mirror CassandraBaseTimeseriesLatestDao and return an empty future; the derived batch
+ // read is a follow-up.
+ return Futures.immediateFuture(Collections.emptyList());
+ }
+
+ @Override
+ public void destroy() {
+ if (!destroyed.compareAndSet(false, true)) {
+ return;
+ }
+ shutdownReadExecutor();
+ }
+
+ // ---- read merge (derived primary + overlay, max-ts-per-key, overlay wins on tie) ----
+
+ private Optional doFindLatest(TenantId tenantId, EntityId entityId, String key)
+ throws Exception {
+ // Derived read FIRST: getEntry fail-fast (IllegalStateException on >1 typed column, the
+ // documented B1 same-timestamp limitation) propagates so the future fails rather than returning
+ // bad data; the overlay read (delete-then-insert => single typed column) cannot trip it.
+ Optional derived =
+ readLatestRow(buildFindLatestSql(tenantId, entityId, key), key, "time");
+ Optional overlay =
+ readLatestRow(buildFindLatestOverlaySql(tenantId, entityId, key), key, "time");
+ return mergeLatest(derived, overlay);
+ }
+
+ // Reads a single latest row but TOLERATES the B1 same-timestamp two-type fail-fast (getEntry's
+ // >1-typed-column IllegalStateException): returns empty instead of propagating, so an ambiguous
+ // telemetry row never blocks a saveLatest guard, a removeLatest snapshot, or a rewrite resurrect.
+ // The strict read paths (findLatest/findLatestOpt/findAllLatest) keep surfacing B1 as a fault.
+ // The
+ // catch is precise: IoTDBTableBaseDao.getEntry is the only IllegalStateException source here.
+ private Optional readLatestRowB1Lenient(String sql, String key) throws Exception {
+ try {
+ return readLatestRow(sql, key, "time");
+ } catch (IllegalStateException b1) {
+ log.debug(
+ "Latest row for key '{}' is an ambiguous same-timestamp two-type row; treating it as "
+ + "absent for this guard/snapshot/resurrect",
+ key,
+ b1);
+ return Optional.empty();
+ }
+ }
+
+ // Derived (telemetry) point-read latest, B1-lenient: used by the saveLatest max-ts guard and the
+ // removeLatest snapshot so an ambiguous telemetry row never blocks a write or a delete.
+ private Optional readDerivedLatestLenient(
+ TenantId tenantId, EntityId entityId, String key) throws Exception {
+ return readLatestRowB1Lenient(buildFindLatestSql(tenantId, entityId, key), key);
+ }
+
+ // Max-ts guard snapshot for saveLatest: B1-lenient derived + overlay (reads derived first, then
+ // overlay — the order the unit tests stub), so a same-timestamp two-type telemetry row cannot
+ // make
+ // every saveLatest for the identity fail.
+ private Optional currentLatestForGuard(
+ TenantId tenantId, EntityId entityId, String key) throws Exception {
+ Optional derived = readDerivedLatestLenient(tenantId, entityId, key);
+ Optional overlay =
+ readLatestRowB1Lenient(buildFindLatestOverlaySql(tenantId, entityId, key), key);
+ return mergeLatest(derived, overlay);
+ }
+
+ // Single-key point reads take the per-identity lock so a concurrent saveLatest/removeLatest
+ // delete-then-insert on the same identity is never observed mid-window (for an overlay-only key
+ // there is no derived row to mask the gap). The multi-key findAllLatest stays unlocked, matching
+ // the sibling IoTDBTableAttributesDao.findAll.
+ private Optional lockedFindLatest(TenantId tenantId, EntityId entityId, String key)
+ throws Exception {
+ Lock lock = identityLock(tenantId, entityId, key);
+ lock.lock();
+ try {
+ return doFindLatest(tenantId, entityId, key);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private List doFindAllLatest(TenantId tenantId, EntityId entityId) throws Exception {
+ Map byKey = new LinkedHashMap<>();
+ // Derived: LAST_BY(col, time) + MAX(time) GROUP BY key gives one synthetic sparse row per key.
+ readEntriesInto(byKey, buildFindAllLatestSql(tenantId, entityId), "last_ts", false);
+ // Overlay: exactly one row per identity (delete-then-insert). Merge by max ts per key: overlay
+ // wins an exact tie and supplies any latest-only key the derived store never saw.
+ readEntriesInto(byKey, buildFindAllLatestOverlaySql(tenantId, entityId), "time", true);
+ return new ArrayList<>(byKey.values());
+ }
+
+ private Optional readLatestRow(String sql, String key, String tsColumn)
+ throws Exception {
+ try (ITableSession session = tableSessionPool.getSession();
+ SessionDataSet dataSet = session.executeQueryStatement(sql)) {
+ SessionDataSet.DataIterator row = dataSet.iterator();
+ if (!row.next()) {
+ return Optional.empty();
+ }
+ TypedKvValue value = getEntry(row);
+ if (!value.hasValue()) {
+ return Optional.empty();
+ }
+ long ts = row.getTimestamp(tsColumn).getTime();
+ return Optional.of(new BasicTsKvEntry(ts, kvEntry(key, value)));
+ }
+ }
+
+ private void readEntriesInto(
+ Map byKey, String sql, String tsColumn, boolean overlayWins)
+ throws Exception {
+ try (ITableSession session = tableSessionPool.getSession();
+ SessionDataSet dataSet = session.executeQueryStatement(sql)) {
+ SessionDataSet.DataIterator row = dataSet.iterator();
+ while (row.next()) {
+ String key = row.getString("key");
+ // B1 fail-fast: getEntry throws IllegalStateException if a key's row has more than one
+ // typed column; the whole findAllLatest future then fails rather than silently skipping it.
+ TypedKvValue value = getEntry(row);
+ if (!value.hasValue()) {
+ continue;
+ }
+ long ts = row.getTimestamp(tsColumn).getTime();
+ TsKvEntry entry = new BasicTsKvEntry(ts, kvEntry(key, value));
+ if (overlayWins) {
+ // remap (existing=derived, incoming=overlay): overlay wins on tie (>=).
+ byKey.merge(
+ key,
+ entry,
+ (existing, incoming) -> incoming.getTs() >= existing.getTs() ? incoming : existing);
+ } else {
+ byKey.put(key, entry);
+ }
+ }
+ }
+ }
+
+ private static Optional mergeLatest(
+ Optional derived, Optional overlay) {
+ if (derived.isEmpty()) {
+ return overlay;
+ }
+ if (overlay.isEmpty()) {
+ return derived;
+ }
+ // Max ts per key; the overlay wins an exact tie (continues the B1 same-timestamp limitation).
+ return overlay.get().getTs() >= derived.get().getTs() ? overlay : derived;
+ }
+
+ // ---- overlay write (delete-then-insert) ----
+
+ // Delete-then-insert (NOT insert-then-delete): the tag-only DELETE removes the identity's overlay
+ // row across ALL time, then the single current row is inserted at time = tsKvEntry.getTs(). The
+ // delete must run first because an IoTDB insert at an existing (tags, time) MERGES typed columns
+ // (a null field does not overwrite an existing value), so inserting a different-type value at a
+ // same-timestamp row without clearing it first would leave two typed columns (the B1 fail-fast on
+ // read). The trade-off is a non-atomic write (IoTDB has no multi-statement transaction): if the
+ // INSERT fails after the DELETE commits the prior overlay value is lost (the future fails loud;
+ // documented in the class limitations). Insert-first would avoid that loss but re-break the
+ // same-timestamp convergence, so it is deliberately not used.
+ private void upsertOverlay(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry, String key)
+ throws Exception {
+ String deleteSql = buildDeleteLatestSql(tenantId, entityId, key);
+ Tablet tablet = buildLatestTablet(tenantId, entityId, tsKvEntry, key);
+ try (ITableSession session = tableSessionPool.getSession()) {
+ session.executeNonQueryStatement(deleteSql);
+ session.insert(tablet);
+ }
+ }
+
+ private void deleteOverlay(TenantId tenantId, EntityId entityId, String key) throws Exception {
+ String sql = buildDeleteLatestSql(tenantId, entityId, key);
+ try (ITableSession session = tableSessionPool.getSession()) {
+ session.executeNonQueryStatement(sql);
+ }
+ }
+
+ private Tablet buildLatestTablet(
+ TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry, String key) {
+ Tablet tablet = new Tablet(TABLE_LATEST, COLUMN_NAMES, DATA_TYPES, COLUMN_CATEGORIES, 1);
+ // telemetry_latest declares an explicit `time TIMESTAMP TIME` column in DDL, but it is still
+ // the table's time column: written through the normal tablet timestamp mechanism (NOT a
+ // ColumnCategory.TIME entry). Use the entry's timestamp as the row time.
+ tablet.addTimestamp(0, tsKvEntry.getTs());
+ // TAG values, in the DDL tag order (entity_type, tenant_id, key, entity_id) — telemetry shape.
+ tablet.addValue("entity_type", 0, entityId.getEntityType().name());
+ tablet.addValue("tenant_id", 0, tenantId.getId().toString());
+ tablet.addValue("key", 0, key);
+ tablet.addValue("entity_id", 0, entityId.getId().toString());
+ // FIELD values: exactly one typed column is non-null, chosen by the entry's DataType.
+ DataType dataType = tsKvEntry.getDataType();
+ tablet.addValue("bool_v", 0, dataType == DataType.BOOLEAN ? tsKvEntry.getValue() : null);
+ tablet.addValue("long_v", 0, dataType == DataType.LONG ? tsKvEntry.getValue() : null);
+ tablet.addValue("double_v", 0, dataType == DataType.DOUBLE ? tsKvEntry.getValue() : null);
+ tablet.addValue("str_v", 0, dataType == DataType.STRING ? tsKvEntry.getValue() : null);
+ tablet.addValue("json_v", 0, dataType == DataType.JSON ? tsKvEntry.getValue() : null);
+ tablet.setRowSize(1);
+ return tablet;
+ }
+
+ // ---- overlay-aware remove ----
+
+ private TsKvLatestRemovingResult doRemoveLatest(
+ TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, String key) throws Exception {
+ long startTs = query.getStartTs();
+ long endTs = query.getEndTs();
+ // (1) Snapshot derived and overlay SEPARATELY under the per-identity lock, so the overlay
+ // mutation below is bounded by the OVERLAY row's OWN ts — not the merged ts, which can come
+ // from
+ // the derived telemetry store (e.g. a telemetry-only / saveWithoutLatest write). BOTH reads are
+ // B1-lenient: a same-ts two-type telemetry row must not block a delete, and although the
+ // overlay
+ // is single-typed within a JVM (per-identity lock + delete-then-insert), a clustered deployment
+ // (acknowledged via iotdb.ts_latest.cluster_mode) could leave a same-ts two-type overlay row,
+ // which must not wedge the delete either — an ambiguous overlay is treated as absent and
+ // self-heals.
+ Optional derived = readDerivedLatestLenient(tenantId, entityId, key);
+ Optional overlay =
+ readLatestRowB1Lenient(buildFindLatestOverlaySql(tenantId, entityId, key), key);
+ Optional latest = mergeLatest(derived, overlay);
+ if (latest.isEmpty()) {
+ return new TsKvLatestRemovingResult(key, false);
+ }
+ long ts = latest.get().getTs();
+ if (ts < startTs || ts >= endTs) {
+ // The current latest is outside the half-open [startTs, endTs) window: nothing removed, so TB
+ // is not told to delete a latest value that is still valid.
+ return new TsKvLatestRemovingResult(key, false);
+ }
+ // TB only invokes removeLatest with deleteLatest=true (BaseTimeseriesService gates it); the
+ // check here is defensive — when false, do not mutate the overlay and report nothing removed.
+ boolean deleteLatest = !Boolean.FALSE.equals(query.getDeleteLatest());
+ if (!deleteLatest) {
+ return new TsKvLatestRemovingResult(key, false);
+ }
+ // (2) Only an overlay row whose OWN ts is inside the window may be deleted; an overlay value
+ // older than the window (e.g. when the in-window latest is a derived/telemetry-only value) must
+ // SURVIVE as the next latest rather than being wiped by the tag-only all-time DELETE.
+ boolean overlayInWindow =
+ overlay.isPresent() && overlay.get().getTs() >= startTs && overlay.get().getTs() < endTs;
+ boolean rewrite = Boolean.TRUE.equals(query.getRewriteLatestIfDeleted());
+ if (rewrite) {
+ // (3) Resurrect the next-older value as the new latest, looking BEFORE the window across BOTH
+ // stores: telemetry (time < startTs) and the overlay row if its own ts is < startTs (the
+ // overlay is single-row, so an out-of-window-older overlay value is itself a candidate);
+ // max-ts-wins. Write it into the overlay and return it as data so onTimeSeriesDelete emits a
+ // WS UPDATE (removed=true, getData()=prior).
+ Optional priorDerived = doFindHistoryBefore(tenantId, entityId, key, startTs);
+ Optional priorOverlay = overlay.filter(e -> e.getTs() < startTs);
+ Optional prior = mergeLatest(priorDerived, priorOverlay);
+ if (prior.isPresent()) {
+ // If the resurrected prior is the overlay's OWN out-of-window value (priorOverlay won the
+ // max-ts merge), it is ALREADY stored and outside the delete window — do NOT rewrite it: a
+ // redundant delete-then-insert would needlessly risk an out-of-window, possibly
+ // latest-only,
+ // value on an INSERT failure (and converges to the same result with no write). Only a prior
+ // that came from the derived telemetry store is installed into the overlay; that write also
+ // drops any in-window overlay row via the delete-then-insert, and is recoverable from
+ // {@code
+ // telemetry} if the insert fails.
+ boolean priorIsExistingOverlay =
+ priorOverlay.isPresent()
+ && (priorDerived.isEmpty()
+ || priorOverlay.get().getTs() >= priorDerived.get().getTs());
+ if (!priorIsExistingOverlay) {
+ upsertOverlay(tenantId, entityId, prior.get(), key);
+ }
+ return new TsKvLatestRemovingResult(prior.get(), null);
+ }
+ // No older value anywhere to resurrect: fall through to a plain latest delete.
+ }
+ // (4) Plain delete: remove ONLY an in-window overlay row (the in-window derived telemetry value
+ // is removed by the separate historical remove future). removed=true reports the in-window
+ // latest was deleted; an out-of-window overlay value, if any, survives and resurfaces via the
+ // derived merge (the documented telemetry-derived eventual-consistency residual).
+ if (overlayInWindow) {
+ deleteOverlay(tenantId, entityId, key);
+ }
+ return new TsKvLatestRemovingResult(key, true, null);
+ }
+
+ private Optional doFindHistoryBefore(
+ TenantId tenantId, EntityId entityId, String key, long startTs) throws Exception {
+ // B1-lenient like the snapshot read: a same-ts two-type next-older row is treated as no
+ // resurrectable prior (fall through to a plain delete) rather than failing the removeLatest.
+ return readLatestRowB1Lenient(buildRewriteHistorySql(tenantId, entityId, key, startTs), key);
+ }
+
+ // ---- SQL builders ----
+
+ private String buildFindLatestSql(TenantId tenantId, EntityId entityId, String key) {
+ return "SELECT "
+ + SELECT_TYPED_COLUMNS
+ + " FROM "
+ + TABLE_NAME
+ + " WHERE "
+ + identityPredicate(tenantId, entityId, key)
+ + " ORDER BY time DESC LIMIT 1";
+ }
+
+ private String buildFindLatestOverlaySql(TenantId tenantId, EntityId entityId, String key) {
+ return "SELECT "
+ + SELECT_TYPED_COLUMNS
+ + " FROM "
+ + TABLE_LATEST
+ + " WHERE "
+ + identityPredicate(tenantId, entityId, key)
+ + " ORDER BY time DESC LIMIT 1";
+ }
+
+ private String buildFindAllLatestSql(TenantId tenantId, EntityId entityId) {
+ // GROUP BY key projects the key tag plus the per-column LAST_BY aggregate (value at max time)
+ // and MAX(time) for the entry timestamp. The other tags are fixed by the WHERE clause, so they
+ // do not need to be (and cannot be) projected as bare columns alongside GROUP BY key.
+ return "SELECT key,"
+ + " LAST_BY(bool_v, time) AS bool_v,"
+ + " LAST_BY(long_v, time) AS long_v,"
+ + " LAST_BY(double_v, time) AS double_v,"
+ + " LAST_BY(str_v, time) AS str_v,"
+ + " LAST_BY(json_v, time) AS json_v,"
+ + " MAX(time) AS last_ts"
+ + " FROM "
+ + TABLE_NAME
+ + " WHERE "
+ + entityPredicate(tenantId, entityId)
+ + " GROUP BY key";
+ }
+
+ private String buildFindAllLatestOverlaySql(TenantId tenantId, EntityId entityId) {
+ // Each identity holds exactly one overlay row (delete-then-insert), so no aggregation is
+ // needed.
+ return "SELECT key, "
+ + SELECT_TYPED_COLUMNS
+ + " FROM "
+ + TABLE_LATEST
+ + " WHERE "
+ + entityPredicate(tenantId, entityId);
+ }
+
+ private String buildDeleteLatestSql(TenantId tenantId, EntityId entityId, String key) {
+ return "DELETE FROM " + TABLE_LATEST + " WHERE " + identityPredicate(tenantId, entityId, key);
+ }
+
+ private String buildRewriteHistorySql(
+ TenantId tenantId, EntityId entityId, String key, long startTs) {
+ return "SELECT "
+ + SELECT_TYPED_COLUMNS
+ + " FROM "
+ + TABLE_NAME
+ + " WHERE "
+ + identityPredicate(tenantId, entityId, key)
+ + " AND time < "
+ + startTs
+ + " ORDER BY time DESC LIMIT 1";
+ }
+
+ private String identityPredicate(TenantId tenantId, EntityId entityId, String key) {
+ return entityPredicate(tenantId, entityId) + " AND key=" + sqlString(key);
+ }
+
+ // ---- mapping + helpers ----
+
+ private static TsKvEntry nullEntry(String key) {
+ // SPI contract: findLatest returns this sentinel when the value is not present in the DB.
+ return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null));
+ }
+
+ private Lock identityLock(TenantId tenantId, EntityId entityId, String key) {
+ return identityLocks.get(
+ tenantId.getId().toString()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getEntityType().name()
+ + LOCK_KEY_SEPARATOR
+ + entityId.getId().toString()
+ + LOCK_KEY_SEPARATOR
+ + key);
+ }
+
+ private static void requireClusterModeAcknowledged(String clusterMode) {
+ String mode = clusterMode == null ? null : clusterMode.trim();
+ if (mode == null || (!mode.equals("sticky-routing") && !mode.equals("disabled"))) {
+ throw new IllegalStateException(
+ "iotdb.ts_latest.cluster_mode must be explicitly set to 'sticky-routing' or 'disabled' "
+ + "when the IoTDB Table Mode latest overlay DAO is active; got '"
+ + clusterMode
+ + "'. The latest overlay write path (delete-then-insert under a per-identity in-JVM "
+ + "lock) converges only within a single JVM, so cross-node single-writer safety must "
+ + "be acknowledged. The race is benign (transient dual overlay rows; the max-ts merge "
+ + "still returns the newer value and a B1 two-type overlay row self-heals via the "
+ + "B1-lenient guard/remove reads), but the decision is made deliberately for parity "
+ + "with the sibling attribute DAO.");
+ }
}
}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestEnabledCondition.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestEnabledCondition.java
new file mode 100644
index 00000000..d36e7090
--- /dev/null
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestEnabledCondition.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+
+/**
+ * Activation guard for the derived-latest DAO. It mirrors {@link IoTDBTableRawOnlyEnabledCondition}
+ * (selector {@code database.ts.type=iotdb-table} plus the explicit {@code
+ * iotdb.ts.experimental-raw-only=true} opt-in) AND additionally requires {@code
+ * database.ts_latest.type=iotdb-table}.
+ *
+ * Requiring the timeseries selector as well as the latest selector is deliberate: the latest
+ * value is derived from the {@code telemetry} table that only the IoTDB writer populates. Were the
+ * DAO to activate on {@code database.ts_latest.type=iotdb-table} alone, a deployment that left
+ * {@code database.ts.type} on another backend would read latest values from a table this module
+ * never writes. Coupling both selectors keeps the latest path coherent with the write path and
+ * leaves the module fully inert on any deployment that has not opted into the IoTDB Table Mode
+ * timeseries backend.
+ */
+final class IoTDBTableLatestEnabledCondition implements Condition {
+
+ private static final String TS_SELECTOR_PROPERTY = "database.ts.type";
+ private static final String TS_LATEST_SELECTOR_PROPERTY = "database.ts_latest.type";
+ private static final String SELECTOR_VALUE = "iotdb-table";
+ private static final String EXPERIMENTAL_PROPERTY = "iotdb.ts.experimental-raw-only";
+
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ String ts = context.getEnvironment().getProperty(TS_SELECTOR_PROPERTY);
+ String tsLatest = context.getEnvironment().getProperty(TS_LATEST_SELECTOR_PROPERTY);
+ boolean tsSelected = ts != null && SELECTOR_VALUE.equalsIgnoreCase(ts.trim());
+ boolean tsLatestSelected = tsLatest != null && SELECTOR_VALUE.equalsIgnoreCase(tsLatest.trim());
+ boolean experimental =
+ context.getEnvironment().getProperty(EXPERIMENTAL_PROPERTY, Boolean.class, false);
+ return tsSelected && tsLatestSelected && experimental;
+ }
+}
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableSchemaBootstrap.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableSchemaBootstrap.java
index dbb4dcd2..b620cbda 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableSchemaBootstrap.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableSchemaBootstrap.java
@@ -38,12 +38,18 @@
*
*
On a fresh IoTDB the {@code telemetry} / {@code entity_attributes} tables (and their database)
* do not exist, so the very first write would fail. This initializer runs once the session pool
- * bean is up, reads {@code schema-iotdb-table.sql} from the classpath, and executes its statements.
- * Every DDL statement uses {@code CREATE ... IF NOT EXISTS}, so a re-run returns SUCCESS without
+ * bean is up, reads a schema SQL resource from the classpath, and executes its statements. Every
+ * DDL statement uses {@code CREATE ... IF NOT EXISTS}, so a re-run returns SUCCESS without
* throwing. As defense-in-depth for racy or partial schema states, an "already exists" failure is
* also recognized by its structured IoTDB status code (with a message-substring fallback) and
* tolerated rather than propagated, keeping the bootstrap idempotent.
*
+ *
The schema resource is parameterized ({@link #SCHEMA_RESOURCE} default, overridable via the
+ * 3-arg constructor) so a SECOND bootstrap bean can load {@code schema-iotdb-table-latest.sql} (the
+ * {@code telemetry_latest} overlay) without re-running the base schema. Each resource is
+ * self-contained ({@code CREATE DATABASE IF NOT EXISTS} + {@code USE} + {@code CREATE TABLE IF NOT
+ * EXISTS}), so multiple bootstrap beans are order-independent and idempotent.
+ *
*
Gated behind {@code iotdb.schema.bootstrap} (default {@code true}) so operators who manage the
* schema out-of-band can disable it; see the module README.
*/
@@ -51,6 +57,7 @@
public class IoTDBTableSchemaBootstrap implements InitializingBean {
static final String SCHEMA_RESOURCE = "schema-iotdb-table.sql";
+ static final String LATEST_SCHEMA_RESOURCE = "schema-iotdb-table-latest.sql";
private static final String DEFAULT_SCHEMA_DATABASE = "thingsboard";
/**
@@ -64,10 +71,17 @@ public class IoTDBTableSchemaBootstrap implements InitializingBean {
private final ITableSessionPool tableSessionPool;
private final IoTDBTableConfig config;
+ private final String schemaResource;
public IoTDBTableSchemaBootstrap(ITableSessionPool tableSessionPool, IoTDBTableConfig config) {
+ this(tableSessionPool, config, SCHEMA_RESOURCE);
+ }
+
+ public IoTDBTableSchemaBootstrap(
+ ITableSessionPool tableSessionPool, IoTDBTableConfig config, String schemaResource) {
this.tableSessionPool = Objects.requireNonNull(tableSessionPool, "tableSessionPool");
this.config = Objects.requireNonNull(config, "config");
+ this.schemaResource = Objects.requireNonNull(schemaResource, "schemaResource");
}
@Override
@@ -116,10 +130,10 @@ private static String requireValidDatabaseName(String database) {
private String loadSchema(String database) throws IOException {
try (InputStream stream =
- IoTDBTableSchemaBootstrap.class.getClassLoader().getResourceAsStream(SCHEMA_RESOURCE)) {
+ IoTDBTableSchemaBootstrap.class.getClassLoader().getResourceAsStream(schemaResource)) {
if (stream == null) {
throw new IllegalStateException(
- "IoTDB Table Mode schema resource not found on classpath: " + SCHEMA_RESOURCE);
+ "IoTDB Table Mode schema resource not found on classpath: " + schemaResource);
}
String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
// Strip block and line comments so split-on-';' never executes a comment fragment.
diff --git a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
index 0be8557e..9dff89c3 100644
--- a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
+++ b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesDao.java
@@ -24,7 +24,6 @@
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.SettableFuture;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -35,16 +34,10 @@
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
-import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DataType;
import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
-import org.thingsboard.server.common.data.kv.DoubleDataEntry;
-import org.thingsboard.server.common.data.kv.JsonDataEntry;
-import org.thingsboard.server.common.data.kv.KvEntry;
-import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
-import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.dao.timeseries.TimeseriesDao;
@@ -53,16 +46,7 @@
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.RejectedExecutionException;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.concurrent.atomic.AtomicInteger;
/**
* Historical telemetry DAO for the IoTDB Table Mode backend.
@@ -88,12 +72,7 @@ public class IoTDBTableTimeseriesDao extends IoTDBTableBaseDao
private static final String TABLE_NAME = IoTDBTableTimeseriesWriter.TABLE_NAME;
private final IoTDBTableTimeseriesWriter timeseriesWriter;
- private final ThreadPoolExecutor readExecutor;
- private final Set> readTasks = ConcurrentHashMap.newKeySet();
- private final AtomicBoolean accepting = new AtomicBoolean(true);
- private final AtomicBoolean destroyed = new AtomicBoolean(false);
private final long defaultTtlSeconds;
- private final long shutdownDrainTimeoutMs;
public IoTDBTableTimeseriesDao(
@Qualifier(IoTDBTableConfiguration.IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
@@ -106,9 +85,7 @@ public IoTDBTableTimeseriesDao(
config.getDefaultTtlMs() > 0L
? TimeUnit.MILLISECONDS.toSeconds(config.getDefaultTtlMs())
: 0L;
- this.shutdownDrainTimeoutMs = config.getTs().getSave().getShutdownDrainTimeoutMs();
int readThreads = config.getTs().getRead().getThreads();
- int readQueueCapacity = config.getTs().getRead().getQueueCapacity();
int flushThreads = config.getTs().getSave().getFlushThreads();
if (readThreads + flushThreads > config.getSessionPoolSize()) {
log.warn(
@@ -117,15 +94,13 @@ public IoTDBTableTimeseriesDao(
readThreads + flushThreads,
config.getSessionPoolSize());
}
- this.readExecutor =
- new ThreadPoolExecutor(
- readThreads,
- readThreads,
- 0L,
- TimeUnit.MILLISECONDS,
- new ArrayBlockingQueue<>(readQueueCapacity),
- readThreadFactory(),
- new ThreadPoolExecutor.AbortPolicy());
+ initReadExecutor(
+ readThreads,
+ config.getTs().getRead().getQueueCapacity(),
+ config.getTs().getSave().getShutdownDrainTimeoutMs(),
+ "iotdb-table-timeseries-read-worker-",
+ "IoTDB Table Mode timeseries read queue is full",
+ "IoTDB Table Mode timeseries DAO is shutting down");
}
@Override
@@ -236,19 +211,7 @@ public void destroy() {
if (!destroyed.compareAndSet(false, true)) {
return;
}
- accepting.set(false);
- IoTDBTableDaoShuttingDownException failure = shuttingDownException();
- for (Runnable dropped : readExecutor.shutdownNow()) {
- failDroppedReadTask(dropped, failure);
- }
- try {
- readExecutor.awaitTermination(shutdownDrainTimeoutMs, TimeUnit.MILLISECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- for (ReadTask> task : readTasks) {
- task.fail(failure);
- }
+ shutdownReadExecutor();
timeseriesWriter.destroy();
}
@@ -348,25 +311,6 @@ private static Aggregation aggregationOf(ReadTsKvQuery query) {
return aggregation == null ? Aggregation.NONE : aggregation;
}
- private KvEntry kvEntry(String key, TypedKvValue value) {
- if (value.booleanValue() != null) {
- return new BooleanDataEntry(key, value.booleanValue());
- }
- if (value.longValue() != null) {
- return new LongDataEntry(key, value.longValue());
- }
- if (value.doubleValue() != null) {
- return new DoubleDataEntry(key, value.doubleValue());
- }
- if (value.stringValue() != null) {
- return new StringDataEntry(key, value.stringValue());
- }
- if (value.jsonValue() != null) {
- return new JsonDataEntry(key, value.jsonValue());
- }
- throw new IllegalArgumentException("Telemetry row does not contain a typed value");
- }
-
private static String sqlOrder(String order) {
String normalized = Objects.requireNonNull(order, "order").trim().toUpperCase(Locale.ROOT);
if (!"ASC".equals(normalized) && !"DESC".equals(normalized)) {
@@ -375,65 +319,6 @@ private static String sqlOrder(String order) {
return normalized;
}
- private static String sqlString(String value) {
- return "'" + Objects.requireNonNull(value, "value").replace("'", "''") + "'";
- }
-
- private ListenableFuture submitReadTask(Callable callable) {
- if (!accepting.get()) {
- return Futures.immediateFailedFuture(shuttingDownException());
- }
- ReadTask task = new ReadTask<>(callable);
- readTasks.add(task);
- try {
- readExecutor.execute(task);
- } catch (RejectedExecutionException e) {
- if (!accepting.get() || readExecutor.isShutdown()) {
- task.fail(shuttingDownException());
- } else {
- task.fail(
- new IoTDBTableReadQueueFullException(
- "IoTDB Table Mode timeseries read queue is full", e));
- }
- readTasks.remove(task);
- return task.future();
- }
- if (!accepting.get() && readExecutor.remove(task)) {
- task.fail(shuttingDownException());
- readTasks.remove(task);
- }
- return task.future();
- }
-
- private void failDroppedReadTask(Runnable dropped, IoTDBTableDaoShuttingDownException failure) {
- if (dropped instanceof ReadTask> task) {
- task.fail(failure);
- readTasks.remove(task);
- }
- }
-
- private IoTDBTableDaoShuttingDownException shuttingDownException() {
- return new IoTDBTableDaoShuttingDownException(
- "IoTDB Table Mode timeseries DAO is shutting down");
- }
-
- private static String requireTelemetryKey(String key) {
- if (key == null || key.trim().isEmpty()) {
- throw new IllegalArgumentException("Telemetry key must not be blank");
- }
- return key;
- }
-
- private static ThreadFactory readThreadFactory() {
- AtomicInteger sequence = new AtomicInteger();
- return runnable -> {
- Thread thread =
- new Thread(runnable, "iotdb-table-timeseries-read-worker-" + sequence.incrementAndGet());
- thread.setDaemon(true);
- return thread;
- };
- }
-
private int dataPointDays(TsKvEntry tsKvEntry, long ttl) {
long effectiveTtlSeconds =
ttl <= 0L
@@ -461,32 +346,4 @@ private Object requiredValue(Optional> value, DataType dataType) {
return value.orElseThrow(
() -> new IllegalArgumentException("Missing value for telemetry data type " + dataType));
}
-
- private final class ReadTask implements Runnable {
- private final Callable callable;
- private final SettableFuture future = SettableFuture.create();
-
- private ReadTask(Callable callable) {
- this.callable = Objects.requireNonNull(callable, "callable");
- }
-
- @Override
- public void run() {
- try {
- future.set(callable.call());
- } catch (Throwable t) {
- future.setException(t);
- } finally {
- readTasks.remove(this);
- }
- }
-
- private ListenableFuture future() {
- return future;
- }
-
- private void fail(Throwable t) {
- future.setException(t);
- }
- }
}
diff --git a/iotdb-thingsboard-table/src/main/resources/schema-iotdb-table-latest.sql b/iotdb-thingsboard-table/src/main/resources/schema-iotdb-table-latest.sql
new file mode 100644
index 00000000..6df9f6ba
--- /dev/null
+++ b/iotdb-thingsboard-table/src/main/resources/schema-iotdb-table-latest.sql
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+CREATE DATABASE IF NOT EXISTS thingsboard;
+USE thingsboard;
+
+-- Per-key latest overlay written on every saveLatest (delete-then-insert, one row per identity).
+-- TAG order is aligned with the historical `telemetry` table (entity_type, tenant_id, key,
+-- entity_id) so the same getEntry()/kvEntry() row mapping is reused. TTL='INF' because a latest
+-- value must never physically expire.
+CREATE TABLE IF NOT EXISTS telemetry_latest (
+ time TIMESTAMP TIME,
+ entity_type STRING TAG, -- DEVICE, ASSET, etc.
+ tenant_id STRING TAG, -- multi-tenant isolation
+ key STRING TAG, -- telemetry key name
+ entity_id STRING TAG, -- ThingsBoard entity UUID
+ bool_v BOOLEAN FIELD,
+ long_v INT64 FIELD, -- exactly one non-null per row,
+ double_v DOUBLE FIELD, -- mirroring AbstractTsKvEntity
+ str_v STRING FIELD,
+ json_v TEXT FIELD
+) WITH (TTL='INF');
diff --git a/iotdb-thingsboard-table/src/provided/java/org/apache/commons/lang3/tuple/Pair.java b/iotdb-thingsboard-table/src/provided/java/org/apache/commons/lang3/tuple/Pair.java
new file mode 100644
index 00000000..3d163830
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/apache/commons/lang3/tuple/Pair.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.tuple;
+
+import java.util.Objects;
+
+/**
+ * Compile-only stub of Apache Commons Lang3 {@code org.apache.commons.lang3.tuple.Pair}, provided
+ * so the IoTDB Table Mode DAO can bind the real ThingsBoard {@code AttributesDao.removeAllByEntityId}
+ * signature (which returns {@code List>}). commons-lang3 is not on this
+ * module's compile classpath; the real artifact is supplied by the host ThingsBoard runtime. This
+ * stub is excluded from the built jar (Strategy F) and only exposes the {@code of}/{@code getLeft}/
+ * {@code getRight} surface the DAO uses.
+ */
+public final class Pair {
+
+ private final L left;
+ private final R right;
+
+ private Pair(L left, R right) {
+ this.left = left;
+ this.right = right;
+ }
+
+ public static Pair of(L left, R right) {
+ return new Pair<>(left, right);
+ }
+
+ public L getLeft() {
+ return left;
+ }
+
+ public R getRight() {
+ return right;
+ }
+
+ // Real commons-lang3 Pair implements Map.Entry, so TB service code reads pairs via
+ // getKey()/getValue(); mirror that surface (getKey == left, getValue == right).
+ public L getKey() {
+ return left;
+ }
+
+ public R getValue() {
+ return right;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Pair)) {
+ return false;
+ }
+ Pair, ?> pair = (Pair, ?>) o;
+ return Objects.equals(left, pair.left) && Objects.equals(right, pair.right);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(left, right);
+ }
+
+ @Override
+ public String toString() {
+ return "(" + left + "," + right + ")";
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/AttributeScope.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/AttributeScope.java
new file mode 100644
index 00000000..54c6ab1d
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/AttributeScope.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.common.data;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.common.data.AttributeScope} so the IoTDB Table Mode DAO can bind the real
+ * {@code AttributesDao} SPI without depending on the upstream ThingsBoard artifact. Excluded from
+ * the built jar; replace with the provided-scope upstream dependency once it is resolvable.
+ *
+ * The IoTDB {@code entity_attributes} table stores the scope by {@link #name()} (e.g. {@code
+ * SERVER_SCOPE}), not by {@link #getId()}; the integer id is retained only to match the real enum's
+ * shape (the custom {@code valueOf(int)} is part of the verified v4.3.1.2 surface).
+ */
+public enum AttributeScope {
+ CLIENT_SCOPE(1),
+ SERVER_SCOPE(2),
+ SHARED_SCOPE(3);
+
+ private final int id;
+
+ private static final Map VALUES =
+ Arrays.stream(values()).collect(Collectors.toMap(AttributeScope::getId, scope -> scope));
+
+ AttributeScope(int id) {
+ this.id = id;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public static AttributeScope valueOf(int id) {
+ return VALUES.get(id);
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/id/DeviceProfileId.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/id/DeviceProfileId.java
new file mode 100644
index 00000000..b8b20f36
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/id/DeviceProfileId.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.common.data.id;
+
+import org.thingsboard.server.common.data.EntityType;
+
+import java.util.UUID;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.common.data.id.DeviceProfileId} so the IoTDB Table Mode DAO can bind the
+ * {@code AttributesDao.findAllKeysByDeviceProfileId} signature without depending on the upstream
+ * ThingsBoard artifact. Excluded from the built jar.
+ */
+public class DeviceProfileId extends UUIDBased implements EntityId {
+
+ public DeviceProfileId(UUID id) {
+ super(id);
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return EntityType.DEVICE_PROFILE;
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java
new file mode 100644
index 00000000..488ede65
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/AttributeKvEntry.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.common.data.kv;
+
+import org.thingsboard.server.common.data.HasVersion;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.common.data.kv.AttributeKvEntry} so the IoTDB Table Mode DAO can bind the
+ * real {@code AttributesDao} SPI without depending on the upstream ThingsBoard artifact. Excluded
+ * from the built jar.
+ */
+public interface AttributeKvEntry extends KvEntry, HasVersion {
+
+ long getLastUpdateTs();
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java
new file mode 100644
index 00000000..4e047128
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/BaseAttributeKvEntry.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.common.data.kv;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.common.data.kv.BaseAttributeKvEntry}. Wraps a {@link KvEntry} together
+ * with its {@code lastUpdateTs} and optional {@code version}, delegating all {@link KvEntry} getters
+ * to the wrapped entry. Excluded from the built jar.
+ */
+public class BaseAttributeKvEntry implements AttributeKvEntry {
+
+ private final long lastUpdateTs;
+ private final KvEntry kv;
+ private final Long version;
+
+ public BaseAttributeKvEntry(KvEntry kv, long lastUpdateTs) {
+ this.kv = kv;
+ this.lastUpdateTs = lastUpdateTs;
+ this.version = null;
+ }
+
+ // Real TB also exposes the (long, KvEntry) argument order; mirror it for stub fidelity.
+ public BaseAttributeKvEntry(long lastUpdateTs, KvEntry kv) {
+ this(kv, lastUpdateTs);
+ }
+
+ public BaseAttributeKvEntry(KvEntry kv, long lastUpdateTs, Long version) {
+ this.kv = kv;
+ this.lastUpdateTs = lastUpdateTs;
+ this.version = version;
+ }
+
+ @Override
+ public long getLastUpdateTs() {
+ return lastUpdateTs;
+ }
+
+ @Override
+ public Long getVersion() {
+ return version;
+ }
+
+ @Override
+ public String getKey() {
+ return kv.getKey();
+ }
+
+ @Override
+ public DataType getDataType() {
+ return kv.getDataType();
+ }
+
+ @Override
+ public Optional getBooleanValue() {
+ return kv.getBooleanValue();
+ }
+
+ @Override
+ public Optional getLongValue() {
+ return kv.getLongValue();
+ }
+
+ @Override
+ public Optional getDoubleValue() {
+ return kv.getDoubleValue();
+ }
+
+ @Override
+ public Optional getStrValue() {
+ return kv.getStrValue();
+ }
+
+ @Override
+ public Optional getJsonValue() {
+ return kv.getJsonValue();
+ }
+
+ @Override
+ public String getValueAsString() {
+ return kv.getValueAsString();
+ }
+
+ @Override
+ public Object getValue() {
+ return kv.getValue();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof BaseAttributeKvEntry)) {
+ return false;
+ }
+ BaseAttributeKvEntry that = (BaseAttributeKvEntry) o;
+ return lastUpdateTs == that.lastUpdateTs
+ && Objects.equals(kv, that.kv)
+ && Objects.equals(version, that.version);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(lastUpdateTs, kv, version);
+ }
+
+ @Override
+ public String toString() {
+ return "BaseAttributeKvEntry(lastUpdateTs="
+ + lastUpdateTs
+ + ", kv="
+ + kv
+ + ", version="
+ + version
+ + ")";
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java
new file mode 100644
index 00000000..2d5defd0
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/kv/TsKvLatestRemovingResult.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.thingsboard.server.common.data.kv;
+
+public class TsKvLatestRemovingResult {
+ private final String key;
+ private final TsKvEntry data;
+ private final boolean removed;
+ private final Long version;
+
+ public TsKvLatestRemovingResult(String key, boolean removed) {
+ this(key, removed, null);
+ }
+
+ public TsKvLatestRemovingResult(TsKvEntry data, Long version) {
+ this.key = data.getKey();
+ this.data = data;
+ this.removed = true;
+ this.version = version;
+ }
+
+ public TsKvLatestRemovingResult(String key, boolean removed, Long version) {
+ this.key = key;
+ this.data = null;
+ this.removed = removed;
+ this.version = version;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public TsKvEntry getData() {
+ return data;
+ }
+
+ public boolean isRemoved() {
+ return removed;
+ }
+
+ public Long getVersion() {
+ return version;
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/util/TbPair.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/util/TbPair.java
new file mode 100644
index 00000000..b1156b9b
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/common/data/util/TbPair.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.common.data.util;
+
+import java.util.Objects;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.common.data.util.TbPair} so the IoTDB Table Mode DAO can return the value
+ * shape expected by the real {@code AttributesDao.removeAllWithVersions} contract without depending
+ * on the upstream ThingsBoard artifact. Excluded from the built jar.
+ */
+public class TbPair {
+ private S first;
+ private T second;
+
+ public TbPair(S first, T second) {
+ this.first = first;
+ this.second = second;
+ }
+
+ public static TbPair of(S first, T second) {
+ return new TbPair<>(first, second);
+ }
+
+ public S getFirst() {
+ return first;
+ }
+
+ public void setFirst(S first) {
+ this.first = first;
+ }
+
+ public T getSecond() {
+ return second;
+ }
+
+ public void setSecond(T second) {
+ this.second = second;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof TbPair)) {
+ return false;
+ }
+ TbPair, ?> tbPair = (TbPair, ?>) o;
+ return Objects.equals(first, tbPair.first) && Objects.equals(second, tbPair.second);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(first, second);
+ }
+
+ @Override
+ public String toString() {
+ return "TbPair(first=" + first + ", second=" + second + ")";
+ }
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/attributes/AttributesDao.java
new file mode 100644
index 00000000..c7cd680f
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/attributes/AttributesDao.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.dao.attributes;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.commons.lang3.tuple.Pair;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.util.TbPair;
+import org.thingsboard.server.dao.model.sql.AttributeKvEntity;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): mirrors {@code
+ * org.thingsboard.server.dao.attributes.AttributesDao} so the IoTDB Table Mode DAO can bind the real
+ * entity-attribute SPI without depending on the upstream ThingsBoard artifact. Excluded from the
+ * built jar; replace with the provided-scope upstream dependency once it is resolvable.
+ *
+ * The 14 method signatures here pin the v4.3.1.2 SPI surface exactly (verified against the real
+ * {@code AttributesDao} at tag v4.3.1.2). {@code StrategyFContractTest} reflects over them so any
+ * accidental drift in the local surface breaks the build.
+ */
+public interface AttributesDao {
+
+ Optional find(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope, String attributeKey);
+
+ List find(
+ TenantId tenantId,
+ EntityId entityId,
+ AttributeScope attributeScope,
+ Collection attributeKey);
+
+ List findAll(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope);
+
+ ListenableFuture save(
+ TenantId tenantId,
+ EntityId entityId,
+ AttributeScope attributeScope,
+ AttributeKvEntry attribute);
+
+ List> removeAll(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys);
+
+ List>> removeAllWithVersions(
+ TenantId tenantId, EntityId entityId, AttributeScope attributeScope, List keys);
+
+ List findNextBatch(
+ UUID entityId, int attributeType, int attributeKey, int batchSize);
+
+ List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId);
+
+ List findAllKeysByEntityIds(TenantId tenantId, List entityIds);
+
+ List findAllKeysByEntityIdsAndScope(
+ TenantId tenantId, List entityIds, AttributeScope scope);
+
+ ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(
+ TenantId tenantId, List entityIds, AttributeScope scope);
+
+ List findLatestByEntityIdsAndScope(
+ TenantId tenantId, List entityIds, AttributeScope scope);
+
+ ListenableFuture> findLatestByEntityIdsAndScopeAsync(
+ TenantId tenantId, List entityIds, AttributeScope scope);
+
+ List> removeAllByEntityId(TenantId tenantId, EntityId entityId);
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java
new file mode 100644
index 00000000..c3c11d38
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.dao.model.sql;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F): a deliberately empty placeholder for {@code
+ * org.thingsboard.server.dao.model.sql.AttributeKvEntity}.
+ *
+ * The upstream type is a relational JPA entity ({@code @Entity}, {@code @EmbeddedId},
+ * {@code jakarta.persistence}) used only by the keyset-pagination migration helper {@code
+ * AttributesDao.findNextBatch}. The IoTDB Table Mode backend documents that method as deferred (it
+ * throws {@code UnsupportedOperationException}), so this stub
+ * carries none of the ORM fields or annotations — it exists purely so the {@code AttributesDao}
+ * interface signature resolves at compile time. Excluded from the built jar; the IoTDB DAO never
+ * constructs or returns an instance.
+ */
+public final class AttributeKvEntity {
+ private AttributeKvEntity() {}
+}
diff --git a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java
new file mode 100644
index 00000000..d25edcf0
--- /dev/null
+++ b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.thingsboard.server.dao.timeseries;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Compile-only Strategy-F surface for ThingsBoard's {@code TimeseriesLatestDao} SPI. Pinned to
+ * ThingsBoard {@code v4.3.1.2} (tag commit {@code c37fb509}); re-verified against upstream on
+ * 2026-06-20. The {@code findLatestByEntityIds} / {@code findLatestByEntityIdsAsync} pair is new in
+ * v4.3.1.2 relative to v4.3.1.1. The method shapes the DAO depends on are pinned by {@code
+ * StrategyFContractTest}.
+ */
+public interface TimeseriesLatestDao {
+
+ ListenableFuture> findLatestOpt(
+ TenantId tenantId, EntityId entityId, String key);
+
+ ListenableFuture findLatest(TenantId tenantId, EntityId entityId, String key);
+
+ ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId);
+
+ ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry);
+
+ ListenableFuture removeLatest(
+ TenantId tenantId, EntityId entityId, DeleteTsKvQuery query);
+
+ List findAllKeysByDeviceProfileId(TenantId tenantId, DeviceProfileId deviceProfileId);
+
+ List findAllKeysByEntityIds(TenantId tenantId, List entityIds);
+
+ ListenableFuture> findAllKeysByEntityIdsAsync(
+ TenantId tenantId, List entityIds);
+
+ // New in ThingsBoard v4.3.1.2 (batch latest read).
+ List findLatestByEntityIds(TenantId tenantId, List entityIds);
+
+ ListenableFuture> findLatestByEntityIdsAsync(
+ TenantId tenantId, List entityIds);
+}
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoIT.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoIT.java
new file mode 100644
index 00000000..3b160fa7
--- /dev/null
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoIT.java
@@ -0,0 +1,690 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.session.pool.TableSessionPoolBuilder;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DataType;
+import org.thingsboard.server.common.data.kv.DoubleDataEntry;
+import org.thingsboard.server.common.data.kv.JsonDataEntry;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.LongDataEntry;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.common.data.util.TbPair;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+class IoTDBTableAttributesDaoIT {
+ // Cold testcontainer first writes/reads are slower than a warm production node, so the
+ // per-future assertion timeout is generous.
+ private static final int FUTURE_TIMEOUT_SECONDS = 30;
+ private static final Duration IOTDB_STARTUP_TIMEOUT = Duration.ofMinutes(3);
+ private static final Duration IOTDB_READY_TIMEOUT = Duration.ofSeconds(60);
+ private static final Duration IOTDB_READY_POLL_INTERVAL = Duration.ofMillis(500);
+
+ @Container
+ static final GenericContainer> IOTDB =
+ new GenericContainer<>(DockerImageName.parse("apache/iotdb:2.0.8-standalone"))
+ .withExposedPorts(6667)
+ // IoTDB binds its client RPC service to dn_rpc_address (default 127.0.0.1); bind to all
+ // interfaces so the Testcontainers port-mapped session handshake succeeds.
+ .withEnv("dn_rpc_address", "0.0.0.0")
+ .waitingFor(Wait.forListeningPort().withStartupTimeout(IOTDB_STARTUP_TIMEOUT));
+
+ @Test
+ void saveThenFind_roundTripsAllFiveTypesWithLastUpdateTs() throws Exception {
+ TestScope scope = scope("attr_types", "55555555-5555-5555-5555-555555555501");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1000L, "bool", bool("bool", true)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1001L, "long", lng("long", 42L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1002L, "double", dbl("double", 4.2D)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1003L, "string", str("string", "v")));
+ save(
+ dao,
+ scope,
+ AttributeScope.SERVER_SCOPE,
+ attr(1004L, "json", json("json", "{\"v\":1}")));
+
+ assertFind(dao, scope, AttributeScope.SERVER_SCOPE, "bool", 1000L, DataType.BOOLEAN, true);
+ assertFind(dao, scope, AttributeScope.SERVER_SCOPE, "long", 1001L, DataType.LONG, 42L);
+ assertFind(dao, scope, AttributeScope.SERVER_SCOPE, "double", 1002L, DataType.DOUBLE, 4.2D);
+ assertFind(dao, scope, AttributeScope.SERVER_SCOPE, "string", 1003L, DataType.STRING, "v");
+ assertFind(
+ dao, scope, AttributeScope.SERVER_SCOPE, "json", 1004L, DataType.JSON, "{\"v\":1}");
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void save_isDeleteThenInsert_keepsExactlyOneRowOnUpdate() throws Exception {
+ TestScope scope = scope("attr_update", "55555555-5555-5555-5555-555555555502");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ // First write a LONG at ts=1000, then overwrite the same identity with a STRING at ts=2000.
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1000L, "temp", lng("temp", 1L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(2000L, "temp", str("temp", "two")));
+
+ // delete-then-insert convergence: exactly one row, one typed FIELD, the newer value.
+ assertEquals(1, attributeRowCount(pool, scope, AttributeScope.SERVER_SCOPE, "temp"));
+ AttributeKvEntry found =
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, "temp")
+ .orElseThrow();
+ assertEquals(DataType.STRING, found.getDataType());
+ assertEquals("two", found.getValue());
+ assertEquals(2000L, found.getLastUpdateTs());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void sameTimestampTypeChange_getEntryDoesNotThrow() throws Exception {
+ // B1 regression: rewriting the same identity at the SAME timestamp with a different type must
+ // still converge to one typed FIELD (delete-then-insert), so find()'s getEntry never sees two
+ // typed columns and never throws the B1 fail-fast.
+ TestScope scope = scope("attr_b1", "55555555-5555-5555-5555-555555555510");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(3000L, "temp", lng("temp", 1L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(3000L, "temp", str("temp", "two")));
+
+ assertEquals(1, attributeRowCount(pool, scope, AttributeScope.SERVER_SCOPE, "temp"));
+ AttributeKvEntry found =
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, "temp")
+ .orElseThrow();
+ assertEquals(DataType.STRING, found.getDataType());
+ assertEquals("two", found.getValue());
+ assertEquals(3000L, found.getLastUpdateTs());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void scopesAreIsolated() throws Exception {
+ TestScope scope = scope("attr_scope", "55555555-5555-5555-5555-555555555503");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.CLIENT_SCOPE, attr(10L, "shared", lng("shared", 1L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(20L, "shared", lng("shared", 2L)));
+
+ assertEquals(
+ 1L,
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.CLIENT_SCOPE, "shared")
+ .orElseThrow()
+ .getValue());
+ assertEquals(
+ 2L,
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, "shared")
+ .orElseThrow()
+ .getValue());
+
+ // Deleting the CLIENT scope key does not touch the SERVER scope key.
+ removeAll(dao, scope, AttributeScope.CLIENT_SCOPE, List.of("shared"));
+ assertTrue(
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.CLIENT_SCOPE, "shared")
+ .isEmpty());
+ assertTrue(
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, "shared")
+ .isPresent());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findAllAndFindKeys_returnAllAttributesInScope() throws Exception {
+ TestScope scope = scope("attr_all", "55555555-5555-5555-5555-555555555504");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1L, "a", lng("a", 1L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(2L, "b", str("b", "x")));
+
+ List all =
+ dao.findAll(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE);
+ assertEquals(2, all.size());
+ assertEquals(Set.of("a", "b"), new HashSet<>(all.stream().map(KvEntry::getKey).toList()));
+
+ List some =
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, List.of("a"));
+ assertEquals(1, some.size());
+ assertEquals("a", some.get(0).getKey());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void save_returnsLastUpdateTs_removeAllVersionNull_findEmpty() throws Exception {
+ TestScope scope = scope("attr_remove", "55555555-5555-5555-5555-555555555505");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1L, "temp", lng("temp", 1L)));
+
+ Long version =
+ dao.save(
+ scope.tenantId(),
+ scope.entityId(),
+ AttributeScope.SERVER_SCOPE,
+ attr(2L, "temp", lng("temp", 2L)))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ // save returns a non-null version (the attribute's lastUpdateTs); ThingsBoard unboxes it.
+ assertEquals(2L, version.longValue());
+
+ List>> futures =
+ dao.removeAllWithVersions(
+ scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, List.of("temp"));
+ TbPair pair = futures.get(0).get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertEquals("temp", pair.getFirst());
+ assertNull(pair.getSecond());
+
+ assertTrue(
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, "temp")
+ .isEmpty());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void removeAllByEntityId_returnsScopeKeyPairsAndClearsEntity() throws Exception {
+ TestScope scope = scope("attr_byent", "55555555-5555-5555-5555-555555555506");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1L, "a", lng("a", 1L)));
+ save(dao, scope, AttributeScope.CLIENT_SCOPE, attr(2L, "b", str("b", "x")));
+
+ List> removed =
+ dao.removeAllByEntityId(scope.tenantId(), scope.entityId());
+ Set pairKeys = new HashSet<>();
+ for (Pair pair : removed) {
+ pairKeys.add(pair.getLeft().name() + ":" + pair.getRight());
+ }
+ assertEquals(Set.of("SERVER_SCOPE:a", "CLIENT_SCOPE:b"), pairKeys);
+
+ assertTrue(
+ dao.findAll(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE).isEmpty());
+ assertTrue(
+ dao.findAll(scope.tenantId(), scope.entityId(), AttributeScope.CLIENT_SCOPE).isEmpty());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void keyDiscovery_distinctKeysAndDeviceProfileNullTenantWide() throws Exception {
+ TestScope scope = scope("attr_keys", "55555555-5555-5555-5555-555555555507");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1L, "a", lng("a", 1L)));
+ save(dao, scope, AttributeScope.CLIENT_SCOPE, attr(2L, "b", str("b", "x")));
+
+ assertEquals(
+ Set.of("a", "b"),
+ new HashSet<>(dao.findAllKeysByEntityIds(scope.tenantId(), List.of(scope.entityId()))));
+ assertEquals(
+ Set.of("a"),
+ new HashSet<>(
+ dao.findAllKeysByEntityIdsAndScope(
+ scope.tenantId(), List.of(scope.entityId()), AttributeScope.SERVER_SCOPE)));
+ // [v4.3.1.2] async key discovery returns the same set as the synchronous overload.
+ assertEquals(
+ Set.of("a"),
+ new HashSet<>(
+ dao.findAllKeysByEntityIdsAndScopeAsync(
+ scope.tenantId(), List.of(scope.entityId()), AttributeScope.SERVER_SCOPE)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS)));
+ assertEquals(
+ Set.of("a", "b"),
+ new HashSet<>(dao.findAllKeysByDeviceProfileId(scope.tenantId(), null)));
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findLatestByEntityIdsAndScope_returnsLatestValuePerEntityAndKey() throws Exception {
+ // [v4.3.1.2] bulk latest read across an OR-set of entities in one scope.
+ TestScope scope = scope("attr_lat", "55555555-5555-5555-5555-555555555511");
+ bootstrapSchema(scope.database());
+ EntityId secondEntity =
+ new TestEntityId(UUID.fromString("55555555-5555-5555-5555-555555555512"), EntityType.ASSET);
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ // Two writes on the first entity (overwrite => latest), one on the second entity.
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(1L, "temp", lng("temp", 1L)));
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(2L, "temp", lng("temp", 9L)));
+ dao.save(
+ scope.tenantId(),
+ secondEntity,
+ AttributeScope.SERVER_SCOPE,
+ attr(3L, "hum", lng("hum", 5L)))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ // A different scope on the first entity must be excluded by the scope predicate.
+ save(dao, scope, AttributeScope.CLIENT_SCOPE, attr(4L, "ignored", lng("ignored", 7L)));
+
+ List latestSync =
+ dao.findLatestByEntityIdsAndScope(
+ scope.tenantId(),
+ List.of(scope.entityId(), secondEntity),
+ AttributeScope.SERVER_SCOPE);
+ assertEquals(toValueMap(latestSync), Map.of("temp", 9L, "hum", 5L));
+
+ List latestAsync =
+ dao.findLatestByEntityIdsAndScopeAsync(
+ scope.tenantId(),
+ List.of(scope.entityId(), secondEntity),
+ AttributeScope.SERVER_SCOPE)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertEquals(toValueMap(latestAsync), Map.of("temp", 9L, "hum", 5L));
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void keyEscaping_roundTripsKeyWithSingleQuote() throws Exception {
+ TestScope scope = scope("attr_esc", "55555555-5555-5555-5555-555555555508");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ String key = "a'b";
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(5L, key, lng(key, 8L)));
+
+ AttributeKvEntry found =
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, key)
+ .orElseThrow();
+ assertEquals(8L, found.getValue());
+
+ removeAll(dao, scope, AttributeScope.SERVER_SCOPE, List.of(key));
+ assertTrue(
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, key)
+ .isEmpty());
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void keyEscaping_adversarialKeysRoundTripWithoutInjection() throws Exception {
+ TestScope scope = scope("attr_esc2", "55555555-5555-5555-5555-555555555521");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config());
+ try {
+ List keys =
+ List.of(
+ "a'b",
+ "a''b",
+ "O'Brien's \"key\"",
+ "back\\slash",
+ "'; DROP TABLE entity_attributes; --",
+ "' OR '1'='1",
+ "中文键名",
+ "emoji🔑key",
+ "key with spaces");
+ long ts = 1000L;
+ for (String key : keys) {
+ save(dao, scope, AttributeScope.SERVER_SCOPE, attr(ts, key, lng(key, ts)));
+ AttributeKvEntry found =
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, key)
+ .orElseThrow(() -> new AssertionError("round-trip read failed for key: " + key));
+ assertEquals(ts, found.getValue(), "value round-trip for key: " + key);
+ ts++;
+ }
+ // No injection: the "DROP TABLE" key did not drop the table; every key is still present.
+ assertEquals(
+ keys.size(),
+ dao.findAll(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE).size());
+ // The DELETE predicate escapes too: each key deletes independently and exactly.
+ for (String key : keys) {
+ removeAll(dao, scope, AttributeScope.SERVER_SCOPE, List.of(key));
+ assertTrue(
+ dao.find(scope.tenantId(), scope.entityId(), AttributeScope.SERVER_SCOPE, key)
+ .isEmpty(),
+ "delete failed for key: " + key);
+ }
+ } finally {
+ dao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void concurrentSaveSameIdentity_convergesToOneRow() throws Exception {
+ TestScope scope = scope("attr_conc", "55555555-5555-5555-5555-555555555509");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config();
+ config.getAttributes().getExecutor().setThreads(8);
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config);
+ ExecutorService callers = Executors.newFixedThreadPool(8);
+ try {
+ int writes = 50;
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+ List> submissions = new ArrayList<>();
+ for (int i = 0; i < writes; i++) {
+ long value = i;
+ submissions.add(
+ callers.submit(
+ () -> {
+ try {
+ start.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ synchronized (futures) {
+ futures.add(
+ dao.save(
+ scope.tenantId(),
+ scope.entityId(),
+ AttributeScope.SERVER_SCOPE,
+ attr(1000L + value, "temp", lng("temp", value))));
+ }
+ }));
+ }
+ start.countDown();
+ for (java.util.concurrent.Future> submission : submissions) {
+ submission.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+ for (ListenableFuture future : futures) {
+ future.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ // §3.5 regression: single-JVM per-identity lock must converge to exactly one row.
+ assertEquals(1, attributeRowCount(pool, scope, AttributeScope.SERVER_SCOPE, "temp"));
+ } finally {
+ callers.shutdownNow();
+ dao.destroy();
+ }
+ }
+ }
+
+ // ---- helpers ----
+
+ private void save(
+ IoTDBTableAttributesDao dao,
+ TestScope scope,
+ AttributeScope attributeScope,
+ AttributeKvEntry attribute)
+ throws Exception {
+ dao.save(scope.tenantId(), scope.entityId(), attributeScope, attribute)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ private void removeAll(
+ IoTDBTableAttributesDao dao,
+ TestScope scope,
+ AttributeScope attributeScope,
+ List keys)
+ throws Exception {
+ for (ListenableFuture future :
+ dao.removeAll(scope.tenantId(), scope.entityId(), attributeScope, keys)) {
+ future.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+ }
+
+ private void assertFind(
+ IoTDBTableAttributesDao dao,
+ TestScope scope,
+ AttributeScope attributeScope,
+ String key,
+ long lastUpdateTs,
+ DataType dataType,
+ Object value) {
+ AttributeKvEntry entry =
+ dao.find(scope.tenantId(), scope.entityId(), attributeScope, key).orElseThrow();
+ assertEquals(key, entry.getKey());
+ assertEquals(dataType, entry.getDataType());
+ assertEquals(value, entry.getValue());
+ assertEquals(lastUpdateTs, entry.getLastUpdateTs());
+ assertNull(entry.getVersion());
+ }
+
+ private Map toValueMap(List entries) {
+ Map values = new HashMap<>();
+ for (AttributeKvEntry entry : entries) {
+ values.put(entry.getKey(), entry.getValue());
+ }
+ return values;
+ }
+
+ private int attributeRowCount(
+ ITableSessionPool pool, TestScope scope, AttributeScope attributeScope, String key)
+ throws Exception {
+ try (ITableSession session = pool.getSession();
+ SessionDataSet dataSet =
+ session.executeQueryStatement(
+ "SELECT bool_v,long_v,double_v,str_v,json_v FROM entity_attributes WHERE tenant_id='"
+ + scope.tenantId().getId()
+ + "' AND entity_type='DEVICE' AND entity_id='"
+ + scope.entityId().getId()
+ + "' AND attribute_scope='"
+ + attributeScope.name()
+ + "' AND key='"
+ + key.replace("'", "''")
+ + "'")) {
+ SessionDataSet.DataIterator rows = dataSet.iterator();
+ int rowCount = 0;
+ while (rows.next()) {
+ rowCount++;
+ }
+ return rowCount;
+ }
+ }
+
+ private ITableSessionPool newPool(String database) {
+ TableSessionPoolBuilder builder =
+ new TableSessionPoolBuilder()
+ .nodeUrls(List.of("127.0.0.1:" + IOTDB.getMappedPort(6667)))
+ .user("root")
+ .password("root")
+ .maxSize(4);
+ if (database != null) {
+ builder.database(database);
+ }
+ return builder.build();
+ }
+
+ private void bootstrapSchema(String database) throws Exception {
+ awaitIoTDBReady(database);
+
+ String schema;
+ try (InputStream stream =
+ IoTDBTableAttributesDaoIT.class
+ .getClassLoader()
+ .getResourceAsStream("schema-iotdb-table.sql")) {
+ schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ schema =
+ schema
+ .replace(
+ "CREATE DATABASE IF NOT EXISTS thingsboard;",
+ "CREATE DATABASE IF NOT EXISTS " + database + ";")
+ .replace("USE thingsboard;", "USE " + database + ";");
+ schema = schema.replaceAll("(?s)/\\*.*?\\*/", "").replaceAll("(?m)--.*$", "");
+ try (ITableSessionPool bootstrapPool = newPool(null)) {
+ try (ITableSession session = bootstrapPool.getSession()) {
+ for (String statement : schema.split(";")) {
+ String trimmed = statement.trim();
+ if (!trimmed.isEmpty()) {
+ session.executeNonQueryStatement(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ private void awaitIoTDBReady(String database) throws Exception {
+ long deadlineNanos = System.nanoTime() + IOTDB_READY_TIMEOUT.toNanos();
+ Exception lastFailure = null;
+ while (System.nanoTime() < deadlineNanos) {
+ try (ITableSessionPool bootstrapPool = newPool(null);
+ ITableSession session = bootstrapPool.getSession()) {
+ session.executeNonQueryStatement("CREATE DATABASE IF NOT EXISTS " + database);
+ return;
+ } catch (Exception e) {
+ lastFailure = e;
+ long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime());
+ if (remainingMillis <= 0) {
+ break;
+ }
+ Thread.sleep(Math.min(IOTDB_READY_POLL_INTERVAL.toMillis(), remainingMillis));
+ }
+ }
+ throw new IllegalStateException(
+ "IoTDB did not accept table-session statements within " + IOTDB_READY_TIMEOUT, lastFailure);
+ }
+
+ private IoTDBTableConfig config() {
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.getAttributes().getExecutor().setThreads(2);
+ config.getAttributes().setClusterMode("disabled");
+ return config;
+ }
+
+ private TestScope scope(String databasePrefix, String entityId) {
+ return new TestScope(
+ uniqueDatabase(databasePrefix),
+ new TenantId(UUID.fromString("44444444-4444-4444-4444-444444444444")),
+ new TestEntityId(UUID.fromString(entityId), EntityType.DEVICE));
+ }
+
+ private String uniqueDatabase(String prefix) {
+ // IoTDB caps database names at 64 chars; keep the per-test prefix short and append a trimmed
+ // UUID so the total length stays well within the limit.
+ String shortPrefix = prefix.length() > 12 ? prefix.substring(0, 12) : prefix;
+ String shortUuid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ return "tb_at_" + shortPrefix + "_" + shortUuid;
+ }
+
+ private TestAttributeKvEntry attr(long lastUpdateTs, String key, KvEntry value) {
+ return new TestAttributeKvEntry(lastUpdateTs, value);
+ }
+
+ private KvEntry bool(String key, boolean value) {
+ return new BooleanDataEntry(key, value);
+ }
+
+ private KvEntry lng(String key, long value) {
+ return new LongDataEntry(key, value);
+ }
+
+ private KvEntry dbl(String key, double value) {
+ return new DoubleDataEntry(key, value);
+ }
+
+ private KvEntry str(String key, String value) {
+ return new StringDataEntry(key, value);
+ }
+
+ private KvEntry json(String key, String value) {
+ return new JsonDataEntry(key, value);
+ }
+
+ private record TestScope(String database, TenantId tenantId, EntityId entityId) {}
+
+ private record TestEntityId(UUID id, EntityType entityType) implements EntityId {
+ @Override
+ public UUID getId() {
+ return id;
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return entityType;
+ }
+ }
+
+ private static final class TestAttributeKvEntry extends BaseAttributeKvEntry {
+ private TestAttributeKvEntry(long lastUpdateTs, KvEntry kv) {
+ super(kv, lastUpdateTs);
+ }
+ }
+}
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoTest.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoTest.java
new file mode 100644
index 00000000..773dc97e
--- /dev/null
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDaoTest.java
@@ -0,0 +1,931 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.rpc.IoTDBConnectionException;
+import org.apache.iotdb.rpc.StatementExecutionException;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.write.record.Tablet;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DataType;
+import org.thingsboard.server.common.data.kv.KvEntry;
+import org.thingsboard.server.common.data.kv.LongDataEntry;
+import org.thingsboard.server.common.data.kv.StringDataEntry;
+import org.thingsboard.server.common.data.util.TbPair;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class IoTDBTableAttributesDaoTest {
+ private static final TenantId TENANT_ID =
+ new TenantId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
+ private static final EntityId ENTITY_ID =
+ new TestEntityId(UUID.fromString("22222222-2222-2222-2222-222222222222"), EntityType.DEVICE);
+ private static final EntityId SECOND_ENTITY_ID =
+ new TestEntityId(UUID.fromString("33333333-3333-3333-3333-333333333333"), EntityType.ASSET);
+
+ private final List daos = new ArrayList<>();
+
+ @AfterEach
+ void tearDown() {
+ for (IoTDBTableAttributesDao dao : daos) {
+ dao.destroy();
+ }
+ daos.clear();
+ }
+
+ @Test
+ void save_buildsDeleteThenInsertWithLastUpdateTsTimeAndOneTypedField() throws Exception {
+ TestContext context = newContext();
+
+ Long version =
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(7000L, "temperature", new LongDataEntry("temperature", 42L)))
+ .get(3, TimeUnit.SECONDS);
+
+ // save MUST return a non-null version (ThingsBoard unboxes it into AttributeKv(..., long)).
+ // IoTDB has no sequence, so it is the attribute's lastUpdateTs.
+ assertEquals(7000L, version.longValue());
+
+ // delete-then-insert: the DELETE is a tag-only equality match (no time predicate) so it removes
+ // the identity across all time, then a single attribute row is inserted at time=lastUpdateTs.
+ ArgumentCaptor deleteSql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeNonQueryStatement(deleteSql.capture());
+ assertEquals(
+ "DELETE FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND key='temperature'",
+ deleteSql.getValue());
+
+ ArgumentCaptor tablet = ArgumentCaptor.forClass(Tablet.class);
+ verify(context.session(), timeout(3000)).insert(tablet.capture());
+ Tablet inserted = tablet.getValue();
+ assertEquals("entity_attributes", inserted.getTableName());
+ assertEquals(1, inserted.getRowSize());
+ assertEquals(7000L, inserted.getTimestamp(0));
+ }
+
+ @Test
+ void save_tabletColumnsFollowNewDdlTagOrder() throws Exception {
+ // TAG-order rot guard: the Tablet column schema must follow the entity_attributes
+ // DDL tag order (attribute_scope, entity_type, tenant_id, key, entity_id), then the five typed
+ // FIELDs, with NO ColumnCategory.TIME entry (the time column is written via addTimestamp).
+ TestContext context = newContext();
+
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(7000L, "temperature", new LongDataEntry("temperature", 42L)))
+ .get(3, TimeUnit.SECONDS);
+
+ ArgumentCaptor tablet = ArgumentCaptor.forClass(Tablet.class);
+ verify(context.session(), timeout(3000)).insert(tablet.capture());
+ Tablet inserted = tablet.getValue();
+
+ List columnNames =
+ inserted.getSchemas().stream()
+ .map(org.apache.tsfile.write.schema.IMeasurementSchema::getMeasurementName)
+ .toList();
+ assertEquals(
+ List.of(
+ "attribute_scope",
+ "entity_type",
+ "tenant_id",
+ "key",
+ "entity_id",
+ "bool_v",
+ "long_v",
+ "double_v",
+ "str_v",
+ "json_v"),
+ columnNames);
+ // The tablet schema carries exactly the 10 non-time columns: 5 TAG then 5 FIELD, with no other
+ // category. The `time TIMESTAMP TIME` column is written via addTimestamp, not as a tablet
+ // column, so it never appears here (tsfile's ColumnCategory has only TAG/FIELD/ATTRIBUTE).
+ List categories = inserted.getColumnTypes();
+ assertEquals(10, categories.size());
+ for (int i = 0; i < 5; i++) {
+ assertEquals(ColumnCategory.TAG, categories.get(i), "tag column " + i);
+ }
+ for (int i = 5; i < 10; i++) {
+ assertEquals(ColumnCategory.FIELD, categories.get(i), "field column " + i);
+ }
+ }
+
+ @Test
+ void save_runsDeleteBeforeInsert() throws Exception {
+ TestContext context = newContext();
+
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.CLIENT_SCOPE,
+ attribute(1L, "k", new BooleanDataEntry("k", true)))
+ .get(3, TimeUnit.SECONDS);
+
+ // Delete-first is required so a same-timestamp type change converges to one typed column.
+ org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(context.session());
+ inOrder.verify(context.session()).executeNonQueryStatement(anyString());
+ inOrder.verify(context.session()).insert(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void find_buildsSingleRowSqlAndMapsToBaseAttributeKvEntry() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(row(150L, "long_v", 42L));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ Optional result =
+ context.dao().find(TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, "temperature");
+
+ assertTrue(result.isPresent());
+ AttributeKvEntry entry = result.get();
+ assertEquals("temperature", entry.getKey());
+ assertEquals(DataType.LONG, entry.getDataType());
+ assertEquals(42L, entry.getValue());
+ // The time column is the attribute's last-update timestamp.
+ assertEquals(150L, entry.getLastUpdateTs());
+ assertNull(entry.getVersion());
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT time, bool_v, long_v, double_v, str_v, json_v FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND key='temperature'",
+ sql.getValue());
+ }
+
+ @Test
+ void find_returnsEmptyWhenNoRow() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet emptyDataSet = dataSet();
+ when(context.session().executeQueryStatement(anyString())).thenReturn(emptyDataSet);
+
+ Optional result =
+ context.dao().find(TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, "absent");
+
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void find_mapsAllFiveTypes() throws Exception {
+ assertSingleType("bool_v", true, DataType.BOOLEAN, true);
+ assertSingleType("long_v", 7L, DataType.LONG, 7L);
+ assertSingleType("double_v", 3.5D, DataType.DOUBLE, 3.5D);
+ assertSingleType("str_v", "hi", DataType.STRING, "hi");
+ assertSingleType("json_v", "{\"a\":1}", DataType.JSON, "{\"a\":1}");
+ }
+
+ private void assertSingleType(String column, Object stored, DataType type, Object expected)
+ throws Exception {
+ TestContext context = newContext();
+ SessionDataSet typedDataSet = dataSet(row(99L, column, stored));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(typedDataSet);
+
+ AttributeKvEntry entry =
+ context.dao().find(TENANT_ID, ENTITY_ID, AttributeScope.SHARED_SCOPE, "k").orElseThrow();
+
+ assertEquals(type, entry.getDataType());
+ assertEquals(expected, entry.getValue());
+ assertEquals(99L, entry.getLastUpdateTs());
+ }
+
+ @Test
+ void findKeys_buildsInClauseAndMapsRowsPerKey() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet =
+ dataSet(
+ keyedRow("temperature", 100L, "long_v", 1L),
+ keyedRow("humidity", 200L, "double_v", 2.0D));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List result =
+ context
+ .dao()
+ .find(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ List.of("temperature", "humidity"));
+
+ assertEquals(2, result.size());
+ assertEquals("temperature", result.get(0).getKey());
+ assertEquals(1L, result.get(0).getValue());
+ assertEquals(100L, result.get(0).getLastUpdateTs());
+ assertEquals("humidity", result.get(1).getKey());
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT key, time, bool_v, long_v, double_v, str_v, json_v FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND key IN ('temperature','humidity')",
+ sql.getValue());
+ }
+
+ @Test
+ void findKeys_emptyCollectionReturnsEmptyAndSkipsQuery() throws Exception {
+ TestContext context = newContext();
+
+ assertEquals(
+ List.of(),
+ context.dao().find(TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, List.of()));
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void findAll_buildsScopeSqlAndMapsRowsPerKey() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet =
+ dataSet(keyedRow("a", 10L, "long_v", 1L), keyedRow("b", 20L, "str_v", "x"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List result =
+ context.dao().findAll(TENANT_ID, ENTITY_ID, AttributeScope.CLIENT_SCOPE);
+
+ assertEquals(2, result.size());
+ assertEquals("a", result.get(0).getKey());
+ assertEquals("b", result.get(1).getKey());
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT key, time, bool_v, long_v, double_v, str_v, json_v FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "AND attribute_scope='CLIENT_SCOPE'",
+ sql.getValue());
+ }
+
+ @Test
+ void removeAll_returnsOneFuturePerKeyAndEachDeletes() throws Exception {
+ TestContext context = newContext();
+
+ List> futures =
+ context
+ .dao()
+ .removeAll(
+ TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, List.of("temperature", "speed"));
+
+ assertEquals(2, futures.size());
+ assertEquals("temperature", futures.get(0).get(3, TimeUnit.SECONDS));
+ assertEquals("speed", futures.get(1).get(3, TimeUnit.SECONDS));
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000).times(2)).executeNonQueryStatement(sql.capture());
+ List statements = sql.getAllValues();
+ assertTrue(
+ statements.stream()
+ .anyMatch(
+ s ->
+ s.equals(
+ "DELETE FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND key='temperature'")));
+ assertTrue(statements.stream().anyMatch(s -> s.endsWith("AND key='speed'")));
+ }
+
+ @Test
+ void removeAllWithVersions_returnsTbPairKeyNullVersionPerKey() throws Exception {
+ TestContext context = newContext();
+
+ List>> futures =
+ context
+ .dao()
+ .removeAllWithVersions(
+ TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, List.of("temperature"));
+
+ assertEquals(1, futures.size());
+ TbPair pair = futures.get(0).get(3, TimeUnit.SECONDS);
+ assertEquals("temperature", pair.getFirst());
+ assertNull(pair.getSecond());
+
+ verify(context.session(), timeout(3000)).executeNonQueryStatement(anyString());
+ }
+
+ @Test
+ void findNextBatch_throwsUnsupportedOperation() throws Exception {
+ TestContext context = newContext();
+
+ UnsupportedOperationException ex =
+ assertThrows(
+ UnsupportedOperationException.class,
+ () -> context.dao().findNextBatch(UUID.randomUUID(), 1, 1, 100));
+ assertTrue(ex.getMessage().contains("migration helper"));
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void findAllKeysByEntityIds_buildsDistinctKeyByEntitySql() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(keyRow("temperature"), keyRow("humidity"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List keys =
+ context.dao().findAllKeysByEntityIds(TENANT_ID, List.of(ENTITY_ID, SECOND_ENTITY_ID));
+
+ assertEquals(List.of("temperature", "humidity"), keys);
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT DISTINCT key FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND ((entity_type='DEVICE' AND entity_id='22222222-2222-2222-2222-222222222222') "
+ + "OR (entity_type='ASSET' AND entity_id='33333333-3333-3333-3333-333333333333')) "
+ + "ORDER BY key",
+ sql.getValue());
+ }
+
+ @Test
+ void findAllKeysByEntityIdsAndScope_addsScopePredicate() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(keyRow("temperature"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List keys =
+ context
+ .dao()
+ .findAllKeysByEntityIdsAndScope(
+ TENANT_ID, List.of(ENTITY_ID), AttributeScope.SHARED_SCOPE);
+
+ assertEquals(List.of("temperature"), keys);
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT DISTINCT key FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND attribute_scope='SHARED_SCOPE' "
+ + "AND ((entity_type='DEVICE' AND entity_id='22222222-2222-2222-2222-222222222222')) "
+ + "ORDER BY key",
+ sql.getValue());
+ }
+
+ @Test
+ void findAllKeysByEntityIdsAndScopeAsync_runsTheSameDistinctKeySql() throws Exception {
+ // [v4.3.1.2] async wrapper: same SQL as the synchronous overload, on the IO executor.
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(keyRow("temperature"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List keys =
+ context
+ .dao()
+ .findAllKeysByEntityIdsAndScopeAsync(
+ TENANT_ID, List.of(ENTITY_ID), AttributeScope.SHARED_SCOPE)
+ .get(3, TimeUnit.SECONDS);
+
+ assertEquals(List.of("temperature"), keys);
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT DISTINCT key FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND attribute_scope='SHARED_SCOPE' "
+ + "AND ((entity_type='DEVICE' AND entity_id='22222222-2222-2222-2222-222222222222')) "
+ + "ORDER BY key",
+ sql.getValue());
+ }
+
+ @Test
+ void findAllKeysByEntityIdsAndScopeAsync_emptyEntityIdsReturnsImmediateEmpty() throws Exception {
+ TestContext context = newContext();
+
+ List keys =
+ context
+ .dao()
+ .findAllKeysByEntityIdsAndScopeAsync(TENANT_ID, List.of(), AttributeScope.SHARED_SCOPE)
+ .get(3, TimeUnit.SECONDS);
+
+ assertEquals(List.of(), keys);
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void findLatestByEntityIdsAndScope_buildsBulkOrSetSqlAndMapsRows() throws Exception {
+ // [v4.3.1.2] bulk latest read: one SELECT over the entity OR-set in a scope, one row per
+ // (entity, key) because delete-then-insert keeps a single current row per identity.
+ TestContext context = newContext();
+ SessionDataSet dataSet =
+ dataSet(keyedRow("a", 10L, "long_v", 1L), keyedRow("b", 20L, "str_v", "x"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List result =
+ context
+ .dao()
+ .findLatestByEntityIdsAndScope(
+ TENANT_ID, List.of(ENTITY_ID, SECOND_ENTITY_ID), AttributeScope.SERVER_SCOPE);
+
+ assertEquals(2, result.size());
+ assertEquals("a", result.get(0).getKey());
+ assertEquals(1L, result.get(0).getValue());
+ assertEquals(10L, result.get(0).getLastUpdateTs());
+ assertNull(result.get(0).getVersion());
+ assertEquals("b", result.get(1).getKey());
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT key, time, bool_v, long_v, double_v, str_v, json_v FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND ((entity_type='DEVICE' AND entity_id='22222222-2222-2222-2222-222222222222') "
+ + "OR (entity_type='ASSET' AND entity_id='33333333-3333-3333-3333-333333333333'))",
+ sql.getValue());
+ }
+
+ @Test
+ void findLatestByEntityIdsAndScopeAsync_runsTheSameBulkRead() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(keyedRow("a", 10L, "long_v", 1L));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List result =
+ context
+ .dao()
+ .findLatestByEntityIdsAndScopeAsync(
+ TENANT_ID, List.of(ENTITY_ID), AttributeScope.SERVER_SCOPE)
+ .get(3, TimeUnit.SECONDS);
+
+ assertEquals(1, result.size());
+ assertEquals("a", result.get(0).getKey());
+ assertEquals(1L, result.get(0).getValue());
+
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT key, time, bool_v, long_v, double_v, str_v, json_v FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND attribute_scope='SERVER_SCOPE' "
+ + "AND ((entity_type='DEVICE' AND entity_id='22222222-2222-2222-2222-222222222222'))",
+ sql.getValue());
+ }
+
+ @Test
+ void findLatestByEntityIdsAndScopeAsync_emptyEntityIdsReturnsImmediateEmpty() throws Exception {
+ TestContext context = newContext();
+
+ List result =
+ context
+ .dao()
+ .findLatestByEntityIdsAndScopeAsync(TENANT_ID, List.of(), AttributeScope.SERVER_SCOPE)
+ .get(3, TimeUnit.SECONDS);
+
+ assertEquals(List.of(), result);
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void findAllKeysByEntityIds_emptyListReturnsEmptyAndSkipsQuery() throws Exception {
+ TestContext context = newContext();
+
+ assertEquals(List.of(), context.dao().findAllKeysByEntityIds(TENANT_ID, List.of()));
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void findAllKeysByDeviceProfileId_nullProfileReturnsTenantWideDistinctKeys() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet dataSet = dataSet(keyRow("temperature"), keyRow("humidity"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(dataSet);
+
+ List keys = context.dao().findAllKeysByDeviceProfileId(TENANT_ID, null);
+
+ assertEquals(List.of("temperature", "humidity"), keys);
+ ArgumentCaptor sql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(sql.capture());
+ assertEquals(
+ "SELECT DISTINCT key FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "ORDER BY key",
+ sql.getValue());
+ }
+
+ @Test
+ void findAllKeysByDeviceProfileId_nonNullProfileReturnsEmptyDeferred() throws Exception {
+ TestContext context = newContext();
+
+ List keys =
+ context
+ .dao()
+ .findAllKeysByDeviceProfileId(
+ TENANT_ID,
+ new DeviceProfileId(UUID.fromString("44444444-4444-4444-4444-444444444444")));
+
+ assertEquals(List.of(), keys);
+ verify(context.pool(), never()).getSession();
+ }
+
+ @Test
+ void removeAllByEntityId_selectsThenDeletesAndReturnsScopeKeyPairs() throws Exception {
+ TestContext context = newContext();
+ SessionDataSet selectResult =
+ dataSet(scopeKeyRow("SERVER_SCOPE", "temperature"), scopeKeyRow("CLIENT_SCOPE", "active"));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(selectResult);
+
+ List> removed =
+ context.dao().removeAllByEntityId(TENANT_ID, ENTITY_ID);
+
+ assertEquals(2, removed.size());
+ assertEquals(AttributeScope.SERVER_SCOPE, removed.get(0).getLeft());
+ assertEquals("temperature", removed.get(0).getRight());
+ assertEquals(AttributeScope.CLIENT_SCOPE, removed.get(1).getLeft());
+ assertEquals("active", removed.get(1).getRight());
+
+ ArgumentCaptor selectSql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeQueryStatement(selectSql.capture());
+ assertEquals(
+ "SELECT DISTINCT attribute_scope, key FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' "
+ + "ORDER BY attribute_scope, key",
+ selectSql.getValue());
+
+ ArgumentCaptor deleteSql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeNonQueryStatement(deleteSql.capture());
+ assertEquals(
+ "DELETE FROM entity_attributes "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222'",
+ deleteSql.getValue());
+ }
+
+ @Test
+ void removeAllByEntityId_writeLockExcludesConcurrentSaveUntilItCompletes() throws Exception {
+ // removeAllByEntityId holds the per-entity WRITE lock around its select+delete; a concurrent
+ // same-entity save holds the per-entity READ lock, so the save cannot run its
+ // delete-then-insert
+ // until removeAllByEntityId releases the write lock. This deterministically proves the added
+ // serialization (no row can be re-inserted between the entity-wide select and delete). It is
+ // made deterministic by blocking the removeAll SELECT on a latch while the write lock is held,
+ // then asserting the save's writes have not yet executed.
+ TestContext context = newContext();
+ CountDownLatch selectEntered = new CountDownLatch(1);
+ CountDownLatch releaseSelect = new CountDownLatch(1);
+ SessionDataSet selectResult = dataSet(scopeKeyRow("SERVER_SCOPE", "temperature"));
+ // The first query is removeAllByEntityId's SELECT DISTINCT; block it (while the entity write
+ // lock is held) until the test releases it.
+ when(context.session().executeQueryStatement(anyString()))
+ .thenAnswer(
+ invocation -> {
+ selectEntered.countDown();
+ if (!releaseSelect.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError("removeAll SELECT was not released in time");
+ }
+ return selectResult;
+ });
+
+ Thread remover =
+ new Thread(() -> context.dao().removeAllByEntityId(TENANT_ID, ENTITY_ID), "remover");
+ remover.start();
+ // Wait until removeAllByEntityId is inside its SELECT, i.e. holding the entity write lock.
+ assertTrue(selectEntered.await(5, TimeUnit.SECONDS), "removeAll did not reach its SELECT");
+
+ // Start a concurrent same-entity save; it must block on the entity read lock and NOT issue any
+ // DELETE/INSERT while the write lock is held.
+ ListenableFuture saveFuture =
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(7L, "humidity", new LongDataEntry("humidity", 5L)));
+
+ // Give the save's io-task a chance to run; it must remain blocked on the entity read lock.
+ Thread.sleep(200L);
+ verify(context.session(), never()).executeNonQueryStatement(anyString());
+ assertFalse(saveFuture.isDone(), "save must not complete while removeAll holds the write lock");
+
+ // Release removeAll; it finishes (its DELETE runs), then the save acquires the read lock and
+ // runs its delete-then-insert.
+ releaseSelect.countDown();
+ saveFuture.get(5, TimeUnit.SECONDS);
+ remover.join(5_000L);
+
+ // Both the removeAll entity-wide DELETE and the save's identity DELETE must have executed; the
+ // save's INSERT (tablet) too. Two executeNonQueryStatement calls = removeAll DELETE + save
+ // DELETE (the save INSERT goes through session.insert, not executeNonQueryStatement).
+ verify(context.session(), timeout(3000).times(2)).executeNonQueryStatement(anyString());
+ }
+
+ @Test
+ void findTakesPerIdentityLockSoItCannotReadDuringTheDeleteInsertGap() throws Exception {
+ // find(single) takes the same per-identity lock save() holds across its (non-atomic)
+ // delete-then-insert, so a concurrent same-identity point-read cannot run its SELECT during the
+ // save's critical section -- it therefore never observes the transient empty window between the
+ // DELETE and the INSERT. Deterministic: block the save's DELETE on a latch while it holds the
+ // identity lock, then assert a concurrent same-identity find() stays blocked (never issues its
+ // SELECT) until the save releases the lock.
+ TestContext context = newContext();
+ CountDownLatch deleteEntered = new CountDownLatch(1);
+ CountDownLatch releaseDelete = new CountDownLatch(1);
+ doAnswer(
+ invocation -> {
+ deleteEntered.countDown();
+ if (!releaseDelete.await(5, TimeUnit.SECONDS)) {
+ throw new AssertionError("save DELETE was not released in time");
+ }
+ return null;
+ })
+ .when(context.session())
+ .executeNonQueryStatement(anyString());
+ SessionDataSet valueRow = dataSet(row(7L, "long_v", 5L));
+ when(context.session().executeQueryStatement(anyString())).thenReturn(valueRow);
+
+ ListenableFuture saveFuture =
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(7L, "humidity", new LongDataEntry("humidity", 5L)));
+ assertTrue(deleteEntered.await(5, TimeUnit.SECONDS), "save did not reach its DELETE");
+
+ CountDownLatch findDone = new CountDownLatch(1);
+ boolean[] present = {false};
+ Thread finder =
+ new Thread(
+ () -> {
+ present[0] =
+ context
+ .dao()
+ .find(TENANT_ID, ENTITY_ID, AttributeScope.SERVER_SCOPE, "humidity")
+ .isPresent();
+ findDone.countDown();
+ },
+ "finder");
+ finder.start();
+
+ // While the save holds the identity lock (its DELETE is blocked), find() must block on the same
+ // identity lock and NOT run its SELECT.
+ Thread.sleep(200L);
+ assertEquals(
+ 1L, findDone.getCount(), "find() must block on the per-identity lock during the save");
+ verify(context.session(), never()).executeQueryStatement(anyString());
+
+ // Release the save; it completes and releases the identity lock; find() then runs and returns.
+ releaseDelete.countDown();
+ saveFuture.get(5, TimeUnit.SECONDS);
+ assertTrue(
+ findDone.await(5, TimeUnit.SECONDS),
+ "find() should complete after the save releases the lock");
+ finder.join(5_000L);
+ assertTrue(present[0], "find() must return the value, never the empty delete->insert gap");
+ }
+
+ @Test
+ void save_escapesSingleQuotesInScopeAndKey() throws Exception {
+ TestContext context = newContext();
+
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(5L, "a'b", new StringDataEntry("a'b", "v")))
+ .get(3, TimeUnit.SECONDS);
+
+ ArgumentCaptor deleteSql = ArgumentCaptor.forClass(String.class);
+ verify(context.session(), timeout(3000)).executeNonQueryStatement(deleteSql.capture());
+ assertTrue(deleteSql.getValue().contains("key='a''b'"));
+ }
+
+ @Test
+ void save_blankKeyFailsFast() {
+ TestContext context = newContext();
+
+ IllegalArgumentException ex =
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ context
+ .dao()
+ .save(
+ TENANT_ID,
+ ENTITY_ID,
+ AttributeScope.SERVER_SCOPE,
+ attribute(1L, " ", new LongDataEntry(" ", 1L))));
+ assertTrue(ex.getMessage().contains("must not be blank"));
+ }
+
+ @Test
+ void constructor_failsWhenClusterModeUnset() {
+ ITableSessionPool pool = mock(ITableSessionPool.class);
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.getAttributes().getExecutor().setThreads(1);
+ // iotdb.attributes.cluster_mode defaults to blank -> must fail fast.
+
+ IllegalStateException ex =
+ assertThrows(IllegalStateException.class, () -> new IoTDBTableAttributesDao(pool, config));
+ assertTrue(ex.getMessage().contains("cluster_mode"));
+ }
+
+ @Test
+ void constructor_acceptsDisabledClusterMode() {
+ ITableSessionPool pool = mock(ITableSessionPool.class);
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.getAttributes().getExecutor().setThreads(1);
+ config.getAttributes().setClusterMode("disabled");
+
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config);
+ daos.add(dao);
+ }
+
+ // ---- helpers (mirrors IoTDBTableLatestDaoTest) ----
+
+ private TestContext newContext() {
+ ITableSessionPool pool = mock(ITableSessionPool.class);
+ ITableSession session = mock(ITableSession.class);
+ try {
+ when(pool.getSession()).thenReturn(session);
+ } catch (IoTDBConnectionException e) {
+ throw new AssertionError(e);
+ }
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.getAttributes().getExecutor().setThreads(1);
+ config.getAttributes().setClusterMode("sticky-routing");
+ IoTDBTableAttributesDao dao = new IoTDBTableAttributesDao(pool, config);
+ daos.add(dao);
+ return new TestContext(dao, pool, session);
+ }
+
+ private TestAttributeKvEntry attribute(long lastUpdateTs, String key, KvEntry value) {
+ return new TestAttributeKvEntry(lastUpdateTs, value);
+ }
+
+ private SessionDataSet dataSet(MockRow... rows)
+ throws IoTDBConnectionException, StatementExecutionException {
+ SessionDataSet dataSet = mock(SessionDataSet.class);
+ SessionDataSet.DataIterator iterator = mock(SessionDataSet.DataIterator.class);
+ AtomicInteger index = new AtomicInteger(-1);
+ when(dataSet.iterator()).thenReturn(iterator);
+ when(iterator.next()).thenAnswer(invocation -> index.incrementAndGet() < rows.length);
+ when(iterator.isNull(anyString()))
+ .thenAnswer(invocation -> rows[index.get()].isNull(invocation.getArgument(0)));
+ when(iterator.getBoolean(anyString()))
+ .thenAnswer(invocation -> rows[index.get()].get(invocation.getArgument(0)));
+ when(iterator.getLong(anyString()))
+ .thenAnswer(invocation -> rows[index.get()].get(invocation.getArgument(0)));
+ when(iterator.getDouble(anyString()))
+ .thenAnswer(invocation -> rows[index.get()].get(invocation.getArgument(0)));
+ when(iterator.getString(anyString()))
+ .thenAnswer(invocation -> String.valueOf(rows[index.get()].get(invocation.getArgument(0))));
+ when(iterator.getTimestamp(anyString()))
+ .thenAnswer(
+ invocation -> new Timestamp((Long) rows[index.get()].get(invocation.getArgument(0))));
+ return dataSet;
+ }
+
+ /** A find()-single-key row exposing {@code time} plus one typed column. */
+ private MockRow row(long ts, String column, Object value) {
+ Map columns = new HashMap<>();
+ columns.put("time", ts);
+ columns.put(column, value);
+ return new MockRow(columns);
+ }
+
+ /** A find(keys)/findAll row exposing {@code key}, {@code time} and one typed column. */
+ private MockRow keyedRow(String key, long ts, String column, Object value) {
+ Map columns = new HashMap<>();
+ columns.put("key", key);
+ columns.put("time", ts);
+ columns.put(column, value);
+ return new MockRow(columns);
+ }
+
+ /** A DISTINCT key row exposing only the {@code key} column. */
+ private MockRow keyRow(String key) {
+ Map columns = new HashMap<>();
+ columns.put("key", key);
+ return new MockRow(columns);
+ }
+
+ /** A DISTINCT (scope, key) row for removeAllByEntityId. */
+ private MockRow scopeKeyRow(String scope, String key) {
+ Map columns = new HashMap<>();
+ columns.put("attribute_scope", scope);
+ columns.put("key", key);
+ return new MockRow(columns);
+ }
+
+ private static final class MockRow {
+ private final Map columns;
+
+ private MockRow(Map columns) {
+ this.columns = columns;
+ }
+
+ private boolean isNull(String column) {
+ return columns.get(column) == null;
+ }
+
+ private Object get(String column) {
+ return columns.get(column);
+ }
+ }
+
+ private record TestContext(
+ IoTDBTableAttributesDao dao, ITableSessionPool pool, ITableSession session) {}
+
+ private record TestEntityId(UUID id, EntityType entityType) implements EntityId {
+ @Override
+ public UUID getId() {
+ return id;
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return entityType;
+ }
+ }
+
+ private static final class TestAttributeKvEntry extends BaseAttributeKvEntry {
+ private TestAttributeKvEntry(long lastUpdateTs, KvEntry kv) {
+ super(kv, lastUpdateTs);
+ }
+ }
+}
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAutoConfigurationTest.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAutoConfigurationTest.java
index d2179a05..cf723d48 100644
--- a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAutoConfigurationTest.java
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAutoConfigurationTest.java
@@ -27,15 +27,19 @@
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult;
import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
import org.thingsboard.server.dao.timeseries.TimeseriesDao;
+import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao;
import java.util.List;
+import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -82,6 +86,35 @@ void autoConfigDiscovery_withSelector_createsPoolAndDao() {
});
}
+ @Test
+ void autoConfigDiscovery_withLatestSelector_createsLatestDaoAndDoesNotFailFast() {
+ contextRunner
+ .withPropertyValues(
+ "database.ts.type=iotdb-table",
+ "database.ts_latest.type=iotdb-table",
+ "iotdb.ts.experimental-raw-only=true",
+ "iotdb.ts_latest.cluster_mode=disabled",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ // All three selectors on: our own latest DAO activates and the conflict guard, seeing
+ // only our IoTDB DAO, must NOT fail startup.
+ assertTrue(
+ context.getStartupFailure() == null,
+ "context should start when the only latest DAO is the IoTDB one");
+ assertTrue(
+ context.containsBeanDefinition(
+ IoTDBTableConfiguration.IOTDB_TABLE_LATEST_DAO_BEAN_NAME));
+ assertTrue(context.getBean(IoTDBTableLatestDao.class) != null);
+ });
+ }
+
@Test
void autoConfigDiscovery_withSelectorButNoExperimentalFlag_createsNoBeans() {
contextRunner
@@ -243,6 +276,65 @@ void hostProvidedTimeseriesDaoUsingModuleBeanName_failsFastWhenIoTDBBackendEnabl
});
}
+ @Test
+ void hostProvidedTimeseriesLatestDao_failsFastWhenLatestBackendEnabled() {
+ contextRunner
+ .withUserConfiguration(HostTimeseriesLatestDaoConfiguration.class)
+ .withPropertyValues(
+ "database.ts.type=iotdb-table",
+ "database.ts_latest.type=iotdb-table",
+ "iotdb.ts.experimental-raw-only=true",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ Throwable failure = context.getStartupFailure();
+ assertTrue(failure != null, "context should fail on conflicting TimeseriesLatestDao");
+ assertTrue(
+ failureContains(
+ failure,
+ "database.ts_latest.type=iotdb-table with the IoTDB timeseries backend "
+ + "enabled, but a non-IoTDB TimeseriesLatestDao bean "
+ + "'hostTimeseriesLatestDao' is present; remove it or unset the IoTDB "
+ + "latest selector"),
+ "startup failure should explain the conflicting TimeseriesLatestDao bean: "
+ + failure);
+ });
+ }
+
+ @Test
+ void hostProvidedTimeseriesLatestDaoUsingModuleBeanName_failsFastWhenLatestBackendEnabled() {
+ contextRunner
+ .withUserConfiguration(HostNamedTimeseriesLatestDaoConfiguration.class)
+ .withPropertyValues(
+ "database.ts.type=iotdb-table",
+ "database.ts_latest.type=iotdb-table",
+ "iotdb.ts.experimental-raw-only=true",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ Throwable failure = context.getStartupFailure();
+ assertTrue(
+ failure != null,
+ "context should fail on a host TimeseriesLatestDao using the module bean name");
+ assertTrue(
+ failureContains(failure, "non-IoTDB TimeseriesLatestDao bean"),
+ "startup failure should explain the conflicting TimeseriesLatestDao bean: "
+ + failure);
+ });
+ }
+
private static boolean failureContains(Throwable failure, String expected) {
for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
String message = cause.getMessage();
@@ -302,4 +394,80 @@ public void cleanup(long systemTtl) {
throw new UnsupportedOperationException();
}
}
+
+ @Configuration
+ static class HostTimeseriesLatestDaoConfiguration {
+ @Bean
+ TimeseriesLatestDao hostTimeseriesLatestDao() {
+ return new NoopTimeseriesLatestDao();
+ }
+ }
+
+ @Configuration
+ static class HostNamedTimeseriesLatestDaoConfiguration {
+ @Bean(name = IoTDBTableConfiguration.IOTDB_TABLE_LATEST_DAO_BEAN_NAME)
+ TimeseriesLatestDao ioTDBTableLatestDao() {
+ return new NoopTimeseriesLatestDao();
+ }
+ }
+
+ /** Minimal host-supplied {@link TimeseriesLatestDao} used only to trigger the conflict guard. */
+ private static final class NoopTimeseriesLatestDao implements TimeseriesLatestDao {
+ @Override
+ public ListenableFuture> findLatestOpt(
+ TenantId tenantId, EntityId entityId, String key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture findLatest(
+ TenantId tenantId, EntityId entityId, String key) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture> findAllLatest(TenantId tenantId, EntityId entityId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture saveLatest(
+ TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture removeLatest(
+ TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAllKeysByDeviceProfileId(
+ TenantId tenantId, DeviceProfileId deviceProfileId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAllKeysByEntityIds(TenantId tenantId, List entityIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture> findAllKeysByEntityIdsAsync(
+ TenantId tenantId, List entityIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findLatestByEntityIds(TenantId tenantId, List entityIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ListenableFuture> findLatestByEntityIdsAsync(
+ TenantId tenantId, List entityIds) {
+ throw new UnsupportedOperationException();
+ }
+ }
}
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfigTest.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfigTest.java
index f928fbf5..dbed4c2b 100644
--- a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfigTest.java
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfigTest.java
@@ -67,6 +67,11 @@ void defaults_haveExpectedValues() {
assertEquals(50L, config.getTs().getSave().getRetryInitialBackoffMs());
assertEquals(1000L, config.getTs().getSave().getRetryMaxBackoffMs());
assertEquals(10000, config.getTs().getRead().getQueueCapacity());
+ // Attribute executor defaults equal the ts.read defaults so behavior is unchanged by default.
+ assertEquals(4, config.getAttributes().getExecutor().getThreads());
+ assertEquals(10000, config.getAttributes().getExecutor().getQueueCapacity());
+ assertEquals("", config.getAttributes().getClusterMode());
+ assertEquals("", config.getTsLatest().getClusterMode());
}
@Test
@@ -93,7 +98,11 @@ void binding_fromProperties_overridesDefaults() {
Map.entry("iotdb.ts.save.flush-threads", "1"),
Map.entry("iotdb.ts.save.retry-max-attempts", "4"),
Map.entry("iotdb.ts.save.retry-initial-backoff-ms", "10"),
- Map.entry("iotdb.ts.save.retry-max-backoff-ms", "100")));
+ Map.entry("iotdb.ts.save.retry-max-backoff-ms", "100"),
+ Map.entry("iotdb.attributes.cluster_mode", "disabled"),
+ Map.entry("iotdb.attributes.executor.threads", "3"),
+ Map.entry("iotdb.attributes.executor.queue-capacity", "1234"),
+ Map.entry("iotdb.ts_latest.cluster_mode", "sticky-routing")));
IoTDBTableConfig config = new Binder(source).bind("iotdb", IoTDBTableConfig.class).get();
@@ -117,6 +126,10 @@ void binding_fromProperties_overridesDefaults() {
assertEquals(4, config.getTs().getSave().getRetryMaxAttempts());
assertEquals(10L, config.getTs().getSave().getRetryInitialBackoffMs());
assertEquals(100L, config.getTs().getSave().getRetryMaxBackoffMs());
+ assertEquals("disabled", config.getAttributes().getClusterMode());
+ assertEquals(3, config.getAttributes().getExecutor().getThreads());
+ assertEquals(1234, config.getAttributes().getExecutor().getQueueCapacity());
+ assertEquals("sticky-routing", config.getTsLatest().getClusterMode());
}
@Test
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableContextStartupTest.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableContextStartupTest.java
index 0acca26b..a5bb7abd 100644
--- a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableContextStartupTest.java
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableContextStartupTest.java
@@ -51,9 +51,108 @@ void noActivation_contextStartsWithoutIoTDBBeans() {
assertFalse(context.containsBeanDefinition("ioTDBTableTimeseriesDao"));
assertFalse(context.containsBeanDefinition("ioTDBTableLatestDao"));
assertFalse(context.containsBeanDefinition("ioTDBTableLabelDao"));
+ // Path-3 stretch: the attribute DAO is inert by default (no attributes selector set).
+ assertFalse(context.containsBeanDefinition("ioTDBTableAttributesDao"));
});
}
+ // The attribute selector is INDEPENDENT of the timeseries selectors: when ONLY
+ // database.attributes.type=iotdb-table is set (and cluster_mode is acknowledged) the attribute
+ // DAO + its own pool/bootstrap activate, while the timeseries DAO stays absent.
+ @Test
+ void attributesSelectorAlone_activatesAttributesDaoAndPoolIndependentOfTs() {
+ contextRunner
+ .withPropertyValues(
+ "database.attributes.type=iotdb-table",
+ "iotdb.attributes.cluster_mode=disabled",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ assertTrue(context.containsBean(SESSION_POOL_BEAN_NAME));
+ assertTrue(context.containsBeanDefinition("ioTDBTableAttributesDao"));
+ assertTrue(context.getBean(IoTDBTableAttributesDao.class) != null);
+ // No timeseries DAO: the ts selector is unset.
+ assertFalse(context.containsBeanDefinition("ioTDBTableTimeseriesDao"));
+ assertTrue(context.getBeansOfType(TimeseriesDao.class).isEmpty());
+ });
+ }
+
+ // Uppercase, trimmed attribute selector still activates (case-insensitive condition).
+ @Test
+ void attributesSelectorUppercase_stillActivatesAttributesDao() {
+ contextRunner
+ .withPropertyValues(
+ "database.attributes.type=IOTDB-TABLE",
+ "iotdb.attributes.cluster_mode=sticky-routing",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ assertTrue(context.containsBean(SESSION_POOL_BEAN_NAME));
+ assertTrue(context.containsBeanDefinition("ioTDBTableAttributesDao"));
+ });
+ }
+
+ // The timeseries selectors alone must NOT activate the attribute DAO (independent routing).
+ @Test
+ void tsSelectorAlone_doesNotActivateAttributesDao() {
+ contextRunner
+ .withPropertyValues(
+ "database.ts.type=iotdb-table",
+ "iotdb.ts.experimental-raw-only=true",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(context -> assertFalse(context.containsBeanDefinition("ioTDBTableAttributesDao")));
+ }
+
+ // Both selectors ON together: the timeseries and attribute DAOs activate side by side and SHARE a
+ // SINGLE named session pool (deduped via @ConditionalOnMissingBean(name=...)), rather than each
+ // inner @Configuration spinning up its own competing pool. This is the cross-selector path the
+ // per-config pool javadoc asserts in prose; pin it here.
+ @Test
+ void bothSelectors_activateBothDaosSharingOneSessionPool() {
+ contextRunner
+ .withPropertyValues(
+ "database.ts.type=iotdb-table",
+ "iotdb.ts.experimental-raw-only=true",
+ "database.attributes.type=iotdb-table",
+ "iotdb.attributes.cluster_mode=disabled",
+ "iotdb.host=localhost",
+ "iotdb.port=6667",
+ "iotdb.username=root",
+ "iotdb.password=root",
+ "iotdb.session-pool-size=8",
+ "iotdb.connection-timeout-ms=5000",
+ "iotdb.schema.bootstrap=false")
+ .run(
+ context -> {
+ // exactly ONE shared pool, not one per inner @Configuration
+ assertTrue(
+ context.getBeanNamesForType(ITableSessionPool.class).length == 1,
+ "both selectors must share a single session pool");
+ assertTrue(context.containsBean(SESSION_POOL_BEAN_NAME));
+ // both DAOs are present, wired against that one pool
+ assertTrue(context.getBean(IoTDBTableTimeseriesDao.class) != null);
+ assertTrue(context.getBean(IoTDBTableAttributesDao.class) != null);
+ });
+ }
+
@Test
void tsTypeActivationWithExperimentalFlag_createsPoolAndTimeseries() {
contextRunner
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoIT.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoIT.java
new file mode 100644
index 00000000..7d56ee04
--- /dev/null
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoIT.java
@@ -0,0 +1,1006 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.session.pool.TableSessionPoolBuilder;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery;
+import org.thingsboard.server.common.data.kv.DataType;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+class IoTDBTableLatestDaoIT {
+ // Cold testcontainer first writes/reads are slower than a warm production node, so the
+ // per-future assertion timeout is generous; production throughput is covered elsewhere.
+ private static final int FUTURE_TIMEOUT_SECONDS = 30;
+ private static final Duration IOTDB_STARTUP_TIMEOUT = Duration.ofMinutes(3);
+ private static final Duration IOTDB_READY_TIMEOUT = Duration.ofSeconds(60);
+ private static final Duration IOTDB_READY_POLL_INTERVAL = Duration.ofMillis(500);
+
+ @Container
+ static final GenericContainer> IOTDB =
+ new GenericContainer<>(DockerImageName.parse("apache/iotdb:2.0.8-standalone"))
+ .withExposedPorts(6667)
+ // IoTDB binds its client RPC service to dn_rpc_address (default 127.0.0.1); bind to all
+ // interfaces so the Testcontainers port-mapped session handshake succeeds.
+ .withEnv("dn_rpc_address", "0.0.0.0")
+ .waitingFor(Wait.forListeningPort().withStartupTimeout(IOTDB_STARTUP_TIMEOUT));
+
+ @Test
+ void saveThenFindLatest_roundTripsAllFiveTypes() throws Exception {
+ TestScope scope =
+ scope(
+ "latest_types",
+ "55555555-5555-5555-5555-555555555501",
+ "66666666-6666-6666-6666-666666666601");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(5);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // Two rows per key at ascending timestamps; the latest read must return the newer value.
+ saveAll(
+ tsDao,
+ scope,
+ List.of(
+ entry(1000L, "bool", DataType.BOOLEAN, false),
+ entry(1010L, "bool", DataType.BOOLEAN, true),
+ entry(1000L, "long", DataType.LONG, 1L),
+ entry(1010L, "long", DataType.LONG, 42L),
+ entry(1000L, "double", DataType.DOUBLE, 1.0D),
+ entry(1010L, "double", DataType.DOUBLE, 4.2D),
+ entry(1000L, "string", DataType.STRING, "old"),
+ entry(1010L, "string", DataType.STRING, "value"),
+ entry(1000L, "json", DataType.JSON, "{\"v\":0}"),
+ entry(1010L, "json", DataType.JSON, "{\"v\":1}")));
+
+ assertLatest(latestDao, scope, "bool", 1010L, DataType.BOOLEAN, true);
+ assertLatest(latestDao, scope, "long", 1010L, DataType.LONG, 42L);
+ assertLatest(latestDao, scope, "double", 1010L, DataType.DOUBLE, 4.2D);
+ assertLatest(latestDao, scope, "string", 1010L, DataType.STRING, "value");
+ assertLatest(latestDao, scope, "json", 1010L, DataType.JSON, "{\"v\":1}");
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findAllLatest_returnsLatestPerKeyWithoutTypeBackfill() throws Exception {
+ TestScope scope =
+ scope(
+ "latest_all",
+ "55555555-5555-5555-5555-555555555502",
+ "66666666-6666-6666-6666-666666666602");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(8);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // "mixed" changes type over time: long_v@2000 then str_v@2010. LAST_BY must return only the
+ // newer string value and must NOT backfill the older long column into the aggregated row.
+ saveAll(
+ tsDao,
+ scope,
+ List.of(
+ entry(2000L, "mixed", DataType.LONG, 7L),
+ entry(2010L, "mixed", DataType.STRING, "latest"),
+ entry(2000L, "temperature", DataType.DOUBLE, 20.0D),
+ entry(2005L, "temperature", DataType.DOUBLE, 21.5D),
+ entry(2000L, "online", DataType.BOOLEAN, true)));
+
+ List latest =
+ latestDao
+ .findAllLatest(scope.tenantId(), scope.entityId())
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ latest.sort(Comparator.comparing(TsKvEntry::getKey));
+
+ // Sorted by key, so the order is: mixed, online, temperature.
+ assertEquals(3, latest.size());
+ assertEntry(latest.get(0), 2010L, "mixed", DataType.STRING, "latest");
+ assertEntry(latest.get(1), 2000L, "online", DataType.BOOLEAN, true);
+ assertEntry(latest.get(2), 2005L, "temperature", DataType.DOUBLE, 21.5D);
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findLatest_emptyReturnsNullStringEntryFallback() throws Exception {
+ TestScope scope =
+ scope(
+ "latest_empty",
+ "55555555-5555-5555-5555-555555555503",
+ "66666666-6666-6666-6666-666666666603");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ Optional opt =
+ latestDao
+ .findLatestOpt(scope.tenantId(), scope.entityId(), "never-written")
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertTrue(opt.isEmpty());
+
+ TsKvEntry fallback =
+ latestDao
+ .findLatest(scope.tenantId(), scope.entityId(), "never-written")
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertEquals("never-written", fallback.getKey());
+ assertEquals(DataType.STRING, fallback.getDataType());
+ assertNull(fallback.getValue());
+ } finally {
+ latestDao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findLatest_failsOnCrossBatchSameTimestampTypeChange() throws Exception {
+ TestScope scope =
+ scope(
+ "latest_stale",
+ "55555555-5555-5555-5555-555555555505",
+ "66666666-6666-6666-6666-666666666605");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // Cross-batch same-timestamp type change leaves two typed columns set at time=4000 (the
+ // documented Phase-1 limitation). The derived DESC-LIMIT-1 latest read must fail-fast.
+ assertEquals(
+ 1,
+ tsDao
+ .save(
+ scope.tenantId(), scope.entityId(), entry(4000L, "flip", DataType.LONG, 7L), 0)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ assertEquals(
+ 1,
+ tsDao
+ .save(
+ scope.tenantId(),
+ scope.entityId(),
+ entry(4000L, "flip", DataType.STRING, "seven"),
+ 0)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+
+ ExecutionException failure =
+ assertThrows(
+ ExecutionException.class,
+ () ->
+ latestDao
+ .findLatest(scope.tenantId(), scope.entityId(), "flip")
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ assertInstanceOf(IllegalStateException.class, failure.getCause());
+ assertTrue(failure.getCause().getMessage().contains("2 typed value columns set"));
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ // ---- latest overlay (telemetry_latest): second bootstrap, saveLatest, merge, removeLatest ----
+
+ @Test
+ void latestOverlayBootstrap_createsTelemetryLatestTable() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_boot",
+ "55555555-5555-5555-5555-555555555510",
+ "66666666-6666-6666-6666-666666666610");
+ bootstrapSchema(scope.database());
+ // The SECOND bootstrap (schema-iotdb-table-latest.sql via the 3-arg ctor) creates the overlay.
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ // Querying telemetry_latest must succeed (table exists); it is empty until a saveLatest.
+ try (ITableSession session = pool.getSession();
+ SessionDataSet dataSet =
+ session.executeQueryStatement("SELECT key FROM telemetry_latest")) {
+ assertFalse(dataSet.iterator().next(), "telemetry_latest should exist and be empty");
+ }
+ // Idempotency: a second bootstrap run must not throw.
+ bootstrapLatestSchema(scope.database());
+ }
+ }
+
+ @Test
+ void withoutLatestBootstrap_telemetryLatestTableIsNotCreated() throws Exception {
+ // The latest overlay DDL lives in a SEPARATE resource gated by the latest selector; the base
+ // bootstrap alone must NOT create telemetry_latest (latest-off => overlay table absent).
+ TestScope scope =
+ scope(
+ "lt_off",
+ "55555555-5555-5555-5555-555555555511",
+ "66666666-6666-6666-6666-666666666611");
+ bootstrapSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database());
+ ITableSession session = pool.getSession()) {
+ assertThrows(
+ Exception.class, () -> session.executeQueryStatement("SELECT key FROM telemetry_latest"));
+ }
+ }
+
+ @Test
+ void latestOnlySaveLatest_thenFindLatest_roundTripsWithoutPairedSave() throws Exception {
+ // Load-bearing EntityView LATEST_AND_WS case: saveLatest is called with NO paired tsDao.save(),
+ // so nothing writes the telemetry table. The overlay must capture the value and findLatest must
+ // return it (the no-shadow-table no-op would have dropped it).
+ TestScope scope =
+ scope(
+ "lt_only",
+ "55555555-5555-5555-5555-555555555512",
+ "66666666-6666-6666-6666-666666666612");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ saveLatest(latestDao, scope, entry(1000L, "bool", DataType.BOOLEAN, true));
+ saveLatest(latestDao, scope, entry(1000L, "long", DataType.LONG, 42L));
+ saveLatest(latestDao, scope, entry(1000L, "double", DataType.DOUBLE, 4.2D));
+ saveLatest(latestDao, scope, entry(1000L, "string", DataType.STRING, "value"));
+ saveLatest(latestDao, scope, entry(1000L, "json", DataType.JSON, "{\"v\":1}"));
+
+ assertLatest(latestDao, scope, "bool", 1000L, DataType.BOOLEAN, true);
+ assertLatest(latestDao, scope, "long", 1000L, DataType.LONG, 42L);
+ assertLatest(latestDao, scope, "double", 1000L, DataType.DOUBLE, 4.2D);
+ assertLatest(latestDao, scope, "string", 1000L, DataType.STRING, "value");
+ assertLatest(latestDao, scope, "json", 1000L, DataType.JSON, "{\"v\":1}");
+
+ // Overwrite is delete-then-insert: a single overlay row survives, holding the newer value.
+ saveLatest(latestDao, scope, entry(2000L, "long", DataType.LONG, 99L));
+ assertLatest(latestDao, scope, "long", 2000L, DataType.LONG, 99L);
+ assertEquals(1, overlayRowCount(pool, scope, "long"));
+ } finally {
+ latestDao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findLatest_mergesTelemetryAndOverlayByMaxTs() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_merge",
+ "55555555-5555-5555-5555-555555555513",
+ "66666666-6666-6666-6666-666666666613");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(2);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // k_over: telemetry@1000 then a NEWER overlay@2000 -> overlay wins.
+ saveAll(tsDao, scope, List.of(entry(1000L, "k_over", DataType.LONG, 1L)));
+ saveLatest(latestDao, scope, entry(2000L, "k_over", DataType.LONG, 99L));
+ assertLatest(latestDao, scope, "k_over", 2000L, DataType.LONG, 99L);
+
+ // k_deriv: overlay@2000 then a NEWER telemetry@3000 -> derived wins.
+ saveLatest(latestDao, scope, entry(2000L, "k_deriv", DataType.LONG, 5L));
+ saveAll(tsDao, scope, List.of(entry(3000L, "k_deriv", DataType.LONG, 7L)));
+ assertLatest(latestDao, scope, "k_deriv", 3000L, DataType.LONG, 7L);
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void findAllLatest_mixesDerivedAndOverlayOnlyKeys() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_all_mix",
+ "55555555-5555-5555-5555-555555555514",
+ "66666666-6666-6666-6666-666666666614");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(4);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // derived-only key, overlay-only key, and a key present in both (overlay newer -> wins).
+ saveAll(
+ tsDao,
+ scope,
+ List.of(
+ entry(1000L, "derived", DataType.DOUBLE, 1.5D),
+ entry(1000L, "both", DataType.LONG, 1L)));
+ saveLatest(latestDao, scope, entry(2000L, "overlay", DataType.STRING, "ov"));
+ saveLatest(latestDao, scope, entry(2000L, "both", DataType.LONG, 9L));
+
+ List latest =
+ latestDao
+ .findAllLatest(scope.tenantId(), scope.entityId())
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ latest.sort(Comparator.comparing(TsKvEntry::getKey));
+
+ assertEquals(3, latest.size());
+ assertEntry(latest.get(0), 2000L, "both", DataType.LONG, 9L);
+ assertEntry(latest.get(1), 1000L, "derived", DataType.DOUBLE, 1.5D);
+ assertEntry(latest.get(2), 2000L, "overlay", DataType.STRING, "ov");
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void removeLatest_ofOverlayValue_marksRemovedAndClearsOverlay() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_rm",
+ "55555555-5555-5555-5555-555555555515",
+ "66666666-6666-6666-6666-666666666615");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ saveLatest(latestDao, scope, entry(1500L, "k", DataType.LONG, 7L));
+ assertLatest(latestDao, scope, "k", 1500L, DataType.LONG, 7L);
+
+ TsKvLatestRemovingResult result =
+ latestDao
+ .removeLatest(
+ scope.tenantId(), scope.entityId(), new BaseDeleteTsKvQuery("k", 1000L, 2000L))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertTrue(result.isRemoved());
+ assertNull(result.getVersion());
+
+ // Overlay row deleted and (no telemetry) findLatest is now empty.
+ assertEquals(0, overlayRowCount(pool, scope, "k"));
+ assertTrue(
+ latestDao
+ .findLatestOpt(scope.tenantId(), scope.entityId(), "k")
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .isEmpty());
+ } finally {
+ latestDao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void removeLatest_rewriteResurrectsPriorHistoryIntoOverlay() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_rw",
+ "55555555-5555-5555-5555-555555555516",
+ "66666666-6666-6666-6666-666666666616");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(2);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // History: a prior value@1000 (outside the delete window) and the in-window latest@1500.
+ saveAll(
+ tsDao,
+ scope,
+ List.of(entry(1000L, "k", DataType.LONG, 10L), entry(1500L, "k", DataType.LONG, 20L)));
+ assertLatest(latestDao, scope, "k", 1500L, DataType.LONG, 20L);
+
+ // Delete window [1200, 2000) covers the latest@1500 but not the prior@1000; rewrite=true.
+ TsKvLatestRemovingResult result =
+ latestDao
+ .removeLatest(
+ scope.tenantId(),
+ scope.entityId(),
+ new BaseDeleteTsKvQuery("k", 1200L, 2000L, true))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+ // The removing result carries the resurrected prior value (WS UPDATE, not DELETE).
+ assertTrue(result.isRemoved());
+ assertNull(result.getVersion());
+ assertNotNull(result.getData());
+ assertEquals(1000L, result.getData().getTs());
+ assertEquals(DataType.LONG, result.getData().getDataType());
+ assertEquals(10L, result.getData().getValue());
+
+ // The prior was written back into the overlay (one row, holding 1000/10). The in-window
+ // telemetry row is NOT deleted by this DAO (TB's historical remove is a separate path), so
+ // the derived read still sees 1500 -- assert the overlay write-back directly.
+ assertEquals(1, overlayRowCount(pool, scope, "k"));
+ OverlayRow overlay = overlayRow(pool, scope, "k");
+ assertEquals(1000L, overlay.ts());
+ assertEquals(10L, overlay.longValue());
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void saveLatest_outOfOrderDoesNotRegressOverlayOnlyLatest() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_ooo",
+ "55555555-5555-5555-5555-555555555517",
+ "66666666-6666-6666-6666-666666666617");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // Overlay-only key (no paired tsDao.save()): saveLatest@200 then a BACKDATED
+ // saveLatest@100.
+ // Max-ts-wins: the out-of-order write must NOT regress the latest below the stored 200.
+ saveLatest(latestDao, scope, entry(200L, "k", DataType.LONG, 20L));
+ saveLatest(latestDao, scope, entry(100L, "k", DataType.LONG, 10L));
+ assertLatest(latestDao, scope, "k", 200L, DataType.LONG, 20L);
+ // The backdated write was skipped, so a single overlay row (the 200 value) survives.
+ assertEquals(1, overlayRowCount(pool, scope, "k"));
+
+ // A forward saveLatest@300 is newer than the stored 200 and MUST win.
+ saveLatest(latestDao, scope, entry(300L, "k", DataType.LONG, 30L));
+ assertLatest(latestDao, scope, "k", 300L, DataType.LONG, 30L);
+ } finally {
+ latestDao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void removeLatest_inWindowDerivedKeepsOutOfWindowOverlay() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_oow",
+ "55555555-5555-5555-5555-555555555518",
+ "66666666-6666-6666-6666-666666666618");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(2);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // Overlay holds an out-of-window value @50; a telemetry-only write @150 (no saveLatest) is
+ // the in-window derived latest. removeLatest[100,200) removes the in-window latest but must
+ // NOT wipe the out-of-window overlay @50 (a tag-only all-time delete would). The overlay
+ // value survives as the next latest once TB's separate historical path removes telemetry.
+ saveLatest(latestDao, scope, entry(50L, "k", DataType.LONG, 5L));
+ saveAll(tsDao, scope, List.of(entry(150L, "k", DataType.LONG, 15L)));
+
+ TsKvLatestRemovingResult result =
+ latestDao
+ .removeLatest(
+ scope.tenantId(), scope.entityId(), new BaseDeleteTsKvQuery("k", 100L, 200L))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertTrue(result.isRemoved());
+
+ // The out-of-window overlay row survived.
+ assertEquals(1, overlayRowCount(pool, scope, "k"));
+ OverlayRow overlay = overlayRow(pool, scope, "k");
+ assertEquals(50L, overlay.ts());
+ assertEquals(5L, overlay.longValue());
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void removeLatest_rewritePrefersNewerOverlayOverOlderHistory() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_rwo",
+ "55555555-5555-5555-5555-555555555519",
+ "66666666-6666-6666-6666-666666666619");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(2);
+ IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, config);
+ IoTDBTableTimeseriesDao tsDao = new IoTDBTableTimeseriesDao(pool, writer, config);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ // Out-of-window overlay value @50 plus an older telemetry history @30, with the in-window
+ // derived latest @150. rewrite must resurrect the NEWEST next-older across BOTH stores: the
+ // overlay @50, not the telemetry history @30 (telemetry-only lookup would pick 30).
+ saveLatest(latestDao, scope, entry(50L, "k", DataType.LONG, 5L));
+ saveAll(
+ tsDao,
+ scope,
+ List.of(entry(30L, "k", DataType.LONG, 3L), entry(150L, "k", DataType.LONG, 15L)));
+
+ TsKvLatestRemovingResult result =
+ latestDao
+ .removeLatest(
+ scope.tenantId(),
+ scope.entityId(),
+ new BaseDeleteTsKvQuery("k", 100L, 200L, true))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+ assertTrue(result.isRemoved());
+ assertNotNull(result.getData());
+ assertEquals(50L, result.getData().getTs());
+ assertEquals(5L, result.getData().getValue());
+ OverlayRow overlay = overlayRow(pool, scope, "k");
+ assertEquals(50L, overlay.ts());
+ } finally {
+ latestDao.destroy();
+ tsDao.destroy();
+ writer.destroy();
+ }
+ }
+ }
+
+ @Test
+ void keyEscaping_adversarialKeysRoundTripWithoutInjection() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_esc",
+ "55555555-5555-5555-5555-555555555520",
+ "66666666-6666-6666-6666-666666666620");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(1);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ try {
+ List keys =
+ List.of(
+ "a'b",
+ "a''b",
+ "O'Brien's \"key\"",
+ "back\\slash",
+ "'; DROP TABLE telemetry_latest; --",
+ "' OR '1'='1",
+ "中文键名",
+ "emoji🔑key",
+ "key with spaces");
+ long ts = 1000L;
+ for (String key : keys) {
+ saveLatest(latestDao, scope, entry(ts, key, DataType.LONG, ts));
+ assertLatest(latestDao, scope, key, ts, DataType.LONG, ts);
+ ts++;
+ }
+ // No injection: each key's latest is removable independently (escaped DELETE predicate),
+ // and
+ // the "DROP TABLE" key never dropped the overlay table.
+ long t = 1000L;
+ for (String key : keys) {
+ TsKvLatestRemovingResult result =
+ latestDao
+ .removeLatest(
+ scope.tenantId(), scope.entityId(), new BaseDeleteTsKvQuery(key, 0L, t + 1))
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertTrue(result.isRemoved(), "removeLatest failed for key: " + key);
+ assertTrue(
+ latestDao
+ .findLatestOpt(scope.tenantId(), scope.entityId(), key)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .isEmpty(),
+ "delete left a value for key: " + key);
+ t++;
+ }
+ } finally {
+ latestDao.destroy();
+ }
+ }
+ }
+
+ @Test
+ void concurrentSaveLatestSameIdentity_convergesToOneRowMaxTsWins() throws Exception {
+ TestScope scope =
+ scope(
+ "lt_conc",
+ "55555555-5555-5555-5555-555555555522",
+ "66666666-6666-6666-6666-666666666622");
+ bootstrapSchema(scope.database());
+ bootstrapLatestSchema(scope.database());
+ try (ITableSessionPool pool = newPool(scope.database())) {
+ IoTDBTableConfig config = config(8);
+ IoTDBTableLatestDao latestDao = new IoTDBTableLatestDao(pool, config);
+ ExecutorService callers = Executors.newFixedThreadPool(8);
+ try {
+ int writes = 50;
+ CountDownLatch start = new CountDownLatch(1);
+ List> futures = new ArrayList<>();
+ List> submissions = new ArrayList<>();
+ for (int i = 0; i < writes; i++) {
+ long value = i;
+ submissions.add(
+ callers.submit(
+ () -> {
+ try {
+ start.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ synchronized (futures) {
+ futures.add(
+ latestDao.saveLatest(
+ scope.tenantId(),
+ scope.entityId(),
+ entry(1000L + value, "temp", DataType.LONG, value)));
+ }
+ }));
+ }
+ start.countDown();
+ for (java.util.concurrent.Future> submission : submissions) {
+ submission.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+ for (ListenableFuture future : futures) {
+ future.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ // Per-identity lock + delete-then-insert converge to exactly one overlay row, and the
+ // max-ts
+ // guard makes the highest timestamp win regardless of the concurrent interleaving.
+ assertEquals(1, overlayRowCount(pool, scope, "temp"));
+ assertLatest(latestDao, scope, "temp", 1049L, DataType.LONG, 49L);
+ } finally {
+ callers.shutdownNow();
+ latestDao.destroy();
+ }
+ }
+ }
+
+ private void assertLatest(
+ IoTDBTableLatestDao latestDao,
+ TestScope scope,
+ String key,
+ long expectedTs,
+ DataType dataType,
+ Object value)
+ throws Exception {
+ Optional opt =
+ latestDao
+ .findLatestOpt(scope.tenantId(), scope.entityId(), key)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertTrue(opt.isPresent(), "expected a latest value for key " + key);
+ assertEntry(opt.get(), expectedTs, key, dataType, value);
+
+ TsKvEntry present =
+ latestDao
+ .findLatest(scope.tenantId(), scope.entityId(), key)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ assertEntry(present, expectedTs, key, dataType, value);
+ }
+
+ private void assertEntry(
+ TsKvEntry entry, long expectedTs, String key, DataType dataType, Object value) {
+ assertEquals(expectedTs, entry.getTs());
+ assertEquals(key, entry.getKey());
+ assertEquals(dataType, entry.getDataType());
+ assertEquals(value, entry.getValue());
+ assertEquals(String.valueOf(value), entry.getValueAsString());
+ }
+
+ private ITableSessionPool newPool(String database) {
+ TableSessionPoolBuilder builder =
+ new TableSessionPoolBuilder()
+ .nodeUrls(List.of("127.0.0.1:" + IOTDB.getMappedPort(6667)))
+ .user("root")
+ .password("root")
+ .maxSize(4);
+ if (database != null) {
+ builder.database(database);
+ }
+ return builder.build();
+ }
+
+ private void bootstrapSchema(String database) throws Exception {
+ awaitIoTDBReady(database);
+
+ String schema;
+ try (InputStream stream =
+ IoTDBTableLatestDaoIT.class
+ .getClassLoader()
+ .getResourceAsStream("schema-iotdb-table.sql")) {
+ schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ schema =
+ schema
+ .replace(
+ "CREATE DATABASE IF NOT EXISTS thingsboard;",
+ "CREATE DATABASE IF NOT EXISTS " + database + ";")
+ .replace("USE thingsboard;", "USE " + database + ";");
+ schema = schema.replaceAll("(?s)/\\*.*?\\*/", "").replaceAll("(?m)--.*$", "");
+ try (ITableSessionPool bootstrapPool = newPool(null)) {
+ try (ITableSession session = bootstrapPool.getSession()) {
+ for (String statement : schema.split(";")) {
+ String trimmed = statement.trim();
+ if (!trimmed.isEmpty()) {
+ session.executeNonQueryStatement(trimmed);
+ }
+ }
+ }
+ }
+ }
+
+ private void bootstrapLatestSchema(String database) throws Exception {
+ // Exercise the REAL production bootstrap path: the 3-arg IoTDBTableSchemaBootstrap loading the
+ // telemetry_latest overlay resource. The bootstrap pool has no fixed database (CREATE DATABASE
+ // runs first), and the config carries the target database so the DDL is rewritten onto it.
+ awaitIoTDBReady(database);
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.setDatabase(database);
+ try (ITableSessionPool bootstrapPool = newPool(null)) {
+ new IoTDBTableSchemaBootstrap(
+ bootstrapPool, config, IoTDBTableSchemaBootstrap.LATEST_SCHEMA_RESOURCE)
+ .afterPropertiesSet();
+ }
+ }
+
+ private void awaitIoTDBReady(String database) throws Exception {
+ long deadlineNanos = System.nanoTime() + IOTDB_READY_TIMEOUT.toNanos();
+ Exception lastFailure = null;
+ while (System.nanoTime() < deadlineNanos) {
+ try (ITableSessionPool bootstrapPool = newPool(null);
+ ITableSession session = bootstrapPool.getSession()) {
+ session.executeNonQueryStatement("CREATE DATABASE IF NOT EXISTS " + database);
+ return;
+ } catch (Exception e) {
+ lastFailure = e;
+ long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime());
+ if (remainingMillis <= 0) {
+ break;
+ }
+ Thread.sleep(Math.min(IOTDB_READY_POLL_INTERVAL.toMillis(), remainingMillis));
+ }
+ }
+ throw new IllegalStateException(
+ "IoTDB did not accept table-session statements within " + IOTDB_READY_TIMEOUT, lastFailure);
+ }
+
+ private IoTDBTableConfig config(int batchSize) {
+ IoTDBTableConfig config = new IoTDBTableConfig();
+ config.getTs().getSave().setBatchSize(batchSize);
+ config.getTs().getSave().setMaxLingerMs(20L);
+ config.getTs().getSave().setRetryInitialBackoffMs(1L);
+ config.getTs().getSave().setRetryMaxBackoffMs(1L);
+ config.getTs().getRead().setThreads(1);
+ config.getTsLatest().setClusterMode("disabled");
+ return config;
+ }
+
+ private void saveAll(IoTDBTableTimeseriesDao dao, TestScope scope, List entries)
+ throws Exception {
+ List> futures = new ArrayList<>();
+ for (TestTsKvEntry entry : entries) {
+ futures.add(dao.save(scope.tenantId(), scope.entityId(), entry, 0));
+ }
+ for (ListenableFuture future : futures) {
+ assertEquals(1, future.get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+ }
+
+ private void saveLatest(IoTDBTableLatestDao dao, TestScope scope, TestTsKvEntry entry)
+ throws Exception {
+ // saveLatest writes the overlay only (no paired tsDao.save()) and returns a null version.
+ assertNull(
+ dao.saveLatest(scope.tenantId(), scope.entityId(), entry)
+ .get(FUTURE_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ }
+
+ private int overlayRowCount(ITableSessionPool pool, TestScope scope, String key)
+ throws Exception {
+ try (ITableSession session = pool.getSession();
+ SessionDataSet dataSet =
+ session.executeQueryStatement(
+ "SELECT bool_v,long_v,double_v,str_v,json_v FROM telemetry_latest WHERE tenant_id='"
+ + scope.tenantId().getId()
+ + "' AND entity_type='DEVICE' AND entity_id='"
+ + scope.entityId().getId()
+ + "' AND key='"
+ + key.replace("'", "''")
+ + "'")) {
+ SessionDataSet.DataIterator rows = dataSet.iterator();
+ int rowCount = 0;
+ while (rows.next()) {
+ rowCount++;
+ }
+ return rowCount;
+ }
+ }
+
+ private OverlayRow overlayRow(ITableSessionPool pool, TestScope scope, String key)
+ throws Exception {
+ try (ITableSession session = pool.getSession();
+ SessionDataSet dataSet =
+ session.executeQueryStatement(
+ "SELECT time, long_v FROM telemetry_latest WHERE tenant_id='"
+ + scope.tenantId().getId()
+ + "' AND entity_type='DEVICE' AND entity_id='"
+ + scope.entityId().getId()
+ + "' AND key='"
+ + key.replace("'", "''")
+ + "'")) {
+ SessionDataSet.DataIterator rows = dataSet.iterator();
+ assertTrue(rows.next(), "expected one overlay row for key " + key);
+ return new OverlayRow(rows.getTimestamp("time").getTime(), rows.getLong("long_v"));
+ }
+ }
+
+ private record OverlayRow(long ts, long longValue) {}
+
+ private TestScope scope(String databasePrefix, String tenantId, String entityId) {
+ return new TestScope(
+ uniqueDatabase(databasePrefix),
+ new TenantId(UUID.fromString(tenantId)),
+ new TestEntityId(UUID.fromString(entityId), EntityType.DEVICE));
+ }
+
+ private String uniqueDatabase(String prefix) {
+ // IoTDB caps database names at 64 chars; keep the per-test prefix short and append a trimmed
+ // UUID so the total length stays well within the limit.
+ String shortPrefix = prefix.length() > 12 ? prefix.substring(0, 12) : prefix;
+ String shortUuid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
+ return "tb_lt_" + shortPrefix + "_" + shortUuid;
+ }
+
+ private TestTsKvEntry entry(long ts, String key, DataType dataType, Object value) {
+ return new TestTsKvEntry(ts, key, dataType, value);
+ }
+
+ private record TestScope(String database, TenantId tenantId, EntityId entityId) {}
+
+ private record TestEntityId(UUID id, EntityType entityType) implements EntityId {
+ @Override
+ public UUID getId() {
+ return id;
+ }
+
+ @Override
+ public EntityType getEntityType() {
+ return entityType;
+ }
+ }
+
+ private record TestTsKvEntry(long ts, String key, DataType dataType, Object value)
+ implements TsKvEntry {
+ @Override
+ public long getTs() {
+ return ts;
+ }
+
+ @Override
+ public String getKey() {
+ return key;
+ }
+
+ @Override
+ public DataType getDataType() {
+ return dataType;
+ }
+
+ @Override
+ public Optional getBooleanValue() {
+ return dataType == DataType.BOOLEAN ? Optional.of((Boolean) value) : Optional.empty();
+ }
+
+ @Override
+ public Optional getLongValue() {
+ return dataType == DataType.LONG ? Optional.of((Long) value) : Optional.empty();
+ }
+
+ @Override
+ public Optional getDoubleValue() {
+ return dataType == DataType.DOUBLE ? Optional.of((Double) value) : Optional.empty();
+ }
+
+ @Override
+ public Optional getStrValue() {
+ return dataType == DataType.STRING ? Optional.of((String) value) : Optional.empty();
+ }
+
+ @Override
+ public Optional getJsonValue() {
+ return dataType == DataType.JSON ? Optional.of((String) value) : Optional.empty();
+ }
+
+ @Override
+ public String getValueAsString() {
+ return String.valueOf(value);
+ }
+
+ @Override
+ public Object getValue() {
+ return value;
+ }
+
+ @Override
+ public Long getVersion() {
+ return null;
+ }
+
+ @Override
+ public int getDataPoints() {
+ return 1;
+ }
+ }
+}
diff --git a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoTest.java b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoTest.java
new file mode 100644
index 00000000..8bc7b33c
--- /dev/null
+++ b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDaoTest.java
@@ -0,0 +1,1076 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.rpc.IoTDBConnectionException;
+import org.apache.iotdb.rpc.StatementExecutionException;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.tsfile.enums.ColumnCategory;
+import org.apache.tsfile.write.record.Tablet;
+import org.apache.tsfile.write.schema.IMeasurementSchema;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery;
+import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
+import org.thingsboard.server.common.data.kv.DataType;
+import org.thingsboard.server.common.data.kv.TsKvEntry;
+import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class IoTDBTableLatestDaoTest {
+ private static final TenantId TENANT_ID =
+ new TenantId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
+ private static final EntityId ENTITY_ID =
+ new TestEntityId(UUID.fromString("22222222-2222-2222-2222-222222222222"), EntityType.DEVICE);
+
+ private static final String DERIVED_SQL_PREFIX =
+ "SELECT time, bool_v, long_v, double_v, str_v, json_v FROM telemetry "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' ";
+ private static final String OVERLAY_SQL_PREFIX =
+ "SELECT time, bool_v, long_v, double_v, str_v, json_v FROM telemetry_latest "
+ + "WHERE tenant_id='11111111-1111-1111-1111-111111111111' "
+ + "AND entity_type='DEVICE' "
+ + "AND entity_id='22222222-2222-2222-2222-222222222222' ";
+
+ private final List daos = new ArrayList<>();
+
+ @AfterEach
+ void tearDown() {
+ for (IoTDBTableLatestDao dao : daos) {
+ dao.destroy();
+ }
+ daos.clear();
+ }
+
+ // ---- read merge: derived primary + overlay, max-ts-per-key, overlay wins on tie ----
+
+ @Test
+ void findLatestOpt_mergesDerivedAndOverlay_overlayNewerWins() throws Exception {
+ TestContext context = newContext();
+ // derived@100 (long 7), overlay@150 (long 99) -> overlay is newer and wins.
+ stubReads(context.session(), rows(row(100L, "long_v", 7L)), rows(row(150L, "long_v", 99L)));
+
+ Optional result =
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "temperature").get(3, TimeUnit.SECONDS);
+
+ assertTrue(result.isPresent());
+ assertMappedEntry(result.get(), 150L, "temperature", DataType.LONG, 99L);
+
+ List queries = captureQueries(context.session(), 2);
+ assertTrue(
+ queries.contains(DERIVED_SQL_PREFIX + "AND key='temperature' ORDER BY time DESC LIMIT 1"),
+ "derived point read SQL: " + queries);
+ assertTrue(
+ queries.contains(OVERLAY_SQL_PREFIX + "AND key='temperature' ORDER BY time DESC LIMIT 1"),
+ "overlay point read SQL: " + queries);
+ }
+
+ @Test
+ void findLatestOpt_derivedNewerThanOverlay_derivedWins() throws Exception {
+ TestContext context = newContext();
+ // derived@200 (double 3.5) beats overlay@150 (long 7).
+ stubReads(context.session(), rows(row(200L, "double_v", 3.5D)), rows(row(150L, "long_v", 7L)));
+
+ Optional result =
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "pressure").get(3, TimeUnit.SECONDS);
+
+ assertTrue(result.isPresent());
+ assertMappedEntry(result.get(), 200L, "pressure", DataType.DOUBLE, 3.5D);
+ }
+
+ @Test
+ void findLatestOpt_equalTimestamp_overlayWinsTie() throws Exception {
+ TestContext context = newContext();
+ // Same ts: the overlay value wins the tie (continues the B1 same-timestamp limitation).
+ stubReads(context.session(), rows(row(150L, "long_v", 1L)), rows(row(150L, "str_v", "ov")));
+
+ Optional result =
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "k").get(3, TimeUnit.SECONDS);
+
+ assertTrue(result.isPresent());
+ assertMappedEntry(result.get(), 150L, "k", DataType.STRING, "ov");
+ }
+
+ @Test
+ void findLatestOpt_overlayOnlyKey_returnsOverlay() throws Exception {
+ TestContext context = newContext();
+ // No derived row (the load-bearing latest-only / EntityView case): the overlay supplies it.
+ stubReads(context.session(), rows(), rows(row(150L, "long_v", 5L)));
+
+ Optional result =
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "latest-only").get(3, TimeUnit.SECONDS);
+
+ assertTrue(result.isPresent());
+ assertMappedEntry(result.get(), 150L, "latest-only", DataType.LONG, 5L);
+ }
+
+ @Test
+ void findLatestOpt_returnsEmptyWhenNeitherStoreHasRow() throws Exception {
+ TestContext context = newContext();
+ stubReads(context.session(), rows(), rows());
+
+ Optional result =
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "absent").get(3, TimeUnit.SECONDS);
+
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void findLatestOpt_failsFutureWhenDerivedRowHasTwoTypedColumns() throws Exception {
+ TestContext context = newContext();
+ stubReads(
+ context.session(), rows(rowOf(3000L, Map.of("long_v", 7L, "str_v", "seven"))), rows());
+
+ Throwable cause =
+ assertFutureFailsWith(
+ context.dao().findLatestOpt(TENANT_ID, ENTITY_ID, "same-ts"),
+ IllegalStateException.class);
+ assertTrue(cause.getMessage().contains("2 typed value columns set"));
+ }
+
+ @Test
+ void findLatest_returnsNullStringEntryFallbackWhenEmpty() throws Exception {
+ TestContext context = newContext();
+ stubReads(context.session(), rows(), rows());
+
+ TsKvEntry fallback =
+ context.dao().findLatest(TENANT_ID, ENTITY_ID, "missing").get(3, TimeUnit.SECONDS);
+
+ assertInstanceOf(BasicTsKvEntry.class, fallback);
+ assertEquals("missing", fallback.getKey());
+ assertEquals(DataType.STRING, fallback.getDataType());
+ assertNull(fallback.getValue());
+ assertTrue(fallback.getStrValue().isEmpty());
+ }
+
+ @Test
+ void findLatest_returnsPresentEntryWithoutFallback() throws Exception {
+ TestContext context = newContext();
+ stubReads(context.session(), rows(row(200L, "double_v", 3.5D)), rows());
+
+ TsKvEntry entry =
+ context.dao().findLatest(TENANT_ID, ENTITY_ID, "pressure").get(3, TimeUnit.SECONDS);
+
+ assertMappedEntry(entry, 200L, "pressure", DataType.DOUBLE, 3.5D);
+ }
+
+ @Test
+ void findAllLatest_mergesDerivedAndOverlayByMaxTsPerKey() throws Exception {
+ TestContext context = newContext();
+ // derived: bool@110, count@120, label@130. overlay: count@125 (newer -> wins), extra@140
+ // (only).
+ stubReads(
+ context.session(),
+ rows(
+ aggRow("bool", 110L, "bool_v", true),
+ aggRow("count", 120L, "long_v", 9L),
+ aggRow("label", 130L, "str_v", "ok")),
+ rows(
+ overlayAllRow("count", 125L, "long_v", 99L),
+ overlayAllRow("extra", 140L, "str_v", "x")));
+
+ List