This guide is for an operator who wants to move a ThingsBoard deployment's telemetry, latest-value, and (optionally) entity-attribute storage onto Apache IoTDB 2.x Table Mode using the iotdb-thingsboard-table module. It covers version pairing, step-by-step enablement, the ThingsBoard-to-IoTDB data-model mapping, coexistence with the existing storage backend, rollback, and the limitations to plan for.
For configuration reference and the full limitation list see the module README.md; for day-to-day operation see docs/user-guide.md. This guide focuses on the one-time cut-over.
The module is a ThingsBoard DAO backend built on IoTDB's table-session API. It adds three independently activated routes:
TimeseriesDao) → the telemetry table;TimeseriesLatestDao) → derived from telemetry plus a per-key telemetry_latest overlay table;AttributesDao, opt-in) → the entity_attributes table.The module is additive and default-inert. Putting the jar on the ThingsBoard classpath changes nothing on its own: every route is gated behind an explicit activation selector, and with no selector set the activation conditions all return false, no IoTDB bean or session pool is created, and ThingsBoard keeps using its existing storage backend. Enabling a route is a deliberate configuration step, described below.
The timeseries route implements time-bucketed aggregation over the IoTDB telemetry table — both the fixed-width millisecond date_bin path and the timezone-aware calendar buckets (WEEK / WEEK_ISO / MONTH / QUARTER) — in addition to raw read, write, and delete. Aggregation is therefore not a reason to keep the host backend for the telemetry route.
Pin these versions so the module, the IoTDB client, and the IoTDB server line up. The module overrides no shared reactor versions; the IoTDB client and TsFile versions are inherited from the iotdb-extras parent reactor.
| Component | Coordinate / image | Version | Where it is fixed |
|---|---|---|---|
| Module | org.apache.iotdb:iotdb-thingsboard-table | 2.0.12-SNAPSHOT (parent version) | module pom.xml <parent> |
| Parent reactor | org.apache.iotdb:iotdb-extras-parent | 2.0.12-SNAPSHOT | module pom.xml <parent> |
| IoTDB session client | org.apache.iotdb:iotdb-session | 2.0.11 | inherited from parent iotdb.version; module declares the dependency with no <version> |
| TsFile | org.apache.tsfile:tsfile | 2.4.0 | inherited from parent tsfile.version (transitive of iotdb-session) |
| Guava | com.google.guava:guava | 32.1.2-jre | inherited from parent guava.version; provided scope |
| Bean-validation API | jakarta.validation:jakarta.validation-api | 3.0.2 | module override (jakarta.* namespace, provided scope) to match the Spring Boot 3 runtime host |
| ThingsBoard host | (ThingsBoard distribution) | 4.3.1.2 | module pom.xml thingsboard.version; SPI surface verified against this tag |
| IoTDB server (test default) | apache/iotdb standalone image | 2.0.11 | integration tests select apache/iotdb:2.0.11-standalone |
Notes:
iotdb.test.image. Run the profile to validate your actual client/server pairing; a configured version is not evidence of successful container execution.jakarta.* namespace). That is why jakarta.validation-api is overridden to 3.0.2 and why the module requires JDK 17+ to build and run. The Hibernate Validator provider is supplied by the ThingsBoard runtime classpath, not bundled by the module.pom.xml and the iotdb-extras parent pom.xml rather than assuming a value.Each route is controlled by its own selector. Until a route's selector is set, that route stays inert.
| Route | Required properties | Default state |
|---|---|---|
| Historical telemetry (raw read/write/delete) | database.ts.type=iotdb-table and iotdb.ts.experimental-raw-only=true | inert |
| Latest value | database.ts.type=iotdb-table and database.ts_latest.type=iotdb-table and iotdb.ts.experimental-raw-only=true; also requires iotdb.ts_latest.cluster_mode set to sticky-routing or disabled | inert |
| Entity attributes (stretch) | database.attributes.type=iotdb-table (independent of the timeseries selectors); also requires iotdb.attributes.cluster_mode set to sticky-routing or disabled | inert |
The latest route deliberately also requires the timeseries selector: the latest value is derived from the telemetry table that only the IoTDB writer populates, so activating latest without the IoTDB write path would read from a table the module never fills. The attribute selector is fully independent of the timeseries selectors.
Build and install org.apache.iotdb:iotdb-thingsboard-table and add it as a dependency of the ThingsBoard application so its classes are on the runtime classpath. The module is a Spring Boot auto-configuration (IoTDBTableConfiguration) registered through META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, so it activates without the host application having to component-scan org.apache.iotdb.extras.
In the iotdb-extras reactor the module lives behind a named opt-in profile and requires JDK 17+:
# from the iotdb-extras repository root mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean install
At this point the jar is present but inert — no selector is set yet, so ThingsBoard still uses its existing backend.
On startup the module runs an idempotent bootstrap (gated by iotdb.schema.bootstrap, default true) that reads schema-iotdb-table.sql from the classpath and creates the target database plus the telemetry and entity_attributes tables on a fresh IoTDB before the first write. When the latest route is active, a second bootstrap reads schema-iotdb-table-latest.sql to create the telemetry_latest overlay table. Both bootstraps use CREATE ... IF NOT EXISTS, so they are safe to re-run.
If you manage the schema out of band, set iotdb.schema.bootstrap=false and apply schema-iotdb-table.sql (and schema-iotdb-table-latest.sql when the latest route is enabled) yourself before first start.
Add the selectors from the table above to the ThingsBoard configuration. For example, to route both historical telemetry and latest values to IoTDB:
database.ts.type=iotdb-table database.ts_latest.type=iotdb-table iotdb.ts.experimental-raw-only=true iotdb.ts_latest.cluster_mode=sticky-routing
iotdb.ts_latest.cluster_mode is mandatory when the latest route is active. Choose sticky-routing when per-identity latest writes are pinned to one node, or disabled for single-node / acknowledged best-effort operation. Any other value, including the empty default, fails DAO construction because the latest overlay write path converges only within a single JVM. This property acknowledges your routing arrangement; it does not configure routing for you.
To additionally opt in to the stretch attributes route, use the selector supplied by this module. On ThingsBoard 4.3.1.2, the module withdraws the host JpaAttributeDao at startup and logs a WARN; see Coexistence and rollback for the exact matching rule and conflict-guard boundary:
database.attributes.type=iotdb-table iotdb.attributes.cluster_mode=sticky-routing
iotdb.attributes.cluster_mode is mandatory when the attribute route is active and 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.
Set the connection and pool properties (bound from iotdb.*):
iotdb.host=<iotdb-host> iotdb.port=6667 iotdb.username=<iotdb-user> iotdb.password=<iotdb-password> iotdb.database=thingsboard iotdb.session-pool-size=8
The defaults are 127.0.0.1:6667, credentials root/root, database thingsboard, and a session pool of 8. iotdb.database must be a valid IoTDB identifier (letter or underscore, then letters/digits/underscores). The write path and read path have their own pools, tunable under iotdb.ts.save.* and iotdb.ts.read.*:
iotdb.ts.save.batch-size (default 500), iotdb.ts.save.max-linger-ms (20), iotdb.ts.save.queue-capacity (50000), and retry/backoff settings. The writer uses a single dedicated flush worker, so iotdb.ts.save.flush-threads is fixed at 1 (any other value fails binding).iotdb.ts.read.threads (default 4) and iotdb.ts.read.queue-capacity (10000).Physical retention in IoTDB Table Mode is a table-level property expressed in milliseconds as a bare long literal, or the keyword INF (never expire) / DEFAULT (inherit the database default, which is INF on a fresh node). The shipped telemetry table is created WITH (TTL=DEFAULT). To enable a concrete retention, either edit the schema before bootstrap, e.g. 7 days:
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 default
Quoted numbers ('604800000') and duration forms ('7d') are rejected by IoTDB 2.0.11 (and 2.0.8). The telemetry_latest overlay table is created WITH (TTL='INF') and is exempt from the telemetry TTL: setting a retention window on telemetry does not evict overlay rows, so bound the overlay separately if needed (see Limitations).
ThingsBoard's relational ts_kv stores one row per (entity_id, key, ts) with the value in one typed column (bool_v / long_v / dbl_v / str_v / json_v) and key dictionary-encoded. The IoTDB telemetry table keys each row by a TAG tuple plus the built-in time column, with the value in the matching FIELD column. Exactly one value column is non-null per row, preserving the source shape.
ts_kv → telemetryThingsBoard ts_kv | IoTDB telemetry | IoTDB column kind |
|---|---|---|
entity_id | entity_id | TAG |
| (entity type) | entity_type | TAG |
| (tenant) | tenant_id | TAG |
key (dictionary id → string) | key (resolved to the string key) | TAG |
ts | time (built-in) | TIME |
bool_v | bool_v (BOOLEAN) | FIELD |
long_v | long_v (INT64) | FIELD |
dbl_v | double_v (DOUBLE) | FIELD |
str_v | str_v (STRING) | FIELD |
json_v | json_v (TEXT) | FIELD |
ts_kv_latest → telemetry_latestDo not migrate ts_kv_latest as a separate store. The latest value is read from both the historical telemetry table (derived, newest row per key) and a minimal per-key telemetry_latest overlay, merged by the maximum timestamp per key. Once history is in telemetry, the derived latest follows automatically; the overlay is written by the module on every saveLatest going forward. The overlay table has the same TAG tuple (entity_type, tenant_id, key, entity_id) and FIELD columns as telemetry, plus an explicit time TIMESTAMP TIME column.
attribute_kv → entity_attributes (stretch route only)Applies only if the attribute route is activated. Each identity tuple (tenant_id, entity_type, entity_id, attribute_scope, key) holds exactly one current row at time = lastUpdateTs, with one typed FIELD set.
| ThingsBoard attributes | IoTDB entity_attributes | IoTDB column kind |
|---|---|---|
entity_id | entity_id | TAG |
| (entity type) | entity_type | TAG |
| (tenant) | tenant_id | TAG |
| attribute scope | attribute_scope (CLIENT_SCOPE / SERVER_SCOPE / SHARED_SCOPE) | TAG |
| attribute key | key | TAG |
last_update_ts | time | TIME |
| boolean / long / double / string / json value | bool_v / long_v / double_v / str_v / json_v | FIELD |
The module supplies the write/read DAO surface but not an ETL tool; the backfill is operator-driven.
telemetry TTL now (Step 5) if you want retention, before backfilling.ts_kv into telemetry: read from the source backend, resolve the key dictionary id to the string key, supply tenant_id / entity_type for each entity_id, and write via batched Tablet inserts using the value-column mapping above. The module's own write path batches 500 rows per flush, a reasonable starting batch size.(entity, key) pairs against the source before the cut-over.telemetry; the derived latest is immediately correct because it reads the same table, and the overlay is written from the first new saveLatest onward.Idempotency: re-running the backfill for the same (tags, time) overwrites the typed column in place (IoTDB same-timestamp overwrite), so re-runs converge rather than duplicate. A value whose type changed at the same timestamp is subject to the same-timestamp limitation below.
For a zero-downtime cut-over, a dual-write or read-old / write-new bridge during backfill is an operator concern beyond this guide.
Each route is independently activated and guarded:
Independent activation. Enabling telemetry does not enable attributes, and vice versa. You can route telemetry + latest to IoTDB while attributes stay in the host entity database (the default Phase-1 posture).
Conflict guards, and they are not all fail-fast. The historical (TimeseriesDao), latest (TimeseriesLatestDao), and attribute (AttributesDao) routes each have their own guard, but they resolve the conflict differently.
The timeseries and latest guards fail startup when a conflicting non-IoTDB host DAO of the same SPI type is present, rather than silently shadowing one DAO with another. Remove or disable the host backend for that route before pointing it at IoTDB.
The attribute guard cannot ask for that, because ThingsBoard registers JpaAttributeDao unconditionally and no configuration stands it down. So when database.attributes.type=iotdb-table is set, the module withdraws that one bean definition itself and logs a WARN naming it. The match is on both the bean name and the exact fully-qualified class name, so nothing else is ever removed: any other competing AttributesDao visible to the guard when it runs fails startup untouched. Definitions registered after it, supplied by a FactoryBean that does not report its type until initialisation, or inherited from a parent context are outside its reach.
To roll a route back, unset its selector(s) and restart. The route‘s activation condition then returns false, the IoTDB DAO and its session pool are not created, the conflict guard does not fire, and the host backend’s DAO resumes serving that route.
What is and isn't reversible:
These are the documented current-scope limitations (see the DAO Javadoc and the module README.md for the authoritative list):
version is always null on the latest and attribute routes. IoTDB has no SQL sequence (same as the Cassandra backend); this is type-correct and contract-legal, but ThingsBoard notifications that key off a non-null version are not driven in Phase-1.(tenant, entity, key, timestamp) point whose value type changes between two separate flushes can land two non-null typed columns. The behavior is fail-fast, not silent: a raw read of that one point throws an IllegalStateException rather than returning a wrong value; every other point is unaffected. The latest overlay wins an exact-timestamp tie, continuing the same limitation on the latest route.INF growth. telemetry_latest never physically expires and has no entity-level cleanup, so under unbounded key cardinality it grows without bound (one row per identity; bounded for normal key sets). Bound it separately with a TTL on telemetry_latest if needed.ttl is not honored as physical retention. The TimeseriesDao.save(..., long ttl) per-data-point TTL cannot be reconciled with table-wide IoTDB TTL; the module uses it only for ThingsBoard's storage-accounting, never as a physical-retention directive. Set physical retention on the table (Step 5).database.attributes.type is a selector this module supplies rather than one ThingsBoard offers; leaving it unset is the default posture, and while unset attributes stay in the host entity database. On ThingsBoard 4.3.1.2, setting it makes the module withdraw the host JpaAttributeDao and log a WARN; no host-provided attribute selector is needed. See the conflict-guard bullet above for the matching rule and its boundary. When activated, save is a non-atomic tag-only delete-then-insert under a per-identity in-JVM lock that converges only within one JVM; findNextBatch is unsupported (UnsupportedOperationException), and findAllKeysByDeviceProfileId with a non-null profile returns an empty list (matching the non-relational backend).After switching a route to IoTDB:
SHOW TABLES in the IoTDB database, and spot-check rows in telemetry (and telemetry_latest when the latest route is on).information_schema.tables (the ttl(ms) column) or SHOW TABLES (the TTL(ms) column) if you set retention.(entity, key) latest values through ThingsBoard and compare against the pre-cut-over source sample.