IoTDB ThingsBoard Table — User Guide

This guide explains how to configure, activate, and operate the IoTDB Table Mode storage backend for ThingsBoard. For the architecture overview, the compile-only ThingsBoard SPI surface, build/test instructions, and the design rationale, see the module README.md.

1. What the module is

iotdb-thingsboard-table is a storage backend that lets a ThingsBoard deployment persist telemetry, latest values, and (optionally) entity attributes in Apache IoTDB 2.x relational Table Mode instead of the default Cassandra / SQL backends. It implements ThingsBoard‘s TimeseriesDao, TimeseriesLatestDao, and AttributesDao SPIs and ships as a Spring Boot auto-configuration, so it activates inside a real ThingsBoard deployment without the host application having to component-scan the module’s package.

The module is built against the reactor's IoTDB 2.0.11 table-session client and its integration tests exercise the real write path against an apache/iotdb:2.0.11-standalone server. It targets ThingsBoard 4.3.1.2 and requires JDK 17+.

It is default-inert. None of the three DAOs activate unless the operator sets the explicit selector properties below. With no selector set, the module contributes no beans and ThingsBoard keeps using its configured backends.

2. Prerequisites

  • A running Apache IoTDB 2.x node with relational Table Mode, reachable from the ThingsBoard host over the IoTDB client RPC port.
  • The target IoTDB database (default thingsboard) and its tables. On a fresh node the module's idempotent startup bootstrap creates them for you (see §5); operators who manage the schema out-of-band can disable the bootstrap and pre-create the tables from the shipped SQL.
  • IoTDB credentials with permission to create the database/tables (when the bootstrap is enabled) and to read/write the tables.

3. Activation

Each path is selected independently and is inert until its selector is present. Selector values are matched case-insensitively (trimmed).

PathRequired propertiesDefault state
Historical timeseries (TimeseriesDao)database.ts.type=iotdb-table and iotdb.ts.experimental-raw-only=trueinert
Latest telemetry (TimeseriesLatestDao)all of database.ts.type=iotdb-table, iotdb.ts.experimental-raw-only=true, and database.ts_latest.type=iotdb-tableinert
Entity attributes (AttributesDao)database.attributes.type=iotdb-table (independent of the timeseries selectors)inert (opt-in / stretch)

Notes:

  • Timeseries requires both database.ts.type=iotdb-table and the explicit experimental opt-in iotdb.ts.experimental-raw-only=true; setting only one of them leaves the path inert.
  • Latest additionally requires database.ts_latest.type=iotdb-table. The timeseries selectors are required as well because the latest value is derived from the telemetry table that only the IoTDB writer populates — the latest path can never activate without that writer.
  • Attributes is an opt-in stretch feature, and database.attributes.type is a selector this module supplies rather than one ThingsBoard offers. ThingsBoard switches its timeseries DAOs by configuration but has no equivalent for attributes: JpaAttributeDao is an unconditional @Component (verified at v4.3.1.2), so nothing in thingsboard.yml can stand it down. Setting this selector therefore has the module withdraw that one bean at startup, logging a WARN that names it. Leaving the selector unset is the default posture and attributes keep flowing to the host entity database. When it is activated, iotdb.attributes.cluster-mode must also be set or the DAO fails fast at startup (see §6). Open question Q6 / ThingsBoard Discussion #15296 tracks a native selector; if one ships, this module should use it instead.

When activated, the DAOs share a single module-owned IoTDB table session pool.

Conflict guards

When a selector is on, the module refuses to share its SPI slot with another backend. The timeseries and latest guards do this by failing startup: if a conflicting non-IoTDB TimeseriesDao or TimeseriesLatestDao bean is present while the matching selector is set, startup stops with a message naming it. Remove the conflicting backend or unset the IoTDB selector.

