Skip to content

Commit a1ae350

Browse files
llambeauclaude
andcommitted
Add GET /queues API endpoint for queue status
Add endpoint to retrieve information about all configured queues from RabbitMQ Management API, including message count and consumer count. API: GET /queues Response: { "queues": [{ "name", "messages", "consumers", "state" }] } Implementation: - src/queue/index.ts: Add QueueInfo type and getQueuesInfo method - src/queue/drivers/amqp.ts: Query /api/queues/{vhost}/{name} for each queue - src/queue/drivers/memory.ts: Return queue lengths from internal state - src/server/rest/index.ts: Add /queues endpoint using config topology Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 05ad6b6 commit a1ae350

9 files changed

Lines changed: 280 additions & 2 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
name: |-
3+
Get queues status
4+
5+
url: |-
6+
/queues
7+
8+
services:
9+
- method: |-
10+
GET
11+
12+
description: |-
13+
Retrieve status information for all queues defined in the Arnavon
14+
configuration, including message count and consumer count.
15+
16+
preconditions:
17+
- RabbitMQ Management plugin must be enabled
18+
- Arnavon must be configured with queue topology
19+
20+
postconditions:
21+
- Returns status for all configured queues
22+
- Each queue includes name, messages, consumers, and state
23+
24+
input_schema: |-
25+
.
26+
27+
output_schema: |-
28+
Queue.ListResponse
29+
30+
error_schema: |-
31+
.
32+
33+
examples:
34+
35+
- description: |-
36+
FOR: Returns status for all configured queues
37+
expected:
38+
content_type: application/json; charset=utf-8
39+
status: 200
40+
assert:
41+
- 'size("queues", 4)'

example/webspicy/schema.fio

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,23 @@ Dlq.Error = {
6060
error : String
6161
}
6262

63+
###
64+
### Queues Info
65+
###
66+
67+
Queue.State = String
68+
69+
Queue.Info = {
70+
name : String
71+
messages : Numeric
72+
consumers : Numeric
73+
state : Queue.State
74+
}
75+
76+
Queue.ListResponse = {
77+
queues : [Queue.Info]
78+
}
79+
6380
###
6481
### Example
6582
###

src/queue/drivers/amqp.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Queue, { QueueInternalProcessor, RequeueOptions, RequeueResult } from '../index';
1+
import Queue, { QueueInternalProcessor, QueueInfo, RequeueOptions, RequeueResult } from '../index';
22
import amqplib, { ConfirmChannel } from 'amqplib';
33
import logger from '../../logger';
44
import { JobMeta } from '../../jobs/job';
@@ -392,6 +392,48 @@ class AmqpQueue extends Queue {
392392
}
393393
}
394394

395+
async _getQueuesInfo(queueNames: string[]): Promise<QueueInfo[]> {
396+
const vhostEncoded = encodeURIComponent(this.#vhost);
397+
398+
const fetchQueueInfo = async (queueName: string): Promise<QueueInfo> => {
399+
const queueNameEncoded = encodeURIComponent(queueName);
400+
const queueInfoUrl = `${this.#managementUrl}/api/queues/${vhostEncoded}/${queueNameEncoded}`;
401+
402+
try {
403+
const response = await fetch(queueInfoUrl, {
404+
headers: { 'Authorization': `Basic ${this.#managementAuth}` },
405+
});
406+
407+
if (response.ok) {
408+
const info = await response.json() as {
409+
name: string;
410+
messages?: number;
411+
consumers?: number;
412+
state?: string;
413+
};
414+
415+
return {
416+
name: info.name,
417+
messages: info.messages ?? 0,
418+
consumers: info.consumers ?? 0,
419+
state: info.state === 'running' ? 'running' : info.state === 'idle' ? 'idle' : 'unknown',
420+
};
421+
} else if (response.status === 404) {
422+
// Queue doesn't exist in RabbitMQ
423+
return { name: queueName, messages: 0, consumers: 0, state: 'unknown' };
424+
} else {
425+
logger.warn(`${this.constructor.name}: Failed to get info for queue ${queueName}: ${response.status}`);
426+
return { name: queueName, messages: 0, consumers: 0, state: 'unknown' };
427+
}
428+
} catch (err) {
429+
logger.error(err, `${this.constructor.name}: Error getting info for queue ${queueName}`);
430+
return { name: queueName, messages: 0, consumers: 0, state: 'unknown' };
431+
}
432+
};
433+
434+
return Promise.all(queueNames.map(fetchQueueInfo));
435+
}
436+
395437
}
396438

397439
export default AmqpQueue;

