aedes-persistence-mongodb
Version:
MongoDB persistence for Aedes
659 lines (580 loc) • 19.1 kB
JavaScript
const regEscape = require('escape-string-regexp')
const Packet = require('aedes-persistence').Packet
const BroadcastPersistence = require('aedes-persistence/broadcastPersistence.js')
const { MongoClient } = require('mongodb')
const { Qlobber } = require('qlobber')
const QlobberSub = require('qlobber/aedes/qlobber-sub')
const QLOBBER_OPTIONS = {
separator: '/',
wildcard_one: '+',
wildcard_some: '#',
match_empty_levels: true
}
// Batching limits for retained message pattern queries
// MongoDB has a BSON document size limit of 16MB, but regex patterns hit
// practical compilation/execution limits around 32KB. These values are tuned
// to prevent "regular expression is too large" errors while maintaining good
// query performance.
//
// Why two limits?
// - MAX_TOTAL_PATTERN_LENGTH: Prevents MongoDB regex size errors (~32KB limit)
// Cumulative pattern length is tracked to stay well under MongoDB's practical
// regex limit, providing a safety margin for regex escaping and construction.
//
// - MAX_PATTERNS_PER_BATCH: Optimizes query performance by reducing network overhead
// Larger batches mean fewer MongoDB queries, which typically improves performance
// more than smaller batches with simpler regex patterns.
//
// These values balance safety (preventing regex errors) with performance (minimizing
// the number of database queries needed to process subscription patterns).
const MAX_PATTERNS_PER_BATCH = 200
const MAX_TOTAL_PATTERN_LENGTH = 15000
class AsyncMongoPersistence {
// private class members start with #
#trie
#destroyed
#broker
#opts
#db
#mongoDBclient
#cl
#broadcast
#retainedBulkQueue
#executing
constructor (opts = {}) {
this.#trie = new QlobberSub(QLOBBER_OPTIONS) // used to match packets
opts.ttl = opts.ttl || {}
if (typeof opts.ttl.packets === 'number') {
const ttl = opts.ttl.packets
opts.ttl.packets = {
retained: ttl,
will: ttl,
outgoing: ttl,
incoming: ttl
}
}
this.#opts = opts
this.#db = null
this.#cl = null
this.#destroyed = false
this.#retainedBulkQueue = [] // used for storing retained packets with ordered bulks
this.#executing = false // used as lock while a bulk is executing
}
#addTTLIndexes (indexes) {
const addTTLIndex = (collection, key, expireAfterSeconds) => {
if (expireAfterSeconds >= 0) {
indexes.push({ collection, key, name: 'ttl', expireAfterSeconds })
}
}
if (this.#opts.ttl.subscriptions >= 0) {
addTTLIndex(
'subscriptions',
this.#opts.ttlAfterDisconnected ? 'disconnected' : 'added',
this.#opts.ttl.subscriptions
)
}
if (this.#opts.ttl.packets) {
addTTLIndex('retained', 'added', this.#opts.ttl.packets.retained)
addTTLIndex('will', 'packet.added', this.#opts.ttl.packets.will)
addTTLIndex('outgoing', 'packet.added', this.#opts.ttl.packets.outgoing)
addTTLIndex('incoming', 'packet.added', this.#opts.ttl.packets.incoming)
}
}
// access #broker, only for testing
get broker () {
return this.#broker
}
// access #db, only for testing
get _db () {
return this.#db
}
// access #mongoDBclient, only for testing
get _mongoDBclient () {
return this.#mongoDBclient
}
// setup is called by aedes-persistence/callbackPersistence.js
async setup (broker) {
this.#broker = broker
// database already connected
if (this.#db) {
return
}
// database already provided in the options
if (this.#opts.db) {
this.#db = this.#opts.db
} else {
// connect to the database
const conn = this.#opts.url || 'mongodb://127.0.0.1/aedes'
const options = this.#opts.mongoOptions
const mongoDBclient = new MongoClient(conn, options)
this.#mongoDBclient = mongoDBclient
const urlParsed = URL.parse(this.#opts.url)
// skip the first / of the pathname if it exists
const pathname = urlParsed.pathname ? urlParsed.pathname.substring(1) : undefined
const databaseName = this.#opts.database || pathname
this.#db = mongoDBclient.db(databaseName)
}
const collectionPrefix = `${this.#opts.collectionPrefix || ''}`
const db = this.#db
const subscriptions = db.collection(`${collectionPrefix}subscriptions`)
const retained = db.collection(`${collectionPrefix}retained`)
const will = db.collection(`${collectionPrefix}will`)
const outgoing = db.collection(`${collectionPrefix}outgoing`)
const incoming = db.collection(`${collectionPrefix}incoming`)
this.#cl = {
subscriptions,
retained,
will,
outgoing,
incoming
}
// drop existing TTL indexes (if exist)
if (this.#opts.dropExistingIndexes) {
const collections = await db.collections()
for (const collection of collections) {
const exists = await collection.indexExists('ttl')
if (exists) {
await collection.dropIndex('ttl')
}
}
}
// create indexes
const createIndex = async (idx) => {
const indexOpts = { name: idx.name }
if (typeof idx.expireAfterSeconds === 'number') {
indexOpts.expireAfterSeconds = idx.expireAfterSeconds
}
if (idx.unique) {
indexOpts.unique = true
}
await this.#cl[idx.collection].createIndex(idx.key, indexOpts)
}
const indexes = [
{
collection: 'outgoing',
key: { clientId: 1, 'packet.brokerId': 1, 'packet.brokerCounter': 1 },
name: 'query_clientId_brokerId'
},
{
collection: 'outgoing',
key: { clientId: 1, 'packet.messageId': 1 },
name: 'query_clientId_messageId'
},
{
collection: 'incoming',
key: { clientId: 1, 'packet.brokerId': 1, 'packet.brokerCounter': 1 },
name: 'query_clientId_brokerId'
},
{
collection: 'incoming',
key: { clientId: 1, 'packet.messageId': 1 },
name: 'query_clientId_messageId'
},
{
// storeRetained / createRetainedStreamCombi key off `topic`. Without
// this index every operation falls back to COLLSCAN; under load
// storeRetained's ordered bulkWrite serializes the whole publish
// pipeline behind it, stalling broker.publish callbacks by tens of
// seconds on deployments with more than a few thousand retained
// topics (observed: ~60s uniform lag with 15k retained + 200 msg/s).
// Unique matches the {topic}-filtered upsert semantics.
collection: 'retained',
key: { topic: 1 },
name: 'query_topic',
unique: true
}
]
// Add TTL indexes
this.#addTTLIndexes(indexes)
// create all indexes in parallel
await Promise.all(indexes.map(createIndex))
if (this.#opts.ttlAfterDisconnected) {
// To avoid stale subscriptions that might be left behind by broker shutting
// down while clients were connected, set all to disconnected on startup.
await this.#cl.subscriptions.updateMany({ disconnected: { $exists: false } }, { $currentDate: { disconnected: true } })
// Handlers for setting and clearing the disconnected timestamp on subscriptions
this.#broker.on('clientReady', (client) => {
this.#cl.subscriptions.updateMany({ clientId: client.id }, { $unset: { disconnected: true } })
})
this.#broker.on('clientDisconnect', (client) => {
this.#cl.subscriptions.updateMany({ clientId: client.id }, { $currentDate: { disconnected: true } })
})
}
// add subscriptions to Trie
for await (const subscription of subscriptions.find({
qos: { $gte: 0 }
})) {
this.#trie.add(subscription.topic, subscription)
}
// subscribe to the broker for subscription updates
this.#broadcast = new BroadcastPersistence(broker, this.#trie)
await this.#broadcast.brokerSubscribe()
// setup is done
}
async processRetainedBulk () {
if (!this.#executing && !this.#destroyed && this.#retainedBulkQueue.length > 0) {
this.#executing = true
const operations = []
const onEnd = []
while (this.#retainedBulkQueue.length) {
const { operation, resolve } = this.#retainedBulkQueue.shift()
operations.push(operation)
onEnd.push(resolve)
}
// execute operations and ignore the error
await this.#cl.retained.bulkWrite(operations).catch(() => { })
// resolve all promises
while (onEnd.length) onEnd.shift().call()
// check if we have new packets in queue
this.#executing = false
// do not await as we run this in background and ignore errors
this.processRetainedBulk()
}
if (this.#destroyed) {
// cleanup dangling promises
while (this.#retainedBulkQueue.length) {
const { resolve } = this.#retainedBulkQueue.shift()
resolve() // resolve all promises
}
}
}
async storeRetained (packet) {
const { promise, resolve } = promiseWithResolvers()
const queue = this.#retainedBulkQueue
const filter = { topic: packet.topic }
if (packet.payload.length > 0) {
queue.push({
operation: {
updateOne: {
filter,
update: { $set: decoratePacket(packet, this.#opts.ttl.packets) },
upsert: true
}
},
resolve
})
} else {
queue.push({
operation: {
deleteOne: {
filter
}
},
resolve
})
}
this.processRetainedBulk()
return promise
}
createRetainedStream (pattern) {
return this.createRetainedStreamCombi([pattern])
}
async * createRetainedStreamCombi (patterns) {
const matcher = new Qlobber(QLOBBER_OPTIONS)
for (let i = 0; i < patterns.length; i++) {
matcher.add(patterns[i], true)
}
// Calculate total pattern length
const totalLength = patterns.reduce((sum, p) => sum + p.length, 0)
// Determine if we need to batch
const needsBatching =
patterns.length > MAX_PATTERNS_PER_BATCH ||
totalLength > MAX_TOTAL_PATTERN_LENGTH
if (needsBatching) {
// Process patterns in batches to avoid creating regex that's too large
// Use dynamic batching based on cumulative length
const seenTopics = new Set() // Track yielded packets to avoid duplicates
const batches = []
let currentBatch = []
let currentLength = 0
for (const pattern of patterns) {
const patternLength = pattern.length
// Edge case: if a single pattern exceeds MAX_TOTAL_PATTERN_LENGTH,
// it will be placed in its own batch. This is intentional behavior
// to ensure the pattern is still processed (MongoDB will handle it
// or fail with a clear error). Very long patterns (>32KB after escaping)
// may still cause MongoDB "regular expression is too large" errors.
// Start a new batch if adding this pattern would exceed limits
if (currentBatch.length >= MAX_PATTERNS_PER_BATCH ||
(currentLength + patternLength > MAX_TOTAL_PATTERN_LENGTH && currentBatch.length > 0)) {
batches.push(currentBatch)
currentBatch = []
currentLength = 0
}
currentBatch.push(pattern)
currentLength += patternLength
}
// Add the last batch if not empty
if (currentBatch.length > 0) {
batches.push(currentBatch)
}
for (const batch of batches) {
for await (const packet of this.#queryRetainedByPatterns(batch, matcher)) {
// Avoid duplicates across batches
if (!seenTopics.has(packet.topic)) {
seenTopics.add(packet.topic)
yield packet
}
}
}
} else {
// Original logic for small pattern sets
for await (const packet of this.#queryRetainedByPatterns(patterns, matcher)) {
yield packet
}
}
}
async * #queryRetainedByPatterns (patterns, matcher) {
// Early return for empty patterns to avoid creating an empty regex
// that would match all documents in the collection
if (patterns.length === 0) {
return
}
const regexes = patterns.map(pattern =>
regEscape(pattern).replace(/(\/*#|\\\+).*$/, '')
)
const topic = new RegExp(regexes.join('|'))
const filter = { topic }
const exclude = { _id: 0 }
for await (const result of this.#cl.retained.find(filter).project(exclude)) {
const packet = asPacket(result)
if (matcher.match(packet.topic).length > 0) {
yield packet
}
}
}
async addSubscriptions (client, subs) {
const subscriptions = []
const operations = subs.map(sub => {
const subscription = { ...sub, clientId: client.id }
subscriptions.push(subscription)
return {
updateOne: {
filter: {
clientId: client.id,
topic: sub.topic
},
update: {
$set: decorateSubscription(subscription, this.#opts)
},
upsert: true
}
}
})
await this.#cl.subscriptions.bulkWrite(operations)
// inform the broker
await this.#broadcast.addedSubscriptions(client, subs)
}
async removeSubscriptions (client, subs) {
const operations = subs.map(topic => ({
deleteOne: {
filter: {
clientId: client.id,
topic
}
}
}))
await this.#cl.subscriptions.bulkWrite(operations)
// inform the broker
await this.#broadcast.removedSubscriptions(client, subs)
}
async subscriptionsByClient (client) {
const filter = { clientId: client.id }
const exclude = { clientId: false, _id: false } // exclude these fields
const subs = await this.#cl.subscriptions.find(filter).project(exclude).toArray()
return subs
}
async countOffline () {
const subsCount = this.#trie.subscriptionsCount
const result = await this.#cl.subscriptions.aggregate([
{
$group: {
_id: '$clientId'
}
}, {
$count: 'clientsCount'
}]).toArray()
const clientsCount = result[0]?.clientsCount || 0
return { subsCount, clientsCount }
}
async destroy () {
if (this.#destroyed) {
throw new Error('destroyed called twice!')
}
this.#destroyed = true
// stop listening to subscription updates
await this.#broadcast.brokerUnsubscribe()
if (this.#opts.db) {
return
}
await this.#mongoDBclient.close()
}
async subscriptionsByTopic (topic) {
return this.#trie.match(topic)
}
async cleanSubscriptions (client) {
const subs = await this.subscriptionsByClient(client)
if (subs.length > 0) {
const remSubs = subs.map(sub => sub.topic)
await this.removeSubscriptions(client, remSubs)
}
}
async outgoingEnqueue (sub, packet) {
return await this.outgoingEnqueueCombi([sub], packet)
}
async outgoingEnqueueCombi (subs, packet) {
if (subs?.length === 0) {
return packet
}
const newPacket = new Packet(packet)
const decoratedPacket = decoratePacket(newPacket, this.#opts.ttl.packets)
const packets = subs.map(sub => ({
clientId: sub.clientId,
packet: decoratedPacket
}))
await this.#cl.outgoing.insertMany(packets)
}
async * outgoingStream (client) {
for await (const result of this.#cl.outgoing.find({ clientId: client.id })) {
yield asPacket(result)
}
}
async outgoingUpdate (client, packet) {
if (packet.brokerId) {
await updateWithMessageId(this.#cl, client, packet)
} else {
await updatePacket(this.#cl, client, packet)
}
}
async outgoingClearMessageId (client, packet) {
const result = await this.#cl.outgoing.findOneAndDelete({
clientId: client.id,
'packet.messageId': packet.messageId
})
return result ? asPacket(result) : null
}
async incomingStorePacket (client, packet) {
const newPacket = new Packet(packet)
newPacket.messageId = packet.messageId
await this.#cl.incoming.insertOne({
clientId: client.id,
packet: decoratePacket(newPacket, this.#opts.ttl.packets)
})
}
async incomingGetPacket (client, packet) {
const result = await this.#cl.incoming.findOne({
clientId: client.id,
'packet.messageId': packet.messageId
})
if (!result) {
throw new Error(`packet not found for: ${client}`)
}
return asPacket(result)
}
async incomingDelPacket (client, packet) {
await this.#cl.incoming.deleteOne({
clientId: client.id,
'packet.messageId': packet.messageId
})
}
async putWill (client, packet) {
packet.clientId = client.id
packet.brokerId = this.#broker.id
await this.#cl.will.insertOne({
clientId: client.id,
packet: decoratePacket(packet, this.#opts.ttl.packets)
})
}
async getWill (client) {
const result = await this.#cl.will.findOne({
clientId: client.id
})
if (!result) {
return null // packet not found
}
return asPacket(result)
}
async delWill (client) {
const result = await this.#cl.will.findOneAndDelete({
clientId: client.id
})
if (!result) {
return null // packet not found
}
return asPacket(result)
}
async * streamWill (brokers) {
const filter = {}
if (brokers) {
filter['packet.brokerId'] = { $nin: Object.keys(brokers) }
}
for await (const will of this.#cl.will.find(filter)) {
yield asPacket(will)
}
}
async * getClientList (topic) {
const filter = {}
if (topic) {
filter.topic = topic
}
for await (const sub of this.#cl.subscriptions.find(filter)) {
yield sub.clientId
}
}
}
function decoratePacket (packet, setTTL) {
if (setTTL) {
packet.added = new Date()
}
return packet
}
function decorateSubscription (sub, opts) {
if (opts.ttl.subscriptions) {
sub.added = new Date()
}
return sub
}
function asPacket (obj) {
const packet = obj?.packet || obj
if (!packet) {
throw new Error('Invalid packet')
}
if (Buffer.isBuffer(packet?.payload?.buffer)) {
packet.payload = packet.payload.buffer
}
return packet
}
async function updateWithMessageId (db, client, packet) {
await db.outgoing.updateOne({
clientId: client.id,
'packet.brokerCounter': packet.brokerCounter,
'packet.brokerId': packet.brokerId
}, {
$set: {
'packet.messageId': packet.messageId
}
})
}
async function updatePacket (db, client, packet) {
await db.outgoing.updateOne({
clientId: client.id,
'packet.messageId': packet.messageId
}, {
$set: {
clientId: client.id,
packet
}
})
}
function promiseWithResolvers () {
// this can be replaced by Promise.withResolvers()in NodeJS >= 22
let res
let rej
const promise = new Promise((resolve, reject) => {
res = resolve
rej = reject
})
return { promise, resolve: res, reject: rej }
}
module.exports = AsyncMongoPersistence