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 275043e  Make the attributes selector usable and correct a runtime 
dependency's scope (#125)
275043e is described below

commit 275043ed3a470d2b570fe548827d3e59dab3f5f0
Author: ZIHAN DAI <[email protected]>
AuthorDate: Tue Aug 25 11:44:21 2026 +1000

    Make the attributes selector usable and correct a runtime dependency's 
scope (#125)
    
    * Make the attributes selector usable and correct a runtime dependency's
    scope
    
    Deploying the module into a live ThingsBoard for the first time surfaced
    two defects that the existing tests could not: the module had never been
    run inside a running ThingsBoard, only compiled and integration-tested
    against its types. The two were seen on different deployment attempts,
    and each is attributed to its own below.
    
    The attributes selector could never take effect. This one is from the
    end-to-end run on thingsboard/tb-node:4.3.1.2 against
    apache/iotdb:2.0.8-standalone. ThingsBoard gates its timeseries DAOs on
    database.ts.type, so for ts and ts_latest it steps aside by itself, but
    it offers no equivalent for attributes: at v4.3.1.2, JpaAttributeDao is
    a bare @Component and so registers unconditionally.
    AttributesDaoConflictGuard therefore always found a competing bean and
    failed startup, and its own advice -- remove the bean or unset the
    selector -- could not be followed, because a @Component cannot be
    un-registered from a properties file. So on that build the documented
    attributes opt-in was not an option a deployment could take: setting the
    selector produced a startup failure, every time. I have checked 4.3.1.2
    and have not surveyed the release history, so this is stated for
    v4.3.1.2 and no wider.
    
    The guard now withdraws ThingsBoard's bean instead of refusing to start,
    and the withdrawal is narrow. Candidates are sorted into three classes,
    and nothing is mutated until every reason to stop has been evaluated:
    
      - ours, found by looking up the bean name this configuration registers
        rather than by filtering the type scan, so a bean that took the name 
while
        implementing something else is caught too;
      - ThingsBoard's own, matched on BOTH the bean name jpaAttributeDao and the
        resolved type
        org.thingsboard.server.dao.sql.attributes.JpaAttributeDao -- this one is
        withdrawn, with a WARN naming it, its class and the property that
        caused it;
      - anything else implementing AttributesDao -- a third-party backend, a
        decorator, a subclass of our own DAO under another name -- which fails
        startup untouched.
    
    The third class is the point. A bean the operator registered
    deliberately is not this module's to delete, so the guard refuses rather
    than guesses, and unlike the old message "remove it or unset the
    selector" is now advice that can actually be followed. Matching on the
    class name rather than on assignability also means an upstream rename
    fails closed: the bean becomes unrecognised and startup stops naming it,
    instead of the guard withdrawing something it should not have.
    
    What the guard guarantees is bounded by what it can see. Candidates come
    from one getBeanNamesForType(type, true, false) snapshot, which does not
    initialise FactoryBeans and does not consult a parent factory, so a
    definition registered by a later post-processor, produced by an opaque
    FactoryBean, or inherited from an ancestor context is outside it. The
    javadoc says so rather than claiming more.
    
    Our own DAO bean also had to lose
    @ConditionalOnMissingBean(type = ATTRIBUTES_DAO_CLASS_NAME) -- the string
    form, chosen so that evaluating the condition does not load ThingsBoard
    classes. It is evaluated while
    configuration classes are parsed, strictly before the post-processor
    runs, so on a stock 4.3.1.2 it skipped our bean on every deployment.
    
    The second defect is in the POM. iotdb-thrift-commons was declared at
    test scope with a comment calling it "a transitive runtime dependency of
    iotdb-session" -- which is exactly what the declaration broke, since a
    direct declaration wins under Maven's nearest-definition rule. It
    carries TEndPoint, which the session pool needs at runtime, so the
    runtime dependency set produced from the POM omitted it and the deployed
    module could not create a session -- observed as a TEndPoint load
    failure during an earlier tb-postgres:4.2.1.1 deployment attempt, not
    during the 4.3.1.2 run above. That container's log was not preserved, so
    this is the observation rather than a quotation. It is now declared at
    runtime scope, which is what it is: no main source references it, the
    unit tests do, and the session pool needs it at runtime. That also
    retires the dependency:analyze suppression -- analyze-only no longer
    reports the artifact, so the correct scope replaces the suppression
    rather than sitting alongside it.
    
    One new compile-only file,
    src/provided/java/org/thingsboard/server/dao/sql/
    attributes/JpaAttributeDao.java, joins the existing Strategy F surface.
    ThingsBoard's dao artifact is not on Maven Central, so without a class
    of that exact fully-qualified name on the test classpath the conjunctive
    match above could not be exercised at all. It is excluded from the
    packaged jar by the existing org/thingsboard/** rule and cannot shadow
    the real class; verified by building the jar and listing its entries. No
    test establishes that the name is correct -- that comes from
    ThingsBoard's source at v4.3.1.2, and a rename fails closed.
    
    Three in-repo documents and two production javadoc blocks change with
    it, because the behaviour they describe changed. IoTDBTableAttributesDao
    and IoTDBTableAttributesEnabledCondition each argued that no real
    Phase-1 deployment sets database.attributes.type -- the rationale this
    change overturns -- and now describe it as a selector this module
    supplies, pointing at the guard for what setting it does.
    docs/user-guide.md and docs/migration-guide.md both described a single
    fail-fast conflict guard for all three routes; that is still true of
    timeseries and latest and is no longer true of attributes, so each now
    describes the two behaviours separately, with the withdrawal's matching
    rule and its visibility boundary. README.md is included for a different
    reason: its attributes section argued that no shipped ThingsBoard
    release exposes the selector, "so a real Phase-1 deployment never sets
    it" -- a rationale this change overturns, since the selector is one this
    module supplies. Its statement that the attributes selector is
    independent of database.ts.type / database.ts_latest.type is retained:
    that is independence among this module's own routes and it remains
    correct.
    
    Two constants in IoTDBTableAttributesEnabledCondition widen from private
    to package-private so the guard's WARN can name the property and value
    from their single definition; the class is itself package-private, so
    nothing a consumer can see changes.
    
    Tests: AttributesDaoConflictGuardTest now carries 14 cases, covering the
    withdrawal, the zero-replacement refusal, an IoTDB peer under another
    name, a third-party DAO alongside a removable ThingsBoard one,
    right-name/wrong-type and right-type/wrong-name, a pre-built singleton
    with no definition to withdraw, the WARN's four fields, the no-op path
    and the ordering. Each of the three guarantees was mutation-checked:
    reverting the class-name half of the match, identifying our bean by
    assignability, and moving the unknown-bean check after the removal each
    reddened exactly one test, with an unrelated test green throughout as a
    contamination control. IoTDBTableTimeseriesAggregationIT gains non-UTC
    calendar coverage with a boundary-straddling sample -- 2023-01-31T20:00Z
    is January under UTC and February under Asia/Shanghai -- so an
    implementation that drops the query timezone changes the bucket count
    and fails; confirmed by forcing the zone to UTC and watching it go red.
    
    204 unit tests and 58 integration tests pass. The full unit suite is
    also green against Spring 6.2.18 / Spring Boot 3.5.14, the line
    ThingsBoard 4.3.1.2 runs.
    
    * Prevent JDK 17 test JVM attach hangs
---
 .github/workflows/compile-check.yml                |   1 +
 iotdb-thingsboard-table/README.md                  |  29 +-
 iotdb-thingsboard-table/docs/migration-guide.md    |  35 +-
 iotdb-thingsboard-table/docs/user-guide.md         |  56 ++-
 iotdb-thingsboard-table/pom.xml                    |  47 ++-
 .../thingsboard/table/IoTDBTableAttributesDao.java |  17 +-
 .../IoTDBTableAttributesEnabledCondition.java      |  15 +-
 .../thingsboard/table/IoTDBTableConfiguration.java | 221 ++++++++--
 .../server/dao/sql/attributes/JpaAttributeDao.java | 162 ++++++++
 .../table/AttributesDaoConflictGuardTest.java      | 444 +++++++++++++++++++++
 .../table/IoTDBTableTimeseriesAggregationIT.java   |  70 +++-
 11 files changed, 1019 insertions(+), 78 deletions(-)

diff --git a/.github/workflows/compile-check.yml 
b/.github/workflows/compile-check.yml
index afb21d9..801631c 100644
--- a/.github/workflows/compile-check.yml
+++ b/.github/workflows/compile-check.yml
@@ -37,6 +37,7 @@ jobs:
         java: [8, 11, 17, 21]
         os: [ ubuntu-latest ]
     runs-on: ${{ matrix.os }}
+    timeout-minutes: 30
     steps:
       - uses: actions/checkout@v4
       - name: Set up JDK ${{ matrix.java }}
diff --git a/iotdb-thingsboard-table/README.md 
b/iotdb-thingsboard-table/README.md
index 00f0c76..9e0b811 100644
--- a/iotdb-thingsboard-table/README.md
+++ b/iotdb-thingsboard-table/README.md
@@ -74,7 +74,8 @@ selector:
   `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.
+  only by the `database.attributes.type=iotdb-table` opt-in, which is separate
+  from the two timeseries selectors.
 
 > **This is an incremental / experimental backend.** Nothing routes through
 > IoTDB Table Mode unless the matching selector is set explicitly; with no
@@ -91,11 +92,19 @@ 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
+rejected). Leaving it unset is the default posture: the activation condition
 stays false, no attribute bean or session pool is created, and attributes keep
 flowing to the host entity-DB `AttributesDao`.
 
+`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 — at v4.3.1.2 its `JpaAttributeDao` is an
+unconditional `@Component`, so no property can stand it down. Setting this
+selector therefore has the module withdraw that one bean at startup, matched on
+both its bean name and its fully-qualified class name, logging a WARN that 
names
+it; any other competing `AttributesDao` fails startup untouched. See
+`docs/user-guide.md` for the full semantics and their boundary.
+
 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
@@ -302,7 +311,7 @@ Key activation and operational flags:
 | --- | --- | --- |
 | `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 
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. |
+| `database.attributes.type` | _(unset)_ | Set to `iotdb-table` to opt in to 
the entity-attribute DAO. Independent of the timeseries selectors. Unset by 
default, and while unset the attribute DAO is inert. Setting it withdraws 
ThingsBoard's own `jpaAttributeDao` bean — see the Entity attributes section. |
 | `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`. |
@@ -380,8 +389,10 @@ 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
-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.
+`IoTDBTableAttributesDao` is **inert by default** and activated only by the
+`database.attributes.type=iotdb-table` opt-in, which is separate from the two
+timeseries selectors (see the Entity attributes section above and its Phase-1
+limitations). While the selector is unset — the default posture — the attribute
+DAO never activates and attributes stay in the host entity database. Setting it
+has the module withdraw ThingsBoard's own attributes bean, which is why that
+section describes the matching rule and its boundary.
diff --git a/iotdb-thingsboard-table/docs/migration-guide.md 
b/iotdb-thingsboard-table/docs/migration-guide.md
index 344ed91..a16b755 100644
--- a/iotdb-thingsboard-table/docs/migration-guide.md
+++ b/iotdb-thingsboard-table/docs/migration-guide.md
@@ -319,12 +319,25 @@ Each route is independently activated and guarded:
 - **Independent activation.** Enabling telemetry does not enable attributes, 
and
   vice versa. You can route telemetry + latest to IoTDB while attributes stay 
in
   the host entity database (the default Phase-1 posture).
-- **Fail-fast conflict guard.** When a route is enabled but a conflicting
-  non-IoTDB host DAO bean of the same SPI type is also present, startup fails
-  fast with a clear message rather than silently shadowing one DAO with 
another.
-  The historical (`TimeseriesDao`), latest (`TimeseriesLatestDao`), and 
attribute
-  (`AttributesDao`) routes each have their own guard. Make sure the host 
backend
-  for a route is removed/disabled when you point that route at IoTDB.
+- **Conflict guards, and they are not all fail-fast.** The historical
+  (`TimeseriesDao`), latest (`TimeseriesLatestDao`), and attribute
+  (`AttributesDao`) routes each have their own guard, but they resolve the
+  conflict differently.
+
+  The timeseries and latest guards fail startup when a conflicting non-IoTDB 
host
+  DAO of the same SPI type is present, rather than silently shadowing one DAO 
with
+  another. Remove or disable the host backend for that route before pointing 
it at
+  IoTDB.
+
+  The attribute guard cannot ask for that, because ThingsBoard registers
+  `JpaAttributeDao` unconditionally and no configuration stands it down. So 
when
+  `database.attributes.type=iotdb-table` is set, the module withdraws that one 
bean
+  definition itself and logs a WARN naming it. The match is on both the bean 
name
+  and the exact fully-qualified class name, so nothing else is ever removed: 
any
+  other competing `AttributesDao` visible to the guard when it runs fails 
startup
+  untouched. Definitions registered after it, supplied by a `FactoryBean` that
+  does not report its type until initialisation, or inherited from a parent
+  context are outside its reach.
 
 ### Rollback
 
@@ -370,10 +383,12 @@ module `README.md` for the authoritative list):
   with table-wide IoTDB TTL; the module uses it only for ThingsBoard's
   storage-accounting, never as a physical-retention directive. Set physical
   retention on the table (Step 5).
-- **The attributes route is a stretch / Phase-2 opt-in.** No shipped 
ThingsBoard
-  release exposes a `database.attributes.type` selector yet (open question,
-  tracked upstream), so in a real Phase-1 deployment the selector is unset and
-  attributes stay in the host entity database. When activated, `save` is a
+- **The attributes route is a stretch / Phase-2 opt-in.** 
`database.attributes.type`
+  is a selector this module supplies rather than one ThingsBoard offers; 
leaving it
+  unset is the default posture, and while unset attributes stay in the host 
entity
+  database. Setting it makes the module withdraw ThingsBoard's own attributes 
bean
+  — see the conflict-guard bullet above for the matching rule and its boundary.
+  When activated, `save` is a
   non-atomic tag-only delete-then-insert under a per-identity in-JVM lock that
   converges only within one JVM; `findNextBatch` is unsupported
   (`UnsupportedOperationException`), and `findAllKeysByDeviceProfileId` with a
diff --git a/iotdb-thingsboard-table/docs/user-guide.md 
b/iotdb-thingsboard-table/docs/user-guide.md
index 2c19743..b96e57e 100644
--- a/iotdb-thingsboard-table/docs/user-guide.md
+++ b/iotdb-thingsboard-table/docs/user-guide.md
@@ -76,22 +76,56 @@ Notes:
   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. No shipped
-  ThingsBoard release exposes a `database.attributes.type` selector yet (open
-  question Q6 / ThingsBoard Discussion #15296), so a real deployment normally
-  leaves it unset 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)).
+- **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 fails startup fast if a conflicting non-IoTDB
-DAO bean of the same SPI type is also present (for example, another
-`TimeseriesDao` while `database.ts.type=iotdb-table`). This is deliberate: it
-prevents the module from silently shadowing, or being shadowed by, a different
-backend. Remove the conflicting backend or unset the IoTDB selector.
+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
 
diff --git a/iotdb-thingsboard-table/pom.xml b/iotdb-thingsboard-table/pom.xml
index 43b65a2..63e8f7b 100644
--- a/iotdb-thingsboard-table/pom.xml
+++ b/iotdb-thingsboard-table/pom.xml
@@ -99,6 +99,24 @@
             <artifactId>spring-context</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <!--
+                Test scope only. Without a binding the module's own slf4j 
calls resolve to the NOP
+                logger, so no test can observe them at all; 
AttributesDaoConflictGuardTest asserts
+                the WARN that records which ThingsBoard bean was withdrawn, 
and that assertion is
+                the only automated check that the disclosure actually happens. 
Version is managed
+                by the parent, and logback is already used by other modules in 
this repository.
+            -->
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <!-- Test scope only: ListAppender, used to capture the withdrawal 
WARN. -->
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-core</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-autoconfigure</artifactId>
@@ -130,14 +148,23 @@
             <scope>test</scope>
         </dependency>
         <dependency>
-            <!-- test-scope only: the unit tests construct 
StatementExecutionException(TSStatus) with
-                 a specific status code to exercise the retry / idempotency 
classification. TSStatus
-                 lives in iotdb-thrift-commons (a transitive runtime 
dependency of iotdb-session);
-                 declared here at test scope so it is a declared, test-only 
use. Version inherited
-                 from the parent reactor (iotdb.version). -->
+            <!-- Runtime scope, deliberately. iotdb-thrift-commons carries 
TEndPoint and TSStatus:
+                 the session pool needs TEndPoint at RUNTIME, and the unit 
tests construct
+                 StatementExecutionException(TSStatus) to exercise the retry / 
idempotency
+                 classification. No main source references it, so compile 
scope would overstate the
+                 dependency; runtime scope puts it on both the runtime and the 
test classpaths,
+                 which is exactly where it is used.
+
+                 The scope must be declared: the artifact arrives transitively 
from iotdb-session,
+                 but a direct declaration wins under Maven's 
nearest-definition rule, so declaring
+                 it at TEST scope (as this block once did) demoted the 
transitive runtime dependency
+                 and dropped it from the runtime classpath entirely. Deploying 
the module then
+                 failed at first session creation with NoClassDefFoundError on 
TEndPoint. Verified
+                 with dependency:build-classpath -DincludeScope=runtime. 
Version inherited from the
+                 parent reactor (iotdb.version). -->
             <groupId>org.apache.iotdb</groupId>
             <artifactId>iotdb-thrift-commons</artifactId>
-            <scope>test</scope>
+            <scope>runtime</scope>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
@@ -264,6 +291,8 @@
                         </goals>
                         <phase>test</phase>
                         <configuration>
+                            <!-- Execution-level argLine overrides the parent 
Surefire argLine; retain its heap limit. -->
+                            <argLine>@{argLine} -Xmx1024m 
-javaagent:${org.mockito:mockito-core:jar}</argLine>
                             <includes>
                                 <include>**/*Test.java</include>
                             </includes>
@@ -278,6 +307,12 @@
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-dependency-plugin</artifactId>
                 <executions>
+                    <execution>
+                        <id>mockito-agent-path</id>
+                        <goals>
+                            <goal>properties</goal>
+                        </goals>
+                    </execution>
                     <execution>
                         <id>check-dependencies</id>
                         <configuration>
diff --git 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDao.java
 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDao.java
index fbd90d9..baf3574 100644
--- 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDao.java
+++ 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesDao.java
@@ -55,14 +55,15 @@ import java.util.concurrent.locks.ReadWriteLock;
 /**
  * Entity-attribute DAO for the IoTDB Table Mode backend.
  *
- * <p>Spring activation: {@code database.attributes.type=iotdb-table}. NOTE: 
this activation
- * property is the Phase-1 selector pending upstream ThingsBoard confirmation; 
upstream ThingsBoard
- * does not yet expose an {@code AttributesDao} selector, so the DAO is 
<b>inert by default</b> (no
- * real Phase-1 deployment sets {@code database.attributes.type}, so the {@link
- * IoTDBTableAttributesEnabledCondition} stays false and the bean is never 
instantiated). The
- * selector is independent of {@code database.ts.type} / {@code 
database.ts_latest.type} (the
- * attribute DAO routes separately); if upstream resolves to a different 
property, the condition is
- * updated. Phase-1 attributes stay in the host entity DB.
+ * <p>Spring activation: {@code database.attributes.type=iotdb-table}. This is 
a selector this
+ * module supplies rather than one upstream ThingsBoard offers -- upstream 
exposes no {@code
+ * AttributesDao} selector of its own -- so the DAO is <b>inert by 
default</b>: while the property
+ * is unset, {@link IoTDBTableAttributesEnabledCondition} stays false, the 
bean is never
+ * instantiated, and attributes stay in the host entity DB. Setting it makes 
{@code
+ * AttributesDaoConflictGuard} withdraw ThingsBoard's own attributes bean; see 
that guard's javadoc
+ * for the matching rule and the boundary of what it can see. The selector is 
independent of {@code
+ * database.ts.type} / {@code database.ts_latest.type} (the attribute DAO 
routes separately); if
+ * upstream ever exposes a native selector, this module should use it instead.
  *
  * <p>This DAO is wired as an explicit {@code @Bean} in {@link 
IoTDBTableConfiguration} (guarded by
  * the activation property) rather than via component scanning, so the {@code 
ITableSessionPool}
diff --git 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
index 50dc515..f06c137 100644
--- 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
+++ 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableAttributesEnabledCondition.java
@@ -28,15 +28,16 @@ import org.springframework.core.type.AnnotatedTypeMetadata;
  *
  * <p>This selector is INDEPENDENT of {@code database.ts.type} / {@code 
database.ts_latest.type}:
  * the attribute DAO routes separately from the time-series DAOs (a piggy-back 
on the timeseries
- * selector was deliberately rejected). Because upstream ThingsBoard does not 
expose an {@code
- * AttributesDao} selector yet, no real Phase-1 deployment sets {@code 
database.attributes.type};
- * the property is therefore absent in practice, this condition returns false, 
the attribute bean is
- * never instantiated, and attributes keep flowing to the host entity-DB 
{@code AttributesDao}. The
- * DAO is thus inert by default and only activates when an operator opts in 
explicitly.
+ * selector was deliberately rejected). Upstream ThingsBoard exposes no {@code 
AttributesDao}
+ * selector of its own, so {@code database.attributes.type} is one this module 
supplies. Leaving it
+ * unset is the default posture: this condition returns false, the attribute 
bean is never
+ * instantiated, and attributes keep flowing to the host entity-DB {@code 
AttributesDao}. The DAO is
+ * inert by default and activates only when an operator opts in explicitly -- 
at which point {@code
+ * AttributesDaoConflictGuard} withdraws ThingsBoard's own attributes bean.
  */
 final class IoTDBTableAttributesEnabledCondition implements Condition {
-  private static final String SELECTOR_PROPERTY = "database.attributes.type";
-  private static final String SELECTOR_VALUE = "iotdb-table";
+  static final String SELECTOR_PROPERTY = "database.attributes.type";
+  static final String SELECTOR_VALUE = "iotdb-table";
 
   @Override
   public boolean matches(ConditionContext context, AnnotatedTypeMetadata 
metadata) {
diff --git 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
index 6384400..407b539 100644
--- 
a/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
+++ 
b/iotdb-thingsboard-table/src/main/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableConfiguration.java
@@ -27,6 +27,7 @@ import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
 import 
org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
 import org.springframework.boot.autoconfigure.AutoConfiguration;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@@ -36,10 +37,16 @@ import 
org.springframework.boot.context.properties.EnableConfigurationProperties
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Conditional;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.PriorityOrdered;
 import org.springframework.core.ResolvableType;
 import org.springframework.util.ClassUtils;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * Spring Boot auto-configuration entry point for the IoTDB Table Mode backend.
@@ -76,6 +83,12 @@ public class IoTDBTableConfiguration {
       "org.thingsboard.server.dao.timeseries.TimeseriesLatestDao";
   static final String ATTRIBUTES_DAO_CLASS_NAME =
       "org.thingsboard.server.dao.attributes.AttributesDao";
+  // ThingsBoard's own attributes component. Verified at v4.3.1.2 (tag 
c37fb509):
+  // 
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java:58-61
 is a
+  // bare @Component on this class, so Spring's default name is the 
uncapitalised simple name.
+  static final String JPA_ATTRIBUTE_DAO_CLASS_NAME =
+      "org.thingsboard.server.dao.sql.attributes.JpaAttributeDao";
+  static final String JPA_ATTRIBUTE_DAO_BEAN_NAME = "jpaAttributeDao";
 
   @Configuration(proxyBeanMethods = false)
   @ConditionalOnClass(name = TIMESERIES_DAO_CLASS_NAME)
@@ -211,10 +224,11 @@ public class IoTDBTableConfiguration {
    * a separate inner configuration from {@link EnabledRawOnlyConfiguration} 
because the attribute
    * DAO routes separately from the time-series DAOs: it must be able to 
activate on its own
    * (attributes selector set, ts selectors unset) and must stay inert when no 
attributes selector
-   * is present. Because no shipped ThingsBoard release exposes {@code 
database.attributes.type},
-   * the default Phase-1 deployment leaves it unset, this configuration is 
skipped, no session pool
-   * or attribute bean is created, and attributes keep flowing to the host 
entity-DB {@code
-   * AttributesDao} (inert by default).
+   * is present. Leaving the selector unset is the default posture: this 
configuration is skipped,
+   * no session pool or attribute bean is created, and attributes keep flowing 
to the host entity-DB
+   * {@code AttributesDao} (inert by default). {@code 
database.attributes.type} is a selector this
+   * module supplies rather than one ThingsBoard offers -- see {@link 
#attributesDaoConflictGuard()}
+   * for what setting it does to the host's own attributes bean.
    *
    * <p>The session pool / schema bootstrap beans here reuse the same bean 
name as {@link
    * EnabledRawOnlyConfiguration} and carry {@code 
@ConditionalOnMissingBean(name=...)}, so when
@@ -234,10 +248,25 @@ public class IoTDBTableConfiguration {
     }
 
     /**
-     * Fails startup before any IoTDB pool/bootstrap singleton is created if 
the explicit IoTDB
-     * attribute backend selection conflicts with a host-provided {@code 
AttributesDao}, mirroring
-     * {@code timeseriesDaoConflictGuard()} so the attribute path does not 
silently shadow a
-     * different backend.
+     * Resolves the attributes-backend conflict before any IoTDB 
pool/bootstrap singleton is
+     * created. Unlike its timeseries siblings this guard does not only fail: 
ThingsBoard switches
+     * its timeseries DAOs by configuration but offers no equivalent for 
attributes, so when the
+     * IoTDB attributes backend is selected the guard <em>withdraws</em> 
ThingsBoard's own {@code
+     * jpaAttributeDao} bean definition and logs a WARN naming it.
+     *
+     * <p>Withdrawal is deliberately narrow. It applies to exactly one bean, 
matched on both the
+     * bean name {@code jpaAttributeDao} and the resolved type {@code
+     * org.thingsboard.server.dao.sql.attributes.JpaAttributeDao}. Any 
<em>other</em> competing
+     * {@code AttributesDao} — a third-party backend, a decorator, a subclass 
of this module's own
+     * DAO — fails startup untouched, because a bean the operator registered 
deliberately is not
+     * ours to delete.
+     *
+     * <p><b>Scope of the guarantee.</b> Candidates are discovered from a 
single {@code
+     * getBeanNamesForType(type, true, false)} snapshot, which does not 
initialise FactoryBeans and
+     * does not consult a parent factory. What this guard promises is 
therefore bounded to the
+     * definitions visible in this bean factory at the moment it runs: a 
definition registered by a
+     * later post-processor, produced by an opaque {@code FactoryBean} whose 
{@code getObjectType()}
+     * is null until initialisation, or inherited from an ancestor context is 
outside it.
      */
     @Bean
     static BeanFactoryPostProcessor attributesDaoConflictGuard() {
@@ -250,9 +279,17 @@ public class IoTDBTableConfiguration {
      * classes while evaluating auto-configuration metadata, and the {@code 
@Bean} destroy method
      * drains the DAO's IO executor on shutdown.
      */
+    // NOTE: deliberately NOT @ConditionalOnMissingBean(type = 
ATTRIBUTES_DAO_CLASS_NAME).
+    // At ThingsBoard v4.3.1.2 JpaAttributeDao is an unconditional @Component, 
and that condition is
+    // evaluated while configuration classes are parsed -- strictly BEFORE
+    // attributesDaoConflictGuard() runs. Keeping it meant this bean was 
skipped on that build, so
+    // the selector could never take effect. The guard now resolves the 
conflict instead: it
+    // withdraws ThingsBoard's own attributes bean, or refuses to start if it 
finds any other
+    // competing AttributesDao. Within the definitions visible to the guard 
when it runs, that
+    // leaves exactly one -- this one. It is not a guarantee about definitions 
the guard cannot
+    // see; see attributesDaoConflictGuard()'s javadoc for that boundary.
     @Bean(name = IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME, destroyMethod = 
"destroy")
     @ConditionalOnBean(name = IOTDB_TABLE_SESSION_POOL_BEAN_NAME)
-    @ConditionalOnMissingBean(type = ATTRIBUTES_DAO_CLASS_NAME)
     IoTDBTableAttributesDao ioTDBTableAttributesDao(
         @Qualifier(IOTDB_TABLE_SESSION_POOL_BEAN_NAME) ITableSessionPool 
tableSessionPool,
         IoTDBTableConfig config) {
@@ -407,33 +444,165 @@ public class IoTDBTableConfiguration {
     }
   }
 
-  private static final class AttributesDaoConflictGuard implements 
BeanFactoryPostProcessor {
+  private static final class AttributesDaoConflictGuard
+      implements BeanFactoryPostProcessor, PriorityOrdered {
+
+    // This guard MUTATES bean definitions; its throw-only siblings do not. 
PriorityOrdered with
+    // HIGHEST_PRECEDENCE puts it ahead of other regular 
BeanFactoryPostProcessors, which is what
+    // keeps the withdrawal ahead of anything that would resolve AttributesDao 
through one. It does
+    // NOT order this guard against BeanDefinitionRegistryPostProcessors, 
which run as a separate
+    // earlier phase -- a definition registered there is simply part of the 
snapshot this guard
+    // reads, while one registered by a LATER post-processor is outside what 
it can see at all.
+    @Override
+    public int getOrder() {
+      return Ordered.HIGHEST_PRECEDENCE;
+    }
+
     @Override
     public void postProcessBeanFactory(ConfigurableListableBeanFactory 
beanFactory)
         throws BeansException {
+      // PHASE 0 -- the only check that depends on no candidate, so it is 
answered once. Testing
+      // this per-candidate made the failure message depend on iteration order.
+      if (!(beanFactory instanceof BeanDefinitionRegistry registry)) {
+        throw new IllegalStateException(
+            "database.attributes.type=iotdb-table, but this bean factory is 
not a "
+                + "BeanDefinitionRegistry, so ThingsBoard's competing 
attributes bean cannot be "
+                + "withdrawn; unset the IoTDB attributes selector");
+      }
       Class<?> attributesDaoType = resolveAttributesDaoClass(beanFactory);
+
+      // PHASE 1 -- read-only. Nothing below this point mutates until every 
reason to stop has
+      // been evaluated.
+      //
+      // (a) OUR bean is found by DIRECT NAME LOOKUP, not by filtering the 
type snapshot.
+      //     Assignability is not identity: a user subclass of 
IoTDBTableAttributesDao is
+      //     somebody else's bean that happens to extend ours. Looking the 
name up directly also
+      //     catches a bean that took our name while implementing something 
else entirely --
+      //     that bean never enters the AttributesDao snapshot at all.
+      boolean ourDefinitionPresent =
+          
beanFactory.containsBeanDefinition(IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME);
+      Class<?> ourType =
+          ourDefinitionPresent
+              ? resolveBeanType(beanFactory, 
IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME)
+              : null;
+
+      // (b) every OTHER visible AttributesDao candidate falls into one of two 
classes:
+      //
+      //   KNOWN_TARGET  ThingsBoard's own attributes component, matched 
CONJUNCTIVELY on the
+      //                 default component name AND the exact resolved class 
name. This is the
+      //                 single bean the explicit 
database.attributes.type=iotdb-table selector
+      //                 asks this module to replace, and the only one the 
documentation
+      //                 names. Verified at ThingsBoard v4.3.1.2 (tag 
c37fb509):
+      //                 JpaAttributeDao is a bare @Component on that class, 
hence that
+      //                 name.
+      //   UNKNOWN       everything else -- a subclass, a decorator, a 
third-party backend, or a
+      //                 right-name/wrong-type imposter. Deleting a bean an 
operator wired on
+      //                 purpose is worse than the ambiguity it would prevent, 
so these keep the
+      //                 original fail-fast semantics, and that advice is now 
actionable: the
+      //                 bean belongs to the application, which can remove it.
+      //
+      // Discovery is getBeanNamesForType(type, true, false): a one-time 
snapshot that does not
+      // initialise FactoryBeans and does not consult a parent factory. 
Definitions registered
+      // after this post-processor, produced by an opaque FactoryBean whose 
getObjectType() is
+      // null until initialisation, or inherited from an ancestor context are 
outside what this
+      // guard can see -- and therefore outside what it promises.
+      // At most ONE bean can ever be the known target: the match is 
conjunctive on a fixed bean
+      // name, and bean names are unique within a factory. A collection here 
would imply a
+      // generality that cannot occur -- the same objection that removed an 
unreachable
+      // "more than one of ours" branch from an earlier draft.
+      String knownTargetName = null;
+      Class<?> knownTargetType = null;
+      Map<String, Class<?>> unknown = new LinkedHashMap<>();
+      List<String> unresolvable = new ArrayList<>();
+
       for (String beanName : 
beanFactory.getBeanNamesForType(attributesDaoType, true, false)) {
-        if (!isIoTDBAttributesDaoBean(beanFactory, beanName)) {
-          throw new IllegalStateException(
-              "database.attributes.type=iotdb-table, but a non-IoTDB 
AttributesDao bean '"
-                  + beanName
-                  + "' is present; remove it or unset the IoTDB attributes 
selector");
+        if (IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME.equals(beanName)) {
+          continue;
+        }
+        Class<?> beanType = resolveBeanType(beanFactory, beanName);
+        if (beanType == null) {
+          unresolvable.add(beanName);
+        } else if (JPA_ATTRIBUTE_DAO_BEAN_NAME.equals(beanName)
+            && JPA_ATTRIBUTE_DAO_CLASS_NAME.equals(beanType.getName())) {
+          knownTargetName = beanName;
+          knownTargetType = beanType;
+        } else {
+          unknown.put(beanName, beanType);
         }
       }
-    }
 
-    private static boolean isIoTDBAttributesDaoBean(
-        ConfigurableListableBeanFactory beanFactory, String beanName) {
-      Class<?> beanType = resolveBeanType(beanFactory, beanName);
-      if (beanType == null) {
+      // An earlier version checked each candidate's removability inside the 
mutation loop, so it
+      // could withdraw candidate one and then throw on candidate two, leaving 
a half-mutated
+      // context that the surrounding comment claimed was impossible.
+      if (!unresolvable.isEmpty()) {
         throw new IllegalStateException(
-            "database.attributes.type=iotdb-table, but AttributesDao bean '"
-                + beanName
-                + "' has no resolvable type; expose a concrete 
IoTDBTableAttributesDao type or "
-                + "remove the bean");
+            "database.attributes.type=iotdb-table, but AttributesDao bean(s) "
+                + unresolvable
+                + " have no resolvable type and cannot be classified; expose a 
concrete type or "
+                + "remove the bean(s). Nothing was withdrawn");
       }
-      // beanType is guaranteed non-null here (the null case throws above).
-      return IoTDBTableAttributesDao.class.isAssignableFrom(beanType);
+      if (!ourDefinitionPresent) {
+        throw new IllegalStateException(
+            "database.attributes.type=iotdb-table, but the IoTDB Table Mode 
attributes DAO bean '"
+                + IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME
+                + "' did not register (check the session pool bean and the 
with-thingsboard "
+                + "build); NOT withdrawing competing AttributesDao bean(s) "
+                + competing(knownTargetName, unknown.keySet()));
+      }
+      if (ourType == null || 
!IoTDBTableAttributesDao.class.isAssignableFrom(ourType)) {
+        throw new IllegalStateException(
+            "database.attributes.type=iotdb-table, but bean '"
+                + IOTDB_TABLE_ATTRIBUTES_DAO_BEAN_NAME
+                + "' is "
+                + (ourType == null ? "of no resolvable type" : "a " + 
ourType.getName())
+                + " rather than an IoTDBTableAttributesDao; something else 
holds this module's "
+                + "bean name. Nothing was withdrawn");
+      }
+      if (!unknown.isEmpty()) {
+        throw new IllegalStateException(
+            "database.attributes.type=iotdb-table selects the IoTDB attributes 
backend, but "
+                + "bean(s) "
+                + unknown
+                + " also implement AttributesDao. This module withdraws only 
ThingsBoard's own '"
+                + JPA_ATTRIBUTE_DAO_BEAN_NAME
+                + "' ("
+                + JPA_ATTRIBUTE_DAO_CLASS_NAME
+                + "); it will not remove a bean your application registered. 
Remove the "
+                + "conflicting bean(s) or unset the selector. Nothing was 
withdrawn");
+      }
+      if (knownTargetName != null && 
!registry.containsBeanDefinition(knownTargetName)) {
+        throw new IllegalStateException(
+            "database.attributes.type=iotdb-table, but ThingsBoard's 
attributes bean '"
+                + knownTargetName
+                + "' has no bean definition in this registry (it was most 
likely supplied as a "
+                + "pre-built singleton) and cannot be withdrawn; unset the 
IoTDB attributes "
+                + "selector. Nothing was withdrawn");
+      }
+
+      // PHASE 2 -- the single mutation. The bean removed here has been 
established to be
+      // ThingsBoard's own component and to have a removable definition, so 
the WARN's wording is
+      // true by construction rather than by assumption.
+      if (knownTargetName != null) {
+        registry.removeBeanDefinition(knownTargetName);
+        log.warn(
+            "Removed ThingsBoard bean '{}' ({}) because {}={} selects the 
IoTDB attributes "
+                + "backend; ThingsBoard provides no configuration switch for 
attributes, so the "
+                + "conflicting bean is deregistered rather than left to 
conflict.",
+            knownTargetName,
+            knownTargetType.getName(),
+            IoTDBTableAttributesEnabledCondition.SELECTOR_PROPERTY,
+            IoTDBTableAttributesEnabledCondition.SELECTOR_VALUE);
+      }
+    }
+
+    /** Names every bean that competes for the AttributesDao role, for a 
refusal message. */
+    private static List<String> competing(String knownTargetName, 
Collection<String> unknown) {
+      List<String> all = new ArrayList<>();
+      if (knownTargetName != null) {
+        all.add(knownTargetName);
+      }
+      all.addAll(unknown);
+      return all;
     }
 
     private static Class<?> resolveBeanType(
diff --git 
a/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
 
b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
new file mode 100644
index 0000000..a2ceede
--- /dev/null
+++ 
b/iotdb-thingsboard-table/src/provided/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
@@ -0,0 +1,162 @@
+/*
+ * 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.
+ */
+
+// Compile-only ThingsBoard stub (Strategy F). Verified against ThingsBoard 
v4.3.1.2
+// (commit c37fb509).
+package org.thingsboard.server.dao.sql.attributes;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.commons.lang3.tuple.Pair;
+import org.thingsboard.server.common.data.AttributeScope;
+import org.thingsboard.server.common.data.id.DeviceProfileId;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.kv.AttributeKvEntry;
+import org.thingsboard.server.common.data.util.TbPair;
+import org.thingsboard.server.dao.attributes.AttributesDao;
+import org.thingsboard.server.dao.model.sql.AttributeKvEntity;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Compile-only ThingsBoard surface stub (Strategy F) for {@code
+ * org.thingsboard.server.dao.sql.attributes.JpaAttributeDao}, ThingsBoard's 
built-in JPA
+ * entity-attribute DAO. Excluded from the built jar, so it can never shadow 
the real class on a
+ * deployment classpath.
+ *
+ * <p><b>Only this class's fully-qualified NAME carries meaning here.</b> 
{@code
+ * AttributesDaoConflictGuard} identifies the one host bean it is authorised 
to withdraw by matching
+ * bean name {@code jpaAttributeDao} against resolved type {@code
+ * org.thingsboard.server.dao.sql.attributes.JpaAttributeDao}; without a class 
of that exact name on
+ * the test classpath that branch could not be exercised at all. The method 
bodies are never
+ * executed and the guard never instantiates the bean -- it runs as a {@code
+ * BeanFactoryPostProcessor}, before any bean is created.
+ *
+ * <p>Two divergences from the real class, deliberate and harmless for the 
above purpose: the real
+ * one is a {@code @Component} extending {@code 
JpaAbstractDaoListeningExecutorService}, and it
+ * implements these methods against JPA repositories. This stub declares 
neither the annotation nor
+ * the superclass, because the guard reads a bean definition's type and never 
the class's
+ * annotations or hierarchy.
+ *
+ * <p>The name itself is not proven by any test in this module: ThingsBoard's 
dao artifact is not on
+ * Maven Central, so the string was taken from ThingsBoard's own source at 
v4.3.1.2, {@code
+ * 
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java:58-61},
 read
+ * independently twice. If ThingsBoard ever renames or repackages that class, 
this module's
+ * attributes selector fails closed -- the bean becomes UNKNOWN and startup 
stops with a message
+ * naming it -- rather than silently withdrawing the wrong bean.
+ */
+public class JpaAttributeDao implements AttributesDao {
+
+  private static UnsupportedOperationException notExecutable() {
+    return new UnsupportedOperationException(
+        "compile-only ThingsBoard stub; the real implementation is supplied by 
the ThingsBoard "
+            + "runtime classpath");
+  }
+
+  @Override
+  public Optional<AttributeKvEntry> find(
+      TenantId tenantId, EntityId entityId, AttributeScope attributeScope, 
String attributeKey) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<AttributeKvEntry> find(
+      TenantId tenantId,
+      EntityId entityId,
+      AttributeScope attributeScope,
+      Collection<String> attributeKey) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<AttributeKvEntry> findAll(
+      TenantId tenantId, EntityId entityId, AttributeScope attributeScope) {
+    throw notExecutable();
+  }
+
+  @Override
+  public ListenableFuture<Long> save(
+      TenantId tenantId,
+      EntityId entityId,
+      AttributeScope attributeScope,
+      AttributeKvEntry attribute) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<ListenableFuture<String>> removeAll(
+      TenantId tenantId, EntityId entityId, AttributeScope attributeScope, 
List<String> keys) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<ListenableFuture<TbPair<String, Long>>> removeAllWithVersions(
+      TenantId tenantId, EntityId entityId, AttributeScope attributeScope, 
List<String> keys) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<AttributeKvEntity> findNextBatch(
+      UUID entityId, int attributeType, int attributeKey, int batchSize) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<String> findAllKeysByDeviceProfileId(
+      TenantId tenantId, DeviceProfileId deviceProfileId) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> 
entityIds) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<String> findAllKeysByEntityIdsAndScope(
+      TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
+    throw notExecutable();
+  }
+
+  @Override
+  public ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(
+      TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<AttributeKvEntry> findLatestByEntityIdsAndScope(
+      TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
+    throw notExecutable();
+  }
+
+  @Override
+  public ListenableFuture<List<AttributeKvEntry>> 
findLatestByEntityIdsAndScopeAsync(
+      TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
+    throw notExecutable();
+  }
+
+  @Override
+  public List<Pair<AttributeScope, String>> removeAllByEntityId(
+      TenantId tenantId, EntityId entityId) {
+    throw notExecutable();
+  }
+}
diff --git 
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/AttributesDaoConflictGuardTest.java
 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/AttributesDaoConflictGuardTest.java
new file mode 100644
index 0000000..e802774
--- /dev/null
+++ 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/AttributesDaoConflictGuardTest.java
@@ -0,0 +1,444 @@
+/*
+ * 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 ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.PriorityOrdered;
+import org.thingsboard.server.dao.attributes.AttributesDao;
+import org.thingsboard.server.dao.sql.attributes.JpaAttributeDao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Behavioural cover for {@code AttributesDaoConflictGuard}, the 
post-processor that makes the IoTDB
+ * attributes selector usable on a stock ThingsBoard.
+ *
+ * <p>ThingsBoard registers {@code JpaAttributeDao} as an unconditional {@code 
@Component} and
+ * exposes no attributes-backend switch of its own, so selecting IoTDB has to 
withdraw the competing
+ * definition. Withdrawing is only safe while a replacement of the same type 
is registered:
+ * otherwise the context ends up with zero {@code AttributesDao} beans, which 
is a startup outage
+ * rather than a degraded mode.
+ *
+ * <p>This class exists because the guard previously had none. A change to its 
behaviour passed 190
+ * green unit tests without a single failure, which is precisely the gap a 
guard of this kind must
+ * not have — it decides whether the host application starts at all.
+ *
+ * <p>The guard never instantiates a bean; it reads definitions and types only.
+ *
+ * <p><b>Two different stand-ins, and the difference is the point.</b> The 
guard identifies the one
+ * bean it may withdraw conjunctively: bean name {@code jpaAttributeDao} AND 
resolved type {@code
+ * org.thingsboard.server.dao.sql.attributes.JpaAttributeDao}. So the host's 
DAO is represented by
+ * the compile-only stub of that exact class ({@link JpaAttributeDao}, 
Strategy F, excluded from the
+ * built jar), and a Mockito-derived {@link AttributesDao} now stands for 
something else entirely --
+ * a third-party backend or a user's own bean, which the guard must refuse to 
touch. An earlier
+ * revision used the Mockito class for ThingsBoard's DAO; under a name-only 
rule that was
+ * indistinguishable, and the indistinguishability was the defect.
+ *
+ * <p>What these tests do NOT establish: that {@code
+ * org.thingsboard.server.dao.sql.attributes.JpaAttributeDao} is the right 
string. ThingsBoard's dao
+ * artifact is not on Maven Central, so that name comes from reading 
ThingsBoard's own source at
+ * v4.3.1.2, not from anything asserted here.
+ */
+class AttributesDaoConflictGuardTest {
+
+  private static final String IOTDB_DAO_BEAN = "ioTDBTableAttributesDao";
+  private static final String HOST_DAO_BEAN = "jpaAttributeDao";
+
+  private static final String PEER_DAO_BEAN = "someOtherIoTDBAttributesDao";
+  private static final String THIRD_PARTY_DAO_BEAN = "auditingAttributesDao";
+
+  /** ThingsBoard's own DAO: the compile-only stub carrying the real 
fully-qualified name. */
+  private static final Class<?> HOST_DAO_TYPE = JpaAttributeDao.class;
+
+  /**
+   * An AttributesDao that is neither ours nor ThingsBoard's -- a third-party 
backend or a bean the
+   * operator wrote. The guard has no standing to delete this and must fail 
loudly instead.
+   */
+  private static final Class<?> THIRD_PARTY_DAO_TYPE = 
mock(AttributesDao.class).getClass();
+
+  private static BeanFactoryPostProcessor guard() {
+    return 
IoTDBTableConfiguration.EnabledAttributesConfiguration.attributesDaoConflictGuard();
+  }
+
+  private static DefaultListableBeanFactory factory(boolean withOurs, boolean 
withHost) {
+    DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+    if (withOurs) {
+      beanFactory.registerBeanDefinition(
+          IOTDB_DAO_BEAN, new 
RootBeanDefinition(IoTDBTableAttributesDao.class));
+    }
+    if (withHost) {
+      beanFactory.registerBeanDefinition(HOST_DAO_BEAN, new 
RootBeanDefinition(HOST_DAO_TYPE));
+    }
+    return beanFactory;
+  }
+
+  /** T1 — the competing definition is withdrawn and ours is left strictly 
alone. */
+  @Test
+  void withdrawsTheHostDaoAndLeavesOursUntouched() {
+    DefaultListableBeanFactory beanFactory = factory(true, true);
+
+    guard().postProcessBeanFactory(beanFactory);
+
+    assertFalse(
+        beanFactory.containsBeanDefinition(HOST_DAO_BEAN), "competing 
definition withdrawn");
+    assertTrue(beanFactory.containsBeanDefinition(IOTDB_DAO_BEAN), "our 
definition untouched");
+    assertEquals(
+        1,
+        beanFactory.getBeanNamesForType(AttributesDao.class, true, 
false).length,
+        "exactly one AttributesDao candidate remains");
+  }
+
+  /**
+   * T3 — the zero-bean invariant, and the case that actually occurred in 
production this morning:
+   * our DAO bean was skipped by a condition, so withdrawing ThingsBoard's 
would have left the
+   * context with no AttributesDao at all. The guard must refuse BEFORE 
mutating anything.
+   */
+  @Test
+  void refusesToWithdrawWhenOurReplacementDidNotRegister() {
+    DefaultListableBeanFactory beanFactory = factory(false, true);
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(
+        thrown.getMessage().contains("did not register"),
+        "states that our DAO did not register: " + thrown.getMessage());
+    assertTrue(
+        thrown.getMessage().contains(HOST_DAO_BEAN),
+        "names the bean it declined to withdraw: " + thrown.getMessage());
+    assertTrue(
+        beanFactory.containsBeanDefinition(HOST_DAO_BEAN),
+        "nothing withdrawn: the throw precedes every mutation");
+  }
+
+  /** With no competing bean at all the guard is a no-op, not a failure. */
+  @Test
+  void isANoOpWhenOnlyOurDaoIsRegistered() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+
+    guard().postProcessBeanFactory(beanFactory);
+
+    assertTrue(beanFactory.containsBeanDefinition(IOTDB_DAO_BEAN), "our 
definition survives");
+    assertEquals(
+        1,
+        beanFactory.getBeanNamesForType(AttributesDao.class, true, 
false).length,
+        "exactly one AttributesDao candidate remains");
+  }
+
+  /**
+   * A definition whose class cannot be resolved is not treated as an {@code 
AttributesDao}
+   * candidate at all, so the guard neither withdraws it nor fails.
+   *
+   * <p>This documents why the guard's own unresolvable-type branch is 
defensive rather than
+   * reachable from here: {@code getBeanNamesForType} cannot match a 
definition whose class it
+   * cannot load, so such a bean never enters the partitioning loop. The test 
asserts the mechanism,
+   * not just the outcome — if a future Spring version starts listing 
unresolvable definitions, the
+   * candidate-count assertion fails and this comment stops being true.
+   */
+  @Test
+  void anUnresolvableDefinitionIsNotAnAttributesDaoCandidate() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+    RootBeanDefinition unresolvable = new RootBeanDefinition();
+    
unresolvable.setBeanClassName("org.thingsboard.server.dao.attributes.NoSuchAttributeDao");
+    beanFactory.registerBeanDefinition(HOST_DAO_BEAN, unresolvable);
+
+    assertEquals(
+        1,
+        beanFactory.getBeanNamesForType(AttributesDao.class, true, 
false).length,
+        "the unresolvable definition is not listed as an AttributesDao 
candidate");
+
+    guard().postProcessBeanFactory(beanFactory);
+
+    assertTrue(
+        beanFactory.containsBeanDefinition(HOST_DAO_BEAN),
+        "an unresolvable definition is left alone rather than withdrawn");
+    assertTrue(beanFactory.containsBeanDefinition(IOTDB_DAO_BEAN), "our 
definition untouched");
+  }
+
+  /**
+   * ThingsBoard's DAO supplied as a pre-built singleton rather than a bean 
definition: visible to
+   * the type scan, but there is no definition to remove. The guard must 
detect that in its
+   * pre-flight and refuse, leaving the singleton in place.
+   *
+   * <p>No subclass or mock is involved: this is a genuine {@code 
DefaultListableBeanFactory} with a
+   * real {@code registerSingleton} call, which is how such a bean actually 
arrives.
+   */
+  @Test
+  void throwsWhenTheHostDefinitionCannotBeWithdrawn() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+    beanFactory.registerSingleton(HOST_DAO_BEAN, new JpaAttributeDao());
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(
+        thrown.getMessage().contains("cannot be withdrawn"),
+        "message explains the definition could not be withdrawn: " + 
thrown.getMessage());
+    assertTrue(
+        thrown.getMessage().contains(HOST_DAO_BEAN),
+        "message names the bean: " + thrown.getMessage());
+    assertTrue(
+        beanFactory.containsSingleton(HOST_DAO_BEAN),
+        "the bean it could not withdraw is still there: the throw precedes 
every mutation");
+    assertTrue(beanFactory.containsBeanDefinition(IOTDB_DAO_BEAN), "our 
definition untouched");
+  }
+
+  /**
+   * ThingsBoard's own attributes DAO for the context tests.
+   *
+   * <p>The declared return type is the concrete class, not {@code 
AttributesDao}, and that is
+   * load-bearing. The guard runs before any bean is created, so it reads the 
type a definition
+   * DECLARES, never the runtime class of an instance. ThingsBoard registers 
this bean by component
+   * scan, whose definition carries the concrete class; an interface-typed 
{@code @Bean} factory
+   * method would resolve only to {@code AttributesDao} and the guard would 
classify it as
+   * unrecognised and refuse to start -- correctly, since at that point 
nothing distinguishes it
+   * from a third-party backend.
+   */
+  @Configuration(proxyBeanMethods = false)
+  static class HostAttributesDaoConfiguration {
+    @Bean(name = HOST_DAO_BEAN)
+    JpaAttributeDao jpaAttributeDao() {
+      return new JpaAttributeDao();
+    }
+  }
+
+  private static ApplicationContextRunner runner() {
+    return new ApplicationContextRunner()
+        
.withConfiguration(AutoConfigurations.of(IoTDBTableConfiguration.class))
+        .withUserConfiguration(HostAttributesDaoConfiguration.class);
+  }
+
+  /**
+   * With the selector unset the attributes path stays inert: the host's own 
DAO survives untouched
+   * and none of ours exist. This pins that the guard cannot fire without the 
property that
+   * justifies it.
+   */
+  @Test
+  void withoutTheSelectorTheHostDaoSurvivesAndNoneOfOursExist() {
+    runner()
+        .withPropertyValues(
+            "iotdb.host=localhost",
+            "iotdb.port=6667",
+            "iotdb.username=root",
+            "iotdb.password=root",
+            "iotdb.schema.bootstrap=false")
+        .run(
+            context -> {
+              assertTrue(context.containsBean(HOST_DAO_BEAN), "host DAO 
untouched");
+              assertFalse(
+                  context.containsBean(IOTDB_DAO_BEAN), "our attributes DAO is 
not registered");
+            });
+  }
+
+  /**
+   * With the selector set, the host's DAO is gone and exactly one 
AttributesDao remains, ours. This
+   * is also the regression test for the removed {@code 
@ConditionalOnMissingBean(type =
+   * AttributesDao)}, which previously skipped our bean on every stock 
ThingsBoard and left the
+   * context with no AttributesDao at all.
+   */
+  @Test
+  void withTheSelectorOurDaoReplacesTheHostDao() {
+    runner()
+        .withPropertyValues(
+            "database.attributes.type=iotdb-table",
+            "iotdb.attributes.cluster_mode=disabled",
+            "iotdb.host=localhost",
+            "iotdb.port=6667",
+            "iotdb.username=root",
+            "iotdb.password=root",
+            "iotdb.schema.bootstrap=false")
+        .run(
+            context -> {
+              assertFalse(context.containsBean(HOST_DAO_BEAN), "host DAO 
withdrawn");
+              assertEquals(
+                  1,
+                  context.getBeanNamesForType(AttributesDao.class).length,
+                  "exactly one AttributesDao remains");
+              assertTrue(context.containsBean(IOTDB_DAO_BEAN), "and it is 
ours");
+            });
+  }
+
+  /**
+   * The withdrawal must be visible in the log, because it is the only signal 
an operator gets that
+   * a bean from their own application was removed. The assertion covers all 
four facts a reader
+   * needs: which bean, its concrete class, and the property and value that 
caused it.
+   */
+  @Test
+  void logsAWarnNamingTheBeanItsClassAndTheCausingProperty() {
+    Logger configurationLogger = (Logger) 
LoggerFactory.getLogger(IoTDBTableConfiguration.class);
+    ListAppender<ILoggingEvent> appender = new ListAppender<>();
+    appender.start();
+    configurationLogger.addAppender(appender);
+    try {
+      guard().postProcessBeanFactory(factory(true, true));
+
+      ILoggingEvent event =
+          appender.list.stream()
+              .filter(e -> e.getFormattedMessage().contains("Removed 
ThingsBoard bean"))
+              .findFirst()
+              .orElseThrow(() -> new AssertionError("no withdrawal log line 
was emitted"));
+
+      assertEquals(Level.WARN, event.getLevel(), "the withdrawal is logged at 
WARN");
+      String message = event.getFormattedMessage();
+      assertTrue(message.contains(HOST_DAO_BEAN), "names the bean: " + 
message);
+      assertTrue(message.contains(HOST_DAO_TYPE.getName()), "names its class: 
" + message);
+      assertTrue(message.contains("database.attributes.type"), "names the 
property: " + message);
+      assertTrue(message.contains("iotdb-table"), "names the value: " + 
message);
+    } finally {
+      configurationLogger.detachAppender(appender);
+    }
+  }
+
+  /**
+   * The guard mutates bean definitions where its throw-only siblings do not, 
so it must run ahead
+   * of anything that might resolve AttributesDao.
+   */
+  @Test
+  void runsAtHighestPrecedence() {
+    BeanFactoryPostProcessor postProcessor = guard();
+
+    assertTrue(postProcessor instanceof PriorityOrdered, "guard is 
PriorityOrdered");
+    assertEquals(
+        Ordered.HIGHEST_PRECEDENCE,
+        ((PriorityOrdered) postProcessor).getOrder(),
+        "guard runs at highest precedence");
+  }
+
+  /**
+   * The first of the two defects this revision fixes. A second bean 
assignable to our own DAO type,
+   * registered under a different name, used to satisfy an "is one of ours 
present?" boolean: both
+   * survived and injection was ambiguous.
+   *
+   * <p>Such a bean is a peer implementation somebody registered on purpose, 
not ThingsBoard's. The
+   * guard has no standing to delete it, so it refuses and leaves the context 
exactly as it found
+   * it.
+   */
+  @Test
+  void anIoTDBPeerUnderAnotherNameIsRefusedAndBothSurvive() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+    beanFactory.registerBeanDefinition(
+        PEER_DAO_BEAN, new RootBeanDefinition(IoTDBTableAttributesDao.class));
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(
+        thrown.getMessage().contains(PEER_DAO_BEAN), "names the peer: " + 
thrown.getMessage());
+    assertTrue(beanFactory.containsBeanDefinition(PEER_DAO_BEAN), "the peer 
survives");
+    assertTrue(beanFactory.containsBeanDefinition(IOTDB_DAO_BEAN), "and so 
does ours");
+    assertEquals(
+        2,
+        beanFactory.getBeanNamesForType(AttributesDao.class, true, 
false).length,
+        "nothing was withdrawn");
+  }
+
+  /**
+   * The second defect. A third-party AttributesDao alongside ThingsBoard's 
own: the removability
+   * check used to sit inside the mutation loop, so one bean could be 
withdrawn before the refusal.
+   *
+   * <p>Every reason to stop is now evaluated first, so the host's DAO is 
still present after the
+   * throw even though it was, on its own, perfectly removable.
+   */
+  @Test
+  void aThirdPartyDaoStopsTheWithdrawalOfTheHostDaoToo() {
+    DefaultListableBeanFactory beanFactory = factory(true, true);
+    beanFactory.registerBeanDefinition(
+        THIRD_PARTY_DAO_BEAN, new RootBeanDefinition(THIRD_PARTY_DAO_TYPE));
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(
+        thrown.getMessage().contains(THIRD_PARTY_DAO_BEAN),
+        "names the bean it will not remove: " + thrown.getMessage());
+    assertTrue(
+        beanFactory.containsBeanDefinition(THIRD_PARTY_DAO_BEAN), "the 
third-party bean survives");
+    assertTrue(
+        beanFactory.containsBeanDefinition(HOST_DAO_BEAN),
+        "and so does the host's, though it was removable on its own");
+  }
+
+  /** Right name, wrong type: the authorisation is for one specific class, not 
for a bean name. */
+  @Test
+  void aBeanUsingTheHostNameWithAnotherTypeIsRefused() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+    beanFactory.registerBeanDefinition(HOST_DAO_BEAN, new 
RootBeanDefinition(THIRD_PARTY_DAO_TYPE));
+
+    assertThrows(IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(beanFactory.containsBeanDefinition(HOST_DAO_BEAN), "left 
untouched");
+  }
+
+  /**
+   * Right type, wrong name: an operator who registered ThingsBoard's class 
themselves, under their
+   * own name, made a deliberate choice. Withdrawing it is not what the 
documented opt-in promises.
+   */
+  @Test
+  void theHostTypeUnderAnotherBeanNameIsRefused() {
+    DefaultListableBeanFactory beanFactory = factory(true, false);
+    beanFactory.registerBeanDefinition(
+        "customJpaAttributeDao", new RootBeanDefinition(HOST_DAO_TYPE));
+
+    assertThrows(IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(beanFactory.containsBeanDefinition("customJpaAttributeDao"), 
"left untouched");
+  }
+
+  /**
+   * Something else holding our bean name. Found by direct lookup rather than 
by filtering the type
+   * scan, so it is caught even when the imposter does not implement 
AttributesDao at all.
+   */
+  @Test
+  void aBeanHoldingOurNameWithTheWrongTypeIsRefused() {
+    DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+    beanFactory.registerBeanDefinition(
+        IOTDB_DAO_BEAN, new RootBeanDefinition(THIRD_PARTY_DAO_TYPE));
+    beanFactory.registerBeanDefinition(HOST_DAO_BEAN, new 
RootBeanDefinition(HOST_DAO_TYPE));
+
+    IllegalStateException thrown =
+        assertThrows(
+            IllegalStateException.class, () -> 
guard().postProcessBeanFactory(beanFactory));
+
+    assertTrue(
+        thrown.getMessage().contains("rather than an IoTDBTableAttributesDao"),
+        "explains what holds the name: " + thrown.getMessage());
+    assertTrue(beanFactory.containsBeanDefinition(HOST_DAO_BEAN), "nothing was 
withdrawn");
+  }
+}
diff --git 
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
index 302f4fa..aad140b 100644
--- 
a/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
+++ 
b/iotdb-thingsboard-table/src/test/java/org/apache/iotdb/extras/thingsboard/table/IoTDBTableTimeseriesAggregationIT.java
@@ -792,6 +792,62 @@ class IoTDBTableTimeseriesAggregationIT {
     }
   }
 
+  @Test
+  void calendarMonthBucketsFollowTheQueryTimezoneRatherThanUtc() throws 
Exception {
+    TestScope scope =
+        scope(
+            "agg_month_tz",
+            "55555555-5555-5555-5555-555555555509",
+            "66666666-6666-6666-6666-666666666609");
+    bootstrapSchema(scope.database());
+    try (ITableSessionPool pool = newPool(scope.database())) {
+      IoTDBTableConfig config = config(8);
+      IoTDBTableTimeseriesWriter writer = new IoTDBTableTimeseriesWriter(pool, 
config);
+      IoTDBTableTimeseriesDao dao = new IoTDBTableTimeseriesDao(pool, writer, 
config);
+      try {
+        // Asia/Shanghai is UTC+8 with no DST, so every calendar MONTH 
boundary sits exactly eight
+        // hours EARLIER in epoch terms than the corresponding UTC boundary 
asserted above:
+        //   Jan [1672502400000,1675180800000) 31d -> midpoint 1673841600000
+        //   Feb [1675180800000,1677600000000) 28d -> midpoint 1676390400000
+        //   Mar [1677600000000,1680278400000) 31d -> midpoint 1678939200000
+        // Each midpoint is 28800000 ms below the UTC midpoint used by the 
test above.
+        //
+        // The middle sample is the discriminator. 1675195200000 is 
2023-01-31T20:00Z, which UTC
+        // bucketing places in JANUARY but Shanghai bucketing places in 
FEBRUARY (local time
+        // 2023-02-01T04:00+08:00). If the DAO dropped the query timezone and 
fell back to UTC this
+        // would collapse to TWO buckets carrying 60 and 7, not three carrying 
10, 50 and 7 -- so
+        // the test fails on the value distribution, not merely on the bucket 
labels.
+        saveAll(
+            dao,
+            scope,
+            List.of(
+                entry(1673308800000L, "n", DataType.LONG, 10L), // 2023-01-10, 
Jan in both zones
+                entry(1675195200000L, "n", DataType.LONG, 50L), // Jan in UTC, 
Feb in Shanghai
+                entry(1677974400000L, "n", DataType.LONG, 7L))); // 
2023-03-05, Mar in both zones
+
+        long startTs = 1672502400000L; // 2023-01-01T00:00+08:00
+        long endTs = 1680278400000L; // 2023-04-01T00:00+08:00
+        long[] shanghaiMidpoints = {1673841600000L, 1676390400000L, 
1678939200000L};
+
+        ReadTsKvQueryResult sum =
+            calendarAggregate(dao, scope, "n", startTs, endTs, 
Aggregation.SUM, "Asia/Shanghai");
+        assertNumericBuckets(
+            sum,
+            shanghaiMidpoints,
+            new DataType[] {DataType.LONG, DataType.LONG, DataType.LONG},
+            new double[] {10.0D, 50.0D, 7.0D},
+            1677974400000L);
+
+        ReadTsKvQueryResult count =
+            calendarAggregate(dao, scope, "n", startTs, endTs, 
Aggregation.COUNT, "Asia/Shanghai");
+        assertLongBuckets(count, shanghaiMidpoints, new long[] {1L, 1L, 1L});
+      } finally {
+        dao.destroy();
+        writer.destroy();
+      }
+    }
+  }
+
   private ReadTsKvQueryResult calendarAggregate(
       IoTDBTableTimeseriesDao dao,
       TestScope scope,
@@ -800,12 +856,24 @@ class IoTDBTableTimeseriesAggregationIT {
       long endTs,
       Aggregation aggregation)
       throws Exception {
+    return calendarAggregate(dao, scope, key, startTs, endTs, aggregation, 
"UTC");
+  }
+
+  private ReadTsKvQueryResult calendarAggregate(
+      IoTDBTableTimeseriesDao dao,
+      TestScope scope,
+      String key,
+      long startTs,
+      long endTs,
+      Aggregation aggregation,
+      String tzId)
+      throws Exception {
     ReadTsKvQuery query =
         new BaseReadTsKvQuery(
             key,
             startTs,
             endTs,
-            AggregationParams.calendar(aggregation, IntervalType.MONTH, "UTC"),
+            AggregationParams.calendar(aggregation, IntervalType.MONTH, tzId),
             100,
             "ASC");
     return dao.findAllAsync(scope.tenantId(), scope.entityId(), List.of(query))

Reply via email to