Skip to content

fix(backend): restore the redis driver, prune bullmq 5 repeatables, and fix search escaping - #2194

Merged
ankit-yc merged 1 commit into
devfrom
fix/bullmq6-boot-and-search
Aug 15, 2026
Merged

fix(backend): restore the redis driver, prune bullmq 5 repeatables, and fix search escaping#2194
ankit-yc merged 1 commit into
devfrom
fix/bullmq6-boot-and-search

Conversation

@ankit-yc

@ankit-yc ankit-yc commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

Three defects arrived on dev with the dependency majors in #2188. They were found by an audit of the dev to main promotion, which is on hold until this lands.

None of them reached production. Only the frontend redeployed on that commit; the backend has not picked up bullmq 6, and main never received it.

1. The backend cannot boot

bullmq 6 moved ioredis from a direct dependency to an optional peer. pnpm's auto-install-peers installs missing non-optional peers only, so the driver dropped out of the lockfile completely: main resolves /ioredis@5.11.1, dev has no entry at all.

apps/backend/src/queues/bull.config.ts passes a plain options object as connection rather than a client instance, so bullmq has to load a driver itself. Reproduced by running it against the dev tree:

BullMQ could not load the optional 'ioredis' package. Install it with `npm install ioredis`,
or provide a different Redis client instance (e.g. node-redis) via the connection option.

initQueues() is awaited in main.ts before app.listen, inside a catch that calls process.exit(1), so this is a whole-backend outage on next deploy rather than a degraded subsystem.

2. Every recurring job would fire twice, permanently

#2188 verified that the seven scheduler ids and intervals were unchanged. That check passed and was the wrong check.

bullmq 5 never used jobId as the Redis key for a repeatable. Repeat.updateRepeatableJob built name:jobId:endDate:tz:every and stored the entry under the md5 of that string. upsertJobScheduler(id, ...) stores under the plain id. Carrying the same id string across the migration therefore adds a second entry next to the old one rather than replacing it, and both sit in the same repeat sorted set, which bullmq 6 still reads and still schedules from.

The effect on a running system: no-show marking twice a minute, task reminders delivered twice, task recurrence generated twice per 6 hours, both lab pollers hitting the external lab APIs twice per 5 minutes, IDEXX sync twice a week.

3. Hyphenated searches silently return nothing

Both call sites feed the escaped value into Prisma contains, which compiles to an ILIKE pattern. That is not a regular expression, so regex escaping was never the right tool. It only appeared to work because escaping an ordinary character with a backslash is a no-op in LIKE, so O'Brien \(Jr\.\) still matched.

The new helper matched escape-string-regexp 5.0.0, which escapes a hyphen as \x2d. LIKE reads that as the literal text x2d, so a search for Jean-Luc looked for Jeanx2dLuc and matched nothing. No error, just an empty result. This hits ParentService.getByName (first name, last name, email) and CompanionService.getByName. Hyphens are common in surnames and near universal in email domains.

What is the new behavior?

1. ioredis is a direct dependency of apps/backend, pinned to ^5.11.1 - the same line main runs today, so nothing changes but the driver being present again.

2. New apps/backend/src/queues/legacy-repeatables.ts, called from initQueues() before any upsert. It lists the schedulers on each of the seven queues and removes the bullmq 5 leftovers. It only removes keys that are exactly a 32 character lowercase hex digest, which is the md5 shape bullmq 5 produced and which none of this app's ids can collide with, so a scheduler this app owns is never removed even if the expected list were incomplete. A removal failure is logged and skipped rather than thrown, because leaving one stale entry costs a duplicate job whereas throwing takes the API down.

3. escape-regexp.ts is replaced by escape-like.ts, which escapes backslash, percent and underscore. That makes the search mean exactly the text typed, and as a side effect closes a wildcard issue that predates all of this: Prisma does not escape LIKE metacharacters, so on main today a search for 100% matches everything starting with 100, and a_c matches abc.

catalog.service.ts has its own local escapeRegExp, which is used inside a real new RegExp(...) and is correct. It is untouched.

Impact area

apps/backend only. No API surface, route, schema or migration changes.

One deployment note: the prune in item 2 mutates the repeat sorted set in the Redis the backend connects to. It is idempotent and a no-op once it has run.

Validation performed

Run locally on Node 20:

  • pnpm run type-check - 17/17 pass
  • pnpm run lint - 10/10 pass
  • pnpm run test:scripts - pass
  • apps/backend full suite - 257 suites, 4864 tests, all passing (was 255 / 4848)

Coverage on both new files:

File Stmts Branch Funcs Lines
src/queues/legacy-repeatables.ts 100 100 100 100
src/utils/escape-like.ts 100 100 100 100

Why the original tests missed all three

Worth stating plainly, because it shaped what was added here. The scheduler suites jest.mock every *.queue module, so a real Queue is never constructed and no driver is ever loaded - that test design is structurally incapable of catching defects 1 and 2, however many cases it contains. And the escape test asserted the new helper against an inlined copy of the spec it had been written to, never against the context the value is actually used in.

So test/queues/redis-driver.test.ts deliberately uses no mocks and checks the dependency graph itself: that ioredis is a declared direct dependency, that it resolves from apps/backend, and that the installed version satisfies the range bullmq asks for. And escape-like.test.ts asserts through a model of how PostgreSQL reads a LIKE pattern, so the cases are about what the database will match rather than what the function returns.

Mutation testing

