Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion lib/services/mongoose.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,60 @@ const resolveDebug = (cfg = config) => Boolean(cfg?.db?.debug) && configHelper.i
*/
const DEFAULT_AWAIT_INDEX_BUILDS_TIMEOUT_MS = 60000;

/**
* MongoDB server error codes for "an index with this name already exists with
* a different definition" (#4004):
* - 85 `IndexOptionsConflict` — same name, different options (e.g. a schema
* declaration gaining a `partialFilterExpression` that the deployed
* database's index does not carry yet)
* - 86 `IndexKeySpecsConflict` — same name, different key spec
* Both describe a state of the ENVIRONMENT (a legacy index on an
* already-deployed database), not a defect in the code — the repair for them
* is a drop/recreate migration, which runs AFTER {@link awaitIndexBuilds} in
* the boot sequence (lib/app.js#bootstrap).
*/
const INDEX_CONFLICT_CODES = new Set([85, 86]);
const INDEX_CONFLICT_CODE_NAMES = new Set(['IndexOptionsConflict', 'IndexKeySpecsConflict']);

/**
* @desc Whether an index-build rejection is a same-name conflict with a
* pre-existing index (see {@link INDEX_CONFLICT_CODES}).
* @param {Error} err - rejection from `Model#init()`
* @returns {boolean} true when the error is an index-name conflict
*/
const isIndexConflictError = (err) => INDEX_CONFLICT_CODES.has(err?.code) || INDEX_CONFLICT_CODE_NAMES.has(err?.codeName);

/**
* @desc Log a tolerated same-name index conflict LOUDLY: the model, the driver
* error (whose message names the requested and existing index), and — best
* effort — the full declared (schema) vs live (collection) index specs, so an
* operator can see exactly which definition the database still holds. Never
* throws: spec enumeration failures are logged and swallowed, since this runs
* on a boot path that must proceed to the migration runner (#4004).
* @param {string} modelName - name of the model whose index build conflicted
* @param {Error} err - the conflict rejection from `Model#init()`
* @returns {Promise<void>} resolves once the conflict has been logged
*/
const logIndexConflict = async (modelName, err) => {
logger.error(
chalk.red(
`Index build CONFLICT on model '${modelName}' (${err?.codeName || err?.code}): ${err?.message} — ` +
'boot CONTINUES with the LIVE (stale) index so the migration runner can reconcile it; ' +
'the schema-declared spec is NOT applied until a migration drops/recreates this index.',
),
err,
);
try {
const model = mongoose.model(modelName);
const declared = model.schema.indexes();
logger.error(chalk.red(` declared (schema): ${JSON.stringify(declared)}`));
const live = await model.collection.listIndexes().toArray();
logger.error(chalk.red(` live (collection): ${JSON.stringify(live)}`));
} catch (specErr) {
logger.error(chalk.red(` could not enumerate declared/live index specs: ${specErr?.message}`));
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Load all mongoose related models
*/
Expand Down Expand Up @@ -98,7 +152,18 @@ const connect = async () => {
* already proceeded past this call.
* A genuine rejection that happens BEFORE the timeout (the common case — an
* invalid index declaration fails fast) still propagates and rejects this
* call, exactly as before.
* call, with ONE deliberate exception (#4004): a same-name index CONFLICT
* (codes 85/86 — the live collection already holds an index under the
* declared name with a different definition) is logged at error level and
* TOLERATED. That state is environment state, not a code bug: the migration
* that reconciles it runs AFTER this call (lib/app.js#bootstrap), so
* rejecting here would gate the repair behind the very defect it repairs —
* the app would crash-loop with no path forward short of manual index
* surgery. Serving with the stale live index (writes keep obeying the OLD
* constraint until the migration lands) beats not serving at all. NOTE: a
* conflict aborts that model's remaining index builds too (createIndexes is
* sequential per model), which the log calls out — the migration reconciling
* the conflict re-converges the rest via autoIndex on the next boot.
* @param {object} [cfg=config] - application configuration object
* @returns {Promise<void>} Resolves once every registered model's index builds
* finish, the configured timeout elapses, or immediately when disabled.
Expand Down Expand Up @@ -127,6 +192,15 @@ const awaitIndexBuilds = async (cfg = config) => {
.then((result) => {
pending.delete(name);
return result;
})
.catch(async (err) => {
pending.delete(name);
// Same-name index conflict with a pre-existing (legacy) index: log
// loudly and keep booting so the migration runner — which runs right
// after this in bootstrap — can reconcile it (#4004). Every other
// rejection (e.g. an invalid index declaration) still propagates.
if (!isIndexConflictError(err)) throw err;
await logIndexConflict(name, err);
}),
),
);
Expand Down
120 changes: 120 additions & 0 deletions lib/services/tests/mongoose.awaitIndexBuilds.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,124 @@ describe('mongoose service — awaitIndexBuilds:', () => {
expect(localMongoose.modelNames).not.toHaveBeenCalled();
expect(localMongoose.model).not.toHaveBeenCalled();
});

/**
* #4004 — a schema-declared index whose options differ from a PRE-EXISTING
* same-name index (MongoDB codes 85 IndexOptionsConflict / 86
* IndexKeySpecsConflict) must NOT fail startup: the migration that
* reconciles the conflict runs AFTER awaitIndexBuilds() in bootstrap, so
* rejecting here gates the repair behind the very defect it repairs. The
* conflict must instead be logged at error level, naming the model and both
* the declared (schema) and live (collection) specs. Every other rejection
* keeps propagating (covered above). These paths need their own logger mock
* (to capture error calls), so they set up an isolated module registry like
* the timeout tests.
* @param {object} opts - scenario configuration
* @param {Error} opts.initError - rejection reason for the `BillingUsage` model's `init()`
* @param {Function} opts.listIndexes - mock `toArray()` implementation for `collection.listIndexes()`
* @returns {Promise<{awaitIndexBuilds: Function, error: import('@jest/globals').Mock}>} the isolated
* `awaitIndexBuilds` under test and the mocked `logger.error`
*/
const setupWithConflictingModel = async ({ initError, listIndexes }) => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
jest.resetModules();
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { db: { uri: 'mongodb://127.0.0.1:27017/NodeTest', options: {} }, files: { mongooseModels: [] } },
}));
const error = jest.fn();
jest.unstable_mockModule('../logger.js', () => ({
default: { info: jest.fn(), error, warn: jest.fn() },
}));