src/queue/drivers/memory.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Queue, { QueueInternalProcessor, RequeueOptions, RequeueResult } from '../index';
1+
import Queue, { QueueInternalProcessor, QueueInfo, RequeueOptions, RequeueResult } from '../index';
22
import Job from '../../jobs/job';
33

44
export type MemoryQueueConfig = unknown;
@@ -75,6 +75,15 @@ class MemoryQueue extends Queue {
7575
return this.#queue.length;
7676
}
7777

78+
async _getQueuesInfo(queueNames: string[]): Promise<QueueInfo[]> {
79+
return queueNames.map(name => ({
80+
name,
81+
messages: this.#queues.has(name) ? this.#queues.get(name)!.length : 0,
82+
consumers: 0, // MemoryQueue doesn't track consumers
83+
state: 'running' as const,
84+
}));
85+
}
86+
7887
}
7988

8089
export default MemoryQueue;

src/queue/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export type RequeueResult = {
2222
errors: Array<{ error: string }>;
2323
}
2424

25+
export type QueueInfo = {
26+
name: string;
27+
messages: number;
28+
consumers: number;
29+
state: 'running' | 'idle' | 'unknown';
30+
}
31+
2532
export type QueueProcessor = (job: Job, context: JobRunnerContext) => Promise<unknown>
2633
export type QueueInternalProcessor = (job: Job, metadata: JobMeta) => Promise<unknown>
2734

@@ -105,6 +112,15 @@ class Queue extends EventEmitter {
105112
});
106113
}
107114

115+
// subclasses should implement _getQueuesInfo(queueNames)
116+
getQueuesInfo(queueNames: string[]): Promise<QueueInfo[]> {
117+
logger.info(`${this.constructor.name} - Getting info for queues: ${queueNames.join(', ')}`);
118+
return this._getQueuesInfo(queueNames).then((result) => {
119+
logger.info(`${this.constructor.name} - Got info for ${result.length} queues`);
120+
return result;
121+
});
122+
}
123+
108124
// To be implemented by subclasses
109125
_consume(_queueName: string, _processor: QueueInternalProcessor) {
110126
throw new Error('NotImplemented');
@@ -126,6 +142,10 @@ class Queue extends EventEmitter {
126142
throw new Error('NotImplemented');
127143
}
128144

145+
_getQueuesInfo(_queueNames: string[]): Promise<QueueInfo[]> {
146+
throw new Error('NotImplemented');
147+
}
148+
129149
}
130150

131151
export default Queue;

src/server/rest/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ export default (dispatcher: JobDispatcher) => {
8484
});
8585
});
8686

87+
api.get('/queues', async (req, res, next) => {
88+
try {
89+
// Get queue names from config topology
90+
const config = Arnavon.config.queue.config as { topology?: { queues?: Array<{ name: string }> } };
91+
const queueNames = config.topology?.queues?.map(q => q.name) || [];
92+
93+
const queues = await Arnavon.queue.getQueuesInfo(queueNames);
94+
return res.status(200).send({ queues });
95+
} catch (err) {
96+
next(err);
97+
}
98+
});
99+
87100
api.post('/dlq/:queueName/requeue', async (req, res, next) => {
88101
const { queueName } = req.params;
89102
const count = req.query.count ? parseInt(req.query.count as string, 10) : undefined;

tests/queue/drivers/memory.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,30 @@ describe('MemoryQueue', () => {
196196
});
197197
});
198198

199+
describe('#_getQueuesInfo', () => {
200+
it('returns info for all requested queues', async () => {
201+
const queue = new MemoryQueue();
202+
203+
queue.pushToQueue('queue-1', 'key', { data: 'item1' });
204+
queue.pushToQueue('queue-1', 'key', { data: 'item2' });
205+
queue.pushToQueue('queue-2', 'key', { data: 'item3' });
206+
207+
const result = await queue._getQueuesInfo(['queue-1', 'queue-2', 'queue-3']);
208+
209+
expect(result).to.have.length(3);
210+
expect(result[0]).to.eql({ name: 'queue-1', messages: 2, consumers: 0, state: 'running' });
211+
expect(result[1]).to.eql({ name: 'queue-2', messages: 1, consumers: 0, state: 'running' });
212+
expect(result[2]).to.eql({ name: 'queue-3', messages: 0, consumers: 0, state: 'running' });
213+
});
214+
215+
it('returns zero messages for non-existent queues', async () => {
216+
const queue = new MemoryQueue();
217+
218+
const result = await queue._getQueuesInfo(['non-existent']);
219+
220+
expect(result).to.have.length(1);
221+
expect(result[0]).to.eql({ name: 'non-existent', messages: 0, consumers: 0, state: 'running' });
222+
});
223+
});
224+
199225
});