Each fix was reverted to the exact defect that shipped and the suite confirmed to go red, then restored and confirmed green:

  1. Restore the \x2d regex escape - 4 tests fail
  2. Make the legacy prune skip every key - 6 tests fail
  3. Remove ioredis from apps/backend/package.json - the direct-dependency test fails (locally only that one, since node_modules still holds the package; under CI's --frozen-lockfile install the resolve and peer-range tests fail too)

Defect 1 was also verified end to end by constructing a real Queue against the dev tree and observing the driver error, then repeating it on this branch and observing the error replaced by an ordinary connection attempt.

A note on the ioredis advisory

The Aikido gate reports one new MEDIUM on this PR: AIKIDO-2026-538318 in ioredis 5.11.1. It is worth being precise about what that means, because the label is misleading here.

This PR does not add exposure. It restores parity with main. main resolves /ioredis@5.11.1 today, via bullmq 5's direct dependency, and carries this same advisory in production right now. Aikido calls it "new" only because it diffs against dev, and dev lost the package when #2188 accidentally dropped the driver. Aikido's own feed already lists this issue as open against the dev branch with a remediate-by date of 2026-08-10.

It cannot be fixed in this PR:

  • 5.11.1 is the last 5.x release. There is no patched 5.x.
  • The only fix is ioredis 6.0.0, which declares engines: node >=20.
  • The backend image is node:18 (apps/backend/Dockerfile line 1).

Taking 6.x was tried on this branch and reverted. Node does not enforce engines at runtime, and #2125 documents that 26 packages in this tree already declare >=20 against the Node 18 image, so it might well work - but "might well work" is not a basis for changing the Redis driver that every background job depends on, inside a hotfix, and it could not be verified here.

So ioredis is frozen at 5.x in .github/dependabot.yml, in the same documented style as the other freezes waiting on the runtime upgrade, and #2125 has been updated to unfreeze it when Node moves. That makes this the first freeze where the old runtime is holding back a security fix rather than only an unsupported-version warning, which is worth weighing when #2125 is scheduled.

Related Issue(s)

Fixes defects introduced by #2188. Unblocks the dev to main promotion.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread apps/backend/src/queues/legacy-repeatables.ts Outdated
@ankit-yc
ankit-yc force-pushed the fix/bullmq6-boot-and-search branch 2 times, most recently from f8b706b to 95fafae Compare August 15, 2026 10:43
…ix search escaping

Three defects from the bullmq/jwks-rsa/escape-string-regexp majors (#2188).
None reached production: only the frontend redeployed on that commit.

bullmq 6 moved ioredis from a direct dependency to an OPTIONAL peer, and
pnpm's auto-install-peers does not install optional peers, so the driver
dropped out of the lockfile entirely. bull.config.ts passes a plain options
object as `connection`, so bullmq has to require('ioredis') itself and threw
"BullMQ could not load the optional 'ioredis' package". initQueues is awaited
before app.listen inside a catch that exits, so this was a whole-backend
outage on next deploy. ioredis is now a direct dependency, pinned to the
5.11.1 line main already ran.

The scheduler ids carried across the migration unchanged, but that was the
wrong thing to check: bullmq 5 never keyed a repeatable by jobId, it keyed by
the md5 of name:jobId:endDate:tz:every. upsertJobScheduler keys by the plain
id, so the upsert added a second entry beside the old one in the same repeat
zset, and bullmq 6 still schedules from the legacy shape. Every recurring job
would have fired twice, indefinitely: duplicate no-show marking, duplicate
task reminders to users, doubled lab polling. initQueues now prunes the md5
entries before upserting.

The escape helper was replaced by a LIKE escape. Both call sites feed the
value into Prisma `contains`, which is an ILIKE pattern and not a regular
expression, so regex escaping was always wrong. It only looked correct
because a backslash before an ordinary character is a no-op in LIKE. Emitting
\x2d for a hyphen broke that: "Jean-Luc" searched for "Jeanx2dLuc" and matched
nothing. Escaping backslash, percent and underscore instead makes the search
mean exactly what was typed, and stops user input acting as a wildcard.

The scheduler suites mock every queue module, so no real Queue is ever built
and they could not have caught the first two. redis-driver.test.ts checks the
dependency graph itself instead.
@ankit-yc
ankit-yc force-pushed the fix/bullmq6-boot-and-search branch from 95fafae to 829c6b4 Compare August 15, 2026 11:12
@ankit-yc

Copy link
Copy Markdown
Contributor Author

Good catch, and it was a real bug rather than a false positive.

Queue.removeJobScheduler is typed Promise<boolean> and reports whether it actually removed anything. The original code awaited it and pushed the key into removed unconditionally, so an entry that was already gone would have been counted as a removal and included in the "removed N bullmq 5 entries" log line.

Fixed by branching on the result. A false is deliberately not treated as an error, since the only thing it means is that the entry had already gone, but it no longer counts as a removal and it now logs at warn level.

Two tests were added for it, and both fail if the boolean is ignored again:

  • does not count an entry the driver reports as not removed
  • reports nothing removed when the driver removes nothing

legacy-repeatables.ts stays at 100 percent statements, branches, functions and lines. Backend suite is 257 suites / 4866 tests.

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@ankit-yc
ankit-yc merged commit 8e23bb3 into dev Aug 15, 2026
62 checks passed
@ankit-yc
ankit-yc deleted the fix/bullmq6-boot-and-search branch August 15, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants