Skip to content

Migronaut v2.1: migrations as BullMQ jobs, custom ids, OpenTelemetry, declared collections - #3

Merged
Alex-Dolid merged 16 commits into
mainfrom
feature/bullmq
Oct 4, 2026
Merged

Alex-Dolid merged 16 commits into
mainfrom
feature/bullmq

Conversation

@Alex-Dolid

Copy link
Copy Markdown

Summary

Everything for v2.1.0: four opt-in features. Nothing changes for anyone who uses none of them,
apart from the narrow edges under Compatibility. Full details are in the
CHANGELOG.

Feature Entry point Commit
Migrations as a queue: one migration per BullMQ job, so migronaut can run as a migration service @alexify/migronaut/bullmq 5a83b8b
Custom id format: ULID, CUID, nanoid, UUIDv7… for run ids and queue group ids generateId config option 2b7a089
OpenTelemetry: a span per run and per migration (active while the migration runs), plus duration metrics telemetry: { tracer, meter } 609d9ef
Declared collections: indexes and validators declared as an end state; converge applies the difference collections / collectionsDir, migronaut converge 311e1ff

Migrations as a queue

  • BullMQ is injected (bullmq: { Queue, Worker, QueueEvents }). It is never a dependency or a peer.
  • MongoDB decides the order, not Redis. Each job is a single-file run under the usual lock, and it refuses while an earlier migration is still pending. Jobs behind a failed migration fail as MIGRATION_BLOCKED.
  • Each enqueue is one batch, so down still rolls back a whole deploy. Duplicate enqueues are deduplicated.
  • This work added three kit options: up(file, { batch, ordered }), down(file, { ordered }) and nextBatch().
  • Runnable example: examples/migration-service.

Custom ids

  • Usage: generateId: ulid. The function is called with no arguments, and its result is checked on every call.
  • The lock document gains a nonce that migronaut mints itself, so mutual exclusion never depends on the user's ids being unique.

OpenTelemetry

  • Two spans: migronaut.run and migronaut.migration. An instrumented MongoDB driver nests its command spans under the migration that issued them.
  • Telemetry can never fail a run. No exception events are recorded, because they would skip redaction.
  • bullmq.telemetry passes BullMQ's own telemetry object through, giving one trace from the enqueue to the MongoDB commands in the worker.

Declared collections (experimental)

  • Declarations go in collections and/or in collectionsDir (one file per collection). migronaut converge compares them with the live database and applies the difference. It is stateless: nothing is recorded.
  • Safe by default:
    • an undeclared index is kept unless prune is on;
    • a conflicting index refuses the run before anything is written;
    • if a rebuild fails, the old index is put back.
  • The CLI plans first and asks before any drop or rebuild. --check exits 28 on drift.
  • With convergeAfterUp, every bulk up ends with a converge under the same lock. The queue adapter adds a converge job at the end of a group.

Docs

New guides: Migrations as a Queue, OpenTelemetry and Declared Collections, plus a converge command page. The README, ARCHITECTURE and the comparison pages are updated.

Compatibility

No breaking changes for existing users. The narrow edges are all listed under Changed in the CHANGELOG:

  • MigronautErrorCode gains MIGRATION_BLOCKED, QUEUE_JOB_INVALID, QUEUE_JOB_FAILED and CONVERGE_FAILED. An exhaustive switch over the codes needs the new cases.
  • The config keys generateId, telemetry, collections, collectionsDir and convergeAfterUp are now validated. A wrong value is CONFIG_INVALID; before, it was ignored.
  • dryRun('up') now applies the out-of-order policy.
  • connect() is safe to call concurrently.
  • The lock document gains a nonce field. Older releases ignore it and can share a database with this one.
  • New exit codes: 24 to 28.

