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.
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.
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.Each path is selected independently and is inert until its selector is present. Selector values are matched case-insensitively (trimmed).
| Path | Required properties | Default state |
|---|---|---|
Historical timeseries (TimeseriesDao) | database.ts.type=iotdb-table and iotdb.ts.experimental-raw-only=true | inert |
Latest telemetry (TimeseriesLatestDao) | all of database.ts.type=iotdb-table, iotdb.ts.experimental-raw-only=true, and database.ts_latest.type=iotdb-table | inert |
Entity attributes (AttributesDao) | database.attributes.type=iotdb-table (independent of the timeseries selectors) | inert (opt-in / stretch) |
Notes:
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.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.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.
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.
TimeseriesDao)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.findAllAsync with no aggregation, or an interval below 1): returns the stored points for the query window, ordered and limited as requested.findAllAsync with a positive interval): time-bucketed aggregation matching ThingsBoard's contract, in two forms:date_bin primitive (supports AVG, SUM, COUNT, MIN, MAX).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.remove): deletes points for an identity within the query's half-open time window.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).TimeseriesLatestDao)Active only when all three timeseries+latest selectors are set.
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.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.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.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).AttributesDao) — opt-in / stretchInert by default; activated only by database.attributes.type=iotdb-table.
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.find, findAll, and the by-keys / by-entity-ids variants): synchronous SELECTs over the entity_attributes table.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.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.
| Table | Purpose | TAG columns (in order) | TTL |
|---|---|---|---|
telemetry | historical telemetry | entity_type, tenant_id, key, entity_id | DEFAULT |
telemetry_latest | per-key latest overlay | entity_type, tenant_id, key, entity_id | INF |
entity_attributes | entity attributes | attribute_scope, entity_type, tenant_id, key, entity_id | INF |
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.
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.
| Property | Default | Notes |
|---|---|---|
iotdb.host | 127.0.0.1 | IoTDB node host. |
iotdb.port | 6667 | IoTDB client RPC port (1–65535). |
iotdb.database | thingsboard | Target Table Mode database. Must be a valid IoTDB identifier (a letter or underscore, then letters/digits/underscores). |
iotdb.username | root | Set a real credential in production. |
iotdb.password | root | Set a real credential in production. |
iotdb.session-pool-size | 8 | Table 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-ms | 5000 | Connection timeout (minimum 100). |
iotdb.enable-compression | false | RPC compression. |
iotdb.default-ttl-ms | -1 | ThingsBoard storage data-point accounting only; not an IoTDB physical-retention setting. -1 disables the default. |
iotdb.schema.bootstrap | true | Run the idempotent startup schema bootstrap. Set false to manage the schema yourself. |
iotdb.ts.save.*)| Property | Default | Notes |
|---|---|---|
batch-size | 500 | Rows per Tablet insert. |
max-linger-ms | 20 | Maximum time a partial batch waits to fill before flushing. |
queue-capacity | 50000 | Bounded accept queue; over-capacity saves are rejected (back-pressure), not blocked. |
flush-threads | 1 | Fixed at 1 (single-writer ordering). Any other value fails binding with a clear error. |
shutdown-drain-timeout-ms | 5000 | Graceful-drain window on shutdown. Keep ≥ max-linger-ms. |
retry-max-attempts | 3 | Per-batch attempts on a transient IoTDB error. |
retry-initial-backoff-ms | 50 | Initial retry backoff. |
retry-max-backoff-ms | 1000 | Maximum retry backoff (exponential between the two). |
iotdb.ts.read.*)These also size the bounded executor used by the latest and attribute DAOs.
| Property | Default | Notes |
|---|---|---|
threads | 4 | Bounded read/IO executor size. |
queue-capacity | 10000 | Read/IO task queue bound; an over-capacity task is rejected with a queue-full error. |
iotdb.attributes.*) — only when the attribute DAO is active| Property | Default | Notes |
|---|---|---|
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.) |
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:
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.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.
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.retry-max-attempts with exponential backoff. Permanent errors (parse, type, schema) fail fast without consuming the retry budget.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.iotdb.session-pool-size at least as large as the sum of read threads and flush threads (see §6).These are documented behaviors of the current module, not defects to work around silently:
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.(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.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).telemetry until the separate historical delete commits.iotdb.attributes.cluster-mode acknowledgement.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.