The attributes guard behaves differently, because the conflict it faces is not one you can resolve from a configuration file. ThingsBoard registers JpaAttributeDao unconditionally, so “remove the conflicting backend” is not advice an operator can act on. When database.attributes.type=iotdb-table is set, the module therefore withdraws that one bean definition itself and logs the line below. AttributesDaoConflictGuardTest asserts that the module emits it; it was also observed in a live run against thingsboard/tb-node:4.3.1.2 on 2026-08-20:

WARN  Removed ThingsBoard bean 'jpaAttributeDao'
      (org.thingsboard.server.dao.sql.attributes.JpaAttributeDao)
      because database.attributes.type=iotdb-table selects the IoTDB attributes
      backend; ...

The withdrawal is narrow on purpose. It matches on both the bean name and the exact fully-qualified class name, so only ThingsBoard‘s own component is removed. Any other competing AttributesDao — a third-party backend, a decorator, or a subclass of this module’s DAO under a different bean name — is left in place and startup fails instead, naming it. A bean your application registered deliberately is not the module's to delete.

The guard reads the bean definitions present when it runs. A definition registered later, supplied by a FactoryBean that does not report its type until initialisation, or inherited from a parent context is outside its reach.

4. The three DAOs

4.1 Timeseries DAO (TimeseriesDao)

  • Write (save): telemetry points are accepted onto a bounded in-memory queue and flushed by a single writer thread as multi-row Tablet inserts into the telemetry table. Writes are asynchronous and batched; the call returns a future. Duplicate (tenant, entity, key, timestamp) saves within one flush are collapsed so the last write wins.
  • Raw read (findAllAsync with no aggregation, or an interval below 1): returns the stored points for the query window, ordered and limited as requested.
  • Aggregated read (findAllAsync with a positive interval): time-bucketed aggregation matching ThingsBoard's contract, in two forms:
    • Fixed-width millisecond buckets anchored at the query start timestamp, using IoTDB's date_bin primitive (supports AVG, SUM, COUNT, MIN, MAX).
    • Timezone-aware calendar buckets for WEEK, WEEK_ISO, MONTH, and QUARTER intervals, where each calendar boundary is computed in the query's timezone and one bounded aggregate query is issued per bucket.
  • Delete (remove): deletes points for an identity within the query's half-open time window.
  • Retention hooks: cleanup is a no-op and savePartition does nothing, because IoTDB Table Mode keeps no per-partition bookkeeping and physical retention is a table-level property (see §7).

4.2 Latest telemetry DAO (TimeseriesLatestDao)

