This is an automated email from the ASF dual-hosted git repository.

CritasWang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git


The following commit(s) were added to refs/heads/master by this push:
     new d6245f3  Document table-level retention and refresh the README for the 
merged DAOs (#117)
d6245f3 is described below

commit d6245f325304a45543eb4c64e58fc341a388c960
Author: ZIHAN DAI <[email protected]>
AuthorDate: Mon Aug 3 12:02:16 2026 +1000

    Document table-level retention and refresh the README for the merged DAOs 
(#117)
    
    Add a Retention / TTL section describing how physical retention is set on
    the IoTDB side: it is a table property in milliseconds, declared as
    WITH (TTL=DEFAULT) in the shipped schema and changed with ALTER TABLE ...
    SET PROPERTIES TTL=<ms>, readable back from information_schema.tables. It
    also records the Phase-1 limitation that ThingsBoard's per-save ttl
    argument cannot be a physical-retention directive, because IoTDB
    retention is table-wide, so the module uses it only for storage
    data-point accounting.
    
    IoTDBTableTtlIT pins both the schema-default and the runtime-ALTER paths
    against a real IoTDB 2.0.8 container. It asserts the TTL property
    mechanism only, not physical row eviction, which is asynchronous and
    compaction-driven and therefore not deterministic in a test.
    
    While writing that section the surrounding README turned out to describe
    the module as it was several changes ago, so this also brings it back in
    line with the code: time-bucketed aggregation, latest telemetry and the
    attribute DAO are implemented rather than out of scope, and key discovery
    now reads DISTINCT keys from both telemetry and the telemetry_latest
    overlay instead of returning an empty list. The batch findLatestByEntityIds
    deferral is unchanged and stays documented.
    
    Signed-off-by: Zihan Dai <[email protected]>
---
 iotdb-thingsboard-table/README.md                  | 124 +++++++++---
 .../extras/thingsboard/table/IoTDBTableTtlIT.java  | 216 +++++++++++++++++++++
 2 files changed, 316 insertions(+), 24 deletions(-)

diff --git a/iotdb-thingsboard-table/README.md 
b/iotdb-thingsboard-table/README.md
index 3b08234..00f0c76 100644
--- a/iotdb-thingsboard-table/README.md
+++ b/iotdb-thingsboard-table/README.md
@@ -27,7 +27,8 @@
 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
+table-session client; its integration tests run the real write, read,
+aggregation, latest-telemetry, attribute and retention paths 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
@@ -58,17 +59,26 @@ 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.
+The module is an inert-by-default foundation — `IoTDBTableBaseDao` 
(session-pool
+lifecycle, schema/table bootstrap) — plus three DAOs, each behind its own
+selector:
+
+- `IoTDBTableTimeseriesDao`: the historical write path (`save`), raw read,
+  delete, and time-bucketed aggregation — both the fixed-width millisecond
+  `date_bin` path and the timezone-aware calendar path (`WEEK` / `WEEK_ISO` /
+  `MONTH` / `QUARTER`). Enabled by `database.ts.type=iotdb-table` together with
+  `iotdb.ts.experimental-raw-only=true`. That property name predates the
+  aggregation support and is kept for compatibility; it is the opt-in for this
+  backend as a whole, not a raw-only switch.
+- `IoTDBTableLatestDao`: latest telemetry, derived from `telemetry` with a
+  `telemetry_latest` overlay. Enabled by `database.ts_latest.type=iotdb-table`
+  (see the latest-telemetry section below).
+- `IoTDBTableAttributesDao`: entity attributes, **inert by default**. Enabled
+  only by the independent `database.attributes.type=iotdb-table` opt-in.
+
+> **This is an incremental / experimental backend.** Nothing routes through
+> IoTDB Table Mode unless the matching selector is set explicitly; with no
+> selectors set the module does nothing.
 
 ## Entity attributes (inert by default)
 
@@ -188,19 +198,82 @@ is the overlay's own already-stored value is reported 
without a redundant rewrit
   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.
+- **Batch latest read deferred (graceful, not throwing).**
+  `findLatestByEntityIds(Async)` (new in v4.3.1.2) returns an **empty list**
+  rather than throwing, because it is reachable in normal operation and a throw
+  would surface as an HTTP 500. This matches the official
+  `CassandraBaseTimeseriesLatestDao`; the derived batch read is a follow-up.
+  Key discovery (`findAllKeysByEntityIds(Async)` and the tenant-wide variant) 
is
+  implemented: it collects `DISTINCT key` from **both** `telemetry` and the
+  `telemetry_latest` overlay, so a key that only ever reached the overlay 
through
+  `saveLatest` is discovered too.
 
 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.
 
+## Retention / TTL
+
+Physical retention is a **table-level** IoTDB property that the operator sets 
on
+the schema; it is not a per-data-point setting. IoTDB Table Mode expresses TTL 
as
+a retention window in **milliseconds**, and the accepted forms are narrow:
+
+| Form | Meaning |
+| --- | --- |
+| `TTL=604800000` | A concrete retention: a bare, **unquoted** long literal 
(milliseconds). |
+| `TTL='INF'` | Never expire. The **quoted** string is the only accepted 
spelling; this is the form `entity_attributes` and `telemetry_latest` ship 
with. |
+| `TTL=DEFAULT` | Inherit the database default, which is `INF` on a fresh 
node. |
+
+Anything else is rejected by IoTDB 2.0.8: an unquoted `TTL=INF` is parsed as an
+identifier (`ttl value must be a LongLiteral, but now is Identifier`), and any
+other quoted value — including a quoted number (`'604800000'`) or a duration
+(`'7d'`) — fails with `ttl value must be 'INF' or a long literal`.
+
+The shipped `schema-iotdb-table.sql` declares `telemetry` with
+`WITH (TTL=DEFAULT)`, so a fresh deployment never expires data until an 
operator
+chooses otherwise. To set a concrete retention, either edit the schema before 
the
+first bootstrap:
+
+```sql
+CREATE TABLE telemetry (...) WITH (TTL=604800000);
+```
+
+or change it at runtime on the live table:
+
+```sql
+ALTER TABLE telemetry SET PROPERTIES TTL=604800000;   -- 7 days, in ms
+ALTER TABLE telemetry SET PROPERTIES TTL=DEFAULT;     -- back to the db default
+```
+
+The effective value can be read back from `SHOW TABLES`, which reports it in a
+`TTL(ms)` column, or from `information_schema.tables`, where the column must be
+quoted because its name contains parentheses:
+
+```sql
+SELECT table_name, "ttl(ms)" FROM information_schema.tables WHERE 
database='thingsboard';
+```
+
+Either way a never-expiring table reads back as `INF` and a concrete retention 
as
+the millisecond number.
+`IoTDBTableTtlIT` pins both paths against a real IoTDB 2.0.8 container. It
+verifies the TTL **property mechanism** only and deliberately does not assert
+physical row eviction, because eviction is asynchronous and compaction-driven 
and
+so is not deterministic inside a test.
+
+The `telemetry_latest` overlay is declared `TTL='INF'` on purpose: it holds at
+most one row per identity and is the authority for a latest value, so expiring 
it
+would silently drop the latest reading while history remained.
+
+- **Phase-1 limitation: the per-save `ttl` argument is not a physical-retention
+  directive.** ThingsBoard's `TimeseriesDao.save(..., long ttl)` carries a
+  per-data-point TTL, but IoTDB Table Mode retention can only be expressed at 
the
+  table level, so the two cannot be reconciled faithfully. The module therefore
+  uses that argument (together with `iotdb.defaultTtlMs`) **only** for
+  ThingsBoard's storage data-point accounting, and never as an instruction to
+  expire rows. Operators who need physical retention set it on the table as 
shown
+  above.
+
 ## Known limitations
 
 **Same-timestamp type change across separate flushes.** The writer collapses
@@ -228,7 +301,7 @@ 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. |
+| `iotdb.ts.experimental-raw-only` | `false` | Explicit opt-in for this 
backend. Must be `true` together with `database.ts.type=iotdb-table`. The name 
predates the aggregation support and is kept for compatibility: write, raw 
read, delete **and** time-bucketed aggregation are all served when it is 
enabled. |
 | `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`. |
@@ -298,11 +371,14 @@ 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
+`IoTDBTableBaseDao` plus the `IoTDBTableTimeseriesDao` write, raw-read, delete
+and time-bucketed aggregation 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.
+Without both properties, that DAO is inert. `IoTDBTableLatestDao` (latest
+telemetry, with the `telemetry_latest` overlay and key discovery) is 
implemented
+behind its own `database.ts_latest.type=iotdb-table` selector. Physical
+retention is a table property the operator sets on the schema; see
+Retention / TTL above.
 
 `IoTDBTableAttributesDao` is **inert by default** and activated only by the 
independent
 `database.attributes.type=iotdb-table` opt-in (see the Entity attributes 
section
diff --git 
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTtlIT.java
 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTtlIT.java
new file mode 100644
index 0000000..bd6cf4f
--- /dev/null
+++ 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTtlIT.java
@@ -0,0 +1,216 @@
+/*
+ * 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.
+ */
+
+package org.apache.iotdb.extras.thingsboard.table;
+
+import org.apache.iotdb.isession.ITableSession;
+import org.apache.iotdb.isession.SessionDataSet;
+import org.apache.iotdb.isession.pool.ITableSessionPool;
+import org.apache.iotdb.session.pool.TableSessionPoolBuilder;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Validates the table-level TTL mechanism against real IoTDB 2.0.8. IoTDB 
Table Mode TTL is a table
+ * property expressed in milliseconds; this IT proves the two operator-facing 
paths the README
+ * documents work as described:
+ *
+ * <ul>
+ *   <li>the schema's {@code WITH (TTL=DEFAULT)} bootstraps and resolves to 
the database default
+ *       ({@code INF}, i.e. never expire), and
+ *   <li>{@code ALTER TABLE telemetry SET PROPERTIES TTL=<ms>} sets a concrete 
millisecond retention
+ *       at runtime, and {@code CREATE TABLE ... WITH (TTL=<ms>)} sets it at 
create time.
+ * </ul>
+ *
+ * <p>The asserted TTL is read back from {@code information_schema.tables} 
(the {@code ttl(ms)}
+ * column). This validates the TTL DDL/property mechanism only. It 
deliberately does NOT assert
+ * physical row eviction: IoTDB TTL eviction is async and compaction-driven, 
so observed row drops
+ * are not deterministic within a test and are out of scope here.
+ */
+@Tag("integration")
+@Testcontainers(disabledWithoutDocker = true)
+class IoTDBTableTtlIT {
+  private static final Duration IOTDB_STARTUP_TIMEOUT = Duration.ofMinutes(3);
+  private static final Duration IOTDB_READY_TIMEOUT = Duration.ofSeconds(60);
+  private static final Duration IOTDB_READY_POLL_INTERVAL = 
Duration.ofMillis(500);
+
+  // 7 days expressed in milliseconds: IoTDB Table Mode TTL is a bare long 
literal in ms.
+  private static final long SEVEN_DAYS_MS = TimeUnit.DAYS.toMillis(7);
+  // 1 day in ms, used for the CREATE-time path.
+  private static final long ONE_DAY_MS = TimeUnit.DAYS.toMillis(1);
+
+  @Container
+  static final GenericContainer<?> IOTDB =
+      new 
GenericContainer<>(DockerImageName.parse("apache/iotdb:2.0.8-standalone"))
+          .withExposedPorts(6667)
+          // IoTDB binds its client RPC service to dn_rpc_address (default 
127.0.0.1); bind to all
+          // interfaces so the Testcontainers port-mapped session handshake 
succeeds.
+          .withEnv("dn_rpc_address", "0.0.0.0")
+          
.waitingFor(Wait.forListeningPort().withStartupTimeout(IOTDB_STARTUP_TIMEOUT));
+
+  @Test
+  void 
schemaBootstrapsWithDefaultTtlThenAlterSetsConcreteMillisecondRetention() 
throws Exception {
+    String database = uniqueDatabase("ttl_alter");
+    bootstrapSchema(database);
+    try (ITableSessionPool pool = newPool(database)) {
+      // The shipped schema declares telemetry WITH (TTL=DEFAULT); DEFAULT 
resolves to the database
+      // default retention, which on a fresh node is INF (never expire).
+      assertEquals("INF", readTableTtlMs(pool, database, "telemetry"));
+
+      // The same schema declares entity_attributes WITH (TTL='INF'). The 
QUOTED string is the only
+      // accepted never-expire literal: an unquoted INF is parsed as an 
identifier and rejected with
+      // "ttl value must be a LongLiteral, but now is Identifier". Asserting 
the read-back pins the
+      // exact form the shipped schema depends on, not just that the bootstrap 
did not throw.
+      assertEquals("INF", readTableTtlMs(pool, database, "entity_attributes"));
+
+      // Operator switches to a concrete retention at runtime via ALTER ... 
SET PROPERTIES TTL=<ms>.
+      try (ITableSession session = pool.getSession()) {
+        session.executeNonQueryStatement(
+            "ALTER TABLE telemetry SET PROPERTIES TTL=" + SEVEN_DAYS_MS);
+      }
+      assertEquals(Long.toString(SEVEN_DAYS_MS), readTableTtlMs(pool, 
database, "telemetry"));
+
+      // And can revert to the database default (INF here) the same way.
+      try (ITableSession session = pool.getSession()) {
+        session.executeNonQueryStatement("ALTER TABLE telemetry SET PROPERTIES 
TTL=DEFAULT");
+      }
+      assertEquals("INF", readTableTtlMs(pool, database, "telemetry"));
+    }
+  }
+
+  @Test
+  void createTableWithConcreteMillisecondTtlIsReadBackExactly() throws 
Exception {
+    String database = uniqueDatabase("ttl_create");
+    bootstrapSchema(database);
+    try (ITableSessionPool pool = newPool(database)) {
+      // The schema can equally ship a concrete retention at create time: WITH 
(TTL=<ms>).
+      try (ITableSession session = pool.getSession()) {
+        session.executeNonQueryStatement(
+            "CREATE TABLE telemetry_ttl ("
+                + "tenant_id STRING TAG, entity_type STRING TAG, entity_id 
STRING TAG, "
+                + "key STRING TAG, long_v INT64 FIELD) WITH (TTL="
+                + ONE_DAY_MS
+                + ")");
+      }
+      assertEquals(Long.toString(ONE_DAY_MS), readTableTtlMs(pool, database, 
"telemetry_ttl"));
+    }
+  }
+
+  /**
+   * Reads the table's TTL back from {@code information_schema.tables}. The 
{@code ttl(ms)} column
+   * is the millisecond retention, or the literal {@code INF} when the table 
never expires.
+   */
+  private String readTableTtlMs(ITableSessionPool pool, String database, 
String table)
+      throws Exception {
+    String sql =
+        "SELECT \"ttl(ms)\" FROM information_schema.tables "
+            + "WHERE database='"
+            + database
+            + "' AND table_name='"
+            + table
+            + "'";
+    try (ITableSession session = pool.getSession();
+        SessionDataSet dataSet = session.executeQueryStatement(sql)) {
+      SessionDataSet.DataIterator row = dataSet.iterator();
+      assertTrue(row.next(), "expected one information_schema row for table " 
+ table);
+      return row.getString("ttl(ms)");
+    }
+  }
+
+  private ITableSessionPool newPool(String database) {
+    TableSessionPoolBuilder builder =
+        new TableSessionPoolBuilder()
+            .nodeUrls(List.of("127.0.0.1:" + IOTDB.getMappedPort(6667)))
+            .user("root")
+            .password("root")
+            .maxSize(2);
+    if (database != null) {
+      builder.database(database);
+    }
+    return builder.build();
+  }
+
+  private void bootstrapSchema(String database) throws Exception {
+    awaitIoTDBReady(database);
+
+    String schema;
+    try (InputStream stream =
+        
IoTDBTableTtlIT.class.getClassLoader().getResourceAsStream("schema-iotdb-table.sql"))
 {
+      schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+    }
+    schema =
+        schema
+            .replace(
+                "CREATE DATABASE IF NOT EXISTS thingsboard;",
+                "CREATE DATABASE IF NOT EXISTS " + database + ";")
+            .replace("USE thingsboard;", "USE " + database + ";");
+    schema = schema.replaceAll("(?s)/\\*.*?\\*/", "").replaceAll("(?m)--.*$", 
"");
+    try (ITableSessionPool bootstrapPool = newPool(null);
+        ITableSession session = bootstrapPool.getSession()) {
+      for (String statement : schema.split(";")) {
+        String trimmed = statement.trim();
+        if (!trimmed.isEmpty()) {
+          session.executeNonQueryStatement(trimmed);
+        }
+      }
+    }
+  }
+
+  private void awaitIoTDBReady(String database) throws Exception {
+    long deadlineNanos = System.nanoTime() + IOTDB_READY_TIMEOUT.toNanos();
+    Exception lastFailure = null;
+    while (System.nanoTime() < deadlineNanos) {
+      try (ITableSessionPool bootstrapPool = newPool(null);
+          ITableSession session = bootstrapPool.getSession()) {
+        session.executeNonQueryStatement("CREATE DATABASE IF NOT EXISTS " + 
database);
+        return;
+      } catch (Exception e) {
+        lastFailure = e;
+        long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - 
System.nanoTime());
+        if (remainingMillis <= 0) {
+          break;
+        }
+        Thread.sleep(Math.min(IOTDB_READY_POLL_INTERVAL.toMillis(), 
remainingMillis));
+      }
+    }
+    throw new IllegalStateException(
+        "IoTDB did not accept table-session statements within " + 
IOTDB_READY_TIMEOUT, lastFailure);
+  }
+
+  private String uniqueDatabase(String prefix) {
+    String shortPrefix = prefix.length() > 12 ? prefix.substring(0, 12) : 
prefix;
+    String shortUuid = UUID.randomUUID().toString().replace("-", 
"").substring(0, 16);
+    return "tb_it_" + shortPrefix + "_" + shortUuid;
+  }
+}

Reply via email to