tests/queue/index.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ describe('Queue', () => {
4545
_requeue(_sourceQueue, _options) {
4646
return Promise.resolve({ status: 'completed', requeued: 1, failed: 0, errors: [] });
4747
}
48+
_getQueuesInfo(queueNames) {
49+
return Promise.resolve(queueNames.map(name => ({ name, messages: 0, consumers: 0, state: 'running' })));
50+
}
4851
}
4952

5053
let queue;
@@ -144,4 +147,20 @@ describe('Queue', () => {
144147
});
145148
});
146149

150+
describe('#getQueuesInfo', () => {
151+
it('is a function', () => {
152+
expect(queue.getQueuesInfo).to.be.an.instanceof(Function);
153+
});
154+
it('returns a promise', () => {
155+
sinon.stub(queue, '_getQueuesInfo').returns(Promise.resolve([]));
156+
expect(queue.getQueuesInfo(['queue-1'])).to.be.an.instanceof(Promise);
157+
});
158+
it('calls the subclass _getQueuesInfo implementation', () => {
159+
const spy = sinon.stub(queue, '_getQueuesInfo')
160+
.returns(Promise.resolve([{ name: 'q1', messages: 5, consumers: 1, state: 'running' }]));
161+
queue.getQueuesInfo(['q1', 'q2']);
162+
expect(spy).to.be.calledOnceWith(['q1', 'q2']);
163+
});
164+
});
165+
147166
});

tests/server/rest/api.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,4 +601,95 @@ describe('server/createApi', () => {
601601
});
602602
});
603603

604+
describe('its GET /queues endpoint', () => {
605+
let api: Express.Application, dispatcher: Partial<JobDispatcher>, getQueuesInfoStub: sinon.SinonStub;
606+
let originalQueue: any, originalConfig: any;
607+
608+
beforeEach(() => {
609+
dispatcher = {
610+
dispatch: sinon.stub().returns(Promise.resolve()),
611+
dispatchBatch: sinon.stub().returns(Promise.resolve()),
612+
};
613+
getQueuesInfoStub = sinon.stub().returns(Promise.resolve([
614+
{ name: 'queue-1', messages: 10, consumers: 2, state: 'running' },
615+
{ name: 'queue-2', messages: 0, consumers: 0, state: 'idle' },
616+
]));
617+
// Save and replace the queue with a mock
618+
originalQueue = Arnavon.queue;
619+
originalConfig = Arnavon.config;
620+
Arnavon.queue = {
621+
getQueuesInfo: getQueuesInfoStub,
622+
disconnect: sinon.stub().returns(Promise.resolve()),
623+
} as any;
624+
Arnavon.config = {
625+
queue: {
626+
config: {
627+
topology: {
628+
queues: [
629+
{ name: 'queue-1' },
630+
{ name: 'queue-2' },
631+
],
632+
},
633+
},
634+
},
635+
} as any;
636+
api = createApi(dispatcher);
637+
});
638+
639+
afterEach(() => {
640+
Arnavon.queue = originalQueue;
641+
Arnavon.config = originalConfig;
642+
sinon.restore();
643+
});
644+
645+
it('returns 200 with queue info from config', (done) => {
646+
chai.request(api)
647+
.get('/queues')
648+
.end((err, res) => {
649+
res.should.have.status(200);
650+
expect(getQueuesInfoStub).to.be.calledOnceWith(['queue-1', 'queue-2']);
651+
res.body.should.eql({
652+
queues: [
653+
{ name: 'queue-1', messages: 10, consumers: 2, state: 'running' },
654+
{ name: 'queue-2', messages: 0, consumers: 0, state: 'idle' },
655+
],
656+
});
657+
done();
658+
});
659+
});
660+
661+
it('returns empty array when no queues in config', (done) => {
662+
Arnavon.config = {
663+
queue: {
664+
config: {
665+
topology: {
666+
queues: [],
667+
},
668+
},
669+
},
670+
} as any;
671+
getQueuesInfoStub.returns(Promise.resolve([]));
672+
673+
chai.request(api)
674+
.get('/queues')
675+
.end((err, res) => {
676+
res.should.have.status(200);
677+
res.body.should.eql({ queues: [] });
678+
done();
679+
});
680+
});
681+
682+
it('returns 500 on queue info errors', (done) => {
683+
getQueuesInfoStub.returns(Promise.reject(new Error('Connection failed')));
684+
685+
chai.request(api)
686+
.get('/queues')
687+
.end((err, res) => {
688+
res.should.have.status(500);
689+
res.body.should.eql({ error: 'Connection failed' });
690+
done();
691+
});
692+
});
693+
});
694+
604695
});

0 commit comments

Comments
 (0)