smqp
Version:
Synchronous message queueing package
639 lines (527 loc) • 17.1 kB
JavaScript
import { generateId, sortByPriority } from './shared.js';
import { kPending, Message } from './Message.js';
import { SmqpError, ERR_EXCLUSIVE_CONFLICT, ERR_EXCLUSIVE_NOT_ALLOWED } from './Errors.js';
const kName = Symbol.for('name');
const kConsumers = Symbol.for('consumers');
const kConsuming = Symbol.for('consuming');
const kExclusive = Symbol.for('exclusive');
const kInternalQueue = Symbol.for('internalQueue');
const kIsReady = Symbol.for('isReady');
const kAvailableCount = Symbol.for('availableCount');
const kStopped = Symbol.for('stopped');
export function Queue(name, options, eventEmitter) {
if (name && typeof name !== 'string') throw new TypeError('Queue name must be a string');
else if (!name) name = `smq.qname-${generateId()}`;
this[kName] = name;
this.options = { autoDelete: true, ...options };
this.messages = [];
this.events = eventEmitter;
this[kConsumers] = [];
this[kStopped] = false;
this[kAvailableCount] = 0;
this[kExclusive] = false;
this._onMessageConsumed = this._onMessageConsumed.bind(this);
}
Object.defineProperties(Queue.prototype, {
name: {
get() {
return this[kName];
},
},
consumerCount: {
get() {
return this[kConsumers].length;
},
},
consumers: {
get() {
return this[kConsumers].slice();
},
},
exclusive: {
get() {
return this[kExclusive];
},
},
messageCount: {
get() {
return this.messages.length;
},
},
stopped: {
get() {
return this[kStopped];
},
},
});
Queue.prototype.queueMessage = function queueMessage(fields, content, properties) {
if (fields && typeof fields !== 'object') throw new TypeError('fields must be an object');
if (properties && typeof properties !== 'object') throw new TypeError('properties must be an object');
if (this[kStopped]) return;
const messageTtl = this.options.messageTtl;
const messageProperties = { ...properties };
if (messageTtl && !('expiration' in messageProperties)) {
messageProperties.expiration = messageTtl;
}
const message = new Message(fields ?? {}, content, messageProperties, this._onMessageConsumed);
const capacity = this._getCapacity();
this.messages.push(message);
this[kAvailableCount]++;
let discarded;
switch (capacity) {
case 0:
discarded = this.evictFirst(message);
break;
case 1:
this.emit('saturated', this);
break;
}
return discarded ? 0 : this._consumeNext();
};
Queue.prototype.evictFirst = function evictFirst(compareMessage) {
const evict = this.get();
if (!evict) return;
evict.nack(false, false);
return evict === compareMessage;
};
Queue.prototype._consumeNext = function consumeNext() {
if (this[kStopped] || !this[kAvailableCount]) return;
const consumers = this[kConsumers];
let consumed = 0;
if (!consumers.length) return consumed;
for (const consumer of consumers) {
if (!consumer.ready) continue;
const msgs = this._consumeMessages(consumer.capacity, consumer.options);
if (!msgs.length) return consumed;
consumer._push(msgs);
consumed += msgs.length;
}
return consumed;
};
Queue.prototype.consume = function consume(onMessage, consumeOptions, owner) {
const consumers = this[kConsumers];
if (consumers.length) {
if (this[kExclusive])
throw new SmqpError(`Queue ${this.name} is exclusively consumed by ${consumers[0].consumerTag}`, ERR_EXCLUSIVE_CONFLICT);
if (consumeOptions?.exclusive)
throw new SmqpError(`Queue ${this.name} already has consumers and cannot be exclusively consumed`, ERR_EXCLUSIVE_NOT_ALLOWED);
}
const consumer = new Consumer(this, onMessage, consumeOptions, owner, new ConsumerEmitter(this));
this.emit('consume', consumer);
if (consumers.push(consumer) > 1 && consumer.options.priority) {
consumers.sort(sortByPriority);
}
if (consumer.options.exclusive) {
this[kExclusive] = true;
}
const pendingMessages = this._consumeMessages(consumer.capacity, consumer.options);
if (pendingMessages.length) consumer._push(pendingMessages);
return consumer;
};
Queue.prototype.assertConsumer = function assertConsumer(onMessage, consumeOptions, owner) {
const consumers = this[kConsumers];
if (!consumers.length) return this.consume(onMessage, consumeOptions, owner);
for (const consumer of consumers) {
if (consumer.onMessage !== onMessage) continue;
if (consumeOptions) {
if (consumeOptions.consumerTag && consumeOptions.consumerTag !== consumer.consumerTag) {
continue;
} else if ('exclusive' in consumeOptions && consumeOptions.exclusive !== consumer.options.exclusive) {
continue;
}
}
return consumer;
}
return this.consume(onMessage, consumeOptions, owner);
};
Queue.prototype.get = function getMessage(options) {
const message = this._consumeMessages(1, { noAck: options?.noAck, consumerTag: options?.consumerTag })[0];
if (!message) return false;
if (options?.noAck) {
this._dequeueMessage(message);
message[kPending] = false;
}
return message;
};
Queue.prototype._consumeMessages = function consumeMessages(n, consumeOptions) {
const msgs = [];
if (this[kStopped] || !this[kAvailableCount] || !n) return msgs;
const messages = this.messages;
if (!messages.length) return msgs;
const evict = [];
for (const message of messages) {
if (message.pending) continue;
if (message.properties.expiration && message.properties.ttl < Date.now()) {
evict.push(message);
continue;
}
message._consume(consumeOptions?.consumerTag);
this[kAvailableCount]--;
msgs.push(message);
if (!--n) break;
}
if (evict.length) {
for (const expired of evict) this.nack(expired, false, false);
}
return msgs;
};
Queue.prototype.ack = function ack(message, allUpTo) {
if (this._onMessageConsumed(message, 'ack', allUpTo, false)) message[kPending] = false;
};
Queue.prototype.nack = function nack(message, allUpTo, requeue = true) {
if (this._onMessageConsumed(message, 'nack', allUpTo, requeue)) message[kPending] = false;
};
Queue.prototype.reject = function reject(message, requeue = true) {
if (this._onMessageConsumed(message, 'nack', false, requeue)) message[kPending] = false;
};
Queue.prototype._onMessageConsumed = function onMessageConsumed(message, operation, allUpTo, requeue) {
if (this[kStopped]) return;
const msgIdx = this._dequeueMessage(message);
if (msgIdx === -1) return false;
const messages = this.messages;
const pending = allUpTo && this._getPendingMessages(msgIdx);
let deadLetterExchange;
switch (operation) {
case 'ack':
break;
case 'nack': {
if (requeue) {
this[kAvailableCount]++;
messages.splice(
msgIdx,
0,
new Message({ ...message.fields, redelivered: true }, message.content, message.properties, this._onMessageConsumed)
);
} else {
deadLetterExchange = this.options.deadLetterExchange;
}
break;
}
}
let capacity;
if (!messages.length) this.emit('depleted', this);
else if ((capacity = this._getCapacity()) === 1) this.emit('ready', capacity);
const pendingLength = pending && pending.length;
if (!pendingLength) this._consumeNext();
if (!requeue && message.properties.confirm) {
this.emit(`message.consumed.${operation}`, { operation, message: { ...message } });
}
if (deadLetterExchange) {
const deadLetterRoutingKey = this.options.deadLetterRoutingKey;
const { expiration, ...messageProperties } = message.properties;
const deadMessage = new Message(message.fields, message.content, messageProperties);
if (deadLetterRoutingKey) deadMessage.fields.routingKey = deadLetterRoutingKey;
this.emit('dead-letter', {
deadLetterExchange,
message: deadMessage,
});
}
if (pendingLength) {
for (const msg of pending) {
msg[operation](false, requeue);
}
}
return true;
};
Queue.prototype.ackAll = function ackAll() {
for (const msg of this._getPendingMessages()) {
msg.ack(false);
}
};
Queue.prototype.nackAll = function nackAll(requeue = true) {
for (const msg of this._getPendingMessages()) {
msg.nack(false, requeue);
}
};
Queue.prototype._getPendingMessages = function getPendingMessages(untilIndex) {
const messages = this.messages;
const l = messages.length;
const result = [];
if (!l) return result;
const until = untilIndex ?? l;
for (let i = 0; i < until; ++i) {
const msg = messages[i];
if (!msg.pending) continue;
result.push(msg);
}
return result;
};
Queue.prototype.peek = function peek(ignoreDelivered) {
const message = this.messages[0];
if (!message) return;
if (!ignoreDelivered) return message;
if (!message.pending) return message;
for (const msg of this.messages) {
if (!msg.pending) return msg;
}
};
Queue.prototype.cancel = function cancel(consumerTag, requeue) {
const consumers = this[kConsumers];
const idx = consumers.findIndex((c) => c.consumerTag === consumerTag);
if (idx === -1) return false;
const consumer = consumers[idx];
this.unbindConsumer(consumer, requeue);
return true;
};
Queue.prototype.dismiss = function dismiss(onMessage, requeue) {
const consumers = this[kConsumers];
const consumer = consumers.find((c) => c.onMessage === onMessage);
if (!consumer) return;
this.unbindConsumer(consumer, requeue);
};
Queue.prototype.unbindConsumer = function unbindConsumer(consumer, requeue = true) {
const consumers = this[kConsumers];
const idx = consumers.indexOf(consumer);
if (idx === -1) return;
consumers.splice(idx, 1);
this[kExclusive] = false;
consumer.stop();
consumer.nackAll(requeue);
this.emit('consumer.cancel', consumer);
if (!consumers.length && this.options.autoDelete) return this.emit('delete', this);
};
Queue.prototype.emit = function emit(eventName, content) {
if (!this.events) return;
this.events.emit(`queue.${eventName}`, content);
};
Queue.prototype.on = function on(eventName, handler, options) {
if (!this.events) return;
return this.events.on(`queue.${eventName}`, handler, options);
};
Queue.prototype.off = function off(eventName, handler) {
if (!this.events) return;
return this.events.off(`queue.${eventName}`, handler);
};
Queue.prototype.purge = function purge() {
const toDelete = this.messages.filter(({ pending }) => !pending);
this[kAvailableCount] = 0;
for (const msg of toDelete) {
this._dequeueMessage(msg);
}
if (!this.messages.length) this.emit('depleted', this);
return toDelete.length;
};
Queue.prototype._dequeueMessage = function dequeueMessage(message) {
const messages = this.messages;
const msgIdx = messages.indexOf(message);
if (msgIdx === -1) return msgIdx;
messages.splice(msgIdx, 1);
return msgIdx;
};
Queue.prototype.getState = function getState() {
const msgs = this.messages;
const state = {
name: this.name,
options: { ...this.options },
};
if (msgs.length) {
try {
state.messages = JSON.parse(JSON.stringify(msgs));
} catch (err) {
err.code = 'EQUEUE_STATE';
err.queue = this.name;
throw err;
}
}
return state;
};
Queue.prototype.recover = function recover(state) {
this[kStopped] = false;
const consumers = this[kConsumers];
if (!state) {
for (const c of consumers.slice()) c.recover();
this._consumeNext();
return this;
}
this.messages.splice(0);
let continueConsume;
if (consumers.length) {
for (const c of consumers) c.nackAll(false);
continueConsume = true;
}
if (!state.messages) return this;
const onConsumed = this._onMessageConsumed;
for (const { fields, content, properties } of state.messages) {
if (properties?.persistent === false) continue;
const msg = new Message({ ...fields, redelivered: true }, content, properties, onConsumed);
this.messages.push(msg);
}
this[kAvailableCount] = this.messages.length;
for (const c of consumers) c.recover();
if (continueConsume) {
this._consumeNext();
}
return this;
};
Queue.prototype.delete = function deleteQueue(options) {
const consumers = this[kConsumers];
if (options?.ifUnused && consumers.length) return;
const messages = this.messages;
if (options?.ifEmpty && messages.length) return;
this[kStopped] = true;
const messageCount = messages.length;
for (const consumer of this[kConsumers].splice(0)) {
this.emit('consumer.cancel', consumer);
}
messages.splice(0);
this.emit('delete', this);
return { messageCount };
};
Queue.prototype.close = function close() {
for (const consumer of this[kConsumers].slice()) {
this.unbindConsumer(consumer);
}
};
Queue.prototype.stop = function stop() {
this[kStopped] = true;
for (const consumer of this[kConsumers].slice()) {
consumer.stop();
}
};
Queue.prototype._getCapacity = function getCapacity() {
if ('maxLength' in this.options) {
return this.options.maxLength - this.messages.length;
}
return Infinity;
};
export function Consumer(queue, onMessage, options, owner, eventEmitter) {
if (typeof onMessage !== 'function') throw new TypeError('message callback is required and must be a function');
const { consumerTag } = (this.options = { prefetch: 1, priority: 0, noAck: false, ...options });
if (!consumerTag) this.options.consumerTag = `smq.ctag-${generateId()}`;
else if (typeof consumerTag !== 'string') throw new TypeError('consumerTag must be a string');
this.queue = queue;
this.onMessage = onMessage;
this.owner = owner;
this.events = eventEmitter;
this[kName] = this.options.consumerTag;
this[kIsReady] = true;
this[kStopped] = false;
this[kConsuming] = false;
this[kInternalQueue] = new Queue(
`${this.options.consumerTag}-q`,
{
autoDelete: false,
maxLength: this.options.prefetch,
},
new ConsumerQueueEvents(this)
);
}
Object.defineProperties(Consumer.prototype, {
consumerTag: {
get() {
return this[kName];
},
},
ready: {
get() {
return this[kIsReady] && !this[kStopped];
},
},
stopped: {
get() {
return this[kStopped];
},
},
capacity: {
get() {
return this[kInternalQueue]._getCapacity();
},
},
messageCount: {
get() {
return this[kInternalQueue].messageCount;
},
},
queueName: {
get() {
return this.queue.name;
},
},
});
Consumer.prototype._push = function push(messages) {
const internalQueue = this[kInternalQueue];
for (const message of messages) {
internalQueue.queueMessage(message.fields, message, message.properties);
}
if (!this[kConsuming]) {
this[kConsuming] = true;
try {
this._consume();
} finally {
this[kConsuming] = false;
}
}
};
Consumer.prototype._consume = function consume() {
const internalQ = this[kInternalQueue];
const consumerTag = this[kName];
let _msg;
while ((_msg = internalQ.get())) {
const msg = _msg;
msg._consume(consumerTag);
const message = msg.content;
message._consume(consumerTag, () => msg.ack(false));
if (this.options.noAck) message.ack();
this.onMessage(msg.fields.routingKey, message, this.owner);
if (this[kStopped]) break;
}
};
Consumer.prototype.nackAll = function nackAll(requeue) {
for (const msg of this[kInternalQueue].messages.slice()) {
msg.content.nack(false, requeue);
}
};
Consumer.prototype.ackAll = function ackAll() {
for (const msg of this[kInternalQueue].messages.slice()) {
msg.content.ack(false);
}
};
Consumer.prototype.cancel = function cancel(requeue = true) {
this.stop();
if (!requeue) this.nackAll(requeue);
this.emit('cancel', this);
};
Consumer.prototype.prefetch = function prefetch(value) {
this.options.prefetch = this[kInternalQueue].options.maxLength = value;
};
Consumer.prototype.emit = function emit(eventName, content) {
const routingKey = `consumer.${eventName}`;
this.events.emit(routingKey, content);
};
Consumer.prototype.on = function on(eventName, handler) {
const pattern = `consumer.${eventName}`;
return this.events.on(pattern, handler);
};
Consumer.prototype.recover = function recover() {
this[kStopped] = false;
};
Consumer.prototype.stop = function stop() {
this[kStopped] = true;
};
function ConsumerEmitter(queue) {
this.queue = queue;
}
ConsumerEmitter.prototype.on = function on(...args) {
return this.queue.on(...args);
};
ConsumerEmitter.prototype.emit = function emit(eventName, content) {
if (eventName === 'consumer.cancel') {
return this.queue.unbindConsumer(content);
}
this.queue.emit(eventName, content);
};
function ConsumerQueueEvents(consumer) {
this.consumer = consumer;
}
ConsumerQueueEvents.prototype.emit = function queueHandler(eventName) {
switch (eventName) {
case 'queue.saturated': {
this.consumer[kIsReady] = false;
break;
}
case 'queue.depleted':
case 'queue.ready':
this.consumer[kIsReady] = true;
break;
}
};