blob: df84f8cfa64df8b5b614a90235555e1ff9285951 [file] [view]
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# 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`](../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](#5-schema-and-bootstrap)); 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).
| 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:
- **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](#6-configuration-reference)). 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:
```text
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](#7-retention--ttl)).
### 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 `SELECT`s 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**.
| 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`](../src/main/resources/schema-iotdb-table.sql)
(`telemetry` + `entity_attributes`) and
[`schema-iotdb-table-latest.sql`](../src/main/resources/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](#3-activation). Defaults are shown.
### Connection
| 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. |
### Write path (`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). |
### Read path (`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. |
### Attributes (`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.) |
## 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](#6-configuration-reference)).
## 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.