Migration Guide: ThingsBoard storage on IoTDB Table Mode

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.

Audience and scope

The module is a ThingsBoard DAO backend built on IoTDB's table-session API. It adds three independently activated routes:

  • historical telemetry (TimeseriesDao) → the telemetry table;
  • latest value (TimeseriesLatestDao) → derived from telemetry plus a per-key telemetry_latest overlay table;
  • entity attributes (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.

Version pairing

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.

ComponentCoordinate / imageVersionWhere it is fixed
Moduleorg.apache.iotdb:iotdb-thingsboard-table2.0.12-SNAPSHOT (parent version)module pom.xml <parent>
Parent reactororg.apache.iotdb:iotdb-extras-parent2.0.12-SNAPSHOTmodule pom.xml <parent>
IoTDB session clientorg.apache.iotdb:iotdb-session2.0.11inherited from parent iotdb.version; module declares the dependency with no <version>
TsFileorg.apache.tsfile:tsfile2.4.0inherited from parent tsfile.version (transitive of iotdb-session)
Guavacom.google.guava:guava32.1.2-jreinherited from parent guava.version; provided scope
Bean-validation APIjakarta.validation:jakarta.validation-api3.0.2module override (jakarta.* namespace, provided scope) to match the Spring Boot 3 runtime host
ThingsBoard host(ThingsBoard distribution)4.3.1.2module pom.xml thingsboard.version; SPI surface verified against this tag
IoTDB server (test default)apache/iotdb standalone image2.0.11integration tests select apache/iotdb:2.0.11-standalone

Notes:

  • The module compiles against the 2.0.11 table-session client. Integration tests default to an IoTDB 2.0.11 standalone server and allow overriding iotdb.test.image. Run the profile to validate your actual client/server pairing; a configured version is not evidence of successful container execution.
  • The build parent itself targets an older Spring line; the module deploys into ThingsBoard 4.3.x, which runs Spring Boot 3.5.x / Spring 6 / JDK 17 (the 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.
  • If you cannot determine a version from this table, read it from the module pom.xml and the iotdb-extras parent pom.xml rather than assuming a value.

Before you begin

  • A reachable IoTDB 2.x server in Table Mode (the tested server is IoTDB 2.0.11). Have its host, port, and credentials ready.
  • A ThingsBoard 4.3.1.2 build you control (the module is consumed as a compile/runtime dependency of the ThingsBoard application; it is not a drop-in for a pre-built binary distribution).
  • JDK 17+ for the ThingsBoard build that includes this module.
  • Decide which routes you are enabling: telemetry only, telemetry + latest, and whether to opt in to the stretch attributes route.

Activation selectors

Each route is controlled by its own selector. Until a route's selector is set, that route stays inert.

RouteRequired propertiesDefault state
Historical telemetry (raw read/write/delete)database.ts.type=iotdb-table and iotdb.ts.experimental-raw-only=trueinert
Latest valuedatabase.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 disabledinert
Entity attributes (stretch)database.attributes.type=iotdb-table (independent of the timeseries selectors); also requires iotdb.attributes.cluster_mode set to sticky-routing or disabledinert

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.

Step-by-step enablement

1. Add the module to the ThingsBoard build

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.

2. Bootstrap the schema

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.

3. Set the activation properties for the routes you want

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.

4. Point the module at IoTDB and size the pools

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.*:

  • Write path: 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).
  • Read path: iotdb.ts.read.threads (default 4) and iotdb.ts.read.queue-capacity (10000).

5. Set retention (optional, table-level TTL)

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).

Data-model mapping

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 → telemetry

ThingsBoard ts_kvIoTDB telemetryIoTDB column kind
entity_identity_idTAG
(entity type)entity_typeTAG
(tenant)tenant_idTAG
key (dictionary id → string)key (resolved to the string key)TAG
tstime (built-in)TIME
bool_vbool_v (BOOLEAN)FIELD
long_vlong_v (INT64)FIELD
dbl_vdouble_v (DOUBLE)FIELD
str_vstr_v (STRING)FIELD
json_vjson_v (TEXT)FIELD

ts_kv_latest → telemetry_latest

Do 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 attributesIoTDB entity_attributesIoTDB column kind
entity_identity_idTAG
(entity type)entity_typeTAG
(tenant)tenant_idTAG
attribute scopeattribute_scope (CLIENT_SCOPE / SERVER_SCOPE / SHARED_SCOPE)TAG
attribute keykeyTAG
last_update_tstimeTIME
boolean / long / double / string / json valuebool_v / long_v / double_v / str_v / json_vFIELD

Migrating existing data (backfill)

The module supplies the write/read DAO surface but not an ETL tool; the backfill is operator-driven.

  1. Stand up IoTDB in Table Mode and bootstrap the schema (Step 2). Set a concrete telemetry TTL now (Step 5) if you want retention, before backfilling.
  2. Backfill historical 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.
  3. Verify a sample: spot-check historical reads and latest values for a set of (entity, key) pairs against the source before the cut-over.
  4. Switch ThingsBoard to the IoTDB routes by setting the selectors (Step 3) and restart. New writes flow into 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.

Coexistence and rollback

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.

Rollback

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:

  • Configuration is fully reversible. Unsetting the selectors returns ThingsBoard to the host backend with no schema changes required on the IoTDB side; the IoTDB tables simply stop receiving new writes.
  • Data written to IoTDB stays in IoTDB. Rolling back the configuration does not copy IoTDB-resident telemetry/latest/attributes back into the host backend. If the host backend was serving the route during the IoTDB window, those two stores will have diverged for that period. Plan a reverse backfill (or a dual-write window) if you need the host backend to be current after a rollback.

Limitations to plan for

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.
  • Same-timestamp cross-store type change. A single (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.
  • Overlay TTL=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.
  • Per-save 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).
  • The attributes route is a stretch / Phase-2 opt-in. 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).

Verifying the cut-over

After switching a route to IoTDB:

  • Confirm the target tables exist and carry data: SHOW TABLES in the IoTDB database, and spot-check rows in telemetry (and telemetry_latest when the latest route is on).
  • Confirm the effective TTL via information_schema.tables (the ttl(ms) column) or SHOW TABLES (the TTL(ms) column) if you set retention.
  • Read back a few (entity, key) latest values through ThingsBoard and compare against the pre-cut-over source sample.