Active only when all three timeseries+latest selectors are set.

  • Read (findLatest, findLatestOpt, findAllLatest): the latest value per (tenant, entity, key) is read from both the historical telemetry table (derived via ORDER BY time DESC LIMIT 1 / LAST_BY, engine-accelerated by IoTDB's last cache) and the telemetry_latest overlay table, then merged by the maximum timestamp per key (the overlay wins an exact tie). The merge is not additive, so a key present in both stores is never double-counted.
  • Write (saveLatest): writes the per-key overlay row as a delete-then-insert under a per-identity lock. It is max-timestamp-wins: a backdated saveLatest whose timestamp is not newer than the stored latest is skipped, so an out-of-order write never regresses the latest. The overlay is written on every (non-backdated) call so a latest-only write — one with no paired historical save, e.g. the EntityView telemetry-copy path — is not lost.
  • Delete (removeLatest): snapshots the merged latest under the per-identity lock; when that latest falls inside the half-open [startTs, endTs) window it deletes the overlay row, and when rewriteLatestIfDeleted is set it resurrects the next-older historical value (from telemetry) back into the overlay.
  • Key discovery (findAllKeysByEntityIds / …Async): returns the distinct telemetry keys for the entity set, unioned across telemetry and the telemetry_latest overlay, so a latest-only key is still discoverable. findAllKeysByDeviceProfileId returns the tenant-wide distinct keys for a null (all-profiles) device profile and an empty list for a specific profile (the tables carry no device-profile membership).

4.3 Entity attributes DAO (AttributesDao) — opt-in / stretch

Inert by default; activated only by database.attributes.type=iotdb-table.

  • Write (save): 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 one typed value column set, both under a per-identity lock so concurrent same-identity writes converge to a single row.
  • Read (find, findAll, and the by-keys / by-entity-ids variants): synchronous SELECTs over the entity_attributes table.
  • Delete (removeAll, removeAllWithVersions): one future per key, each a tag-only DELETE. removeAllByEntityId is a best-effort select-then-delete under an entity-level write lock.

5. Schema and bootstrap

The module uses one table per path. All three share the same five typed value columns — bool_v BOOLEAN, long_v INT64, double_v DOUBLE, str_v STRING, json_v TEXT — of which exactly one is non-null per row.

TablePurposeTAG columns (in order)TTL
telemetryhistorical telemetryentity_type, tenant_id, key, entity_idDEFAULT
telemetry_latestper-key latest overlayentity_type, tenant_id, key, entity_idINF
entity_attributesentity attributesattribute_scope, entity_type, tenant_id, key, entity_idINF

The shipped DDL lives in schema-iotdb-table.sql (telemetry + entity_attributes) and schema-iotdb-table-latest.sql (telemetry_latest).

Bootstrap. When iotdb.schema.bootstrap is true (the default), the module runs an idempotent startup bootstrap that reads the schema SQL from the classpath and creates the database and tables before the first write. Every statement uses CREATE … IF NOT EXISTS, so re-runs are harmless. The base schema is created whenever the timeseries or attributes path is active; the telemetry_latest overlay is created by a second bootstrap only when the latest path is active. Set iotdb.schema.bootstrap=false to manage the schema out-of-band.

6. Configuration reference

All module knobs are bound from the iotdb.* prefix; the three database.* selectors and iotdb.ts.experimental-raw-only are the activation switches from §3. Defaults are shown.

Connection

PropertyDefaultNotes
iotdb.host127.0.0.1IoTDB node host.
iotdb.port6667IoTDB client RPC port (1–65535).
iotdb.databasethingsboardTarget Table Mode database. Must be a valid IoTDB identifier (a letter or underscore, then letters/digits/underscores).
iotdb.usernamerootSet a real credential in production.
iotdb.passwordrootSet a real credential in production.
iotdb.session-pool-size8Table session pool size (1–1024). Keep it at least iotdb.ts.read.threads + iotdb.ts.save.flush-threads, or the module logs a warning that reads/flushes may wait for a session.
iotdb.connection-timeout-ms5000Connection timeout (minimum 100).
iotdb.enable-compressionfalseRPC compression.
iotdb.default-ttl-ms-1ThingsBoard storage data-point accounting only; not an IoTDB physical-retention setting. -1 disables the default.
iotdb.schema.bootstraptrueRun the idempotent startup schema bootstrap. Set false to manage the schema yourself.

Write path (iotdb.ts.save.*)

PropertyDefaultNotes
batch-size500Rows per Tablet insert.
max-linger-ms20Maximum time a partial batch waits to fill before flushing.
queue-capacity50000Bounded accept queue; over-capacity saves are rejected (back-pressure), not blocked.
flush-threads1Fixed at 1 (single-writer ordering). Any other value fails binding with a clear error.
shutdown-drain-timeout-ms5000Graceful-drain window on shutdown. Keep ≥ max-linger-ms.
retry-max-attempts3Per-batch attempts on a transient IoTDB error.
retry-initial-backoff-ms50Initial retry backoff.
retry-max-backoff-ms1000Maximum retry backoff (exponential between the two).

Read path (iotdb.ts.read.*)

These also size the bounded executor used by the latest and attribute DAOs.

PropertyDefaultNotes
threads4Bounded read/IO executor size.
queue-capacity10000Read/IO task queue bound; an over-capacity task is rejected with a queue-full error.

Attributes (iotdb.attributes.*) — only when the attribute DAO is active

PropertyDefaultNotes
iotdb.attributes.cluster-mode(empty)Required when database.attributes.type=iotdb-table. Must be sticky-routing (writes for an identity pinned to one node) or disabled (single-node / acknowledged best-effort). Any other value, including the empty default, fails fast at startup, because the per-identity write lock converges writes only within a single JVM. (The module also accepts the equivalent iotdb.attributes.cluster_mode spelling.)

7. Retention / TTL

Physical retention is a table-level IoTDB property, expressed in milliseconds, set by the operator on the schema — not a per-data-point setting. The shipped telemetry table is declared WITH (TTL=DEFAULT) (inherit the database default, which is INF on a fresh node). To enable a concrete retention, edit the schema before bootstrap or run ALTER TABLE telemetry SET PROPERTIES TTL=<milliseconds> on the live table.

Two consequences to be aware of:

  • The per-save ttl argument carried by TimeseriesDao.save(..., ttl) is not applied as physical retention per row — IoTDB Table Mode TTL is table-wide. The module uses the per-save ttl (and iotdb.default-ttl-ms) only for ThingsBoard's storage data-point accounting.
  • The telemetry_latest overlay is declared WITH (TTL='INF') and is exempt from the telemetry TTL, so a retention window on telemetry does not evict overlay rows. Operators who need to bound the overlay must set a TTL on telemetry_latest separately.

See the README's Retention / TTL section for the full mechanism and the syntax IoTDB accepts.

8. Operational notes

  • Back-pressure. The write accept queue (iotdb.ts.save.queue-capacity) is bounded. When it is full, a save is rejected with a queue-full error rather than blocking the caller — size the queue for your ingest rate.
  • Retries. A batch that hits a transient IoTDB condition (connection error, or a server-side overload / timeout / dispatch status) is retried up to retry-max-attempts with exponential backoff. Permanent errors (parse, type, schema) fail fast without consuming the retry budget.
  • Graceful shutdown. On shutdown the writer stops accepting new saves and drains already-accepted writes within iotdb.ts.save.shutdown-drain-timeout-ms. If the window expires, the remaining pending writes are failed. Shutdown is at-least-once: if the timeout races an insert that has already started, the write may still commit after its future has failed, so tolerate a duplicate or uncertain final batch.
  • Session pool sizing. Keep iotdb.session-pool-size at least as large as the sum of read threads and flush threads (see §6).

9. Known limitations (Phase-1 residuals)

These are documented behaviors of the current module, not defects to work around silently:

  • Latest/attribute version is always null. IoTDB has no SQL sequence, so saveLatest, attribute save, and removeAllWithVersions return a null version (type-correct, matching the Cassandra backend). ThingsBoard notifications that key off a non-null version are therefore not driven.
  • Same-timestamp cross-store type change. The writer collapses duplicate (tenant, entity, key, timestamp) saves within a single flush, but it does not defend against a same-timestamp save whose value type changes across two separate flushes. Because each typed value lands in its own column, that single point can end up with two non-null typed columns. The behavior is fail-fast, not silent: a read of that one point throws rather than returning a wrong value; every other point is unaffected. In the latest path, the overlay wins an exact timestamp tie.
  • Overlay growth. telemetry_latest is 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).
  • Latest delete eventual consistency. A purely telemetry-derived (full-save) latest can transiently still be read from telemetry until the separate historical delete commits.
  • Single-JVM write convergence. The per-identity locks that serialize the latest-overlay and attribute writes are in-JVM, so cross-node single-writer safety is the operator's responsibility. The attribute path makes this explicit via the required iotdb.attributes.cluster-mode acknowledgement.
  • Unsupported SPI operations. The attribute relational-migration helper findNextBatch throws UnsupportedOperationException: it is a relational keyset-pagination helper with no IoTDB equivalent, and it is the only method in the module that throws. The batch latest read (findLatestByEntityIds / …Async, new in ThingsBoard 4.3.1.2) instead returns empty, matching CassandraBaseTimeseriesLatestDao — it backs the includeSamples branch of POST /api/entitiesQuery/find/keys, where a synchronous throw would surface as an HTTP 500. A specific (non-null) device-profile key lookup likewise returns an empty list.