multiconsumer-queue
Version:
A wrapper to build multiconsumer queues
294 lines (256 loc) • 8.52 kB
text/typescript
/*
* Copyright (c) 2017 by The MultiConsumer Queue Project Developers.
* Some rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createStringsLiveSet, RedisLiveSet } from "redis-liveset"
import { Set, Map } from "immutable"
/**
* Callback passed to ProcessCallback to be called when processing is done
*/
export type DoneCallback = (error?: Error | null, value?: any) => void
/**
* Job Processing function interface
*/
export type ProcessCallback<Job> = (job: Job, done: DoneCallback) => void
/**
* Queue topic name refined type
*/
export type QueueTopic = string & { _tag: "QueueTopic" }
/**
* QueueTopic constructor
* @constructor
*/
export function QueueTopic(topic: string): QueueTopic {
return topic as QueueTopic
}
/**
* Consumer Group identifier refined type
*/
export type ConsumerGroupId = string & { _tag: "ConsumerGroupId" }
/**
* ConsumerGroupId constructor
* @constructor
*/
export function ConsumerGroupId(groupId: string): ConsumerGroupId {
return groupId as ConsumerGroupId
}
/**
* {@Link Consumer} accepts messages
*/
export interface Consumer {
/**
* Add new Message/Task to queue
* @param {Object} data
*/
add(data: any): void
}
/**
* {@Link Producer} is a source of messages which can be consumed by attaching a callback function
*/
export interface Producer<Job> {
/**
* Attach queue processing handler with processing parallelism
* @param {MultiConsumerQueue.ProcessCallback<Job>} fn callback
* @param {number} n parallelism
*/
process(fn: ProcessCallback<Job>, n?: number): void
}
/**
* {@Link Queue} is a {@Link Consumer} and a {@Link Producer}
*/
export interface Queue<Job> extends Producer<Job>, Consumer {
}
/**
* A {@Link Consumer} which accepts message to different topics identified by a {@Link TopicName}
*/
export interface NamedConsumer {
add(topic: QueueTopic, data: any): void
}
/**
* A {@Link Producer} which produces messages to different {@Link QueueTopic}
*/
export interface NamedProducer<Job> {
/**
* Attach queue processing handler for given topic name and parallelism
* @param {QueueTopic} topic
* @param {MultiConsumerQueue.ProcessCallback<Job>} fn
* @param {number} n
*/
process(topic: QueueTopic, fn: ProcessCallback<Job>, n?: number): void
}
/**
* A {@Link Queue} is a {@Link NamedProducer} and a {@Link NamedConsumer}
*/
export interface NamedQueue<Job> extends NamedProducer<Job>, NamedConsumer {
}
/**
* Defines a {@Link Producer} than can be consumed by multiple groups idenified by {@Link ConsumerGroupId}
*
* All distinct groups will process same set of messages
*/
export interface MultiConsumerProducer<Job> {
/**
* Process jobs within group defined by groupId identifier
* @param {ConsumerGroupId} groupId consumer group identifier
* @param {MultiConsumerQueue.ProcessCallback<Job>} fn processing function
* @param {number} n process parallelism, defaults to 1
*/
process(groupId: ConsumerGroupId, fn: ProcessCallback<Job>, n?: number): void
}
/**
* A Queue type which may be consumed by multiple groups
*
* It also adds the ability to _unsubscribe_ specific consumer group when it is not going
* to consume Queue messages anymore, to avoid storing messages
*/
export interface MultiConsumerQueue<Job> extends Consumer, MultiConsumerProducer<Job> {
/**
* Removes consumer group for current topic
* Use this for cleanup, replace process(groupId) with removeGroup(groupId), deploy for a while
*/
removeGroup(groupId: ConsumerGroupId): this
}
/**
* Wraps a {@Link NamedQueue} with given name, exposing {@Link Queue} interface
*/
export class NamedQueueWrap<Job> implements Queue<Job> {
constructor(private readonly _topic: QueueTopic,
private readonly _out: NamedQueue<Job>) {
}
add(data: any): void {
this._out.add(this._topic, data)
}
process(fn: ProcessCallback<Job>, n?: number): void {
this._out.process(this._topic, fn, n)
}
}
/**
* Passes calls to out {@Link NamedQueue} but transforming input topic name using given map function
*/
export class DynamicallyNamedQueue<Job> implements NamedQueue<Job> {
constructor(readonly topic: (t: QueueTopic) => QueueTopic,
readonly out: NamedQueue<Job>) {
}
add(topic: QueueTopic, data: any): void {
this.out.add(this.topic(topic), data)
}
process(topic: QueueTopic, fn: ProcessCallback<Job>, n?: number): void {
this.out.process(this.topic(topic), fn, n)
}
}
/**
* EventBus is a communication channel to publish/subscribe for messages by topics
*/
export interface EventBus<Job> {
/**
* Returns new EventBus topic queue instance
*/
topic(topic: QueueTopic): MultiConsumerQueue<Job>
}
/**
* {@Link MultiConsumerQueue} implementation helper
*
* Dispatches messages from source Queue to out {@Link NamedQueue} using all group IDs
* (syncronized using a RedisLiveSet) as topic names
*/
export class MultiConsumerQueueImpl<Job> implements MultiConsumerQueue<Job> {
/**
* @param _source queue to add and dispatch messages from
* @param _out a named queue to attach final processors to using groupId as topic name
* @param _groups live set to synchronize consumer groups across nodes
* @param _jobDataLens lens to extract data from Job, to support multiple implementations
*/
constructor(private readonly _source: Queue<Job>,
private readonly _out: NamedQueue<Job>,
private readonly _groups: RedisLiveSet<string>,
private readonly _jobDataLens: (j: Job) => any,
private _lastGroupsSet: Set<string> = Set()) {
/**
* Subscribe for consumer group ids changes
*/
this._groups.subscribe((consumers) => {
this._lastGroupsSet = consumers
})
// subscribe source processing once groups initialized
this._groups.onMembersInit(() => {
// group members list initialized (loaded from db), can now process source jobs
this._source.process((job, cb) => {
/**
* Dispatch messages from source {@Link Queue} to out {@Link NamedQueue} using ConsumerGroupId as QueueTopic
*/
this._lastGroupsSet.forEach((groupId) => {
if (groupId) { // forEach interface is fixed in v4 (may not be undefined)
this._out.add(QueueTopic(groupId), this._jobDataLens(job))
}
})
cb()
})
})
}
/**
* Create new job
* @param {Object} data
* @returns {Job}
*/
add(data: any): void {
this._source.add(data)
}
/**
* Subscribe for processing using given groupId
*
* All subscribers using same groupId will split messages processing
* @param {ConsumerGroupId} groupId
* @param {ProcessCallback<Job>} fn
* @param {number} n
*/
process(groupId: ConsumerGroupId, fn: ProcessCallback<Job>, n?: number): void {
/**
* Register new consumer group so sources can dispatch messages for new group
*/
this._groups.add(groupId)
/**
* Attach handler itself for processing
*/
this._out.process(QueueTopic(groupId), fn, n)
}
/**
* Removes consumer group for current topic
* Use this for cleanup, replace process(groupId) with removeGroup(groupId), deploy for a while
*/
removeGroup(groupId: ConsumerGroupId): this {
this._groups.remove(groupId)
return this
}
}
/**
* EventBus implementation
*/
export class EventBusImpl<Job> implements EventBus<Job> {
private _queueMap: Map<QueueTopic, MultiConsumerQueue<Job>> = Map()
/**
* @param _buildQueue MultiConsumerQueue builder function
*/
constructor(private readonly _buildQueue: (t: QueueTopic) => MultiConsumerQueue<Job>) {
}
topic(topic: QueueTopic): MultiConsumerQueue<Job> {
/**
* Create queue for given topic name and store the reference
*/
if (!this._queueMap.has(topic)) {
this._queueMap = this._queueMap.set(topic, this._buildQueue(topic))
}
return this._queueMap.get(topic)
}
}