Multi-Tenant Messaging
This document explains how BOOST ensures tenant isolation across all message-driven services using JMS message properties and selectors.
Overview
BOOST is a multi-tenant platform where multiple organizations share the same infrastructure. When processing messages (documents, emails, thumbnails, etc.), it's critical that:
- Messages from Tenant A are never processed by Tenant B's processor
- Events published by one tenant don't leak to another tenant's consumers
- The system can scale per-tenant without cross-contamination
The Problem
AMQP/JMS queues are shared across all tenants. Without isolation, a message published to cmd.document.process could be consumed by any processor listening on that queue.
┌─────────────────────────────────────────────────────────────┐
│ cmd.document.process │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Tenant A │ │ Tenant C │ │
│ │ message │ │ message │ │ message │ │ message │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
Which processor should handle which message?The Solution: tenantUUID Property
Every message in BOOST carries a tenantUUID JMS message property. This is set as a message header, not in the JSON body.
Setting the Property (Producer Side)
When publishing a message, the producer sets tenantUUID as a JMS string property:
TextMessage message = session.createTextMessage(jsonPayload);
message.setStringProperty("tenantUUID", organizationId);
producer.send(message);Filtering Messages (Consumer Side)
Each tenant processor creates consumers with a JMS message selector that filters by tenantUUID:
String selector = "tenantUUID = '" + organizationId + "'";
MessageConsumer consumer = session.createConsumer(queue, selector);The broker (ActiveMQ Artemis) evaluates the selector server-side, so filtered messages are never delivered to the wrong consumer.
┌─────────────────────────────────────────────────────────────┐
│ cmd.document.process │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Tenant A │ │ Tenant B │ │ Tenant A │ │ Tenant C │ │
│ │tenantUUID│ │tenantUUID│ │tenantUUID│ │tenantUUID│ │
│ │= "org-A" │ │= "org-B" │ │= "org-A" │ │= "org-C" │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼─────────────┼─────────────┼───────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Processor│ │Processor│ │Processor│ │Processor│
│Tenant A │ │Tenant B │ │Tenant A │ │Tenant C │
│selector:│ │selector:│ │selector:│ │selector:│
│"org-A" │ │"org-B" │ │"org-A" │ │"org-C" │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
▲ ▲
│ │
└───────────────────────────┘
Both messages delivered to Tenant A processorWhy Property vs JSON Body?
JMS message selectors only work on message properties (headers), not on the message body content.
// This WORKS - selector evaluates message property
message.setStringProperty("tenantUUID", "org-123");
consumer = session.createConsumer(queue, "tenantUUID = 'org-123'");
// This DOES NOT WORK - selector cannot see JSON body
String json = "{\"tenantUUID\": \"org-123\", ...}";
message = session.createTextMessage(json);
// Selector "tenantUUID = 'org-123'" will NOT match!The JSON body may also contain tenantUUID (or organizationId) for application-level use, but the JMS property is what enables efficient broker-side filtering.
Service-by-Service Implementation
BOOST.IntegrationsServer (Secretary)
Entry point for document processing. Sets tenantUUID on all outgoing commands.
// Secretary.java
public static void sendDocumentProcessCommand(String documentUUID, String organizationId) {
TextMessage message = session.createTextMessage(command.toString());
message.setStringProperty("tenantUUID", organizationId);
documentProcessProducer.send(message);
}Queues produced to:
cmd.document.processcmd.thumbnail.generate
BOOST.DocumentManager
Central orchestrator. Both consumes and produces messages with tenantUUID.
// TenantDocumentProcessor.java
// Consumer with selector
String selector = "tenantUUID = '" + organizationId + "'";
processConsumer = createConsumer("cmd.document.process", selector);
textExtractedConsumer = createConsumer("evt.document.text.extracted", selector);
interpretedConsumer = createConsumer("evt.document.interpreted", selector);
// Producer sets property
private void sendMessage(MessageProducer producer, String json) {
TextMessage message = session.createTextMessage(json);
message.setStringProperty("tenantUUID", organizationId);
producer.send(message);
}Queues consumed from:
cmd.document.processcmd.document.retryevt.document.text.extractedevt.document.interpreted
Queues produced to:
cmd.document.extractTextcmd.document.interpretevt.document.processing.startedevt.document.readyevt.document.needs.reviewevt.document.failed
BOOST.DocumentExtractor
Extracts text from documents. Reads tenantUUID from incoming message, propagates to outgoing events.
// DocumentExtractionHandler.java
private FileDescriptor parseMessage(String messageBody) {
// Try organizationId first (from DocumentManager), fall back to tenantUUID
String tenantUUID = json.getString("organizationId");
if (tenantUUID == null) {
tenantUUID = json.getString("tenantUUID");
}
fd.setTenantUUID(tenantUUID);
return fd;
}
// EventPublisher.java
public void publishSuccess(ExtractionEvent event) {
TextMessage message = session.createTextMessage(json);
if (event.getTenantUUID() != null) {
message.setStringProperty("tenantUUID", event.getTenantUUID());
}
successProducer.send(message);
}Queues consumed from:
cmd.document.extractText
Queues produced to:
evt.document.text.extractedevt.document.text.error
BOOST.DocumentProfessor
Interprets document content. Same pattern as DocumentExtractor.
// InterpretationHandler.java
private InterpretationRequest parseRequest(String messageBody) {
String tenantUUID = json.getString("organizationId");
if (tenantUUID == null) {
tenantUUID = json.getString("tenantUUID");
}
request.setTenantUUID(tenantUUID);
return request;
}
// RuleEngine.java
public InterpretationResult process(InterpretationRequest request) {
InterpretationResult result = new InterpretationResult();
result.setTenantUUID(request.getTenantUUID());
// ... processing ...
return result;
}
// EventPublisher.java
public void publishSuccess(InterpretationResult result) {
TextMessage message = session.createTextMessage(json);
if (result.getTenantUUID() != null) {
message.setStringProperty("tenantUUID", result.getTenantUUID());
}
successProducer.send(message);
}Queues consumed from:
cmd.document.interpret
Queues produced to:
evt.document.interpretedevt.document.interpretation.error
Message Flow Diagram
IntegrationsServer DocumentManager DocumentExtractor
│ │ │
│ cmd.document.process │ │
│ [tenantUUID: "org-123"] │ │
├───────────────────────────────────►│ │
│ │ │
│ │ cmd.document.extractText │
│ │ [tenantUUID: "org-123"] │
│ ├──────────────────────────────►│
│ │ │
│ │ evt.document.text.extracted │
│ │ [tenantUUID: "org-123"] │
│ │◄──────────────────────────────┤
│ │ │
│ │ DocumentProfessor
│ │ │
│ │ cmd.document.interpret
│ │ [tenantUUID: "org-123"]
│ ├──────────────►│
│ │ │
│ │ evt.document.interpreted
│ │ [tenantUUID: "org-123"]
│ │◄──────────────┤
│ │
│ evt.document.ready │
│ [tenantUUID: "org-123"] │
│◄───────────────────────────────────┤Fallback Handling
For backward compatibility, services accept both organizationId and tenantUUID in the JSON body:
String tenantUUID = json.getString("organizationId");
if (tenantUUID == null) {
tenantUUID = json.getString("tenantUUID");
}However, the JMS property should always be tenantUUID for consistency with other BOOST services (like PersonalDispatcher in BOOST.DeliveryBoy).
Checklist for New Services
When creating a new message-driven service, ensure:
Tenant Isolation
Consumers use selectors:
javaString selector = "tenantUUID = '" + tenantId + "'"; consumer = session.createConsumer(queue, selector);Producers set the property:
javamessage.setStringProperty("tenantUUID", tenantId);Parse tenantUUID from incoming messages:
java// From JSON body (for application use) String tenantUUID = json.getString("organizationId"); if (tenantUUID == null) { tenantUUID = json.getString("tenantUUID"); }Propagate through processing:
javaresult.setTenantUUID(request.getTenantUUID());Include in outgoing event JSON:
javajson.put("tenantUUID", tenantUUID); // or json.put("organizationId", tenantUUID);
Message Resilience
Use CLIENT_ACKNOWLEDGE mode:
javasession = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);Only acknowledge after success:
javatry { processMessage(message); message.acknowledge(); // After success only } catch (Exception e) { // Don't acknowledge - will be redelivered }Implement max redelivery protection:
javaif (redeliveryCount >= MAX_REDELIVERY_COUNT) { log.error("Max redelivery exceeded"); message.acknowledge(); // Prevent infinite loop return; }Persist errors before they're lost:
javarecordProcessingError(documentUUID, e.getMessage());Distinguish temporary vs permanent failures:
- Temporary (network, S3): throw exception to retry
- Permanent (not found, invalid data): log and acknowledge
Message Resilience
All BOOST messaging services follow these patterns to ensure no messages are lost:
CLIENT_ACKNOWLEDGE Mode
Use CLIENT_ACKNOWLEDGE instead of AUTO_ACKNOWLEDGE:
session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);This ensures messages are only removed from the queue after explicit acknowledgment.
Acknowledge After Success
Only acknowledge messages after successful processing:
@Override
public void onMessage(Message message) {
try {
// Process message...
processMessage(message);
// Only acknowledge after success
message.acknowledge();
} catch (Exception e) {
log.error("Processing failed", e);
// Don't acknowledge - broker will redeliver
}
}Poison Message Protection
Prevent infinite redelivery loops with a max retry count:
private static final int MAX_REDELIVERY_COUNT = 5;
@Override
public void onMessage(Message message) {
try {
int redeliveryCount = 0;
if (message.propertyExists("JMSXDeliveryCount")) {
redeliveryCount = message.getIntProperty("JMSXDeliveryCount") - 1;
}
if (redeliveryCount >= MAX_REDELIVERY_COUNT) {
log.error("Max redelivery exceeded, dropping message");
message.acknowledge();
return;
}
// Process...
message.acknowledge();
} catch (Exception e) {
// Will be redelivered up to MAX_REDELIVERY_COUNT times
}
}Temporary vs Permanent Failures
Distinguish between failures that might resolve on retry vs those that won't:
| Failure Type | Action | Example |
|---|---|---|
| Temporary | Throw exception (retry) | Network timeout, S3 unavailable |
| Permanent | Log error, acknowledge | Invalid data, document not found |
try {
String text = s3Client.fetchText(bucket, key);
} catch (S3Exception e) {
// Temporary - throw to trigger retry
throw new RuntimeException("S3 fetch failed", e);
}
if (document == null) {
// Permanent - log and skip
log.error("Document not found: {}", documentUUID);
return; // Will acknowledge
}Persisting Error State
Always record failures in the database before they're lost:
try {
processDocument(documentUUID);
} catch (Exception e) {
// Save error state to database BEFORE potentially losing the message
recordProcessingError(documentUUID, e.getMessage());
throw e; // Re-throw to trigger redelivery
}Security Considerations
- Never trust client-supplied tenantUUID for authorization decisions
- The tenantUUID should be derived from the authenticated session or database lookup
- Message selectors prevent cross-tenant message delivery but don't replace proper authorization
- Always validate that the requesting user has access to the specified tenant
Troubleshooting
Messages not being received
Check that the producer is setting the JMS property:
javalog.debug("Sending message with tenantUUID: {}", tenantUUID); message.setStringProperty("tenantUUID", tenantUUID);Check that the consumer selector matches:
javalog.info("Using message selector: {}", selector);Verify the property value is identical (case-sensitive)
Messages going to wrong tenant
- Ensure
setStringPropertyis called beforesend() - Check for typos in the property name (
tenantUUIDnottenantUuidortenant_uuid) - Verify the value isn't null or empty
Related Documentation
- BOOST.DocumentManager - Document processing orchestrator
- BOOST.DocumentExtractor - Text extraction service
- BOOST.DocumentProfessor - Document interpretation service
- BOOST.IntegrationsServer - Email and integration service