Repository navigation
Migronaut v2.1: migrations as BullMQ jobs, custom ids, OpenTelemetry, declared collections - #3
Merged
Merged
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
… 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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
@alexify/migronaut/bullmqgenerateIdconfig optiontelemetry: { tracer, meter }collections/collectionsDir,migronaut convergeMigrations as a queue
bullmq: { Queue, Worker, QueueEvents }). It is never a dependency or a peer.MIGRATION_BLOCKED.downstill rolls back a whole deploy. Duplicate enqueues are deduplicated.up(file, { batch, ordered }),down(file, { ordered })andnextBatch().examples/migration-service.Custom ids
generateId: ulid. The function is called with no arguments, and its result is checked on every call.noncethat migronaut mints itself, so mutual exclusion never depends on the user's ids being unique.OpenTelemetry
migronaut.runandmigronaut.migration. An instrumented MongoDB driver nests its command spans under the migration that issued them.bullmq.telemetrypasses BullMQ's own telemetry object through, giving one trace from the enqueue to the MongoDB commands in the worker.Declared collections (experimental)
collectionsand/or incollectionsDir(one file per collection).migronaut convergecompares them with the live database and applies the difference. It is stateless: nothing is recorded.pruneis on;--checkexits 28 on drift.convergeAfterUp, every bulkupends 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
convergecommand 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:
MigronautErrorCodegainsMIGRATION_BLOCKED,QUEUE_JOB_INVALID,QUEUE_JOB_FAILEDandCONVERGE_FAILED. An exhaustiveswitchover the codes needs the new cases.generateId,telemetry,collections,collectionsDirandconvergeAfterUpare now validated. A wrong value isCONFIG_INVALID; before, it was ignored.dryRun('up')now applies the out-of-order policy.connect()is safe to call concurrently.noncefield. Older releases ignore it and can share a database with this one.There are still zero runtime dependencies.
bullmq,ioredis,@opentelemetry/*andbullmq-otelare devDependencies only, and unit tests pin thatsrc/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).@opentelemetry/instrumentation-mongodbcovers the span nesting.