const localMongoose = {
modelNames: jest.fn(() => ['User', 'BillingUsage']),
model: jest.fn((name) => ({
init: name === 'BillingUsage' ? jest.fn().mockRejectedValue(initError) : jest.fn().mockResolvedValue(undefined),
schema: {
indexes: jest.fn(() => [
[{ organizationId: 1, month: 1 }, { unique: true, partialFilterExpression: { legacyPeriod: { $exists: true } } }],
]),
},
collection: { listIndexes: jest.fn(() => ({ toArray: listIndexes })) },
})),
connect: jest.fn(),
set: jest.fn(),
};
jest.unstable_mockModule('mongoose', () => ({ default: localMongoose }));

const mod = await import('../mongoose.js');
return { awaitIndexBuilds: mod.default.awaitIndexBuilds, error };
};

test('same-name index conflict (code 85, IndexOptionsConflict) does NOT reject — boot continues, error names model + both specs', async () => {
const conflict = Object.assign(new Error('An existing index has the same name as the requested index.'), {
code: 85,
codeName: 'IndexOptionsConflict',
});
const { awaitIndexBuilds: run, error } = await setupWithConflictingModel({
initError: conflict,
listIndexes: jest.fn().mockResolvedValue([{ v: 2, key: { organizationId: 1, month: 1 }, name: 'organizationId_1_month_1', unique: true }]),
});

await expect(run()).resolves.toBeUndefined();

const logged = error.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toEqual(expect.stringContaining('BillingUsage'));
expect(logged).toEqual(expect.stringContaining('IndexOptionsConflict'));
expect(logged).toEqual(expect.stringContaining('declared (schema)'));
expect(logged).toEqual(expect.stringContaining('partialFilterExpression'));
expect(logged).toEqual(expect.stringContaining('live (collection)'));
expect(logged).toEqual(expect.stringContaining('organizationId_1_month_1'));
});

test('same-name key-spec conflict identified by codeName only (IndexKeySpecsConflict, no numeric code) is tolerated too', async () => {
const conflict = Object.assign(new Error('Index must have unique name.'), { codeName: 'IndexKeySpecsConflict' });
const { awaitIndexBuilds: run, error } = await setupWithConflictingModel({
initError: conflict,
listIndexes: jest.fn().mockResolvedValue([]),
});

await expect(run()).resolves.toBeUndefined();
expect(error).toHaveBeenCalled();
});

test('conflict tolerance survives a failing spec enumeration (listIndexes throws) — still resolves, still logs', async () => {
const conflict = Object.assign(new Error('An existing index has the same name as the requested index.'), { code: 85 });
const { awaitIndexBuilds: run, error } = await setupWithConflictingModel({
initError: conflict,
listIndexes: jest.fn().mockRejectedValue(new Error('not authorized on listIndexes')),
});

await expect(run()).resolves.toBeUndefined();
const logged = error.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toEqual(expect.stringContaining('could not enumerate declared/live index specs'));
});

test('tolerance is scoped to conflicts: a non-conflict rejection on another model still propagates', async () => {
jest.resetModules();
jest.unstable_mockModule('../../../config/index.js', () => ({
default: { db: { uri: 'mongodb://127.0.0.1:27017/NodeTest', options: {} }, files: { mongooseModels: [] } },
}));
jest.unstable_mockModule('../logger.js', () => ({
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}));

const conflict = Object.assign(new Error('An existing index has the same name as the requested index.'), { code: 85 });
const localMongoose = {
modelNames: jest.fn(() => ['User', 'BillingUsage']),
model: jest.fn((name) => ({
init:
name === 'BillingUsage'
? jest.fn().mockRejectedValue(conflict)
: jest.fn().mockRejectedValue(new Error('unsupported partial filter operator')),
schema: { indexes: jest.fn(() => []) },
collection: { listIndexes: jest.fn(() => ({ toArray: jest.fn().mockResolvedValue([]) })) },
})),
connect: jest.fn(),
set: jest.fn(),
};
jest.unstable_mockModule('mongoose', () => ({ default: localMongoose }));

const mod = await import('../mongoose.js');
await expect(mod.default.awaitIndexBuilds()).rejects.toThrow('unsupported partial filter operator');
});
});
Loading