Skip to content

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:

  1. Messages from Tenant A are never processed by Tenant B's processor
  2. Events published by one tenant don't leak to another tenant's consumers
  3. 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:

java
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:

java
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 processor

Why Property vs JSON Body?

JMS message selectors only work on message properties (headers), not on the message body content.

java
// 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.

java
// 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.process
  • cmd.thumbnail.generate

BOOST.DocumentManager

Central orchestrator. Both consumes and produces messages with tenantUUID.

java
// 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.process
  • cmd.document.retry
  • evt.document.text.extracted
  • evt.document.interpreted

Queues produced to:

  • cmd.document.extractText
  • cmd.document.interpret
  • evt.document.processing.started
  • evt.document.ready
  • evt.document.needs.review
  • evt.document.failed

BOOST.DocumentExtractor

Extracts text from documents. Reads tenantUUID from incoming message, propagates to outgoing events.

java
// 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.extracted
  • evt.document.text.error

BOOST.DocumentProfessor

Interprets document content. Same pattern as DocumentExtractor.

java
// 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.interpreted
  • evt.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:

java
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

  1. Consumers use selectors:

    java
    String selector = "tenantUUID = '" + tenantId + "'";
    consumer = session.createConsumer(queue, selector);
  2. Producers set the property:

    java
    message.setStringProperty("tenantUUID", tenantId);
  3. Parse tenantUUID from incoming messages:

    java
    // From JSON body (for application use)
    String tenantUUID = json.getString("organizationId");
    if (tenantUUID == null) {
        tenantUUID = json.getString("tenantUUID");
    }
  4. Propagate through processing:

    java
    result.setTenantUUID(request.getTenantUUID());
  5. Include in outgoing event JSON:

    java
    json.put("tenantUUID", tenantUUID);
    // or
    json.put("organizationId", tenantUUID);

Message Resilience

  1. Use CLIENT_ACKNOWLEDGE mode:

    java
    session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);
  2. Only acknowledge after success:

    java
    try {
        processMessage(message);
        message.acknowledge();  // After success only
    } catch (Exception e) {
        // Don't acknowledge - will be redelivered
    }
  3. Implement max redelivery protection:

    java
    if (redeliveryCount >= MAX_REDELIVERY_COUNT) {
        log.error("Max redelivery exceeded");
        message.acknowledge();  // Prevent infinite loop
        return;
    }
  4. Persist errors before they're lost:

    java
    recordProcessingError(documentUUID, e.getMessage());
  5. 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:

java
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:

java
@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:

java
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 TypeActionExample
TemporaryThrow exception (retry)Network timeout, S3 unavailable
PermanentLog error, acknowledgeInvalid data, document not found
java
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:

java
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

  1. Check that the producer is setting the JMS property:

    java
    log.debug("Sending message with tenantUUID: {}", tenantUUID);
    message.setStringProperty("tenantUUID", tenantUUID);
  2. Check that the consumer selector matches:

    java
    log.info("Using message selector: {}", selector);
  3. Verify the property value is identical (case-sensitive)

Messages going to wrong tenant

  1. Ensure setStringProperty is called before send()
  2. Check for typos in the property name (tenantUUID not tenantUuid or tenant_uuid)
  3. Verify the value isn't null or empty