iotdb-thingsboard-table is a ThingsBoard historical-telemetry DAO backend
built on Apache IoTDB Table Mode. It lets a ThingsBoard deployment store
and serve time-series telemetry through IoTDB's table-session API instead of the
default Cassandra/SQL backends. It compiles against the reactor's IoTDB 2.0.5
table-session client; its integration tests run the real write, read,
aggregation, latest-telemetry, attribute and retention paths against an
apache/iotdb:2.0.8-standalone server. The module targets ThingsBoard v4.3.1.2. Because
it compiles with Java 17 language features (records and others), the
iotdb-extras parent reactor builds and tests it only on JDK 17+ through the
explicit, named with-thingsboard opt-in profile (it is not JDK-auto-activated):
CI passes -P with-thingsboard on the JDK 17/21 jobs while the 8/11 jobs omit it
and skip the module, and a plain reactor build never pulls it in.
The ThingsBoard DAO SPI and value types (org.thingsboard.*) are not published
to Maven Central, so they cannot be a normal compile dependency. Strategy F
treats them as a compile-only source surface under src/provided/java: just
enough of the ThingsBoard interfaces and value objects to compile against. The
maven-jar-plugin excludes org/thingsboard/** from the built jar, so these
compile-only types never ship and never shadow the real ThingsBoard classes. At
runtime the actual ThingsBoard classpath supplies them. This keeps the module
buildable in isolation while binding to the genuine ThingsBoard types on a real
deployment.
The compile-only surface under src/provided/java was manually verified against
ThingsBoard v4.3.1.2 (commit c37fb509): the TimeseriesDao SPI methods the
DAO consumes and the value-object accessors it reads were checked against the
upstream sources. A fully-automated check against the upstream artifact is not
possible because ThingsBoard's dao/common-data modules are not published to
Maven Central (the reason for Strategy F). As a guard against silent drift,
StrategyFContractTest pins the exact TimeseriesDao SPI method signatures the
DAO depends on, so any accidental edit to the local surface fails the build.
The module is an inert-by-default foundation — IoTDBTableBaseDao (session-pool
lifecycle, schema/table bootstrap) — plus three DAOs, each behind its own
selector:
IoTDBTableTimeseriesDao: the historical write path (save), raw read, delete, and time-bucketed aggregation — both the fixed-width milliseconddate_binpath and the timezone-aware calendar path (WEEK/WEEK_ISO/MONTH/QUARTER). Enabled bydatabase.ts.type=iotdb-tabletogether withiotdb.ts.experimental-raw-only=true. That property name predates the aggregation support and is kept for compatibility; it is the opt-in for this backend as a whole, not a raw-only switch.IoTDBTableLatestDao: latest telemetry, derived fromtelemetrywith atelemetry_latestoverlay. Enabled bydatabase.ts_latest.type=iotdb-table(see the latest-telemetry section below).IoTDBTableAttributesDao: entity attributes, inert by default. Enabled only by the independentdatabase.attributes.type=iotdb-tableopt-in.
This is an incremental / experimental backend. Nothing routes through IoTDB Table Mode unless the matching selector is set explicitly; with no selectors set the module does nothing.
IoTDBTableAttributesDao is an AttributesDao implementation for the IoTDB Table
Mode backend, storing entity attributes in the entity_attributes table. It is
inert by default: in Phase-1, entity attributes stay in the host entity
database (PostgreSQL), and this DAO only activates when an operator opts in
explicitly with database.attributes.type=iotdb-table.
This attribute selector is independent of database.ts.type /
database.ts_latest.type — the attribute DAO routes separately from the
time-series DAOs (a piggy-back on the timeseries selector was deliberately
rejected). No shipped ThingsBoard release exposes a database.attributes.type
selector yet, so a real Phase-1 deployment never sets it; the activation condition
stays false, no attribute bean or session pool is created, and attributes keep
flowing to the host entity-DB AttributesDao.
When activated, each identity tuple
(tenant_id, entity_type, entity_id, attribute_scope, key) holds exactly one
current row: save is a tag-only DELETE (no time predicate) followed by an
INSERT at time = lastUpdateTs with exactly one typed FIELD set, both under a
per-identity in-JVM lock so concurrent same-identity writes converge to a single
row.
- No SQL sequence, so versions are best-effort.
removeAllWithVersionsreturns anullversion (type-correct; the ThingsBoard service null-checks it before the EDQS attribute-delete notification, so that notification is not driven in Phase-1).save, however, returns a non-null version — the attribute'slastUpdateTs— becauseBaseAttributesService#doSavepasses the version intoAttributeKv(..., long)with no null-check, so anullwould unbox to aNullPointerExceptionon every save.lastUpdateTsis a per-identity-monotonic version proxy (stable across restarts). findNextBatchis unsupported. It is a relational keyset-pagination migration helper with no IoTDB equivalent; it throwsUnsupportedOperationException.findAllKeysByDeviceProfileIdwith a non-null profile returns an empty list, matching the official non-relational backend:CassandraBaseTimeseriesLatestDao.findAllKeysByDeviceProfileIdalso returnsCollections.emptyList(), since a NoSQL / time-series store cannot do the cross-device-table profile-dimension join.entity_attributeshas nodevice_profile_idtag and the module has no device→profile lookup. The sole upstream caller isDeviceProfileControllerGET /api/deviceProfile/devices/keys/attributes(TENANT_ADMIN, a config-time UI key enumeration) which tolerates an empty result; failing loud would 500 under IoTDB while Cassandra returns empty. The null-profile path returns the tenant-wide distinct keys. A real device→profile lookup is a Phase-2 optional enhancement.saveis non-atomic. The tag-onlyDELETE-then-INSERTis two separate statements with no rollback (IoTDB has no multi-statement transaction): if theINSERTfails after theDELETE, the prior value is lost (the future fails loud; the caller retries). Delete-first is required — an IoTDB insert at an existing(tags, time)merges typed columns, so a same-timestamp type change would otherwise leave two typed columns (the B1 fail-fast); insert-first would re-break that, so the order is not reversed. A concurrent same-identity pointfindtakes the same per-identity lock and so never observes the transient empty window, but full-scope reads (find-by-keys /findAll/findLatestByEntityIdsAndScope) are best-effort (unlocked).- Single-JVM convergence only. The per-identity lock converges writes only
within one JVM; cross-node single-writer safety is the operator's
responsibility, acknowledged via
iotdb.attributes.cluster_mode(must besticky-routingordisabledwhen the DAO is active, else construction fails fast).
IoTDBTableLatestDao is a TimeseriesLatestDao implementation that serves the
latest value per (tenant, entity, key). It activates only when all of
database.ts.type=iotdb-table, database.ts_latest.type=iotdb-table, and
iotdb.ts.experimental-raw-only=true are set (the timeseries selector is
required because the derived latest reads the telemetry table that only the
IoTDB writer populates). When it activates, iotdb.ts_latest.cluster_mode must
also be set to sticky-routing or disabled (mirroring iotdb.attributes.cluster_mode);
the empty default fails construction fast, because the overlay's per-identity lock
converges only within a single JVM.
The latest value is read from both the historical telemetry table
(derived, ORDER BY time DESC LIMIT 1 / LAST_BY(col, time), engine-accelerated
by IoTDB's native last cache) and a minimal per-key telemetry_latest
overlay table, merged by the maximum timestamp per key (the overlay wins an
exact tie). The merge is max-by-ts, not additive, so a key present in both stores
is never double-counted.
The overlay is written on every saveLatest. The module's historical save()
write path is asynchronous and batched,
so at the moment saveLatest runs it cannot see whether a paired telemetry row
will be written. It therefore cannot distinguish a latest-only write (e.g. the
EntityView telemetry-copy LATEST_AND_WS / saveTs=false path, which calls
saveLatest with no paired save()) from a normal full-save, and writes the
overlay unconditionally as a delete-then-insert (one row per identity, under a
per-identity in-JVM lock). This closes the latest-only data-loss gap that a
no-shadow-table no-op would otherwise drop, at the cost of one extra overlay write
per latest update (equivalent to the standard ThingsBoard latest-table behavior).
removeLatest reads the derived and overlay latest separately under the
per-identity lock and, when the merged latest is inside the half-open
[startTs, endTs) delete window, deletes the overlay row only if the overlay's
own timestamp is in-window (an out-of-window overlay value survives as the next
latest rather than being wiped). When rewriteLatestIfDeleted is set it resurrects
the next-older value across both stores (telemetry where time < startTs and
the overlay row if its own ts is < startTs; max-ts-wins) and returns it as the
removing result's data (so TB emits a WS update rather than a delete); a prior that
is the overlay's own already-stored value is reported without a redundant rewrite.
versionis alwaysnull. IoTDB has no SQL sequence (same as Cassandra); type-correct and contract-legal, but TB notifications that key off a non-null version are not driven in Phase-1.- Telemetry-derived race residual. Overlay-backed values are race-free under
the per-identity lock, but the historical
removeruns as a separate future fromremoveLatest, so a purely telemetry-derived (full-save) latest can transiently still be read fromtelemetryuntil that historical delete commits (eventually consistent). - Overlay growth.
telemetry_latestisTTL='INF'with no entity-level cleanup, so under unbounded key cardinality it grows without bound (one row per identity; bounded for normal key sets). - Same-timestamp cross-store type change. The overlay wins an exact-ts tie, continuing the documented same-timestamp (B1) limitation below.
- Batch latest read deferred (graceful, not throwing).
findLatestByEntityIds(Async)(new in v4.3.1.2) returns an empty list rather than throwing, because it is reachable in normal operation and a throw would surface as an HTTP 500. This matches the officialCassandraBaseTimeseriesLatestDao; the derived batch read is a follow-up. Key discovery (findAllKeysByEntityIds(Async)and the tenant-wide variant) is implemented: it collectsDISTINCT keyfrom bothtelemetryand thetelemetry_latestoverlay, so a key that only ever reached the overlay throughsaveLatestis discovered too.
The telemetry_latest table is created on startup by a second idempotent
schema bootstrap (schema-iotdb-table-latest.sql), registered only when the
latest selector is active and iotdb.schema.bootstrap is not disabled; when the
latest selector is off the overlay table is never created.
Physical retention is a table-level IoTDB property that the operator sets on the schema; it is not a per-data-point setting. IoTDB Table Mode expresses TTL as a retention window in milliseconds, and the accepted forms are narrow:
| Form | Meaning |
|---|---|
TTL=604800000 |
A concrete retention: a bare, unquoted long literal (milliseconds). |
TTL='INF' |
Never expire. The quoted string is the only accepted spelling; this is the form entity_attributes and telemetry_latest ship with. |
TTL=DEFAULT |
Inherit the database default, which is INF on a fresh node. |
Anything else is rejected by IoTDB 2.0.8: an unquoted TTL=INF is parsed as an
identifier (ttl value must be a LongLiteral, but now is Identifier), and any
other quoted value — including a quoted number ('604800000') or a duration
('7d') — fails with ttl value must be 'INF' or a long literal.
The shipped schema-iotdb-table.sql declares telemetry with
WITH (TTL=DEFAULT), so a fresh deployment never expires data until an operator
chooses otherwise. To set a concrete retention, either edit the schema before the
first bootstrap:
CREATE TABLE telemetry (...) WITH (TTL=604800000);or change it at runtime on the live table:
ALTER TABLE telemetry SET PROPERTIES TTL=604800000; -- 7 days, in ms
ALTER TABLE telemetry SET PROPERTIES TTL=DEFAULT; -- back to the db defaultThe effective value can be read back from SHOW TABLES, which reports it in a
TTL(ms) column, or from information_schema.tables, where the column must be
quoted because its name contains parentheses:
SELECT table_name, "ttl(ms)" FROM information_schema.tables WHERE database='thingsboard';Either way a never-expiring table reads back as INF and a concrete retention as
the millisecond number.
IoTDBTableTtlIT pins both paths against a real IoTDB 2.0.8 container. It
verifies the TTL property mechanism only and deliberately does not assert
physical row eviction, because eviction is asynchronous and compaction-driven and
so is not deterministic inside a test.
The telemetry_latest overlay is declared TTL='INF' on purpose: it holds at
most one row per identity and is the authority for a latest value, so expiring it
would silently drop the latest reading while history remained.
- Phase-1 limitation: the per-save
ttlargument is not a physical-retention directive. ThingsBoard'sTimeseriesDao.save(..., long ttl)carries a per-data-point TTL, but IoTDB Table Mode retention can only be expressed at the table level, so the two cannot be reconciled faithfully. The module therefore uses that argument (together withiotdb.defaultTtlMs) only for ThingsBoard's storage data-point accounting, and never as an instruction to expire rows. Operators who need physical retention set it on the table as shown above.
Same-timestamp type change across separate flushes. The writer collapses
duplicate (tenant, entity, key, timestamp) saves within a single flush so the
last write wins, but it does not yet defend against a same-(tenant, entity, key, timestamp) save whose value type changes between two separate flushes (for
example a LONG written in one flush and a STRING written at the same
timestamp in a later flush). Because each typed value lands in its own column
(long_v, str_v, ...), that single point can end up with two non-null typed
columns.
This is a deliberate current-scope decision: the cleanup that would prevent it (a
delete-then-insert overwrite on every save) is outside the current scope. The
behavior is fail-fast, not silent: a raw read of that one poisoned point
throws an IllegalStateException (the single-typed-column invariant enforced in
IoTDBTableBaseDao) rather than returning a wrong value. Every other point is
unaffected. This documented behavior is pinned by an integration test
(IoTDBTableTimeseriesDaoIT).
The backend is bound from iotdb.* Spring properties (see IoTDBTableConfig).
Key activation and operational flags:
| Property | Default | Meaning |
|---|---|---|
database.ts.type |
(unset) | Set to iotdb-table as the ThingsBoard historical-timeseries backend selector. |
iotdb.ts.experimental-raw-only |
false |
Explicit opt-in for this backend. Must be true together with database.ts.type=iotdb-table. The name predates the aggregation support and is kept for compatibility: write, raw read, delete and time-bucketed aggregation are all served when it is enabled. |
database.attributes.type |
(unset) | Set to iotdb-table to opt in to the entity-attribute DAO. Independent of the timeseries selectors. Unset in a real Phase-1 deployment, so the attribute DAO is inert by default. |
iotdb.attributes.cluster_mode |
(empty) | Required when database.attributes.type=iotdb-table. Must be sticky-routing (per-identity writes pinned to one node) or disabled (single-node / acknowledged best-effort); any other value (including the empty default) fails construction fast, because the attribute write path converges only within a single JVM. |
iotdb.ts_latest.cluster_mode |
(empty) | Required when database.ts_latest.type=iotdb-table (the latest-overlay DAO is active). Must be sticky-routing (per-identity latest writes pinned to one node) or disabled (single-node / acknowledged best-effort); any other value (including the empty default) fails construction fast, because the latest-overlay write path converges only within a single JVM. This is the symmetric acknowledgement to iotdb.attributes.cluster_mode. |
iotdb.attributes.executor.threads |
4 |
Worker-thread count for the attribute DAO's bounded IO executor. Sized independently of iotdb.ts.read.* so the attribute path's concurrency can be tuned on its own; the default matches iotdb.ts.read. |
iotdb.attributes.executor.queue-capacity |
10000 |
Bounded task-queue capacity for the attribute DAO's IO executor; a full queue rejects fast (back-pressure) rather than growing unboundedly. Default matches iotdb.ts.read. |
iotdb.host / iotdb.port |
127.0.0.1 / 6667 |
IoTDB node address. |
iotdb.username / iotdb.password |
root / root |
IoTDB credentials. |
iotdb.database |
thingsboard |
Target IoTDB database. |
iotdb.session-pool-size |
8 |
Table session pool size. |
iotdb.schema.bootstrap |
true |
When true, the module runs an idempotent startup bootstrap that reads schema-iotdb-table.sql from the classpath and creates the telemetry / entity_attributes tables (and database) on a fresh IoTDB before the first write. When the latest selector is active it also runs a second bootstrap from schema-iotdb-table-latest.sql to create the telemetry_latest overlay table. Set to false if you manage the schema out-of-band. |
The module is a Spring Boot auto-configuration (IoTDBTableConfiguration).
Its deployment host is ThingsBoard 4.3.x, which runs on Spring Boot 3.5.x, so the
active registration is
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
(paired with the @AutoConfiguration annotation). The legacy
META-INF/spring.factories EnableAutoConfiguration entry is retained only so the
module still activates if it is ever consumed by a Spring Boot 2.7 host; on Boot 3.x
it is ignored. Either way it activates in a real ThingsBoard deployment without the
host application having to component-scan org.apache.iotdb.extras.
The module is an explicit, named opt-in profile (with-thingsboard), so it is built
only when that profile is activated (a plain reactor build never pulls it in). It
requires JDK 17+:
# from the iotdb-extras repository root
mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean testIt can also be built standalone from the module directory:
cd iotdb-thingsboard-table
mvn compile -DskipTestsRun the Java test scaffold from the module directory:
mvn testRun the Docker-backed integration tests only when required:
mvn -Piotdb-table-it verifyStart the local integration stack with explicit environment values:
TB_POSTGRES_USER=<postgres-user> TB_POSTGRES_PASSWORD=<postgres-password> \
IOTDB_USERNAME=<iotdb-user> IOTDB_PASSWORD=<iotdb-password> \
docker compose -f docker-compose.test.yml up -dStop and remove the local stack:
docker compose -f docker-compose.test.yml down -vIoTDBTableBaseDao plus the IoTDBTableTimeseriesDao write, raw-read, delete
and time-bucketed aggregation paths are implemented behind
database.ts.type=iotdb-table and iotdb.ts.experimental-raw-only=true.
Without both properties, that DAO is inert. IoTDBTableLatestDao (latest
telemetry, with the telemetry_latest overlay and key discovery) is implemented
behind its own database.ts_latest.type=iotdb-table selector. Physical
retention is a table property the operator sets on the schema; see
Retention / TTL above.
IoTDBTableAttributesDao is inert by default and activated only by the independent
database.attributes.type=iotdb-table opt-in (see the Entity attributes section
above and its Phase-1 limitations). In a real Phase-1 deployment the selector is
unset, so the attribute DAO never activates and attributes stay in the host
entity database.