From ac453a0b5fd9d8e77ac7f1f7827eecff7006fcec Mon Sep 17 00:00:00 2001 From: Gary Tully Date: Thu, 25 Jun 2026 14:51:29 +0100 Subject: [PATCH] ARTEMIS-6143 resource-quota, a way to group and limit broker resource usage across addresses Assisted-by: Claude --- CLAUDE.md | 105 + .../api/core/ActiveMQExceptionType.java | 6 + ...ctiveMQResourceQuotaExceededException.java | 34 + .../core/management/ObjectNameBuilder.java | 9 + .../core/management/ResourceQuotaControl.java | 105 + .../amqp/proton/ProtonAbstractReceiver.java | 4 +- .../protocol/mqtt/MQTTPublishManager.java | 12 + .../mqtt/MQTTSubscriptionManager.java | 9 + .../artemis/core/config/Configuration.java | 20 + .../core/config/impl/ConfigurationImpl.java | 19 + .../impl/FileConfigurationParser.java | 49 + .../impl/ActiveMQServerControlImpl.java | 4 +- .../impl/ResourceQuotaControlImpl.java | 180 ++ .../artemis/core/paging/PagingManager.java | 11 + .../artemis/core/paging/PagingStore.java | 15 + .../impl/PageCounterRebuildManager.java | 9 + .../cursor/impl/PageSubscriptionImpl.java | 15 + .../core/paging/impl/PagingManagerImpl.java | 65 +- .../core/paging/impl/PagingStoreImpl.java | 87 + .../core/postoffice/impl/PostOfficeImpl.java | 22 +- .../core/server/ActiveMQMessageBundle.java | 4 + .../artemis/core/server/ActiveMQServer.java | 3 + .../artemis/core/server/impl/Activation.java | 2 +- .../core/server/impl/ActiveMQServerImpl.java | 41 +- .../impl/BackupRecoveryJournalLoader.java | 2 +- .../server/impl/PostOfficeJournalLoader.java | 14 +- .../artemis/core/server/impl/QueueImpl.java | 29 + .../core/server/impl/ServerSessionImpl.java | 15 + .../server/management/ManagementService.java | 4 + .../impl/ManagementServiceImpl.java | 12 + .../plugin/ActiveMQServerAddressPlugin.java | 4 +- .../server/quota/ResourceQuotaManager.java | 370 +++ .../server/quota/ResourceQuotaService.java | 122 + .../quota/impl/ResourceQuotaServiceImpl.java | 651 +++++ .../core/settings/impl/AddressSettings.java | 14 + .../core/settings/impl/ResourceQuota.java | 560 ++++ .../settings/impl/ResourceQuotaConfig.java | 195 ++ .../core/security/jaas/oidc/OIDCSupport.java | 1 - .../schema/artemis-configuration.xsd | 87 + .../config/impl/ConfigurationImplTest.java | 70 + .../paging/cursor/impl/ConcurrentAckTest.java | 2 + .../postoffice/impl/PostOfficeImplTest.java | 7 +- .../group/impl/ClusteredResetMockTest.java | 10 + .../ArtemisRbacMBeanServerBuilderTest.java | 72 + docs/user-manual/management.adoc | 13 + docs/user-manual/resource-quotas.md | 338 +++ .../DeleteMessagesOnStartupTest.java | 2 +- .../quota/ByteQuotaWithPagingTest.java | 537 ++++ .../quota/LargeMessageQuotaLeakTest.java | 150 + .../quota/MqttQuotaReasonCodeTest.java | 200 ++ .../quota/MultiProtocolByteQuotaTest.java | 313 ++ .../quota/NonPersistentByteQuotaTest.java | 180 ++ .../quota/QuotaOrphaningPreventionTest.java | 338 +++ .../quota/QuotaPolicyEnforcementTest.java | 290 ++ .../quota/QuotaRestartPersistenceTest.java | 988 +++++++ .../quota/QuotaRollbackVerificationTest.java | 415 +++ .../quota/QuotaTokenRollbackTest.java | 351 +++ .../quota/ResourceQuotaEdgeCasesTest.java | 456 +++ .../quota/ResourceQuotaIntegrationTest.java | 330 +++ .../quota/ResourceQuotaJMXTest.java | 160 + .../quota/ResourceQuotaReloadTest.java | 2586 +++++++++++++++++ .../quota/ResourceQuotaServiceTest.java | 422 +++ .../quota/WildcardMulticastByteQuotaTest.java | 373 +++ .../quota/WildcardQuotaTemplateTest.java | 325 +++ .../storage/PersistMultiThreadTest.java | 25 + .../core/paging/ResourceQuotaManagerTest.java | 373 +++ .../tests/unit/core/paging/impl/PageTest.java | 2 +- .../impl/DuplicateDetectionUnitTest.java | 6 +- .../core/settings/impl/ResourceQuotaTest.java | 304 ++ 69 files changed, 12525 insertions(+), 23 deletions(-) create mode 100644 CLAUDE.md create mode 100644 artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQResourceQuotaExceededException.java create mode 100644 artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ResourceQuotaControl.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ResourceQuotaControlImpl.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaManager.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaService.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/impl/ResourceQuotaServiceImpl.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuota.java create mode 100644 artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuotaConfig.java create mode 100644 docs/user-manual/resource-quotas.md create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ByteQuotaWithPagingTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/LargeMessageQuotaLeakTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MqttQuotaReasonCodeTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MultiProtocolByteQuotaTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/NonPersistentByteQuotaTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaOrphaningPreventionTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaPolicyEnforcementTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRestartPersistenceTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRollbackVerificationTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaTokenRollbackTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaEdgeCasesTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaIntegrationTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaJMXTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaReloadTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaServiceTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardMulticastByteQuotaTest.java create mode 100644 tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardQuotaTemplateTest.java create mode 100644 tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/ResourceQuotaManagerTest.java create mode 100644 tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/settings/impl/ResourceQuotaTest.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..2f05732faf8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# Running Tests in Apache Artemis + +This document explains how to run tests in the Apache Artemis repository, which uses Maven skip properties to control test execution. + +## Skip Test Properties + +The repository uses different skip properties for different test modules: + +- **`skipUnitTests`** - Controls unit tests (default: `true`) +- **`skipIntegrationTests`** - Controls integration tests (default: `true`) +- **`e2e-tests.skipTests`** - Controls end-to-end tests (default: `true`) + +By default, tests are **skipped** to speed up the build. You must explicitly set these properties to `false` to run tests. + +## Running Unit Tests + +Unit tests are located in `tests/unit-tests/`. + +### Run a specific unit test: +```bash +mvn test -pl tests/unit-tests -Dtest=ResourceQuotaManagerTest -DskipUnitTests=false +``` + +### Run all unit tests in the module: +```bash +mvn test -pl tests/unit-tests -DskipUnitTests=false +``` + +### Run all unit tests from root: +```bash +mvn test -DskipUnitTests=false +``` + +## Running Integration Tests + +Integration tests are located in `tests/integration-tests/`. + +### Run a specific integration test: +```bash +mvn test -pl tests/integration-tests -Dtest=ResourceQuotaReloadTest -DskipIntegrationTests=false +``` + +### Run all integration tests in the module: +```bash +mvn test -pl tests/integration-tests -DskipIntegrationTests=false +``` + +## Running Tests from artemis-server + +Some tests are located directly in the `artemis-server` module. These use the standard `-DskipTests` property: + +```bash +mvn test -pl artemis-server -Dtest=SomeTest -DskipTests=false +``` + +## Common Patterns + +### Run tests with wildcard pattern: +```bash +mvn test -pl tests/unit-tests -Dtest=ResourceQuota* -DskipUnitTests=false +``` + +### Run multiple specific tests: +```bash +mvn test -pl tests/unit-tests -Dtest=TestA,TestB,TestC -DskipUnitTests=false +``` + +### Build a module and run its tests: +```bash +mvn clean install -pl tests/unit-tests -DskipUnitTests=false +``` + +### Run tests with debug output: +```bash +mvn test -pl tests/unit-tests -Dtest=ResourceQuotaManagerTest -DskipUnitTests=false -X +``` + +## Maven Build Profiles + +The repository defines profiles in the root `pom.xml` that control test execution: + +- **Default profile**: All tests skipped (`skipUnitTests=true`, `skipIntegrationTests=true`) +- **`dev` profile**: Unit tests enabled (`skipUnitTests=false`) +- **`release` profile**: Unit and integration tests enabled + +### Activate a profile: +```bash +mvn test -Pdev +``` + +## Quick Reference + +| Test Location | Module Path | Skip Property | +|---------------|-------------|---------------| +| artemis-server tests | `artemis-server` | `-DskipTests=false` | +| Unit tests | `tests/unit-tests` | `-DskipUnitTests=false` | +| Integration tests | `tests/integration-tests` | `-DskipIntegrationTests=false` | +| E2E tests | `tests/e2e-tests` | `-De2e-tests.skipTests=false` | + + +**IMPORTANT:** After modifying any source code in `artemis-server/`, you MUST rebuild before running tests: +```bash +mvn clean install -pl artemis-server -DskipTests +``` +Otherwise tests will run against the old compiled code and won't reflect your changes. diff --git a/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQExceptionType.java b/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQExceptionType.java index ab37420c60b..04bc444457b 100644 --- a/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQExceptionType.java +++ b/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQExceptionType.java @@ -279,6 +279,12 @@ public ActiveMQException createException(String msg) { public ActiveMQException createException(String msg) { return new ActiveMQTimeoutException(msg); } + }, + RESOURCE_QUOTA_EXCEEDED(224) { + @Override + public ActiveMQException createException(String msg) { + return new ActiveMQResourceQuotaExceededException(msg); + } }; private static final Map TYPE_MAP; diff --git a/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQResourceQuotaExceededException.java b/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQResourceQuotaExceededException.java new file mode 100644 index 00000000000..c0252ed9c32 --- /dev/null +++ b/artemis-commons/src/main/java/org/apache/activemq/artemis/api/core/ActiveMQResourceQuotaExceededException.java @@ -0,0 +1,34 @@ +/* + * 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.activemq.artemis.api.core; + +/** + * A resource quota has been exceeded. + * Resource quotas enforce hierarchical limits on message bytes, address count, and queue count. + */ +public final class ActiveMQResourceQuotaExceededException extends ActiveMQException { + + private static final long serialVersionUID = 1L; + + public ActiveMQResourceQuotaExceededException(String message) { + super(ActiveMQExceptionType.RESOURCE_QUOTA_EXCEEDED, message); + } + + public ActiveMQResourceQuotaExceededException() { + super(ActiveMQExceptionType.RESOURCE_QUOTA_EXCEEDED); + } +} diff --git a/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ObjectNameBuilder.java b/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ObjectNameBuilder.java index 5d6dec53ee9..2adf0010ecc 100644 --- a/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ObjectNameBuilder.java +++ b/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ObjectNameBuilder.java @@ -210,6 +210,15 @@ private String getActiveMQServerName() { return String.format("%s:broker=%s", domain, (jmxUseBrokerName && brokerName != null) ? ObjectName.quote(brokerName) : "artemis"); } + /** + * {@return the ObjectName used by ResourceQuotaControl} + * + * @see ResourceQuotaControl + */ + public ObjectName getResourceQuotaObjectName(final String quotaName) throws Exception { + return createObjectName("quota", quotaName); + } + @Deprecated() public ObjectName getManagementContextObjectName() throws Exception { return getSecurityObjectName(); diff --git a/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ResourceQuotaControl.java b/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ResourceQuotaControl.java new file mode 100644 index 00000000000..0d697ce2966 --- /dev/null +++ b/artemis-core-client/src/main/java/org/apache/activemq/artemis/api/core/management/ResourceQuotaControl.java @@ -0,0 +1,105 @@ +/* + * 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.activemq.artemis.api.core.management; + +/** + * A ResourceQuotaControl is used to manage and monitor a resource quota. + *

+ * Resource quotas track and enforce limits on addresses, queues, and message bytes + * across groups of addresses. They support hierarchical organization where child + * quotas count toward parent limits. + */ +public interface ResourceQuotaControl { + + /** + * {@return the name of this resource quota} + */ + @Attribute(desc = "the name of this resource quota") + String getName(); + + /** + * {@return the name of the parent quota this quota is part of, or null if this is a root quota} + */ + @Attribute(desc = "the name of the parent quota this quota is part of") + String getPartOf(); + + /** + * {@return the maximum number of addresses allowed by this quota, or -1 if unlimited} + */ + @Attribute(desc = "the maximum number of addresses allowed by this quota") + int getMaxAddresses(); + + /** + * {@return the current number of addresses tracked by this quota} + */ + @Attribute(desc = "the current number of addresses tracked by this quota") + int getCurrentAddressCount(); + + /** + * {@return the percentage of the address limit currently in use (0-100), or -1 if no limit configured} + */ + @Attribute(desc = "the percentage of the address limit currently in use") + double getAddressUtilizationPercent(); + + /** + * {@return the maximum number of queues allowed by this quota, or -1 if unlimited} + */ + @Attribute(desc = "the maximum number of queues allowed by this quota") + int getMaxQueues(); + + /** + * {@return the current number of queues tracked by this quota} + */ + @Attribute(desc = "the current number of queues tracked by this quota") + int getCurrentQueueCount(); + + /** + * {@return the percentage of the queue limit currently in use (0-100), or -1 if no limit configured} + */ + @Attribute(desc = "the percentage of the queue limit currently in use") + double getQueueUtilizationPercent(); + + /** + * {@return the maximum number of bytes allowed for messages in this quota, or -1 if unlimited} + */ + @Attribute(desc = "the maximum number of bytes allowed for messages in this quota") + long getMaxMessageBytes(); + + /** + * {@return the current number of bytes used by messages in this quota} + */ + @Attribute(desc = "the current number of bytes used by messages in this quota") + long getCurrentMessageBytes(); + + /** + * {@return the percentage of the message bytes limit currently in use (0-100), or -1 if no limit configured} + */ + @Attribute(desc = "the percentage of the message bytes limit currently in use") + double getMessageBytesUtilizationPercent(); + + /** + * {@return true if this quota has at least one limit configured (addresses, queues, or bytes)} + */ + @Attribute(desc = "whether this quota has any limits configured") + boolean hasLimits(); + + /** + * {@return true if this quota has reached any of its limits} + */ + @Attribute(desc = "whether this quota has reached any of its limits") + boolean isLimitReached(); +} diff --git a/artemis-protocols/artemis-amqp-protocol/src/main/java/org/apache/activemq/artemis/protocol/amqp/proton/ProtonAbstractReceiver.java b/artemis-protocols/artemis-amqp-protocol/src/main/java/org/apache/activemq/artemis/protocol/amqp/proton/ProtonAbstractReceiver.java index b5a5a022032..0d209e55a96 100644 --- a/artemis-protocols/artemis-amqp-protocol/src/main/java/org/apache/activemq/artemis/protocol/amqp/proton/ProtonAbstractReceiver.java +++ b/artemis-protocols/artemis-amqp-protocol/src/main/java/org/apache/activemq/artemis/protocol/amqp/proton/ProtonAbstractReceiver.java @@ -683,7 +683,9 @@ protected void failIfCoreTunnelNotEnabled() { } protected static boolean isAddressFull(final Exception e) { - return e instanceof ActiveMQException amqe && ActiveMQExceptionType.ADDRESS_FULL.equals(amqe.getType()); + return e instanceof ActiveMQException amqe && + (ActiveMQExceptionType.ADDRESS_FULL.equals(amqe.getType()) || + ActiveMQExceptionType.RESOURCE_QUOTA_EXCEEDED.equals(amqe.getType())); } protected static boolean outcomeSupported(final Source source, final Symbol outcome) { diff --git a/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTPublishManager.java b/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTPublishManager.java index 5f8d8453f82..b4326ff4fe1 100644 --- a/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTPublishManager.java +++ b/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTPublishManager.java @@ -34,6 +34,7 @@ import io.netty.handler.codec.mqtt.MqttTopicSubscription; import org.apache.activemq.artemis.api.core.ActiveMQBuffer; import org.apache.activemq.artemis.api.core.ActiveMQIllegalStateException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; import org.apache.activemq.artemis.api.core.ActiveMQSecurityException; import org.apache.activemq.artemis.api.core.ICoreMessage; import org.apache.activemq.artemis.api.core.Message; @@ -266,6 +267,17 @@ void sendToQueue(MqttPublishMessage message, boolean internal) throws Exception */ logger.debug("MQTT 3.1 client not authorized to publish message."); } + } catch (ActiveMQResourceQuotaExceededException e) { + tx.rollback(); + if (internal) { + throw e; + } + if (session.getVersion() == MQTTVersion.MQTT_5) { + sendMessageAck(internal, qos, packetId, MQTTReasonCodes.QUOTA_EXCEEDED); + return; + } else { + throw new DisconnectException(); + } } catch (Throwable t) { MQTTLogger.LOGGER.failedToPublishMqttMessage(t.getMessage(), t); tx.rollback(); diff --git a/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTSubscriptionManager.java b/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTSubscriptionManager.java index 6c65292a5cb..ff21634e187 100644 --- a/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTSubscriptionManager.java +++ b/artemis-protocols/artemis-mqtt-protocol/src/main/java/org/apache/activemq/artemis/core/protocol/mqtt/MQTTSubscriptionManager.java @@ -27,6 +27,7 @@ import io.netty.handler.codec.mqtt.MqttSubscriptionOption; import io.netty.handler.codec.mqtt.MqttTopicSubscription; import org.apache.activemq.artemis.api.core.ActiveMQQueueExistsException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; import org.apache.activemq.artemis.api.core.ActiveMQSecurityException; import org.apache.activemq.artemis.api.core.FilterConstants; import org.apache.activemq.artemis.api.core.QueueConfiguration; @@ -334,6 +335,14 @@ int[] addSubscriptions(List subscriptions, Integer subscr */ qos[i] = subscriptions.get(i).qualityOfService().value(); } + } catch (ActiveMQResourceQuotaExceededException e) { + if (session.getVersion() == MQTTVersion.MQTT_5) { + qos[i] = MQTTReasonCodes.QUOTA_EXCEEDED; + } else if (session.getVersion() == MQTTVersion.MQTT_3_1_1) { + qos[i] = MQTTReasonCodes.UNSPECIFIED_ERROR; + } else { + qos[i] = subscriptions.get(i).qualityOfService().value(); + } } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/Configuration.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/Configuration.java index b8f6cb1e1db..65c588ce58c 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/Configuration.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/Configuration.java @@ -173,6 +173,26 @@ public interface Configuration { */ Configuration addResourceLimitSettings(ResourceLimitSettings resourceLimitSettings); + /** + * {@return resource quota configurations (limits only, no runtime counters)} + */ + Map getResourceQuotas(); + + /** + * Set the collection of resource quota configurations indexed by name. + * + * @param quotaConfigs quota names mapped to ResourceQuotaConfig + */ + Configuration setResourceQuotas(Map quotaConfigs); + + /** + * Add a resource quota configuration to the underlying collection. + * + * @param name the unique name of the quota + * @param config the ResourceQuotaConfig defining limits + */ + Configuration addResourceQuota(String name, org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig config); + /** * {@return the period (in milliseconds) to scan configuration files used by deployment; default is {@link * ActiveMQDefaultConfiguration#DEFAULT_FILE_DEPLOYER_SCAN_PERIOD}} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImpl.java index da849420b55..447b360c9f4 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImpl.java @@ -370,6 +370,8 @@ public class ConfigurationImpl extends javax.security.auth.login.Configuration i private Map resourceLimitSettings = new HashMap<>(); + private Map resourceQuotas = new HashMap<>(); + private Map> securitySettings = new HashMap<>(); private List securitySettingPlugins = new ArrayList<>(); @@ -2531,6 +2533,23 @@ public ConfigurationImpl addResourceLimitSetting(ResourceLimitSettings resourceL return this.addResourceLimitSettings(resourceLimitSettings); } + @Override + public Map getResourceQuotas() { + return resourceQuotas; + } + + @Override + public ConfigurationImpl setResourceQuotas(final Map quotaConfigs) { + this.resourceQuotas = quotaConfigs; + return this; + } + + @Override + public ConfigurationImpl addResourceQuota(String name, org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig config) { + this.resourceQuotas.put(name, config); + return this; + } + @Override public Map> getSecurityRoles() { for (SecuritySettingPlugin securitySettingPlugin : securitySettingPlugins) { diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/deployers/impl/FileConfigurationParser.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/deployers/impl/FileConfigurationParser.java index 4d5824b15c5..6e982e748b8 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/deployers/impl/FileConfigurationParser.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/deployers/impl/FileConfigurationParser.java @@ -922,6 +922,8 @@ public void parseMainConfig(final Element e, final Configuration config) throws parseAddressSettings(e, config); + parseResourceQuotas(e, config); + parseResourceLimits(e, config); parseQueues(e, config); @@ -1206,6 +1208,51 @@ private void parseResourceLimits(final Element e, final Configuration config) { } } + private void parseResourceQuotas(final Element e, final Configuration config) { + NodeList elements = e.getElementsByTagName("resource-quotas"); + + if (elements.getLength() != 0) { + Element node = (Element) elements.item(0); + NodeList list = node.getElementsByTagName("resource-quota"); + for (int i = 0; i < list.getLength(); i++) { + org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig quotaConfig = parseResourceQuota(list.item(i)); + if (config.getResourceQuotas().containsKey(quotaConfig.getName())) { + logger.warn("Duplicate resource quota name: {}", quotaConfig.getName()); + } else { + config.addResourceQuota(quotaConfig.getName(), quotaConfig); + } + } + } + } + + private org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig parseResourceQuota(final Node node) { + String name = getAttributeValue(node, "name"); + org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig quotaConfig = + new org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig(name); + + String maxMessageBytes = getString((Element) node, "max-message-bytes", null, NO_CHECK); + if (maxMessageBytes != null && !maxMessageBytes.isEmpty()) { + quotaConfig.setMaxMessageBytes(org.apache.activemq.artemis.utils.ByteUtil.convertTextBytes(maxMessageBytes)); + } + + Integer maxAddresses = getInteger((Element) node, "max-addresses", null, MINUS_ONE_OR_GT_ZERO); + if (maxAddresses != null) { + quotaConfig.setMaxAddresses(maxAddresses); + } + + Integer maxQueues = getInteger((Element) node, "max-queues", null, MINUS_ONE_OR_GT_ZERO); + if (maxQueues != null) { + quotaConfig.setMaxQueues(maxQueues); + } + + String partOf = getString((Element) node, "part-of", null, NO_CHECK); + if (partOf != null && !partOf.isEmpty()) { + quotaConfig.setPartOf(partOf); + } + + return quotaConfig; + } + protected Pair> parseSecurityRoles(final Node node, final Map> roleMappings) { final String match = node.getAttributes().getNamedItem("match").getNodeValue(); @@ -1534,6 +1581,8 @@ protected Pair parseAddressSettings(final Node node) { addressSettings.setIDCacheSize(GE_ZERO.validate(ID_CACHE_SIZE, XMLUtil.parseInt(child)).intValue()); } else if (INITIAL_QUEUE_BUFFER_SIZE.equalsIgnoreCase(name)) { addressSettings.setInitialQueueBufferSize(POSITIVE_POWER_OF_TWO.validate(INITIAL_QUEUE_BUFFER_SIZE, XMLUtil.parseInt(child)).intValue()); + } else if ("resource-quota".equalsIgnoreCase(name)) { + addressSettings.setResourceQuota(getTrimmedTextContent(child)); } } return setting; diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ActiveMQServerControlImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ActiveMQServerControlImpl.java index 94f9873d4f9..892cd4ef3d0 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ActiveMQServerControlImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ActiveMQServerControlImpl.java @@ -4672,7 +4672,9 @@ public void rebuildPageCounters() throws Exception { // managementLock will guarantee there's only one management operation being called try (AutoCloseable lock = server.managementLock()) { Future task = server.getPagingManager().rebuildCounters(null); - task.get(); + if (task != null) { + task.get(); + } } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ResourceQuotaControlImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ResourceQuotaControlImpl.java new file mode 100644 index 00000000000..0947f5c10ae --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/management/impl/ResourceQuotaControlImpl.java @@ -0,0 +1,180 @@ +/* + * 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.activemq.artemis.core.management.impl; + +import javax.management.MBeanAttributeInfo; +import javax.management.MBeanOperationInfo; + +import org.apache.activemq.artemis.api.core.management.ResourceQuotaControl; +import org.apache.activemq.artemis.core.persistence.StorageManager; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; + +/** + * Implementation of ResourceQuotaControl for JMX management. + */ +public class ResourceQuotaControlImpl extends AbstractControl implements ResourceQuotaControl { + + private final ResourceQuota resourceQuota; + + public ResourceQuotaControlImpl(final ResourceQuota resourceQuota, + final StorageManager storageManager) throws Exception { + super(ResourceQuotaControl.class, storageManager); + this.resourceQuota = resourceQuota; + } + + @Override + protected MBeanAttributeInfo[] fillMBeanAttributeInfo() { + return MBeanInfoHelper.getMBeanAttributesInfo(ResourceQuotaControl.class); + } + + @Override + protected MBeanOperationInfo[] fillMBeanOperationInfo() { + return MBeanInfoHelper.getMBeanOperationsInfo(ResourceQuotaControl.class); + } + + @Override + public String getName() { + clearIO(); + try { + return resourceQuota.getName(); + } finally { + blockOnIO(); + } + } + + @Override + public String getPartOf() { + clearIO(); + try { + return resourceQuota.getPartOf(); + } finally { + blockOnIO(); + } + } + + @Override + public int getMaxAddresses() { + clearIO(); + try { + return resourceQuota.getMaxAddresses(); + } finally { + blockOnIO(); + } + } + + @Override + public int getCurrentAddressCount() { + clearIO(); + try { + return resourceQuota.getCurrentAddressCount(); + } finally { + blockOnIO(); + } + } + + @Override + public double getAddressUtilizationPercent() { + clearIO(); + try { + return resourceQuota.getAddressUtilizationPercent(); + } finally { + blockOnIO(); + } + } + + @Override + public int getMaxQueues() { + clearIO(); + try { + return resourceQuota.getMaxQueues(); + } finally { + blockOnIO(); + } + } + + @Override + public int getCurrentQueueCount() { + clearIO(); + try { + return resourceQuota.getCurrentQueueCount(); + } finally { + blockOnIO(); + } + } + + @Override + public double getQueueUtilizationPercent() { + clearIO(); + try { + return resourceQuota.getQueueUtilizationPercent(); + } finally { + blockOnIO(); + } + } + + @Override + public long getMaxMessageBytes() { + clearIO(); + try { + return resourceQuota.getMaxMessageBytes(); + } finally { + blockOnIO(); + } + } + + @Override + public long getCurrentMessageBytes() { + clearIO(); + try { + return resourceQuota.getCurrentMessageBytes(); + } finally { + blockOnIO(); + } + } + + @Override + public double getMessageBytesUtilizationPercent() { + clearIO(); + try { + return resourceQuota.getByteUtilizationPercent(); + } finally { + blockOnIO(); + } + } + + @Override + public boolean hasLimits() { + clearIO(); + try { + return resourceQuota.hasLimits(); + } finally { + blockOnIO(); + } + } + + @Override + public boolean isLimitReached() { + clearIO(); + try { + return resourceQuota.isAddressLimitReached() || + resourceQuota.isQueueLimitReached() || + resourceQuota.isByteLimitReached(); + } finally { + blockOnIO(); + } + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingManager.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingManager.java index 2eab3889436..8623b0c545e 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingManager.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingManager.java @@ -143,6 +143,17 @@ default long getGlobalMessages() { */ void checkMemory(Runnable runWhenAvailable); + /** + * Get the resource quota manager for hierarchical quota management. + */ + default org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager getResourceQuotaManager() { + return null; + } + + default void setResourceQuotaManager(org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager resourceQuotaManager) { + // Default no-op for implementations that don't support quotas + } + void counterSnapshot(); /** diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingStore.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingStore.java index 55775c5b53f..fba1d80e5d6 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingStore.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/PagingStore.java @@ -107,6 +107,21 @@ default PagingStore enforceAddressFullMessagePolicy(AddressFullMessagePolicy enf void applySetting(AddressSettings addressSettings); + /** + * Set the resource quota for this paging store. + * + * @param quota the resource quota to enforce, or null for no quota + */ + void setResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota); + + org.apache.activemq.artemis.core.settings.impl.ResourceQuota getResourceQuota(); + + void rebuildQuotaCounters() throws Exception; + + void setRebuiltQuotaBytes(long bytes); + + long applyPreliminaryQuotaEstimate(); + /** * This method will look if the current state of paging is not paging, without using a lock. For cases where you need * absolutely atomic results, check it directly on the internal variables while requiring a readLock. diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageCounterRebuildManager.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageCounterRebuildManager.java index 5b0c09a99ee..0e3718da93a 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageCounterRebuildManager.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageCounterRebuildManager.java @@ -59,6 +59,7 @@ public class PageCounterRebuildManager implements Runnable { private int limitMessageNr; private LongObjectHashMap copiedSubscriptionMap = new LongObjectHashMap<>(); private final Set storedLargeMessages; + private long quotaBytes; public PageCounterRebuildManager(PagingManager pagingManager, PagingStore store, Map transactions, Set storedLargeMessages, AtomicLong minPageTXIDFound) { @@ -179,6 +180,7 @@ private void done() { copiedSubscription.subscriptionCounter.finishRebuild(); } }); + pgStore.setRebuiltQuotaBytes(quotaBytes); pgStore.getCursorProvider().counterRebuildDone(); pgStore.getCursorProvider().scheduleCleanup(); } @@ -262,6 +264,8 @@ public void rebuild() throws Exception { logger.trace("lookup on {}, tx={}, preparedTX={}", msg.getTransactionID(), txInfo, preparedTX); } + boolean messageActiveForQuota = false; + for (long queueID : routedQueues) { boolean ok = !isACK(queueID, msg.getPageNumber(), msg.getMessageNumber()); @@ -292,6 +296,7 @@ public void afterCommit(Transaction tx) { } if (ok && txIncluded) { // not acked and TX is ok + messageActiveForQuota = true; if (logger.isTraceEnabled()) { logger.trace("Message pageNumber={}/{} NOT acked on queue {}", msg.getPageNumber(), msg.getMessageNumber(), queueID); } @@ -308,6 +313,10 @@ public void afterCommit(Transaction tx) { } } } + + if (messageActiveForQuota) { + quotaBytes += msg.getPersistentSize(); + } } } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageSubscriptionImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageSubscriptionImpl.java index cd812b1a9fb..86f604b50a7 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageSubscriptionImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/cursor/impl/PageSubscriptionImpl.java @@ -52,6 +52,7 @@ import org.apache.activemq.artemis.core.server.ActiveMQServerLogger; import org.apache.activemq.artemis.core.server.MessageReference; import org.apache.activemq.artemis.core.server.Queue; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; import org.apache.activemq.artemis.core.transaction.Transaction; import org.apache.activemq.artemis.core.transaction.TransactionOperationAbstract; import org.apache.activemq.artemis.core.transaction.TransactionPropertyIndexes; @@ -431,6 +432,20 @@ public void ackTx(final Transaction tx, final PagedReference reference, boolean tx.setContainsPersistent(); } + final ResourceQuota quota = pageStore.getResourceQuota(); + if (quota != null) { + if (tx != null) { + tx.addOperation(new TransactionOperationAbstract() { + @Override + public void afterCommit(final Transaction tx1) { + quota.addSize(-persistentSize); + } + }); + } else { + quota.addSize(-persistentSize); + } + } + } @Override diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingManagerImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingManagerImpl.java index 4ce1cb3b3fc..5ebaee31a5b 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingManagerImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingManagerImpl.java @@ -41,6 +41,7 @@ import org.apache.activemq.artemis.core.paging.PagingManager; import org.apache.activemq.artemis.core.paging.PagingStore; import org.apache.activemq.artemis.core.paging.PagingStoreFactory; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager; import org.apache.activemq.artemis.core.paging.cursor.impl.PageCounterRebuildManager; import org.apache.activemq.artemis.core.server.ActiveMQScheduledComponent; import org.apache.activemq.artemis.core.server.ActiveMQServer; @@ -82,6 +83,8 @@ public final class PagingManagerImpl implements PagingManager { private final HierarchicalRepository addressSettingsRepository; + private ResourceQuotaManager resourceQuotaManager; + private final ActiveMQServer server; private PagingStoreFactory pagingStoreFactory; @@ -171,6 +174,16 @@ public long getMaxMessages() { return maxMessages; } + @Override + public ResourceQuotaManager getResourceQuotaManager() { + return resourceQuotaManager; + } + + @Override + public void setResourceQuotaManager(ResourceQuotaManager resourceQuotaManager) { + this.resourceQuotaManager = resourceQuotaManager; + } + public PagingManagerImpl(final PagingStoreFactory pagingSPI, final HierarchicalRepository addressSettingsRepository) { this(pagingSPI, addressSettingsRepository, -1, -1, null, null); @@ -200,6 +213,14 @@ private void reapplySettings() { for (PagingStore store : stores.values()) { AddressSettings settings = this.addressSettingsRepository.getMatch(store.getAddress().toString()); store.applySetting(settings); + + if (resourceQuotaManager != null && settings.getResourceQuota() != null) { + org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = + resourceQuotaManager.getQuotaForAddress(store.getAddress(), settings); + store.setResourceQuota(quota); + } else { + store.setResourceQuota(null); + } } } @@ -400,6 +421,19 @@ public void reloadStores() throws Exception { for (PagingStore store : reloadedStores) { store.getCursorProvider().counterRebuildStarted(); store.start(); + + if (resourceQuotaManager != null) { + org.apache.activemq.artemis.core.settings.impl.AddressSettings addrSettings = + addressSettingsRepository.getMatch(store.getStoreName().toString()); + if (addrSettings != null && addrSettings.getResourceQuota() != null) { + org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = + resourceQuotaManager.getQuotaForAddress(store.getStoreName(), addrSettings); + if (quota != null) { + store.setResourceQuota(quota); + } + } + } + stores.put(store.getStoreName(), store); } } finally { @@ -574,7 +608,18 @@ private PagingStore newStore(final SimpleString address) throws Exception { assert managementAddress == null || (managementAddress != null && !address.startsWith(managementAddress)); syncLock.readLock().lock(); try { - PagingStore store = pagingStoreFactory.newStore(address, addressSettingsRepository.getMatch(address.toString())); + org.apache.activemq.artemis.core.settings.impl.AddressSettings settings = addressSettingsRepository.getMatch(address.toString()); + PagingStore store = pagingStoreFactory.newStore(address, settings); + + // Set resource quota if quota manager is available and address settings specify a quota + if (resourceQuotaManager != null && settings.getResourceQuota() != null) { + org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = + resourceQuotaManager.getQuotaForAddress(address, settings); + if (quota != null) { + store.setResourceQuota(quota); + } + } + store.start(); if (!cleanupEnabled) { store.disableCleanup(); @@ -604,6 +649,7 @@ public void forEachTransaction(BiConsumer transaction public Future rebuildCounters(Set storedLargeMessages) { if (rebuildingPageCounters) { logger.debug("Rebuild page counters is already underway, ignoring call"); + return null; } Map transactionsSet = new LongObjectHashMap(); // making a copy @@ -622,12 +668,29 @@ public Future rebuildCounters(Set storedLargeMessages) { transactionsSet.forEach((a, b) -> logger.debug("{} = {}", a, b)); } + // Quick synchronous estimate: set conservative byte counts from page metadata. + // This gives quotas a non-zero value immediately; the async rebuild corrects it. + currentStoreMap.forEach((address, pgStore) -> { + pgStore.applyPreliminaryQuotaEstimate(); + }); + currentStoreMap.forEach((address, pgStore) -> { PageCounterRebuildManager rebuildManager = new PageCounterRebuildManager(this, pgStore, transactionsSet, storedLargeMessages, minLargeMessageID); logger.debug("Setting destination {} to rebuild counters", address); managerExecutor.execute(rebuildManager); }); + // Rebuild quota counters for each paging store after page counter rebuild completes + currentStoreMap.forEach((address, pgStore) -> { + managerExecutor.execute(() -> { + try { + pgStore.rebuildQuotaCounters(); + } catch (Exception e) { + logger.warn("Error rebuilding quota counters for address {}", address, e); + } + }); + }); + managerExecutor.execute(() -> cleanupPageTransactions(transactionsSet, currentStoreMap)); FutureTask task = new FutureTask<>(() -> null); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingStoreImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingStoreImpl.java index 1ba73dcffa0..c13f34b158f 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingStoreImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/paging/impl/PagingStoreImpl.java @@ -60,6 +60,7 @@ import org.apache.activemq.artemis.core.settings.impl.PageFullMessagePolicy; import org.apache.activemq.artemis.core.transaction.Transaction; import org.apache.activemq.artemis.core.transaction.TransactionOperation; +import org.apache.activemq.artemis.core.transaction.TransactionOperationAbstract; import org.apache.activemq.artemis.core.transaction.TransactionPropertyIndexes; import org.apache.activemq.artemis.utils.ArtemisCloseable; import org.apache.activemq.artemis.utils.FutureLatch; @@ -146,6 +147,12 @@ public class PagingStoreImpl implements PagingStore { // Bytes consumed by the queue on the memory private final SizeAwareMetric size; + private volatile org.apache.activemq.artemis.core.settings.impl.ResourceQuota resourceQuota; + + private volatile long preliminaryQuotaBytes; + + private volatile long rebuiltQuotaBytes = -1; + private volatile boolean full; private long numberOfPages; @@ -278,6 +285,16 @@ public void applySetting(final AddressSettings addressSettings) { applySetting(addressSettings, false); } + @Override + public void setResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota) { + this.resourceQuota = quota; + } + + @Override + public org.apache.activemq.artemis.core.settings.impl.ResourceQuota getResourceQuota() { + return resourceQuota; + } + private void applySetting(final AddressSettings addressSettings, final boolean firstTime) { maxSize = addressSettings.getMaxSizeBytes(); @@ -1522,6 +1539,21 @@ private int writePage(Message message, return -1; } + final org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = resourceQuota; + if (quota != null) { + final long size = message.getPersistentSize(); + if (tx != null) { + tx.addOperation(new TransactionOperationAbstract() { + @Override + public void afterCommit(Transaction tx) { + quota.addSize(size); + } + }); + } else { + quota.addSize(size); + } + } + final long transactionID = (tx != null && tx.isAllowPageTransaction()) ? tx.getID() : -1L; if (pageDecorator != null) { @@ -1738,6 +1770,61 @@ private void internalDestroy() { } } + @Override + public long applyPreliminaryQuotaEstimate() { + final org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = resourceQuota; + if (quota == null || numberOfPages <= 0) { + return 0; + } + long estimate = numberOfPages * pageSize; + preliminaryQuotaBytes = estimate; + quota.addSize(estimate); + logger.debug("Applied preliminary quota estimate for address {}: {} bytes ({} pages x {} pageSize)", + address, estimate, numberOfPages, pageSize); + return estimate; + } + + @Override + public void setRebuiltQuotaBytes(long bytes) { + this.rebuiltQuotaBytes = bytes; + } + + /** + * Apply the quota byte total computed by PageCounterRebuildManager during the + * page-counter rebuild, correcting the preliminary estimate that was set + * synchronously at startup. + */ + @Override + public void rebuildQuotaCounters() throws Exception { + final org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = resourceQuota; + if (quota == null) { + if (logger.isDebugEnabled()) { + logger.debug("No quota configured for address {}, skipping quota rebuild", address); + } + return; + } + + // Use the per-message quota bytes computed by PageCounterRebuildManager, + // which counted each paged message's persistentSize exactly once — matching + // what writePage() originally added to the quota. This avoids both re-reading + // page files and double-counting messages routed to multiple subscriptions. + long totalBytes = rebuiltQuotaBytes >= 0 ? rebuiltQuotaBytes : 0; + rebuiltQuotaBytes = -1; + + long correction = totalBytes - preliminaryQuotaBytes; + preliminaryQuotaBytes = 0; + if (correction != 0) { + quota.addSize(correction); + } + if (totalBytes > 0) { + logger.info("Rebuilt quota for address {}: {} bytes", address, totalBytes); + } else { + if (logger.isDebugEnabled()) { + logger.debug("Quota rebuild for address {} found no paged data", address); + } + } + } + private static class FinishPageMessageOperation implements TransactionOperation { private final PageTransactionInfo pageTransaction; diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImpl.java index 6ee9a03a1a9..a893c333ec0 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImpl.java @@ -93,6 +93,7 @@ import org.apache.activemq.artemis.core.server.management.Notification; import org.apache.activemq.artemis.core.server.management.NotificationListener; import org.apache.activemq.artemis.core.server.mirror.MirrorController; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; import org.apache.activemq.artemis.core.settings.HierarchicalRepository; import org.apache.activemq.artemis.core.settings.HierarchicalRepositoryChangeListener; import org.apache.activemq.artemis.core.settings.impl.NamedHierarchicalRepositoryChangeListener; @@ -137,6 +138,8 @@ public class PostOfficeImpl implements PostOffice, NotificationListener, Binding private final ManagementService managementService; + private final ResourceQuotaService resourceQuotaService; + private ExpiryReaper expiryReaperRunnable; private final long expiryReaperPeriod; @@ -166,6 +169,7 @@ public PostOfficeImpl(final ActiveMQServer server, final PagingManager pagingManager, final QueueFactory bindableFactory, final ManagementService managementService, + final ResourceQuotaService resourceQuotaService, final long expiryReaperPeriod, final long addressQueueReaperPeriod, final WildcardConfiguration wildcardConfiguration, @@ -178,6 +182,8 @@ public PostOfficeImpl(final ActiveMQServer server, this.managementService = managementService; + this.resourceQuotaService = resourceQuotaService; + this.pagingManager = pagingManager; this.expiryReaperPeriod = expiryReaperPeriod; @@ -544,6 +550,11 @@ private boolean internalAddressInfo(AddressInfo addressInfo, boolean reload) thr server.callBrokerAddressPlugins(plugin -> plugin.beforeAddAddress(addressInfo, reload)); } + // Check quota early before creating address (skip check during reload) + if (!reload) { + resourceQuotaService.checkAddressQuota(addressInfo.getName()); + } + boolean result; if (reload) { result = addressManager.reloadAddressInfo(addressInfo); @@ -555,10 +566,8 @@ private boolean internalAddressInfo(AddressInfo addressInfo, boolean reload) thr if (!reload && mirrorControllerSource != null) { mirrorControllerSource.addAddress(addressInfo); } - try { managementService.registerAddress(addressInfo); - if (server.hasBrokerAddressPlugins()) { server.callBrokerAddressPlugins(plugin -> plugin.afterAddAddress(addressInfo, reload)); } @@ -572,6 +581,10 @@ private boolean internalAddressInfo(AddressInfo addressInfo, boolean reload) thr } catch (Exception e) { e.printStackTrace(); } + + // Increment quota only after all operations complete successfully + // This ensures quota is tracked even if operations above throw (but address was created) + resourceQuotaService.incrementAddressCount(addressInfo.getName()); } return result; } @@ -900,6 +913,7 @@ public AddressInfo removeAddressInfo(SimpleString address, boolean force) throws } else if (!bindingsForAddress.isEmpty()) { throw ActiveMQMessageBundle.BUNDLE.addressHasBindings(address); } + managementService.unregisterAddress(address); final AddressInfo addressInfo = addressManager.removeAddressInfo(address); @@ -915,6 +929,10 @@ public AddressInfo removeAddressInfo(SimpleString address, boolean force) throws server.callBrokerAddressPlugins(plugin -> plugin.afterRemoveAddress(address, addressInfo)); } + // Decrement quota after successful removal + if (addressInfo != null) { + resourceQuotaService.decrementAddressCount(address); + } return addressInfo; } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQMessageBundle.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQMessageBundle.java index 0ca2902c56f..46251bf828d 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQMessageBundle.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQMessageBundle.java @@ -38,6 +38,7 @@ import org.apache.activemq.artemis.api.core.ActiveMQQueueExistsException; import org.apache.activemq.artemis.api.core.ActiveMQQueueMaxConsumerLimitReached; import org.apache.activemq.artemis.api.core.ActiveMQRemoteDisconnectException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; import org.apache.activemq.artemis.api.core.ActiveMQReplicationTimeooutException; import org.apache.activemq.artemis.api.core.ActiveMQRoutingException; import org.apache.activemq.artemis.api.core.ActiveMQSecurityException; @@ -317,6 +318,9 @@ public interface ActiveMQMessageBundle { @Message(id = 229102, value = "Address \"{}\" is full.") ActiveMQAddressFullException addressIsFull(String addressName); + @Message(id = 229261, value = "Resource quota exceeded: {}") + ActiveMQResourceQuotaExceededException resourceQuotaExceeded(String details); + @Message(id = 229103, value = "No Connectors or Discovery Groups configured for Scale Down") ActiveMQException noConfigurationFoundForScaleDown(); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQServer.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQServer.java index ac39f28f041..cfb37867f3e 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQServer.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/ActiveMQServer.java @@ -61,6 +61,7 @@ import org.apache.activemq.artemis.core.server.metrics.MetricsManager; import org.apache.activemq.artemis.core.server.mirror.MirrorController; import org.apache.activemq.artemis.core.server.mirror.MirrorRegistry; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; import org.apache.activemq.artemis.core.server.plugin.ActiveMQPluginRunnable; import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerAddressPlugin; import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerBasePlugin; @@ -173,6 +174,8 @@ enum SERVER_STATE { ManagementService getManagementService(); + ResourceQuotaService getResourceQuotaService(); + ActiveMQSecurityManager getSecurityManager(); NetworkHealthCheck getNetworkHealthCheck(); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/Activation.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/Activation.java index 32956532963..ed4fbabefc5 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/Activation.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/Activation.java @@ -101,7 +101,7 @@ public JournalLoader createJournalLoader(PostOffice postOffice, GroupingHandler groupingHandler, Configuration configuration, ActiveMQServer parentServer) throws ActiveMQException { - return new PostOfficeJournalLoader(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration); + return new PostOfficeJournalLoader(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration, parentServer.getResourceQuotaService()); } // todo, remove this, its only needed for JMSServerManagerImpl, it should be sought elsewhere diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ActiveMQServerImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ActiveMQServerImpl.java index 4fb63b0b006..9187dddd7b0 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ActiveMQServerImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ActiveMQServerImpl.java @@ -170,6 +170,8 @@ import org.apache.activemq.artemis.core.server.mirror.MirrorController; import org.apache.activemq.artemis.core.server.mirror.MirrorRegistry; import org.apache.activemq.artemis.core.server.plugin.ActiveMQPluginRunnable; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.server.quota.impl.ResourceQuotaServiceImpl; import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerAddressPlugin; import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerBasePlugin; import org.apache.activemq.artemis.core.server.plugin.ActiveMQServerBindingPlugin; @@ -342,6 +344,8 @@ public class ActiveMQServerImpl implements ActiveMQServer { private volatile ManagementService managementService; + private volatile ResourceQuotaService resourceQuotaService; + private volatile MirrorController mirrorControllerService; private volatile ConnectorsService connectorsService; @@ -1526,6 +1530,7 @@ private void stop(boolean failoverOnServerShutdown, installMirrorController(null); pagingManager = null; + resourceQuotaService = null; securityStore = null; resourceManager = null; postOffice = null; @@ -1798,6 +1803,11 @@ public ManagementService getManagementService() { return managementService; } + @Override + public ResourceQuotaService getResourceQuotaService() { + return resourceQuotaService; + } + @Override public HierarchicalRepository> getSecurityRepository() { return securityRepository; @@ -2630,6 +2640,11 @@ public void destroyQueue(final SimpleString queueName, callBrokerQueuePlugins(plugin -> plugin.afterDestroyQueue(queue, address, session, checkConsumerCount, removeConsumers, forceAutoDeleteAddress)); } + // Decrement quota after successful deletion + if (resourceQuotaService != null) { + resourceQuotaService.decrementQueueCount(address); + } + if (forceAutoDeleteAddress) { AddressInfo addressInfo = getAddressInfo(address); @@ -3410,6 +3425,8 @@ synchronized boolean initialisePart1(boolean scalingDown) throws Exception { pagingManager = createPagingManager(); + resourceQuotaService = new ResourceQuotaServiceImpl(addressSettingsRepository, configuration); + resourceManager = new ResourceManagerImpl(this, (int) (configuration.getTransactionTimeout() / 1000), configuration.getTransactionTimeoutScanPeriod(), scheduledPool); /* @@ -3425,7 +3442,11 @@ synchronized boolean initialisePart1(boolean scalingDown) throws Exception { metricsManager.registerExecutorService(BrokerMetricNames.SCHEDULED_EXECUTOR_SERVICE, scheduledPool); } - postOffice = new PostOfficeImpl(this, storageManager, pagingManager, queueFactory, managementService, configuration.getMessageExpiryScanPeriod(), configuration.getAddressQueueScanPeriod(), configuration.getWildcardConfiguration(), configuration.getIDCacheSize(), configuration.isPersistIDCache(), addressSettingsRepository); + postOffice = new PostOfficeImpl(this, storageManager, pagingManager, queueFactory, managementService, resourceQuotaService, configuration.getMessageExpiryScanPeriod(), configuration.getAddressQueueScanPeriod(), configuration.getWildcardConfiguration(), configuration.getIDCacheSize(), configuration.isPersistIDCache(), addressSettingsRepository); + + resourceQuotaService.setPostOffice(postOffice); + resourceQuotaService.setPagingManager(pagingManager); + resourceQuotaService.setManagementService(managementService); // This can't be created until node id is set clusterManager = new ClusterManager(executorFactory, this, postOffice, scheduledPool, managementService, configuration, nodeManager, haPolicy.useQuorumManager()); @@ -3470,6 +3491,8 @@ synchronized boolean initialisePart1(boolean scalingDown) throws Exception { managementService.start(); + pagingManager.setResourceQuotaManager(resourceQuotaService.getResourceQuotaManager()); + resourceManager.start(); deploySecurityFromConfiguration(); @@ -3922,7 +3945,7 @@ private void deployAddressSettingsFromConfiguration() { private JournalLoadInformation[] loadJournals(Set storedLargeMessages) throws Exception { - JournalLoader journalLoader = activation.createJournalLoader(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration, parentServer); + JournalLoader journalLoader = activation.createJournalLoader(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration, this); JournalLoadInformation[] journalInfo = new JournalLoadInformation[2]; @@ -4209,9 +4232,11 @@ public Queue createQueue(final QueueConfiguration queueConfiguration, boolean ig mirrorControllerService.createQueue(queueConfiguration); } + // Check quota early before creating queue + resourceQuotaService.checkQueueQuota(queueConfiguration.getAddress()); + queueConfiguration.setId(storageManager.generateID()); - // preemptive check to ensure the filterString is good Filter filter = FilterImpl.createFilter(queueConfiguration.getFilterString()); final Queue queue = queueFactory.createQueueWith(queueConfiguration, pagingManager, filter); @@ -4257,6 +4282,8 @@ public Queue createQueue(final QueueConfiguration queueConfiguration, boolean ig callPostQueueCreationCallbacks(queue.getName()); + resourceQuotaService.incrementQueueCount(queueConfiguration.getAddress()); + return queue; } } @@ -4766,6 +4793,7 @@ private void updateReloadableConfigurationFrom(Configuration config) { configuration.setPurgePageFolders(config.isPurgePageFolders()); configuration.setConnectionRouters(config.getConnectionRouters()); configuration.setJaasConfigs(config.getJaasConfigs()); + configuration.setResourceQuotas(config.getResourceQuotas()); } private static boolean hasReloadableConfig(Configuration configuration) { @@ -4779,7 +4807,8 @@ private static boolean hasReloadableConfig(Configuration configuration) { !configuration.getAcceptorConfigurations().isEmpty() || !configuration.getAMQPConnection().isEmpty() || !configuration.getConnectionRouters().isEmpty() || - !configuration.getJaasConfigs().isEmpty(); + !configuration.getJaasConfigs().isEmpty() || + !configuration.getResourceQuotas().isEmpty(); } private void deployReloadableConfigFromConfiguration() throws Exception { @@ -4888,6 +4917,10 @@ private void deployReloadableConfigFromConfiguration() throws Exception { ActiveMQServerLogger.LOGGER.reloadingConfiguration("protocol services"); updateProtocolServices(); + if (resourceQuotaService != null) { + ActiveMQServerLogger.LOGGER.reloadingConfiguration("resource quotas"); + resourceQuotaService.reloadQuotas(); + } ActiveMQServerLogger.LOGGER.configurationReloadCompleted(); } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/BackupRecoveryJournalLoader.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/BackupRecoveryJournalLoader.java index 4a39855aed9..7fc28876e62 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/BackupRecoveryJournalLoader.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/BackupRecoveryJournalLoader.java @@ -65,7 +65,7 @@ public BackupRecoveryJournalLoader(PostOffice postOffice, ServerLocatorInternal locator, ClusterController clusterController) { - super(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration); + super(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration, parentServer.getResourceQuotaService()); this.parentServer = parentServer; this.locator = locator; this.clusterController = clusterController; diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/PostOfficeJournalLoader.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/PostOfficeJournalLoader.java index cc7811a9ea0..d1c69f9b470 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/PostOfficeJournalLoader.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/PostOfficeJournalLoader.java @@ -78,6 +78,7 @@ public class PostOfficeJournalLoader implements JournalLoader { protected final NodeManager nodeManager; private final ManagementService managementService; private final GroupingHandler groupingHandler; + private final org.apache.activemq.artemis.core.server.quota.ResourceQuotaService resourceQuotaService; private final Configuration configuration; private Map queues; @@ -88,7 +89,8 @@ public PostOfficeJournalLoader(PostOffice postOffice, NodeManager nodeManager, ManagementService managementService, GroupingHandler groupingHandler, - Configuration configuration) { + Configuration configuration, + org.apache.activemq.artemis.core.server.quota.ResourceQuotaService resourceQuotaService) { this.postOffice = postOffice; this.pagingManager = pagingManager; @@ -97,6 +99,7 @@ public PostOfficeJournalLoader(PostOffice postOffice, this.nodeManager = nodeManager; this.managementService = managementService; this.groupingHandler = groupingHandler; + this.resourceQuotaService = resourceQuotaService; this.configuration = configuration; queues = new HashMap<>(); } @@ -109,9 +112,10 @@ public PostOfficeJournalLoader(PostOffice postOffice, ManagementService managementService, GroupingHandler groupingHandler, Configuration configuration, + org.apache.activemq.artemis.core.server.quota.ResourceQuotaService resourceQuotaService, Map queues) { - this(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration); + this(postOffice, pagingManager, storageManager, queueFactory, nodeManager, managementService, groupingHandler, configuration, resourceQuotaService); this.queues = queues; } @@ -156,9 +160,15 @@ public void initQueues(Map queueBindingInfosMap, final Binding binding = new LocalQueueBinding(queue.getAddress(), queue, nodeManager.getNodeId()); queues.put(queue.getID(), queue); + postOffice.addBinding(binding); managementService.registerQueue(queue, queue.getAddress(), storageManager); + // Rebuild quota counter during reload (no enforcement check needed) + if (resourceQuotaService != null) { + resourceQuotaService.incrementQueueCount(queue.getAddress()); + } + } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/QueueImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/QueueImpl.java index 04a47ad3aa4..bab9f106423 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/QueueImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/QueueImpl.java @@ -99,6 +99,7 @@ import org.apache.activemq.artemis.core.settings.HierarchicalRepositoryChangeListener; import org.apache.activemq.artemis.core.settings.impl.AddressSettings; import org.apache.activemq.artemis.core.settings.impl.NamedHierarchicalRepositoryChangeListener; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; import org.apache.activemq.artemis.core.settings.impl.SlowConsumerPolicy; import org.apache.activemq.artemis.core.settings.impl.SlowConsumerThresholdMeasurementUnit; import org.apache.activemq.artemis.core.transaction.Transaction; @@ -761,6 +762,18 @@ public int durableDown(Message message) { return message.durableDown(); } + private static long getMessageSizeForQuota(MessageReference messageReference) { + long size = messageReference.getMessageMemoryEstimate(); + if (messageReference.getMessage().isLargeMessage()) { + try { + size += messageReference.getPersistentSize(); + } catch (ActiveMQException ignore) { + // ignored + } + } + return size; + } + @Override public void refUp(MessageReference messageReference) { int count = messageReference.getMessage().refUp(); @@ -769,6 +782,10 @@ public void refUp(MessageReference messageReference) { if (owner != null) { owner.addSize(messageReference.getMessageMemoryEstimate(), false); messageReference.getMessage().routed(); + final ResourceQuota quota = owner.getResourceQuota(); + if (quota != null) { + quota.addSize(getMessageSizeForQuota(messageReference)); + } } } if (pagingStore != null) { @@ -779,6 +796,10 @@ public void refUp(MessageReference messageReference) { } pagingStore.refUp(messageReference.getMessage(), count); + final ResourceQuota quota = pagingStore.getResourceQuota(); + if (quota != null) { + quota.addSize(MessageReferenceImpl.getMemoryEstimate()); + } } } @@ -788,6 +809,10 @@ public void refDown(MessageReference messageReference) { PagingStore owner = (PagingStore) messageReference.getMessage().getOwner(); if (count == 0 && owner != null) { owner.addSize(-messageReference.getMessageMemoryEstimate(), false); + final ResourceQuota quota = owner.getResourceQuota(); + if (quota != null) { + quota.addSize(-getMessageSizeForQuota(messageReference)); + } } if (pagingStore != null) { if (isMirrorController() && owner != null && pagingStore != owner) { @@ -796,6 +821,10 @@ public void refDown(MessageReference messageReference) { pagingStore.addSize(-messageReference.getMessage().getOriginalEstimate(), false, false); } pagingStore.refDown(messageReference.getMessage(), count); + final ResourceQuota quota = pagingStore.getResourceQuota(); + if (quota != null) { + quota.addSize(-MessageReferenceImpl.getMemoryEstimate()); + } } } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ServerSessionImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ServerSessionImpl.java index 01764ae8e58..61426d798f9 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ServerSessionImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/impl/ServerSessionImpl.java @@ -91,6 +91,7 @@ import org.apache.activemq.artemis.core.server.management.ManagementService; import org.apache.activemq.artemis.core.server.management.Notification; import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; import org.apache.activemq.artemis.core.transaction.ResourceManager; import org.apache.activemq.artemis.core.transaction.Transaction; import org.apache.activemq.artemis.core.transaction.Transaction.State; @@ -2444,6 +2445,20 @@ public synchronized RoutingStatus doSend(final Transaction tx, // check the user has write access to this address (and potentially queue). try { securityCheck(CompositeAddress.extractAddressName(messageAddress), CompositeAddress.isFullyQualified(messageAddress) ? CompositeAddress.extractQueueName(messageAddress) : null, CheckType.SEND, this); + + final org.apache.activemq.artemis.core.paging.PagingStore pagingStore = + server.getPagingManager() != null ? server.getPagingManager().getPageStore(messageAddress) : null; + final ResourceQuota quota = pagingStore != null ? pagingStore.getResourceQuota() : null; + if (quota != null) { + final long sizeForConsistency = MessageReferenceImpl.getMemoryEstimate() + message.getMemoryEstimate() + (message.isLargeMessage() ? message.getPersistentSize() : 0); + if (!quota.canAddBytes(sizeForConsistency)) { + throw ActiveMQMessageBundle.BUNDLE.resourceQuotaExceeded( + "quota '" + quota.getName() + + "' - cannot add message of " + sizeForConsistency + + " bytes, current usage " + quota.getCurrentMessageBytes() + + " bytes, max " + quota.getMaxMessageBytes() + " bytes"); + } + } } catch (ActiveMQException e) { if (!autoCommitSends && tx != null) { tx.markAsRollbackOnly(e); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ManagementService.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ManagementService.java index 099b363610e..a846ee70917 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ManagementService.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/ManagementService.java @@ -148,6 +148,10 @@ ActiveMQServerControlImpl registerServer(PostOffice postOffice, int getAddressControlCount(); + void registerResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota resourceQuota) throws Exception; + + void unregisterResourceQuota(String quotaName) throws Exception; + List getAddressControls(); List getAddressControls(Predicate predicate); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/impl/ManagementServiceImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/impl/ManagementServiceImpl.java index fbc00f0ca0c..02e0aa46ebc 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/impl/ManagementServiceImpl.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/management/impl/ManagementServiceImpl.java @@ -302,6 +302,18 @@ public void unregisterAddress(final SimpleString address) throws Exception { unregisterMeters(ResourceNames.ADDRESS + address); } + @Override + public void registerResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota resourceQuota) throws Exception { + org.apache.activemq.artemis.core.management.impl.ResourceQuotaControlImpl quotaControl = + new org.apache.activemq.artemis.core.management.impl.ResourceQuotaControlImpl(resourceQuota, storageManager); + registerInJMX(objectNameBuilder.getResourceQuotaObjectName(resourceQuota.getName()), quotaControl); + } + + @Override + public void unregisterResourceQuota(String quotaName) throws Exception { + unregisterFromJMX(objectNameBuilder.getResourceQuotaObjectName(quotaName)); + } + @Override public void registerQueue(final Queue queue, final SimpleString address, final StorageManager storageManager) throws Exception { QueueControlImpl queueControl = new QueueControlImpl(queue, address.toString(), messagingServer, storageManager, addressSettingsRepository); diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/plugin/ActiveMQServerAddressPlugin.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/plugin/ActiveMQServerAddressPlugin.java index 44bffe1375e..fe8bcd2c047 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/plugin/ActiveMQServerAddressPlugin.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/plugin/ActiveMQServerAddressPlugin.java @@ -25,7 +25,7 @@ public interface ActiveMQServerAddressPlugin extends ActiveMQServerBasePlugin { /** - * Before an address is added tot he broker + * Before an address is added to the broker * * @param addressInfo The addressInfo that will be added * @param reload If the address is being reloaded @@ -35,7 +35,7 @@ default void beforeAddAddress(AddressInfo addressInfo, boolean reload) throws Ac } /** - * After an address has been added tot he broker + * After an address has been added to the broker * * @param addressInfo The newly added address * @param reload If the address is being reloaded diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaManager.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaManager.java new file mode 100644 index 00000000000..1e785c88e1d --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaManager.java @@ -0,0 +1,370 @@ +/* + * 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.activemq.artemis.core.server.quota; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.WildcardConfiguration; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.invoke.MethodHandles; + +/** + * Manages resource quotas including template instantiation and parent hierarchy resolution. + *

+ * This manager handles: + *

    + *
  • Storage and retrieval of quota definitions
  • + *
  • Wildcard template expansion (e.g., "EU.*" template creates "EU.fr" instance)
  • + *
  • Parent-child quota hierarchy establishment
  • + *
+ */ +public class ResourceQuotaManager { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final HierarchicalObjectRepository quotaRepository; + private final ConcurrentHashMap instantiatedQuotas; + private final WildcardConfiguration wildcardConfiguration; + + public ResourceQuotaManager(HierarchicalObjectRepository quotaRepository, + WildcardConfiguration wildcardConfiguration) { + this.quotaRepository = quotaRepository; + this.instantiatedQuotas = new ConcurrentHashMap<>(); + this.wildcardConfiguration = wildcardConfiguration; + } + + /** + * Get the resource quota for a given address based on its settings. + * + * @param address the address to get quota for + * @param settings the address settings containing quota reference + * @return the resource quota, or null if none configured + */ + public ResourceQuota getQuotaForAddress(SimpleString address, AddressSettings settings) { + if (settings == null || settings.getResourceQuota() == null) { + return null; + } + + String quotaName = settings.getResourceQuota(); + + // Check if quota name contains wildcard - if so, need to resolve instance + if (quotaName.contains("*")) { + return resolveWildcardQuota(quotaName, address); + } + + // Simple case: direct quota lookup + ResourceQuota quota = quotaRepository.getMatch(quotaName); + if (quota == null) { + logger.warn("Quota {} referenced but not found for address {}", quotaName, address); + } + return quota; + } + + /** + * Resolve a wildcard quota template to a concrete instance. + * For example, quota "EU.*" with address "eu.fr.orders" becomes instance "EU.fr" + * + * @param quotaTemplate the quota template name (e.g., "EU.*") + * @param address the address to match + * @return resolved quota instance, or null if template not found + */ + private ResourceQuota resolveWildcardQuota(String quotaTemplate, SimpleString address) { + // First check if template exists + ResourceQuota template = quotaRepository.getMatch(quotaTemplate); + if (template == null) { + logger.warn("Quota template {} not found", quotaTemplate); + return null; + } + + // Extract wildcard value from address + // For quota "EU.*" and address "eu.fr.orders", extract "fr" + String wildcardValue = extractWildcardValue(address.toString(), quotaTemplate); + if (wildcardValue == null) { + logger.debug("Could not extract wildcard value for quota {} from address {}", quotaTemplate, address); + return template; // Fall back to template itself + } + + // Build instance name by substituting wildcard + String instanceName = quotaTemplate.replace("*", wildcardValue); + + // Get or create the instance + return instantiatedQuotas.computeIfAbsent(instanceName, name -> { + logger.debug("Creating quota instance {} from template {}", name, quotaTemplate); + return createQuotaInstance(name, template); + }); + } + + /** + * Extract the wildcard value from an address. + * For quota "EU.*" we expect addresses like "eu.XX.*" where XX is the wildcard value. + * + * @param addressStr the address string + * @param quotaTemplate the quota template (e.g., "EU.*") + * @return the extracted wildcard value, or null if not found + */ + private String extractWildcardValue(String addressStr, String quotaTemplate) { + // Convert quota template to lowercase prefix for matching + // "EU.*" becomes "eu." + String templatePrefix = quotaTemplate.substring(0, quotaTemplate.indexOf('*')).toLowerCase(); + + // Check if address starts with this prefix pattern + String delimiterRegex = java.util.regex.Pattern.quote(String.valueOf(wildcardConfiguration.getDelimiter())); + String[] addressParts = addressStr.split(delimiterRegex); + String[] templateParts = templatePrefix.split(delimiterRegex); + + // Find the position of the wildcard in the template + int wildcardIndex = templateParts.length; + + // Extract the value at that position from the address + if (addressParts.length > wildcardIndex) { + return addressParts[wildcardIndex]; + } + + return null; + } + + /** + * Create a new quota instance from a template. + * + * @param instanceName the name for the new instance (e.g., "EU.fr") + * @param template the template to copy from + * @return the new quota instance + */ + private ResourceQuota createQuotaInstance(String instanceName, ResourceQuota template) { + ResourceQuota instance = template.copy(instanceName); + + // Establish parent relationship if template has one + if (instance.getPartOf() != null) { + String parentName = instance.getPartOf(); + + // Wildcard in partOf is not supported - it doesn't make semantic sense + // because each instance would get a separate parent instance + if (parentName.contains("*")) { + logger.warn("Quota template {} has wildcard in part-of '{}' - wildcards are not supported in part-of. Parent relationship will not be established.", + template.getName(), parentName); + return instance; + } + + // Look up non-wildcard parent + ResourceQuota parent = quotaRepository.getMatch(parentName); + if (parent != null) { + instance.setParent(parent); + } else { + logger.warn("Parent quota {} not found for instance {}", parentName, instanceName); + } + } + + return instance; + } + + /** + * Establish parent-child relationships for all quotas in the repository. + * This should be called after all quotas are loaded from configuration. + */ + public void establishParentRelationships() { + List quotasList = getAllQuotas(); + Map allQuotas = new HashMap<>(); + for (ResourceQuota quota : quotasList) { + allQuotas.put(quota.getName(), quota); + } + establishParentRelationships(allQuotas); + } + + private void establishParentRelationships(Map allQuotas) { + Set visited = new HashSet<>(); + + for (ResourceQuota quota : allQuotas.values()) { + establishParentChain(quota, allQuotas, visited); + } + + logger.debug("Established parent relationships for {} quotas", allQuotas.size()); + } + + /** + * Recursively establish parent chain for a quota, detecting circular references. + * + * @param quota the quota to process + * @param allQuotas all available quotas + * @param visited set of quota names already visited (for cycle detection) + * @return true if parent relationship was successfully established, false if circular reference detected + */ + private boolean establishParentChain(ResourceQuota quota, Map allQuotas, Set visited) { + if (quota == null || quota.getPartOf() == null) { + return true; // No parent needed, success + } + + // Already processed + if (quota.getParent() != null) { + return true; // Parent already set, success + } + + // Detect circular reference + if (visited.contains(quota.getName())) { + logger.error("Circular parent reference detected for quota: {}", quota.getName()); + return false; // Circular reference, failure + } + + visited.add(quota.getName()); + + String parentName = quota.getPartOf(); + + // Wildcard in partOf is not supported + if (parentName.contains("*")) { + logger.warn("Quota {} has wildcard in part-of '{}' - wildcards are not supported in part-of. Parent relationship will not be established.", + quota.getName(), parentName); + visited.remove(quota.getName()); + return false; // Invalid configuration, failure + } + + ResourceQuota parent = allQuotas.get(parentName); + + if (parent == null) { + logger.warn("Parent quota {} not found for quota {}", parentName, quota.getName()); + visited.remove(quota.getName()); + return false; // Parent not found, failure + } + + // Recursively establish parent's chain first + boolean parentSuccess = establishParentChain(parent, allQuotas, visited); + + // Only set parent if parent chain was successfully established (no circular reference) + if (parentSuccess) { + quota.setParent(parent); + logger.debug("Established parent relationship: {} -> {}", quota.getName(), parent.getName()); + } + + visited.remove(quota.getName()); + return parentSuccess; // Return same result as parent's processing + } + + /** + * Add a quota to the repository. + * + * @param name the quota name + * @param quota the quota object + */ + public void addQuota(String name, ResourceQuota quota) { + quotaRepository.addMatch(name, quota); + logger.debug("Added quota: {}", name); + } + + /** + * Get a quota by exact name. + * + * @param name the quota name + * @return the quota, or null if not found + */ + public ResourceQuota getQuota(String name) { + return quotaRepository.getMatch(name); + } + + /** + * Get all configured quotas from the repository. + * This returns all quotas including templates and exact matches. + * + * @return list of all quota objects from the repository + */ + public List getAllQuotas() { + return quotaRepository.values(); + } + + /** + * Get all instantiated quotas created from templates. + * + * @return map of instance name to quota object + */ + public Map getInstantiatedQuotas() { + return new ConcurrentHashMap<>(instantiatedQuotas); + } + + /** + * Remove a quota from the repository. + * Handles bulk decrement from parent if this quota is part of a hierarchy. + * + * @param name the quota name to remove + */ + public void removeQuota(String name) { + ResourceQuota quota = quotaRepository.getMatch(name); + if (quota == null) { + logger.warn("Attempted to remove non-existent quota: {}", name); + return; + } + + // If this is a wildcard template, remove all runtime instances created from it + // and decrement their parent contributions before discarding them. + if (name.contains("*")) { + int wildcardIndex = name.indexOf('*'); + String prefix = name.substring(0, wildcardIndex); + instantiatedQuotas.entrySet().removeIf(entry -> { + if (entry.getKey().startsWith(prefix)) { + decrementParentCounters(entry.getValue()); + return true; + } + return false; + }); + } + + // Decrement parent counters for the quota itself (non-wildcard, or the template) + decrementParentCounters(quota); + + // Remove from repository + quotaRepository.removeMatch(name); + + logger.debug("Removed quota: {}", name); + } + + private void decrementParentCounters(ResourceQuota quota) { + ResourceQuota parent = quota.getParent(); + if (parent == null) { + return; + } + int addressCount = quota.getCurrentAddressCount(); + int queueCount = quota.getCurrentQueueCount(); + long sizeBytes = quota.getCurrentMessageBytes(); + + for (int i = 0; i < addressCount; i++) { + parent.decrementAddressCount(); + } + for (int i = 0; i < queueCount; i++) { + parent.decrementQueueCount(); + } + if (sizeBytes > 0) { + parent.addSize(-sizeBytes); + } + + logger.debug("Decremented parent quota '{}' by {} addresses, {} queues, {} bytes for child '{}'", + parent.getName(), addressCount, queueCount, sizeBytes, quota.getName()); + } + + @Override + public String toString() { + return "ResourceQuotaManager{" + + "instantiatedQuotas=" + instantiatedQuotas.size() + + '}'; + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaService.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaService.java new file mode 100644 index 00000000000..7f5173e8c51 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/ResourceQuotaService.java @@ -0,0 +1,122 @@ +/* + * 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.activemq.artemis.core.server.quota; + +import org.apache.activemq.artemis.api.core.SimpleString; + +/** + * Service for managing resource quota enforcement. + */ +public interface ResourceQuotaService { + + /** + * Check if creating an address would exceed quota limits. + * + * @param address the address name for quota lookup + * @throws Exception if quota would be exceeded + */ + void checkAddressQuota(SimpleString address) throws Exception; + + /** + * Increment address count for the quota associated with the address. + * Should be called after successful address creation and JMX registration. + * + * @param address the address name for quota lookup + */ + void incrementAddressCount(SimpleString address); + + /** + * Decrement address count for the quota associated with the address. + * Should be called after successful address removal. + * + * @param address the address name for quota lookup + */ + void decrementAddressCount(SimpleString address); + + /** + * Check if creating a queue would exceed quota limits. + * + * @param address the address name for quota lookup + * @throws Exception if quota would be exceeded + */ + void checkQueueQuota(SimpleString address) throws Exception; + + /** + * Increment queue count for the quota associated with the address. + * Should be called after successful queue creation and JMX registration. + * + * @param address the address name for quota lookup + */ + void incrementQueueCount(SimpleString address); + + /** + * Decrement queue count for the quota associated with the address. + * Should be called after successful queue removal. + * + * @param address the address name for quota lookup + */ + void decrementQueueCount(SimpleString address); + + /** + * Lookup the resource quota for a given address. + * This looks up which quota applies to the address via AddressSettings. + * + * @param address the address name + * @return the ResourceQuota instance, or null if no quota is configured + */ + org.apache.activemq.artemis.core.settings.impl.ResourceQuota lookupQuota(SimpleString address); + + /** + * Get a resource quota by its configured name. + * This provides direct access to a quota by the name it was configured with. + * + * @param quotaName the quota name + * @return the ResourceQuota instance, or null if no quota with that name exists + */ + default org.apache.activemq.artemis.core.settings.impl.ResourceQuota getQuotaByName(String quotaName) { + ResourceQuotaManager manager = getResourceQuotaManager(); + return manager != null ? manager.getQuota(quotaName) : null; + } + + /** + * Get the ResourceQuotaManager managed by this service. + * This provides access to the manager for operations that need direct access + * to the hierarchy and wildcard template functionality. + * + * @return the ResourceQuotaManager instance, or null if quotas are not configured + */ + default ResourceQuotaManager getResourceQuotaManager() { + return null; + } + + /** + * Reload resource quotas from configuration. + * Creates fresh quota instances, clears wildcard instances, and rebuilds counters. + * + */ + void reloadQuotas(); + + default void setPostOffice(org.apache.activemq.artemis.core.postoffice.PostOffice postOffice) { + } + + default void setPagingManager(org.apache.activemq.artemis.core.paging.PagingManager pagingManager) { + } + + default void setManagementService(org.apache.activemq.artemis.core.server.management.ManagementService managementService) { + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/impl/ResourceQuotaServiceImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/impl/ResourceQuotaServiceImpl.java new file mode 100644 index 00000000000..8a613c18a0f --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/server/quota/impl/ResourceQuotaServiceImpl.java @@ -0,0 +1,651 @@ +/* + * 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.activemq.artemis.core.server.quota.impl; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.HierarchicalRepository; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.invoke.MethodHandles; + +/** + * Implementation of ResourceQuotaService with lazy initialization. + */ +public class ResourceQuotaServiceImpl implements ResourceQuotaService { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + private final HierarchicalRepository addressSettingsRepository; + private final org.apache.activemq.artemis.core.config.Configuration configuration; + private final java.util.concurrent.ConcurrentHashMap addressQuotaMapping = new java.util.concurrent.ConcurrentHashMap<>(); + private volatile ResourceQuotaManager resourceQuotaManager; + private org.apache.activemq.artemis.core.postoffice.PostOffice postOffice; + private org.apache.activemq.artemis.core.paging.PagingManager pagingManager; + private org.apache.activemq.artemis.core.server.management.ManagementService managementService; + + public ResourceQuotaServiceImpl(HierarchicalRepository addressSettingsRepository, + org.apache.activemq.artemis.core.config.Configuration configuration) { + this.addressSettingsRepository = addressSettingsRepository; + this.configuration = configuration; + } + + @Override + public void setPostOffice(org.apache.activemq.artemis.core.postoffice.PostOffice postOffice) { + this.postOffice = postOffice; + } + + @Override + public void setPagingManager(org.apache.activemq.artemis.core.paging.PagingManager pagingManager) { + this.pagingManager = pagingManager; + } + + @Override + public void setManagementService(org.apache.activemq.artemis.core.server.management.ManagementService managementService) { + this.managementService = managementService; + } + + /** + * Initialize ResourceQuotaManager lazily with runtime quota instances from configuration. + * Creates ResourceQuota instances from ResourceQuotaConfig definitions. + * Counters start at zero and are rebuilt by scanning existing addresses/queues during broker startup. + * This is called on first access to ensure the manager is ready. + */ + private void ensureInitialized() { + if (resourceQuotaManager != null) { + return; + } + + synchronized (this) { + if (resourceQuotaManager != null) { + return; + } + createAndInitializeQuotaManager(); + } + } + + /** + * Create ResourceQuotaManager and initialize it with runtime quota instances from configuration. + * Must be called within synchronized block. + */ + private void createAndInitializeQuotaManager() { + if (configuration == null) { + return; + } + + java.util.Map quotaConfigs = + configuration.getResourceQuotas(); + if (quotaConfigs == null || quotaConfigs.isEmpty()) { + logger.debug("No quota configurations found, skipping ResourceQuotaManager creation"); + return; + } + + // Create ResourceQuotaManager + org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository quotaRepo = + new org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository<>( + configuration.getWildcardConfiguration()); + + resourceQuotaManager = new ResourceQuotaManager(quotaRepo, configuration.getWildcardConfiguration()); + + // Create runtime ResourceQuota instances from configuration + for (java.util.Map.Entry entry : quotaConfigs.entrySet()) { + ResourceQuota runtimeQuota = entry.getValue().createRuntimeQuota(); + resourceQuotaManager.addQuota(entry.getKey(), runtimeQuota); + } + + // Establish parent relationships between runtime quota instances + resourceQuotaManager.establishParentRelationships(); + + // Register quotas in JMX + if (managementService != null) { + for (ResourceQuota quota : resourceQuotaManager.getAllQuotas()) { + try { + managementService.registerResourceQuota(quota); + } catch (Exception e) { + logger.warn("Failed to register resource quota '{}' in JMX: {}", quota.getName(), e.getMessage(), e); + } + } + } + } + + @Override + public ResourceQuotaManager getResourceQuotaManager() { + ensureInitialized(); + return resourceQuotaManager; + } + + @Override + public void checkAddressQuota(SimpleString address) throws Exception { + ResourceQuota quota = lookupQuota(address); + if (quota == null) { + return; + } + + if (!quota.canAddAddress()) { + throw new ActiveMQResourceQuotaExceededException( + "Address quota exceeded for quota '" + quota.getName() + + "': max addresses is " + quota.getMaxAddresses()); + } + } + + @Override + public void incrementAddressCount(SimpleString address) { + ResourceQuota quota = lookupQuota(address); + if (quota != null) { + quota.incrementAddressCount(); + addressQuotaMapping.put(address, quota.getName()); + } + } + + @Override + public void decrementAddressCount(SimpleString address) { + ResourceQuota quota = lookupQuota(address); + if (quota != null) { + quota.decrementAddressCount(); + addressQuotaMapping.remove(address); + } + } + + @Override + public void checkQueueQuota(SimpleString address) throws Exception { + ResourceQuota quota = lookupQuota(address); + if (quota == null) { + return; + } + + if (!quota.canAddQueue()) { + throw new ActiveMQResourceQuotaExceededException( + "Queue quota exceeded for quota '" + quota.getName() + + "': max queues is " + quota.getMaxQueues()); + } + } + + @Override + public void incrementQueueCount(SimpleString address) { + ResourceQuota quota = lookupQuota(address); + if (quota != null) { + quota.incrementQueueCount(); + } + } + + @Override + public void decrementQueueCount(SimpleString address) { + ResourceQuota quota = lookupQuota(address); + if (quota != null) { + quota.decrementQueueCount(); + } + } + + @Override + public ResourceQuota lookupQuota(SimpleString address) { + if (resourceQuotaManager == null) { + return null; + } + + AddressSettings settings = addressSettingsRepository.getMatch(address.toString()); + return resourceQuotaManager.getQuotaForAddress(address, settings); + } + + @Override + public void reloadQuotas() { + logger.debug("Reloading resource quotas from configuration"); + + java.util.Map newConfigs = + configuration.getResourceQuotas(); + + // Create manager if it doesn't exist and we have quotas to add + if (resourceQuotaManager == null && (newConfigs != null && !newConfigs.isEmpty())) { + org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository quotaRepo = + new org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository<>( + configuration.getWildcardConfiguration()); + resourceQuotaManager = new ResourceQuotaManager(quotaRepo, configuration.getWildcardConfiguration()); + } + + // If manager is null and no new quotas, nothing to do + if (resourceQuotaManager == null) { + logger.debug("No quotas configured and no existing quotas"); + return; + } + + // Get current quotas from manager + java.util.List currentQuotasList = resourceQuotaManager.getAllQuotas(); + + logger.debug("Found {} current quotas, new config has {} quotas", + currentQuotasList.size(), newConfigs == null ? 0 : newConfigs.size()); + + // Process removals - remove quotas that are no longer in config + java.util.Set removedQuotaNames = new java.util.HashSet<>(); + for (ResourceQuota quota : currentQuotasList) { + String name = quota.getName(); + if (newConfigs == null || !newConfigs.containsKey(name)) { + logger.debug("Removing quota: {}", name); + resourceQuotaManager.removeQuota(name); + removedQuotaNames.add(name); + } + } + + // Unregister removed quotas from JMX + if (managementService != null && !removedQuotaNames.isEmpty()) { + for (String quotaName : removedQuotaNames) { + try { + managementService.unregisterResourceQuota(quotaName); + } catch (Exception e) { + logger.warn("Failed to unregister resource quota '{}' from JMX: {}", quotaName, e.getMessage(), e); + } + } + } + + // Clear orphaned parent references - when a parent quota is removed, + // child quotas that referenced it should have their parent field cleared + if (!removedQuotaNames.isEmpty()) { + clearOrphanedParentReferences(removedQuotaNames); + } + + // Process additions and modifications + java.util.Map newQuotas = new java.util.HashMap<>(); + java.util.Set hierarchyChangedQuotas = new java.util.HashSet<>(); + + if (newConfigs != null) { + for (java.util.Map.Entry entry : newConfigs.entrySet()) { + String name = entry.getKey(); + org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig newConfig = entry.getValue(); + ResourceQuota currentQuota = resourceQuotaManager.getQuota(name); + + if (currentQuota == null) { + // New quota - add it, counters will be populated by scanning later + logger.debug("Adding new quota: {}", name); + ResourceQuota newQuota = newConfig.createRuntimeQuota(); + resourceQuotaManager.addQuota(name, newQuota); + newQuotas.put(name, newQuota); + + // Register new quota in JMX + if (managementService != null) { + try { + managementService.registerResourceQuota(newQuota); + } catch (Exception e) { + logger.warn("Failed to register resource quota '{}' in JMX: {}", name, e.getMessage(), e); + } + } + } else if (!configEquals(currentQuota, newConfig)) { + // Quota exists but config changed - update in place + logger.debug("Modifying quota: {}", name); + + // Check if hierarchy changed before updating + boolean hierarchyChanged = !java.util.Objects.equals( + currentQuota.getPartOf(), + newConfig.getPartOf() + ); + + // Update limits in place (preserves counters) + updateQuotaLimits(currentQuota, newConfig); + + if (hierarchyChanged) { + // Decrement this quota's contribution from the old parent chain + // BEFORE clearing the parent pointer. This preserves the child's + // own counters (no enforcement window) while correctly adjusting + // the old parent's aggregate counts. + ResourceQuota oldParent = currentQuota.getParent(); + if (oldParent != null) { + adjustParentChain(oldParent, + -currentQuota.getCurrentAddressCount(), + -currentQuota.getCurrentQueueCount(), + -currentQuota.getCurrentMessageBytes()); + } + currentQuota.setParent(null); + hierarchyChangedQuotas.add(name); + logger.debug("Quota {} hierarchy changed - parent chain adjusted in place", name); + } else { + logger.debug("Quota {} limits updated without counter rebuild", name); + } + } + } + + // Re-establish parent relationships for new/modified quotas + if (!newQuotas.isEmpty() || !hierarchyChangedQuotas.isEmpty()) { + resourceQuotaManager.establishParentRelationships(); + } + + // For hierarchy-changed quotas: increment the new parent chain + // using the child's current counter values (which were never zeroed) + for (String name : hierarchyChangedQuotas) { + ResourceQuota quota = resourceQuotaManager.getQuota(name); + if (quota != null) { + ResourceQuota newParent = quota.getParent(); + if (newParent != null) { + adjustParentChain(newParent, + quota.getCurrentAddressCount(), + quota.getCurrentQueueCount(), + quota.getCurrentMessageBytes()); + } + } + } + + // For wildcard templates whose hierarchy changed, also reparent + // their instantiated children. Instances live in a separate map + // and are not covered by establishParentRelationships(). + reparentWildcardInstances(hierarchyChangedQuotas, newConfigs); + + // For new quotas: scan existing addresses to populate initial counters + if (!newQuotas.isEmpty()) { + for (java.util.Map.Entry entry : newQuotas.entrySet()) { + populateCountersForNewQuota(entry.getKey(), entry.getValue()); + } + } + } + + // Rebalance address-to-quota mappings. Address-settings may have changed + // (swap happens before reloadQuotas), so existing addresses may now map + // to different quotas than what currently tracks them. + rebalanceAddressMappings(); + + logger.debug("Resource quota reload complete"); + } + + /** + * Update quota limits in place from configuration. + * This preserves existing counters while updating the limit values. + * + * @param quota the quota to update + * @param config the new configuration + */ + private void updateQuotaLimits(ResourceQuota quota, org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig config) { + quota.setMaxAddresses(config.getMaxAddresses() >= 0 ? config.getMaxAddresses() : null); + quota.setMaxQueues(config.getMaxQueues() >= 0 ? config.getMaxQueues() : null); + quota.setMaxMessageBytes(config.getMaxMessageBytes() >= 0 ? config.getMaxMessageBytes() : null); + quota.setPartOf(config.getPartOf()); + } + + /** + * Adjust counters on a parent chain without propagation. + * Used during hierarchy changes to move a child quota's contribution + * from the old parent chain to the new one, avoiding any counter reset. + * + * @param parent the starting parent quota in the chain + * @param addressDelta change in address count (negative to remove, positive to add) + * @param queueDelta change in queue count + * @param bytesDelta change in byte count + */ + private void adjustParentChain(ResourceQuota parent, int addressDelta, int queueDelta, long bytesDelta) { + while (parent != null) { + parent.adjustCountersDirect(addressDelta, queueDelta, bytesDelta); + parent = parent.getParent(); + } + } + + /** + * Reparent wildcard instances when their template's partOf changes. + * Instances live in a separate map from the quota repository and are not + * covered by establishParentRelationships(). When a wildcard template like + * "region.*" changes its partOf, all instances (e.g., "region.us", "region.eu") + * must also be moved to the new parent chain using delta adjustments. + */ + private void reparentWildcardInstances(java.util.Set hierarchyChangedQuotas, + java.util.Map newConfigs) { + if (resourceQuotaManager == null) { + return; + } + + java.util.Map instances = resourceQuotaManager.getInstantiatedQuotas(); + if (instances.isEmpty()) { + return; + } + + for (String templateName : hierarchyChangedQuotas) { + if (!templateName.contains("*")) { + continue; + } + + org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig newConfig = newConfigs.get(templateName); + if (newConfig == null) { + continue; + } + + String prefix = templateName.substring(0, templateName.indexOf('*')); + String newPartOf = newConfig.getPartOf(); + + // Resolve the new parent quota object + ResourceQuota newParentQuota = newPartOf != null ? resourceQuotaManager.getQuota(newPartOf) : null; + + for (java.util.Map.Entry instanceEntry : instances.entrySet()) { + if (!instanceEntry.getKey().startsWith(prefix)) { + continue; + } + + ResourceQuota instance = instanceEntry.getValue(); + + // Decrement from old parent chain + ResourceQuota oldParent = instance.getParent(); + if (oldParent != null) { + adjustParentChain(oldParent, + -instance.getCurrentAddressCount(), + -instance.getCurrentQueueCount(), + -instance.getCurrentMessageBytes()); + } + + // Update instance's partOf and parent + instance.setPartOf(newPartOf); + instance.setParent(newParentQuota); + + // Increment new parent chain + if (newParentQuota != null) { + adjustParentChain(newParentQuota, + instance.getCurrentAddressCount(), + instance.getCurrentQueueCount(), + instance.getCurrentMessageBytes()); + } + + logger.debug("Reparented wildcard instance '{}' from old parent to '{}'", + instanceEntry.getKey(), newPartOf); + } + } + } + + /** + * Clear orphaned parent references when parent quotas are removed. + * When a parent quota is removed, child quotas that referenced it should have + * their parent field cleared to avoid holding references to removed objects. + * + * @param removedQuotaNames names of quotas that were removed + */ + private void clearOrphanedParentReferences(java.util.Set removedQuotaNames) { + if (resourceQuotaManager == null) { + return; + } + + java.util.List allQuotas = resourceQuotaManager.getAllQuotas(); + for (ResourceQuota quota : allQuotas) { + ResourceQuota parent = quota.getParent(); + if (parent != null && removedQuotaNames.contains(parent.getName())) { + quota.setParent(null); + logger.warn("Cleared orphaned parent reference from quota '{}' to removed quota '{}'. " + + "The quota configuration still references part-of='{}', but the parent quota no longer exists.", + quota.getName(), parent.getName(), quota.getPartOf()); + } + } + } + + /** + * Check if a runtime quota matches the configuration. + * Used to detect modifications during reload. + */ + private boolean configEquals(ResourceQuota quota, org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig config) { + if (quota.getMaxAddresses() != config.getMaxAddresses()) { + return false; + } + if (quota.getMaxQueues() != config.getMaxQueues()) { + return false; + } + if (quota.getMaxMessageBytes() != config.getMaxMessageBytes()) { + return false; + } + return java.util.Objects.equals(quota.getPartOf(), config.getPartOf()); + } + + /** + * Populate counters for a newly added quota by scanning existing addresses, queues, and message bytes. + * Called only for new quotas that start with zero counters. + * Uses propagating increment methods so parent chain counts are updated automatically. + * + * @param quotaName the name of the quota being populated + * @param quota the quota instance (must have zero counters) + */ + private void populateCountersForNewQuota(String quotaName, ResourceQuota quota) { + if (postOffice == null) { + logger.warn("PostOffice not available, cannot populate counters for quota '{}'", quotaName); + return; + } + + int addressCount = 0; + int queueCount = 0; + long totalBytes = 0; + + java.util.Set addresses = postOffice.getAddresses(); + for (org.apache.activemq.artemis.api.core.SimpleString address : addresses) { + ResourceQuota addressQuota = lookupQuota(address); + if (addressQuota != null && addressQuota.getName().equals(quotaName)) { + quota.incrementAddressCount(); + addressQuotaMapping.put(address, quotaName); + addressCount++; + + if (pagingManager != null) { + try { + org.apache.activemq.artemis.core.paging.PagingStore pagingStore = + pagingManager.getPageStore(address); + if (pagingStore != null) { + long addressSize = pagingStore.getAddressSize(); + quota.addSize(addressSize); + totalBytes += addressSize; + } + } catch (Exception e) { + logger.warn("Error getting paging store size for address {}: {}", address, e.getMessage()); + } + } + + try { + java.util.List queuesForAddress = + postOffice.listQueuesForAddress(address); + for (org.apache.activemq.artemis.core.server.Queue queue : queuesForAddress) { + ResourceQuota queueQuota = lookupQuota(queue.getAddress()); + if (queueQuota != null && queueQuota.getName().equals(quotaName)) { + quota.incrementQueueCount(); + queueCount++; + } + } + } catch (Exception e) { + logger.warn("Error listing queues for address {} when populating quota counters: {}", address, e.getMessage()); + } + } + } + + logger.debug("Populated counters for new quota '{}': {} addresses, {} queues, {} bytes", + quotaName, addressCount, queueCount, totalBytes); + } + + /** + * Rebalance address-to-quota mappings after address-settings may have changed. + * For each tracked address, checks whether lookupQuota() (using updated address-settings) + * returns a different quota. If so, decrements the old quota's counters and increments the + * new quota's counters using propagating methods so parent chains are adjusted automatically. + */ + private void rebalanceAddressMappings() { + if (postOffice == null || resourceQuotaManager == null || addressQuotaMapping.isEmpty()) { + return; + } + + int rebalanced = 0; + for (java.util.Map.Entry entry : addressQuotaMapping.entrySet()) { + SimpleString address = entry.getKey(); + String oldQuotaName = entry.getValue(); + + ResourceQuota newQuota = lookupQuota(address); + String newQuotaName = newQuota != null ? newQuota.getName() : null; + + if (java.util.Objects.equals(oldQuotaName, newQuotaName)) { + continue; + } + + // Find the old quota object + ResourceQuota oldQuota = resourceQuotaManager.getQuota(oldQuotaName); + + // Count queues for this address + int queueCount = 0; + try { + java.util.List queues = + postOffice.listQueuesForAddress(address); + queueCount = queues.size(); + } catch (Exception e) { + logger.warn("Error listing queues for address {} during rebalance: {}", address, e.getMessage()); + } + + // Get bytes for this address + long bytes = 0; + if (pagingManager != null) { + try { + org.apache.activemq.artemis.core.paging.PagingStore pagingStore = + pagingManager.getPageStore(address); + if (pagingStore != null) { + bytes = pagingStore.getAddressSize(); + } + } catch (Exception e) { + logger.warn("Error getting paging store for address {} during rebalance: {}", address, e.getMessage()); + } + } + + // Decrement old quota (propagating to parent chain) + if (oldQuota != null) { + oldQuota.decrementAddressCount(); + for (int i = 0; i < queueCount; i++) { + oldQuota.decrementQueueCount(); + } + if (bytes > 0) { + oldQuota.addSize(-bytes); + } + } + + // Increment new quota (propagating to parent chain) + if (newQuota != null) { + newQuota.incrementAddressCount(); + for (int i = 0; i < queueCount; i++) { + newQuota.incrementQueueCount(); + } + if (bytes > 0) { + newQuota.addSize(bytes); + } + } + + // Update tracking + if (newQuotaName != null) { + entry.setValue(newQuotaName); + } else { + addressQuotaMapping.remove(address); + } + + rebalanced++; + logger.debug("Rebalanced address '{}' from quota '{}' to '{}'", address, oldQuotaName, newQuotaName); + } + + if (rebalanced > 0) { + logger.debug("Rebalanced {} address-to-quota mappings", rebalanced); + } + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/AddressSettings.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/AddressSettings.java index 1189d2849b1..da832bcfb39 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/AddressSettings.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/AddressSettings.java @@ -551,6 +551,11 @@ public class AddressSettings implements Mergeable, Serializable @Deprecated private transient Integer queuePrefetch = null; + static { + metaBean.add(String.class, "resourceQuota", (t, p) -> t.resourceQuota = p, t -> t.resourceQuota); + } + private String resourceQuota = null; + public AddressSettings(AddressSettings other) { metaBean.copy(other, this); } @@ -1295,6 +1300,15 @@ public AddressSettings setInitialQueueBufferSize(Integer initialQueueBufferSize) return this; } + public String getResourceQuota() { + return resourceQuota; + } + + public AddressSettings setResourceQuota(String resourceQuota) { + this.resourceQuota = resourceQuota; + return this; + } + /** * Merge two AddressSettings instances in one instance */ diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuota.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuota.java new file mode 100644 index 00000000000..ee13e6dd93c --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuota.java @@ -0,0 +1,560 @@ +/* + * 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.activemq.artemis.core.settings.impl; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.LongAdder; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.invoke.MethodHandles; + +/** + * Runtime resource quota tracker for hierarchical resource management. + *

+ * This class tracks live quota usage (counters) and enforces limits defined in {@link ResourceQuotaConfig}. + * ResourceQuota instances are NOT serializable - they are always rebuilt on broker restart by: + *

    + *
  1. Creating from ResourceQuotaConfig via {@link ResourceQuotaConfig#createRuntimeQuota()}
  2. + *
  3. Scanning existing addresses/queues to rebuild counters (during journal replay)
  4. + *
+ *

+ * Three types of limits are enforced: + *

    + *
  • max-message-bytes: Total bytes for messages across all addresses in this quota
  • + *
  • max-addresses: Maximum number of addresses in this quota
  • + *
  • max-queues: Maximum number of queues in this quota
  • + *
+ *

+ * Quotas can be organized in a parent-child hierarchy where child quotas count toward parent limits. + * Quotas support wildcard templates via {@link ResourceQuotaConfig} that create runtime instances + * on-demand when addresses match patterns. + * + * @see ResourceQuotaConfig for the configuration/limits definition + */ +public class ResourceQuota { + + private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + public static final long DEFAULT_MAX_MESSAGE_BYTES = -1; + public static final int DEFAULT_MAX_ADDRESSES = -1; + public static final int DEFAULT_MAX_QUEUES = -1; + + // Configuration (limits) - set from ResourceQuotaConfig + // volatile ensures changes during reload are visible to all threads + private final String name; + private volatile String partOf; + private volatile Long maxMessageBytes; + private volatile Integer maxAddresses; + private volatile Integer maxQueues; + + // Runtime state (counters, relationships) + // volatile for parent since it can be updated during reload/hierarchy changes + private volatile ResourceQuota parent; + // LongAdder stripes across cache lines — much faster than AtomicLong under + // contention from concurrent message sends propagating up a shared parent chain. + // sum() is eventually consistent, which is acceptable for best-effort quota enforcement. + private LongAdder sizeBytes; + private AtomicInteger addressCount; + private AtomicInteger queueCount; + + // ======================================================================== + // Constructor and Initialization + // ======================================================================== + + /** + * Create a runtime quota instance. Typically called via {@link ResourceQuotaConfig#createRuntimeQuota()}. + * Counters start at zero and are rebuilt by scanning existing addresses/queues. + * + * @param name the quota name + */ + public ResourceQuota(String name) { + this.name = name; + this.partOf = null; + this.maxMessageBytes = null; + this.maxAddresses = null; + this.maxQueues = null; + initializeRuntimeState(); + } + + /** + * Initialize transient runtime state (counters). + * Called on construction and lazily after deserialization via ensureInitialized(). + * This allows ResourceQuota instances to be serialized as configuration and + * automatically initialize runtime state when used. + */ + private void initializeRuntimeState() { + this.sizeBytes = new LongAdder(); + this.addressCount = new AtomicInteger(0); + this.queueCount = new AtomicInteger(0); + } + + // ======================================================================== + // Configuration Getters and Setters + // ======================================================================== + + public String getName() { + return name; + } + + public String getPartOf() { + return partOf; + } + + public ResourceQuota setPartOf(String partOf) { + this.partOf = partOf; + return this; + } + + public long getMaxMessageBytes() { + return maxMessageBytes != null ? maxMessageBytes : DEFAULT_MAX_MESSAGE_BYTES; + } + + public ResourceQuota setMaxMessageBytes(Long maxMessageBytes) { + this.maxMessageBytes = maxMessageBytes; + return this; + } + + public int getMaxAddresses() { + return maxAddresses != null ? maxAddresses : DEFAULT_MAX_ADDRESSES; + } + + public ResourceQuota setMaxAddresses(Integer maxAddresses) { + this.maxAddresses = maxAddresses; + return this; + } + + public int getMaxQueues() { + return maxQueues != null ? maxQueues : DEFAULT_MAX_QUEUES; + } + + public ResourceQuota setMaxQueues(Integer maxQueues) { + this.maxQueues = maxQueues; + return this; + } + + public ResourceQuota getParent() { + return parent; + } + + public void setParent(ResourceQuota parent) { + this.parent = parent; + } + + // ======================================================================== + // Byte Quota Operations + // ======================================================================== + + /** + * Add size delta to this quota and propagate to parent. + * + * @param delta size change in bytes (can be negative for decrements) + */ + public void addSize(long delta) { + ensureInitialized(); + sizeBytes.add(delta); + + // Propagate to parent + if (parent != null) { + parent.addSize(delta); + } + + if (logger.isDebugEnabled()) { + logger.debug("Quota {} size changed by {} to {}", name, delta, sizeBytes.sum()); + } + } + + /** + * Get current size in bytes tracked by this quota. + * Alias for getCurrentMessageBytes() for consistency with other getCurrentXXX methods. + */ + public long getSize() { + return getCurrentMessageBytes(); + } + + /** + * Get current message bytes tracked by this quota. + */ + public long getCurrentMessageBytes() { + ensureInitialized(); + return sizeBytes.sum(); + } + + // ======================================================================== + // Limit Checking Methods + // ======================================================================== + + /** + * Check if adding bytes would exceed limits. + * This is a non-modifying check - use addSize() after successfully routing the message. + * + * @param bytesToAdd the number of bytes to check + * @return true if adding the bytes would stay within limits, false if adding would exceed + */ + public boolean canAddBytes(long bytesToAdd) { + ensureInitialized(); + + if (parent != null && !parent.canAddBytes(bytesToAdd)) { + return false; + } + + Long limit = maxMessageBytes; + if (limit != null && limit >= 0) { + long currentSize = sizeBytes.sum(); + if ((currentSize + bytesToAdd) > limit) { + logger.debug("Quota {} byte limit {} would be exceeded: current {} + {} bytes", + name, limit, currentSize, bytesToAdd); + return false; + } + } + + return true; + } + + /** + * Check if byte limit is exceeded. + * Note: This is a reactive check. For proactive enforcement, use canAddBytes() before adding. + * + * @return true if current message bytes exceed maxMessageBytes + */ + public boolean isByteLimitReached() { + ensureInitialized(); + Long limit = maxMessageBytes; + return limit != null && limit >= 0 && sizeBytes.sum() > limit; + } + + /** + * Check if address limit is reached or exceeded. + * + * @return true if current address count is at or above maxAddresses + */ + public boolean isAddressLimitReached() { + Integer limit = maxAddresses; + return limit != null && limit >= 0 && addressCount.get() >= limit; + } + + /** + * Check if queue limit is reached or exceeded. + * + * @return true if current queue count is at or above maxQueues + */ + public boolean isQueueLimitReached() { + Integer limit = maxQueues; + return limit != null && limit >= 0 && queueCount.get() >= limit; + } + + /** + * Check if this quota has any limits configured. + * + * @return true if at least one limit (bytes, addresses, or queues) is configured + */ + public boolean hasLimits() { + Long bytesLimit = maxMessageBytes; + Integer addrLimit = maxAddresses; + Integer qLimit = maxQueues; + return (bytesLimit != null && bytesLimit >= 0) || + (addrLimit != null && addrLimit >= 0) || + (qLimit != null && qLimit >= 0); + } + + /** + * Get percentage of byte limit used. + * + * @return percentage (0-100) or -1 if no limit configured + */ + public double getByteUtilizationPercent() { + Long limit = maxMessageBytes; + if (limit == null || limit <= 0) { + return -1; + } + ensureInitialized(); + return (sizeBytes.sum() * 100.0) / limit; + } + + /** + * Get percentage of address limit used. + * + * @return percentage (0-100) or -1 if no limit configured + */ + public double getAddressUtilizationPercent() { + Integer limit = maxAddresses; + if (limit == null || limit <= 0) { + return -1; + } + ensureInitialized(); + return (addressCount.get() * 100.0) / limit; + } + + /** + * Get percentage of queue limit used. + * + * @return percentage (0-100) or -1 if no limit configured + */ + public double getQueueUtilizationPercent() { + Integer limit = maxQueues; + if (limit == null || limit <= 0) { + return -1; + } + ensureInitialized(); + return (queueCount.get() * 100.0) / limit; + } + + // ======================================================================== + // Address Counter Operations + // ======================================================================== + + /** + * Check if an address can be added without exceeding limits. + * This is a non-modifying check - use incrementAddressCount() after successful address creation. + * + * @return true if an address can be added, false if adding would exceed limit + */ + public boolean canAddAddress() { + ensureInitialized(); + + if (parent != null && !parent.canAddAddress()) { + return false; + } + + Integer limit = maxAddresses; + if (limit != null && limit >= 0) { + int current = addressCount.get(); + if (current >= limit) { + logger.debug("Quota {} address limit {} would be exceeded at count {}", name, limit, current); + return false; + } + } + + return true; + } + + /** + * Increment address count and propagate to parent. + * Should only be called after successful canAddAddress() check. + */ + public void incrementAddressCount() { + ensureInitialized(); + addressCount.incrementAndGet(); + if (parent != null) { + parent.incrementAddressCount(); + } + logger.debug("Quota {} address count incremented to {}", name, addressCount.get()); + } + + /** + * Decrement address count and propagate to parent + */ + public void decrementAddressCount() { + ensureInitialized(); + int current = addressCount.decrementAndGet(); + + // Always propagate to parent to maintain sync + if (parent != null) { + parent.decrementAddressCount(); + } + + // Log warning if negative, but don't reset - let it stay negative + // This keeps parent-child in sync and allows self-correction + if (current < 0) { + logger.debug("Quota {} address count is negative: {} - this indicates a double-decrement bug that should be fixed", name, current); + } else { + logger.debug("Quota {} address count decremented to {}", name, current); + } + } + + // ======================================================================== + // Queue Counter Operations + // ======================================================================== + + /** + * Check if a queue can be added without exceeding limits. + * This is a non-modifying check - use incrementQueueCount() after successful queue creation. + * + * @return true if a queue can be added, false if adding would exceed limit + */ + public boolean canAddQueue() { + ensureInitialized(); + + if (parent != null && !parent.canAddQueue()) { + return false; + } + + Integer limit = maxQueues; + if (limit != null && limit >= 0) { + int current = queueCount.get(); + if (current >= limit) { + logger.debug("Quota {} queue limit {} would be exceeded at count {}", name, limit, current); + return false; + } + } + + return true; + } + + /** + * Increment queue count and propagate to parent. + * Should only be called after successful canAddQueue() check. + */ + public void incrementQueueCount() { + ensureInitialized(); + queueCount.incrementAndGet(); + if (parent != null) { + parent.incrementQueueCount(); + } + logger.debug("Quota {} queue count incremented to {}", name, queueCount.get()); + } + + /** + * Decrement queue count and propagate to parent + */ + public void decrementQueueCount() { + ensureInitialized(); + int current = queueCount.decrementAndGet(); + + // Always propagate to parent to maintain sync + if (parent != null) { + parent.decrementQueueCount(); + } + + // Log warning if negative, but don't reset - let it stay negative + // This keeps parent-child in sync and allows self-correction + if (current < 0) { + logger.debug("Quota {} queue count is negative: {} - this indicates a double-decrement bug that should be fixed", name, current); + } else { + logger.debug("Quota {} queue count decremented to {}", name, current); + } + } + + /** + * Get current address count tracked by this quota. + */ + public int getCurrentAddressCount() { + ensureInitialized(); + return addressCount.get(); + } + + /** + * Alias for getCurrentAddressCount() for backward compatibility. + */ + public int getAddressCount() { + return getCurrentAddressCount(); + } + + /** + * Get current queue count tracked by this quota. + */ + public int getCurrentQueueCount() { + ensureInitialized(); + return queueCount.get(); + } + + /** + * Alias for getCurrentQueueCount() for backward compatibility. + */ + public int getQueueCount() { + return getCurrentQueueCount(); + } + + // ======================================================================== + // Lifecycle and Internal Methods + // ======================================================================== + + /** + * Adjust counters directly without propagating to parent quotas. + * Used during reload to adjust parent chain counters when a child quota's + * hierarchy changes, avoiding the need to reset and rebuild from scratch. + * + * @param addressDelta change in address count (can be negative) + * @param queueDelta change in queue count (can be negative) + * @param bytesDelta change in byte count (can be negative) + */ + public void adjustCountersDirect(int addressDelta, int queueDelta, long bytesDelta) { + ensureInitialized(); + addressCount.addAndGet(addressDelta); + queueCount.addAndGet(queueDelta); + sizeBytes.add(bytesDelta); + } + + /** + * Ensure runtime state is initialized (handles deserialization). + */ + private void ensureInitialized() { + if (sizeBytes == null) { + initializeRuntimeState(); + } + } + + // ======================================================================== + // Copy Method (for wildcard template instantiation) + // ======================================================================== + + /** + * Create a copy of this runtime quota for wildcard template instantiation. + * The copy has the same limits but fresh counters (starting at zero). + * This is used when a wildcard template (e.g., "region.*") creates a specific instance (e.g., "region.us"). + * Counters are NOT copied - new instance starts at zero and will be rebuilt by scanning. + */ + public ResourceQuota copy(String newName) { + ResourceQuota copy = new ResourceQuota(newName); + copy.maxMessageBytes = this.maxMessageBytes; + copy.maxAddresses = this.maxAddresses; + copy.maxQueues = this.maxQueues; + copy.partOf = this.partOf; + // Counters start at zero - will be rebuilt + return copy; + } + + // ======================================================================== + // Object Methods (equals, hashCode, toString) + // ======================================================================== + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ResourceQuota that)) { + return false; + } + return Objects.equals(name, that.name) && + Objects.equals(partOf, that.partOf) && + Objects.equals(maxMessageBytes, that.maxMessageBytes) && + Objects.equals(maxAddresses, that.maxAddresses) && + Objects.equals(maxQueues, that.maxQueues); + } + + @Override + public int hashCode() { + return Objects.hash(name, partOf, maxMessageBytes, maxAddresses, maxQueues); + } + + @Override + public String toString() { + return "ResourceQuota{" + + "name='" + name + '\'' + + ", partOf='" + partOf + '\'' + + ", maxMessageBytes=" + maxMessageBytes + + ", maxAddresses=" + maxAddresses + + ", maxQueues=" + maxQueues + + ", currentSize=" + (sizeBytes != null ? sizeBytes.sum() : 0) + + ", currentAddresses=" + (addressCount != null ? addressCount.get() : 0) + + ", currentQueues=" + (queueCount != null ? queueCount.get() : 0) + + '}'; + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuotaConfig.java b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuotaConfig.java new file mode 100644 index 00000000000..5be06fcb31c --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/core/settings/impl/ResourceQuotaConfig.java @@ -0,0 +1,195 @@ +/* + * 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.activemq.artemis.core.settings.impl; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Configuration for resource quota limits. + *

+ * This class defines the LIMITS for a quota (configuration) without any runtime state. + * It is serializable and stored in broker.xml. At runtime, ResourceQuota instances + * are created from these configs and track actual usage. + *

+ * Three types of limits can be configured: + *

    + *
  • max-message-bytes: Total bytes for messages across all addresses in this quota
  • + *
  • max-addresses: Maximum number of addresses in this quota
  • + *
  • max-queues: Maximum number of queues in this quota
  • + *
+ *

+ * Quotas can be organized in a parent-child hierarchy via the 'partOf' field. + * Child quota usage counts toward parent quota limits. + *

+ * Quotas support wildcard templates (e.g., "EU.*") that create runtime instances + * on-demand when addresses match patterns. + * + * @see ResourceQuota for the runtime tracking implementation + */ +public class ResourceQuotaConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final long DEFAULT_MAX_MESSAGE_BYTES = -1; + public static final int DEFAULT_MAX_ADDRESSES = -1; + public static final int DEFAULT_MAX_QUEUES = -1; + + private String name; + private String partOf; + private Long maxMessageBytes; + private Integer maxAddresses; + private Integer maxQueues; + + public ResourceQuotaConfig() { + this.name = null; + this.partOf = null; + this.maxMessageBytes = null; + this.maxAddresses = null; + this.maxQueues = null; + } + + public ResourceQuotaConfig(String name) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Quota name cannot be null or empty"); + } + this.name = name; + this.partOf = null; + this.maxMessageBytes = null; + this.maxAddresses = null; + this.maxQueues = null; + } + + public String getName() { + return name; + } + + public ResourceQuotaConfig setName(String name) { + this.name = name; + return this; + } + + public String getPartOf() { + return partOf; + } + + public ResourceQuotaConfig setPartOf(String partOf) { + this.partOf = partOf; + return this; + } + + public long getMaxMessageBytes() { + return maxMessageBytes != null ? maxMessageBytes : DEFAULT_MAX_MESSAGE_BYTES; + } + + public ResourceQuotaConfig setMaxMessageBytes(long maxMessageBytes) { + this.maxMessageBytes = maxMessageBytes; + return this; + } + + public int getMaxAddresses() { + return maxAddresses != null ? maxAddresses : DEFAULT_MAX_ADDRESSES; + } + + public ResourceQuotaConfig setMaxAddresses(int maxAddresses) { + this.maxAddresses = maxAddresses; + return this; + } + + public int getMaxQueues() { + return maxQueues != null ? maxQueues : DEFAULT_MAX_QUEUES; + } + + public ResourceQuotaConfig setMaxQueues(int maxQueues) { + this.maxQueues = maxQueues; + return this; + } + + /** + * Check if this config has any limits configured. + * + * @return true if at least one limit is configured + */ + public boolean hasLimits() { + return (maxMessageBytes != null && maxMessageBytes >= 0) || + (maxAddresses != null && maxAddresses >= 0) || + (maxQueues != null && maxQueues >= 0); + } + + /** + * Create a runtime ResourceQuota instance from this configuration. + * The runtime instance will have the same limits but fresh counters (starting at zero). + * + * @return new ResourceQuota instance with these limits and zero counters + */ + public ResourceQuota createRuntimeQuota() { + ResourceQuota runtime = new ResourceQuota(this.name); + runtime.setMaxMessageBytes(this.maxMessageBytes); + runtime.setMaxAddresses(this.maxAddresses); + runtime.setMaxQueues(this.maxQueues); + runtime.setPartOf(this.partOf); + return runtime; + } + + /** + * Create a copy of this config for wildcard template instantiation. + * For example, template "EU.*" with address "eu.fr.orders" creates config "EU.fr". + * + * @param newName the name for the new config instance + * @return copy with new name but same limits and partOf + */ + public ResourceQuotaConfig copy(String newName) { + ResourceQuotaConfig copy = new ResourceQuotaConfig(newName); + copy.maxMessageBytes = this.maxMessageBytes; + copy.maxAddresses = this.maxAddresses; + copy.maxQueues = this.maxQueues; + copy.partOf = this.partOf; + return copy; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ResourceQuotaConfig)) { + return false; + } + ResourceQuotaConfig that = (ResourceQuotaConfig) o; + return Objects.equals(name, that.name) && + Objects.equals(partOf, that.partOf) && + Objects.equals(maxMessageBytes, that.maxMessageBytes) && + Objects.equals(maxAddresses, that.maxAddresses) && + Objects.equals(maxQueues, that.maxQueues); + } + + @Override + public int hashCode() { + return Objects.hash(name, partOf, maxMessageBytes, maxAddresses, maxQueues); + } + + @Override + public String toString() { + return "ResourceQuotaConfig{" + + "name='" + name + '\'' + + ", partOf='" + partOf + '\'' + + ", maxMessageBytes=" + maxMessageBytes + + ", maxAddresses=" + maxAddresses + + ", maxQueues=" + maxQueues + + '}'; + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java index 1fa44d73ea4..d9ebe2199e1 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java @@ -374,7 +374,6 @@ public JWKSecurityContext currentContext() { * @param value value extracted from JWT. Strings are converted to one-element String arrays. Strings with whitespace * characters are first converted to multi-value tokens (but not Strings in actual arrays). * Null values are always treated as invalid. - * @param value an extracted value (can be null) * @param valid whether the JSON path successfully lead to actual value (String or String array) */ public record JWTStringArray(String[] value, boolean valid) { diff --git a/artemis-server/src/main/resources/schema/artemis-configuration.xsd b/artemis-server/src/main/resources/schema/artemis-configuration.xsd index cbb95bdc0ff..45c04d689da 100644 --- a/artemis-server/src/main/resources/schema/artemis-configuration.xsd +++ b/artemis-server/src/main/resources/schema/artemis-configuration.xsd @@ -1106,6 +1106,8 @@ + + @@ -4881,6 +4883,16 @@ + + + + The name of the resource quota to apply to addresses matching this pattern. + Resource quotas provide hierarchical limits on message bytes, address count, and queue count. + Can use wildcards (e.g., "EU.*") to match quota templates. + + + + @@ -4950,6 +4962,81 @@ + + + + + A list of resource quotas for hierarchical resource management. + Quotas can be organized in parent-child hierarchies and support wildcard templates. + + + + + + + + + + + + + + Complex type element to configure a resource quota. + Resource quotas provide hierarchical limits on message bytes, address count, and queue count. + Quotas can be organized in parent-child hierarchies where child quotas count toward parent limits. + + + + + + + Maximum total bytes for all messages across all addresses in this quota. + Supports size suffixes: B, K, M, G (e.g., "500M", "10G"). + -1 or not specified means no limit (default is -1). + + + + + + + + Maximum number of addresses that can be created in this quota (-1 means no limit, default is -1). + + + + + + + + Maximum number of queues that can be created in this quota (-1 means no limit, default is -1). + + + + + + + + The name of the parent quota. This quota's usage will count toward the parent's limits. + Creates a hierarchical quota structure (e.g., "EU.fr" part of "EU" part of "global"). + If not specified, this quota has no parent. + NOTE: The parent name must NOT contain wildcards (*). Wildcards are only allowed in the quota name attribute. + + + + + + + + + The unique name of this resource quota. + Can include wildcards (e.g., "EU.*") to create a template that dynamically instantiates + quota instances when addresses match patterns in address-settings. + + + + + + diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImplTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImplTest.java index ad129038f2f..61a39321519 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImplTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/config/impl/ConfigurationImplTest.java @@ -112,6 +112,7 @@ import org.apache.activemq.artemis.core.settings.impl.DeletionPolicy; import org.apache.activemq.artemis.core.settings.impl.DiskFullMessagePolicy; import org.apache.activemq.artemis.core.settings.impl.ResourceLimitSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; import org.apache.activemq.artemis.core.settings.impl.SlowConsumerThresholdMeasurementUnit; import org.apache.activemq.artemis.jdbc.store.drivers.JDBCDataSourceUtils; import org.apache.activemq.artemis.json.JsonObject; @@ -2043,6 +2044,75 @@ public void testAddressSettingsPageLimitInvalidConfiguration5() throws Throwable assertNull(storeImpl.getPageFullMessagePolicy()); } + @Test + public void testResourceQuotaViaProperties() throws Throwable { + ConfigurationImpl configuration = new ConfigurationImpl(); + + Properties properties = new Properties(); + + properties.put("resourceQuotas.test-quota.maxAddresses", "10"); + properties.put("resourceQuotas.test-quota.maxQueues", "20"); + properties.put("resourceQuotas.test-quota.maxMessageBytes", "1000000"); + properties.put("resourceQuotas.test-quota.partOf", "parent-quota"); + + properties.put("addressSettings.\"test.#\".resourceQuota", "test-quota"); + + configuration.parsePrefixedProperties(properties, null); + + assertEquals(1, configuration.getResourceQuotas().size()); + ResourceQuotaConfig quotaConfig = configuration.getResourceQuotas().get("test-quota"); + assertNotNull(quotaConfig); + assertEquals("test-quota", quotaConfig.getName()); + assertEquals(10, quotaConfig.getMaxAddresses()); + assertEquals(20, quotaConfig.getMaxQueues()); + assertEquals(1000000L, quotaConfig.getMaxMessageBytes()); + assertEquals("parent-quota", quotaConfig.getPartOf()); + + assertEquals(1, configuration.getAddressSettings().size()); + assertEquals("test-quota", configuration.getAddressSettings().get("test.#").getResourceQuota()); + } + + @Test + public void testResourceQuotaExportImport() throws Exception { + ConfigurationImpl configuration = new ConfigurationImpl(); + + ResourceQuotaConfig parentQuota = new ResourceQuotaConfig("parent-quota"); + parentQuota.setMaxAddresses(100); + parentQuota.setMaxQueues(200); + parentQuota.setMaxMessageBytes(5000000); + configuration.addResourceQuota("parent-quota", parentQuota); + + ResourceQuotaConfig childQuota = new ResourceQuotaConfig("child-quota"); + childQuota.setMaxAddresses(10); + childQuota.setMaxQueues(20); + childQuota.setPartOf("parent-quota"); + configuration.addResourceQuota("child-quota", childQuota); + + File exported = new File(getTestDirfile(), "broker_config_as_properties_export.txt"); + configuration.exportAsProperties(exported); + + ConfigurationImpl loadFromExport = new ConfigurationImpl(); + loadFromExport.parseFileProperties(exported); + + assertTrue(loadFromExport.getStatus().contains("\"errors\":[]"), loadFromExport.getStatus()); + + assertEquals(2, loadFromExport.getResourceQuotas().size()); + + ResourceQuotaConfig importedParent = loadFromExport.getResourceQuotas().get("parent-quota"); + assertNotNull(importedParent); + assertEquals("parent-quota", importedParent.getName()); + assertEquals(100, importedParent.getMaxAddresses()); + assertEquals(200, importedParent.getMaxQueues()); + assertEquals(5000000L, importedParent.getMaxMessageBytes()); + + ResourceQuotaConfig importedChild = loadFromExport.getResourceQuotas().get("child-quota"); + assertNotNull(importedChild); + assertEquals("child-quota", importedChild.getName()); + assertEquals(10, importedChild.getMaxAddresses()); + assertEquals(20, importedChild.getMaxQueues()); + assertEquals("parent-quota", importedChild.getPartOf()); + } + @Test public void testPagePrefetch() throws Throwable { ConfigurationImpl configuration = new ConfigurationImpl(); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/paging/cursor/impl/ConcurrentAckTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/paging/cursor/impl/ConcurrentAckTest.java index dbc634bc76d..d13784cb710 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/paging/cursor/impl/ConcurrentAckTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/paging/cursor/impl/ConcurrentAckTest.java @@ -34,6 +34,7 @@ import org.apache.activemq.artemis.core.paging.PagingStoreFactory; import org.apache.activemq.artemis.core.paging.impl.PagingStoreImpl; import org.apache.activemq.artemis.core.persistence.impl.nullpm.NullStorageManager; +import org.apache.activemq.artemis.core.server.Queue; import org.apache.activemq.artemis.core.settings.impl.AddressSettings; import org.apache.activemq.artemis.tests.util.ServerTestBase; import org.apache.activemq.artemis.utils.actors.ArtemisExecutor; @@ -63,6 +64,7 @@ private void testConcurrentAddAckPaging(ScheduledExecutorService scheduledExecut PageCursorProviderImpl pageCursorProvider = new PageCursorProviderImpl(store, new NullStorageManager()); PageSubscriptionImpl subscription = (PageSubscriptionImpl) pageCursorProvider.createSubscription(1, null, true); + subscription.setQueue(Mockito.mock(Queue.class));// necessary if debug level logging is enabled PageSubscriptionImpl.PageCursorInfo cursorInfo = subscription.getPageInfo(new PagePositionImpl(1, 1)); CountDownLatch done = new CountDownLatch(5); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImplTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImplTest.java index 446f03e15c5..211e5972eb5 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImplTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/postoffice/impl/PostOfficeImplTest.java @@ -36,6 +36,7 @@ import org.apache.activemq.artemis.core.server.impl.RoutingContextImpl; import org.apache.activemq.artemis.core.server.management.ManagementService; import org.apache.activemq.artemis.core.server.mirror.MirrorController; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; import org.apache.activemq.artemis.core.settings.HierarchicalRepository; import org.apache.activemq.artemis.core.settings.impl.AddressSettings; import org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository; @@ -353,12 +354,13 @@ public void testProcessRouteDoesNotCallMirrorControllerWhenMessageDropped() thro PagingStore pagingStore = mock(PagingStore.class); MirrorController mirrorController = mock(MirrorController.class); ManagementService managementService = mock(ManagementService.class); + ResourceQuotaService resourceQuotaService = mock(ResourceQuotaService.class); QueueFactory queueFactory = mock(QueueFactory.class); WildcardConfiguration wildcardConfiguration = new WildcardConfiguration(); HierarchicalRepository hierarchicalRepository = new HierarchicalObjectRepository<>(); PostOfficeImpl postOffice = new PostOfficeImpl(server, storageManager, pagingManager, queueFactory, - managementService, 100, 100, + managementService, resourceQuotaService, 100, 100, wildcardConfiguration, -1, false, hierarchicalRepository).setMirrorControlSource(mirrorController); SimpleString address = RandomUtil.randomUUIDSimpleString(); @@ -414,12 +416,13 @@ public void testMirrorDisablePropagation() throws Exception { PagingStore pagingStore = mock(PagingStore.class); MirrorController mirrorController = mock(MirrorController.class); ManagementService managementService = mock(ManagementService.class); + ResourceQuotaService resourceQuotaService = mock(ResourceQuotaService.class); QueueFactory queueFactory = mock(QueueFactory.class); WildcardConfiguration wildcardConfiguration = new WildcardConfiguration().setRoutingEnabled(false); HierarchicalRepository hierarchicalRepository = new HierarchicalObjectRepository<>(); PostOfficeImpl postOffice = new PostOfficeImpl(server, storageManager, pagingManager, queueFactory, - managementService, 100, 100, + managementService, resourceQuotaService, 100, 100, wildcardConfiguration, -1, false, hierarchicalRepository); diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/group/impl/ClusteredResetMockTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/group/impl/ClusteredResetMockTest.java index 2858ddb278b..cf966424bf6 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/group/impl/ClusteredResetMockTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/group/impl/ClusteredResetMockTest.java @@ -355,6 +355,16 @@ public void unregisterAddress(SimpleString address) throws Exception { } + @Override + public void registerResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota resourceQuota) throws Exception { + + } + + @Override + public void unregisterResourceQuota(String quotaName) throws Exception { + + } + @Override public void registerQueue(Queue queue, SimpleString address, StorageManager storageManager) throws Exception { diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisRbacMBeanServerBuilderTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisRbacMBeanServerBuilderTest.java index c6431b7120a..fab06b79b27 100644 --- a/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisRbacMBeanServerBuilderTest.java +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/core/server/management/ArtemisRbacMBeanServerBuilderTest.java @@ -158,6 +158,17 @@ public void testRbacAddressFrom() throws Exception { assertNotNull(rbacAddress); assertEquals(0, rbacAddress.compareTo(SimpleString.of("jmx.divert.d.opOnDivert"))); + // resource quota + attrs.clear(); + + attrs.put("broker", "bb"); + attrs.put("component", "quotas"); + attrs.put("name", "myQuota"); + + rbacAddress = handler.addressFrom(new ObjectName("a.b", attrs), "getMaxAddresses"); + assertNotNull(rbacAddress); + assertEquals(0, rbacAddress.compareTo(SimpleString.of("jmx.quotas.myQuota.getMaxAddresses"))); + } @Test @@ -835,4 +846,65 @@ public void testCanInvokeStripsParameterList() throws Exception { assertEquals("deleteAddress()", cd.get("Method")); assertEquals(true, cd.get("CanInvoke")); } + + @Test + public void testPermissionResourceQuota() throws Exception { + + MBeanServer proxy = underTest.newMBeanServer("d", mbeanServer, mBeanServerDelegate); + + final ActiveMQServer server = createServer(false); + server.setMBeanServer(proxy); + server.getConfiguration().setJMXManagementEnabled(true).setSecurityEnabled(true); + + // Configure a resource quota + org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig globalQuota = + new org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig("global"); + globalQuota.setMaxAddresses(100); + globalQuota.setMaxQueues(200); + server.getConfiguration().addResourceQuota("global", globalQuota); + + Set roles = new HashSet<>(); + roles.add(new Role("viewers", false, false, false, false, false, false, false, false, false, false, true, false)); + server.getConfiguration().putSecurityRoles("mops.quotas.global.getMaxAddresses", roles); + + server.start(); + + try { + ObjectName quotaObjectName = ObjectNameBuilder.DEFAULT.getResourceQuotaObjectName("global"); + final org.apache.activemq.artemis.api.core.management.ResourceQuotaControl quotaControl = JMX.newMBeanProxy( + proxy, quotaObjectName, org.apache.activemq.artemis.api.core.management.ResourceQuotaControl.class, false); + + Subject viewSubject = new Subject(); + viewSubject.getPrincipals().add(new UserPrincipal("v")); + viewSubject.getPrincipals().add(new RolePrincipal("viewers")); + + Object ret = SecurityManagerShim.callAs(viewSubject, (Callable) () -> { + try { + return quotaControl.getMaxAddresses(); + } catch (Exception e1) { + return e1; + } + }); + assertNotNull(ret); + assertInstanceOf(Integer.class, ret); + assertEquals(100, (Integer) ret); + + // verify failure case - no permission for getMaxQueues + Subject noPermSubject = new Subject(); + noPermSubject.getPrincipals().add(new UserPrincipal("dud")); + noPermSubject.getPrincipals().add(new RolePrincipal("dud")); + + ret = SecurityManagerShim.callAs(noPermSubject, (Callable) () -> { + try { + return quotaControl.getMaxAddresses(); + } catch (Exception e1) { + return e1; + } + }); + assertNotNull(ret); + assertInstanceOf(SecurityException.class, ret); + } finally { + server.stop(); + } + } } diff --git a/docs/user-manual/management.adoc b/docs/user-manual/management.adoc index 111bba26f9a..a963bd412d4 100644 --- a/docs/user-manual/management.adoc +++ b/docs/user-manual/management.adoc @@ -217,6 +217,19 @@ Broadcast groups parameters can be retrieved using the `BroadcastGroupControl` a They can be started or stopped using the `start()` or `stop()` method on the `ClusterConnectionControl` interface. Cluster connections parameters can be retrieved using the `ClusterConnectionControl` attributes (see xref:clusters.adoc#clusters[Clusters]) +* Resource quotas ++ +Resource quotas can be monitored using the `ResourceQuotaControl` interface. +Resource quota parameters can be retrieved using the `ResourceQuotaControl` attributes, including current usage counts, configured limits, and utilization percentages for addresses, queues, and message bytes. +The `ObjectName` for a resource quota named `myQuota` is: ++ +---- +org.apache.activemq.artemis:broker=,component=quotas,name="myQuota" +---- ++ +Resource quotas support hierarchical organization where child quotas count toward parent limits. +Unlike other resources, quotas cannot be started or stopped via JMX; they are read-only monitoring interfaces. + == Management Via JMX The broker can be managed using http://www.oracle.com/technetwork/java/javase/tech/javamanagement-140525.html[JMX]. diff --git a/docs/user-manual/resource-quotas.md b/docs/user-manual/resource-quotas.md new file mode 100644 index 00000000000..3a483037085 --- /dev/null +++ b/docs/user-manual/resource-quotas.md @@ -0,0 +1,338 @@ +# Resource Quotas + +## Overview + +Resource quotas provide hierarchical limits on broker resources to prevent any single tenant, region, or application from consuming excessive broker capacity. Unlike global limits which apply to the entire broker, or address settings that constrain matched addresses, resource quotas allow control over resource consumption across arbitrary groups of addresses. + +Resource quotas can limit three types of resources: +- **Message bytes**: Total memory or disk consumed by messages across all addresses in the quota +- **Address count**: Maximum number of addresses that can be created within the quota +- **Queue count**: Maximum number of queues that can be created within the quota + +## Key Features + +### Hierarchical Quotas + +Quotas can be organized in parent-child hierarchies where child quota usage counts toward parent limits. This allows modeling organizational structures like: +- Enterprise → Region → Tenant +- Department → Team → Application +- Global → Geography → Customer + +Child quotas are constrained by both their own limits AND their parent's limits, enforcing quotas at multiple organizational levels simultaneously. + +### Wildcard Templates + +Quota templates with wildcards automatically create quota instances when addresses match the pattern. For example: +- Template `EU.*` with address `eu.fr.orders` creates instance `EU.fr` +- Template `tenant.*` with address `tenant.acme.data` creates instance `tenant.acme` + +Instances inherit the template's limits and parent relationships, allowing dynamic multi-tenant configurations. + +### Integration with Address Settings + +Quotas are applied through address-settings, allowing arbitrary address patterns to participate in shared quota schemes. An address can only belong to one quota, determined by its matching address-settings entry. + +## Configuration + +### Defining Resource Quotas + +Resource quotas are defined in the `` section of `broker.xml`: + +```xml + + + + + + 10G + 1000 + 5000 + + + + + 2G + 200 + 1000 + EUROPE + + + + + 500M + 50 + 100 + EUROPE + + + + +``` + +### Configuration Elements + +#### `` +Defines a quota with a unique name. The name can contain wildcards (`*`) for template instantiation. + +**Attributes:** +- `name` (required): Unique identifier for the quota + +**Child Elements:** +- ``: Maximum total bytes for messages (supports K, M, G suffixes). Default: unlimited (-1) +- ``: Maximum number of addresses. Default: unlimited (-1) +- ``: Maximum number of queues. Default: unlimited (-1) +- ``: Parent quota name for hierarchical enforcement. Must reference a statically-defined, non-wildcard quota name (not a dynamically-instantiated wildcard instance). Optional. + +### Assigning Quotas to Addresses + +Reference quotas from address-settings using the `` element: + +```xml + + + + EU.* + + + + + tenant.acme + + + + + US.* + + +``` + +## How It Works + +### Quota Enforcement + +Resource quotas enforce limits on three resource types using a consistent proactive check-then-increment pattern: + +1. **Address creation**: Before creating an address, the broker checks if the address count quota would be exceeded (checking child and all parents in the hierarchy). If the limit would be exceeded, an exception is thrown and the address is not created. After successful address creation, the address count is incremented in the quota and all parent quotas. + +2. **Queue creation**: Before creating a queue, the broker checks if the queue count quota would be exceeded (checking child and all parents). If the limit would be exceeded, an exception is thrown and the queue is not created. After successful queue creation, the queue count is incremented in the quota and all parent quotas. + +3. **Message bytes**: Before routing a message, the broker checks if adding the message's size would exceed the byte quota (checking child and all parents). If the limit would be exceeded, an exception is thrown and the message is rejected. Message byte counters are updated as messages flow through the paging and queue subsystems. + +All quota types follow the same enforcement pattern: +- **Check before**: Before allocating resources, check if the operation would exceed quota limits +- **Increment after success**: Only after successful completion, increment the counters + +Address and queue count limits are enforced precisely using atomic counters. Byte limits use striped counters (`LongAdder`) for high-throughput message paths, which means enforcement is best-effort under heavy concurrent load — a small transient overshoot is possible before the limit takes effect. + +### Hierarchical Enforcement + +When checking limits, quotas walk up the parent chain: + +1. Check if child quota limit would be exceeded +2. Check if parent quota limit would be exceeded (recursively up the chain) +3. If any ancestor would be exceeded, reject the operation +4. Otherwise, increment counters in child and all ancestors + +This ensures that parent limits are respected even when child limits are generous. + +### Wildcard Template Resolution + +When an address operation (creation, queue creation, or message send) triggers a quota lookup with a wildcard quota reference: + +1. Extract the wildcard value from the address name (e.g., `eu.fr.orders` → `fr`) +2. Create instance name by substituting wildcard (e.g., `EU.*` → `EU.fr`) +3. Check if instance already exists; if not, create from template +4. Establish parent relationship if template has `` +5. Return the quota instance for enforcement + +## Behavior on Limit Exceeded + +When a quota limit is exceeded, the broker throws `ActiveMQResourceQuotaExceededException`: + +- **Address creation**: Address creation fails with quota exception +- **Queue creation**: Queue creation fails with quota exception +- **Message routing**: Message is rejected at send time (before entering the broker) + +Clients receive the exception and can handle it (retry, route elsewhere, alert, etc). + +## Counter Rebuild on Restart + +Resource quota counters are **not persisted**. After broker restart: + +1. Quota configurations are loaded from `broker.xml` +2. Quota instances are created with zero counters +3. Parent relationships are established +4. On first access, the broker scans existing addresses and queues via the PostOffice +5. Counters are rebuilt by incrementing for each discovered resource (propagating to parent chains) +6. Broker continues with accurate counts + +## Live Reload + +Resource quotas are reloaded automatically when `broker.xml` is modified (as part of the broker's standard configuration reload). During reload: + +- New quotas are added and their counters populated by scanning existing addresses +- Removed quotas are unregistered and their counters subtracted from parent chains +- Modified quotas have their limits updated in place, preserving existing counters +- Hierarchy changes (modified `part-of`) are handled by adjusting parent chain counters without resetting child counters +- Address-to-quota mappings are rebalanced when address-settings changes cause addresses to map to different quotas + +## JMX Management + +Each resource quota is registered as a JMX MBean exposing the following attributes: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `Name` | String | Quota name | +| `PartOf` | String | Parent quota name, or null | +| `MaxAddresses` | int | Address limit (-1 if unlimited) | +| `CurrentAddressCount` | int | Current address count | +| `AddressUtilizationPercent` | double | Percentage of address limit used | +| `MaxQueues` | int | Queue limit (-1 if unlimited) | +| `CurrentQueueCount` | int | Current queue count | +| `QueueUtilizationPercent` | double | Percentage of queue limit used | +| `MaxMessageBytes` | long | Byte limit (-1 if unlimited) | +| `CurrentMessageBytes` | long | Current byte count | +| `MessageBytesUtilizationPercent` | double | Percentage of byte limit used | +| `HasLimits` | boolean | Whether any limit is configured | +| `LimitReached` | boolean | Whether any limit is currently reached | + +### Logging + +Quota operations are logged at DEBUG level: +- Quota instance creation from templates +- Counter increments/decrements +- Limit checks and rejections +- Parent relationship establishment + +Enable DEBUG logging for `org.apache.activemq.artemis.core.server.quota` and `org.apache.activemq.artemis.core.settings.impl.ResourceQuota` to trace quota activity. + +## Best Practices + +- **Size parent limits to accommodate children**: A parent quota's limits should be at least the sum of its children's expected usage, since all child counters propagate upward. +- **Use wildcard templates for dynamic tenants**: Rather than defining a quota per tenant, use a template like `tenant.*` so new tenants get quota instances automatically. +- **Avoid referencing wildcard instances in `part-of`**: The `part-of` field must reference a statically-defined quota. Dynamically-instantiated wildcard instances (e.g., `EU.fr` from template `EU.*`) cannot be used as parents. +- **Set limits on the dimensions you care about**: Omitted limits default to unlimited (-1). Only configure the resource types you need to constrain. +- **Monitor via JMX**: Use the utilization percentage attributes to set up alerts before quotas are fully consumed. + +## Examples + +### Multi-Tenant SaaS Platform + +```xml + + + + 50G + 10000 + 50000 + + + + + 5G + 1000 + 5000 + TENANTS + + + + + + tenant.* + + +``` + +### Geographic Segmentation + +```xml + + + + 20G + + + + 15G + + + + 10G + + + + + 5G + AMERICAS + + + + 3G + EUROPE + + + + + + US.* + + + + EU.* + + +``` + +### Department Quotas + +```xml + + + + 10G + 1000 + + + + 5G + 500 + + + + 2G + 200 + + + + + + dept.engineering + + + + dept.sales + + + + dept.support + + +``` + +## Relationship to Other Limits + +Resource quotas operate independently of the broker's other limiting mechanisms. An address can be subject to all of them simultaneously — when multiple limits apply, the strictest one determines the outcome. + +| Mechanism | Scope | What it limits | How it differs from resource quotas | +|-----------|-------|----------------|--------------------------------------| +| `global-max-size` | Whole broker | Total message memory across all addresses | A single broker-wide ceiling; resource quotas partition usage across groups | +| `max-size-bytes` (address-setting) | Per address (or wildcard match) | Message bytes on individual addresses | Limits each address independently; resource quotas aggregate across addresses | +| `resource-limit-settings` | Per user | Sessions and queues created by a specific user | Tied to user identity; resource quotas are tied to address naming, regardless of which user sends | +| **Resource quotas** | Arbitrary address groups | Bytes, addresses, and queues across grouped addresses | Hierarchical, cross-address enforcement by address naming convention | + +For example, an address `eu.fr.orders` might be constrained by: +- Its resource quota `EU.fr` (max 2G across all `eu.fr.*` addresses) +- Its parent quota `EUROPE` (max 10G across all European addresses) +- Its address-setting `max-size-bytes` (max 500M on this specific address) +- The broker's `global-max-size` (max 30G broker-wide) +- A `resource-limit-settings` entry for the connected user (max 100 queues per user) \ No newline at end of file diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/persistence/DeleteMessagesOnStartupTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/persistence/DeleteMessagesOnStartupTest.java index 6a3e3e1fe54..02564a68007 100644 --- a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/persistence/DeleteMessagesOnStartupTest.java +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/persistence/DeleteMessagesOnStartupTest.java @@ -82,7 +82,7 @@ public void testDeleteMessagesOnStartup() throws Exception { FakePostOffice postOffice = new FakePostOffice(); - journal.loadMessageJournal(postOffice, null, null, null, null, null, null, null, new PostOfficeJournalLoader(postOffice, null, journal, null, null, null, null, null, queues)); + journal.loadMessageJournal(postOffice, null, null, null, null, null, null, null, new PostOfficeJournalLoader(postOffice, null, journal, null, null, null, null, null, null, queues)); assertEquals(98, deletedMessage.size()); diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ByteQuotaWithPagingTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ByteQuotaWithPagingTest.java new file mode 100644 index 00000000000..c6701ca51e1 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ByteQuotaWithPagingTest.java @@ -0,0 +1,537 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientConsumer; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.Queue; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.artemis.tests.util.Wait; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that byte quota is enforced consistently regardless of whether + * addresses are paging or not. The quota provides a hard bound on broker + * resource usage for message storage. + */ +public class ByteQuotaWithPagingTest extends ActiveMQTestBase { + + private static final int MESSAGE_SIZE = 1024; // 1KB message body + // Actual persistent size includes headers, properties, and encoding overhead (~34 bytes) + private static final int MESSAGE_SIZE_ON_BROKER = 1084 + 72; // Actual size tracked in quota + + private static final long QUOTA_LIMIT = 15 * MESSAGE_SIZE_ON_BROKER; // 10KB quota + + @Test + public void testByteQuotaEnforcedWhenAddressIsPaging() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled for paging + + // Create quota with 10KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(QUOTA_LIMIT); + config.addResourceQuota("test-quota", quotaConfig); + + // Configure address to page after 5KB (half the quota) + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(5 * 1024L); // Page after 5KB + settings.setPageSizeBytes(2 * 1024); // 2KB pages + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("paging.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("paging.test"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("paging.queue").setAddress(address)); + + ClientProducer producer = session.createProducer(address); + + // Send messages until quota exceeded + // Should be enforced at ~10KB regardless of paging + boolean quotaExceeded = false; + int messagesSent = 0; + + for (int i = 0; i < 20; i++) { + try { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[MESSAGE_SIZE]); + producer.send(message); + messagesSent++; + } catch (ActiveMQException e) { + if (e instanceof ActiveMQResourceQuotaExceededException && e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + break; + } + } + } + + assertTrue(quotaExceeded, + "Byte quota should be enforced even when address is paging. Sent " + messagesSent + " messages without quota exception!"); + + // Allow some overhead for message headers + assertTrue(messagesSent >= 8 && messagesSent <= 19, + "Expected ~10 messages before quota (10KB / 1KB), but sent " + messagesSent); + + // Verify the address actually started paging + Queue queue = server.locateQueue(SimpleString.of("paging.queue")); + assertNotNull(queue); + assertTrue(queue.getPageSubscription().getPagingStore().isPaging(), + "Address should have started paging at 5KB (before quota limit at 10KB)"); + + // Verify quota tracking + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + long currentBytes = quota.getCurrentMessageBytes(); + assertTrue(currentBytes >= QUOTA_LIMIT * 0.9 && currentBytes <= QUOTA_LIMIT * 1.1, + "Quota should track ~10KB, but tracking " + currentBytes + " bytes"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testByteQuotaConsistentAcrossPagingAndNonPaging() throws Exception { + Configuration config = createDefaultConfig(true); + + // Create quota with 20KB byte limit shared across two addresses + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("shared-quota"); + quotaConfig.setMaxMessageBytes(20 * 1024L); // 20KB total + config.addResourceQuota("shared-quota", quotaConfig); + + // Configure one address to page aggressively (paging.*) + AddressSettings pagingSettings = new AddressSettings(); + pagingSettings.setResourceQuota("shared-quota"); + pagingSettings.setMaxSizeBytes(2 * 1024L); // Page after just 2KB + pagingSettings.setPageSizeBytes(1024); + pagingSettings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("paging.#", pagingSettings); + + // Configure another address to never page (memory.*) + AddressSettings memorySettings = new AddressSettings(); + memorySettings.setResourceQuota("shared-quota"); + memorySettings.setMaxSizeBytes(-1L); // Never page locally + memorySettings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("memory.#", memorySettings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString pagingAddr = SimpleString.of("paging.address"); + SimpleString memoryAddr = SimpleString.of("memory.address"); + + session.createAddress(pagingAddr, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("paging.queue").setAddress(pagingAddr)); + + session.createAddress(memoryAddr, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("memory.queue").setAddress(memoryAddr)); + + ClientProducer pagingProducer = session.createProducer(pagingAddr); + ClientProducer memoryProducer = session.createProducer(memoryAddr); + + // Send 10KB to paging address (will page) + for (int i = 0; i < 10; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); + pagingProducer.send(message); + } + + // Verify paging started + Queue pagingQueue = server.locateQueue(SimpleString.of("paging.queue")); + assertTrue(pagingQueue.getPageSubscription().getPagingStore().isPaging(), + "Paging address should be paging"); + + // Try to send 10KB more to memory address + // Should hit quota at ~20KB total across both addresses + boolean quotaExceeded = false; + int memoryMessagesSent = 0; + + for (int i = 0; i < 15; i++) { + try { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); + memoryProducer.send(message); + memoryMessagesSent++; + } catch (ActiveMQResourceQuotaExceededException e) { + quotaExceeded = true; + break; + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + break; + } + throw e; + } + } + + assertTrue(quotaExceeded, + "Quota should be enforced consistently - sent 10KB to paging address + " + memoryMessagesSent + + "KB to memory address without hitting 20KB quota!"); + + // Verify total is ~20KB (allow overhead) + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("shared-quota"); + long totalBytes = quota.getCurrentMessageBytes(); + + assertTrue(totalBytes >= 18 * 1024 && totalBytes <= 22 * 1024, + "Expected ~20KB total across paging and non-paging addresses, but got " + totalBytes + " bytes"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testByteQuotaWithLargeMessages() throws Exception { + Configuration config = createDefaultConfig(true); + + // Create quota with 50KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("large-msg-quota"); + quotaConfig.setMaxMessageBytes(50 * 1024L); + config.addResourceQuota("large-msg-quota", quotaConfig); + + // Configure to page, with large message threshold at 10KB + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("large-msg-quota"); + settings.setMaxSizeBytes(30 * 1024L); // Page after 30KB + settings.setPageSizeBytes(10 * 1024); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("large.#", settings); + + // Set min large message size to 10KB + config.setJournalFileSize(1024 * 1024); // 1MB journal files + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setMinLargeMessageSize(10 * 1024); // 10KB threshold for large messages + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("large.messages"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("large.messages").setAddress(address)); + + ClientProducer producer = session.createProducer(address); + + // Send large messages (15KB each) until quota exceeded + boolean quotaExceeded = false; + int largeMessagesSent = 0; + + for (int i = 0; i < 10; i++) { + try { + ClientMessage message = session.createMessage(true); + // 15KB message - should be treated as large message + message.getBodyBuffer().writeBytes(new byte[15 * 1024]); + producer.send(message); + largeMessagesSent++; + } catch (ActiveMQException e) { + if (e.getCause() instanceof ActiveMQResourceQuotaExceededException && e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + break; + } + throw e; + } + } + + // Get quota for verification + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("large-msg-quota"); + + assertTrue(quotaExceeded, + "Byte quota should enforce limit for large messages. Sent " + largeMessagesSent + " large messages without quota exception!"); + + // Should send ~3 large messages (50KB quota / 15KB per message) + assertTrue(largeMessagesSent >= 2 && largeMessagesSent <= 4, + "Expected ~3 large messages before quota (50KB / 15KB), but sent " + largeMessagesSent); + + // Verify quota tracking for large messages + long currentBytes = quota.getCurrentMessageBytes(); + + // Each large message is ~15KB body + ~800 bytes headers = ~16KB total + // With 50KB quota, we can send 3 messages (48KB), but the test stops after 2 messages when the 3rd is rejected + // So we expect ~32KB (2 * 16KB) + long expectedBytes = largeMessagesSent * 16 * 1024L; + assertTrue(currentBytes >= expectedBytes * 0.9 && currentBytes <= expectedBytes * 1.1, + "Quota should track ~" + (expectedBytes / 1024) + "KB for " + largeMessagesSent + " large messages, but tracking " + currentBytes + " bytes"); + + // Verify the address is not paging, memory usage below the quota, quota which includes large message content is reached earlier + Queue queue = server.locateQueue(address); + assertNotNull(queue); + assertFalse(queue.getPageSubscription().getPagingStore().isPaging(), "Address should not have started paging"); + + ClientConsumer consumer = session.createConsumer(address); + session.start(); + + // Consume messages to free up quota space + int messagesToConsume = largeMessagesSent; + for (int i = 0; i < messagesToConsume; i++) { + ClientMessage received = consumer.receive(5000); + assertNotNull(received, "Should receive message " + i); + received.acknowledge(); + session.commit(); + } + + // Wait for quota to be updated after consumption + Wait.assertTrue(() -> quota.getCurrentMessageBytes() == 0, + 2000, 100); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testByteQuotaDecrementsWhenConsumingPagedMessages() throws Exception { + Configuration config = createDefaultConfig(true); + + // Create quota with 15KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("consume-quota"); + quotaConfig.setMaxMessageBytes(15 * 1024L); + config.addResourceQuota("consume-quota", quotaConfig); + + // Configure to page aggressively + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("consume-quota"); + settings.setMaxSizeBytes(5 * 1024L); // Page after 5KB + settings.setPageSizeBytes(2 * 1024); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("consume.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); // Required to get exception response for durable sends + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, false); // Auto-commit sends, manual ack + + SimpleString address = SimpleString.of("consume.test"); + SimpleString queueName = SimpleString.of("consume.queue"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of(queueName).setAddress(address)); + + ClientProducer producer = session.createProducer(address); + + // Fill quota to ~15KB + int messagesSent = 0; + for (int i = 0; i < 20; i++) { + try { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); + producer.send(message); + messagesSent++; + } catch (ActiveMQException e) { + if (e instanceof ActiveMQResourceQuotaExceededException || + e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + break; + } + throw e; + } + } + + assertTrue(messagesSent >= 12 && messagesSent <= 17, + "Should have sent ~15 messages before quota, but sent " + messagesSent); + + // Verify quota is at limit + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("consume-quota"); + long bytesBeforeConsume = quota.getCurrentMessageBytes(); + + assertTrue(bytesBeforeConsume >= 13 * 1024, + "Quota should be near limit before consuming"); + + // Now consume and ack some messages + ClientConsumer consumer = session.createConsumer(queueName); + session.start(); + + // Consume some messages to free up quota space + int messagesToConsume = 6; + for (int i = 0; i < messagesToConsume; i++) { + ClientMessage received = consumer.receive(5000); + assertNotNull(received, "Should receive message " + i); + received.acknowledge(); + session.commit(); + } + + // Wait for quota to be updated after consumption + Wait.assertTrue(() -> quota.getCurrentMessageBytes() < bytesBeforeConsume, + 2000, 100); + + long bytesAfterConsume = quota.getCurrentMessageBytes(); + + // Bytes should have decreased significantly (now that refDown decrements by persistent size) + assertTrue(bytesAfterConsume < bytesBeforeConsume * 0.7, + "Quota should decrease when consuming paged messages. Before: " + bytesBeforeConsume + + " After: " + bytesAfterConsume); + + // Should now be able to send more messages since quota freed up + boolean canSendMore = false; + try { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); + producer.send(message); + canSendMore = true; + } catch (ActiveMQException e) { + e.printStackTrace(); + } + + assertTrue(canSendMore, + "Should be able to send more messages after consuming and freeing quota space"); + + + messagesToConsume = messagesSent - messagesToConsume + 1; + for (int i = 0; i < messagesToConsume; i++) { + ClientMessage received = consumer.receive(5000); + assertNotNull(received, "Should receive message " + i); + received.acknowledge(); + session.commit(); + } + + session.close(); + locator.close(); + + // Wait for quota to be updated after consumption + Wait.assertTrue(() -> quota.getCurrentMessageBytes() == 0, + 2000, 100); + + } finally { + server.stop(); + } + } + + @Test + public void testByteQuotaEnforcedBeforePageFileCreated() throws Exception { + Configuration config = createDefaultConfig(true); + + // Create very tight quota - smaller than typical page file + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("tight-quota"); + quotaConfig.setMaxMessageBytes(3 * 1024L); // Only 3KB + config.addResourceQuota("tight-quota", quotaConfig); + + // Configure paging with larger page size than quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("tight-quota"); + settings.setMaxSizeBytes(1024L); // Page after 1KB (before quota) + settings.setPageSizeBytes(10 * 1024); // 10KB page files (larger than quota!) + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("tight.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("tight.quota"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("tight.queue").setAddress(address)); + + ClientProducer producer = session.createProducer(address); + + // Send messages - quota should enforce at 3KB even though page files are 10KB + boolean quotaExceeded = false; + int messagesSent = 0; + + for (int i = 0; i < 10; i++) { + try { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); // 1KB each + producer.send(message); + messagesSent++; + } catch (ActiveMQResourceQuotaExceededException e) { + quotaExceeded = true; + break; + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + break; + } + throw e; + } + } + + assertTrue(quotaExceeded, + "Quota should enforce 3KB limit regardless of 10KB page file size. Sent " + messagesSent + " without quota exception!"); + + assertTrue(messagesSent >= 2 && messagesSent <= 4, + "Expected ~3 messages before 3KB quota, but sent " + messagesSent); + + // Verify quota is the limiting factor, not page file size + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("tight-quota"); + long currentBytes = quota.getCurrentMessageBytes(); + + assertTrue(currentBytes <= 4 * 1024, + "Quota enforcement should limit storage to ~3KB, not allow filling a 10KB page file. Current: " + currentBytes); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/LargeMessageQuotaLeakTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/LargeMessageQuotaLeakTest.java new file mode 100644 index 00000000000..f9143d52813 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/LargeMessageQuotaLeakTest.java @@ -0,0 +1,150 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientConsumer; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.artemis.tests.util.Wait; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class LargeMessageQuotaLeakTest extends ActiveMQTestBase { + + private static final int LARGE_MESSAGE_THRESHOLD = 10 * 1024; // 10KB + private static final int LARGE_MESSAGE_SIZE = 20 * 1024; // 20KB body + + @Test + public void testLargeMessageQuotaDoesNotLeakOnConsumption() throws Exception { + Configuration config = createDefaultConfig(true); + + // Create quota with 100KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("leak-test-quota"); + quotaConfig.setMaxMessageBytes(100 * 1024L); + config.addResourceQuota("leak-test-quota", quotaConfig); + + // Configure address with quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("leak-test-quota"); + settings.setMaxSizeBytes(-1L); // No paging limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("leak.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setMinLargeMessageSize(LARGE_MESSAGE_THRESHOLD); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, false, false); // Manual acks and commits + + SimpleString address = SimpleString.of("leak.test"); + SimpleString queueName = SimpleString.of("leak.queue"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of(queueName).setAddress(address).setDurable(true)); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("leak-test-quota"); + assertNotNull(quota); + + // Verify quota starts at zero + assertEquals(0, quota.getCurrentMessageBytes(), "Quota should start at zero"); + + ClientProducer producer = session.createProducer(address); + + // Send 3 large messages + int numMessages = 3; + for (int i = 0; i < numMessages; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[LARGE_MESSAGE_SIZE]); + producer.send(message); + } + session.commit(); + + // Wait for quota to reflect the sent messages + Wait.assertTrue(() -> quota.getCurrentMessageBytes() > 0, 2000, 100); + + long quotaAfterSend = quota.getCurrentMessageBytes(); + assertTrue(quotaAfterSend > numMessages * LARGE_MESSAGE_SIZE, + "Quota should track large message body size. Expected > " + (numMessages * LARGE_MESSAGE_SIZE) + + " bytes, got " + quotaAfterSend); + + // Now consume and acknowledge all messages + ClientConsumer consumer = session.createConsumer(queueName); + session.start(); + + for (int i = 0; i < numMessages; i++) { + ClientMessage received = consumer.receive(5000); + assertNotNull(received, "Should receive message " + i); + received.acknowledge(); + } + session.commit(); + + // Wait for quota to be decremented after consumption + Wait.waitFor(() -> quota.getCurrentMessageBytes() == 0, 5000, 50); + + long quotaAfterConsume = quota.getCurrentMessageBytes(); + + // Quota should return to zero after all messages consumed + // If this fails, it indicates the refUp/refDown asymmetry bug + assertEquals(0, quotaAfterConsume, + "QUOTA LEAK DETECTED: Quota should return to zero after consuming all messages. " + + "This indicates QueueImpl.refDown() is not properly accounting for large message body size. " + + "Expected: 0, Actual: " + quotaAfterConsume + + " (leak of ~" + (quotaAfterConsume / numMessages) + " bytes per message)"); + + // Additional verification: Send the same messages again to verify quota is truly freed + for (int i = 0; i < numMessages; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[LARGE_MESSAGE_SIZE]); + producer.send(message); + } + session.commit(); + + long quotaAfterSecondSend = quota.getCurrentMessageBytes(); + + // If there was a leak, the quota after the second send will be higher than after the first send + assertTrue(quotaAfterSecondSend <= quotaAfterSend * 1.1, + "Quota after second send (" + quotaAfterSecondSend + ") should be approximately equal to " + + "quota after first send (" + quotaAfterSend + "). Higher value indicates accumulated leak."); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MqttQuotaReasonCodeTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MqttQuotaReasonCodeTest.java new file mode 100644 index 00000000000..23651260d39 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MqttQuotaReasonCodeTest.java @@ -0,0 +1,200 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttPubReplyMessageVariableHeader; +import io.netty.handler.codec.mqtt.MqttSubAckPayload; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.protocol.mqtt.MQTTInterceptor; +import org.apache.activemq.artemis.core.protocol.mqtt.MQTTReasonCodes; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.eclipse.paho.mqttv5.client.MqttClient; +import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; +import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence; +import org.eclipse.paho.mqttv5.common.MqttException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Verifies that MQTT 5 clients receive the correct QUOTA_EXCEEDED (0x97) + * reason code in PUBACK, PUBREC, and SUBACK responses when resource + * quotas are exceeded. + */ +public class MqttQuotaReasonCodeTest extends ActiveMQTestBase { + + private static final int NETTY_PORT = 61616; + + private ActiveMQServer createServerWithQuota(ResourceQuotaConfig quotaConfig, String addressPattern) throws Exception { + Configuration config = createDefaultConfig(true); + config.addResourceQuota(quotaConfig.getName(), quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota(quotaConfig.getName()); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + settings.setAutoCreateAddresses(true); + settings.setAutoCreateQueues(true); + config.addAddressSetting(addressPattern, settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + return server; + } + + private MqttClient createMqttClient(String clientId) throws MqttException { + MqttClient client = new MqttClient("tcp://localhost:" + NETTY_PORT, clientId, new MemoryPersistence()); + MqttConnectionOptions options = new MqttConnectionOptions(); + options.setCleanStart(true); + options.setConnectionTimeout(10); + client.connect(options); + return client; + } + + private void closeMqttClient(MqttClient client) { + try { + if (client.isConnected()) { + client.disconnect(); + } + } catch (MqttException e) { + // ignore + } + try { + client.close(); + } catch (MqttException e) { + // ignore + } + } + + @Test + public void testPubAckQuotaExceededReasonCode() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("byte-quota"); + quotaConfig.setMaxMessageBytes(10L); + ActiveMQServer server = createServerWithQuota(quotaConfig, "rc.#"); + + try { + final CountDownLatch latch = new CountDownLatch(1); + final byte[] capturedReasonCode = new byte[1]; + + MQTTInterceptor outgoingInterceptor = (packet, connection) -> { + if (packet.fixedHeader().messageType() == MqttMessageType.PUBACK) { + capturedReasonCode[0] = ((MqttPubReplyMessageVariableHeader) packet.variableHeader()).reasonCode(); + latch.countDown(); + } + return true; + }; + server.getRemotingService().addOutgoingInterceptor(outgoingInterceptor); + + MqttClient mqttClient = createMqttClient("puback-rc-client"); + try { + mqttClient.subscribe("rc/puback", 1); + + try { + mqttClient.publish("rc/puback", new byte[1024], 1, false); + } catch (MqttException e) { + // expected + } + + assertTrue(latch.await(5, TimeUnit.SECONDS), "PUBACK interceptor should have fired"); + assertEquals(MQTTReasonCodes.QUOTA_EXCEEDED, capturedReasonCode[0]); + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } + + @Test + public void testPubRecQuotaExceededReasonCode() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("byte-quota"); + quotaConfig.setMaxMessageBytes(10L); + ActiveMQServer server = createServerWithQuota(quotaConfig, "rc.#"); + + try { + MqttClient mqttClient = createMqttClient("pubrec-rc-client"); + try { + mqttClient.subscribe("rc/pubrec", 2); + + try { + mqttClient.publish("rc/pubrec", new byte[1024], 2, false); + fail("QoS 2 publish should throw MqttException when byte quota exceeded"); + } catch (MqttException e) { + assertEquals(MQTTReasonCodes.QUOTA_EXCEEDED, (byte) e.getReasonCode()); + } + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } + + @Test + public void testSubAckQuotaExceededReasonCode() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("queue-quota"); + quotaConfig.setMaxQueues(1); + ActiveMQServer server = createServerWithQuota(quotaConfig, "rc.#"); + + try { + final CountDownLatch latch = new CountDownLatch(1); + final byte[] capturedReasonCode = new byte[1]; + + MQTTInterceptor outgoingInterceptor = (packet, connection) -> { + if (packet.fixedHeader().messageType() == MqttMessageType.SUBACK) { + List codes = ((MqttSubAckPayload) packet.payload()).reasonCodes(); + byte rc = codes.get(codes.size() - 1).byteValue(); + if (rc == MQTTReasonCodes.QUOTA_EXCEEDED) { + capturedReasonCode[0] = rc; + latch.countDown(); + } + } + return true; + }; + server.getRemotingService().addOutgoingInterceptor(outgoingInterceptor); + + MqttClient mqttClient = createMqttClient("suback-rc-client"); + try { + mqttClient.subscribe("rc/topic1", 1); + + try { + mqttClient.subscribe("rc/topic2", 1); + } catch (MqttException e) { + // Paho 1.2.5 rejects QUOTA_EXCEEDED (0x97) in SUBACK as an unknown return code + } + + assertTrue(latch.await(5, TimeUnit.SECONDS), "SUBACK with QUOTA_EXCEEDED should have been sent"); + assertEquals(MQTTReasonCodes.QUOTA_EXCEEDED, capturedReasonCode[0]); + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MultiProtocolByteQuotaTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MultiProtocolByteQuotaTest.java new file mode 100644 index 00000000000..ed6350ef780 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/MultiProtocolByteQuotaTest.java @@ -0,0 +1,313 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import java.io.IOException; +import java.net.URI; + +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.transport.amqp.client.AmqpClient; +import org.apache.activemq.transport.amqp.client.AmqpConnection; +import org.apache.activemq.transport.amqp.client.AmqpMessage; +import org.apache.activemq.transport.amqp.client.AmqpReceiver; +import org.apache.activemq.transport.amqp.client.AmqpSender; +import org.apache.activemq.transport.amqp.client.AmqpSession; +import org.eclipse.paho.mqttv5.client.MqttClient; +import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; +import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence; +import org.eclipse.paho.mqttv5.common.MqttException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Verifies that resource quotas (bytes, addresses, queues) are enforced + * for AMQP and MQTT clients, not just the CORE protocol. + */ +public class MultiProtocolByteQuotaTest extends ActiveMQTestBase { + + private static final int MESSAGE_SIZE = 1024; + private static final long BYTE_QUOTA_LIMIT = 50 * 1024L; + private static final int NETTY_PORT = 61616; + + private ActiveMQServer createServerWithQuota(ResourceQuotaConfig quotaConfig, String addressPattern) throws Exception { + Configuration config = createDefaultConfig(true); + config.addResourceQuota(quotaConfig.getName(), quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota(quotaConfig.getName()); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + settings.setAutoCreateAddresses(true); + settings.setAutoCreateQueues(true); + config.addAddressSetting(addressPattern, settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + return server; + } + + private MqttClient createMqttClient(String clientId) throws MqttException { + MqttClient client = new MqttClient("tcp://localhost:" + NETTY_PORT, clientId, new MemoryPersistence()); + MqttConnectionOptions options = new MqttConnectionOptions(); + options.setCleanStart(true); + options.setConnectionTimeout(10); + client.connect(options); + return client; + } + + private void closeMqttClient(MqttClient client) { + try { + if (client.isConnected()) { + client.disconnect(); + } + } catch (MqttException e) { + // ignore + } + try { + client.close(); + } catch (MqttException e) { + // ignore + } + } + + // --- Byte quota tests --- + + @Test + public void testAmqpSenderBytesQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(BYTE_QUOTA_LIMIT); + ActiveMQServer server = createServerWithQuota(quotaConfig, "quotaBytes.#"); + + try { + SimpleString address = SimpleString.of("quotaBytes.amqp"); + server.addAddressInfo(new AddressInfo(address, RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of(address).setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + AmqpClient client = new AmqpClient(new URI("tcp://127.0.0.1:" + NETTY_PORT), null, null); + AmqpConnection connection = client.connect(); + + try { + AmqpSession session = connection.createSession(); + AmqpSender sender = session.createSender(address.toString()); + + boolean rejected = false; + int messagesSent = 0; + + for (int i = 0; i < 100; i++) { + AmqpMessage message = new AmqpMessage(); + message.setBytes(new byte[MESSAGE_SIZE]); + try { + sender.send(message); + messagesSent++; + } catch (IOException e) { + rejected = true; + break; + } + } + + Assertions.assertTrue(rejected, + "AMQP sender should be rejected when byte quota exceeded. Sent " + messagesSent + " messages."); + Assertions.assertTrue(messagesSent > 0, + "Should have sent at least some messages before quota limit"); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + Assertions.assertNotNull(quota); + Assertions.assertTrue(quota.getCurrentMessageBytes() > 0, "Quota should have tracked bytes"); + } finally { + connection.close(); + } + } finally { + server.stop(); + } + } + + @Test + public void testMqttSenderBytesQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(BYTE_QUOTA_LIMIT); + ActiveMQServer server = createServerWithQuota(quotaConfig, "quotaBytes.#"); + + try { + MqttClient mqttClient = createMqttClient("quota-test-client"); + try { + mqttClient.subscribe("quotaBytes/mqtt", 1); + + for (int i = 0; i < 100; i++) { + try { + mqttClient.publish("quotaBytes/mqtt", new byte[MESSAGE_SIZE], 1, false); + } catch (MqttException e) { + // expect puback with QUOTA reason code but seems to be ignored + break; + } + } + + String queueName = "quota-test-client.quotaBytes.mqtt"; + long queueCount = server.locateQueue(SimpleString.of(queueName)).getMessageCount(); + Assertions.assertTrue(queueCount > 0, "Some messages should be stored before quota limit"); + Assertions.assertTrue(queueCount < 80, "Quota should limit stored messages, but queue has " + queueCount); + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } + + // --- Address quota tests --- + + @Test + public void testAmqpAddressQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("addr-quota"); + quotaConfig.setMaxAddresses(2); + ActiveMQServer server = createServerWithQuota(quotaConfig, "addrQuota.#"); + + try { + AmqpClient client = new AmqpClient(new URI("tcp://127.0.0.1:" + NETTY_PORT), null, null); + AmqpConnection connection = client.connect(); + + try { + AmqpSession session = connection.createSession(); + + AmqpSender sender1 = session.createSender("addrQuota.addr1"); + Assertions.assertNotNull(sender1, "First sender should attach successfully"); + + AmqpSender sender2 = session.createSender("addrQuota.addr2"); + Assertions.assertNotNull(sender2, "Second sender should attach successfully"); + + boolean rejected = false; + try { + session.createSender("addrQuota.addr3"); + } catch (Exception expected) { + rejected = true; + } + + Assertions.assertTrue(rejected, "AMQP sender attach should fail when address quota exceeded"); + Assertions.assertTrue(server.getResourceQuotaService().getQuotaByName("addr-quota").isAddressLimitReached()); + } finally { + connection.close(); + } + } finally { + server.stop(); + } + } + + @Test + public void testMqttAddressQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("addr-quota"); + quotaConfig.setMaxAddresses(2); + ActiveMQServer server = createServerWithQuota(quotaConfig, "addrQuota.#"); + + try { + server.addAddressInfo(new AddressInfo(SimpleString.of("addrQuota.addr1"), RoutingType.MULTICAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("addrQuota.addr2"), RoutingType.MULTICAST)); + + MqttClient mqttClient = createMqttClient("addr-quota-client"); + try { + try { + mqttClient.publish("addrQuota/addr3", new byte[10], 1, false); + } catch (MqttException e) { + // expected but not forthcoming from paho + } + + Assertions.assertNull(server.getAddressInfo(SimpleString.of("addrQuota.addr3")), + "Third address should not be created - quota exceeded"); + Assertions.assertEquals(2, server.getResourceQuotaService().getQuotaByName("addr-quota").getAddressCount()); + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } + + // --- Queue quota tests --- + + @Test + public void testAmqpQueueQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("queue-quota"); + quotaConfig.setMaxQueues(2); + ActiveMQServer server = createServerWithQuota(quotaConfig, "queueQuota.#"); + + try { + AmqpClient client = new AmqpClient(new URI("tcp://127.0.0.1:" + NETTY_PORT), null, null); + AmqpConnection connection = client.connect(); + + try { + AmqpSession session = connection.createSession(); + + AmqpReceiver receiver1 = session.createReceiver("queueQuota.addr1"); + Assertions.assertNotNull(receiver1, "First receiver should attach successfully"); + + AmqpReceiver receiver2 = session.createReceiver("queueQuota.addr2"); + Assertions.assertNotNull(receiver2, "Second receiver should attach successfully"); + + boolean rejected = false; + try { + session.createReceiver("queueQuota.addr3"); + } catch (Exception e) { + rejected = true; + } + + Assertions.assertTrue(rejected, "AMQP receiver attach should fail when queue quota exceeded"); + } finally { + connection.close(); + } + } finally { + server.stop(); + } + } + + @Test + public void testMqttQueueQuota() throws Exception { + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("queue-quota"); + quotaConfig.setMaxQueues(2); + ActiveMQServer server = createServerWithQuota(quotaConfig, "queueQuota.#"); + + try { + MqttClient mqttClient = createMqttClient("queue-quota-client"); + try { + mqttClient.subscribe("queueQuota/topic1", 1); + mqttClient.subscribe("queueQuota/topic2", 1); + + try { + mqttClient.subscribe("queueQuota/topic3", 1); + } catch (MqttException expected) { + // expected + } + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("queue-quota"); + Assertions.assertNotNull(quota); + Assertions.assertTrue(quota.getCurrentQueueCount() <= 2, + "Queue count should not exceed quota limit, got " + quota.getCurrentQueueCount()); + } finally { + closeMqttClient(mqttClient); + } + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/NonPersistentByteQuotaTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/NonPersistentByteQuotaTest.java new file mode 100644 index 00000000000..fd4dd7a5270 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/NonPersistentByteQuotaTest.java @@ -0,0 +1,180 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that byte quota is enforced for non-persistent (in-memory) messages. + * + */ +public class NonPersistentByteQuotaTest extends ActiveMQTestBase { + + @Test + public void testNonPersistentMessagesRespectByteQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 1KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(1024L); // 1KB + config.addResourceQuota("test-quota", quotaConfig); + + // Configure address settings: no paging, just in-memory + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); // No local size limit + settings.setMaxSizeMessages(-1); // No message count limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + config.setGlobalMaxSize(-1); // No global size limit + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnNonDurableSend(true); // Required to get exception response for non-persistent sends + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.nonpersistent"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Send non-persistent messages until quota exceeded + boolean quotaExceeded = false; + int messagesSent = 0; + for (int i = 0; i < 100; i++) { + try { + ClientMessage message = session.createMessage(false); // NON-PERSISTENT + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + messagesSent++; + } catch (ActiveMQResourceQuotaExceededException e) { + // Expected - quota exceeded + assertTrue(e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded"), + "Expected quota exception but got: " + e.getMessage()); + quotaExceeded = true; + break; + } catch (ActiveMQException e) { + // May get wrapped exception + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + break; + } + throw e; + } + } + + assertTrue(quotaExceeded, + "Non-persistent messages should respect byte quota even without paging. Sent " + messagesSent + " messages without quota enforcement!"); + + // Verify we sent some and not all + assertTrue(messagesSent >= 1 && messagesSent <= 100, + "Expected to send some messages before hitting 1KB quota, but sent " + messagesSent); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testMixedPersistentAndNonPersistentRespectQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 2KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(2048L); // 2KB + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnNonDurableSend(true); // Required to get exception response for non-persistent sends + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.mixed"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Send some persistent messages + for (int i = 0; i < 2; i++) { + ClientMessage message = session.createMessage(true); // PERSISTENT + message.getBodyBuffer().writeBytes(new byte[212]); + producer.send(message); + } + + // Now try sending non-persistent messages - should also count toward quota + boolean quotaExceeded = false; + for (int i = 0; i < 10; i++) { + try { + ClientMessage message = session.createMessage(false); // NON-PERSISTENT + message.getBodyBuffer().writeBytes(new byte[212]); + producer.send(message); + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded") || + e instanceof ActiveMQResourceQuotaExceededException) { + quotaExceeded = true; + break; + } + throw e; + } + } + + assertTrue(quotaExceeded, + "Mixed persistent and non-persistent messages should both count toward quota"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaOrphaningPreventionTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaOrphaningPreventionTest.java new file mode 100644 index 00000000000..c8eb5698b67 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaOrphaningPreventionTest.java @@ -0,0 +1,338 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that verify quota counters don't get orphaned when deletion operations fail. + * + * Before fixes: + * - removeAddress() decremented quota AFTER removeAddressInfo() - if removal failed, quota leaked + * - destroyQueue() decremented quota AFTER deleteQueue() - if deletion failed, quota leaked + * + * After fixes: + * - Quota is decremented BEFORE potentially-throwing operations + * - If deletion fails, quota was already decremented (conservative approach) + */ +public class QuotaOrphaningPreventionTest extends ActiveMQTestBase { + + /** + * Test that address quota doesn't orphan when address removal succeeds. + * This is the normal case - verify quota is correctly decremented. + */ + @Test + public void testAddressQuotaDecrementedOnSuccessfulRemoval() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("address-remove-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("address-remove-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("address-remove-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Get quota by looking up an address that matches the pattern + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(SimpleString.of("test.addr1")); + assertNotNull(quota); + + // Create 3 addresses + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)); + + assertEquals(3, quota.getCurrentAddressCount(), "Should have 3 addresses"); + + // Remove one address + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + + assertEquals(2, quota.getCurrentAddressCount(), "Should have 2 addresses after removal"); + + // Remove another + server.removeAddressInfo(SimpleString.of("test.addr2"), null); + + assertEquals(1, quota.getCurrentAddressCount(), "Should have 1 address after removal"); + + } finally { + server.stop(); + } + } + + /** + * Test that queue quota doesn't orphan when queue deletion succeeds. + * This is the normal case - verify quota is correctly decremented. + */ + @Test + public void testQueueQuotaDecrementedOnSuccessfulDeletion() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("queue-delete-quota"); + quotaConfig.setMaxQueues(5); + config.addResourceQuota("queue-delete-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("queue-delete-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + SimpleString address = SimpleString.of("test.queues"); + server.addAddressInfo(new AddressInfo(address, RoutingType.ANYCAST)); + + // Get quota by looking up an address that matches the pattern + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + assertNotNull(quota); + + // Create 3 queues + server.createQueue(QueueConfiguration.of("queue1").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of("queue2").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of("queue3").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + assertEquals(3, quota.getCurrentQueueCount(), "Should have 3 queues"); + + // Delete one queue + server.destroyQueue(SimpleString.of("queue1")); + + assertEquals(2, quota.getCurrentQueueCount(), "Should have 2 queues after deletion"); + + // Delete another + server.destroyQueue(SimpleString.of("queue2")); + + assertEquals(1, quota.getCurrentQueueCount(), "Should have 1 queue after deletion"); + + } finally { + server.stop(); + } + } + + /** + * Test that address quota can be reused after addresses are removed and added back. + * This verifies quota accounting stays consistent through add/remove cycles. + */ + @Test + public void testAddressQuotaReuseAfterRemoval() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("reuse-quota"); + quotaConfig.setMaxAddresses(2); + config.addResourceQuota("reuse-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("reuse-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create 2 addresses (max quota) + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST)); + + // Try to create third - should fail + assertThrows(ActiveMQResourceQuotaExceededException.class, () -> + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)) + ); + + // Remove one address + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + + // Now creating addr3 should succeed + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)); + + // Remove both + server.removeAddressInfo(SimpleString.of("test.addr2"), null); + server.removeAddressInfo(SimpleString.of("test.addr3"), null); + + // Should be able to create 2 new addresses + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr5"), RoutingType.ANYCAST)); + + // Get quota again to verify final count + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(SimpleString.of("test.addr4")); + assertEquals(2, quota.getCurrentAddressCount(), "Should have 2 addresses"); + + } finally { + server.stop(); + } + } + + /** + * Test that queue quota can be reused after queues are deleted and created again. + */ + @Test + public void testQueueQuotaReuseAfterDeletion() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("queue-reuse-quota"); + quotaConfig.setMaxQueues(2); + config.addResourceQuota("queue-reuse-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("queue-reuse-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + SimpleString address = SimpleString.of("test.qreuse"); + server.addAddressInfo(new AddressInfo(address, RoutingType.ANYCAST)); + + // Create 2 queues (max quota) + server.createQueue(QueueConfiguration.of("queue1").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of("queue2").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + // Try to create third - should fail + assertThrows(ActiveMQResourceQuotaExceededException.class, () -> + server.createQueue(QueueConfiguration.of("queue3").setAddress(address).setRoutingType(RoutingType.ANYCAST)) + ); + + // Delete one queue + server.destroyQueue(SimpleString.of("queue1")); + + // Now creating queue3 should succeed + server.createQueue(QueueConfiguration.of("queue3").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + // Delete both + server.destroyQueue(SimpleString.of("queue2")); + server.destroyQueue(SimpleString.of("queue3")); + + // Should be able to create 2 new queues + server.createQueue(QueueConfiguration.of("queue4").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of("queue5").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + // Get quota again to verify final count + org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + assertEquals(2, quota.getCurrentQueueCount(), "Should have 2 queues"); + + } finally { + server.stop(); + } + } + + /** + * Test that address removal with force=true correctly decrements quota. + * Force removal deletes bindings first, then the address. + */ + @Test + public void testAddressQuotaDecrementedOnForceRemoval() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("force-remove-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("force-remove-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("force-remove-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + SimpleString address = SimpleString.of("test.force"); + server.addAddressInfo(new AddressInfo(address, RoutingType.ANYCAST)); + server.createQueue(QueueConfiguration.of("queue1").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + + assertEquals(1, quota.getCurrentAddressCount()); + + // Force remove (deletes queues and address) + server.removeAddressInfo(address, null, true); + + assertEquals(0, quota.getCurrentAddressCount(), + "Quota should be decremented after force removal"); + + } finally { + server.stop(); + } + } + + /** + * Test that quota decrement happens even if address doesn't exist. + * This is the conservative approach - better to decrement twice than leak. + */ + @Test + public void testAddressQuotaDecrementIdempotent() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("idempotent-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("idempotent-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("idempotent-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + SimpleString address = SimpleString.of("test.idempotent"); + server.addAddressInfo(new AddressInfo(address, RoutingType.ANYCAST)); + + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + + assertEquals(1, quota.getCurrentAddressCount()); + + // Remove address + server.removeAddressInfo(address, null); + + assertEquals(0, quota.getCurrentAddressCount()); + + // Try to remove again - address doesn't exist, but quota was already decremented + // This should not cause quota to go negative + try { + server.removeAddressInfo(address, null); + } catch (Exception e) { + // Expected - address doesn't exist + } + + // Quota should not go negative + assertTrue(quota.getCurrentAddressCount() >= 0, + "Quota should not go negative: " + quota.getCurrentAddressCount()); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaPolicyEnforcementTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaPolicyEnforcementTest.java new file mode 100644 index 00000000000..b9bb6795c67 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaPolicyEnforcementTest.java @@ -0,0 +1,290 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for quota enforcement with address full policies (BLOCK/FAIL/PAGE) + * Quota is independent + */ +public class QuotaPolicyEnforcementTest extends ActiveMQTestBase { + + /** + * Test that FAIL policy enforces quota when quota is the ONLY limit configured. + * evaluated to false when only quota was set, bypassing enforcement. + */ + @Test + public void testFAILPolicyWithQuotaOnly() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 1KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("fail-quota"); + quotaConfig.setMaxMessageBytes(1024L); // 1KB + config.addResourceQuota("fail-quota", quotaConfig); + + // Configure address settings: quota only, no maxSize + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("fail-quota"); + settings.setMaxSizeBytes(-1L); // No local size limit + settings.setMaxSizeMessages(-1); // No message count limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + config.setGlobalMaxSize(-1); // No global size limit + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.fail"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.fail").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + assertThrows(ActiveMQException.class, () -> { + for (int i = 0; i < 100; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + } + }); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testBLOCKPolicyIndependentOfQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 1KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("block-quota"); + quotaConfig.setMaxMessageBytes(1024L); // 1KB + config.addResourceQuota("block-quota", quotaConfig); + + // Configure address settings: quota only, no maxSize + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("block-quota"); + settings.setMaxSizeBytes(-1L); // No local size limit + settings.setMaxSizeMessages(-1); // No message count limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.BLOCK); + config.addAddressSetting("test.#", settings); + config.setGlobalMaxSize(-1); // No global size limit + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnNonDurableSend(true); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.block"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.block").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + assertThrows(ActiveMQException.class, () -> { + for (int i = 0; i < 100; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + } + }); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testPAGEPolicyIndependentOfQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 1KB byte limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("page-quota"); + quotaConfig.setMaxMessageBytes(1024L); // 1KB + config.addResourceQuota("page-quota", quotaConfig); + + // Configure address settings: quota only, no maxSize + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("page-quota"); + settings.setMaxSizeBytes(-1L); // No local size limit + settings.setMaxSizeMessages(-1); // No message count limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("test.#", settings); + config.setGlobalMaxSize(-1); // No global size limit + + ActiveMQServer server = createServer(true, config); // Enable persistence for paging + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.page"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.page").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Send enough messages to exceed quota and potentially trigger paging + assertThrows(ActiveMQException.class, () -> { + for (int i = 0; i < 100; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + } + }); + + // Verify paging was not triggered + assertFalse(server.getPagingManager().getPageStore(address).isPaging(), + "PAGE policy should have started paging when quota exceeded"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testIsFullIgnoredQuotaCheck() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("full-check-quota"); + quotaConfig.setMaxMessageBytes(512L); // 512 bytes + config.addResourceQuota("full-check-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("full-check-quota"); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.full"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.full").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + assertThrows(ActiveMQException.class, () -> { + for (int i = 0; i < 100; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + } + }); + + // Verify isFull() ignores quota + assertFalse(server.getPagingManager().getPageStore(address).isFull(), + "isFull() check when quota exceeded"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that quota is enforced alongside existing maxSize limits. + */ + @Test + public void testQuotaWithMaxSizeCombined() throws Exception { + Configuration config = createDefaultConfig(false); + + // Quota has higher limit than maxSize + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("combined-quota"); + quotaConfig.setMaxMessageBytes(2048L); // 2KB quota + config.addResourceQuota("combined-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("combined-quota"); + settings.setMaxSizeBytes(1024L); // 1KB maxSize (lower than quota) + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.combined"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.combined").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + assertThrows(ActiveMQException.class, () -> { + for (int i = 0; i < 100; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[200]); // 200 bytes each + producer.send(message); + } + }); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRestartPersistenceTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRestartPersistenceTest.java new file mode 100644 index 00000000000..b693f5e6a93 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRestartPersistenceTest.java @@ -0,0 +1,988 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.Queue; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.artemis.tests.util.Wait; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests that quota state is correctly rebuilt after server restart. + * Verifies that quotas survive restart and continue enforcing limits. + * + */ +public class QuotaRestartPersistenceTest extends ActiveMQTestBase { + + @Test + public void testAddressQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled + + // Create quota with max 3 addresses + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(3); + config.addResourceQuota("test-quota", quotaConfig); + + // Configure address settings to use this quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + // Create 2 addresses before restart + AddressInfo addr1 = new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST); + server.addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST); + server.addAddressInfo(addr2); + + // Verify addresses exist + assertNotNull(server.getAddressInfo(SimpleString.of("test.addr1"))); + assertNotNull(server.getAddressInfo(SimpleString.of("test.addr2"))); + + // Get the RUNTIME quota instance by name (both addresses share same quota) + ResourceQuota runtimeQuota = server.getResourceQuotaService() + .getQuotaByName("test-quota"); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // Verify runtime quota count is 2 before restart + assertEquals(2, runtimeQuota.getAddressCount(), "Before restart, runtime quota count should be 2"); + } finally { + server.stop(); + } + + // Restart the server with same configuration + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + // Verify addresses were restored from journal + assertNotNull(server2.getAddressInfo(SimpleString.of("test.addr1")), + "addr1 should be restored after restart"); + assertNotNull(server2.getAddressInfo(SimpleString.of("test.addr2")), + "addr2 should be restored after restart"); + + // Get the RUNTIME quota instance by name (both addresses share same quota) + ResourceQuota runtimeQuota = server2.getResourceQuotaService() + .getQuotaByName("test-quota"); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // CRITICAL TEST: After restart, quota counts should be rebuilt by reloading addresses + // The count should be 2 (matching the restored addresses) + assertEquals(2, runtimeQuota.getAddressCount(), + "After restart, quota count should be rebuilt to 2 - THIS IS THE BUG TEST"); + + // Should be able to create one more address (limit is 3) + AddressInfo addr3 = new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST); + server2.addAddressInfo(addr3); + assertEquals(3, runtimeQuota.getAddressCount(), "After adding addr3, count should be 3"); + + // Fourth address should fail (quota limit reached) + AddressInfo addr4 = new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST); + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.addAddressInfo(addr4), + "Fourth address should exceed quota limit" + ); + assertTrue(exception.getMessage().contains("Address quota exceeded")); + assertTrue(exception.getMessage().contains("test-quota")); + + // Verify final count is still 3 (addr4 was not created) + assertEquals(3, runtimeQuota.getAddressCount()); + + } finally { + server2.stop(); + } + } + + @Test + public void testAddressQuotaRebuildAfterDeleteAndRestart() throws Exception { + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + for (int i = 1; i <= 4; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertEquals(4, quota.getAddressCount()); + + // Delete two addresses before restart + server.removeAddressInfo(SimpleString.of("test.addr2"), null); + server.removeAddressInfo(SimpleString.of("test.addr4"), null); + assertEquals(2, quota.getAddressCount(), "Count should be 2 after deleting two addresses"); + } finally { + server.stop(); + } + + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + assertNotNull(server2.getAddressInfo(SimpleString.of("test.addr1"))); + assertNotNull(server2.getAddressInfo(SimpleString.of("test.addr3"))); + + ResourceQuota quota = server2.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(2, quota.getAddressCount(), + "After restart, count should reflect only surviving addresses (not deleted ones)"); + + // Can create 3 more up to limit of 5 + server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr5"), RoutingType.ANYCAST)); + server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST)); + server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr7"), RoutingType.ANYCAST)); + assertEquals(5, quota.getAddressCount()); + + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr8"), RoutingType.ANYCAST)), + "Sixth address should exceed quota limit" + ); + } finally { + server2.stop(); + } + } + + @Test + public void testQueueQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled + + // Create quota with max 4 queues + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(4); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + // Create address + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr"), RoutingType.ANYCAST); + server.addAddressInfo(addr); + + // Create 2 queues before restart + server.createQueue(QueueConfiguration.of("queue1").setAddress("test.addr").setDurable(true)); + server.createQueue(QueueConfiguration.of("queue2").setAddress("test.addr").setDurable(true)); + + // Get the RUNTIME quota instance (not the config template!) + ResourceQuota runtimeQuota = server.getResourceQuotaService() + .lookupQuota(SimpleString.of("test.addr")); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // Verify runtime quota count before restart + assertEquals(2, runtimeQuota.getQueueCount(), "Before restart, runtime queue count should be 2"); + + } finally { + server.stop(); + } + + // Restart the server + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + // Verify queues were restored + assertNotNull(server2.locateQueue(SimpleString.of("queue1")), "queue1 should be restored"); + assertNotNull(server2.locateQueue(SimpleString.of("queue2")), "queue2 should be restored"); + + // Get the RUNTIME quota instance by name + ResourceQuota runtimeQuota = server2.getResourceQuotaService() + .getQuotaByName("test-quota"); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // CRITICAL TEST: Quota count should be rebuilt to 2 + assertEquals(2, runtimeQuota.getQueueCount(), + "After restart, queue count should be rebuilt to 2"); + + // Should be able to create 2 more queues (limit is 4) + server2.createQueue(QueueConfiguration.of("queue3").setAddress("test.addr").setDurable(true)); + assertEquals(3, runtimeQuota.getQueueCount()); + + server2.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr").setDurable(true)); + assertEquals(4, runtimeQuota.getQueueCount()); + + // Fifth queue should fail + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.createQueue(QueueConfiguration.of("queue5").setAddress("test.addr").setDurable(true)), + "Fifth queue should exceed quota limit" + ); + assertTrue(exception.getMessage().contains("Queue quota exceeded")); + + } finally { + server2.stop(); + } + } + + @Test + public void testWildcardQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled + + // Create wildcard template quota "region.*" with max 2 addresses per region + ResourceQuotaConfig regionTemplate = new ResourceQuotaConfig("region.*"); + regionTemplate.setMaxAddresses(2); + config.addResourceQuota("region.*", regionTemplate); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + // Create 2 addresses in region.us before restart + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.payments"), RoutingType.ANYCAST)); + + // Create 1 address in region.eu before restart + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST)); + + } finally { + server.stop(); + } + + // Restart the server + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + // Verify addresses were restored + assertNotNull(server2.getAddressInfo(SimpleString.of("region.us.orders"))); + assertNotNull(server2.getAddressInfo(SimpleString.of("region.us.payments"))); + assertNotNull(server2.getAddressInfo(SimpleString.of("region.eu.orders"))); + + // Get the RUNTIME quota instances for each region (wildcard creates separate instances) + ResourceQuota usQuota = server2.getResourceQuotaService() + .lookupQuota(SimpleString.of("region.us.orders")); + ResourceQuota euQuota = server2.getResourceQuotaService() + .lookupQuota(SimpleString.of("region.eu.orders")); + + assertNotNull(usQuota, "US quota instance should exist"); + assertNotNull(euQuota, "EU quota instance should exist"); + + // CRITICAL TEST: Each region's quota should be rebuilt + assertEquals(2, usQuota.getAddressCount(), + "region.us count should be 2 after restart"); + assertEquals(1, euQuota.getAddressCount(), + "region.eu count should be 1 after restart"); + + // region.us should be at limit (2 addresses) + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.addAddressInfo(new AddressInfo(SimpleString.of("region.us.shipping"), RoutingType.ANYCAST)), + "region.us should be at limit after restart" + ); + + // region.eu should have capacity for 1 more + server2.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.payments"), RoutingType.ANYCAST)); + assertEquals(2, euQuota.getAddressCount()); + + // Now region.eu should also be at limit + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.shipping"), RoutingType.ANYCAST)), + "region.eu should be at limit after adding second address" + ); + + } finally { + server2.stop(); + } + } + + @Test + public void testHierarchicalQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled + + // Create parent quota with total limit of 5 addresses + ResourceQuotaConfig parentQuota = new ResourceQuotaConfig("parent"); + parentQuota.setMaxAddresses(5); + config.addResourceQuota("parent", parentQuota); + + // Create child quota with higher limit but part of parent + ResourceQuotaConfig childQuota = new ResourceQuotaConfig("child"); + childQuota.setMaxAddresses(10); + childQuota.setPartOf("parent"); + config.addResourceQuota("child", childQuota); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("child"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + // Create 3 addresses before restart + for (int i = 1; i <= 3; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + // Get the RUNTIME quota instances (not the config templates!) + ResourceQuota runtimeChild = server.getResourceQuotaService() + .getQuotaByName("child"); + + assertNotNull(runtimeChild, "Runtime child quota should exist"); + ResourceQuota runtimeParent = runtimeChild.getParent(); + assertNotNull(runtimeParent, "Runtime parent quota should be linked"); + + // Verify runtime counts before restart + assertEquals(3, runtimeChild.getAddressCount(), "Runtime child count before restart"); + assertEquals(3, runtimeParent.getAddressCount(), "Runtime parent count before restart"); + } finally { + server.stop(); + } + + // Restart the server + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + // Verify addresses were restored + for (int i = 1; i <= 3; i++) { + assertNotNull(server2.getAddressInfo(SimpleString.of("test.addr" + i)), + "addr" + i + " should be restored"); + } + + // Get the RUNTIME quota instances (not the config templates!) + ResourceQuota runtimeChild = server2.getResourceQuotaService() + .getQuotaByName("child"); + + assertNotNull(runtimeChild, "Runtime child quota should exist"); + + // Get parent from child (they should be linked at runtime) + ResourceQuota runtimeParent = runtimeChild.getParent(); + assertNotNull(runtimeParent, "Parent quota should be linked"); + + // CRITICAL TEST: Quota counts should be rebuilt + assertEquals(3, runtimeChild.getAddressCount(), + "Child count after restart should be 3 - THIS IS THE BUG TEST"); + assertEquals(3, runtimeParent.getAddressCount(), + "Parent count after restart should be 3 - THIS IS THE BUG TEST"); + + // Should be able to create 2 more (parent limit is 5) + server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST)); + server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr5"), RoutingType.ANYCAST)); + + assertEquals(5, runtimeChild.getAddressCount()); + assertEquals(5, runtimeParent.getAddressCount()); + + // Sixth address should fail due to parent limit + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.addAddressInfo(new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST)), + "Should fail due to parent quota limit" + ); + + } finally { + server2.stop(); + } + } + + @Test + public void testWildcardByteQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig globalConfig = new ResourceQuotaConfig("global"); + globalConfig.setMaxMessageBytes(100 * 1600L); + config.addResourceQuota("global", globalConfig); + + ResourceQuotaConfig regionConfig = new ResourceQuotaConfig("region.*"); + regionConfig.setMaxMessageBytes(50 * 1600L); + regionConfig.setPartOf("global"); + config.addResourceQuota("region.*", regionConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + long euBytesBeforeRestart; + long usBytesBeforeRestart; + long globalBytesBeforeRestart; + ServerLocator locator = null; + + try { + locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString euAddress = SimpleString.of("region.eu.orders"); + SimpleString usAddress = SimpleString.of("region.us.orders"); + + session.createAddress(euAddress, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("eu.queue").setAddress(euAddress).setDurable(true)); + session.createAddress(usAddress, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("us.queue").setAddress(usAddress).setDurable(true)); + + ClientProducer euProducer = session.createProducer(euAddress); + for (int i = 0; i < 5; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1024]); + euProducer.send(msg); + } + + ClientProducer usProducer = session.createProducer(usAddress); + for (int i = 0; i < 3; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1024]); + usProducer.send(msg); + } + + ResourceQuota euQuota = server.getResourceQuotaService().lookupQuota(euAddress); + ResourceQuota usQuota = server.getResourceQuotaService().lookupQuota(usAddress); + ResourceQuota global = server.getResourceQuotaService().getQuotaByName("global"); + + assertNotNull(euQuota); + assertNotNull(usQuota); + assertNotNull(global); + + euBytesBeforeRestart = euQuota.getCurrentMessageBytes(); + usBytesBeforeRestart = usQuota.getCurrentMessageBytes(); + globalBytesBeforeRestart = global.getCurrentMessageBytes(); + + assertTrue(euBytesBeforeRestart > 0, "EU should have bytes tracked"); + assertTrue(usBytesBeforeRestart > 0, "US should have bytes tracked"); + assertEquals(euBytesBeforeRestart + usBytesBeforeRestart, globalBytesBeforeRestart, + "Global should be sum of children before restart"); + + session.close(); + sf.close(); + } finally { + if (locator != null) { + locator.close(); + } + server.stop(); + } + + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + assertNotNull(server2.locateQueue(SimpleString.of("eu.queue"))); + assertNotNull(server2.locateQueue(SimpleString.of("us.queue"))); + + ResourceQuota euQuota = server2.getResourceQuotaService() + .lookupQuota(SimpleString.of("region.eu.orders")); + ResourceQuota usQuota = server2.getResourceQuotaService() + .lookupQuota(SimpleString.of("region.us.orders")); + ResourceQuota global = server2.getResourceQuotaService().getQuotaByName("global"); + + assertNotNull(euQuota, "EU wildcard instance should be recreated"); + assertNotNull(usQuota, "US wildcard instance should be recreated"); + assertNotNull(global); + + assertEquals(euBytesBeforeRestart, euQuota.getCurrentMessageBytes(), + "EU byte counter should match pre-restart value"); + assertEquals(usBytesBeforeRestart, usQuota.getCurrentMessageBytes(), + "US byte counter should match pre-restart value"); + assertEquals(globalBytesBeforeRestart, global.getCurrentMessageBytes(), + "Global byte counter should be sum of children after restart"); + } finally { + server2.stop(); + } + } + + @Test + public void testByteQuotaRebuildAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); // persistence enabled + + // Create quota with max 10KB message bytes + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(10 * 1600L); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + long bytesBeforeRestart; + ServerLocator locator = null; + try { + locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); // Required to get exception response for durable sends + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + // Create address and queue + SimpleString address = SimpleString.of("test.addr"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setDurable(true)); + + ClientProducer producer = session.createProducer(address); + + for (int i = 0; i < 5; i++) { + ClientMessage message = session.createMessage(true); // durable + message.getBodyBuffer().writeBytes(new byte[1024]); // 1KB payload + producer.send(message); + } + + // Get the RUNTIME quota instance + ResourceQuota runtimeQuota = server.getResourceQuotaService() + .getQuotaByName("test-quota"); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // Verify runtime quota byte count before restart + bytesBeforeRestart = runtimeQuota.getCurrentMessageBytes(); + assertTrue(bytesBeforeRestart >= 4 * 1024 && bytesBeforeRestart <= 8 * 1024, + "Before restart, runtime quota should track ~5KB, but was " + bytesBeforeRestart); + + session.close(); + sf.close(); + } finally { + if (locator != null) { + locator.close(); + } + server.stop(); + } + + // Restart the server with same configuration + ActiveMQServer server2 = createServer(config); + server2.start(); + + ServerLocator locator2 = null; + try { + // Verify queue was restored from journal + assertNotNull(server2.locateQueue(SimpleString.of("test.queue")), + "Queue should be restored after restart"); + + // Get the RUNTIME quota instance + ResourceQuota runtimeQuota = server2.getResourceQuotaService() + .getQuotaByName("test-quota"); + + assertNotNull(runtimeQuota, "Runtime quota should exist"); + + // After restart, quota byte count should be rebuilt by reloading messages + // The count should be ~5KB (matching the restored messages) + long bytesAfterRestart = runtimeQuota.getCurrentMessageBytes(); + assertTrue(bytesAfterRestart >= 4 * 1024 && bytesAfterRestart <= 8 * 1024, + "After restart, quota bytes should be rebuilt to ~5KB - THIS IS THE BUG TEST. Was: " + bytesAfterRestart); + + assertEquals(bytesBeforeRestart, bytesAfterRestart, "rebuild should match original"); + + // Should be able to send ~5KB more (limit is 10KB) + locator2 = createInVMNonHALocator(); + locator2.setBlockOnDurableSend(true); // Required to get exception response for durable sends + ClientSessionFactory sf2 = createSessionFactory(locator2); + ClientSession session2 = sf2.createSession(false, true, true); + ClientProducer producer2 = session2.createProducer(SimpleString.of("test.addr")); + + int additionalMessagesSent = 0; + for (int i = 0; i < 5; i++) { + ClientMessage message = session2.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); // 1KB payload + producer2.send(message); + additionalMessagesSent++; + } + assertEquals(5, additionalMessagesSent, "Should send 5 more messages before quota"); + + // Verify we're now at the ~10KB limit + long bytesAtLimit = runtimeQuota.getCurrentMessageBytes(); + assertTrue(bytesAtLimit >= 9 * 1024 && bytesAtLimit <= 16 * 1024, + "After sending to limit, should be at ~10KB, but was " + bytesAtLimit); + + // Next message should fail (quota limit reached) + boolean quotaExceeded = false; + try { + ClientMessage message = session2.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[1024]); + producer2.send(message); + } catch (ActiveMQResourceQuotaExceededException e) { + quotaExceeded = true; + assertTrue(e.getMessage().contains("byte quota exceeded") || + e.getMessage().contains("Resource quota exceeded")); + assertTrue(e.getMessage().contains("test-quota")); + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + quotaExceeded = true; + } else { + throw e; + } + } + + assertTrue(quotaExceeded, "Sending beyond 10KB limit should exceed quota"); + + session2.close(); + sf2.close(); + } finally { + if (locator2 != null) { + locator2.close(); + } + server2.stop(); + } + } + + @Test + public void testNonDurableQueueQuotaNotCountedAfterRestart() throws Exception { + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(config); + server.start(); + + try { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr"), RoutingType.ANYCAST)); + + server.createQueue(QueueConfiguration.of("durable1").setAddress("test.addr").setDurable(true)); + server.createQueue(QueueConfiguration.of("durable2").setAddress("test.addr").setDurable(true)); + server.createQueue(QueueConfiguration.of("temp1").setAddress("test.addr").setDurable(false)); + server.createQueue(QueueConfiguration.of("temp2").setAddress("test.addr").setDurable(false)); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + assertEquals(4, quota.getQueueCount(), "All 4 queues should be counted before restart"); + } finally { + server.stop(); + } + + ActiveMQServer server2 = createServer(config); + server2.start(); + + try { + assertNotNull(server2.locateQueue(SimpleString.of("durable1"))); + assertNotNull(server2.locateQueue(SimpleString.of("durable2"))); + + ResourceQuota quota = server2.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(2, quota.getQueueCount(), + "After restart, only durable queues should be counted (non-durable queues don't survive restart)"); + + // Can create 3 more up to limit of 5 + server2.createQueue(QueueConfiguration.of("durable3").setAddress("test.addr").setDurable(true)); + server2.createQueue(QueueConfiguration.of("durable4").setAddress("test.addr").setDurable(true)); + server2.createQueue(QueueConfiguration.of("durable5").setAddress("test.addr").setDurable(true)); + assertEquals(5, quota.getQueueCount()); + + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server2.createQueue(QueueConfiguration.of("durable6").setAddress("test.addr").setDurable(true)), + "Sixth queue should exceed quota limit" + ); + } finally { + server2.stop(); + } + } + + @Test + public void testPagedByteQuotaAccurateAfterRestartEventually() throws Exception { + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(500 * 1024L); + config.addResourceQuota("test-quota", quotaConfig); + + // Force paging early: max-size-bytes=1024 means only ~1 message fits in memory, + // the rest go to page files. Page files are NOT rebuilt synchronously on restart. + // pageSizeBytes is larger than individual messages so numberOfPages * pageSizeBytes + // produces a conservative overestimate. + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(1024L); + settings.setPageSizeBytes(10 * 1024); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("paging.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + long bytesBeforeRestart; + ServerLocator locator = null; + + try { + locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("paging.test"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("paging.queue").setAddress(address).setDurable(true)); + + ClientProducer producer = session.createProducer(address); + + // Send 20 messages — with max-size-bytes=1024, most will be paged + for (int i = 0; i < 20; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1024]); + producer.send(msg); + } + + // Confirm paging is active + Queue queue = server.locateQueue(SimpleString.of("paging.queue")); + assertTrue(queue.getPageSubscription().getPagingStore().isPaging(), + "Address must be paging with max-size-bytes=1024"); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + bytesBeforeRestart = quota.getCurrentMessageBytes(); + assertTrue(bytesBeforeRestart > 10_000, + "Should have significant bytes tracked before restart, was " + bytesBeforeRestart); + + session.close(); + sf.close(); + } finally { + if (locator != null) { + locator.close(); + } + server.stop(); + } + + // Restart — rebuildQuotaCounters runs asynchronously on the paging executor. + ActiveMQServer server2 = createServer(true, config); + server2.start(); + + try { + ResourceQuota quota = server2.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + long bytesAfterRestart = quota.getCurrentMessageBytes(); + + // The byte counter must reflect paged data immediately after restart + // via the preliminary estimate (numberOfPages * pageSizeBytes). + assertTrue(bytesAfterRestart > bytesBeforeRestart / 2, + "Byte counter must include paged data immediately after restart. " + + "Before restart: " + bytesBeforeRestart + ", after restart: " + bytesAfterRestart + + ". The gap indicates paged bytes were not yet rebuilt (Race 1)."); + + // Enforcement: the quota must know it's non-empty so it won't allow + // another full quota's worth of data. + assertFalse(quota.canAddBytes(quota.getMaxMessageBytes()), + "canAddBytes should deny adding the full quota limit again, " + + "but the counter is only " + bytesAfterRestart + " — quota doesn't see paged bytes yet"); + + // eventually it is correct + assertTrue(Wait.waitFor((Wait.Condition) () -> bytesBeforeRestart == quota.getCurrentMessageBytes())); + + } finally { + server2.stop(); + } + } + + @Test + public void testPagedByteQuotaAccurateAfterRestartMulticastTwoSubs() throws Exception { + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(500 * 1024L); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(1024L); + settings.setPageSizeBytes(10 * 1024); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("paging.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + long bytesBeforeRestart; + ServerLocator locator = null; + + try { + locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("paging.multicast"); + session.createAddress(address, RoutingType.MULTICAST, false); + session.createQueue(QueueConfiguration.of("sub1").setAddress(address).setDurable(true)); + session.createQueue(QueueConfiguration.of("sub2").setAddress(address).setDurable(true)); + + ClientProducer producer = session.createProducer(address); + + for (int i = 0; i < 20; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1024]); + producer.send(msg); + } + + Queue queue = server.locateQueue(SimpleString.of("sub1")); + assertTrue(queue.getPageSubscription().getPagingStore().isPaging(), + "Address must be paging with max-size-bytes=1024"); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + bytesBeforeRestart = quota.getCurrentMessageBytes(); + assertTrue(bytesBeforeRestart > 10_000, + "Should have significant bytes tracked before restart, was " + bytesBeforeRestart); + + session.close(); + sf.close(); + } finally { + if (locator != null) { + locator.close(); + } + server.stop(); + } + + ActiveMQServer server2 = createServer(true, config); + server2.start(); + + try { + ResourceQuota quota = server2.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + assertTrue(Wait.waitFor((Wait.Condition) () -> bytesBeforeRestart == quota.getCurrentMessageBytes()), "Byte counter must match after restart with two multicast subscriptions. " + + "Before restart: " + bytesBeforeRestart + ", after restart: " + quota.getCurrentMessageBytes() + + "; should match if page counters match quota."); + + } finally { + server2.stop(); + } + } + + @Test + public void testPagedLargeMessageByteQuotaAccurateAfterRestart() throws Exception { + final int largeMessageThreshold = 10 * 1024; + final int largeMessageBodySize = 20 * 1024; + + Configuration config = createDefaultConfig(true); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(2 * 1024 * 1024L); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(1024L); + settings.setPageSizeBytes(10 * 1024); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("paging.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + long bytesBeforeRestart; + ServerLocator locator = null; + + try { + locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + locator.setMinLargeMessageSize(largeMessageThreshold); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("paging.large"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("paging.large.queue").setAddress(address).setDurable(true)); + + ClientProducer producer = session.createProducer(address); + + for (int i = 0; i < 10; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[largeMessageBodySize]); + producer.send(msg); + } + + Queue queue = server.locateQueue(SimpleString.of("paging.large.queue")); + assertTrue(queue.getPageSubscription().getPagingStore().isPaging(), + "Address must be paging"); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + bytesBeforeRestart = quota.getCurrentMessageBytes(); + assertTrue(bytesBeforeRestart > 10 * largeMessageBodySize, + "Quota should include large message body sizes, was " + bytesBeforeRestart); + + session.close(); + sf.close(); + } finally { + if (locator != null) { + locator.close(); + } + server.stop(); + } + + ActiveMQServer server2 = createServer(true, config); + server2.start(); + + try { + ResourceQuota quota = server2.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + assertTrue(Wait.waitFor((Wait.Condition) () -> bytesBeforeRestart == quota.getCurrentMessageBytes()), + "Byte counter must match after restart with large messages. " + + "Before restart: " + bytesBeforeRestart + ", after restart: " + quota.getCurrentMessageBytes()); + + } finally { + server2.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRollbackVerificationTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRollbackVerificationTest.java new file mode 100644 index 00000000000..72aa11647a9 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaRollbackVerificationTest.java @@ -0,0 +1,415 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that quota byte counts are properly rolled back + * when messages are rejected due to quota limits. + */ +public class QuotaRollbackVerificationTest extends ActiveMQTestBase { + + @Test + public void testQuotaBytesNotIncrementedWhenMessageRejected() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with 1KB limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(1624L); // 1KB + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); // No address limit + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(true, config); // persistence for paging + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); // Block to get exceptions + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.rollback"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Get quota instance to check state + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.lookupQuota(address); + + // Verify quota starts at 0 + long initialBytes = quota.getCurrentMessageBytes(); + assertEquals(0L, initialBytes, "Quota should start at 0 bytes"); + + // Send first message (400 bytes) + ClientMessage msg1 = session.createMessage(true); + msg1.getBodyBuffer().writeBytes(new byte[200]); + producer.send(msg1); + + long afterFirst = quota.getCurrentMessageBytes(); + assertTrue(afterFirst > 0 && afterFirst <= 1024, + "Quota should include first message: " + afterFirst + " bytes"); + + // Send second message (400 bytes) - total ~800 bytes + ClientMessage msg2 = session.createMessage(true); + msg2.getBodyBuffer().writeBytes(new byte[200]); + producer.send(msg2); + + long afterSecond = quota.getCurrentMessageBytes(); + assertTrue(afterSecond > afterFirst && afterSecond <= 1524, + "Quota should include second message: " + afterSecond + " bytes"); + + // Now try to send a message that EXCEEDS the quota + boolean exceptionThrown = false; + try { + ClientMessage msg3 = session.createMessage(true); + msg3.getBodyBuffer().writeBytes(new byte[200]); // Would exceed 1KB + producer.send(msg3); + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + exceptionThrown = true; + } else { + throw e; // Unexpected exception type + } + } + + assertTrue(exceptionThrown, "Expected quota exceeded exception"); + + // CRITICAL: Verify quota was NOT incremented for the rejected message + long afterReject = quota.getCurrentMessageBytes(); + assertEquals(afterSecond, afterReject, + "ROLLBACK BUG: Quota should NOT include rejected message size! " + + "Before reject: " + afterSecond + ", After reject: " + afterReject); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testQuotaBytesDecrementedOnConsumption() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(2248L); // 2KB + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.decrement"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST).setDurable(true)); + + ClientProducer producer = session.createProducer(address); + + // Get quota to monitor + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.lookupQuota(address); + + // Send 3 messages + for (int i = 0; i < 3; i++) { + ClientMessage msg = session.createMessage(true); + // this is 690 bytes of quota for the check on the server with core! + msg.getBodyBuffer().writeBytes(new byte[200]); + producer.send(msg); + } + + long afterSend = quota.getCurrentMessageBytes(); + assertTrue(afterSend > 1000, "Quota should include all 3 messages: " + afterSend + " bytes"); + + // Consume 2 messages + session.start(); + var consumer = session.createConsumer("test.queue"); + ClientMessage received1 = consumer.receive(1000); + received1.acknowledge(); + ClientMessage received2 = consumer.receive(1000); + received2.acknowledge(); + session.commit(); + + // Quota should decrement when messages are consumed + long afterConsume = quota.getCurrentMessageBytes(); + assertTrue(afterConsume < afterSend, + "Quota should DECREASE after consuming messages. Before: " + afterSend + ", After: " + afterConsume); + + // Should be roughly 1 message worth left + assertTrue(afterConsume > 0 && afterConsume < afterSend / 2, + "Quota should reflect only 1 remaining message, got: " + afterConsume + " bytes"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testMultipleRejectionsDoNotAccumulateInQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Small quota for easy testing + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(900L); // Just enough for 1 message + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.PAGE); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(true, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.multiple"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.lookupQuota(address); + + // Send one message that fits + ClientMessage msg1 = session.createMessage(true); + msg1.getBodyBuffer().writeBytes(new byte[400]); + producer.send(msg1); + + long afterFirst = quota.getCurrentMessageBytes(); + + // Try to send 5 more messages that all get rejected + for (int i = 0; i < 5; i++) { + try { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[400]); + producer.send(msg); + } catch (ActiveMQException e) { + // Expected - quota exceeded + } + } + + // Quota should still only reflect the ONE accepted message + long afterRejects = quota.getCurrentMessageBytes(); + assertEquals(afterFirst, afterRejects, + "ACCUMULATION BUG: Multiple rejections should not accumulate! " + + "After 1st message: " + afterFirst + ", After 5 rejections: " + afterRejects); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testQuotaBytesNotIncrementedForRejectedNonDurableMessages() throws Exception { + Configuration config = createDefaultConfig(false); + + // Small quota for testing + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(1624L); // 1KB + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); // No address limit - stay in memory + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); // NO persistence - in-memory only + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnNonDurableSend(true); // Must block to get exceptions + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.nondurable.rollback"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.queue").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.lookupQuota(address); + + // Verify quota starts at 0 + assertEquals(0L, quota.getCurrentMessageBytes(), "Quota should start at 0"); + + // Send non-durable messages that fit + ClientMessage msg1 = session.createMessage(false); // NON-DURABLE + msg1.getBodyBuffer().writeBytes(new byte[300]); + producer.send(msg1); + + long afterFirst = quota.getCurrentMessageBytes(); + assertTrue(afterFirst > 0, "Quota should track non-durable message: " + afterFirst); + + ClientMessage msg2 = session.createMessage(false); // NON-DURABLE + msg2.getBodyBuffer().writeBytes(new byte[300]); + producer.send(msg2); + + long afterSecond = quota.getCurrentMessageBytes(); + assertTrue(afterSecond > afterFirst, "Quota should track second non-durable message: " + afterSecond); + + // Now try to send non-durable message that exceeds quota + boolean exceptionThrown = false; + try { + ClientMessage msg3 = session.createMessage(false); // NON-DURABLE + msg3.getBodyBuffer().writeBytes(new byte[300]); + producer.send(msg3); + } catch (ActiveMQException e) { + if (e.getMessage().contains("quota") || e.getMessage().contains("Resource quota exceeded")) { + exceptionThrown = true; + } else { + throw e; // Unexpected exception + } + } + + assertTrue(exceptionThrown, "Expected quota exceeded exception for non-durable message"); + + // CRITICAL: Verify non-durable message quota was NOT incremented for rejected message + long afterReject = quota.getCurrentMessageBytes(); + assertEquals(afterSecond, afterReject, + "Quota should NOT include rejected non-durable message! " + + "Before: " + afterSecond + " bytes, After: " + afterReject + " bytes"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + @Test + public void testNonDurableMessagesDecrementQuotaOnConsumption() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxMessageBytes(3000L); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + settings.setMaxSizeBytes(-1L); // In-memory + settings.setAddressFullMessagePolicy(AddressFullMessagePolicy.FAIL); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); // In-memory + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnNonDurableSend(true); // Required to get exception response for non-persistent sends + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, false, false); // Manual ack + + SimpleString address = SimpleString.of("test.nondurable.decrement"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.nondurable.decrement").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + + // Send 3 non-durable messages + for (int i = 0; i < 3; i++) { + ClientMessage msg = session.createMessage(false); // NON-DURABLE + msg.getBodyBuffer().writeBytes(new byte[300]); + producer.send(msg); + session.commit(); + } + + long afterSend = quota.getCurrentMessageBytes(); + assertTrue(afterSend > 600, "Quota should include all 3 non-durable messages: " + afterSend); + + // Consume and acknowledge 2 messages + session.start(); + var consumer = session.createConsumer(address); + + ClientMessage received1 = consumer.receive(1000); + received1.acknowledge(); + + ClientMessage received2 = consumer.receive(1000); + received2.acknowledge(); + + session.commit(); + + // Verify quota decremented for non-durable messages + long afterConsume = quota.getCurrentMessageBytes(); + assertTrue(afterConsume < afterSend, + "NON-DURABLE DECREMENT: Quota should decrease after consuming non-durable messages. " + + "Before: " + afterSend + ", After: " + afterConsume); + + // Should be roughly 1 message left + assertTrue(afterConsume > 0 && afterConsume < afterSend / 2, + "Quota should reflect ~1 non-durable message remaining, got: " + afterConsume); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaTokenRollbackTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaTokenRollbackTest.java new file mode 100644 index 00000000000..1ad4b5ce0fe --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/QuotaTokenRollbackTest.java @@ -0,0 +1,351 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.paging.PagingStore; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.artemis.tests.util.Wait; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for quota token rollback behavior. + * + * These tests verify that quota accounting is transactional: + * - Quota is reserved when operations start + * - Quota is committed when operations succeed + * - Quota is automatically rolled back when operations fail + */ +public class QuotaTokenRollbackTest extends ActiveMQTestBase { + + /** + * Test that quota is correctly accounted for when messages are successfully routed. + */ + @Test + public void testQuotaIncrementedOnSuccessfulSend() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("success-quota"); + quotaConfig.setMaxMessageBytes(10240L); // 10KB + config.addResourceQuota("success-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("success-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.success"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.success").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Get quota instance from ResourceQuotaService (not config) + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + + long initialSize = quota.getCurrentMessageBytes(); + + // Send 5 messages of 100 bytes each + for (int i = 0; i < 5; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + } + + // Quota should be incremented by approximately 500 bytes (body) plus memory overhead + // (buffer allocations + reference overhead ~500 bytes per message) + long finalSize = quota.getCurrentMessageBytes(); + long delta = finalSize - initialSize; + + assertTrue(delta >= 500, "Quota should increase by at least message body size. Delta: " + delta); + assertTrue(delta < 4000, "Quota increase should be reasonable (body + memory overhead). Delta: " + delta); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that quota is rolled back when transaction is rolled back. + * Uses QuotaTransactionOperation to participate in transaction lifecycle. + */ + @Test + public void testQuotaRollbackOnTransactionRollback() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("rollback-quota"); + quotaConfig.setMaxMessageBytes(10240L); // 10KB + config.addResourceQuota("rollback-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("rollback-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + // Create transacted session + ClientSession session = sf.createSession(false, false, false); + + SimpleString address = SimpleString.of("test.rollback"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.rollback").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + // Get quota instance from ResourceQuotaService (not config) + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + + long initialSize = quota.getCurrentMessageBytes(); + + // Send messages in transaction + for (int i = 0; i < 5; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + } + + // Note: Quota increment behavior in transactions depends on when routing occurs. + // The important test is that rollback returns quota to initial state. + + // Rollback transaction + session.rollback(); + + // Quota should be rolled back to initial size + long finalSize = quota.getCurrentMessageBytes(); + assertTrue(Wait.waitFor(() -> finalSize == quota.getCurrentMessageBytes()), + "Quota should be rolled back to initial size after transaction rollback"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that quota doesn't leak when messages fail to route. + */ + @Test + public void testQuotaNoLeakOnRoutingFailure() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("leak-quota"); + quotaConfig.setMaxMessageBytes(10240L); // 10KB + config.addResourceQuota("leak-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("leak-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + locator.setBlockOnDurableSend(true); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.leak"); + session.createAddress(address, RoutingType.ANYCAST, false); + + // Create queue but then delete it + session.createQueue(QueueConfiguration.of("test.leak").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + session.deleteQueue(SimpleString.of("test.leak")); + + ClientProducer producer = session.createProducer(address); + + // Get quota instance from ResourceQuotaService (not config) + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + + long initialSize = quota.getCurrentMessageBytes(); + + // send with no bindings + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + + // Quota should not have leaked - should be back to initial + long finalSize = quota.getCurrentMessageBytes(); + assertEquals(initialSize, finalSize, + "Quota should not leak when routing fails"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that quota accounting matches actual message size in paging store. + */ + @Test + public void testQuotaSizeMatchesPagingStoreSize() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("size-quota"); + quotaConfig.setMaxMessageBytes(10240L); // 10KB + config.addResourceQuota("size-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("size-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + SimpleString address = SimpleString.of("test.size"); + session.createAddress(address, RoutingType.ANYCAST, false); + session.createQueue(QueueConfiguration.of("test.size").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + ClientProducer producer = session.createProducer(address); + + ClientMessage message = null; + // Send messages + for (int i = 0; i < 10; i++) { + message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + } + + // Get sizes + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + long quotaSize = quota.getCurrentMessageBytes(); + + PagingStore pagingStore = server.getPagingManager().getPageStore(address); + long pagingStoreSize = pagingStore.getAddressSize(); + + // Quota size should match paging store size + assertEquals(pagingStoreSize, quotaSize, + "Quota size should match paging store size, memSize:" + message.getMemoryEstimate() + ", persistedSize:" + message.getPersistentSize()); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that quota handles concurrent sends correctly without double-counting. + */ + @Test + public void testQuotaConcurrentSendsNoDoubleCounting() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("concurrent-quota"); + quotaConfig.setMaxMessageBytes(102400L); // 100KB + config.addResourceQuota("concurrent-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("concurrent-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + SimpleString address = SimpleString.of("test.concurrent"); + AddressInfo addressInfo = new AddressInfo(address, RoutingType.ANYCAST); + server.addAddressInfo(addressInfo); + server.createQueue(QueueConfiguration.of("test.concurrent").setAddress(address).setRoutingType(RoutingType.ANYCAST)); + + // Create multiple producers sending concurrently + Thread[] threads = new Thread[5]; + for (int t = 0; t < threads.length; t++) { + threads[t] = new Thread(() -> { + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + ClientProducer producer = session.createProducer(address); + + for (int i = 0; i < 10; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + } + + session.close(); + locator.close(); + } catch (Exception e) { + e.printStackTrace(); + } + }); + threads[t].start(); + } + + // Wait for all threads + for (Thread thread : threads) { + thread.join(); + } + + // Verify quota matches paging store (no double counting) + ResourceQuota quota = server.getResourceQuotaService().lookupQuota(address); + long quotaSize = quota.getCurrentMessageBytes(); + + PagingStore pagingStore = server.getPagingManager().getPageStore(address); + long pagingStoreSize = pagingStore.getAddressSize(); + + assertEquals(pagingStoreSize, quotaSize, + "Quota size should match paging store size (no double counting)"); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaEdgeCasesTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaEdgeCasesTest.java new file mode 100644 index 00000000000..ed88360b7be --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaEdgeCasesTest.java @@ -0,0 +1,456 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for edge cases and boundary conditions in resource quota enforcement. + */ +public class ResourceQuotaEdgeCasesTest extends ActiveMQTestBase { + + @Test + public void testZeroLimitEnforcement() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with zero limits - should reject immediately + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("zero-quota"); + quotaConfig.setMaxAddresses(0); + quotaConfig.setMaxQueues(0); + config.addResourceQuota("zero-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("zero-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // First address should fail with zero limit + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)) + ); + assertTrue(exception.getMessage().contains("Address quota exceeded")); + assertTrue(exception.getMessage().contains("max addresses is 0")); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("zero-quota"); + assertNotNull(quota); + + assertEquals(0, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testNegativeLimitMeansUnlimited() throws Exception { + Configuration config = createDefaultConfig(false); + + // Negative limits mean unlimited + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("unlimited-quota"); + quotaConfig.setMaxAddresses(-1); + quotaConfig.setMaxQueues(-1); + config.addResourceQuota("unlimited-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("unlimited-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Should be able to create many addresses + for (int i = 0; i < 100; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("unlimited-quota"); + assertNotNull(quota); + + assertEquals(100, quota.getAddressCount()); + + // Create first address to attach queues to + server.addAddressInfo(new AddressInfo(SimpleString.of("test.queues"), RoutingType.ANYCAST)); + + // Should be able to create many queues + for (int i = 0; i < 50; i++) { + server.createQueue(QueueConfiguration.of("queue" + i).setAddress("test.queues")); + } + + assertEquals(50, quota.getQueueCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testNullLimitMeansUnlimited() throws Exception { + Configuration config = createDefaultConfig(false); + + // Null limits (not set) mean unlimited + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("null-quota"); + // Don't set maxAddresses or maxQueues - they default to null + config.addResourceQuota("null-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("null-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Should be able to create many addresses + for (int i = 0; i < 50; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("null-quota"); + assertNotNull(quota); + + assertEquals(50, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testAddressDeletionDecrementsQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create 5 addresses (at limit) + for (int i = 1; i <= 5; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(5, quota.getAddressCount()); + + // Should be at limit + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST)) + ); + + // Remove 2 addresses + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + assertEquals(4, quota.getAddressCount()); + + server.removeAddressInfo(SimpleString.of("test.addr2"), null); + assertEquals(3, quota.getAddressCount()); + + // Verify addr6 doesn't exist yet + AddressInfo addr6Before = server.getAddressInfo(SimpleString.of("test.addr6")); + assertTrue(addr6Before == null, "addr6 should not exist before creation"); + + // Should be able to create 2 more + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST)); + + // Verify addr6 was actually created + AddressInfo addr6After = server.getAddressInfo(SimpleString.of("test.addr6")); + assertNotNull(addr6After, "addr6 should exist after creation"); + + assertEquals(4, quota.getAddressCount(), "Quota should be 4 after creating addr6"); + + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr7"), RoutingType.ANYCAST)); + assertEquals(5, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testQueueDeletionDecrementsQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create address + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr"), RoutingType.ANYCAST)); + + // Create 5 queues (at limit) + for (int i = 1; i <= 5; i++) { + server.createQueue(QueueConfiguration.of("queue" + i).setAddress("test.addr")); + } + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(5, quota.getQueueCount()); + + // Should be at limit + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.createQueue(QueueConfiguration.of("queue6").setAddress("test.addr")) + ); + + // Destroy 2 queues + server.destroyQueue(SimpleString.of("queue1")); + assertEquals(4, quota.getQueueCount()); + + server.destroyQueue(SimpleString.of("queue2")); + assertEquals(3, quota.getQueueCount()); + + // Should be able to create 2 more + server.createQueue(QueueConfiguration.of("queue6").setAddress("test.addr")); + assertEquals(4, quota.getQueueCount()); + + server.createQueue(QueueConfiguration.of("queue7").setAddress("test.addr")); + assertEquals(5, quota.getQueueCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testDuplicateAddressRollback() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create first address + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(1, quota.getAddressCount()); + + // Try to create duplicate - should fail and rollback quota + try { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + } catch (Exception e) { + // Expected - duplicate address + } + + // Quota should still be 1 (rollback worked) + assertEquals(1, quota.getAddressCount()); + + // Should still have capacity + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST)); + assertEquals(2, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testHierarchyWithZeroParentLimit() throws Exception { + Configuration config = createDefaultConfig(false); + + // Parent with zero limit + ResourceQuotaConfig parentQuota = new ResourceQuotaConfig("parent"); + parentQuota.setMaxAddresses(0); + config.addResourceQuota("parent", parentQuota); + + // Child with higher limit but constrained by parent + ResourceQuotaConfig childQuota = new ResourceQuotaConfig("child"); + childQuota.setMaxAddresses(10); + childQuota.setPartOf("parent"); + config.addResourceQuota("child", childQuota); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("child"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Should fail immediately due to parent limit of 0 + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)) + ); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("child"); + assertNotNull(quota); + + assertEquals(0, quota.getAddressCount()); + + quota = quotaService.getQuotaByName("parent"); + assertNotNull(quota); + assertEquals(0, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testMixedLimitsInHierarchy() throws Exception { + Configuration config = createDefaultConfig(false); + + // Parent with address limit but no queue limit + ResourceQuotaConfig parentQuota = new ResourceQuotaConfig("parent"); + parentQuota.setMaxAddresses(5); + parentQuota.setMaxQueues(-1); // unlimited + config.addResourceQuota("parent", parentQuota); + + // Child with queue limit but unlimited addresses + ResourceQuotaConfig childQuota = new ResourceQuotaConfig("child"); + childQuota.setMaxAddresses(-1); // unlimited (but parent constrains) + childQuota.setMaxQueues(3); + childQuota.setPartOf("parent"); + config.addResourceQuota("child", childQuota); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("child"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create 5 addresses (parent limit) + for (int i = 1; i <= 5; i++) { + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST)); + } + + // Sixth address should fail (parent limit) + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST)) + ); + + // Create 3 queues on first address (child limit) + for (int i = 1; i <= 3; i++) { + server.createQueue(QueueConfiguration.of("queue" + i).setAddress("test.addr1")); + } + + // Fourth queue should fail (child limit) + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr1")) + ); + + } finally { + server.stop(); + } + } + + @Test + public void testQuotaCountNeverGoesNegative() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create an address + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(1, quota.getAddressCount()); + + // Remove it + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + assertEquals(0, quota.getAddressCount()); + + // Try to remove non-existent address (should not decrement below 0) + try { + server.removeAddressInfo(SimpleString.of("test.nonexistent"), null); + } catch (Exception e) { + // Expected - address doesn't exist + } + + // Count should still be 0, not negative + assertTrue(quota.getAddressCount() >= 0); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaIntegrationTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaIntegrationTest.java new file mode 100644 index 00000000000..700661c76e2 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaIntegrationTest.java @@ -0,0 +1,330 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ResourceQuotaIntegrationTest extends ActiveMQTestBase { + + @Test + public void testAddressQuotaEnforcement() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with max 2 addresses + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(2); + config.addResourceQuota("test-quota", quotaConfig); + + // Configure address settings to use this quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create first address - should succeed + AddressInfo addr1 = new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST); + server.addAddressInfo(addr1); + + // Create second address - should succeed + AddressInfo addr2 = new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST); + server.addAddressInfo(addr2); + + // Create third address - should fail due to quota + AddressInfo addr3 = new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST); + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(addr3) + ); + assertTrue(exception.getMessage().contains("Address quota exceeded")); + assertTrue(exception.getMessage().contains("test-quota")); + + // Remove one address + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + + // Now creating addr3 should succeed + server.addAddressInfo(addr3); + + } finally { + server.stop(); + } + } + + @Test + public void testQueueQuotaEnforcement() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with max 3 queues + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(3); + config.addResourceQuota("test-quota", quotaConfig); + + // Configure address settings to use this quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create address first + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr"), RoutingType.ANYCAST); + server.addAddressInfo(addr); + + // Create three queues - should all succeed + server.createQueue(QueueConfiguration.of("queue1").setAddress("test.addr")); + server.createQueue(QueueConfiguration.of("queue2").setAddress("test.addr")); + server.createQueue(QueueConfiguration.of("queue3").setAddress("test.addr")); + + // Create fourth queue - should fail due to quota + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr")) + ); + assertTrue(exception.getMessage().contains("Queue quota exceeded")); + assertTrue(exception.getMessage().contains("test-quota")); + + // Destroy one queue + server.destroyQueue(SimpleString.of("queue1")); + + // Now creating queue4 should succeed + server.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr")); + + } finally { + server.stop(); + } + } + + @Test + public void testConcurrentAddressCreationWithQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with max 10 addresses + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(10); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + int numThreads = 20; + int successCount = 0; + int failureCount = 0; + + Thread[] threads = new Thread[numThreads]; + boolean[] results = new boolean[numThreads]; + + for (int i = 0; i < numThreads; i++) { + final int index = i; + threads[i] = new Thread(() -> { + try { + AddressInfo addr = new AddressInfo( + SimpleString.of("test.concurrent.addr" + index), + RoutingType.ANYCAST + ); + server.addAddressInfo(addr); + results[index] = true; + } catch (Exception e) { + assertInstanceOf(ActiveMQResourceQuotaExceededException.class, e); + results[index] = false; + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads + for (Thread thread : threads) { + thread.join(); + } + + // Count successes and failures + for (boolean result : results) { + if (result) { + successCount++; + } else { + failureCount++; + } + } + + // Exactly 10 should succeed due to quota limit + assertEquals(10, successCount, "Expected exactly 10 successful address creations"); + assertEquals(10, failureCount, "Expected exactly 10 failed address creations"); + + } finally { + server.stop(); + } + } + + @Test + public void testQuotaRollbackOnDuplicate() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create quota with max 5 addresses + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create 3 addresses + for (int i = 1; i <= 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + server.addAddressInfo(addr); + } + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota, "Quota 'test-quota' should exist"); + + // Verify quota count is 3 + assertEquals(3, quota.getAddressCount()); + + // Try to create an address that already exists (duplicate) + AddressInfo addr1Duplicate = new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST); + boolean duplicateResult = server.addAddressInfo(addr1Duplicate); + // Should return false for duplicate + assertFalse(duplicateResult, "Duplicate address should return false"); + + // Quota count should still be 3 (no change for duplicate) + assertEquals(3, quota.getAddressCount()); + + // We should still be able to create 2 more addresses + AddressInfo addr4 = new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST); + server.addAddressInfo(addr4); + + AddressInfo addr5 = new AddressInfo(SimpleString.of("test.addr5"), RoutingType.ANYCAST); + server.addAddressInfo(addr5); + + assertEquals(5, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testHierarchicalQuotaEnforcement() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create parent quota + ResourceQuotaConfig parentQuota = new ResourceQuotaConfig("parent"); + parentQuota.setMaxAddresses(5); + config.addResourceQuota("parent", parentQuota); + + // Create child quota with higher limit but part of parent + ResourceQuotaConfig childQuota = new ResourceQuotaConfig("child"); + childQuota.setMaxAddresses(10); + childQuota.setPartOf("parent"); + config.addResourceQuota("child", childQuota); + + // Configure address settings to use child quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("child"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Establish parent relationship (normally done by ResourceQuotaManager) + // childQuota.setParent(parentQuota); + + // Create 5 addresses - should succeed (parent limit) + for (int i = 1; i <= 5; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + server.addAddressInfo(addr); + } + + // Sixth address should fail due to parent limit (even though child limit is 10) + AddressInfo addr6 = new AddressInfo(SimpleString.of("test.addr6"), RoutingType.ANYCAST); + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(addr6) + ); + assertTrue(exception.getMessage().contains("quota exceeded")); + + } finally { + server.stop(); + } + } + + @Test + public void testNoQuotaConfigured() throws Exception { + Configuration config = createDefaultConfig(false); + + // No quota configured + AddressSettings settings = new AddressSettings(); + // resourceQuota not set + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Should be able to create many addresses without quota + for (int i = 1; i <= 100; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + server.addAddressInfo(addr); + } + + // All should succeed - no quota enforcement + // Verify first and last addresses exist + assertNotNull(server.getAddressInfo(SimpleString.of("test.addr1"))); + assertNotNull(server.getAddressInfo(SimpleString.of("test.addr100"))); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaJMXTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaJMXTest.java new file mode 100644 index 00000000000..74ab33b2f38 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaJMXTest.java @@ -0,0 +1,160 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import javax.management.MBeanServer; +import javax.management.MBeanServerInvocationHandler; +import javax.management.ObjectName; +import java.lang.management.ManagementFactory; + +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.management.ResourceQuotaControl; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for ResourceQuota JMX management. + */ +public class ResourceQuotaJMXTest extends ActiveMQTestBase { + + @Test + public void testResourceQuotaJMXRegistration() throws Exception { + ActiveMQServer server = createServer(true, createDefaultInVMConfig().setJMXManagementEnabled(true)); + + // Configure resource quotas + ResourceQuotaConfig globalQuota = new ResourceQuotaConfig("global"); + globalQuota.setMaxAddresses(100); + globalQuota.setMaxQueues(200); + globalQuota.setMaxMessageBytes(1000000L); + + ResourceQuotaConfig tenantQuota = new ResourceQuotaConfig("tenant1"); + tenantQuota.setPartOf("global"); + tenantQuota.setMaxAddresses(30); + tenantQuota.setMaxQueues(60); + + server.getConfiguration().addResourceQuota("global", globalQuota); + server.getConfiguration().addResourceQuota("tenant1", tenantQuota); + + // Configure address settings to use quota + server.getConfiguration().getAddressSettings().put("tenant1.#", + new org.apache.activemq.artemis.core.settings.impl.AddressSettings() + .setResourceQuota("tenant1")); + + server.start(); + + try { + MBeanServer mbeanServer = ManagementFactory.getPlatformMBeanServer(); + + // Verify global quota is registered + ObjectName globalObjectName = new ObjectName( + "org.apache.activemq.artemis:broker=\"localhost\",component=quotas,name=\"global\""); + assertTrue(mbeanServer.isRegistered(globalObjectName), "Global quota should be registered in JMX"); + + ResourceQuotaControl globalControl = MBeanServerInvocationHandler + .newProxyInstance(mbeanServer, globalObjectName, ResourceQuotaControl.class, false); + + assertEquals("global", globalControl.getName()); + assertNull(globalControl.getPartOf()); + assertEquals(100, globalControl.getMaxAddresses()); + assertEquals(200, globalControl.getMaxQueues()); + assertEquals(1000000L, globalControl.getMaxMessageBytes()); + assertEquals(0, globalControl.getCurrentAddressCount()); + assertEquals(0, globalControl.getCurrentQueueCount()); + assertEquals(0L, globalControl.getCurrentMessageBytes()); + assertTrue(globalControl.hasLimits()); + assertFalse(globalControl.isLimitReached()); + + // Verify tenant quota is registered + ObjectName tenantObjectName = new ObjectName( + "org.apache.activemq.artemis:broker=\"localhost\",component=quotas,name=\"tenant1\""); + assertTrue(mbeanServer.isRegistered(tenantObjectName), "Tenant quota should be registered in JMX"); + + ResourceQuotaControl tenantControl = MBeanServerInvocationHandler + .newProxyInstance(mbeanServer, tenantObjectName, ResourceQuotaControl.class, false); + + assertEquals("tenant1", tenantControl.getName()); + assertEquals("global", tenantControl.getPartOf()); + assertEquals(30, tenantControl.getMaxAddresses()); + assertEquals(60, tenantControl.getMaxQueues()); + assertEquals(-1, tenantControl.getMaxMessageBytes()); // No limit configured for bytes + + // Create some addresses and verify counters update + AddressInfo addr1 = new AddressInfo(SimpleString.of("tenant1.app1"), RoutingType.ANYCAST); + server.addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("tenant1.app2"), RoutingType.ANYCAST); + server.addAddressInfo(addr2); + + // Counters should reflect the addresses + assertEquals(2, tenantControl.getCurrentAddressCount()); + assertEquals(2, globalControl.getCurrentAddressCount()); // Parent tracks child's addresses + + // Check utilization percentages + double tenantUtilization = tenantControl.getAddressUtilizationPercent(); + assertTrue(tenantUtilization > 0 && tenantUtilization < 100, + "Tenant address utilization should be between 0 and 100"); + + double globalUtilization = globalControl.getAddressUtilizationPercent(); + assertTrue(globalUtilization > 0 && globalUtilization < 100, + "Global address utilization should be between 0 and 100"); + + } finally { + server.stop(); + } + } + + @Test + public void testResourceQuotaJMXUnregistration() throws Exception { + ActiveMQServer server = createServer(true, createDefaultInVMConfig().setJMXManagementEnabled(true)); + + // Configure a quota + ResourceQuotaConfig testQuota = new ResourceQuotaConfig("test-quota"); + testQuota.setMaxAddresses(10); + + server.getConfiguration().addResourceQuota("test-quota", testQuota); + server.start(); + + try { + MBeanServer mbeanServer = ManagementFactory.getPlatformMBeanServer(); + ObjectName quotaObjectName = new ObjectName( + "org.apache.activemq.artemis:broker=\"localhost\",component=quotas,name=\"test-quota\""); + + assertTrue(mbeanServer.isRegistered(quotaObjectName), "Quota should be registered"); + + // Reload with empty config (removes quota) + server.getConfiguration().getResourceQuotas().clear(); + server.getResourceQuotaService().reloadQuotas(); + + // Small delay to allow async JMX operations + Thread.sleep(100); + + assertFalse(mbeanServer.isRegistered(quotaObjectName), "Quota should be unregistered after reload"); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaReloadTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaReloadTest.java new file mode 100644 index 00000000000..1e4508b153f --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaReloadTest.java @@ -0,0 +1,2586 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager; +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.apache.activemq.artemis.utils.ReusableLatch; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for runtime resource quota configuration reload. + */ +public class ResourceQuotaReloadTest extends ActiveMQTestBase { + + private static final String BROKER_XML_BOILERPLATE = """ + + + + 0.0.0.0 + 100 + false + false + NIO + ./target/data/paging + ./target/data/bindings + ./target/data/journal + ./target/data/large-messages + + vm://0 + + %s + + + """; + + private void writeConfig(Path brokerXML, String quotaContent) throws Exception { + Files.writeString(brokerXML, String.format(BROKER_XML_BOILERPLATE, quotaContent)); + } + + private void reloadConfiguration(EmbeddedActiveMQ server, Path brokerXML, + String quotaContent, ReusableLatch latch) throws Exception { + latch.setCount(1); + server.getActiveMQServer().getReloadManager().setTick(latch::countDown); + writeConfig(brokerXML, quotaContent); + brokerXML.toFile().setLastModified(System.currentTimeMillis()); + assertTrue(latch.await(10, TimeUnit.SECONDS), "Configuration reload timed out"); + } + + /** + * Add a new quota at runtime + * Verifies that a new quota can be added via reload and immediately enforced. + */ + @Test + public void testAddQuotaAtRuntime() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + 20 + + + + + test-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + // Verify initial quota exists + ResourceQuotaService quotaService = embeddedActiveMQ.getActiveMQServer().getResourceQuotaService(); + assertNotNull(quotaService); + ResourceQuotaManager quotaManager = quotaService.getResourceQuotaManager(); + assertNotNull(quotaManager); + + ResourceQuota initialQuota = quotaManager.getQuota("test-quota"); + assertNotNull(initialQuota, "Initial quota should exist"); + assertEquals(10, initialQuota.getMaxAddresses()); + + // Verify new quota does not exist yet + ResourceQuota newQuota = quotaManager.getQuota("new-quota"); + assertNull(newQuota, "New quota should not exist before reload"); + + String addQuotaConfig = """ + + + 10 + 20 + + + 5 + + + + + test-quota + + + new-quota + + """; + + // Reload configuration with new quota + reloadConfiguration(embeddedActiveMQ, brokerXML, addQuotaConfig, latch); + + // Verify new quota now exists + newQuota = quotaManager.getQuota("new-quota"); + assertNotNull(newQuota, "New quota should exist after reload"); + assertEquals(5, newQuota.getMaxAddresses()); + + // Verify old quota still exists + initialQuota = quotaManager.getQuota("test-quota"); + assertNotNull(initialQuota, "Initial quota should still exist"); + + // Verify new quota is enforced - create addresses under new.# pattern + AddressInfo addr1 = new AddressInfo(SimpleString.of("new.addr1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + // Verify counter incremented + newQuota = quotaManager.getQuota("new-quota"); + assertEquals(1, newQuota.getCurrentAddressCount(), "New quota should track created address"); + + // reload again with no change does not update the instance + reloadConfiguration(embeddedActiveMQ, brokerXML, addQuotaConfig, latch); + assertSame(newQuota, quotaManager.getQuota("new-quota")); + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Remove quota at runtime + * Verifies that removing a quota disables enforcement gracefully. + */ + @Test + public void testRemoveQuotaAtRuntime() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + 20 + + + + + test-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + // Verify quota exists + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + ResourceQuota quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota, "Quota should exist initially"); + + // Create an address under quota + AddressInfo addr1 = new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + // Verify quota tracks it + quota = quotaManager.getQuota("test-quota"); + assertEquals(1, quota.getCurrentAddressCount()); + + // Reload configuration without quota + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + + """, latch); + + // Verify quota removed + quota = quotaManager.getQuota("test-quota"); + assertNull(quota, "Quota should be removed after reload"); + + // Verify addresses still work - create more addresses (no quota limit now) + for (int i = 0; i < 15; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Success - no exception thrown, quota enforcement disabled + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Modify quota limits + * Verifies that changing quota limits updates enforcement and rebuilds counters. + */ + @Test + public void testModifyQuotaLimits() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + 20 + + + + + test-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial limits + ResourceQuota quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota); + assertEquals(10, quota.getMaxAddresses()); + assertEquals(20, quota.getMaxQueues()); + + // Create 5 addresses + for (int i = 0; i < 5; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify counter + quota = quotaManager.getQuota("test-quota"); + assertEquals(5, quota.getCurrentAddressCount()); + + // Reload with modified limits + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 20 + 40 + + + + + test-quota + + """, latch); + + // Verify limits changed + quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota); + assertEquals(20, quota.getMaxAddresses(), "Max addresses should be updated to 20"); + assertEquals(40, quota.getMaxQueues(), "Max queues should be updated to 40"); + + // Verify counter rebuilt correctly + assertEquals(5, quota.getCurrentAddressCount(), "Counter should be rebuilt to 5"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Wildcard quota template preserved on reload + * Verifies wildcard template quotas work after reload with modified limits. + */ + @Test + public void testWildcardQuotaTemplatePreservedOnReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 15 + + + + + region.* + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify wildcard template exists + ResourceQuota template = quotaManager.getQuota("region.*"); + assertNotNull(template, "Wildcard template should exist"); + assertEquals(15, template.getMaxAddresses()); + + // Create addresses that match wildcard pattern + AddressInfo addr1 = new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr2); + + // Reload configuration with modified limits + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 25 + + + + + region.* + + """, latch); + + // Verify wildcard template still exists with updated limits + template = quotaManager.getQuota("region.*"); + assertNotNull(template, "Wildcard template should exist after reload"); + assertEquals(25, template.getMaxAddresses(), "Max addresses should be updated to 25"); + + // Verify can create more addresses + AddressInfo addr3 = new AddressInfo(SimpleString.of("region.ap.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr3); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Parent hierarchy reload + * Verifies parent-child quota relationships work after reload with modified limits. + */ + @Test + public void testParentHierarchyReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + global + 30 + + + + + tenant1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify parent and child quotas exist + ResourceQuota parent = quotaManager.getQuota("global"); + ResourceQuota child = quotaManager.getQuota("tenant1"); + assertNotNull(parent, "Parent quota should exist"); + assertNotNull(child, "Child quota should exist"); + + // Verify parent relationship + assertEquals("global", child.getPartOf(), "Child should reference parent"); + assertNotNull(child.getParent(), "Child should have parent reference"); + assertEquals("global", child.getParent().getName(), "Child parent should be global"); + + // Create addresses under child quota + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("tenant1.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify counters and initial limits + child = quotaManager.getQuota("tenant1"); + parent = quotaManager.getQuota("global"); + assertEquals(3, child.getCurrentAddressCount(), "Child should track 3 addresses"); + assertEquals(3, parent.getCurrentAddressCount(), "Parent should also track 3 addresses"); + assertEquals(30, child.getMaxAddresses(), "Initial child max should be 30"); + assertEquals(100, parent.getMaxAddresses(), "Initial parent max should be 100"); + + // Reload configuration with modified limits + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 200 + + + global + 50 + + + + + tenant1 + + """, latch); + + // Verify hierarchy still correct after reload + child = quotaManager.getQuota("tenant1"); + parent = quotaManager.getQuota("global"); + assertNotNull(child.getParent(), "Child should still have parent after reload"); + assertEquals("global", child.getParent().getName()); + + // Verify limits changed + assertEquals(50, child.getMaxAddresses(), "Child max should be updated to 50"); + assertEquals(200, parent.getMaxAddresses(), "Parent max should be updated to 200"); + + // Verify counters rebuilt correctly + assertEquals(3, child.getCurrentAddressCount(), "Child counter should be rebuilt"); + assertEquals(3, parent.getCurrentAddressCount(), "Parent counter should be rebuilt"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Wildcard quota decrement on removal + * Verifies that wildcard quota instances properly decrement counters + * when addresses/queues are removed. + */ + @Test + public void testWildcardQuotaDecrementOnRemoval() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 15 + + + + + region.* + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify wildcard template exists + ResourceQuota template = quotaManager.getQuota("region.*"); + assertNotNull(template, "Wildcard template should exist"); + assertEquals(15, template.getMaxAddresses()); + + // Create addresses that match wildcard pattern - this should create instances + AddressInfo addr1 = new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("region.us.payments"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr2); + + AddressInfo addr3 = new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr3); + + // Check that wildcard instances were created and have correct counts + // The quota lookup should return the instantiated quota (region.us or region.eu) + ResourceQuota usQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.us.orders")); + assertNotNull(usQuota, "US region quota instance should exist"); + assertEquals(2, usQuota.getCurrentAddressCount(), "US region should have 2 addresses"); + + ResourceQuota euQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.eu.orders")); + assertNotNull(euQuota, "EU region quota instance should exist"); + assertEquals(1, euQuota.getCurrentAddressCount(), "EU region should have 1 address"); + + // Now remove one US address + embeddedActiveMQ.getActiveMQServer().removeAddressInfo(SimpleString.of("region.us.orders"), null); + + // Verify US quota decremented + usQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.us.payments")); + assertEquals(1, usQuota.getCurrentAddressCount(), "US region should have 1 address after removal"); + + // EU quota should be unchanged + euQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.eu.orders")); + assertEquals(1, euQuota.getCurrentAddressCount(), "EU region should still have 1 address"); + + // Remove remaining US address + embeddedActiveMQ.getActiveMQServer().removeAddressInfo(SimpleString.of("region.us.payments"), null); + + // Verify US quota is now at 0 + usQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.us.test")); + assertEquals(0, usQuota.getCurrentAddressCount(), "US region should have 0 addresses after all removed"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Circular reference detection during reload + * Verifies that circular parent references in config are detected and handled gracefully. + */ + @Test + public void testCircularReferenceDetectionOnReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + 20 + + + + + test-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial quota has no parent + ResourceQuota quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota); + assertNull(quota.getParent()); + + // Reload with circular reference config + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + + quota2 + 10 + + + quota1 + 20 + + + + + quota1 + + """, latch); + + // Verify both quotas exist but neither has parent (circular reference detected) + ResourceQuota quota1 = quotaManager.getQuota("quota1"); + ResourceQuota quota2 = quotaManager.getQuota("quota2"); + + assertNotNull(quota1, "quota1 should exist"); + assertNotNull(quota2, "quota2 should exist"); + + // Circular reference should be detected and broken + assertNull(quota1.getParent(), "quota1 should have no parent due to circular reference"); + assertNull(quota2.getParent(), "quota2 should have no parent due to circular reference"); + + // Verify quotas still function (limits enforced even without parent) + assertEquals(10, quota1.getMaxAddresses()); + assertEquals(20, quota2.getMaxAddresses()); + + // Verify can create addresses under the quota + AddressInfo addr1 = new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + // Should use quota1 based on address-settings + ResourceQuota usedQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("test.addr1")); + assertEquals("quota1", usedQuota.getName()); + assertEquals(1, usedQuota.getCurrentAddressCount()); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Queue quota enforcement after reload + * Verifies that queue quotas are properly enforced after configuration reload + * and that queue counters are correctly rebuilt. + */ + @Test + public void testQueueQuotaEnforcementAfterReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 20 + 10 + + + + + queue-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial quota + ResourceQuota quota = quotaManager.getQuota("queue-quota"); + assertNotNull(quota); + assertEquals(10, quota.getMaxQueues()); + + // Create an address and some queues + AddressInfo addr1 = new AddressInfo(SimpleString.of("queue.test1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("q1") + .setAddress("queue.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("q2") + .setAddress("queue.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("q3") + .setAddress("queue.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + // Verify counters + quota = quotaManager.getQuota("queue-quota"); + assertEquals(1, quota.getCurrentAddressCount()); + assertEquals(3, quota.getCurrentQueueCount()); + + // Reload with modified queue limit + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 20 + 25 + + + + + queue-quota + + """, latch); + + // Verify queue limit changed and counters rebuilt + quota = quotaManager.getQuota("queue-quota"); + assertNotNull(quota); + assertEquals(25, quota.getMaxQueues(), "Max queues should be updated to 25"); + assertEquals(1, quota.getCurrentAddressCount(), "Address counter should be rebuilt to 1"); + assertEquals(3, quota.getCurrentQueueCount(), "Queue counter should be rebuilt to 3"); + + // Verify can create more queues (new limit allows it) + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("q4") + .setAddress("queue.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + quota = quotaManager.getQuota("queue-quota"); + assertEquals(4, quota.getCurrentQueueCount(), "Queue counter should increment to 4"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Wildcard quota instance cleanup on reload + * Verifies that instantiated wildcard quotas are cleaned up when + * the template is removed from configuration. + */ + @Test + public void testWildcardQuotaInstancesCleanedOnReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 15 + + + + + region.* + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Create addresses that instantiate the wildcard template + AddressInfo addr1 = new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr2); + + // Verify instances were created + ResourceQuota usQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.us.orders")); + assertNotNull(usQuota, "US region quota instance should exist"); + + ResourceQuota euQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.eu.orders")); + assertNotNull(euQuota, "EU region quota instance should exist"); + + // Reload config without wildcard quota + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + + """, latch); + + // Verify template is removed + ResourceQuota template = quotaManager.getQuota("region.*"); + assertNull(template, "Wildcard template should be removed"); + + // Verify addresses still exist + assertNotNull(embeddedActiveMQ.getActiveMQServer().getAddressInfo(SimpleString.of("region.us.orders"))); + assertNotNull(embeddedActiveMQ.getActiveMQServer().getAddressInfo(SimpleString.of("region.eu.orders"))); + + // Verify no quota is applied to these addresses anymore + ResourceQuota noQuota = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("region.us.orders")); + assertNull(noQuota, "No quota should be applied after template removal"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Change parent relationship during reload + * Verifies that changing a quota's parent hierarchy during reload + * correctly updates counters throughout the new hierarchy. + */ + @Test + public void testChangeParentOnReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + global + 30 + + + + + tenant1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial hierarchy: global -> tenant1 + ResourceQuota global = quotaManager.getQuota("global"); + ResourceQuota tenant1 = quotaManager.getQuota("tenant1"); + assertNotNull(global); + assertNotNull(tenant1); + assertEquals("global", tenant1.getPartOf()); + assertNotNull(tenant1.getParent()); + assertEquals("global", tenant1.getParent().getName()); + + // Create addresses under tenant1 + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("tenant1.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify initial counters + tenant1 = quotaManager.getQuota("tenant1"); + global = quotaManager.getQuota("global"); + assertEquals(3, tenant1.getCurrentAddressCount()); + assertEquals(3, global.getCurrentAddressCount(), "Parent global should track 3 addresses"); + + // Reload with new hierarchy: global -> newParent -> tenant1 + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + global + 50 + + + newParent + 30 + + + + + tenant1 + + """, latch); + + // Verify new hierarchy + global = quotaManager.getQuota("global"); + ResourceQuota newParent = quotaManager.getQuota("newParent"); + tenant1 = quotaManager.getQuota("tenant1"); + + assertNotNull(global); + assertNotNull(newParent); + assertNotNull(tenant1); + + assertEquals("newParent", tenant1.getPartOf(), "tenant1 should now be part of newParent"); + assertEquals("newParent", tenant1.getParent().getName()); + assertEquals("global", newParent.getPartOf(), "newParent should be part of global"); + assertEquals("global", newParent.getParent().getName()); + + // Verify counters are correct throughout hierarchy + assertEquals(3, tenant1.getCurrentAddressCount(), "tenant1 should have 3 addresses"); + assertEquals(3, newParent.getCurrentAddressCount(), "newParent should track 3 addresses from tenant1"); + assertEquals(3, global.getCurrentAddressCount(), "global should track 3 addresses from entire hierarchy"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Address settings quota mapping change. + * Verifies that when address-settings mapping changes to point to a different quota, + * counters are rebalanced: decremented from the old quota, incremented on the new one. + */ + @Test + public void testAddressSettingsQuotaMappingChange() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + + + 20 + + + + + quota1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify both quotas exist + ResourceQuota quota1 = quotaManager.getQuota("quota1"); + ResourceQuota quota2 = quotaManager.getQuota("quota2"); + assertNotNull(quota1); + assertNotNull(quota2); + + // Create addresses under test.# pattern (mapped to quota1 initially) + for (int i = 0; i < 5; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify quota1 tracks these addresses + quota1 = quotaManager.getQuota("quota1"); + quota2 = quotaManager.getQuota("quota2"); + assertEquals(5, quota1.getCurrentAddressCount(), "quota1 should track 5 addresses"); + assertEquals(0, quota2.getCurrentAddressCount(), "quota2 should track 0 addresses"); + + // Verify lookup returns quota1 + ResourceQuota lookedUp = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("test.addr0")); + assertEquals("quota1", lookedUp.getName()); + + // Reload with address-settings changed to map test.# to quota2 + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 10 + + + 20 + + + + + quota2 + + """, latch); + + // Verify quotas still exist + quota1 = quotaManager.getQuota("quota1"); + quota2 = quotaManager.getQuota("quota2"); + assertNotNull(quota1); + assertNotNull(quota2); + + // Verify quota2 now tracks these addresses (counters rebuilt) + assertEquals(0, quota1.getCurrentAddressCount(), "quota1 should no longer track addresses"); + assertEquals(5, quota2.getCurrentAddressCount(), "quota2 should now track 5 addresses"); + + // Verify lookup now returns quota2 + lookedUp = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().lookupQuota(SimpleString.of("test.addr0")); + assertEquals("quota2", lookedUp.getName()); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Parent quota removed while child exists + * Verifies that when a parent quota is removed but the child remains, + * the child functions correctly without a parent. + */ + @Test + public void testParentRemovedChildRemains() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + global + 30 + + + + + tenant1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify parent-child hierarchy exists + ResourceQuota global = quotaManager.getQuota("global"); + ResourceQuota tenant1 = quotaManager.getQuota("tenant1"); + assertNotNull(global); + assertNotNull(tenant1); + assertEquals("global", tenant1.getParent().getName()); + + // Create addresses under child + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("tenant1.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify both track the addresses + tenant1 = quotaManager.getQuota("tenant1"); + global = quotaManager.getQuota("global"); + assertEquals(3, tenant1.getCurrentAddressCount()); + assertEquals(3, global.getCurrentAddressCount()); + + // Reload config removing parent + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + global + 30 + + + + + tenant1 + + """, latch); + + // Verify parent is gone + global = quotaManager.getQuota("global"); + assertNull(global, "Parent quota 'global' should be removed"); + + // Verify child still exists with correct counters + tenant1 = quotaManager.getQuota("tenant1"); + assertNotNull(tenant1, "Child quota 'tenant1' should still exist"); + assertEquals(3, tenant1.getCurrentAddressCount(), "Child counter should be rebuilt to 3"); + + // Verify child has no parent (even though part-of is still set in config) + assertNull(tenant1.getParent(), "Child should have no parent since parent was removed"); + + // Verify child quota still functions - can create more addresses + AddressInfo addr = new AddressInfo(SimpleString.of("tenant1.app99"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + + tenant1 = quotaManager.getQuota("tenant1"); + assertEquals(4, tenant1.getCurrentAddressCount(), "Child should track new address"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + @Test + public void testMessageBytesCounterRebuildOnReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100000 + 50 + + + + + bytes-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial quota + ResourceQuota quota = quotaManager.getQuota("bytes-quota"); + assertNotNull(quota); + assertEquals(100000L, quota.getMaxMessageBytes()); + assertEquals(0L, quota.getCurrentMessageBytes(), "Initial bytes should be 0"); + + // Create an address and queue + AddressInfo addr1 = new AddressInfo(SimpleString.of("bytes.test1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("bytesQueue1") + .setAddress("bytes.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + // Send real messages using Core API client to build up bytes + org.apache.activemq.artemis.api.core.client.ServerLocator locator = createInVMNonHALocator(); + org.apache.activemq.artemis.api.core.client.ClientSessionFactory sf = createSessionFactory(locator); + org.apache.activemq.artemis.api.core.client.ClientSession session = sf.createSession(false, true, true); + org.apache.activemq.artemis.api.core.client.ClientProducer producer = session.createProducer("bytes.test1"); + + // Send 50 messages of ~1000 bytes each = ~50KB total + for (int i = 0; i < 50; i++) { + org.apache.activemq.artemis.api.core.client.ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1000]); + producer.send(msg); + } + + session.close(); + sf.close(); + locator.close(); + + // Verify quota tracked the bytes + quota = quotaManager.getQuota("bytes-quota"); + long bytesBeforeReload = quota.getCurrentMessageBytes(); + assertTrue(bytesBeforeReload > 0, "Quota should track message bytes before reload"); + + // Reload with modified byte limit + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 200000 + 50 + + + + + bytes-quota + + """, latch); + + // Verify byte limit changed + quota = quotaManager.getQuota("bytes-quota"); + assertNotNull(quota); + assertEquals(200000L, quota.getMaxMessageBytes(), "Max bytes should be updated to 200000"); + + // CRITICAL: Verify bytes counter was rebuilt correctly + long bytesAfterReload = quota.getCurrentMessageBytes(); + assertEquals(bytesBeforeReload, bytesAfterReload, + "Message bytes counter should be rebuilt to match actual size in PagingStore. " + + "This test exposes the bug that rebuildCountersForQuota() doesn't rebuild message bytes!"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + + /** + * Message bytes hierarchy rebuild on reload + * Verifies that parent quotas correctly track total bytes from child quotas + * after reload, with proper counter rebuilding throughout the hierarchy. + */ + @Test + public void testMessageBytesHierarchyRebuild() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 500000 + + + global-bytes + 200000 + + + + + tenant-bytes + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify hierarchy exists + ResourceQuota globalQuota = quotaManager.getQuota("global-bytes"); + ResourceQuota tenantQuota = quotaManager.getQuota("tenant-bytes"); + assertNotNull(globalQuota); + assertNotNull(tenantQuota); + assertEquals("global-bytes", tenantQuota.getParent().getName()); + + // Create address and queue under child quota + AddressInfo addr1 = new AddressInfo(SimpleString.of("tenant.app1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("tenantQueue1") + .setAddress("tenant.app1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + // Send real messages to build up size in the hierarchy + org.apache.activemq.artemis.api.core.client.ServerLocator locator = createInVMNonHALocator(); + org.apache.activemq.artemis.api.core.client.ClientSessionFactory sf = createSessionFactory(locator); + org.apache.activemq.artemis.api.core.client.ClientSession session = sf.createSession(false, true, true); + org.apache.activemq.artemis.api.core.client.ClientProducer producer = session.createProducer("tenant.app1"); + + // Send 50 messages of ~1000 bytes each = ~50KB + for (int i = 0; i < 50; i++) { + org.apache.activemq.artemis.api.core.client.ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1000]); + producer.send(msg); + } + + session.close(); + sf.close(); + locator.close(); + + // Verify both quotas track the bytes + tenantQuota = quotaManager.getQuota("tenant-bytes"); + globalQuota = quotaManager.getQuota("global-bytes"); + long tenantBytesBeforeReload = tenantQuota.getCurrentMessageBytes(); + long globalBytesBeforeReload = globalQuota.getCurrentMessageBytes(); + + assertTrue(tenantBytesBeforeReload > 0, "Tenant should track bytes"); + assertEquals(tenantBytesBeforeReload, globalBytesBeforeReload, + "Parent should track same bytes as child (hierarchy propagation)"); + + // Verify initial limits + assertEquals(200000L, tenantQuota.getMaxMessageBytes(), "Initial tenant max should be 200000"); + assertEquals(500000L, globalQuota.getMaxMessageBytes(), "Initial global max should be 500000"); + + // Reload configuration with modified limits + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 1000000 + + + global-bytes + 400000 + + + + + tenant-bytes + + """, latch); + + // Verify hierarchy still correct + tenantQuota = quotaManager.getQuota("tenant-bytes"); + globalQuota = quotaManager.getQuota("global-bytes"); + assertNotNull(tenantQuota.getParent()); + assertEquals("global-bytes", tenantQuota.getParent().getName()); + + // Verify limits changed + assertEquals(400000L, tenantQuota.getMaxMessageBytes(), "Tenant max should be updated to 400000"); + assertEquals(1000000L, globalQuota.getMaxMessageBytes(), "Global max should be updated to 1000000"); + + // CRITICAL: Verify bytes rebuilt correctly throughout hierarchy + long tenantBytesAfterReload = tenantQuota.getCurrentMessageBytes(); + long globalBytesAfterReload = globalQuota.getCurrentMessageBytes(); + + assertEquals(tenantBytesBeforeReload, tenantBytesAfterReload, + "Tenant bytes should be rebuilt correctly"); + assertEquals(globalBytesBeforeReload, globalBytesAfterReload, + "Parent bytes should be rebuilt correctly (this will fail without fix)"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Test that when multiple sibling children both change parents during reload, + * the shared original parent's counters are reset only once (not double-reset). + * This is a regression test for the double-reset bug where each child's rebuild + * would reset the entire parent chain, causing shared parents to lose counts. + */ + @Test + public void testMultipleSiblingsChangeParent() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + global + 30 + + + global + 30 + + + + + child1 + + + child2 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial hierarchy: global <- child1, global <- child2 + ResourceQuota global = quotaManager.getQuota("global"); + ResourceQuota child1 = quotaManager.getQuota("child1"); + ResourceQuota child2 = quotaManager.getQuota("child2"); + assertNotNull(global); + assertNotNull(child1); + assertNotNull(child2); + assertEquals("global", child1.getPartOf()); + assertEquals("global", child2.getPartOf()); + assertEquals("global", child1.getParent().getName()); + assertEquals("global", child2.getParent().getName()); + + // Create addresses under both children + for (int i = 0; i < 3; i++) { + AddressInfo addr1 = new AddressInfo(SimpleString.of("child1.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + + AddressInfo addr2 = new AddressInfo(SimpleString.of("child2.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr2); + } + + // Verify counts before reload + assertEquals(3, child1.getCurrentAddressCount(), "child1 should have 3 addresses"); + assertEquals(3, child2.getCurrentAddressCount(), "child2 should have 3 addresses"); + assertEquals(6, global.getCurrentAddressCount(), "global should track 6 addresses (3+3)"); + + // Reload config where both children change to different new parents + // child1: global -> newParent1, child2: global -> newParent2 + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + global + 50 + + + global + 50 + + + newParent1 + 30 + + + newParent2 + 30 + + + + + child1 + + + child2 + + """, latch); + + // Get updated quota references + global = quotaManager.getQuota("global"); + child1 = quotaManager.getQuota("child1"); + child2 = quotaManager.getQuota("child2"); + ResourceQuota newParent1 = quotaManager.getQuota("newParent1"); + ResourceQuota newParent2 = quotaManager.getQuota("newParent2"); + + assertNotNull(global); + assertNotNull(child1); + assertNotNull(child2); + assertNotNull(newParent1); + assertNotNull(newParent2); + + // Verify new hierarchy + assertEquals("newParent1", child1.getPartOf()); + assertEquals("newParent2", child2.getPartOf()); + assertEquals("newParent1", child1.getParent().getName()); + assertEquals("newParent2", child2.getParent().getName()); + assertEquals("global", newParent1.getParent().getName()); + assertEquals("global", newParent2.getParent().getName()); + + // Verify counts after reload + // This is the critical test: without the alreadyReset fix, global would be + // reset twice (once for child1's rebuild, once for child2's rebuild) but + // only rebuilt once (from the last child processed), losing half the counts. + assertEquals(3, child1.getCurrentAddressCount(), "child1 should still have 3 addresses"); + assertEquals(3, child2.getCurrentAddressCount(), "child2 should still have 3 addresses"); + assertEquals(3, newParent1.getCurrentAddressCount(), "newParent1 should track 3 addresses from child1"); + assertEquals(3, newParent2.getCurrentAddressCount(), "newParent2 should track 3 addresses from child2"); + assertEquals(6, global.getCurrentAddressCount(), + "global should track 6 addresses (3 from newParent1 + 3 from newParent2) - " + + "this will fail without the double-reset fix"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Test that quota enforcement is preserved continuously during reload when + * the hierarchy changes. + */ + @Test + public void testReloadPreservesQuotaEnforcementDuringHierarchyChange() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 5 + + + + + test-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial quota exists with no parent + ResourceQuota quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota); + assertEquals(5, quota.getMaxAddresses()); + assertNull(quota.getParent(), "Initial quota should have no parent"); + + // Create exactly 5 addresses to reach the limit + for (int i = 0; i < 5; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("child.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify quota is at its limit + assertEquals(5, quota.getCurrentAddressCount(), "Should have 5 addresses"); + assertFalse(quota.canAddAddress(), "Should be at limit - no more addresses allowed"); + + // Verify enforcement works before reload + assertThrows( + org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException.class, + () -> embeddedActiveMQ.getActiveMQServer().addAddressInfo( + new AddressInfo(SimpleString.of("child.overflow"), RoutingType.ANYCAST)), + "Should reject address creation when quota is at limit"); + + // Set up concurrent enforcement checker. + // This thread polls canAddAddress() in a tight loop to detect if + // counters are ever reset to zero during reload. + final AtomicBoolean windowDetected = new AtomicBoolean(false); + final AtomicBoolean checkingActive = new AtomicBoolean(true); + final CountDownLatch checkerStarted = new CountDownLatch(1); + final ResourceQuota quotaRef = quota; + + Thread enforcementChecker = new Thread(() -> { + checkerStarted.countDown(); + while (checkingActive.get()) { + if (quotaRef.canAddAddress()) { + windowDetected.set(true); + } + } + }); + enforcementChecker.setDaemon(true); + enforcementChecker.start(); + checkerStarted.await(); + + // Trigger reload that adds a parent (partOf changes from null to "parent-quota"). + // This hierarchy change causes rebuildCountersForQuota() which calls + // resetCounters() (zeroing all counters) then scans to rebuild them. + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + parent-quota + 5 + + + + + test-quota + + """, latch); + + // Stop the checker + checkingActive.set(false); + enforcementChecker.join(5000); + + // Verify counters were rebuilt correctly after reload + quota = quotaManager.getQuota("test-quota"); + assertNotNull(quota); + assertEquals(5, quota.getCurrentAddressCount(), "Counter should be rebuilt to 5 after reload"); + assertFalse(quota.canAddAddress(), "Should still be at limit after reload"); + + // Verify parent relationship was established + assertNotNull(quota.getParent(), "Should have parent after reload"); + assertEquals("parent-quota", quota.getParent().getName()); + + // THE KEY ASSERTION: enforcement should never have been broken during reload. + // This fails because resetCounters() zeros the counters before the rebuild + // scan restores them, creating a window where canAddAddress() returns true + // for a quota that is at its limit. + assertFalse(windowDetected.get(), + "Quota enforcement was temporarily broken during reload: canAddAddress() returned true " + + "for a quota at its limit. The reload resets counters to zero before rebuilding, " + + "creating a window where quota limits are not enforced."); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Byte counters preserved during hierarchy change. + * Verifies that adjustParentChain correctly handles byte deltas + * when a quota's partOf changes. + */ + @Test + public void testHierarchyChangePreservesByteCounters() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 500000 + + + global-bytes + 200000 + + + + + tenant-bytes + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial hierarchy: global-bytes -> tenant-bytes + ResourceQuota globalQuota = quotaManager.getQuota("global-bytes"); + ResourceQuota tenantQuota = quotaManager.getQuota("tenant-bytes"); + assertNotNull(globalQuota); + assertNotNull(tenantQuota); + assertEquals("global-bytes", tenantQuota.getParent().getName()); + + // Create address, queue, and send messages to build up bytes + AddressInfo addr1 = new AddressInfo(SimpleString.of("tenant.app1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("tenantQ1") + .setAddress("tenant.app1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + + org.apache.activemq.artemis.api.core.client.ServerLocator locator = createInVMNonHALocator(); + org.apache.activemq.artemis.api.core.client.ClientSessionFactory sf = createSessionFactory(locator); + org.apache.activemq.artemis.api.core.client.ClientSession session = sf.createSession(false, true, true); + org.apache.activemq.artemis.api.core.client.ClientProducer producer = session.createProducer("tenant.app1"); + + for (int i = 0; i < 50; i++) { + org.apache.activemq.artemis.api.core.client.ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1000]); + producer.send(msg); + } + session.close(); + sf.close(); + locator.close(); + + // Capture counters before reload + tenantQuota = quotaManager.getQuota("tenant-bytes"); + globalQuota = quotaManager.getQuota("global-bytes"); + long tenantBytesBefore = tenantQuota.getCurrentMessageBytes(); + int tenantAddrBefore = tenantQuota.getCurrentAddressCount(); + int tenantQueueBefore = tenantQuota.getCurrentQueueCount(); + assertTrue(tenantBytesBefore > 0, "Tenant should have bytes"); + assertEquals(tenantBytesBefore, globalQuota.getCurrentMessageBytes()); + + // Reload: tenant-bytes changes partOf from global-bytes to new-parent-bytes + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 500000 + + + 300000 + + + new-parent-bytes + 200000 + + + + + tenant-bytes + + """, latch); + + // Verify tenant counters preserved (never zeroed) + tenantQuota = quotaManager.getQuota("tenant-bytes"); + assertEquals(tenantBytesBefore, tenantQuota.getCurrentMessageBytes(), "Tenant bytes should be preserved"); + assertEquals(tenantAddrBefore, tenantQuota.getCurrentAddressCount(), "Tenant addresses should be preserved"); + assertEquals(tenantQueueBefore, tenantQuota.getCurrentQueueCount(), "Tenant queues should be preserved"); + + // Verify new parent gets tenant's counters + ResourceQuota newParent = quotaManager.getQuota("new-parent-bytes"); + assertNotNull(newParent, "new-parent-bytes should exist"); + assertEquals("new-parent-bytes", tenantQuota.getParent().getName()); + assertEquals(tenantBytesBefore, newParent.getCurrentMessageBytes(), "New parent should aggregate tenant bytes"); + + // Verify old parent lost tenant's counters + globalQuota = quotaManager.getQuota("global-bytes"); + assertEquals(0, globalQuota.getCurrentMessageBytes(), "Global should have 0 bytes after tenant moved away"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Quota loses its parent (partOf removed from config). + * Verifies that removing the partOf field from a quota's config + * correctly detaches it from the parent while preserving its own counters. + */ + @Test + public void testRemovePartOfFromQuota() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + global + 30 + + + + + tenant1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial hierarchy: global -> tenant1 + ResourceQuota global = quotaManager.getQuota("global"); + ResourceQuota tenant1 = quotaManager.getQuota("tenant1"); + assertNotNull(global); + assertNotNull(tenant1); + assertEquals("global", tenant1.getPartOf()); + assertNotNull(tenant1.getParent()); + + // Create addresses + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("tenant1.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + assertEquals(3, tenant1.getCurrentAddressCount()); + assertEquals(3, global.getCurrentAddressCount()); + + // Reload: tenant1 loses partOf (no element), global still exists + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + 30 + + + + + tenant1 + + """, latch); + + // Verify tenant1 counters preserved and parent detached + tenant1 = quotaManager.getQuota("tenant1"); + global = quotaManager.getQuota("global"); + assertNotNull(tenant1); + assertNotNull(global); + assertEquals(3, tenant1.getCurrentAddressCount(), "Tenant counters should be preserved"); + assertNull(tenant1.getParent(), "Tenant should have no parent after partOf removed"); + assertNull(tenant1.getPartOf(), "Tenant partOf config should be null"); + + // Global should have been decremented by delta + assertEquals(0, global.getCurrentAddressCount(), "Global should be decremented to 0"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Add quota when none existed, with pre-existing addresses. + * Tests both manager creation from null and populateCountersForNewQuota + * scanning pre-existing addresses. + */ + @Test + public void testAddQuotaWithPreExistingAddresses() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + -1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + // Verify no quota manager initially + ResourceQuotaService quotaService = embeddedActiveMQ.getActiveMQServer().getResourceQuotaService(); + assertNotNull(quotaService); + assertNull(quotaService.getResourceQuotaManager(), "Manager should be null with no quotas"); + + // Create addresses and queues BEFORE any quota exists + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("quota.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("q" + i) + .setAddress("quota.addr" + i) + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + } + + // Reload: add quota for quota.# pattern + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 10 + 20 + + + + + new-quota + + """, latch); + + // Verify manager was created + ResourceQuotaManager quotaManager = quotaService.getResourceQuotaManager(); + assertNotNull(quotaManager, "Manager should be created after reload"); + + // Verify new quota picked up pre-existing addresses and queues + ResourceQuota newQuota = quotaManager.getQuota("new-quota"); + assertNotNull(newQuota, "new-quota should exist"); + assertEquals(3, newQuota.getCurrentAddressCount(), + "New quota should have scanned pre-existing addresses"); + assertEquals(3, newQuota.getCurrentQueueCount(), + "New quota should have scanned pre-existing queues"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * New child quota added under existing parent that already has counters. + * Verifies that populateCountersForNewQuota correctly propagates to parent + * without double-counting. + */ + @Test + public void testNewChildQuotaUnderExistingParent() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + + + parent-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify only parent-quota exists + ResourceQuota parentQuota = quotaManager.getQuota("parent-quota"); + assertNotNull(parentQuota); + assertNull(quotaManager.getQuota("child-quota"), "child-quota should not exist yet"); + + // Create addresses under parent.# (tracked by parent-quota) + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("parent.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Create addresses under child.# (no quota yet) + for (int i = 0; i < 2; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("child.app" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + assertEquals(3, parentQuota.getCurrentAddressCount(), "Parent should track its own 3 addresses"); + + // Reload: add child-quota with partOf=parent-quota for child.# + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + parent-quota + 50 + + + + + parent-quota + + + child-quota + + """, latch); + + // Verify child-quota picks up pre-existing child.* addresses + ResourceQuota childQuota = quotaManager.getQuota("child-quota"); + assertNotNull(childQuota, "child-quota should exist after reload"); + assertEquals(2, childQuota.getCurrentAddressCount(), + "child-quota should have scanned 2 pre-existing child addresses"); + + // Verify parent aggregates correctly: 3 (own) + 2 (from child propagation) = 5 + parentQuota = quotaManager.getQuota("parent-quota"); + assertEquals(5, parentQuota.getCurrentAddressCount(), + "Parent should aggregate own (3) + child (2) = 5 addresses"); + + // Verify hierarchy + assertEquals("parent-quota", childQuota.getParent().getName()); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Nested hierarchy changes in same reload. + * Both parent and child change partOf simultaneously. Verifies that + * delta adjustments produce correct results regardless of HashMap iteration order. + */ + @Test + public void testNestedHierarchyChangesInSameReload() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + root + 50 + + + parent + 10 + + + + + child + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify initial hierarchy: root -> parent -> child + ResourceQuota root = quotaManager.getQuota("root"); + ResourceQuota parent = quotaManager.getQuota("parent"); + ResourceQuota child = quotaManager.getQuota("child"); + assertNotNull(root); + assertNotNull(parent); + assertNotNull(child); + assertEquals("parent", child.getPartOf()); + assertEquals("root", parent.getPartOf()); + + // Create addresses under child + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("child.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + assertEquals(3, child.getCurrentAddressCount()); + assertEquals(3, parent.getCurrentAddressCount(), "Parent should aggregate child's 3"); + assertEquals(3, root.getCurrentAddressCount(), "Root should aggregate 3 through parent"); + + // Reload: child changes partOf to root, parent changes partOf to newRoot + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + 100 + + + newRoot + 50 + + + root + 10 + + + + + child + + """, latch); + + // Verify new hierarchy + child = quotaManager.getQuota("child"); + parent = quotaManager.getQuota("parent"); + root = quotaManager.getQuota("root"); + ResourceQuota newRoot = quotaManager.getQuota("newRoot"); + assertNotNull(newRoot); + + assertEquals("root", child.getPartOf(), "child should now be partOf root"); + assertEquals("newRoot", parent.getPartOf(), "parent should now be partOf newRoot"); + + // Verify counters are correct + assertEquals(3, child.getCurrentAddressCount(), "child counters should be preserved"); + assertEquals(0, parent.getCurrentAddressCount(), "parent should have 0 (child moved away)"); + assertEquals(3, root.getCurrentAddressCount(), "root should have 3 (from child directly)"); + assertEquals(0, newRoot.getCurrentAddressCount(), "newRoot should have 0 (parent has no addresses)"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Wildcard template partOf change during reload. + * Verifies that when a wildcard template quota changes its partOf, + * both the template and its instantiated children are reparented + * with counters correctly moved between parent chains. + */ + @Test + public void testWildcardTemplatePartOfChange() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + parent-quota + 15 + + + + + region.* + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + ResourceQuotaService quotaService = embeddedActiveMQ.getActiveMQServer().getResourceQuotaService(); + + // Verify initial hierarchy: parent-quota -> region.* + ResourceQuota parentQuota = quotaManager.getQuota("parent-quota"); + ResourceQuota template = quotaManager.getQuota("region.*"); + assertNotNull(parentQuota); + assertNotNull(template); + assertEquals("parent-quota", template.getPartOf()); + + // Create addresses to instantiate wildcard instances + AddressInfo addr1 = new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr1); + AddressInfo addr2 = new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr2); + + // Verify instances were created and parent tracks addresses + ResourceQuota usQuota = quotaService.lookupQuota(SimpleString.of("region.us.orders")); + ResourceQuota euQuota = quotaService.lookupQuota(SimpleString.of("region.eu.orders")); + assertNotNull(usQuota, "US instance should exist"); + assertNotNull(euQuota, "EU instance should exist"); + assertEquals(1, usQuota.getCurrentAddressCount()); + assertEquals(1, euQuota.getCurrentAddressCount()); + + parentQuota = quotaManager.getQuota("parent-quota"); + int parentAddrBefore = parentQuota.getCurrentAddressCount(); + assertTrue(parentAddrBefore > 0, "Parent should have addresses from wildcard instances"); + + // Reload: region.* template changes partOf from parent-quota to new-parent + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + 100 + + + new-parent + 15 + + + + + region.* + + """, latch); + + // Verify template's partOf updated correctly + template = quotaManager.getQuota("region.*"); + assertNotNull(template, "Template should still exist"); + assertEquals("new-parent", template.getPartOf()); + + // Verify instances were reparented: their partOf and parent should + // now point to new-parent, not parent-quota + usQuota = quotaService.lookupQuota(SimpleString.of("region.us.orders")); + euQuota = quotaService.lookupQuota(SimpleString.of("region.eu.orders")); + assertEquals("new-parent", usQuota.getPartOf(), "US instance should have new partOf"); + assertEquals("new-parent", euQuota.getPartOf(), "EU instance should have new partOf"); + assertEquals(1, usQuota.getCurrentAddressCount(), "US instance counters should be preserved"); + assertEquals(1, euQuota.getCurrentAddressCount(), "EU instance counters should be preserved"); + + // Verify new parent gets the instance counts + ResourceQuota newParent = quotaManager.getQuota("new-parent"); + assertNotNull(newParent, "new-parent should exist"); + assertEquals(parentAddrBefore, newParent.getCurrentAddressCount(), + "New parent should aggregate instance counts"); + + // Verify old parent lost the instance counts + parentQuota = quotaManager.getQuota("parent-quota"); + assertEquals(0, parentQuota.getCurrentAddressCount(), + "Old parent should have 0 addresses after instances reparented"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Queue enforcement preserved during hierarchy change. + * Concurrent thread polls canAddQueue() while a reload changes the quota's partOf. + */ + @Test + public void testReloadPreservesQueueEnforcementDuringHierarchyChange() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 5 + + + + + queue-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + ResourceQuota quota = quotaManager.getQuota("queue-quota"); + assertNotNull(quota); + assertEquals(5, quota.getMaxQueues()); + + // Create address and 5 queues to reach the limit + AddressInfo addr = new AddressInfo(SimpleString.of("qenf.test1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + for (int i = 0; i < 5; i++) { + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("qenf.q" + i) + .setAddress("qenf.test1") + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + } + + assertEquals(5, quota.getCurrentQueueCount()); + assertFalse(quota.canAddQueue(), "Should be at queue limit"); + + // Set up concurrent queue enforcement checker + final AtomicBoolean windowDetected = new AtomicBoolean(false); + final AtomicBoolean checkingActive = new AtomicBoolean(true); + final CountDownLatch checkerStarted = new CountDownLatch(1); + final ResourceQuota quotaRef = quota; + + Thread enforcementChecker = new Thread(() -> { + checkerStarted.countDown(); + while (checkingActive.get()) { + if (quotaRef.canAddQueue()) { + windowDetected.set(true); + } + } + }); + enforcementChecker.setDaemon(true); + enforcementChecker.start(); + checkerStarted.await(); + + // Trigger reload adding partOf + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + parent-quota + 5 + + + + + queue-quota + + """, latch); + + checkingActive.set(false); + enforcementChecker.join(5000); + + // Verify counters rebuilt and enforcement preserved + quota = quotaManager.getQuota("queue-quota"); + assertEquals(5, quota.getCurrentQueueCount(), "Queue count should be preserved"); + assertFalse(quota.canAddQueue(), "Should still be at queue limit"); + assertNotNull(quota.getParent(), "Should have parent after reload"); + + assertFalse(windowDetected.get(), + "Queue enforcement was temporarily broken during reload: canAddQueue() returned true " + + "for a quota at its queue limit."); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Byte enforcement preserved during hierarchy change. + * Concurrent thread polls canAddBytes() while a reload changes the quota's partOf. + */ + @Test + public void testReloadPreservesByteEnforcementDuringHierarchyChange() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 50000 + + + + + bytes-quota + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + ResourceQuota quota = quotaManager.getQuota("bytes-quota"); + assertNotNull(quota); + assertEquals(50000L, quota.getMaxMessageBytes()); + + // Create address so the quota is active + AddressInfo addr = new AddressInfo(SimpleString.of("benf.test1"), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + + // Add bytes directly to reach exactly the limit + quota.addSize(50000); + long bytesBefore = quota.getCurrentMessageBytes(); + assertEquals(50000L, bytesBefore); + assertFalse(quota.canAddBytes(1), "Should not allow more bytes at limit"); + + // Set up concurrent byte enforcement checker + final AtomicBoolean windowDetected = new AtomicBoolean(false); + final AtomicBoolean checkingActive = new AtomicBoolean(true); + final CountDownLatch checkerStarted = new CountDownLatch(1); + final ResourceQuota quotaRef = quota; + + Thread enforcementChecker = new Thread(() -> { + checkerStarted.countDown(); + while (checkingActive.get()) { + if (quotaRef.canAddBytes(1)) { + windowDetected.set(true); + } + } + }); + enforcementChecker.setDaemon(true); + enforcementChecker.start(); + checkerStarted.await(); + + // Trigger reload adding partOf + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 500000 + + + parent-quota + 50000 + + + + + bytes-quota + + """, latch); + + checkingActive.set(false); + enforcementChecker.join(5000); + + // Verify byte counters preserved and enforcement maintained + quota = quotaManager.getQuota("bytes-quota"); + assertEquals(bytesBefore, quota.getCurrentMessageBytes(), "Byte count should be preserved"); + assertFalse(quota.canAddBytes(1), "Should still be over byte limit"); + assertNotNull(quota.getParent(), "Should have parent after reload"); + + assertFalse(windowDetected.get(), + "Byte enforcement was temporarily broken during reload: canAddBytes() returned true " + + "for a quota over its byte limit."); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Address-settings mapping change with queues and bytes. + * Verifies that when address-settings remaps addresses to a different quota, + * address counts, queue counts, AND byte counts are all rebalanced. + */ + @Test + public void testAddressSettingsQuotaMappingChangeWithQueuesAndBytes() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 10 + 20 + 500000 + + + 20 + 40 + 1000000 + + + + + quota1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + ResourceQuota quota1 = quotaManager.getQuota("quota1"); + ResourceQuota quota2 = quotaManager.getQuota("quota2"); + assertNotNull(quota1); + assertNotNull(quota2); + + // Create 3 addresses, each with 2 queues + for (int i = 0; i < 3; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + for (int q = 0; q < 2; q++) { + embeddedActiveMQ.getActiveMQServer().createQueue( + org.apache.activemq.artemis.api.core.QueueConfiguration.of("test.addr" + i + ".q" + q) + .setAddress("test.addr" + i) + .setRoutingType(RoutingType.ANYCAST) + .setDurable(false)); + } + } + + // Send messages to build up bytes on test.addr0 + org.apache.activemq.artemis.api.core.client.ServerLocator locator = createInVMNonHALocator(); + org.apache.activemq.artemis.api.core.client.ClientSessionFactory sf = createSessionFactory(locator); + org.apache.activemq.artemis.api.core.client.ClientSession session = sf.createSession(false, true, true); + org.apache.activemq.artemis.api.core.client.ClientProducer producer = session.createProducer("test.addr0"); + for (int i = 0; i < 20; i++) { + org.apache.activemq.artemis.api.core.client.ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[1000]); + producer.send(msg); + } + session.close(); + sf.close(); + locator.close(); + + // Verify quota1 tracks everything + quota1 = quotaManager.getQuota("quota1"); + quota2 = quotaManager.getQuota("quota2"); + assertEquals(3, quota1.getCurrentAddressCount(), "quota1 should track 3 addresses"); + assertEquals(6, quota1.getCurrentQueueCount(), "quota1 should track 6 queues"); + long bytesBefore = quota1.getCurrentMessageBytes(); + assertTrue(bytesBefore > 0, "quota1 should track bytes from messages"); + + assertEquals(0, quota2.getCurrentAddressCount(), "quota2 should track 0 addresses"); + assertEquals(0, quota2.getCurrentQueueCount(), "quota2 should track 0 queues"); + assertEquals(0, quota2.getCurrentMessageBytes(), "quota2 should track 0 bytes"); + + // Reload with address-settings changed to map test.# to quota2 + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 10 + 20 + 500000 + + + 20 + 40 + 1000000 + + + + + quota2 + + """, latch); + + // Verify all counters moved from quota1 to quota2 + quota1 = quotaManager.getQuota("quota1"); + quota2 = quotaManager.getQuota("quota2"); + assertEquals(0, quota1.getCurrentAddressCount(), "quota1 should no longer track addresses"); + assertEquals(0, quota1.getCurrentQueueCount(), "quota1 should no longer track queues"); + assertEquals(0, quota1.getCurrentMessageBytes(), "quota1 should no longer track bytes"); + + assertEquals(3, quota2.getCurrentAddressCount(), "quota2 should now track 3 addresses"); + assertEquals(6, quota2.getCurrentQueueCount(), "quota2 should now track 6 queues"); + assertEquals(bytesBefore, quota2.getCurrentMessageBytes(), "quota2 should now track all bytes"); + + } finally { + embeddedActiveMQ.stop(); + } + } + + /** + * Address-settings mapping change with parent hierarchy. + * Verifies that when addresses are remapped from quota1 (partOf=parent1) to + * quota2 (partOf=parent2), the parent chain counters are also rebalanced. + */ + @Test + public void testAddressSettingsQuotaMappingChangeWithHierarchy() throws Exception { + Path brokerXML = getTestDirfile().toPath().resolve("broker.xml"); + writeConfig(brokerXML, """ + + + 100 + + + 100 + + + parent1 + 10 + + + parent2 + 20 + + + + + quota1 + + """); + + EmbeddedActiveMQ embeddedActiveMQ = new EmbeddedActiveMQ(); + embeddedActiveMQ.setConfigResourcePath(brokerXML.toUri().toString()); + embeddedActiveMQ.start(); + + final ReusableLatch latch = new ReusableLatch(0); + embeddedActiveMQ.getActiveMQServer().getReloadManager().setTick(latch::countDown); + + try { + ResourceQuotaManager quotaManager = embeddedActiveMQ.getActiveMQServer() + .getResourceQuotaService().getResourceQuotaManager(); + + // Verify hierarchy: quota1→parent1, quota2→parent2 + ResourceQuota quota1 = quotaManager.getQuota("quota1"); + ResourceQuota quota2 = quotaManager.getQuota("quota2"); + ResourceQuota parent1 = quotaManager.getQuota("parent1"); + ResourceQuota parent2 = quotaManager.getQuota("parent2"); + assertNotNull(quota1); + assertNotNull(quota2); + assertNotNull(parent1); + assertNotNull(parent2); + assertNotNull(quota1.getParent(), "quota1 should have parent"); + assertEquals("parent1", quota1.getParent().getName()); + assertNotNull(quota2.getParent(), "quota2 should have parent"); + assertEquals("parent2", quota2.getParent().getName()); + + // Create 4 addresses under test.# (mapped to quota1 initially) + for (int i = 0; i < 4; i++) { + AddressInfo addr = new AddressInfo(SimpleString.of("test.addr" + i), RoutingType.ANYCAST); + embeddedActiveMQ.getActiveMQServer().addAddressInfo(addr); + } + + // Verify quota1 and parent1 track the addresses + quota1 = quotaManager.getQuota("quota1"); + parent1 = quotaManager.getQuota("parent1"); + assertEquals(4, quota1.getCurrentAddressCount(), "quota1 should track 4 addresses"); + assertEquals(4, parent1.getCurrentAddressCount(), "parent1 should track 4 addresses via propagation"); + assertEquals(0, quota2.getCurrentAddressCount(), "quota2 should track 0"); + assertEquals(0, parent2.getCurrentAddressCount(), "parent2 should track 0"); + + // Reload with address-settings changed to map test.# to quota2 + reloadConfiguration(embeddedActiveMQ, brokerXML, """ + + + 100 + + + 100 + + + parent1 + 10 + + + parent2 + 20 + + + + + quota2 + + """, latch); + + // Verify counters moved from quota1→parent1 chain to quota2→parent2 chain + quota1 = quotaManager.getQuota("quota1"); + quota2 = quotaManager.getQuota("quota2"); + parent1 = quotaManager.getQuota("parent1"); + parent2 = quotaManager.getQuota("parent2"); + + assertEquals(0, quota1.getCurrentAddressCount(), "quota1 should no longer track addresses"); + assertEquals(0, parent1.getCurrentAddressCount(), "parent1 should no longer track addresses"); + assertEquals(4, quota2.getCurrentAddressCount(), "quota2 should now track 4 addresses"); + assertEquals(4, parent2.getCurrentAddressCount(), "parent2 should track 4 via propagation"); + + } finally { + embeddedActiveMQ.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaServiceTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaServiceTest.java new file mode 100644 index 00000000000..c51889f1878 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/ResourceQuotaServiceTest.java @@ -0,0 +1,422 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaService; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ResourceQuotaServiceTest extends ActiveMQTestBase { + + @Test + public void testAddressQuotaCheckAndIncrement() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(3); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ResourceQuotaService quotaService = server.getResourceQuotaService(); + assertNotNull(quotaService); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + // Check quota before creating address + quotaService.checkAddressQuota(SimpleString.of("test.addr1")); + assertEquals(0, quota.getAddressCount()); + + // Increment after successful creation + quotaService.incrementAddressCount(SimpleString.of("test.addr1")); + assertEquals(1, quota.getAddressCount()); + + // Check and increment again + quotaService.checkAddressQuota(SimpleString.of("test.addr2")); + quotaService.incrementAddressCount(SimpleString.of("test.addr2")); + assertEquals(2, quota.getAddressCount()); + + // Decrement after removal + quotaService.decrementAddressCount(SimpleString.of("test.addr1")); + assertEquals(1, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testQueueQuotaCheckAndIncrement() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(5); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + // Check quota before creating queue + quotaService.checkQueueQuota(SimpleString.of("test.addr")); + assertEquals(0, quota.getQueueCount()); + + // Increment after successful creation + quotaService.incrementQueueCount(SimpleString.of("test.addr")); + assertEquals(1, quota.getQueueCount()); + + // Check and increment again + quotaService.checkQueueQuota(SimpleString.of("test.addr")); + quotaService.incrementQueueCount(SimpleString.of("test.addr")); + assertEquals(2, quota.getQueueCount()); + + // Decrement after removal + quotaService.decrementQueueCount(SimpleString.of("test.addr")); + assertEquals(1, quota.getQueueCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testEndToEndAddressCreation() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(3); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + // Create addresses through normal API (uses tokens internally) + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + assertEquals(1, quota.getAddressCount()); + + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST)); + assertEquals(2, quota.getAddressCount()); + + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)); + assertEquals(3, quota.getAddressCount()); + + // Fourth should fail + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST)) + ); + + // Remove one + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + assertEquals(2, quota.getAddressCount()); + + // Should be able to create another + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST)); + assertEquals(3, quota.getAddressCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testEndToEndQueueCreationWithTokens() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxQueues(3); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create address first + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr"), RoutingType.ANYCAST)); + + // Create queues through normal API (uses tokens internally) + server.createQueue(QueueConfiguration.of("queue1").setAddress("test.addr")); + + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota quota = quotaService.getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(1, quota.getQueueCount()); + + server.createQueue(QueueConfiguration.of("queue2").setAddress("test.addr")); + assertEquals(2, quota.getQueueCount()); + + server.createQueue(QueueConfiguration.of("queue3").setAddress("test.addr")); + assertEquals(3, quota.getQueueCount()); + + // Fourth should fail + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr")) + ); + + // Destroy one + server.destroyQueue(SimpleString.of("queue1")); + assertEquals(2, quota.getQueueCount()); + + // Should be able to create another + server.createQueue(QueueConfiguration.of("queue4").setAddress("test.addr")); + assertEquals(3, quota.getQueueCount()); + + } finally { + server.stop(); + } + } + + @Test + public void testReloadAddingSiblingPreservesParentCounts() throws Exception { + Configuration config = createDefaultConfig(false); + + // Start with Global parent and two children: EU and US + ResourceQuotaConfig globalConfig = new ResourceQuotaConfig("global"); + globalConfig.setMaxAddresses(100); + config.addResourceQuota("global", globalConfig); + + ResourceQuotaConfig euConfig = new ResourceQuotaConfig("eu-quota"); + euConfig.setMaxAddresses(50); + euConfig.setPartOf("global"); + config.addResourceQuota("eu-quota", euConfig); + + ResourceQuotaConfig usConfig = new ResourceQuotaConfig("us-quota"); + usConfig.setMaxAddresses(50); + usConfig.setPartOf("global"); + config.addResourceQuota("us-quota", usConfig); + + AddressSettings euSettings = new AddressSettings(); + euSettings.setResourceQuota("eu-quota"); + config.addAddressSetting("eu.#", euSettings); + + AddressSettings usSettings = new AddressSettings(); + usSettings.setResourceQuota("us-quota"); + config.addAddressSetting("us.#", usSettings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota global = quotaService.getQuotaByName("global"); + ResourceQuota eu = quotaService.getQuotaByName("eu-quota"); + ResourceQuota us = quotaService.getQuotaByName("us-quota"); + assertNotNull(global); + assertNotNull(eu); + assertNotNull(us); + + // Create addresses under both quotas + server.addAddressInfo(new AddressInfo(SimpleString.of("eu.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("eu.addr2"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("eu.addr3"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("us.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("us.addr2"), RoutingType.ANYCAST)); + + assertEquals(3, eu.getAddressCount()); + assertEquals(2, us.getAddressCount()); + assertEquals(5, global.getAddressCount(), "Global should have combined count"); + + // Add a new sibling "ap-quota" under the same parent via reload. + // This new quota goes into newQuotasForRebuild, which resets its parent chain + // (Global). Only the new quota is rebuilt — EU and US contributions to Global + // must not be lost. + ResourceQuotaConfig apConfig = new ResourceQuotaConfig("ap-quota"); + apConfig.setMaxAddresses(50); + apConfig.setPartOf("global"); + config.addResourceQuota("ap-quota", apConfig); + + AddressSettings apSettings = new AddressSettings(); + apSettings.setResourceQuota("ap-quota"); + config.addAddressSetting("ap.#", apSettings); + + quotaService.reloadQuotas(); + + // Re-fetch since reload may recreate objects + global = quotaService.getQuotaByName("global"); + eu = quotaService.getQuotaByName("eu-quota"); + us = quotaService.getQuotaByName("us-quota"); + ResourceQuota ap = quotaService.getQuotaByName("ap-quota"); + + assertNotNull(ap, "New AP quota should exist after reload"); + assertEquals(0, ap.getAddressCount(), "AP has no addresses yet"); + + // Existing quotas must be unchanged + assertEquals(3, eu.getAddressCount(), "EU address count must be preserved"); + assertEquals(2, us.getAddressCount(), "US address count must be preserved"); + + // Global must retain ALL children's contributions + assertEquals(5, global.getAddressCount(), + "Global must retain EU+US contributions after adding new sibling AP"); + + } finally { + server.stop(); + } + } + + @Test + public void testReloadWithWildcardTemplatePreservesParentCounts() throws Exception { + Configuration config = createDefaultConfig(false); + + // Global parent quota + ResourceQuotaConfig globalConfig = new ResourceQuotaConfig("global"); + globalConfig.setMaxAddresses(100); + config.addResourceQuota("global", globalConfig); + + // Wildcard template child — instantiates "region.eu", "region.us" etc. + ResourceQuotaConfig regionConfig = new ResourceQuotaConfig("region.*"); + regionConfig.setMaxAddresses(50); + regionConfig.setPartOf("global"); + config.addResourceQuota("region.*", regionConfig); + + // Address settings route region.# addresses to the wildcard template + AddressSettings regionSettings = new AddressSettings(); + regionSettings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", regionSettings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ResourceQuotaService quotaService = server.getResourceQuotaService(); + ResourceQuota global = quotaService.getQuotaByName("global"); + assertNotNull(global); + + // Create addresses that trigger wildcard instantiation: + // "region.eu.addr1" → instantiated quota "region.eu" with parent=global + // "region.us.addr1" → instantiated quota "region.us" with parent=global + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr2"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr1"), RoutingType.ANYCAST)); + + assertEquals(3, global.getAddressCount(), + "Global should have combined count from instantiated wildcard children"); + + // Add a new direct child quota under global via reload. + // This triggers resetQuotaCounters on global (parent chain reset). + // The sibling rebuild walks getAllQuotas() which only returns repository + // quotas — instantiated wildcard children are missed, so their + // contributions to global are lost. + ResourceQuotaConfig newChildConfig = new ResourceQuotaConfig("new-child"); + newChildConfig.setMaxAddresses(20); + newChildConfig.setPartOf("global"); + config.addResourceQuota("new-child", newChildConfig); + + quotaService.reloadQuotas(); + + // Re-fetch in case reload recreated objects + global = quotaService.getQuotaByName("global"); + + // Global must still reflect the instantiated wildcard children + assertEquals(3, global.getAddressCount(), + "Global must retain instantiated wildcard children's contributions after reload"); + + } finally { + server.stop(); + } + } + + @Test + public void testReloadUpdatesQuotaOnPagingStore() throws Exception { + Configuration config = createDefaultConfig(false); + + // Start with quotaA assigned to "test.#" addresses + ResourceQuotaConfig quotaAConfig = new ResourceQuotaConfig("quotaA"); + quotaAConfig.setMaxAddresses(10); + config.addResourceQuota("quotaA", quotaAConfig); + + ResourceQuotaConfig quotaBConfig = new ResourceQuotaConfig("quotaB"); + quotaBConfig.setMaxAddresses(20); + config.addResourceQuota("quotaB", quotaBConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("quotaA"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create an address to establish its PagingStore + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + + ResourceQuota quotaA = server.getResourceQuotaService().getQuotaByName("quotaA"); + ResourceQuota quotaB = server.getResourceQuotaService().getQuotaByName("quotaB"); + + // Verify PagingStore initially references quotaA + org.apache.activemq.artemis.core.paging.PagingStore pagingStore = + server.getPagingManager().getPageStore(SimpleString.of("test.addr1")); + assertSame(quotaA, pagingStore.getResourceQuota(), + "PagingStore should initially reference quotaA"); + + // Change the live address-settings repository to reference quotaB instead + AddressSettings newSettings = new AddressSettings(); + newSettings.setResourceQuota("quotaB"); + server.getAddressSettingsRepository().addMatch("test.#", newSettings); + + // PagingStore must now reference quotaB + assertSame(quotaB, pagingStore.getResourceQuota(), + "PagingStore must reference quotaB after reload reassigns the address"); + + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardMulticastByteQuotaTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardMulticastByteQuotaTest.java new file mode 100644 index 00000000000..9a15a4aa5f6 --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardMulticastByteQuotaTest.java @@ -0,0 +1,373 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.QueueConfiguration; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.api.core.client.ClientMessage; +import org.apache.activemq.artemis.api.core.client.ClientProducer; +import org.apache.activemq.artemis.api.core.client.ClientSession; +import org.apache.activemq.artemis.api.core.client.ClientSessionFactory; +import org.apache.activemq.artemis.api.core.client.ServerLocator; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Tests for byte quota enforcement across wildcard addresses with multicast routing. + * + * Verifies that: + * 1. Multiple addresses under a wildcard quota (e.g., a.#) share the same byte quota + * 2. Publishing to different addresses (a.1, a.2, a.3) accumulates against the shared quota + * 3. When quota is exceeded, publishes to ANY address matching the wildcard fail + * 4. Multicast routing correctly accounts for N queue references in quota + */ +public class WildcardMulticastByteQuotaTest extends ActiveMQTestBase { + + /** + * Test that byte quota is shared across multiple addresses matching a wildcard, + * and that publishing eventually fails when the shared quota is full. + * + * Scenario: + * - Wildcard quota "a.#" with 5KB byte limit + * - Create multicast queue on "a.#" (consumes messages from a.1, a.2, a.3, etc.) + * - Consumer stays offline (messages accumulate) + * - Publish to a.1, a.2, a.3 until quota fills + * - Next publish to any a.X should fail + */ + @Test + public void testWildcardByteQuotaSharedAcrossAddresses() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create shared quota for wildcard "a.#" with 5KB limit + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("a-quota"); + quotaConfig.setMaxMessageBytes(5120L); // 5KB + config.addResourceQuota("a-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("a-quota"); + settings.setMaxSizeBytes(-1L); // Disable paging limit, only quota limit + settings.setAddressFullMessagePolicy(org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy.FAIL); + config.addAddressSetting("a.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + // Create addresses a.1, a.2, a.3 with MULTICAST routing + SimpleString addr1 = SimpleString.of("a.1"); + SimpleString addr2 = SimpleString.of("a.2"); + SimpleString addr3 = SimpleString.of("a.3"); + + session.createAddress(addr1, RoutingType.MULTICAST, false); + session.createAddress(addr2, RoutingType.MULTICAST, false); + session.createAddress(addr3, RoutingType.MULTICAST, false); + + // Create multicast queue on wildcard "a.#" - receives messages from all a.* addresses + SimpleString queueName = SimpleString.of("multicast-queue"); + SimpleString wildcardAddress = SimpleString.of("a.#"); + session.createQueue(QueueConfiguration.of(queueName) + .setAddress(wildcardAddress) + .setRoutingType(RoutingType.MULTICAST)); + + // Get quota instance by name (all a.* addresses share this quota) + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("a-quota"); + assertNotNull(quota, "Quota 'a-quota' should exist"); + + long initialQuota = quota.getCurrentMessageBytes(); + assertEquals(0, initialQuota, "Quota should start at 0"); + + // Verify all addresses point to the same quota + ResourceQuota quota1 = server.getResourceQuotaService().lookupQuota(addr1); + ResourceQuota quota2 = server.getResourceQuotaService().lookupQuota(addr2); + ResourceQuota quota3 = server.getResourceQuotaService().lookupQuota(addr3); + assertEquals(quota, quota1, "a.1 should use a-quota"); + assertEquals(quota, quota2, "a.2 should use a-quota"); + assertEquals(quota, quota3, "a.3 should use a-quota"); + + // Create producers for each address + ClientProducer producer1 = session.createProducer(addr1); + ClientProducer producer2 = session.createProducer(addr2); + ClientProducer producer3 = session.createProducer(addr3); + + // Send messages to a.1, a.2, a.3 (consumer stays offline so messages accumulate) + // Each message ~500 bytes, so ~10 messages should fill 5KB quota + int messageSize = 500; + int sentCount = 0; + + try { + // Send to a.1 + for (int i = 0; i < 4; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[messageSize]); + producer1.send(message); + sentCount++; + } + + // Send to a.2 + for (int i = 0; i < 4; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[messageSize]); + producer2.send(message); + sentCount++; + } + + // Send to a.3 - this should start hitting the quota limit + for (int i = 0; i < 10; i++) { + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[messageSize]); + producer3.send(message); + sentCount++; + } + + // Should eventually fail - quota is shared across a.1, a.2, a.3 + fail("Expected quota/address full exception"); + + } catch (org.apache.activemq.artemis.api.core.ActiveMQException e) { + // Expected - quota exceeded (may be ResourceQuotaExceeded or AddressFull depending on policy) + assertTrue(e.getMessage().contains("quota") || e.getMessage().contains("full"), + "Exception should indicate quota or address full. Got: " + e.getMessage()); + } + + // Verify quota is at or near limit + long currentQuota = quota.getCurrentMessageBytes(); + assertTrue(currentQuota >= quota.getMaxMessageBytes() * 0.9, + "Quota should be near limit. Current: " + currentQuota + ", Max: " + quota.getMaxMessageBytes()); + + // Verify we sent some messages before hitting limit + // Note: With memory overhead, ~500 byte body = ~1000 bytes total + // So 5KB quota fills after ~5 messages + assertTrue(sentCount >= 4, "Should have sent at least 4 messages before quota filled. Sent: " + sentCount); + + // Try to send to a.1 again - should still fail (quota is shared) + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[messageSize]); + assertThrows(org.apache.activemq.artemis.api.core.ActiveMQException.class, + () -> producer1.send(message), + "Publishing to a.1 should fail when shared quota is full"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that multicast routing correctly accounts for multiple queue references + * when calculating byte quota. + * + * Scenario: + * - Create 3 multicast queues on wildcard "b.#" + * - Publish to b.1 (creates 3 references, one per queue) + * - Verify quota accounts for message + 3× reference overhead + */ + @Test + public void testMulticastReferenceOverheadInQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("b-quota"); + quotaConfig.setMaxMessageBytes(10240L); // 10KB + config.addResourceQuota("b-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("b-quota"); + settings.setMaxSizeBytes(-1L); + config.addAddressSetting("b.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + // Create address b.1 with MULTICAST routing + SimpleString addr1 = SimpleString.of("b.1"); + session.createAddress(addr1, RoutingType.MULTICAST, false); + + // Create 3 multicast queues on wildcard "b.#" - each will receive a reference + SimpleString wildcardAddress = SimpleString.of("b.#"); + session.createQueue(QueueConfiguration.of("queue1") + .setAddress(wildcardAddress) + .setRoutingType(RoutingType.MULTICAST)); + session.createQueue(QueueConfiguration.of("queue2") + .setAddress(wildcardAddress) + .setRoutingType(RoutingType.MULTICAST)); + session.createQueue(QueueConfiguration.of("queue3") + .setAddress(wildcardAddress) + .setRoutingType(RoutingType.MULTICAST)); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("b-quota"); + assertNotNull(quota, "Quota 'b-quota' should exist"); + long initialQuota = quota.getCurrentMessageBytes(); + + // Send 1 message to b.1 (creates 3 references) + ClientProducer producer = session.createProducer(addr1); + ClientMessage message = session.createMessage(true); + message.getBodyBuffer().writeBytes(new byte[100]); + producer.send(message); + + long finalQuota = quota.getCurrentMessageBytes(); + long delta = finalQuota - initialQuota; + + // Quota should include: + // - Message memory estimate (~100 bytes body + overhead) + // - 3× reference overhead (3 queues × 72 bytes each = 216 bytes) + // Total should be significantly more than just message body (100 bytes) + assertTrue(delta > 300, "Quota delta should account for 3 references. Delta: " + delta); + + // For wildcard quotas, we sum all matching paging stores + // In this case, just b.1 has messages + long pagingStoreSize = server.getPagingManager().getPageStore(addr1).getAddressSize(); + + // Quota should be close to paging store size (allowing for overhead variations) + long difference = Math.abs(pagingStoreSize - finalQuota); + assertTrue(difference < 300, + "Quota should be close to paging store size. Quota: " + finalQuota + + ", PagingStore: " + pagingStoreSize + ", Difference: " + difference); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } + + /** + * Test that publishing to different addresses under same wildcard quota + * all contribute to the shared byte limit. + */ + @Test + public void testMultipleAddressesShareWildcardByteQuota() throws Exception { + Configuration config = createDefaultConfig(false); + + // Small quota for testing + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("c-quota"); + quotaConfig.setMaxMessageBytes(3000L); // 3KB + config.addResourceQuota("c-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("c-quota"); + settings.setMaxSizeBytes(-1L); + settings.setAddressFullMessagePolicy(org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy.FAIL); + config.addAddressSetting("c.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + ServerLocator locator = createInVMNonHALocator(); + ClientSessionFactory sf = createSessionFactory(locator); + ClientSession session = sf.createSession(false, true, true); + + // Create addresses c.1, c.2, c.3 with ANYCAST routing (simpler for this test) + SimpleString addr1 = SimpleString.of("c.1"); + SimpleString addr2 = SimpleString.of("c.2"); + SimpleString addr3 = SimpleString.of("c.3"); + + session.createAddress(addr1, RoutingType.ANYCAST, false); + session.createAddress(addr2, RoutingType.ANYCAST, false); + session.createAddress(addr3, RoutingType.ANYCAST, false); + + session.createQueue(QueueConfiguration.of("c.1").setAddress(addr1).setRoutingType(RoutingType.ANYCAST)); + session.createQueue(QueueConfiguration.of("c.2").setAddress(addr2).setRoutingType(RoutingType.ANYCAST)); + session.createQueue(QueueConfiguration.of("c.3").setAddress(addr3).setRoutingType(RoutingType.ANYCAST)); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("c-quota"); + assertNotNull(quota, "Quota 'c-quota' should exist"); + + ClientProducer producer1 = session.createProducer(addr1); + ClientProducer producer2 = session.createProducer(addr2); + ClientProducer producer3 = session.createProducer(addr3); + + // Send messages until quota fills + // Each message ~300 bytes body, ~700 bytes total with overhead + // 3KB quota should fit ~4 messages + int messagesSent = 0; + boolean quotaExceeded = false; + + try { + // Send to c.1 + for (int i = 0; i < 5; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[300]); + producer1.send(msg); + messagesSent++; + } + + long afterC1 = quota.getCurrentMessageBytes(); + assertTrue(afterC1 > 0, "Quota should increase after sending to c.1"); + + // Send to c.2 + for (int i = 0; i < 5; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[300]); + producer2.send(msg); + messagesSent++; + } + + long afterC2 = quota.getCurrentMessageBytes(); + assertTrue(afterC2 > afterC1, "Quota should increase further after sending to c.2"); + + // Send to c.3 until quota fills + for (int i = 0; i < 10; i++) { + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[300]); + producer3.send(msg); + messagesSent++; + } + } catch (org.apache.activemq.artemis.api.core.ActiveMQException e) { + quotaExceeded = true; + // May be quota exceeded or address full depending on policy + assertTrue(e.getMessage().contains("quota") || e.getMessage().contains("full"), + "Exception should indicate quota or full. Got: " + e.getMessage()); + } + + assertTrue(quotaExceeded, "Quota should eventually be exceeded"); + assertTrue(messagesSent >= 3, "Should have sent at least 3 messages before quota filled. Sent: " + messagesSent); + + // Verify quota is shared: publishing to c.1 should also fail now + ClientMessage msg = session.createMessage(true); + msg.getBodyBuffer().writeBytes(new byte[300]); + assertThrows(org.apache.activemq.artemis.api.core.ActiveMQException.class, + () -> producer1.send(msg), + "Publishing to c.1 should fail when shared wildcard quota is full"); + + session.close(); + locator.close(); + } finally { + server.stop(); + } + } +} diff --git a/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardQuotaTemplateTest.java b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardQuotaTemplateTest.java new file mode 100644 index 00000000000..ed52dd8ceba --- /dev/null +++ b/tests/integration-tests/src/test/java/org/apache/activemq/artemis/tests/integration/quota/WildcardQuotaTemplateTest.java @@ -0,0 +1,325 @@ +/* + * 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.activemq.artemis.tests.integration.quota; + +import org.apache.activemq.artemis.api.core.ActiveMQResourceQuotaExceededException; +import org.apache.activemq.artemis.api.core.RoutingType; +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.server.ActiveMQServer; +import org.apache.activemq.artemis.core.server.impl.AddressInfo; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuotaConfig; +import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for wildcard quota template functionality. + * Verifies that quota templates like "region.*" create separate instances + * for each wildcard value (e.g., "region.us", "region.eu"). + */ +public class WildcardQuotaTemplateTest extends ActiveMQTestBase { + + @Test + public void testWildcardQuotaTemplateInstantiation() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create wildcard template quota "region.*" with max 3 addresses + ResourceQuotaConfig regionTemplate = new ResourceQuotaConfig("region.*"); + regionTemplate.setMaxAddresses(3); + config.addResourceQuota("region.*", regionTemplate); + + // Configure addresses matching "region.#" to use the wildcard quota + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create addresses in region.us - should instantiate "region.us" quota + AddressInfo usAddr1 = new AddressInfo(SimpleString.of("region.us.orders"), RoutingType.ANYCAST); + server.addAddressInfo(usAddr1); + + AddressInfo usAddr2 = new AddressInfo(SimpleString.of("region.us.payments"), RoutingType.ANYCAST); + server.addAddressInfo(usAddr2); + + AddressInfo usAddr3 = new AddressInfo(SimpleString.of("region.us.shipping"), RoutingType.ANYCAST); + server.addAddressInfo(usAddr3); + + // Fourth address in region.us should fail (limit is 3 per region) + AddressInfo usAddr4 = new AddressInfo(SimpleString.of("region.us.inventory"), RoutingType.ANYCAST); + ActiveMQResourceQuotaExceededException exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(usAddr4) + ); + assertTrue(exception.getMessage().contains("Address quota exceeded")); + + // Create addresses in region.eu - should instantiate separate "region.eu" quota + AddressInfo euAddr1 = new AddressInfo(SimpleString.of("region.eu.orders"), RoutingType.ANYCAST); + server.addAddressInfo(euAddr1); + + AddressInfo euAddr2 = new AddressInfo(SimpleString.of("region.eu.payments"), RoutingType.ANYCAST); + server.addAddressInfo(euAddr2); + + AddressInfo euAddr3 = new AddressInfo(SimpleString.of("region.eu.shipping"), RoutingType.ANYCAST); + server.addAddressInfo(euAddr3); + + // Fourth address in region.eu should also fail (separate quota instance) + AddressInfo euAddr4 = new AddressInfo(SimpleString.of("region.eu.inventory"), RoutingType.ANYCAST); + exception = assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(euAddr4) + ); + assertTrue(exception.getMessage().contains("Address quota exceeded")); + + // Verify we can create addresses in another region (region.asia) + AddressInfo asiaAddr1 = new AddressInfo(SimpleString.of("region.asia.orders"), RoutingType.ANYCAST); + server.addAddressInfo(asiaAddr1); + + } finally { + server.stop(); + } + } + + @Test + public void testWildcardQuotaIsolation() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig regionTemplate = new ResourceQuotaConfig("region.*"); + regionTemplate.setMaxAddresses(2); + config.addResourceQuota("region.*", regionTemplate); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Fill region.us quota (2 addresses) + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr2"), RoutingType.ANYCAST)); + + // region.us should be full + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr3"), RoutingType.ANYCAST)) + ); + + // region.eu should still have capacity (separate quota instance) + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr2"), RoutingType.ANYCAST)); + + // Now region.eu should also be full + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr3"), RoutingType.ANYCAST)) + ); + + } finally { + server.stop(); + } + } + + @Test + public void testWildcardQuotaWithParentHierarchy() throws Exception { + Configuration config = createDefaultConfig(false); + + // Create parent quota with total limit of 8 addresses + ResourceQuotaConfig globalQuota = new ResourceQuotaConfig("global"); + globalQuota.setMaxAddresses(8); + config.addResourceQuota("global", globalQuota); + + // Create wildcard template that's part of global, each region limited to 3 + ResourceQuotaConfig regionTemplate = new ResourceQuotaConfig("region.*"); + regionTemplate.setMaxAddresses(3); + regionTemplate.setPartOf("global"); + config.addResourceQuota("region.*", regionTemplate); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Create 3 addresses in region.us (hits region limit) + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr2"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr3"), RoutingType.ANYCAST)); + + // Fourth in region.us should fail (region limit) + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("region.us.addr4"), RoutingType.ANYCAST)) + ); + + // Create 3 addresses in region.eu (total now 6, within global limit of 8) + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr2"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.eu.addr3"), RoutingType.ANYCAST)); + + // Create 2 addresses in region.asia (total now 8, hitting global limit) + server.addAddressInfo(new AddressInfo(SimpleString.of("region.asia.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("region.asia.addr2"), RoutingType.ANYCAST)); + + // Next address should fail due to parent global limit (even though region.asia has capacity) + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("region.asia.addr3"), RoutingType.ANYCAST)) + ); + + } finally { + server.stop(); + } + } + + @Test + public void testConcurrentWildcardInstanceCreation() throws Exception { + Configuration config = createDefaultConfig(false); + + ResourceQuotaConfig regionTemplate = new ResourceQuotaConfig("region.*"); + regionTemplate.setMaxAddresses(10); + config.addResourceQuota("region.*", regionTemplate); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + config.addAddressSetting("region.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + int numThreads = 20; + int regionsPerThread = 3; + + Thread[] threads = new Thread[numThreads]; + Map successCounts = new ConcurrentHashMap<>(); + + // Each thread creates addresses in multiple regions concurrently + for (int i = 0; i < numThreads; i++) { + final int threadIndex = i; + threads[i] = new Thread(() -> { + for (int region = 0; region < regionsPerThread; region++) { + String regionName = "r" + region; + successCounts.putIfAbsent(regionName, new AtomicInteger(0)); + + for (int addr = 0; addr < 15; addr++) { + try { + AddressInfo address = new AddressInfo( + SimpleString.of("region." + regionName + ".t" + threadIndex + "a" + addr), + RoutingType.ANYCAST + ); + server.addAddressInfo(address); + successCounts.get(regionName).incrementAndGet(); + } catch (ActiveMQResourceQuotaExceededException e) { + // Expected when quota exceeded + } catch (Exception e) { + e.printStackTrace(); + } + } + } + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for completion + for (Thread thread : threads) { + thread.join(); + } + + // Each region should have exactly 10 successful creates (the quota limit) + for (int region = 0; region < regionsPerThread; region++) { + String regionName = "r" + region; + int count = successCounts.get(regionName).get(); + assertEquals(10, count, "Region " + regionName + " should have exactly 10 addresses"); + } + + } finally { + server.stop(); + } + } + + @Test + public void testWildcardQuotaDeletion() throws Exception { + Configuration config = createDefaultConfig(false); + + // Use a simple quota (not wildcard) for deletion test + ResourceQuotaConfig quotaConfig = new ResourceQuotaConfig("test-quota"); + quotaConfig.setMaxAddresses(2); + config.addResourceQuota("test-quota", quotaConfig); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + config.addAddressSetting("test.#", settings); + + ActiveMQServer server = createServer(false, config); + server.start(); + + try { + // Fill quota (2 addresses) + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr1"), RoutingType.ANYCAST)); + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr2"), RoutingType.ANYCAST)); + + ResourceQuota quota = server.getResourceQuotaService().getQuotaByName("test-quota"); + assertNotNull(quota); + + assertEquals(2, quota.getAddressCount()); + + // Should be at limit + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)) + ); + + // Remove one address + server.removeAddressInfo(SimpleString.of("test.addr1"), null); + assertEquals(1, quota.getAddressCount()); + + // Should now be able to create another + server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr3"), RoutingType.ANYCAST)); + assertEquals(2, quota.getAddressCount()); + + // Should be at limit again + assertThrows( + ActiveMQResourceQuotaExceededException.class, + () -> server.addAddressInfo(new AddressInfo(SimpleString.of("test.addr4"), RoutingType.ANYCAST)) + ); + + } finally { + server.stop(); + } + } +} diff --git a/tests/performance-tests/src/test/java/org/apache/activemq/artemis/tests/performance/storage/PersistMultiThreadTest.java b/tests/performance-tests/src/test/java/org/apache/activemq/artemis/tests/performance/storage/PersistMultiThreadTest.java index 29a8ff9671a..c62cea71024 100644 --- a/tests/performance-tests/src/test/java/org/apache/activemq/artemis/tests/performance/storage/PersistMultiThreadTest.java +++ b/tests/performance-tests/src/test/java/org/apache/activemq/artemis/tests/performance/storage/PersistMultiThreadTest.java @@ -45,6 +45,7 @@ import org.apache.activemq.artemis.core.settings.impl.AddressFullMessagePolicy; import org.apache.activemq.artemis.core.settings.impl.AddressSettings; import org.apache.activemq.artemis.core.settings.impl.PageFullMessagePolicy; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; import org.apache.activemq.artemis.core.transaction.Transaction; import org.apache.activemq.artemis.tests.util.ActiveMQTestBase; import org.apache.activemq.artemis.utils.actors.ArtemisExecutor; @@ -251,6 +252,30 @@ public void run() { class FakePagingStore implements PagingStore { + @Override + public void setResourceQuota(org.apache.activemq.artemis.core.settings.impl.ResourceQuota quota) { + } + + @Override + public ResourceQuota getResourceQuota() { + return null; + } + + @Override + public void rebuildQuotaCounters() throws Exception { + + } + + @Override + public void setRebuiltQuotaBytes(long bytes) { + + } + + @Override + public long applyPreliminaryQuotaEstimate() { + return 0; + } + @Override public PageFullMessagePolicy getPageFullMessagePolicy() { return null; diff --git a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/ResourceQuotaManagerTest.java b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/ResourceQuotaManagerTest.java new file mode 100644 index 00000000000..98132c5e824 --- /dev/null +++ b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/ResourceQuotaManagerTest.java @@ -0,0 +1,373 @@ +/* + * 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.activemq.artemis.tests.unit.core.paging; + +import org.apache.activemq.artemis.api.core.SimpleString; +import org.apache.activemq.artemis.core.config.WildcardConfiguration; +import org.apache.activemq.artemis.core.server.quota.ResourceQuotaManager; +import org.apache.activemq.artemis.core.settings.impl.AddressSettings; +import org.apache.activemq.artemis.core.settings.impl.HierarchicalObjectRepository; +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ResourceQuotaManagerTest { + + private ResourceQuotaManager manager; + private HierarchicalObjectRepository repository; + private WildcardConfiguration wildcardConfig; + + @BeforeEach + public void setUp() { + wildcardConfig = new WildcardConfiguration(); + repository = new HierarchicalObjectRepository<>(wildcardConfig); + manager = new ResourceQuotaManager(repository, wildcardConfig); + } + + @Test + public void testBasicQuotaLookup() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxMessageBytes(10000L); + manager.addQuota("test-quota", quota); + + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("test-quota"); + + ResourceQuota found = manager.getQuotaForAddress(SimpleString.of("test.address"), settings); + assertNotNull(found); + assertEquals("test-quota", found.getName()); + assertEquals(10000L, found.getMaxMessageBytes()); + } + + @Test + public void testQuotaNotFound() { + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("non-existent"); + + ResourceQuota found = manager.getQuotaForAddress(SimpleString.of("test.address"), settings); + assertNull(found); + } + + @Test + public void testNullSettings() { + ResourceQuota found = manager.getQuotaForAddress(SimpleString.of("test.address"), null); + assertNull(found); + } + + @Test + public void testSettingsWithNoQuota() { + AddressSettings settings = new AddressSettings(); + ResourceQuota found = manager.getQuotaForAddress(SimpleString.of("test.address"), settings); + assertNull(found); + } + + @Test + public void testEstablishSimpleParentRelationship() { + ResourceQuota parent = new ResourceQuota("parent"); + ResourceQuota child = new ResourceQuota("child"); + child.setPartOf("parent"); + + manager.addQuota("parent", parent); + manager.addQuota("child", child); + + manager.establishParentRelationships(); + + assertNotNull(child.getParent()); + assertEquals("parent", child.getParent().getName()); + } + + @Test + public void testEstablishThreeLevelHierarchy() { + ResourceQuota global = new ResourceQuota("global"); + + ResourceQuota region = new ResourceQuota("EU"); + region.setPartOf("global"); + + ResourceQuota country = new ResourceQuota("EU.fr"); + country.setPartOf("EU"); + + manager.addQuota("global", global); + manager.addQuota("EU", region); + manager.addQuota("EU.fr", country); + + manager.establishParentRelationships(); + + assertNull(global.getParent()); + assertNotNull(region.getParent()); + assertEquals("global", region.getParent().getName()); + assertNotNull(country.getParent()); + assertEquals("EU", country.getParent().getName()); + assertNotNull(country.getParent().getParent()); + assertEquals("global", country.getParent().getParent().getName()); + } + + @Test + public void testCircularReferenceDetection() { + ResourceQuota quota1 = new ResourceQuota("quota1"); + quota1.setPartOf("quota2"); + + ResourceQuota quota2 = new ResourceQuota("quota2"); + quota2.setPartOf("quota1"); + + manager.addQuota("quota1", quota1); + manager.addQuota("quota2", quota2); + + // Should not throw, just log error + manager.establishParentRelationships(); + + // Neither should have parent set due to circular reference + assertNull(quota1.getParent()); + assertNull(quota2.getParent()); + } + + @Test + public void testMissingParent() { + ResourceQuota child = new ResourceQuota("child"); + child.setPartOf("non-existent-parent"); + + manager.addQuota("child", child); + + manager.establishParentRelationships(); + + assertNull(child.getParent()); + } + + @Test + public void testQuotaWithoutParent() { + ResourceQuota quota = new ResourceQuota("standalone"); + + manager.addQuota("standalone", quota); + + manager.establishParentRelationships(); + + assertNull(quota.getParent()); + } + + @Test + public void testBytesPropagateUpHierarchy() { + ResourceQuota global = new ResourceQuota("global"); + global.setMaxMessageBytes(100000L); + + ResourceQuota region = new ResourceQuota("EU"); + region.setMaxMessageBytes(50000L); + region.setPartOf("global"); + + ResourceQuota country = new ResourceQuota("EU.fr"); + country.setMaxMessageBytes(10000L); + country.setPartOf("EU"); + + manager.addQuota("global", global); + manager.addQuota("EU", region); + manager.addQuota("EU.fr", country); + + manager.establishParentRelationships(); + + // Add bytes at country level + country.addSize(5000); + + assertEquals(5000, country.getSize()); + assertEquals(5000, region.getSize()); + assertEquals(5000, global.getSize()); + } + + @Test + public void testCountsPropagateUpHierarchy() { + ResourceQuota parent = new ResourceQuota("parent"); + parent.setMaxAddresses(100); + parent.setMaxQueues(100); + + ResourceQuota child = new ResourceQuota("child"); + child.setMaxAddresses(50); + child.setMaxQueues(50); + child.setPartOf("parent"); + + manager.addQuota("parent", parent); + manager.addQuota("child", child); + + manager.establishParentRelationships(); + + child.incrementAddressCount(); + child.incrementQueueCount(); + + assertEquals(1, child.getAddressCount()); + assertEquals(1, child.getQueueCount()); + assertEquals(1, parent.getAddressCount()); + assertEquals(1, parent.getQueueCount()); + } + + @Test + public void testGetAllQuotas() { + ResourceQuota quota1 = new ResourceQuota("quota1"); + ResourceQuota quota2 = new ResourceQuota("quota2"); + + manager.addQuota("quota1", quota1); + manager.addQuota("quota2", quota2); + + List all = manager.getAllQuotas(); + assertNotNull(all); + // Note: getAllQuotas returns instantiated quotas, not base quotas + // This is a limitation for Phase 1 + } + + @Test + public void testMultipleQuotasWithHierarchy() { + ResourceQuota global = new ResourceQuota("global"); + global.setMaxMessageBytes(1000000L); + + ResourceQuota us = new ResourceQuota("US"); + us.setMaxMessageBytes(400000L); + us.setPartOf("global"); + + ResourceQuota eu = new ResourceQuota("EU"); + eu.setMaxMessageBytes(400000L); + eu.setPartOf("global"); + + ResourceQuota usCa = new ResourceQuota("US.ca"); + usCa.setMaxMessageBytes(100000L); + usCa.setPartOf("US"); + + ResourceQuota euFr = new ResourceQuota("EU.fr"); + euFr.setMaxMessageBytes(100000L); + euFr.setPartOf("EU"); + + manager.addQuota("global", global); + manager.addQuota("US", us); + manager.addQuota("EU", eu); + manager.addQuota("US.ca", usCa); + manager.addQuota("EU.fr", euFr); + + manager.establishParentRelationships(); + + // US.ca should propagate to US and global + usCa.addSize(50000); + assertEquals(50000, usCa.getSize()); + assertEquals(50000, us.getSize()); + assertEquals(50000, global.getSize()); + assertEquals(0, eu.getSize()); + assertEquals(0, euFr.getSize()); + + // EU.fr should propagate to EU and global + euFr.addSize(30000); + assertEquals(30000, euFr.getSize()); + assertEquals(30000, eu.getSize()); + assertEquals(80000, global.getSize()); + assertEquals(50000, us.getSize()); + assertEquals(50000, usCa.getSize()); + } + + @Test + public void testRemoveQuotaDecrementsBytesFromParent() { + // Create parent and child quotas + ResourceQuota parent = new ResourceQuota("parent"); + parent.setMaxMessageBytes(1000000L); + + ResourceQuota child = new ResourceQuota("child"); + child.setMaxMessageBytes(500000L); + child.setPartOf("parent"); + + // Add quotas to manager + manager.addQuota("parent", parent); + manager.addQuota("child", child); + + // Establish parent-child relationship + manager.establishParentRelationships(); + + // Add bytes to child (should propagate to parent) + child.addSize(100000); + assertEquals(100000, child.getSize()); + assertEquals(100000, parent.getSize()); + + // Increment address and queue counts on child + child.incrementAddressCount(); + child.incrementAddressCount(); + child.incrementQueueCount(); + assertEquals(2, child.getCurrentAddressCount()); + assertEquals(1, child.getCurrentQueueCount()); + assertEquals(2, parent.getCurrentAddressCount()); + assertEquals(1, parent.getCurrentQueueCount()); + + // Remove child quota - should decrement parent's bytes, addresses, and queues + manager.removeQuota("child"); + + // Verify parent's counters are decremented + assertEquals(0, parent.getSize(), "Parent bytes should be decremented when child is removed"); + assertEquals(0, parent.getCurrentAddressCount(), "Parent address count should be decremented when child is removed"); + assertEquals(0, parent.getCurrentQueueCount(), "Parent queue count should be decremented when child is removed"); + + // Verify child is removed from repository + assertNull(repository.getMatch("child")); + assertNotNull(repository.getMatch("parent")); + } + + @Test + public void testRemoveWildcardTemplateDecrementsInstantiatedChildrenFromParent() { + // Create a parent quota and a wildcard template whose instances will reference it + ResourceQuota global = new ResourceQuota("global"); + global.setMaxAddresses(100); + + ResourceQuota template = new ResourceQuota("region.*"); + template.setMaxAddresses(50); + template.setPartOf("global"); + + manager.addQuota("global", global); + manager.addQuota("region.*", template); + + manager.establishParentRelationships(); + + // Resolve wildcard addresses — this creates instantiated children "region.eu" and "region.us" + AddressSettings settings = new AddressSettings(); + settings.setResourceQuota("region.*"); + + ResourceQuota euQuota = manager.getQuotaForAddress(SimpleString.of("region.eu.orders"), settings); + assertNotNull(euQuota); + assertEquals("region.eu", euQuota.getName()); + + ResourceQuota usQuota = manager.getQuotaForAddress(SimpleString.of("region.us.orders"), settings); + assertNotNull(usQuota); + assertEquals("region.us", usQuota.getName()); + + // Both instances should have global as parent + assertEquals(global, euQuota.getParent()); + assertEquals(global, usQuota.getParent()); + + // Simulate address creation — counts propagate to global + euQuota.incrementAddressCount(); + euQuota.incrementAddressCount(); + euQuota.incrementAddressCount(); + usQuota.incrementAddressCount(); + usQuota.incrementAddressCount(); + + assertEquals(3, euQuota.getCurrentAddressCount()); + assertEquals(2, usQuota.getCurrentAddressCount()); + assertEquals(5, global.getCurrentAddressCount(), "Global should have combined count from both children"); + + // Remove the wildcard template — must decrement children's contributions from global + manager.removeQuota("region.*"); + + assertEquals(0, global.getCurrentAddressCount(), + "Global address count must be zero after removing wildcard template with instantiated children"); + + // Verify instances are cleaned up + assertEquals(0, manager.getInstantiatedQuotas().size()); + } +} diff --git a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/impl/PageTest.java b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/impl/PageTest.java index 166bbadcb62..27a87e58276 100644 --- a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/impl/PageTest.java +++ b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/paging/impl/PageTest.java @@ -205,7 +205,7 @@ protected void testDamagedPage(final SequentialFileFactory factory, final int nu // Add more 10 as they will need to be ignored addPageElements(simpleDestination, page, 10, numberOfElements + 2); - // Damage data... position the file on the middle between points A and B + // Damage data... position the file in the middle between points A and B file.position(positionA + (positionB - positionA) / 2); ByteBuffer buffer = ByteBuffer.allocate((int) (positionB - file.position())); diff --git a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/postoffice/impl/DuplicateDetectionUnitTest.java b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/postoffice/impl/DuplicateDetectionUnitTest.java index b04ffb52ca5..7e36581c180 100644 --- a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/postoffice/impl/DuplicateDetectionUnitTest.java +++ b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/postoffice/impl/DuplicateDetectionUnitTest.java @@ -95,7 +95,7 @@ public void testReloadDuplication() throws Exception { Map>> mapDups = new HashMap<>(); FakePagingManager pagingManager = new FakePagingManager(); - journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null)); + journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null, null)); assertEquals(0, mapDups.size()); @@ -111,7 +111,7 @@ public void testReloadDuplication() throws Exception { journal.start(); journal.loadBindingJournal(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); - journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null)); + journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null, null)); assertEquals(1, mapDups.size()); @@ -134,7 +134,7 @@ public void testReloadDuplication() throws Exception { journal.start(); journal.loadBindingJournal(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); - journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null)); + journal.loadMessageJournal(postOffice, pagingManager, new ResourceManagerImpl(null, 0, 0, scheduledThreadPool), null, mapDups, null, null, new PostOfficeJournalLoader(postOffice, pagingManager, null, null, null, null, null, null, null)); assertEquals(1, mapDups.size()); diff --git a/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/settings/impl/ResourceQuotaTest.java b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/settings/impl/ResourceQuotaTest.java new file mode 100644 index 00000000000..5dcdf47df42 --- /dev/null +++ b/tests/unit-tests/src/test/java/org/apache/activemq/artemis/tests/unit/core/settings/impl/ResourceQuotaTest.java @@ -0,0 +1,304 @@ +/* + * 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.activemq.artemis.tests.unit.core.settings.impl; + +import org.apache.activemq.artemis.core.settings.impl.ResourceQuota; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ResourceQuotaTest { + + @Test + public void testBasicQuotaCreation() { + ResourceQuota quota = new ResourceQuota("test-quota"); + assertEquals("test-quota", quota.getName()); + assertEquals(ResourceQuota.DEFAULT_MAX_MESSAGE_BYTES, quota.getMaxMessageBytes()); + assertEquals(ResourceQuota.DEFAULT_MAX_ADDRESSES, quota.getMaxAddresses()); + assertEquals(ResourceQuota.DEFAULT_MAX_QUEUES, quota.getMaxQueues()); + assertNull(quota.getPartOf()); + } + + @Test + public void testQuotaConfiguration() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxMessageBytes(1024L * 1024L); + quota.setMaxAddresses(100); + quota.setMaxQueues(50); + quota.setPartOf("parent-quota"); + + assertEquals(1024L * 1024L, quota.getMaxMessageBytes()); + assertEquals(100, quota.getMaxAddresses()); + assertEquals(50, quota.getMaxQueues()); + assertEquals("parent-quota", quota.getPartOf()); + } + + @Test + public void testByteTracking() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxMessageBytes(1000L); + + assertEquals(0, quota.getSize()); + + quota.addSize(100); + assertEquals(100, quota.getSize()); + + quota.addSize(200); + assertEquals(300, quota.getSize()); + + quota.addSize(-50); + assertEquals(250, quota.getSize()); + } + + @Test + public void testByteLimitExceeded() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxMessageBytes(1000L); + + assertFalse(quota.isByteLimitReached()); + + quota.addSize(500); + assertFalse(quota.isByteLimitReached()); + + quota.addSize(600); + assertTrue(quota.isByteLimitReached()); + + // Going back under the lower mark (90% of max = 900) + quota.addSize(-300); + assertFalse(quota.isByteLimitReached()); + } + + @Test + public void testAddressCountTracking() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxAddresses(5); + + assertEquals(0, quota.getAddressCount()); + assertFalse(quota.isAddressLimitReached()); + + quota.incrementAddressCount(); + assertEquals(1, quota.getAddressCount()); + assertFalse(quota.isAddressLimitReached()); + + for (int i = 0; i < 4; i++) { + quota.incrementAddressCount(); + } + assertEquals(5, quota.getAddressCount()); + assertTrue(quota.isAddressLimitReached()); + + quota.decrementAddressCount(); + assertEquals(4, quota.getAddressCount()); + assertFalse(quota.isAddressLimitReached()); + } + + @Test + public void testQueueCountTracking() { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxQueues(10); + + assertEquals(0, quota.getQueueCount()); + assertFalse(quota.isQueueLimitReached()); + + for (int i = 0; i < 10; i++) { + quota.incrementQueueCount(); + } + assertEquals(10, quota.getQueueCount()); + assertTrue(quota.isQueueLimitReached()); + + quota.decrementQueueCount(); + assertEquals(9, quota.getQueueCount()); + assertFalse(quota.isQueueLimitReached()); + } + + @Test + public void testParentPropagation() { + ResourceQuota parent = new ResourceQuota("parent"); + parent.setMaxMessageBytes(10000L); + parent.setMaxAddresses(100); + parent.setMaxQueues(100); + + ResourceQuota child = new ResourceQuota("child"); + child.setMaxMessageBytes(5000L); + child.setMaxAddresses(50); + child.setMaxQueues(50); + child.setParent(parent); + + // Test byte propagation + child.addSize(1000); + assertEquals(1000, child.getSize()); + assertEquals(1000, parent.getSize()); + + // Test address count propagation + child.incrementAddressCount(); + assertEquals(1, child.getAddressCount()); + assertEquals(1, parent.getAddressCount()); + + // Test queue count propagation + child.incrementQueueCount(); + assertEquals(1, child.getQueueCount()); + assertEquals(1, parent.getQueueCount()); + + // Test decrement propagation + child.decrementAddressCount(); + assertEquals(0, child.getAddressCount()); + assertEquals(0, parent.getAddressCount()); + + child.decrementQueueCount(); + assertEquals(0, child.getQueueCount()); + assertEquals(0, parent.getQueueCount()); + } + + @Test + public void testThreeLevelHierarchy() { + ResourceQuota global = new ResourceQuota("global"); + global.setMaxMessageBytes(100000L); + + ResourceQuota region = new ResourceQuota("EU"); + region.setMaxMessageBytes(50000L); + region.setParent(global); + + ResourceQuota country = new ResourceQuota("EU.fr"); + country.setMaxMessageBytes(10000L); + country.setParent(region); + + // Add bytes to country-level quota + country.addSize(5000); + + assertEquals(5000, country.getSize()); + assertEquals(5000, region.getSize()); + assertEquals(5000, global.getSize()); + + // Add more to exceed country limit but not region + country.addSize(6000); + assertEquals(11000, country.getSize()); + assertTrue(country.isByteLimitReached()); + assertFalse(region.isByteLimitReached()); + assertFalse(global.isByteLimitReached()); + } + + @Test + public void testThreadSafety() throws Exception { + ResourceQuota quota = new ResourceQuota("test-quota"); + quota.setMaxMessageBytes(1000000L); + quota.setMaxAddresses(1000); + quota.setMaxQueues(1000); + + int threadCount = 10; + int operationsPerThread = 100; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startLatch.await(); + for (int j = 0; j < operationsPerThread; j++) { + quota.addSize(10); + quota.incrementAddressCount(); + quota.incrementQueueCount(); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(10, TimeUnit.SECONDS)); + + assertEquals(threadCount * operationsPerThread * 10, quota.getSize()); + assertEquals(threadCount * operationsPerThread, quota.getAddressCount()); + assertEquals(threadCount * operationsPerThread, quota.getQueueCount()); + } + + @Test + public void testNoNegativeCountProtection() { + ResourceQuota quota = new ResourceQuota("test"); + + // Decrement address count when it's already 0 + quota.decrementAddressCount(); + assertEquals(-1, quota.getAddressCount()); + + // Decrement queue count when it's already 0 + quota.decrementQueueCount(); + assertEquals(-1, quota.getQueueCount()); + } + + @Test + public void testSizeOnlyTracking() { + ResourceQuota quota = new ResourceQuota("test"); + + quota.addSize(100); + assertEquals(100, quota.getSize()); + + quota.addSize(50); + assertEquals(150, quota.getSize()); + } + + @Test + public void testAdjustCountersDirect() { + ResourceQuota parent = new ResourceQuota("parent"); + parent.setMaxMessageBytes(100000L); + parent.setMaxAddresses(100); + parent.setMaxQueues(100); + + ResourceQuota child = new ResourceQuota("child"); + child.setMaxMessageBytes(50000L); + child.setMaxAddresses(50); + child.setMaxQueues(50); + child.setParent(parent); + + // Build up counters via propagating methods + for (int i = 0; i < 5; i++) { + child.incrementAddressCount(); + child.incrementQueueCount(); + } + child.addSize(10000); + assertEquals(5, child.getAddressCount()); + assertEquals(5, child.getQueueCount()); + assertEquals(10000, child.getSize()); + assertEquals(5, parent.getAddressCount()); + assertEquals(5, parent.getQueueCount()); + assertEquals(10000, parent.getSize()); + + // adjustCountersDirect should modify counters WITHOUT propagating to parent + child.adjustCountersDirect(3, 2, 5000); + assertEquals(8, child.getAddressCount()); + assertEquals(7, child.getQueueCount()); + assertEquals(15000, child.getSize()); + // Parent should NOT have changed + assertEquals(5, parent.getAddressCount(), "Parent should not be affected by adjustCountersDirect"); + assertEquals(5, parent.getQueueCount(), "Parent should not be affected by adjustCountersDirect"); + assertEquals(10000, parent.getSize(), "Parent should not be affected by adjustCountersDirect"); + + // Negative deltas + child.adjustCountersDirect(-2, -3, -5000); + assertEquals(6, child.getAddressCount()); + assertEquals(4, child.getQueueCount()); + assertEquals(10000, child.getSize()); + assertEquals(5, parent.getAddressCount(), "Parent should remain unchanged"); + } + +}