Aerospike Backend¶
Comprehensive documentation of the Aerospike storage backend for IgnisMQ.
Overview¶
IgnisMQ uses Aerospike as its primary storage backend, leveraging the Magazine library for queue primitives (load/fire operations) and AerospikeQueueService for queue metadata management.
The storage layer is split into two concerns:
- AerospikeStorage — a thin configuration holder that carries the
AerospikeConfigurationand namespace, implementing theBaseStoragevisitor pattern. - AerospikeQueueService — the CRUD for queue metadata and the sweep pointers. It is not on the message path: message slots are read and written through Magazine's own storage, not through this class.
Configuration¶
AerospikeStorage storage = new AerospikeStorage(
AerospikeConfiguration.builder()
.hosts(List.of(AerospikeHost.builder()
.host("localhost")
.port(3000)
.build()))
.retries(3)
.sleepBetweenRetries(100)
.socketTimeout(3000)
.totalTimeout(5000)
.maxConnectionsPerNode(100)
.threadPoolSize(4) // default: availableProcessors * 4
.build(),
"my-namespace"
);
TLS
If credentials are configured in AerospikeConfiguration, the client will negotiate TLS automatically.
Queue Metadata Set: {farmId}_{clientId}_ignis_queues¶
Each record represents one queue. The Aerospike key is the queue name.
| Bin | Type | Description |
|---|---|---|
handlerType |
String | Message handler type identifier |
shards |
int | Number of shards for parallelism |
queueExpiry |
int | Queue expiry in seconds |
messageExpiry |
int | Message TTL in seconds |
concurrency |
int | Consumer concurrency |
shovelConcur |
int | Shovel concurrency |
shovelInterval |
int | Shovel time interval in seconds |
createdAt |
long | Creation timestamp (epoch ms) |
active |
String | "true" / "false" — used for secondary index |
sweepPointers |
Map<String, Long> | Per-shard sweep pointer positions |
sidelineSweep |
Map<String, Long> | Per-shard sideline sweep pointer positions |
sweptCounter |
long | Total messages swept from main magazine |
sidelineSwept |
long | Total messages swept from sideline magazine |
sweepDuration |
long | Sweep look-back duration in milliseconds |
handlerTimeout |
long | Handler execution budget in milliseconds. Absent on queues created before 2.0, which fall back to the default rather than to zero |
maxBatchSize |
int | Batching config — max batch size (0 = disabled) |
maxWaitTime |
int | Batching config — max wait time in seconds (0 = disabled) |
Magazine Data Sets (managed by Magazine library)¶
| Set Name Pattern | Purpose |
|---|---|
{farmId}_{clientId}_data_set |
Message data records (keyed by {queueName}_SHARD_{shard}_{pointer}) |
{farmId}_{clientId}_meta_set |
Magazine pointers — load/fire pointers per shard |
Sideline
Sideline queues reuse the same Magazine sets but with _SIDELINE appended to the queue name (e.g., my-queue_SIDELINE).
Secondary Indexes¶
A secondary index is automatically created on the active bin (IndexType.STRING) during AerospikeQueueService construction:
// Index name: {farmId}_{clientId}_ignis_queues_active
createIndex(setName + "_" + ACTIVE_BIN, ACTIVE_BIN, IndexType.STRING);
This enables efficient queries for active or inactive queues via Filter.equal(ACTIVE_BIN, "true").
Warning
If the index already exists (result code 200), creation is silently skipped.
Client Configuration¶
The Aerospike client is configured in AerospikeStoreClient with the following policies:
| Setting | Value |
|---|---|
| Write commit level | CommitLevel.COMMIT_ALL |
| Write/read replica | Replica.MASTER_PROLES |
| Connection pool | maxConnectionsPerNode from config |
| Thread pool | threadPoolSize (default: availableProcessors * 4) |
| Write policy | sendKey = true (stores user key for retrieval) |
| TTL on write | Configurable per operation (-2 = don't update TTL) |
Retry Strategy¶
All AerospikeQueueService operations use a Guava Retryer:
RetryerBuilder.newBuilder()
.retryIfExceptionOfType(AerospikeException.class)
.withStopStrategy(StopStrategies.stopAfterAttempt(configuration.getRetries()))
.withWaitStrategy(WaitStrategies.fixedWait(
configuration.getSleepBetweenRetries(), TimeUnit.MILLISECONDS))
.withBlockStrategy(BlockStrategies.threadSleepStrategy())
.build();
Delivery-Time Watermarks¶
Changed in 2.0
Earlier versions stamped a fireTS bin on every message as it was delivered, and the sweeper
compared that timestamp per record. Both the bin and addFireTimestamp() are gone. If you are
upgrading, see the upgrade notes.
The sweeper has to answer one question: was this message handed to a consumer long enough ago that the consumer is certainly gone? The old answer was per message — a timestamp on every record. The current answer is positional, and costs nothing per message.
Magazine periodically records where each shard's fire pointer stood at a point in time, a
checkpoint. Given a cutoff, firePointerBefore(Instant) returns, per shard, the highest fire
pointer known to have been reached at or before that moment:
Map<String, FireCheckpoint> watermarks =
magazine.firePointerBefore(Instant.ofEpochMilli(now - sweepDurationMillis));
Everything at or below that pointer was claimed at least sweepDuration ago. Everything above it
might have been claimed a moment ago and must not be touched.
How checkpoints are recorded¶
IgnisMQ uses a window of sweepDuration / 8 (150 seconds at the default 20 minutes) and keeps 32
checkpoints per shard, so history reaches back four sweep durations. Magazine records them from its
active-shard refresh:
- At most one checkpoint per shard per window, across all instances. Each instance waits a random offset into the first half of the window; the first to get there writes, the others see it and skip.
- None for a shard whose pointer has not moved. Idle queues cost nothing.
- One batch call per magazine, not one per shard.
The sweeper, which runs only on the leader, also records when it reads. This normally writes nothing because the refresh already has; it matters when consumption has stopped everywhere, so the last claims still become sweepable.
A checkpoint is labelled with the end of the window it was taken in, so it can be used one window
later. A message abandoned at time T is therefore eligible from about T + sweepDuration + one
window, and is moved on the next sweep pass after that.
What this buys¶
Per-message fireTS |
Watermark | |
|---|---|---|
| Write cost per delivery | One extra Aerospike write per message | None |
| Storage per record | An extra bin on every record | None |
| How "old enough" is decided | Read the record, compare its bin | Compare a pointer to a shard-level watermark |
Two behaviours worth knowing¶
A shard missing from the answer means "sweep nothing here". That is normal for a young queue — no checkpoint reaches back that far yet — and it is the safe direction.
There is a pause after a fresh deploy, and it is expected
Checkpoints are recorded as the queue is used. Immediately after a new queue is created, or after
a long idle period, no checkpoint reaches back sweepDuration, so the sweeper correctly does
nothing. Sweeping resumes on its own once history has accumulated past that age — roughly
sweepDuration plus one checkpoint window. A quiet sweeper in that period after a deploy is not a
fault.
The sweeper refuses loudly rather than guessing. If the retained checkpoint history no longer
spans the requested cutoff, firePointerBefore throws INVALID_REQUEST, the pass is abandoned for
that magazine with an error log, and ignismq.sweep{outcome=failure} is recorded. Refusing is the
only safe answer — without history the sweeper cannot tell an abandoned message from a live one — and
the noise is deliberate: silently sweeping nothing looks exactly like a healthy idle queue.
Checkpoint retention is sized from sweepDuration, so this should not happen in normal operation.
Alert on it — see the monitoring runbook.
Sweep Internals¶
Runs on the one instance the leader assigned the sweep partition to, and only for active queues.
Sweeper.run()— skipped entirely unless this node holds the sweep assignment.- Active queues are fetched once via
queueService.getQueues(true). - Each queue is submitted to a pool capped at
Constants.PARALLEL_FACTOR(64). - Both magazines are swept — the main queue and its sideline.
Per magazine¶
flowchart TD
A[sweepMagazine] --> B["firePointerBefore(now - sweepDuration)"]
B --> C{Any shard has a checkpoint?}
C -- No --> Z[Done — nothing is provably old enough]
C -- Yes --> D[Per shard: limit = checkpoint fire pointer]
D --> E[Per shard: start = stored sweep pointer]
E --> F{start <= limit?}
F -- No --> Z2[Shard already swept to the watermark]
F -- Yes --> G["peek() a window of pointers, batched across shards"]
G --> H{Slot still holds a record?}
H -- No --> I[Delivered and acknowledged — nothing to do]
H -- Yes --> J[Load into the sideline magazine]
J --> K{Load succeeded?}
K -- No --> L[Leave it. The next pass retries]
K -- Yes --> M[Delete from the source magazine]
M --> N[Advance the shard's sweep pointer]
I --> N
N --> O{Reached the watermark?}
O -- No --> G
O -- Yes --> Z3[Done]
Key details¶
- The watermark bounds the range; it does not say what is in it. A slot below the fire pointer is
either empty — delivered and acknowledged — or still full, meaning abandoned. Only looking can tell,
so the range is still scanned, but with one
peekper batch rather than a read per message. - Every slot is scanned once, ever. The per-shard sweep pointer only moves forward.
peekis batched across shards, not per shard, so a queue with 8 shards issues one multi-key read per round rather than 8.- Re-homing is at-least-once, deliberately. The message is loaded into the sideline and only then deleted from the source. A crash between the two leaves it in both, and the next pass re-homes it again. The opposite ordering loses messages.
- A failed load is not a delete. The record stays where it is for a later pass. This is the same contract the consumer follows when the sideline refuses a message.
- Shard count comes from
magazine.getShards(), the persisted value, so ignisMQ's view and Magazine's can no longer disagree. sweepDurationis capped at 12 hours, and the handler timeout is clamped to at most half of it so that a handler cannot still be running when the sweeper re-homes the record it is working on.
Performance Tips¶
| Lever | Guidance |
|---|---|
| Shards | More shards = more parallelism, but every shard widens active-shard discovery. Range 1–512, default 8. Fixed once the queue exists. |
| Message TTL | Set messageExpiry to keep data clean and prevent unbounded growth. |
| Queue metadata TTL | Automatically set to queueExpiry * 2 for safety margin. |
| Sweep batch size | Fixed at 1000 — balances memory usage vs. scan efficiency. |
| Namespace sizing | Size the namespace memory for peak in-flight message volume. |
| Thread pool | Sweep parallelism is capped at 64 threads (Constants.PARALLEL_FACTOR). |
| Concurrency | Max 100 consumers per queue (MAX_CONSUMERS_ALLOWED). |
| Connection pool | Tune maxConnectionsPerNode based on cluster size and throughput. |