Console¶
A read-only console, plus a small set of actions behind a role. It ships with ignismq-dw-bundle
and is mounted automatically.
The shape of it, and why¶
ignisMQ has no broker. RabbitMQ's management UI and Kafka's consoles talk to a server that knows the whole cluster. ignisMQ is a library: the only thing that knows the whole system is Aerospike, and the only thing that knows a process is that process.
So the console is two views over two sources, and it never lets one borrow the other's authority.
| View | Source | True of |
|---|---|---|
| Cluster | Aerospike, through the queue service | What queues exist, their stored configuration, and whether they are active. Correct wherever it is read |
| Instance | This process's own registry | Consumers, shovels and meters here. Correct for this process and nothing else |
Every response and every panel says which it is.
A queue is active or it is not — that is the only state the console treats as a category. Every instance adopts every active queue on its refresh pass, so "which instances serve this queue" is not a useful distinction and the console does not present one.
The exception is real but narrow. A queue can be active in storage and not yet adopted here:
between its creation on another instance and this instance's next refresh, or permanently if this
instance has no handler registered for its messageHandlerType. Depth is read through live
magazines, so that queue reports its configuration and no depth at all rather than zeros, which
would read as an idle queue. The console flags it as the anomaly it is, not as a category of queue.
Aggregating across instances¶
It does not. Instance metrics are per-process and ignisMQ has no register of processes. Rolling
them up is the metrics backend's job — scrape ignismq.* with Prometheus and aggregate in Grafana,
which is what those tools are for. See the monitoring runbook.
The rejected alternative is worth stating, because it sounds reasonable: instances could register themselves in Aerospike and publish periodic metric snapshots. That is a control plane, with its own liveness, staleness and retention semantics — which is to say it is a broker, the exact component ignisMQ's design declines to have. Adding one as a side effect of a monitoring feature would make the library's central claim false.
Endpoints¶
Mounted at /ignismq/v1. The dashboard is at /ignisConsole.
Both are relative to the application's Jersey root path: if you set server.rootPath the endpoints
move under it, while /ignisConsole is a servlet and does not. The dashboard derives the API base
from its own URL, so it follows a root path of / without configuration and needs the base adjusting
if you set one.
| Method | Path | View | Role |
|---|---|---|---|
GET |
/queues |
Cluster | none |
GET |
/queues/{name} |
Cluster, plus the instance view when this process serves it | none |
GET |
/queues/{name}/shards |
Instance - 409 if this process does not serve the queue |
none |
GET |
/instance |
Instance | none |
GET |
/instance/metrics |
Instance. ?queue=NAME for one queue, ?scope=process for meters tied to no queue |
none |
GET |
/whoami |
The caller | none |
POST |
/queues/{name}/shovel |
Instance, or cluster with intervalSeconds |
ignismq_operate |
POST |
/queues/{name}/sweep |
Cluster | ignismq_operate |
POST |
/queues/{name}/consumers |
Instance | ignismq_operate |
POST |
/queues/{name}/deactivate |
Cluster | ignismq_deactivate |
GET /queues lists deactivated queues too, marked "active": false. A deactivated queue is still a
queue an operator has to be able to look at.
Reading a queue¶
{
"queue": {
"name": "orders",
"active": true,
"heldHere": true,
"shards": 8,
"concurrency": 6,
"createdAt": 1700000000000,
"messageExpirySeconds": 172800,
"queueExpirySeconds": 172800,
"sweepDurationMillis": 1200000,
"handlerTimeoutMillis": 600000,
"shovelConcurrency": null,
"shovelIntervalSeconds": null,
"maxBatchSize": null,
"maxWaitTimeSeconds": null,
"messageHandlerType": "orderHandler"
},
"depth": {"published": 120, "consumed": 100, "unconsumed": 20, "sidelined": 5, "shovelled": 2},
"shards": [
{"shard": "SHARD_0", "published": 100, "consumed": 90, "pending": 10},
{"shard": "SHARD_1", "published": 20, "consumed": 10, "pending": 10}
],
"instance": {"name": "orders", "consumers": 4, "shovels": 1,
"shovelConcurrency": 2, "shovelIntervalSeconds": 600}
}
depth, shards and instance are null when this process does not serve the queue. A stored
shovel that was never configured is null rather than the -1 that is stored, which would read as a
value.
Shard balance¶
There is no such thing as a hot key here, because there is no key. publish picks a shard with
ThreadLocalRandom.nextInt(shards), and a consumer fires from a randomly chosen active shard.
Nothing routes by content. (Ordering-key routing is a candidate feature, not a current one.)
So an even spread is the expected shape, and this view is for the opposite question: is any shard
sitting far from where random assignment would have put it? A shard well above the others is one
whose consumers are not keeping up with it — most often because messages on it are being retried,
sidelined slowly, or handled by something that has stalled. The dashboard marks a shard above
max(3 x even share, even share + 50), and states the even share so you can judge it yourself. The
absolute floor stops a nearly empty queue flagging ordinary noise as an outlier.
What it is not evidence for is the shard count. Whether eight shards or thirty-two is right is a question about contention on each shard's pointer counters and about discovery fan-out, and it is answered by throughput under concurrency, not by this table.
/shards returns 409 Conflict for a queue this instance has not adopted — a different answer from
404, which means the queue does not exist.
The instance tab¶
GET /instance reports the process and the queues it is running. GET /instance/metrics reports
the ignisMQ and Magazine meters this process currently holds, read straight from the meter
registry so it cannot drift from what a scrape would report. With hundreds of queues that is tens of
thousands of meters, so it can be narrowed: ?scope=process returns the meters tied to no queue, and
?queue=NAME one queue's. meterCount always counts every ignisMQ and Magazine meter in the process, whatever the filter. The dashboard uses only the
narrowed forms: the process meters on every refresh, and a queue's meters when its group is opened.
Magazine's meters are included deliberately: ignisMQ's own meters say nothing about the storage calls underneath them, and an operator reading one without the other draws the wrong conclusion.
The dashboard groups them rather than listing them flat, because forty rows in name order answer no question:
- Process — pools, schedulers, and Magazine's storage meters (
magazine.*, folded tomainandsidelinefor the process) - One group per queue running here, alphabetically, 15 at a time. A queue's meters are fetched when
its group is opened. With
perQueueStorageMetricson, the queue's Magazine meters appear here too - Counters and timers sharing a name — Aerospike calls by operation, messages by outcome, handler duration by outcome — are one row, expanding to the members. A counter row shows the total and lists zero members on one line; each Aerospike operation says what queue operation it serves. A timer row adds up counts and rates, takes its mean as total time over total calls (not an average of means), and its max from the slowest member
- A filter over queue names and loaded meters, and a link from any queue on either tab straight to its meters
"Queues running here" sits below the meters in its own scrolling box with its own filter, so a process with hundreds of queues does not push everything else off the page.
Finding a queue¶
The queue list filters by name and by All / Active / Inactive, with counts. More filters adds handler (with counts), created time (last hour, 24 hours, 7 days, 30 days, or older than 30 days), whether the queue runs on this instance, and sorting by name or age. Each entry shows its handler and age under the name.
Trends¶
The page refreshes every 10 seconds by default; the interval is selectable in the header (5, 10, 30 or 60 seconds, or paused) and remembered per browser. Each refresh adds a sample, and from those samples the console draws:
- on a queue: backlog (unconsumed and sidelined) and published vs consumed per second;
- on the instance tab: a per-second rate with a sparkline for every counter and timer, the mean of the last interval for timers next to the lifetime mean, and a trend for every gauge.
How each figure is worked out:
| Figure | Calculation |
|---|---|
| Per second | (value now − previous value) ÷ seconds between the two samples; a drop (a restart) counts as 0 |
| Mean (last refresh) | Δ total time ÷ Δ count between the last two samples, so only the calls since the previous refresh |
| Sparkline | The last 60 per-second values, scaled from 0 to that row's own peak - it compares a row with itself, not with other rows |
| Gauge trend | The gauge's values as sampled |
| Queue per second | Rates of the queue's stored published and consumed totals, so all instances, unlike the instance tab |
A row reads "collecting" until it has two samples. The queue depth is cached for cacheSeconds, so at
an interval shorter than that, consecutive samples can repeat and draw a zero followed by a spike.
The history lives in the browser only, holds the last 60 samples, and covers only meters the page has loaded, so it adds no requests and does not grow with the number of queues. A trend therefore starts when the page opens; for longer history use your metrics backend.
Meters are grouped by queue, and within a queue by type — timers, distributions, counters and gauges each get their own table, because each carries different statistics and a single shared column could only stringify them. Numbers sit in real columns that line up down the page, and a bar scaled against the largest value in that table shows which row is the outlier without reading every figure. Timers are shown as count, mean and max in milliseconds rather than as Micrometer's raw statistics. Every meter carries an i marker; hovering or tabbing to it explains in a sentence what that meter actually measures, so the name is not the only thing you have to go on.
There is no cluster total on that tab and there cannot be one. It is one process out of however
many are deployed. Scrape ignismq.* and magazine.* and aggregate in your metrics backend — the
monitoring runbook is the companion to this tab.
One console, one client¶
The header states the clientId and farmId everything on screen is scoped to. It is a label, not a
selector, and that is structural rather than a simplification: a bundle builds one
IgnisMQManager with one clientId, and the Aerospike set it reads is named
<farmId>_<clientId>_ignis_queues. A process can only see its own client's queues, so a picker would
have exactly one entry.
If one application really does run several managers, the console would have to be given all of them; say so and it can be, but nothing in the bundle builds that shape today.
Actions and the roles¶
Every mutating endpoint carries a role, and deactivation carries a different one.
| Role | Grants |
|---|---|
ignismq_operate |
Shovel, scheduled shovel, sweep, consumer scaling |
ignismq_deactivate |
Deactivation, and nothing else |
They are separate because deactivation cannot be undone by any API. The grant that lets an on-call
engineer drain a sideline at 3am should not also let them destroy a queue, and ignismq_operate is
the grant that gets handed out widely.
Deactivation additionally requires ?confirm=<queue name>, checked server-side. A UI-only
confirmation protects nobody driving this over HTTP. The bundle defines no
authentication of its own. It registers Jersey's RolesAllowedDynamicFeature, so without a
SecurityContext supplied by your application the actions are closed and return 403. Reads stay
open.
How the console authenticates¶
IgnisMQ ships no authentication and the dashboard has no login. The role is read from the Jersey
SecurityContext that your application populates, so what the browser has to send depends entirely
on how your application authenticates:
| Your application uses | What the console does |
|---|---|
| Session cookies | Nothing to configure. Requests are sent with credentials: 'same-origin', so the browser attaches them |
| A token | The dashboard has no way to know your token, so there is a Token button in the header. Paste the full header value and every request carries it verbatim |
| mTLS or a proxy that injects identity | Nothing to configure, provided the proxy is in front of the console too |
Paste the whole value the Authorization header should carry, including whatever prefix your
application expects — Bearer <token>, O-Bearer <token>, or the bare token with no prefix at all.
The console does not add one. That prefix is the first thing to check if the actions stay disabled
after pasting a token you know is valid: applications front ignisMQ with their own authentication and
not all of them use Bearer. A value the application does not recognise is indistinguishable from
sending nothing — the request is simply unauthenticated, whoami reports no roles, and every action
is disabled.
The token is kept in sessionStorage, so it is gone when the tab closes. It is readable by any script
on that origin, so use a short-lived token and serve the console over HTTPS. It is never put in a
URL.
A 401 or 403 says which of the two situations you are in: whether credentials were sent at all, or
whether they were sent and simply lack the role.
Asking what the caller may do¶
GET /ignismq/v1/whoami answers with the two grants, so the dashboard can disable an action rather
than offer it and let it fail:
authenticated is reported separately from the grants because the remedies differ: false means no
credentials arrived and a token is needed, while true with no grants means the credentials are fine
and a role is missing. The dashboard puts exactly that sentence in the disabled button's tooltip.
It reports capability, never identity. No principal name, no role list, nothing beyond what the
caller could already discover by attempting one POST - which is why it is open like the other reads.
It is a courtesy, not a control. Every mutating endpoint still enforces its own role server-side, and if this call fails the dashboard disables every action rather than assuming permission.
Grant the role from your own authentication. Whatever supplies it, it has to run at
Priorities.AUTHENTICATION: RolesAllowedDynamicFeature evaluates at Priorities.AUTHORIZATION, so
a filter left at the default user priority populates the SecurityContext only after the role check
has already failed.
@Priority(Priorities.AUTHENTICATION)
public final class OperatorRoleFilter implements ContainerRequestFilter {
@Override
public void filter(final ContainerRequestContext requestContext) {
final Operator operator = authenticate(requestContext);
requestContext.setSecurityContext(new SecurityContext() {
@Override
public Principal getUserPrincipal() {
return operator::name;
}
@Override
public boolean isUserInRole(final String role) {
// Both roles are answered here. Checking only OPERATE_ROLE would leave
// deactivation permanently denied, which is safe but silent.
if (IgnisMQResource.OPERATE_ROLE.equals(role)) {
return operator.mayOperateQueues();
}
return IgnisMQResource.DEACTIVATE_ROLE.equals(role) && operator.mayDeactivateQueues();
}
@Override
public boolean isSecure() {
return true;
}
@Override
public String getAuthenticationScheme() {
return SecurityContext.BASIC_AUTH;
}
});
}
}
What each action actually changes¶
This is the part worth reading before pressing a button, because the blast radius is not uniform.
| Action | Scope | Notes |
|---|---|---|
shovel without intervalSeconds |
This instance | One-shot drain of the sideline back into the main magazine. Nothing is persisted, no other instance is affected |
shovel with intervalSeconds |
Cluster | Stores a repeating shovel. Every instance adopts it on its next refresh |
sweep |
Cluster | Re-homes anything a consumer claimed and never retired. Runs on the calling thread, so a large queue holds the request open for as long as the sweep takes |
consumers |
This instance | ?delta=2 adds two here, ?delta=-3 stops three here. The resulting count is persisted, but the consumers themselves are this process's. delta=0 is a 400 |
deactivate |
Cluster, irreversible | Consumers stop on every instance and messages still in the queue are left where they are. There is no supported way to activate a queue again. Separate role, plus ?confirm=<queue name> |
Creating queues is deliberately absent: a queue is only useful on an instance whose handler map
contains its messageHandlerType, and a console has no way to know which instances those are.
Configuration¶
The console is configured through IgnisMQContext:
@Override
protected IgnisMQContext context(final AppConfig config) {
return IgnisMQContext.builder()
.clientId(config.getServiceName())
.farmId(config.getFarmId())
.storage(new AerospikeStorage(config.getAerospike(), config.getNamespace()))
.curatorFramework(curatorFramework)
.console(ConsoleConfiguration.builder()
.enabled(true)
.dashboardEnabled(true)
.cacheSeconds(5)
.build())
.build();
}
| Setting | Default | Meaning |
|---|---|---|
enabled |
true |
Mounts the resource. false registers nothing at all |
dashboardEnabled |
true |
Serves the page at /ignisConsole. false keeps the JSON endpoints |
cacheSeconds |
5 |
How long the queue inventory and each queue's depth are held. 0 disables caching |
Action feedback¶
Every action shows a toast: one while it is running, replaced by the result. All action buttons are locked for the duration, not just the one clicked — they act on the same queue, and a sweep runs synchronously on the server and can take minutes on a large queue. Without that, a button that looks inert invites a second click, and the second click is a second sweep.
cacheSeconds is not a detail. Rendering the console is a storage read per queue, fanned out
over every shard. An open browser tab polls every 10 seconds by default; anything scripted polls harder. The
cache is what stops a console being a load source against the cluster serving production traffic,
and it is the same argument behind the three-minute TTL on the ignis.queue.stats gauge.
Seeing it without a cluster¶
A demo application lives in the bundle's test sources. It boots the console against a stateful in-memory stand-in for the manager, so there is nothing to install and no Aerospike to start:
mvn -B -pl ignismq-dw-bundle test-compile
mvn -B -pl ignismq-dw-bundle exec:java -Dexec.classpathScope=test \
-Dexec.mainClass=com.phonepe.ignis.demo.ConsoleDemoApplication -Dexec.args=server
Then open http://localhost:8080/ignisConsole/.
It runs about forty queues with a mix of handlers, ages and states, and simulates their traffic once
a second through the real QueueMeters and Magazine MagazineMetrics, with the same
magazine.* folding the manager installs. The instance tab therefore shows the meters a real process
registers, and the charts move.
The demo grants both roles unconditionally so the actions can be tried, and the stand-in is stateful — scaling consumers and deactivating a queue really do change what the console then shows. A real deployment must not grant the role that way.
What is deliberately missing¶
Anything per-message: message search, sideline inspection with a failure reason, the age of the oldest message, attempt counts.
Today a message is its payload. A sidelined message carries no record of why it was sidelined, so a "sideline browser" could only show the payload — the least useful column. These views need a message envelope first; faking them before that means inventing a side-channel that the real envelope would then have to replace.