There are still zero runtime dependencies. bullmq, ioredis, @opentelemetry/* and bullmq-otel are devDependencies only, and unit tests pin that src/ never imports them.

Testing

  • pnpm run test:coverage: 1439/1439 tests pass, with coverage at 98.6% lines, 96.3% branches and 99.3% functions (the gate is 90).
  • lint, format:check, test:types (tsd), check:dts and docs:build all pass.
  • Real BullMQ + Redis suite: 46/46. CI now runs it against a Redis service.
  • Converge integration tests on MongoDB 5.0.31, 6.0.29, 7.0.14 and 8.0.32 all pass, except the clustered-collection test on 5.0. That test's fixture cannot be created there, because clustered collections arrived in MongoDB 5.3. CI runs 7.0.14.
  • An integration test with the real @opentelemetry/instrumentation-mongodb covers the span nesting.

Alex-Dolid and others added 6 commits October 1, 2026 20:07
…llmq

Add an opt-in queue adapter under a new `./bullmq` entry point so migronaut
can run as a migration service: pending migrations are enqueued one job per
migration and applied in order by a single-concurrency worker.

Adapter (src/bullmq, bullmq.js, bullmq.d.ts):
- createMigrationQueue(): enqueueUp/enqueueDown (group handle with wait()),
  startWorker, status/pending/audit/lockInfo, getJob, pause/resume,
  schedule/unschedule, close
- createMigrationProcessor() plus enqueue/plan/wait building blocks for a
  Queue or Worker the application owns (NestJS, BullMQ Pro)
- BullMQ is injected, never required: no dependency and no peer; a unit
  test greps src/ for it and bullmq.d.ts types it structurally
- order is enforced from the MongoDB changelog, not from Redis: every job
  is an ordered single-file run under the usual lock, so a failed migration
  stops the line (MIGRATION_BLOCKED) and duplicate jobs complete as skipped
- one attempt per job (a BullMQ retry would reorder the queue); a held lock
  is waited out inside the job; one shared batch per enqueue
- job payloads are validated as untrusted input; messages, stacks and job
  logs are redacted

Core:
- up(file, { batch, ordered }), down(file, { ordered }), nextBatch()
- MigrationBlockedError, QueueJobInvalidError, QueueJobFailedError
  (exit codes 24-26)
- lock-wait loop extracted to core/lock-wait.js, shared with runMigrations
- connect() is safe under overlapping calls
- dryRun('up') now applies the onOutOfOrder policy of the run it previews

Tests: an in-tree fake BullMQ carries unit and integration coverage; the
same scenarios run against the real bullmq package when
MIGRONAUT_TEST_REDIS_URL is set, which CI now does via a Redis service.

Also adds examples/migration-service (plain node:http), the guide page and
the related README/CHANGELOG/ARCHITECTURE updates. Version 2.1.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every id migronaut mints can now come from the user's own generator (ULID,
CUID, nanoid, UUIDv7, ...) instead of crypto.randomUUID(): the run id —
stamped on changelog records, events and log lines, and stored as the lock's
owner token — and the queue adapter's group id.

- generateId is a code-only config key (like logger/hooks), so it reaches the
  CLI through migronaut.config.js/.ts, MigratorKit, runMigrations and
  createMigrationQueue({ config }) alike. It is called with no arguments and
  no receiver, so third-party generators pass straight through.
- src/utils/id.js is the only module that mints an id (a unit test greps
  src/ for randomUUID). Every returned id is checked: a non-empty string of
  at most 128 characters, synchronously. A throw, a promise or a non-id fails
  with CONFIG_INVALID before a run starts or a job is enqueued.
- MigratorKit.generateId() exposes the same source; the queue adapter mints
  group ids through it, with no option of its own.
- The lock document gains a migronaut-minted nonce, matched alongside owner
  on readback, renew and release. The owner token is now the user-shaped run
  id, so mutual exclusion must not depend on its uniqueness: a generator that
  repeats an id blurs correlation but can never let two runs hold the lock.
  Older releases ignore the field and can share a database.
- A generateId key that is not a function, previously ignored as an unknown
  key, is now rejected.

Types (IdGenerator, MigratorKit.generateId), docs, README, ARCHITECTURE,
CHANGELOG and the config templates are updated; part of the unreleased v2.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ario

"should mint the group of a sync job in the configured id format" polled for
the migration's marker document and then read the changelog at once. The body
writes the marker first and the record after, so on a real Redis the read
regularly raced the write and saw no record — about every second run, on an
untouched checkout as well.

Poll for the changelog record itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onfig option

Migronaut can now report through the user's own OpenTelemetry tracer and
meter — `telemetry: { tracer, meter }` — without gaining a dependency or a
peer: `@opentelemetry/api` is injected, the same way a logger is.

Spans:
- `migronaut.run` for every run that acquired the lock, and a child
  `migronaut.migration` for every migration executed.
- The migration's span is the *active* one while its hooks, its up/down and
  its changelog write run, so an instrumented MongoDB driver nests its command
  spans under the migration that issued them. Lifecycle events cannot do that,
  and at application startup there is no ambient span — the driver
  instrumentation records nothing without a parent.
- A run that never got the lock emits no span: lock-wait polling retries the
  whole run every few hundred milliseconds. Refusals are counted instead.

Metrics: `migronaut.run.duration`, `migronaut.migration.duration` and
`migronaut.lock.acquire.duration` (histograms, seconds, 10ms..1h buckets), and
the counters `migronaut.lock.refused` and `migronaut.lock.lost`.

Guarantees:
- src/utils/telemetry.js is the only module that calls a tracer, span or
  instrument, and every call goes through one guard — telemetry never fails a
  run. A tracer that throws before or after running the work, or runs it
  twice, still gets each migration executed exactly once.
- A failure sets the ERROR status with a message through errorText (plus the
  redacted cause) and `error.type` to the typed code. No exception event is
  recorded: it would carry the unredacted message and stack.
- Nothing under src/ or bin/, and neither .d.ts, imports @opentelemetry/* or
  bullmq-otel (a unit test greps for it). The types are structural:
  MigronautTracer, MigronautSpan, MigronautMeter and friends.

Queue adapter:
- `bullmq.telemetry` hands BullMQ's own telemetry object (bullmq-otel) to the
  Queue and the Worker the facade constructs. The facade used to build its
  Queue without it, so the enqueuing side of a trace could not be joined;
  with it, one trace runs from the request that enqueued to the MongoDB
  commands in the worker.
- The scheduled sync job's template carries `telemetry: { omitContext: true }`.
  BullMQ builds each scheduler iteration from the previous job's options, so
  every tick would otherwise join one endless trace (confirmed against the
  real library; the Redis suite pins it).

Also: a `telemetry` key that is not usable is now rejected (CONFIG_INVALID),
and one lost lock is counted once even when the heartbeat and the TTL deadline
both report it.

Tests run against the real SDK with in-memory exporters (tests/helpers/otel.js):
unit tests of the guard, integration tests of the span tree and metrics on a
real MongoDB, one test with the real @opentelemetry/instrumentation-mongodb,
part-by-part type assertions against the real API, and the cross-process trace
in the opt-in real-Redis suite. The OpenTelemetry packages and bullmq-otel are
devDependencies only.

examples/migration-service gains tracing.js and a Jaeger; docs get a new
OpenTelemetry guide, and the events table in the hooks guide is brought back
in line with the payloads. README, ARCHITECTURE, CHANGELOG, CONTRIBUTING and
the config templates are updated; part of the unreleased v2.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd state

Indexes and validators only ever have a current value, yet until now the only
way to change one was a hand-written migration: an up that creates, a down
that drops, a new file per option. Migronaut can now take them as an end
state — `collections` in the config and/or one file per collection in
`collectionsDir` — and converge the live database to it. Stateless: nothing
is recorded, every run reads the database afresh and plans against it.

Declaring:
- An index is the driver's flat createIndexes shape (key plus options). Every
  option and definition key is checked against a whitelist — the driver
  silently drops unknown options, so `uniqe: true` would otherwise build a
  non-unique index that looks in sync forever.
- A missing `indexes` / `validator` leaves that part unmanaged;
  `validator: null` means there must be none. A definition that manages
  nothing, a collection declared twice, system and migronaut's own
  collections, `_id_`, duplicate names or signatures and a second text index
  are all CONFIG_INVALID with a path to the offending entry.
- Definition files load at converge time, not config time, so a broken file
  never blocks `status` or an emergency `down`. A function export (a Mongoose
  model left in the directory) is refused.

Converging (`kit.converge()`, `migronaut converge`):
- Indexes pair by effective name; a declared index whose key, partial filter
  and collation (or text-ness) are taken by an undeclared live one is
  accepted as is when identical, and is a conflict otherwise — the whole run
  is refused before the first write. Without prune, an undeclared index is
  never dropped.
- Options compare the way the server stores them: server defaults for absent
  semantic options, collation as a subset of the expanded spec plus the
  collection default, text indexes in their _fts/_ftsx form. TTL (when both
  sides have one) and `hidden` change in place via collMod; anything else is
  a drop-and-create that restores the old index if the new one fails to
  build.
- Steps run one at a time: collection, validator, creates, collMods,
  rebuilds, then pruned drops. Changed collections are re-read afterwards;
  whatever still differs is reported under `unstable` instead of rebuilding
  on every run.
- A real run holds the migration lock and emits converge:start / :action /
  :end. Failures are ConvergeFailedError (CONVERGE_FAILED, exit 27), with the
  progress in context.converge and a hint for the usual server errors.

CLI: `migronaut converge [--dry-run] [--check] [--prune] [-y]`. It plans
first and asks only before a drop or rebuild; `--json` without `--yes`
refuses a destructive plan, and `--check` exits 28 (COLLECTIONS_DRIFT) on
drift. `up --converge` / `--no-converge` override `convergeAfterUp`, with
which every bulk `up` (and runMigrations) ends by converging under the same
lock — even with nothing pending, so a failed converge is retried by the next
deploy. `up <file>`, `up --to` and `redo` never converge. The argument parser
now leaves a declared `--x` / `--no-x` pair undefined when neither is given.

Queue adapter: a `converge` job kind. planUpJobs/enqueueGroup append one
after the group's last migration when the kit converges after up (a dry-run
probe decides when nothing is pending), deduplicated per plan tail;
enqueueConverge() and schedule({ job: 'converge' }) run it on its own, and
the sync tick probes for drift. A converge job is ordered by default and
carries no prune.

Types (both .d.ts), the JSON schema, docs (a Declared Collections guide and a
converge command page), README, CHANGELOG, ARCHITECTURE, CLAUDE.md and
examples/migration-service are updated; docs/commands/audit.md now counts the
changelog's five indexes. Marked experimental; part of the unreleased v2.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The comparison tables (why, vs-migrate-mongo, vs-mongo-migrate-kit, the
  migrate-mongo guide and both README tables) gain a row for declared indexes
  and validators (`converge`), and the "Reasons to choose it" lists in why.md
  and the README a bullet. vs-migrate-mongo explains the difference in a short
  section, experimental status included.
- The Declared Collections guide now says what converge was tested on:
  MongoDB 5.0, 6.0, 7.0 and 8.0. The converge integration tests pass on
  5.0.31, 6.0.29, 7.0.14 and 8.0.32, apart from the clustered-collection
  test on 5.0 (clustered collections arrived in 5.3).
- CHANGELOG: v2.1.0 is dated 2026-10-03.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
migronaut Ready Ready Preview Oct 4, 2026 9:40am UTC

Alex-Dolid and others added 6 commits October 3, 2026 16:34
Phase 1 of the feature/bullmq code review — correctness, data safety and the
contracts that freeze once 2.1.0 is published.

- converge: a rebuild that drops a unique index and builds it back is a
  conflict unless converge({ rebuildUnique: true }) / --rebuild-unique; the
  after-up hook and queue jobs never pass it. A failed restore says why.
- converge: collations compare with strength/caseLevel/numericOrdering at their
  universal defaults; a Map key keeps an integer-like field only first;
  declared-duplicate detection reuses the index-spec rules; a flag stored as 1
  reads as true; wildcardProjection only on an all-fields wildcard.
- converge: converge:action fires with status 'started' before every step, and
  an index build logs which index it is starting on.
- queue: a job a shutdown stops before its migration starts is moved back to
  the head of the queue (moveToWait + WaitingError) instead of failing its
  whole group; close() stops the worker fetching first.
- queue: the job contract accepts versions MIN..CURRENT, refuses unknown fields,
  always states `ordered`, and may carry a plan-time checksum; golden v1
  payloads pin it.
- queue: scheduler ticks carry jobOptions and a bounded retention; an injected
  Queue/QueueEvents on another name or prefix is refused; startWorker() can be
  retried; a closed queue refuses further calls; force-close keeps the running
  migration's connection; failed jobs carry runId/jobId/groupId.
- kit: an ordered single-file up only targets a file of the migration sequence.
- telemetry: a promise returned by the SDK can no longer surface as an
  unhandled rejection; errorType never throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Phase 2 of the feature/bullmq code review.

- lock wait: the default budget follows the holder's TTL (now written to the
  lock document), polls back off up to 5s, waits are measured by the clock,
  timeouts carry attempts/waitedMs/timedOut, onLockHeld is validated, and
  runMigrations takes an AbortSignal; after-up definitions load once while
  refused.
- queue: a job blocked only by migrations that did not fail waits for them
  (several workers without global concurrency); jobs carry the plan-time
  checksum and refuse another version of the file; the worker's `allow`
  policy refuses force / ordered:false by default; wait() has one budget and
  carries the job's typed code; progress during a long wait is throttled;
  failed-job text masks duplicate-key values; schedule() needs every >= 1s;
  dedup ids are reversible and keep forced re-runs apart.
- converge: one listCollections plus bounded listIndexes, one createIndexes
  per collection, no restore after a connection failure mid-build, from/to on
  result rows, CLI confirmation for validator changes and --ordered, no
  prompt for a plan that conflicts, a warning for indexes: [] with prune.
- kit: up(name, { checksum }), list/status({ checksums: false }),
  LockInfo.runId/ttlMs, single-flight config loading.
- telemetry: db.namespace and static telemetry.attributes on every span and
  point, migronaut.lock.wait.duration by outcome, counter units, span status
  masked and bounded; telemetryIssues lives in telemetry.js.
- governance: @experimental on the new shapes; sync tests pin the OTel guide,
  the schema's index/collection definitions and compareIndex coverage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d converge for 2.1.0

- converge history: an append-only `_migronaut_converge` entry per converge
  that changed something or failed (convergeLogCollection,
  MIGRONAUT_CONVERGE_LOG_COLLECTION), read with kit.convergeHistory() and
  `migronaut converge --history`
- requestedBy / reason on up, down, redo and converge (kit options, queue
  job contract, `--reason` in the CLI), stamped on changelog records and
  history entries; failed traces also keep the file's checksum
- the scheduled sync holds a migration whose last attempt failed with the
  same checksum instead of re-running it every tick (SyncJobResult.held)
- in-place index changes: unique via collMod prepareUnique -> unique on 7.0+,
  TTL added via collMod on 5.1+; rolled back when the data holds duplicates
- sharded clusters: the shard-key index is kept under prune, and a refused
  drop of it is reported as `keep` instead of failing the run
- converge re-plans each collection before applying it and refuses a plan
  that gained a conflict or a destructive step since confirmation
- canonical: `__proto__` stays a key; RegExp flags the driver cannot store
  as written are rejected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e history is

The closing "Kept N undeclared index(es) — converge with prune to drop them"
counted indexes kept because they back a shard key, which stay under prune
too; it now counts only indexes kept for want of prune. The comments and
docs that still called converge "stateless: nothing is recorded" now say
that it keeps a history no run reads back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er-tick scenario

The scenario ended once both markers were written, but the changelog write
comes after the marker. The afterEach force-close does not wait for a job
in flight, so its late write landed in the next test's freshly dropped
database as a `failed` record and broke "the re-run finds the work done"
about one run in three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rd out of migrator.js

No behaviour change; the test suite is untouched.

- core/options.js: the pure validators, one assert*Options per run method,
  checks in the order that decides which error a caller sees
- core/sequence.js: the migration files on disk vs the applied names —
  pendingIn, truncateAtTarget, lateArrivals, revertOrder, and blockedError,
  the one wording of an ordered step's refusal (three call sites before)
- core/run-recorder.js: RunRecorder takes #withLock's bookkeeping — run and
  lock events, run metrics, the span's end, the closing "Done" line; the
  run span is still opened in migrator.js, one of the two wrap sites
- utils/error.js errorWithCause(): the "which — why" text the changelog's
  failure trace and a failed span's status share; utils/actor.js
  actorFields(), also used by changelog.markReverted
- #filepath goes through assertMigrationName
- converge.js: runConverge in phases — readAndPlan, refuseConflicts,
  replan, applyCollection, verifyFixedPoint, reportSuccess/reportFailure
- bullmq/producer.js: one foreignJobs() lookup and one makeWait() for the
  group and the converge-only handles

migrator.js goes from 2323 to 1976 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Alex-Dolid and others added 2 commits October 3, 2026 20:38
… always has its outcome

- wait(): BullMQ's own waitUntilFinished timer got the same remaining
  budget as wait()'s, so it could fire first, while Date.now() was still a
  millisecond short of the deadline, and the timeout was reported as an
  ordinary failure (`timedOut: false`). BullMQ's timer now gets a second
  more: it only takes its listeners off QueueEvents, and wait()'s own clock
  decides.
- getJob(): the job and its state were two reads, so a job that finished
  between them read as `completed`/`failed` with no returnvalue or
  failedReason and the attempt not counted. A finished job is read again.

Both showed up as CI flakes (Node 22) and in the real-Redis suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test released the stuck renewal at 320ms and counted on no tick
landing after that. On a coarse timer (Windows) the ~300ms tick could
arrive after 320ms, start a second renewal nobody released, and the
release then waited on it until Node cancelled the test. It now keeps
every renewal stuck for 350ms (at least three ticks), counts, and then
lets all of them go, a late one included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three tests asserted a measured wait against the real clock while the
sleeps between attempts ran on real timers, which fire up to a millisecond
off by Date.now(). "The first onWait reports 0ms" failed on CI under
coverage when the clock ticked over between the refusal and the callback.
Date.now() now moves only when the test advances it, so the waits are
exact: from the first refusal, the slow last attempt included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alex-Dolid
Alex-Dolid merged commit 902cacd into main Oct 4, 2026
12 checks passed
@Alex-Dolid
Alex-Dolid deleted the feature/bullmq branch October 4, 2026 10:14

This branch was successfully deployed

1 active deployment
Preview — be32ae0a Deployed Oct 4, 2026 by vercel[bot]
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.

1 participant