Architecture¶
This page provides a deep dive into the internal architecture of IgnisMQ — its components, threading model, data flows, type hierarchy, and operational mechanics.
1. System Overview¶
IgnisMQ is structured in distinct layers, each with a clear responsibility.
graph TB
subgraph Application Layer
A[IgnisMQBundle<br/><i>Dropwizard Bundle</i>]
B[IgnisMQManager<br/><i>Queue Registry</i>]
end
subgraph Queue Layer
C1[MagazineQueue #1]
C2[MagazineQueue #2]
CN[MagazineQueue #N]
subgraph "Each MagazineQueue"
MQ_MAIN[Main Magazine]
MQ_SIDE[Sideline Magazine]
end
end
subgraph Consumer Layer
D1[MagazineConsumerTask x concurrency]
D2[ShovelTask x shovel concurrency]
end
subgraph Coordination Layer
E1[TaskInitializer]
E2[LeaderElector]
E3[Sweeper]
E4[ZooKeeper / Curator]
end
subgraph Storage Layer
F1[AerospikeQueueService]
F2[AerospikeStoreClient]
F3[(Aerospike)]
end
A -->|creates| B
B -->|holds| C1
B -->|holds| C2
B -->|holds| CN
C1 --> D1
C1 --> D2
E1 -->|creates| E2
E1 -->|creates| E3
E2 -->|LeaderSelector| E4
E2 -->|activates the assigned instance| E3
B --> F1
E3 --> F1
F1 --> F2
F2 --> F3
Layered Design
Each layer only communicates with its immediate neighbours. The application layer never touches storage directly — it always goes through the queue abstraction.
2. Component Relationships¶
classDiagram
class IgnisMQBundle {
<<abstract>>
-IgnisMQManager ignisMQManager
+run(config, environment)
+getIgnisMQManager() IgnisMQManager
#context(config) IgnisMQContext
}
class IgnisMQContext {
+BaseStorage storage
+String clientId
+String farmId
+CuratorFramework curatorFramework
+IgnisMQSettings settings
+ConsoleConfiguration console
}
class IgnisMQManager {
-Map~String, IQueue~ ignisMQMap
+createQueue(CreateQueueRequest)
+getQueue(name) IQueue
}
class IQueue~M~ {
<<sealed interface>>
+publish(M message) boolean
+getUnconsumedCount() long
+getMetaData() QueueMetaData
+shovel(int concurrency)
}
class MagazineQueue~M~ {
-Magazine~String~ magazine
-Magazine~String~ sidelineMagazine
-MessageHandler~M~ messageHandler
-List~ScheduledFuture~ consumers
-List~ScheduledFuture~ sidelineConsumers
-HandlerExecutor handlerExecutor
-ObjectMapper mapper
+publish(M message) boolean
+createConsumers(int count)
+stopConsumers(int count)
}
class Magazine~T~ {
-BaseMagazineStorage~T~ baseMagazineStorage
-String magazineIdentifier
+load(T data) boolean
+fire() MagazineData~T~
+delete(MagazineData~T~ magazineData)
+deleteAll(Collection~MagazineData~T~~ magazineData)
}
class MagazineConsumerTask~M~ {
-Magazine~String~ magazine
-Magazine~String~ sidelineMagazine
-MessageHandler~M~ messageHandler
-HandlerExecutor handlerExecutor
+run()
}
class ShovelTask {
-Magazine~String~ magazine
-Magazine~String~ sidelineMagazine
+run()
}
class TaskInitializer {
-LeaderElector leaderElector
-ScheduledFuture sweeperTask
+start()
+stop()
}
class LeaderElector {
-LeaderSelector leaderSelector
-CuratorFramework curatorFramework
+takeLeadership()
-updateState(boolean force)
}
class Sweeper {
<<implements LoadBalancer>>
-AtomicBoolean active
-QueueSweeper queueSweeper
+activate()
+deactivate()
+run()
}
class QueueStatGuage {
<<Supplier~List~>>
+get() List
}
class QueueService {
<<sealed interface>>
+exists(String name) boolean
+get(String name) Optional~QueueEntity~
+store(String name, QueueEntity entity, int ttl)
+getQueues(boolean active) Map~String, QueueEntity~
+updateSweepProgress(...)
}
class AerospikeQueueService {
-IAerospikeClient client
-String namespace
-String setName
}
class AerospikeStoreClient {
-IAerospikeClient client
-AerospikeConfiguration config
}
IgnisMQBundle ..> IgnisMQContext : context(config) returns
IgnisMQBundle --> IgnisMQManager : creates from the context
IgnisMQManager o-- IQueue : ConcurrentHashMap
IQueue <|.. MagazineQueue : permits
MagazineQueue *-- Magazine : main
MagazineQueue *-- Magazine : sideline
MagazineQueue *-- MagazineConsumerTask : consumers
MagazineQueue *-- ShovelTask : sidelineConsumers
MagazineConsumerTask --> Magazine : reads from main
MagazineConsumerTask --> Magazine : writes to sideline
ShovelTask --> Magazine : reads from sideline
ShovelTask --> Magazine : writes to main
TaskInitializer --> LeaderElector : creates
TaskInitializer --> Sweeper : creates
LeaderElector ..> Sweeper : activates/deactivates
QueueService <|.. AerospikeQueueService : permits
AerospikeQueueService --> AerospikeStoreClient
IgnisMQManager --> QueueStatGuage : reads
IgnisMQBundle --> QueueStatGuage : exposes as CachedGauge
ConcurrentHashMap
IgnisMQManager holds its queues in ignisMQMap, a Map<String, IQueue<?>> backed by a ConcurrentHashMap. This allows thread-safe registration and lookup at runtime.
3. Threading Model¶
Rewritten in 2.0
IgnisMQ used to run one java.util.Timer thread per consumer, per shovel, plus one each for the
watcher and sweeper. A queue with 8 consumers and 2 shovels cost 10 threads on scheduling alone.
Everything is now a task on one of three shared pools.
The three pools¶
| Pool | Size | Runs | Isolation rationale |
|---|---|---|---|
| Control | Fixed at 2 | Queue watcher, sweeper | The control plane. The watcher is how a queue created elsewhere becomes consumable here; the sweeper is the only thing that rescues messages from a dead consumer. Neither may be starved by the data plane it supervises |
| Worker | Grows to workerThreads (256) |
Consumers, shovels | Shovels belong here, not with the watcher: they are per-queue, configurable and do storage I/O — the same class of work as a consumer. A third pool would add threads and a knob without buying isolation that matters |
| Handler | Up to workerThreads |
User MessageHandler code only |
The only way to bound how long a worker waits for arbitrary user code is to not run it on that thread |
Sharing one pool coupled control with data: enough slow consumers and the watcher silently stops refreshing queues while the sweeper stops recovering orphans.
Schedule summary¶
| Component | Scheduled on | Initial delay | Period |
|---|---|---|---|
| Consumer task | Worker | 1 s | 1 s fixed delay |
| Shovel task | Worker | 1 s | timeIntervalInSecs, default 600 s |
| Queue watcher | Control | 1 min | 5 min |
| Sweeper | Control | 10 min | 15 min |
| Leader election | Its own Curator threads | — | 30 s state poll |
| Sweep fan-out | Fixed pool, PARALLEL_FACTOR = 64 |
— | Per pass, across queues |
Fixed delay, not fixed rate: the gap is measured after a task finishes, so a slow consumer never has runs queued up behind it.
What bounds a thread¶
The split bounds the blast radius of a badly behaved handler; it does not make the worker pool immune to one. Three mechanisms do that:
| Mechanism | Value | Closes |
|---|---|---|
| Consumer run budget | 30 s | A backlogged queue holding a worker thread forever. The consumer yields and resumes next run; a batch in hand is always finished first, so the budget never costs a delivery |
| Handler timeout | handlerTimeoutInMins, clamped to half of sweepDuration |
A hung handler holding a worker, and the duplicate-processing window where the sweeper re-homes a record the handler is still working on |
| Saturation refusal | 1 s grace, then refuse | Running a batch inline with no bound when the handler pool is exhausted. The batch is sidelined under reason=saturated instead |
concurrency is a target, not a thread count
For a queue with concurrency = 8 and shovelConcurrency = 2, IgnisMQ schedules 10 tasks
that share the worker pool — not 10 threads. Size workerThreads as concurrently active queues
times their concurrency.
4. Data Flow¶
4.1 Publish Flow¶
sequenceDiagram
participant App as Application
participant IQ as IQueue.publish()
participant OM as ObjectMapper
participant Mag as Main Magazine
participant MS as BaseMagazineStorage
participant AS as Aerospike
App->>IQ: publish(message)
IQ->>OM: writeValueAsString(message)
OM-->>IQ: JSON string
IQ->>Mag: load(json)
Mag->>MS: write slot, advance pointer
MS->>AS: put(key, bins)
AS-->>MS: ok
MS-->>Mag: ok
Mag-->>IQ: true
IQ-->>App: boolean
4.2 Consume Flow¶
sequenceDiagram
participant T as Worker pool
participant CT as MagazineConsumerTask
participant M as Main Magazine
participant MS as BaseMagazineStorage
participant AS as Aerospike
participant H as MessageHandler
T->>CT: run()
CT->>M: fire()
M->>MS: claim next pointer, read slot
MS->>AS: operate(meta) + get(data)
AS-->>MS: Record
MS-->>M: Record
M-->>CT: MagazineData
CT->>CT: deserialize(JSON → M)
CT->>H: handle(message) or handle(batch), on the handler pool, with a timeout
alt Handler returns true
H-->>CT: true
CT->>M: deleteAll(batch)
M->>MS: deleteAll(slots)
MS->>AS: one batched delete, or one delete per key<br/>when the batch holds a single record
else Returns false, throws, times out, or the pool is saturated
H-->>CT: failure
CT->>CT: sidelineMagazine.load(payload)
alt Sideline accepted it
CT->>M: delete(record), one at a time
else Sideline refused it
CT->>CT: leave the record for the sweeper
end
end
4.3 Shovel Flow¶
sequenceDiagram
participant T as Worker pool
participant ST as ShovelTask
participant SL as Sideline Magazine
participant M as Main Magazine
participant AS as Aerospike
T->>ST: run()
ST->>SL: fire()
SL->>AS: query(sidelineSet)
AS-->>SL: Record
SL-->>ST: Record
ST->>M: load(record)
M->>AS: put(mainSet, record)
AS-->>M: ok
M-->>ST: ok
ST->>SL: delete(id)
SL->>AS: delete(sidelineSet, id)
4.4 Sweep Flow¶
sequenceDiagram
participant Sched as Control pool
participant SW as Sweeper
participant SP as Sweep pool (fixed, 64)
participant QS as QueueService
participant M as Main Magazine
participant SL as Sideline Magazine
Sched->>SW: run()
SW->>QS: getQueues(active=true)
QS-->>SW: Map<String, QueueEntity>
loop For each queue (parallel, capped at 64)
SW->>SP: submit(sweepTask)
SP->>M: firePointerBefore(now - sweepDuration)
M-->>SP: per-shard watermark, or absent
loop Until each shard reaches its watermark
SP->>M: peek(pointers, batched across shards)
M-->>SP: records still present = abandoned
SP->>SL: load(payload)
SP->>M: delete(record) only if the load succeeded
end
SP->>QS: updateSweepProgress(pointers, swept)
end
How the sweeper knows a message is old enough
It does not read a timestamp from the message — there is no longer one to read. Magazine reports
where each shard's fire pointer stood at a past moment, so everything at or below that pointer was
claimed at least sweepDuration ago. See
delivery-time watermarks.
If the retained checkpoint history does not reach back far enough, the sweeper refuses the pass
and logs an error rather than guessing. Alert on ignismq.sweep{outcome=failure}.
5. Sealed Type Hierarchy¶
IgnisMQ uses Java 17 sealed types to enforce a closed set of implementations.
classDiagram
class IQueue~M~ {
<<sealed interface>>
+publish(M message) boolean
+getUnconsumedCount() long
+getMetaData() QueueMetaData
+shovel(int concurrency)
}
class MagazineQueue~M~ {
<<final>>
}
IQueue <|.. MagazineQueue : permits
class QueueService {
<<sealed interface>>
+exists(String name) boolean
+get(String name) Optional~QueueEntity~
+store(String name, QueueEntity entity, int ttl)
+updateSweepProgress(...)
}
class AerospikeQueueService {
<<final>>
}
QueueService <|.. AerospikeQueueService : permits
class BaseStorage {
<<abstract sealed>>
+accept(StorageVisitor~T~ visitor) T
}
class AerospikeStorage {
<<final>>
-AerospikeConfiguration configuration
-String namespace
}
BaseStorage <|-- AerospikeStorage : permits
class StorageClient~T~ {
<<interface>>
+getClient() T
+stop()
}
class AerospikeStoreClient {
<<final>>
}
StorageClient <|.. AerospikeStoreClient : implements
Why Sealed Types?
Sealed types provide exhaustiveness guarantees at compile time. When you pattern-match on IQueue, the compiler knows the only possible implementation is MagazineQueue. This makes the codebase safer to extend — adding a new storage backend requires updating all permits clauses and their consumers.
6. Visitor Pattern¶
IgnisMQ uses the visitor pattern to decouple storage construction from storage type specifics.
classDiagram
class BaseStorage {
<<abstract sealed>>
+accept(StorageVisitor~T~ visitor) T
}
class AerospikeStorage {
-AerospikeConfiguration configuration
-String namespace
+accept(StorageVisitor~T~ visitor) T
}
class StorageVisitor~T~ {
<<interface>>
+visit(AerospikeStorage storage) T
}
class MagazineStorageVisitor {
<<final, top-level>>
+visit(AerospikeStorage storage) BaseMagazineStorage~String~
}
class QueueServiceVisitor {
<<anonymous, inside IgnisMQManager>>
+visit(AerospikeStorage storage) QueueService
}
class StoreClientVisitor {
<<anonymous, inside IgnisMQManager>>
+visit(AerospikeStorage storage) StorageClient
}
BaseStorage <|-- AerospikeStorage : permits
StorageVisitor <|.. MagazineStorageVisitor
StorageVisitor <|.. QueueServiceVisitor
StorageVisitor <|.. StoreClientVisitor
BaseStorage --> StorageVisitor : accept()
AerospikeStorage ..|> BaseStorage
How it works:
IgnisMQManagerreceives aBaseStorageinstance (which is actuallyAerospikeStorage).- To build a
QueueService, it callsstorage.accept(...)with an anonymousStorageVisitor. AerospikeStorage.accept()dispatches tovisitor.visit(this).- The visitor has access to the concrete
AerospikeStoragefields and constructsAerospikeQueueService.
Two of the three visitors have no name
QueueServiceVisitor and StoreClientVisitor above are labels for readability, not types — in
source both are anonymous new StorageVisitor<>() { ... } expressions inside
IgnisMQManager.buildQueueCommands and buildStorageClient. Only MagazineStorageVisitor is a
real named type, and it is top-level rather than nested in MagazineQueue: the sweeper needs
the same storage recipe, and having a queue type own it made the sweep package depend back on the
queue package. That is a genuine cycle, and the architecture rules reject it.
The Magazine instances themselves are built through MagazineStorageVisitor, which returns a
BaseMagazineStorage<String> — the storage a Magazine is constructed on, not a Magazine.
Extensibility
To add a new storage backend (e.g., Redis), you would: (1) create RedisStorage extends BaseStorage, (2) add visit(RedisStorage) to StorageVisitor, (3) implement the visitor methods. The sealed hierarchy ensures you cannot forget any call site.
7. Metrics¶
Core records metrics through Micrometer. ignismq-dw-bundle bridges them into the application's
Dropwizard MetricRegistry and supplies Dropwizard-specific gauges and lifecycle adapters.
ignismq-core has no Dropwizard dependency at any scope, and an enforcer rule fails the build if one
appears.
Registered metrics¶
Full definitions, tags and semantics: Metrics. In outline:
| Meter family | Covers |
|---|---|
ignismq.publish, ignismq.consume, ignismq.messages, ignismq.poll, ignismq.sideline |
The message path |
ignismq.handler.* |
Duration, batch size, timeouts, saturation |
ignismq.queue.* |
Depth and lifetime counters, as gauges from one cached snapshot |
ignismq.sweep*, ignismq.shovel*, ignismq.queue.refresh, ignismq.queue.create |
Control plane |
ignismq.pool.* |
Control and worker pool saturation |
magazine.* |
Magazine's own instrumentation, tagged by operation or outcome and by magazine=main/sideline for the whole process (per queue only with perQueueStorageMetrics) |
ignis.queue.stats |
Bundle only: a Dropwizard CachedGauge, 3 minute TTL |
Every dimension is a tag rather than part of the name, so a queue is a tag value and not a new meter.
Dropwizard has no notion of tags, so the bundle's bridge flattens each Micrometer tag into the metric
name. magazine.fire.outcomes tagged magazine=main, outcome=delivered is published as
magazine.fire.outcomes.magazine.main.outcome.delivered.
The queue-stat gauges¶
flowchart LR
A[Metrics scrape] --> B[Queue depth gauges]
B --> C{Snapshot fresh?}
C -->|Yes| D[Serve from the cached snapshot]
C -->|No| E[One metadata read per queue]
E --> F[published, consumed, unconsumed,<br/>sidelined, shovelled]
F --> D
Depth is the one value that costs a storage read, so all five gauges for a queue share a single refresh rather than each triggering their own. Without that, a scrape would cost one metadata read per queue per meter.
What happened to commands.*¶
Earlier versions wove @MonitoredFunction timers into most public methods with AspectJ. That was
removed along with the Dropwizard coupling: it pulled AspectJ and a build-time weaving step into
ignismq-core, and the resulting timers measured method entry and exit rather than anything
operationally meaningful.
The two that were worth keeping — publish and consume latency — are now ignismq.publish and
ignismq.consume, with the queue as a tag. The rest of the commands.* namespace is gone, and
nothing is aliased: see the upgrade notes.
8. ZooKeeper Node Structure¶
IgnisMQ uses ZooKeeper (via Apache Curator) for leader election and work distribution among instances.
/{clientId}-ignis-workers/
└── {clientId}/
├── loadbalancer-leader ← LeaderSelector (persistent node)
│ Only one instance holds leadership.
│ The leader assigns the sweep
│ partition; the assigned instance
│ runs the Sweeper, leader or not.
│
├── loadbalancer/
│ └── {balancerId} ← Ephemeral nodes, named by the
│ instance's balancerId UUID.
│ Not sequential: the UUID is
│ already the identity.
│
└── readers/
└── {partition} ← Communicator nodes.
Each partition stores the
assigned balancerId, mapping
partitions to instances.
graph TD
ROOT["/{clientId}-ignis-workers"] --> CLIENT["/{clientId}"]
CLIENT --> LEADER["loadbalancer-leader<br/><i>LeaderSelector</i>"]
CLIENT --> LB["loadbalancer/"]
CLIENT --> READERS["readers/"]
LB --> LB1["{balancerId-001}<br/><i>ephemeral</i>"]
LB --> LB2["{balancerId-002}<br/><i>ephemeral</i>"]
LB --> LBN["{balancerId-N}<br/><i>ephemeral</i>"]
READERS --> R1["1<br/>assigned: balancerId-002"]
style LEADER fill:#f9a825,stroke:#f57f17
style LB1 fill:#81d4fa,stroke:#0277bd
style LB2 fill:#81d4fa,stroke:#0277bd
style LBN fill:#81d4fa,stroke:#0277bd
Leader election flow:
- Each instance creates an ephemeral node under
loadbalancer/. - Curator's
LeaderSelectorcompetes forloadbalancer-leader. - The winning instance calls
takeLeadership(), which forces an immediate reassignment pass. Leadership is what makes an instance the assigner; it is not what activates a sweeper. - The leader reassigns partitions across live balancer nodes by round-robin, writing each assignment into that partition's node under
readers/. It rewrites them only when membership changes, or on the forced pass taken at step 3. - Every instance polls its assignment every 30 seconds - these nodes are read, not watched - and the instance whose
balancerIdmatches activates itsSweeper. That may or may not be the leader, and activation can lag a reassignment by up to 30 seconds. - When the leader dies, its ephemeral node disappears and Curator triggers a new election.
ZooKeeper Dependency
ZooKeeper is required for multi-instance deployments. In single-instance mode, the instance always becomes the leader. If ZK is unavailable, sweeping and partition rebalancing will stall — but publish and consume continue to work.