RedisEventBusService.emit() stages a workflow's grouped events in a Redis list at staging:<eventGroupId> and guards that list with a TTL, so a group that never gets released does not stay in Redis forever. The EXPIRE is issued before the RPUSH that creates the key, and EXPIRE against a key that does not exist is a no-op. The TTL is therefore silently never applied on a group's first emit().
for (const [groupId, events] of groupEventsMap.entries()) {
if (!events?.length) {
continue
}
// Set a TTL for the key of the list that is scoped to a group
// This will be helpful in preventing stale data from staying in redis for too long
// in the event the module fails to cleanup events. ...
void this.setExpire(groupId, groupedEventsTTL)
const eventsData = this.buildEvents(events, options)
promises.push(this.groupEvents(groupId, eventsData))
}
setExpire issues EXPIRE staging:<groupId>; groupEvents issues the RPUSH staging:<groupId> that brings the key into existence. Both commands go out on the same ioredis connection in that order, so the always reaches Redis first:
EXPIRE
> EXPIRE nonexistent:key 600
(integer) 0
The TTL only lands from a group's secondemit() call onward, when the key happens to already exist. A group whose events all arrive in a single emit() call ends up with a list that has no expiry at all. Both outcomes are easy to observe side by side in one run, since it depends only on how many times a given group is emitted to.
Why it matters
Staged events are otherwise removed only by releaseGroupedEvents or clearGroupedEvents, both of which run in the process that ran the workflow. When that process does not reach them — a crash, a container restart, a medusa exec script whose process exits before the release lands — the list is left behind, and with no expiry it accumulates without bound in the same Redis instance that carries the events queue, the workflow engine and the locks.
The comment above the call states the intent precisely, and the bug removes the safety net in exactly the case it was written for.
Nothing surfaces the leak either: the staging:* keys sit outside the BullMQ prefix, so queue metrics and Bull dashboards do not count them.
Reproduction
No reproduction repo — the defect is visible directly in Redis on any project with @medusajs/event-bus-redis registered, with two redis-cli commands.
redis-cli monitor during any workflow that emits an event shows the ordering:
Three such lists from the previous day's testing were still present, all at TTL -1. A group that did receive a second emit() in the same run showed a normal TTL 476 — which is the tell: whether the expiry exists depends on how many times the group happened to be emitted to.
Expected behavior
Every staging:<eventGroupId> list carries groupedEventsTTL (default 600s) from the moment it is created, so an event group that is never released expires on its own.
Actual behavior
A group emitted to once has no TTL and stays in Redis indefinitely.
Suggested fix
Set the expiry after the push, in the same pipeline as the push:
That also removes the fire-and-forget void on a write whose failure is currently unobservable.
Also, in the same file: clearGroupedEvents can issue an RPUSH with no members
event-bus-redis.ts#L379-L390. When called with eventNames — which emitEventStep does when it compensates — and every staged event in the group matches, eventsToKeep is empty and the pipeline pushes nothing back:
> RPUSH probe:empty:test
(error) ERR wrong number of arguments for 'rpush' command
The del in the same pipeline has already emptied the group, so the outcome is correct, but the pipeline result carries an error nobody reads. Guarding the rpush on a non-empty eventsToKeep would close it.
Environment
Found on @medusajs/event-bus-redis 2.19.0; develop at the time of writing is identical in both places, and the permalinks above point at it
Node.js v24.18.0, PostgreSQL 17.10, Redis 8.10.0, Linux