CritasWang commented on code in PR #113:
URL: https://github.com/apache/iotdb-extras/pull/113#discussion_r3527057480
##########
iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java:
##########
@@ -18,25 +18,871 @@
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.SettableFuture;
+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.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.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.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;
+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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p><b>Why the overlay is written on EVERY {@code saveLatest}:</b> 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.
*
- * <p>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.
+ * <ul>
+ * <li>{@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.
+ * <li>{@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.
+ * <li>{@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).
+ * </ul>
*
- * <p>Strategy F keeps this class free of ThingsBoard imports and interface
clauses until the
- * latest-telemetry path is implemented.
+ * <p>Residual Phase-1 limitations (documented):
+ *
+ * <ul>
+ * <li><b>{@code version} is always {@code null}.</b> 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.
+ * <li><b>Telemetry-derived race residual.</b> 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.
+ * <li><b>Non-atomic overlay write (delete-then-insert).</b> 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.
+ * <li><b>Telemetry-only writes (saveWithoutLatest) surface via the derived
read.</b> 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).
+ * <li><b>removeLatest honesty + overlapping deletes.</b> {@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.
+ * <li><b>Overlay growth.</b> {@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).
+ * <li><b>Single-JVM overlay convergence.</b> 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). The sibling AttributesDao gates
this with an explicit
+ * cluster-mode acknowledgement; a symmetric ack for the latest overlay
is an option,
+ * documented here for parity in the meantime.
+ * <li><b>Same-timestamp cross-store type change.</b> The overlay wins an
exact-ts tie, continuing
+ * the documented same-timestamp two-type-column limitation (B1).
+ * </ul>
+ *
+ * <p>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<String> 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<TSDataType> 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<ColumnCategory> COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ private final ThreadPoolExecutor readExecutor;
+ private final Set<ReadTask<?>> readTasks = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean accepting = new AtomicBoolean(true);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private final long shutdownDrainTimeoutMs;
+ // 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<Lock> identityLocks = Striped.lock(256);
+
+ public IoTDBTableLatestDao(ITableSessionPool tableSessionPool,
IoTDBTableConfig config) {
super(tableSessionPool);
+ this.shutdownDrainTimeoutMs =
config.getTs().getSave().getShutdownDrainTimeoutMs();
+ int readThreads = config.getTs().getRead().getThreads();
+ int readQueueCapacity = config.getTs().getRead().getQueueCapacity();
+ this.readExecutor =
+ new ThreadPoolExecutor(
+ readThreads,
+ readThreads,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(readQueueCapacity),
+ readThreadFactory(),
+ new ThreadPoolExecutor.AbortPolicy());
+ }
+
+ @Override
+ public ListenableFuture<Optional<TsKvEntry>> 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<TsKvEntry> 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<List<TsKvEntry>> findAllLatest(TenantId tenantId,
EntityId entityId) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ return submitReadTask(() -> doFindAllLatest(tenantId, entityId));
+ }
+
+ @Override
+ public ListenableFuture<Long> 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).
+ Lock lock = identityLock(tenantId, entityId, key);
+ return submitReadTask(
Review Comment:
写路径(saveLatest/removeLatest)与读路径共用同一个由 iotdb.ts.read.* 配置的有界
readExecutor(已确认)。写入突发填满队列后,并发的仪表盘 findLatest/findAllLatest 会被拒绝并抛出
IoTDBTableReadQueueFullException(line 828),配置名和异常名都指向"读",会把排障方向引偏;javadoc
也未说明写共享读队列。建议:拆分读/写执行器,或至少在异常与文档中说明。
##########
iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDao.java:
##########
@@ -18,25 +18,1014 @@
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.SettableFuture;
+import com.google.common.util.concurrent.Striped;
import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.tuple.Pair;
+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.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.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 org.thingsboard.server.dao.attributes.AttributesDao;
+import org.thingsboard.server.dao.model.sql.AttributeKvEntity;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+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;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
/**
- * Attribute DAO skeleton for the IoTDB Table Mode backend.
+ * Entity-attribute DAO for the IoTDB Table Mode backend.
+ *
+ * <p>Spring activation: {@code database.attributes.type=iotdb-table}. NOTE:
this activation
+ * property is the Phase-1 selector pending upstream ThingsBoard confirmation;
upstream ThingsBoard
+ * does not yet expose an {@code AttributesDao} selector, so the DAO is
<b>inert by default</b> (no
+ * real Phase-1 deployment sets {@code database.attributes.type}, so the {@link
+ * IoTDBTableAttributesEnabledCondition} stays false and the bean is never
instantiated). The
+ * selector is independent of {@code database.ts.type} / {@code
database.ts_latest.type} (the
+ * attribute DAO routes separately); if upstream resolves to a different
property, the condition is
+ * updated. Phase-1 attributes stay in the host entity DB.
+ *
+ * <p>This DAO is wired as an explicit {@code @Bean} in {@link
IoTDBTableConfiguration} (guarded by
+ * the activation property) rather than via component scanning, so the {@code
ITableSessionPool}
+ * constructor parameter is guaranteed to be created first; that avoids the
+ * {@code @ConditionalOnBean} bean-definition ordering trap, where a
component-scanned bean can be
+ * condition-evaluated before the imported configuration's pool {@code @Bean}
is registered.
+ *
+ * <p>Current-state (latest-only) contract on the historical-shaped {@code
entity_attributes} table:
+ * each identity tuple {@code (tenant_id, entity_type, entity_id,
attribute_scope, key)} holds
+ * exactly one current row, so:
*
- * <p>Spring activation is intentionally absent because upstream ThingsBoard
does not expose an
- * AttributesDao selector yet. The activation property and SPI binding should
be introduced together
- * when the attributes path is implemented.
+ * <ul>
+ * <li>{@link #save} is delete-then-insert: a tag-only {@code DELETE} (no
time predicate) removes
+ * the identity across all time, then one row is inserted at {@code time
=
+ * attribute.getLastUpdateTs()} with exactly one typed FIELD set.
Delete-first is required: an
+ * IoTDB insert at an existing {@code (tags, time)} merges typed
columns, so a same-timestamp
+ * type change would otherwise leave two typed columns — the
same-timestamp two-type-column
+ * limitation (B1), enforced fail-fast. Both statements run under a
per-identity in-JVM lock
+ * so concurrent same-identity writes converge to a single row. The
write is non-atomic (IoTDB
+ * has no multi-statement transaction): an {@code INSERT} failure after
the {@code DELETE}
+ * commits loses the prior value — see the limitations list.
+ * <li>{@code find}/{@code findAll} are synchronous single/multi-row {@code
SELECT}s that run on
+ * the calling thread (the ThingsBoard service wraps them in {@code
+ * Futures.immediateFuture(...)}).
+ * <li>{@code removeAll}/{@code removeAllWithVersions} return one future per
key, each a tag-only
+ * {@code DELETE}.
+ * </ul>
*
- * <p>Strategy F keeps this class free of ThingsBoard imports and interface
clauses until the
- * attribute path is implemented.
+ * <p>The {@code COLUMN_NAMES}/{@code DATA_TYPES}/{@code COLUMN_CATEGORIES}
arrays below follow the
+ * {@code entity_attributes} DDL tag order in {@code schema-iotdb-table.sql}
(attribute_scope,
+ * entity_type, tenant_id, key, entity_id, then the five typed FIELDs). The
{@code time TIMESTAMP
+ * TIME} column is written via {@link Tablet#addTimestamp}, never as a {@code
ColumnCategory.TIME}
+ * tablet column, so the three parallel arrays cover exactly the 10 non-time
columns and must stay
+ * positionally aligned.
+ *
+ * <p>Every {@code SELECT}/{@code DELETE} predicate is keyed on the full
identity (tenant_id +
+ * entity_type + entity_id, plus {@code attribute_scope} where scope-scoped,
plus {@code key}) — a
+ * deliberate SUPERSET of ThingsBoard's relational {@code JpaAttributeDao},
which keys an attribute
+ * row by the entity UUID alone. The extra tag predicates are required because
{@code
+ * entity_attributes} is a single multi-tenant / multi-entity IoTDB table;
they prevent any
+ * cross-tenant, cross-entity, or cross-scope read or delete.
+ *
+ * <p>Phase-1 honest limitations (documented, not silently degraded):
+ *
+ * <ul>
+ * <li>IoTDB has no SQL sequence. {@code removeAllWithVersions} returns a
{@code null} version
+ * (type-correct; the ThingsBoard service null-checks it before the EDQS
attribute-delete
+ * notification, so that notification is simply not driven in Phase-1).
{@code save}, however,
+ * MUST return a NON-null version: {@code BaseAttributesService#doSave}
passes it into {@code
+ * AttributeKv(EntityId, AttributeScope, AttributeKvEntry, long)} with
no null-check, so a
+ * {@code null} would unbox to a {@code NullPointerException} on every
save. It therefore
+ * returns the attribute's {@code lastUpdateTs} as a
per-identity-monotonic version proxy
+ * (stable across restarts; EDQS integration otherwise stays out of
Phase-1 scope, consistent
+ * with the latest DAO).
+ * <li>{@code findNextBatch} is a relational keyset-pagination migration
helper with no IoTDB
+ * equivalent; it throws {@code UnsupportedOperationException}.
+ * <li>{@code findAllKeysByDeviceProfileId} with a non-null profile returns
an empty list,
+ * matching the official non-relational backend ({@code
+ * CassandraBaseTimeseriesLatestDao.findAllKeysByDeviceProfileId} also
returns {@code
+ * Collections.emptyList()}): {@code entity_attributes} has no {@code
device_profile_id} tag,
+ * and the sole caller — {@code DeviceProfileController} {@code GET
+ * /api/deviceProfile/devices/keys/attributes} (TENANT_ADMIN, a
config-time UI key
+ * enumeration) — tolerates an empty result. A real device→profile
lookup is a Phase-2
+ * optional enhancement.
+ * <li>{@code removeAllByEntityId} is best-effort select-then-delete (IoTDB
has no {@code DELETE
+ * ... RETURNING}); a key inserted between the select and the delete may
be deleted but not
+ * reported.
+ * <li><b>Non-atomic delete-then-insert.</b> {@code save} runs a DELETE then
an INSERT with no
+ * multi-statement transaction (IoTDB has none). 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); insert-first would re-break that, so the order
cannot be reversed. An
+ * {@code INSERT} failure after the {@code DELETE} commits therefore
loses the prior value (no
+ * derived store to recover an attribute from); the {@code save} future
fails loud and the
+ * caller retries.
+ * <li><b>Best-effort multi-key reads.</b> The single-key {@code find} takes
the per-identity lock
+ * so it never observes the delete→insert gap, but {@code find(keys)} /
{@code findAll} /
+ * {@code findLatestByEntityIdsAndScope} read WITHOUT a lock, so a
concurrent same-identity
+ * {@code save} can make a key transiently absent (between its {@code
DELETE} and {@code
+ * INSERT}). Eventually consistent; bulk-read callers tolerate transient
key-absence.
+ * <li><b>Unbounded predicate fan-out.</b> {@code find(keys)} builds a
{@code key IN (...)} list
+ * and the entity-id methods build a {@code (entity_type=.. AND
entity_id=..) OR ...} set with
+ * no chunking, so a very large key/entity set yields a large SQL string
(bounded for normal
+ * scopes; chunking is a follow-up if large fan-outs appear).
+ * <li>The per-identity lock converges writes only within a single JVM;
cross-node single-writer
+ * safety is the operator's responsibility, acknowledged via {@code
+ * iotdb.attributes.cluster_mode}.
+ * </ul>
*/
@Slf4j
-public class IoTDBTableAttributesDao extends IoTDBTableBaseDao {
- // Not annotated @Repository yet: this must not auto-register until
ThingsBoard exposes an
- // AttributesDao selector and this module provides the matching
implementation.
- public IoTDBTableAttributesDao(ITableSessionPool tableSessionPool) {
+public class IoTDBTableAttributesDao extends IoTDBTableBaseDao
+ implements AttributesDao, DisposableBean {
+
+ static final String TABLE_NAME = "entity_attributes";
+
+ // NUL is the identity-lock key separator: it cannot appear in any
tenant/entity UUID,
+ // entity-type/scope 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';
+ private static final String SELECT_TYPED_COLUMNS =
+ "time, bool_v, long_v, double_v, str_v, json_v";
+
+ // The three parallel arrays below follow the entity_attributes DDL tag order
+ // (schema-iotdb-table.sql): attribute_scope, entity_type, tenant_id, key,
entity_id (TAGs),
+ // then bool_v, long_v, double_v, str_v, json_v (FIELDs). They must stay
positionally aligned and
+ // cover exactly the 10 non-time columns; the `time TIMESTAMP TIME` column
is written through
+ // Tablet#addTimestamp (NOT a ColumnCategory.TIME entry), so
COLUMN_CATEGORIES holds only TAG and
+ // FIELD. Rebuilding with a different tag order is a correctness bug
(TAG-order rot).
+ private static final List<String> COLUMN_NAMES =
+ List.of(
+ "attribute_scope",
+ "entity_type",
+ "tenant_id",
+ "key",
+ "entity_id",
+ "bool_v",
+ "long_v",
+ "double_v",
+ "str_v",
+ "json_v");
+ private static final List<TSDataType> DATA_TYPES =
+ List.of(
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.STRING,
+ TSDataType.BOOLEAN,
+ TSDataType.INT64,
+ TSDataType.DOUBLE,
+ TSDataType.STRING,
+ TSDataType.TEXT);
+ private static final List<ColumnCategory> COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ private final ThreadPoolExecutor ioExecutor;
+ private final Set<IoTask<?>> ioTasks = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean accepting = new AtomicBoolean(true);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private final long shutdownDrainTimeoutMs;
+ // Per-identity write serialization (single-JVM convergence).
+ private final Striped<Lock> identityLocks = Striped.lock(256);
+ // Per-ENTITY read/write serialization guarding entity-wide
removeAllByEntityId against concurrent
+ // single-identity save/delete. A per-key mutate (save/deleteIdentity) takes
the entity READ lock
+ // (shared: many keys of the same entity may proceed concurrently);
removeAllByEntityId takes the
+ // entity WRITE lock (exclusive) around its select+delete so no save can
re-INSERT a key between
+ // the entity-wide select and delete. Lock ordering is ALWAYS entity-lock
(outer) then
+ // per-identity lock (inner); removeAllByEntityId never takes an identity
lock, so no cycle is
+ // possible.
+ private final Striped<ReadWriteLock> entityLocks =
Striped.readWriteLock(256);
+
+ public IoTDBTableAttributesDao(ITableSessionPool tableSessionPool,
IoTDBTableConfig config) {
super(tableSessionPool);
+ // Section 3.5 cluster opt-in validator: when the attribute DAO is active
the operator must
+ // acknowledge cluster routing explicitly, because the delete-then-insert
write path converges
+ // only within a single JVM. This is intentionally independent of ts.type
/ ts_latest.type
+ // (the attribute DAO routes separately). Fail fast at construction on an
absent/invalid value.
+ requireClusterModeAcknowledged(config.getAttributes().getClusterMode());
+ this.shutdownDrainTimeoutMs =
config.getTs().getSave().getShutdownDrainTimeoutMs();
+ int ioThreads = config.getTs().getRead().getThreads();
Review Comment:
属性 DAO 的 IO 执行器线程数/队列容量取自 config.getTs().getRead()(时序读配置),但属性 DAO
是独立于时序选择器激活的,且 IoTDBTableConfig.Attributes 配置块已存在却只有 clusterMode
一个字段(已确认)。运营方无法独立调优属性路径的并发/背压。建议:为 iotdb.attributes.* 增加执行器配置项。
##########
iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java:
##########
@@ -18,25 +18,871 @@
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.SettableFuture;
+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.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.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.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;
+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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p><b>Why the overlay is written on EVERY {@code saveLatest}:</b> 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.
*
- * <p>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.
+ * <ul>
+ * <li>{@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.
+ * <li>{@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.
+ * <li>{@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).
+ * </ul>
*
- * <p>Strategy F keeps this class free of ThingsBoard imports and interface
clauses until the
- * latest-telemetry path is implemented.
+ * <p>Residual Phase-1 limitations (documented):
+ *
+ * <ul>
+ * <li><b>{@code version} is always {@code null}.</b> 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.
+ * <li><b>Telemetry-derived race residual.</b> 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.
+ * <li><b>Non-atomic overlay write (delete-then-insert).</b> 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.
+ * <li><b>Telemetry-only writes (saveWithoutLatest) surface via the derived
read.</b> 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).
+ * <li><b>removeLatest honesty + overlapping deletes.</b> {@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.
+ * <li><b>Overlay growth.</b> {@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).
+ * <li><b>Single-JVM overlay convergence.</b> 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). The sibling AttributesDao gates
this with an explicit
+ * cluster-mode acknowledgement; a symmetric ack for the latest overlay
is an option,
+ * documented here for parity in the meantime.
+ * <li><b>Same-timestamp cross-store type change.</b> The overlay wins an
exact-ts tie, continuing
+ * the documented same-timestamp two-type-column limitation (B1).
+ * </ul>
+ *
+ * <p>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<String> 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<TSDataType> 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<ColumnCategory> COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ private final ThreadPoolExecutor readExecutor;
+ private final Set<ReadTask<?>> readTasks = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean accepting = new AtomicBoolean(true);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private final long shutdownDrainTimeoutMs;
+ // 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<Lock> identityLocks = Striped.lock(256);
+
+ public IoTDBTableLatestDao(ITableSessionPool tableSessionPool,
IoTDBTableConfig config) {
super(tableSessionPool);
+ this.shutdownDrainTimeoutMs =
config.getTs().getSave().getShutdownDrainTimeoutMs();
+ int readThreads = config.getTs().getRead().getThreads();
+ int readQueueCapacity = config.getTs().getRead().getQueueCapacity();
+ this.readExecutor =
+ new ThreadPoolExecutor(
+ readThreads,
+ readThreads,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(readQueueCapacity),
+ readThreadFactory(),
+ new ThreadPoolExecutor.AbortPolicy());
+ }
+
+ @Override
+ public ListenableFuture<Optional<TsKvEntry>> 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<TsKvEntry> 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<List<TsKvEntry>> findAllLatest(TenantId tenantId,
EntityId entityId) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ return submitReadTask(() -> doFindAllLatest(tenantId, entityId));
+ }
+
+ @Override
+ public ListenableFuture<Long> 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).
+ 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<TsKvEntry> 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<TsKvLatestRemovingResult> 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<String> 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<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId>
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<List<String>> findAllKeysByEntityIdsAsync(
+ TenantId tenantId, List<EntityId> 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<TsKvEntry> findLatestByEntityIds(TenantId tenantId,
List<EntityId> 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<List<TsKvEntry>> findLatestByEntityIdsAsync(
+ TenantId tenantId, List<EntityId> 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;
+ }
+ 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);
+ }
+ }
+
+ // ---- read merge (derived primary + overlay, max-ts-per-key, overlay wins
on tie) ----
+
+ private Optional<TsKvEntry> 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<TsKvEntry> derived =
+ readLatestRow(buildFindLatestSql(tenantId, entityId, key), key,
"time");
+ Optional<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> currentLatestForGuard(
+ TenantId tenantId, EntityId entityId, String key) throws Exception {
+ Optional<TsKvEntry> derived = readDerivedLatestLenient(tenantId, entityId,
key);
+ Optional<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> doFindAllLatest(TenantId tenantId, EntityId
entityId) throws Exception {
+ Map<String, TsKvEntry> 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<TsKvEntry> 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<String, TsKvEntry> 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<TsKvEntry> mergeLatest(
+ Optional<TsKvEntry> derived, Optional<TsKvEntry> 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
+ // (the latest path has no cluster-mode gate) 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<TsKvEntry> derived = readDerivedLatestLenient(tenantId, entityId,
key);
+ Optional<TsKvEntry> overlay =
+ readLatestRowB1Lenient(buildFindLatestOverlaySql(tenantId, entityId,
key), key);
+ Optional<TsKvEntry> 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<TsKvEntry> priorDerived = doFindHistoryBefore(tenantId,
entityId, key, startTs);
+ Optional<TsKvEntry> priorOverlay = overlay.filter(e -> e.getTs() <
startTs);
+ Optional<TsKvEntry> 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<TsKvEntry> 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);
+ }
+
+ private 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());
+ }
+
+ // ---- 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 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 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 String sqlString(String value) {
+ return "'" + Objects.requireNonNull(value, "value").replace("'", "''") +
"'";
+ }
+
+ private static String requireTelemetryKey(String key) {
+ if (key == null || key.trim().isEmpty()) {
+ throw new IllegalArgumentException("Telemetry key must not be blank");
+ }
+ return key;
+ }
+
+ private <T> ListenableFuture<T> submitReadTask(Callable<T> callable) {
Review Comment:
有界执行器/任务跟踪机制(submitReadTask/ReadTask/failDroppedReadTask/关停排空逻辑)在三个 DAO
中近乎逐字复制三份(IoTDBTableTimeseriesDao.java:382、IoTDBTableLatestDao.java:816、IoTDBTableAttributesDao.java:957,后者仅改名为
IoTask)(已确认)。未来对关停竞态的修复须手工改三处,漏一处即留下语义分歧。建议提取到 IoTDBTableBaseDao(两个新 DAO
已继承它,目前只含 getEntry())。
##########
iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java:
##########
@@ -18,25 +18,871 @@
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.SettableFuture;
+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.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.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.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;
+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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p><b>Why the overlay is written on EVERY {@code saveLatest}:</b> 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.
*
- * <p>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.
+ * <ul>
+ * <li>{@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.
+ * <li>{@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.
+ * <li>{@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).
+ * </ul>
*
- * <p>Strategy F keeps this class free of ThingsBoard imports and interface
clauses until the
- * latest-telemetry path is implemented.
+ * <p>Residual Phase-1 limitations (documented):
+ *
+ * <ul>
+ * <li><b>{@code version} is always {@code null}.</b> 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.
+ * <li><b>Telemetry-derived race residual.</b> 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.
+ * <li><b>Non-atomic overlay write (delete-then-insert).</b> 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.
+ * <li><b>Telemetry-only writes (saveWithoutLatest) surface via the derived
read.</b> 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).
+ * <li><b>removeLatest honesty + overlapping deletes.</b> {@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.
+ * <li><b>Overlay growth.</b> {@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).
+ * <li><b>Single-JVM overlay convergence.</b> 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). The sibling AttributesDao gates
this with an explicit
+ * cluster-mode acknowledgement; a symmetric ack for the latest overlay
is an option,
+ * documented here for parity in the meantime.
+ * <li><b>Same-timestamp cross-store type change.</b> The overlay wins an
exact-ts tie, continuing
+ * the documented same-timestamp two-type-column limitation (B1).
+ * </ul>
+ *
+ * <p>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<String> 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<TSDataType> 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<ColumnCategory> COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ private final ThreadPoolExecutor readExecutor;
+ private final Set<ReadTask<?>> readTasks = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean accepting = new AtomicBoolean(true);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private final long shutdownDrainTimeoutMs;
+ // 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<Lock> identityLocks = Striped.lock(256);
+
+ public IoTDBTableLatestDao(ITableSessionPool tableSessionPool,
IoTDBTableConfig config) {
super(tableSessionPool);
+ this.shutdownDrainTimeoutMs =
config.getTs().getSave().getShutdownDrainTimeoutMs();
+ int readThreads = config.getTs().getRead().getThreads();
+ int readQueueCapacity = config.getTs().getRead().getQueueCapacity();
+ this.readExecutor =
+ new ThreadPoolExecutor(
+ readThreads,
+ readThreads,
+ 0L,
+ TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(readQueueCapacity),
+ readThreadFactory(),
+ new ThreadPoolExecutor.AbortPolicy());
+ }
+
+ @Override
+ public ListenableFuture<Optional<TsKvEntry>> 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<TsKvEntry> 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<List<TsKvEntry>> findAllLatest(TenantId tenantId,
EntityId entityId) {
+ Objects.requireNonNull(tenantId, "tenantId");
+ Objects.requireNonNull(entityId, "entityId");
+ return submitReadTask(() -> doFindAllLatest(tenantId, entityId));
+ }
+
+ @Override
+ public ListenableFuture<Long> 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).
+ 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<TsKvEntry> 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<TsKvLatestRemovingResult> 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<String> 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<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId>
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<List<String>> findAllKeysByEntityIdsAsync(
+ TenantId tenantId, List<EntityId> 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<TsKvEntry> findLatestByEntityIds(TenantId tenantId,
List<EntityId> 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<List<TsKvEntry>> findLatestByEntityIdsAsync(
+ TenantId tenantId, List<EntityId> 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;
+ }
+ 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);
+ }
+ }
+
+ // ---- read merge (derived primary + overlay, max-ts-per-key, overlay wins
on tie) ----
+
+ private Optional<TsKvEntry> 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<TsKvEntry> derived =
+ readLatestRow(buildFindLatestSql(tenantId, entityId, key), key,
"time");
+ Optional<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> currentLatestForGuard(
+ TenantId tenantId, EntityId entityId, String key) throws Exception {
+ Optional<TsKvEntry> derived = readDerivedLatestLenient(tenantId, entityId,
key);
+ Optional<TsKvEntry> 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<TsKvEntry> 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<TsKvEntry> doFindAllLatest(TenantId tenantId, EntityId
entityId) throws Exception {
+ Map<String, TsKvEntry> 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<TsKvEntry> 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<String, TsKvEntry> 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<TsKvEntry> mergeLatest(
+ Optional<TsKvEntry> derived, Optional<TsKvEntry> 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
+ // (the latest path has no cluster-mode gate) 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<TsKvEntry> derived = readDerivedLatestLenient(tenantId, entityId,
key);
+ Optional<TsKvEntry> overlay =
+ readLatestRowB1Lenient(buildFindLatestOverlaySql(tenantId, entityId,
key), key);
+ Optional<TsKvEntry> 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<TsKvEntry> priorDerived = doFindHistoryBefore(tenantId,
entityId, key, startTs);
+ Optional<TsKvEntry> priorOverlay = overlay.filter(e -> e.getTs() <
startTs);
+ Optional<TsKvEntry> 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<TsKvEntry> 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);
+ }
+
+ private 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());
+ }
+
+ // ---- 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 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 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 String sqlString(String value) {
+ return "'" + Objects.requireNonNull(value, "value").replace("'", "''") +
"'";
Review Comment:
多个小工具方法在三个 DAO 中重复:sqlString() SQL
单引号转义(三份逐字节相同,是全模块唯一的注入相关转义点,分散风险最高)、kvEntry() 类型映射
switch(三份)、requireTelemetryKey/requireKey
空白校验(三份)、entityPredicate()(两份)(已确认)。同样建议上提到基类。
##########
iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableLatestDao.java:
##########
@@ -18,25 +18,871 @@
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.SettableFuture;
+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.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.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.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;
+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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p><b>Why the overlay is written on EVERY {@code saveLatest}:</b> 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.
*
- * <p>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.
+ * <ul>
+ * <li>{@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.
+ * <li>{@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.
+ * <li>{@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).
+ * </ul>
*
- * <p>Strategy F keeps this class free of ThingsBoard imports and interface
clauses until the
- * latest-telemetry path is implemented.
+ * <p>Residual Phase-1 limitations (documented):
+ *
+ * <ul>
+ * <li><b>{@code version} is always {@code null}.</b> 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.
+ * <li><b>Telemetry-derived race residual.</b> 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.
+ * <li><b>Non-atomic overlay write (delete-then-insert).</b> 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.
+ * <li><b>Telemetry-only writes (saveWithoutLatest) surface via the derived
read.</b> 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).
+ * <li><b>removeLatest honesty + overlapping deletes.</b> {@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.
+ * <li><b>Overlay growth.</b> {@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).
+ * <li><b>Single-JVM overlay convergence.</b> 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). The sibling AttributesDao gates
this with an explicit
+ * cluster-mode acknowledgement; a symmetric ack for the latest overlay
is an option,
+ * documented here for parity in the meantime.
+ * <li><b>Same-timestamp cross-store type change.</b> The overlay wins an
exact-ts tie, continuing
+ * the documented same-timestamp two-type-column limitation (B1).
+ * </ul>
+ *
+ * <p>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<String> 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<TSDataType> 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<ColumnCategory> COLUMN_CATEGORIES =
+ List.of(
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.TAG,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD,
+ ColumnCategory.FIELD);
+
+ private final ThreadPoolExecutor readExecutor;
+ private final Set<ReadTask<?>> readTasks = ConcurrentHashMap.newKeySet();
+ private final AtomicBoolean accepting = new AtomicBoolean(true);
+ private final AtomicBoolean destroyed = new AtomicBoolean(false);
+ private final long shutdownDrainTimeoutMs;
+ // 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<Lock> identityLocks = Striped.lock(256);
+
+ public IoTDBTableLatestDao(ITableSessionPool tableSessionPool,
IoTDBTableConfig config) {
Review Comment:
属性 DAO 构造时强制 iotdb.attributes.cluster_mode 集群确认,而 latest overlay 有同样的 JVM 内
Striped 锁集群风险却无对称门禁(疑似)。javadoc 论证了该竞态是良性的(瞬时双 overlay 行、max-ts 合并仍返回新值),且 PR
描述已把对称确认列为待导师评审项——建议在本 PR 或紧随的 PR 中补齐,以消除运营方静默误配的风险。
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]