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 inert by default (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. + *
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 inert by default: 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. * *
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 @@ * *
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.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.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 @@ IoTDBTableSchemaBootstrap latestSchemaBootstrap( * 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. * *
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 @@ ITableSessionPool tableSessionPool(IoTDBTableConfig config) { } /** - * 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 withdraws ThingsBoard's own {@code + * jpaAttributeDao} bean definition and logs a WARN naming it. + * + *
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 other 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. + * + *
Scope of the guarantee. 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 @@ static BeanFactoryPostProcessor attributesDaoConflictGuard() {
* 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 @@ private static Class> resolveTimeseriesLatestDaoClass(
}
}
- 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 Only this class's fully-qualified NAME carries meaning here. {@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.
+ *
+ * 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.
+ *
+ * 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 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.
+ *
+ * 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.
+ *
+ * The guard never instantiates a bean; it reads definitions and types only.
+ *
+ * Two different stand-ins, and the difference is the point. 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 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.
+ *
+ * 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 @@ void maxOverNonPositiveLongOnlyAndMixedBucketsKeepsResultTypeAgainstRealIoTDB()
}
}
+ @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 @@ private ReadTsKvQueryResult calendarAggregate(
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))
> findAllKeysByEntityIdsAndScopeAsync(
+ TenantId tenantId, List
> findLatestByEntityIdsAndScopeAsync(
+ TenantId tenantId, List