blob: 3b0823418928b94a384b001e8ea6eb432b8848f8 [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
## Overview
`iotdb-thingsboard-table` is a ThingsBoard historical-telemetry DAO backend
built on Apache IoTDB Table Mode. It lets a ThingsBoard deployment store
and serve time-series telemetry through IoTDB's table-session API instead of the
default Cassandra/SQL backends. It compiles against the reactor's IoTDB 2.0.5
table-session client; its integration tests run the real write path against an
`apache/iotdb:2.0.8-standalone` server. The module targets ThingsBoard v4.3.1.2. Because
it compiles with Java 17 language features (records and others), the
`iotdb-extras` parent reactor builds and tests it only on JDK 17+ through the
explicit, named `with-thingsboard` opt-in profile (it is not JDK-auto-activated):
CI passes `-P with-thingsboard` on the JDK 17/21 jobs while the 8/11 jobs omit it
and skip the module, and a plain reactor build never pulls it in.
## ThingsBoard SPI surface (Strategy F)
The ThingsBoard DAO SPI and value types (`org.thingsboard.*`) are not published
to Maven Central, so they cannot be a normal compile dependency. Strategy F
treats them as a **compile-only source surface** under `src/provided/java`: just
enough of the ThingsBoard interfaces and value objects to compile against. The
maven-jar-plugin excludes `org/thingsboard/**` from the built jar, so these
compile-only types never ship and never shadow the real ThingsBoard classes. At
runtime the actual ThingsBoard classpath supplies them. This keeps the module
buildable in isolation while binding to the genuine ThingsBoard types on a real
deployment.
The compile-only surface under `src/provided/java` was manually verified against
ThingsBoard `v4.3.1.2` (commit `c37fb509`): the `TimeseriesDao` SPI methods the
DAO consumes and the value-object accessors it reads were checked against the
upstream sources. A fully-automated check against the upstream artifact is not
possible because ThingsBoard's `dao`/`common-data` modules are not published to
Maven Central (the reason for Strategy F). As a guard against silent drift,
`StrategyFContractTest` pins the exact `TimeseriesDao` SPI method signatures the
DAO depends on, so any accidental edit to the local surface fails the build.
## Scope
This initial module delivers an inert-by-default foundation: `IoTDBTableBaseDao`
(session-pool lifecycle, schema/table bootstrap) and the
`IoTDBTableTimeseriesDao` write path (`save`), raw read, and delete. To exercise
it, set both `database.ts.type=iotdb-table` and
`iotdb.ts.experimental-raw-only=true`. Aggregation, latest telemetry, and
attribute/label DAOs are outside the current scope.
> **This is an incremental / experimental backend.** Explicitly enabling it
> routes ThingsBoard historical telemetry through IoTDB Table Mode for **raw read
> + write + delete only**. **Time-bucketed aggregation is NOT implemented yet**;
> aggregation, latest telemetry, and attributes are outside the current scope.
## Entity attributes (inert by default)
`IoTDBTableAttributesDao` is an `AttributesDao` implementation for the IoTDB Table
Mode backend, storing entity attributes in the `entity_attributes` table. It is
**inert by default**: in Phase-1, entity attributes stay in the host entity
database (PostgreSQL), and this DAO only activates when an operator opts in
explicitly with `database.attributes.type=iotdb-table`.
This attribute selector is **independent** of `database.ts.type` /
`database.ts_latest.type` — the attribute DAO routes separately from the
time-series DAOs (a piggy-back on the timeseries selector was deliberately
rejected). No shipped ThingsBoard release exposes a `database.attributes.type`
selector yet, so a real Phase-1 deployment never sets it; the activation condition
stays false, no attribute bean or session pool is created, and attributes keep
flowing to the host entity-DB `AttributesDao`.
When activated, 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 exactly one typed FIELD set, both under a
per-identity in-JVM lock so concurrent same-identity writes converge to a single
row.
### Phase-1 attribute limitations (documented, not silently degraded)
- **No SQL sequence, so versions are best-effort.** `removeAllWithVersions`
returns a `null` version (type-correct; the ThingsBoard service null-checks it
before the EDQS attribute-delete notification, so that notification is not driven
in Phase-1). `save`, however, returns a **non-null** version — the attribute's
`lastUpdateTs` — because `BaseAttributesService#doSave` passes the version into
`AttributeKv(..., long)` with no null-check, so a `null` would unbox to a
`NullPointerException` on every save. `lastUpdateTs` is a per-identity-monotonic
version proxy (stable across restarts).
- **`findNextBatch` is unsupported.** It is a relational keyset-pagination
migration helper with no IoTDB equivalent; it throws
`UnsupportedOperationException`.
- **`findAllKeysByDeviceProfileId` with a non-null profile returns an empty
list**, matching the official non-relational backend:
`CassandraBaseTimeseriesLatestDao.findAllKeysByDeviceProfileId` also returns
`Collections.emptyList()`, since a NoSQL / time-series store cannot do the
cross-device-table profile-dimension join. `entity_attributes` has no
`device_profile_id` tag and the module has no device→profile lookup. The sole
upstream caller is `DeviceProfileController`
`GET /api/deviceProfile/devices/keys/attributes` (TENANT_ADMIN, a config-time UI
key enumeration) which tolerates an empty result; failing loud would 500 under
IoTDB while Cassandra returns empty. The null-profile path returns the
tenant-wide distinct keys. A real device→profile lookup is a Phase-2 optional
enhancement.
- **`save` is non-atomic.** The tag-only `DELETE`-then-`INSERT` is two separate
statements with no rollback (IoTDB has no multi-statement transaction): if the
`INSERT` fails after the `DELETE`, the prior value is lost (the future fails
loud; the caller retries). Delete-first is **required** — an IoTDB insert at an
existing `(tags, time)` merges typed columns, so a same-timestamp type change
would otherwise leave two typed columns (the B1 fail-fast); insert-first would
re-break that, so the order is not reversed. A concurrent same-identity point
`find` takes the same per-identity lock and so never observes the transient empty
window, but full-scope reads (`find`-by-keys / `findAll` /
`findLatestByEntityIdsAndScope`) are best-effort (unlocked).
- **Single-JVM convergence only.** The per-identity lock converges writes only
within one JVM; cross-node single-writer safety is the operator's
responsibility, acknowledged via `iotdb.attributes.cluster_mode` (must be
`sticky-routing` or `disabled` when the DAO is active, else construction fails
fast).
## Latest telemetry (derived + overlay)
`IoTDBTableLatestDao` is a `TimeseriesLatestDao` implementation that serves the
latest value per `(tenant, entity, key)`. It activates only when all of
`database.ts.type=iotdb-table`, `database.ts_latest.type=iotdb-table`, and
`iotdb.ts.experimental-raw-only=true` are set (the timeseries selector is
required because the derived latest reads the `telemetry` table that only the
IoTDB writer populates). When it activates, `iotdb.ts_latest.cluster_mode` must
also be set to `sticky-routing` or `disabled` (mirroring `iotdb.attributes.cluster_mode`);
the empty default fails construction fast, because the overlay's per-identity lock
converges only within a single JVM.
The latest value is read from **both** the historical `telemetry` table
(derived, `ORDER BY time DESC LIMIT 1` / `LAST_BY(col, time)`, engine-accelerated
by IoTDB's native last cache) **and** a minimal per-key `telemetry_latest`
**overlay** table, merged by the maximum timestamp per key (the overlay wins an
exact tie). The merge is max-by-ts, not additive, so a key present in both stores
is never double-counted.
**The overlay is written on every `saveLatest`**. The module's historical `save()`
write path is asynchronous and batched,
so at the moment `saveLatest` runs it cannot see whether a paired `telemetry` row
will be written. It therefore cannot distinguish a latest-only write (e.g. the
EntityView telemetry-copy `LATEST_AND_WS` / `saveTs=false` path, which calls
`saveLatest` with **no** paired `save()`) from a normal full-save, and writes the
overlay unconditionally as a delete-then-insert (one row per identity, under a
per-identity in-JVM lock). This **closes the latest-only data-loss gap** that a
no-shadow-table no-op would otherwise drop, at the cost of one extra overlay write
per latest update (equivalent to the standard ThingsBoard latest-table behavior).
`removeLatest` reads the derived and overlay latest **separately** under the
per-identity lock and, when the merged latest is inside the half-open
`[startTs, endTs)` delete window, deletes the overlay row **only if the overlay's
own timestamp is in-window** (an out-of-window overlay value survives as the next
latest rather than being wiped). When `rewriteLatestIfDeleted` is set it resurrects
the next-older value across **both** stores (`telemetry` where `time < startTs` and
the overlay row if its own ts is `< startTs`; max-ts-wins) and returns it as the
removing result's data (so TB emits a WS update rather than a delete); a prior that
is the overlay's own already-stored value is reported without a redundant rewrite.
### Residual latest limitations
- **`version` is always `null`.** IoTDB has no SQL sequence (same as Cassandra);
type-correct and contract-legal, but TB notifications that key off a non-null
version are not driven in Phase-1.
- **Telemetry-derived race residual.** Overlay-backed values are race-free under
the per-identity lock, but the historical `remove` runs as a separate future
from `removeLatest`, so a purely telemetry-derived (full-save) latest can
transiently still be read from `telemetry` until that historical delete commits
(eventually consistent).
- **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).
- **Same-timestamp cross-store type change.** The overlay wins an exact-ts tie,
continuing the documented same-timestamp (B1) limitation below.
- **Batch latest read / key discovery deferred (graceful, not throwing).**
`findLatestByEntityIds(Async)` (new in v4.3.1.2, full impl is a follow-up) and the
key-discovery methods `findAllKeysByEntityIds(Async)` (a follow-up) return an **empty
list** rather than throwing, because they are reachable in normal operation (the
dashboard `/api/entitiesQuery/find/keys` lookup and entity-delete housekeeping),
where a throw would surface as an HTTP 500 / a failed cleanup task. This matches
the official `CassandraBaseTimeseriesLatestDao`, which returns empty for all four.
The `telemetry_latest` table is created on startup by a **second** idempotent
schema bootstrap (`schema-iotdb-table-latest.sql`), registered only when the
latest selector is active and `iotdb.schema.bootstrap` is not disabled; when the
latest selector is off the overlay table is never created.
## Known limitations
**Same-timestamp type change across separate flushes.** The writer collapses
duplicate `(tenant, entity, key, timestamp)` saves *within a single flush* so the
last write wins, but it does not yet defend against a same-`(tenant, entity, key,
timestamp)` save whose value *type* changes between two **separate** flushes (for
example a `LONG` written in one flush and a `STRING` written at the same
timestamp in a later flush). Because each typed value lands in its own column
(`long_v`, `str_v`, ...), that single point can end up with two non-null typed
columns.
This is a deliberate current-scope decision: the cleanup that would prevent it (a
delete-then-insert overwrite on every save) is outside the current scope. The
behavior is **fail-fast, not silent**: a raw read of that one poisoned point
throws an `IllegalStateException` (the single-typed-column invariant enforced in
`IoTDBTableBaseDao`) rather than returning a wrong value. Every other point is
unaffected. This documented behavior is pinned by an integration test
(`IoTDBTableTimeseriesDaoIT`).
## Configuration
The backend is bound from `iotdb.*` Spring properties (see `IoTDBTableConfig`).
Key activation and operational flags:
| Property | Default | Meaning |
| --- | --- | --- |
| `database.ts.type` | _(unset)_ | Set to `iotdb-table` as the ThingsBoard historical-timeseries backend selector. |
| `iotdb.ts.experimental-raw-only` | `false` | Explicit opt-in for this initial raw-only backend. Must be `true` together with `database.ts.type=iotdb-table`; write, raw read, and delete are implemented, while time-bucketed aggregation is outside the current scope. |
| `database.attributes.type` | _(unset)_ | Set to `iotdb-table` to opt in to the entity-attribute DAO. Independent of the timeseries selectors. Unset in a real Phase-1 deployment, so the attribute DAO is inert by default. |
| `iotdb.attributes.cluster_mode` | _(empty)_ | Required when `database.attributes.type=iotdb-table`. 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. |
| `iotdb.ts_latest.cluster_mode` | _(empty)_ | Required when `database.ts_latest.type=iotdb-table` (the latest-overlay DAO is active). Must be `sticky-routing` (per-identity latest writes pinned to one node) or `disabled` (single-node / acknowledged best-effort); any other value (including the empty default) fails construction fast, because the latest-overlay write path converges only within a single JVM. This is the symmetric acknowledgement to `iotdb.attributes.cluster_mode`. |
| `iotdb.attributes.executor.threads` | `4` | Worker-thread count for the attribute DAO's bounded IO executor. Sized independently of `iotdb.ts.read.*` so the attribute path's concurrency can be tuned on its own; the default matches `iotdb.ts.read`. |
| `iotdb.attributes.executor.queue-capacity` | `10000` | Bounded task-queue capacity for the attribute DAO's IO executor; a full queue rejects fast (back-pressure) rather than growing unboundedly. Default matches `iotdb.ts.read`. |
| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB node address. |
| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB credentials. |
| `iotdb.database` | `thingsboard` | Target IoTDB database. |
| `iotdb.session-pool-size` | `8` | Table session pool size. |
| `iotdb.schema.bootstrap` | `true` | When `true`, the module runs an idempotent startup bootstrap that reads `schema-iotdb-table.sql` from the classpath and creates the `telemetry` / `entity_attributes` tables (and database) on a fresh IoTDB before the first write. When the latest selector is active it also runs a second bootstrap from `schema-iotdb-table-latest.sql` to create the `telemetry_latest` overlay table. Set to `false` if you manage the schema out-of-band. |
The module is a Spring Boot **auto-configuration** (`IoTDBTableConfiguration`).
Its deployment host is ThingsBoard 4.3.x, which runs on Spring Boot 3.5.x, so the
active registration is
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
(paired with the `@AutoConfiguration` annotation). The legacy
`META-INF/spring.factories` `EnableAutoConfiguration` entry is retained only so the
module still activates if it is ever consumed by a Spring Boot 2.7 host; on Boot 3.x
it is ignored. Either way it activates in a real ThingsBoard deployment without the
host application having to component-scan `org.apache.iotdb.extras`.
## Build
The module is an explicit, named opt-in profile (`with-thingsboard`), so it is built
only when that profile is activated (a plain reactor build never pulls it in). It
requires JDK 17+:
```bash
# from the iotdb-extras repository root
mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean test
```
It can also be built standalone from the module directory:
```bash
cd iotdb-thingsboard-table
mvn compile -DskipTests
```
## Test
Run the Java test scaffold from the module directory:
```bash
mvn test
```
Run the Docker-backed integration tests only when required:
```bash
mvn -Piotdb-table-it verify
```
Start the local integration stack with explicit environment values:
```bash
TB_POSTGRES_USER=<postgres-user> TB_POSTGRES_PASSWORD=<postgres-password> \
IOTDB_USERNAME=<iotdb-user> IOTDB_PASSWORD=<iotdb-password> \
docker compose -f docker-compose.test.yml up -d
```
Stop and remove the local stack:
```bash
docker compose -f docker-compose.test.yml down -v
```
## Status
Initial module status: `IoTDBTableBaseDao` plus the `IoTDBTableTimeseriesDao`
write, raw-read, and delete paths are implemented behind
`database.ts.type=iotdb-table` and `iotdb.ts.experimental-raw-only=true`.
Without both properties, the module is inert. Aggregation and latest telemetry
are outside the current scope.
`IoTDBTableAttributesDao` is **inert by default** and activated only by the independent
`database.attributes.type=iotdb-table` opt-in (see the Entity attributes section
above and its Phase-1 limitations). In a real Phase-1 deployment the selector is
unset, so the attribute DAO never activates and attributes stay in the host
entity database.