diff --git a/extensions/lifecycle/conductor/LifecycleConductor.js b/extensions/lifecycle/conductor/LifecycleConductor.js index 09371dc80..d67d1a6ac 100644 --- a/extensions/lifecycle/conductor/LifecycleConductor.js +++ b/extensions/lifecycle/conductor/LifecycleConductor.js @@ -11,6 +11,7 @@ const BucketClient = require('bucketclient').RESTClient; const MongoClient = require('arsenal').storage .metadata.mongoclient.MongoClientInterface; +const config = require('../../../lib/Config'); const BackbeatProducer = require('../../../lib/BackbeatProducer'); const BackbeatTask = require('../../../lib/tasks/BackbeatTask'); const ZookeeperManager = require('../../../lib/clients/ZookeeperManager'); @@ -25,6 +26,7 @@ const { } = require('../../../lib/constants'); const { LifecycleMetrics } = require('../LifecycleMetrics'); +const { hasMassExpirationRule } = require('../util/mongoRules'); const { BreakerState, CircuitBreaker } = require('breakbeat').CircuitBreaker; const { startCircuitBreakerMetricsExport, @@ -277,6 +279,12 @@ class LifecycleConductor { return cb(null, lifecycleTaskVersions.v1); } + if (task.hasMassExpirationRule) { + log.trace('skipping index creation: bucket has a mass-expiration rule'); + LifecycleMetrics.onLegacyTask(log, 'massExpirationRule'); + return cb(null, lifecycleTaskVersions.v1); + } + if (!this.lcConfig.autoCreateIndexes) { log.trace('skipping index creation: auto creation of indexes disabled'); LifecycleMetrics.onLegacyTask(log, 'noIndex'); @@ -331,6 +339,22 @@ class LifecycleConductor { return cb(null, lifecycleTaskVersions.v1); } + const { maxAutoIndexDocCount, maxAutoIndexStorageBytes } = + config.lifecycleConductorOptions; + const docCount = results.collStats.count || 0; + const storageSize = results.collStats.storageSize || 0; + if (docCount > maxAutoIndexDocCount || storageSize > maxAutoIndexStorageBytes) { + log.warn('skipping index creation: collection too large', { + bucket: task.bucketName, + docCount, + storageSize, + maxDocCount: maxAutoIndexDocCount, + maxStorageBytes: maxAutoIndexStorageBytes, + }); + LifecycleMetrics.onLegacyTask(log, 'collectionTooBig'); + return cb(null, lifecycleTaskVersions.v1); + } + this.activeIndexingJobs.push({ bucket: task.bucketName, indexes: indexesForFeature.lifecycle.v2, @@ -893,6 +917,8 @@ class LifecycleConductor { canonicalId: doc.value.owner, isLifecycled: !!doc.value.lifecycleConfiguration, hasSosApi: !!doc.value.capabilities?.VeeamSOSApi, + hasMassExpirationRule: hasMassExpirationRule( + doc.value.lifecycleConfiguration?.rules), }); nEnqueued += 1; lastSentId = doc._id; diff --git a/extensions/lifecycle/util/mongoRules.js b/extensions/lifecycle/util/mongoRules.js new file mode 100644 index 000000000..93a4155bc --- /dev/null +++ b/extensions/lifecycle/util/mongoRules.js @@ -0,0 +1,28 @@ +'use strict'; + +// Operates on the MongoDB internal lifecycle format (lowercase ruleStatus, +// actions[], filter.rulePrefix) — NOT the AWS S3 format used by util/rules.js. + +// A whole-bucket Expiration with days=0 is the explicit "empty this bucket" +// signal: treat it as mass-expiration so the conductor keeps lifecycle on v1. +function isMassExpirationRule(rule) { + if (!rule || rule.ruleStatus !== 'Enabled') { + return false; + } + if (rule.filter && (rule.filter.rulePrefix + || (rule.filter.tags && rule.filter.tags.length > 0))) { + return false; + } + return (rule.actions || []).some(action => action + && action.actionName === 'Expiration' + && action.days === 0); +} + +function hasMassExpirationRule(rules) { + return (rules || []).some(r => isMassExpirationRule(r)); +} + +module.exports = { + isMassExpirationRule, + hasMassExpirationRule, +}; diff --git a/lib/Config.js b/lib/Config.js index 5d54beefa..57a9aaa88 100644 --- a/lib/Config.js +++ b/lib/Config.js @@ -180,6 +180,19 @@ class Config extends EventEmitter { this.transientLocations = {}; this._setTimeOptions(); + this._setLifecycleConductorOptions(); + } + + _setLifecycleConductorOptions() { + const maxAutoIndexDocCount = + Number.parseInt(process.env.LIFECYCLE_MAX_AUTO_INDEX_DOC_COUNT, 10) || 10000000; + const maxAutoIndexStorageBytes = + Number.parseInt(process.env.LIFECYCLE_MAX_AUTO_INDEX_STORAGE_BYTES, 10) + || 10 * 1024 * 1024 * 1024; + this.lifecycleConductorOptions = { + maxAutoIndexDocCount, + maxAutoIndexStorageBytes, + }; } _setTimeOptions() { diff --git a/package.json b/package.json index f9b2167b2..f2ed5276d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "backbeat", - "version": "9.5.0-preview.2", + "version": "9.5.0-preview.3", "description": "Asynchronous queue and job manager", "main": "index.js", "scripts": { diff --git a/tests/unit/lifecycle/LifecycleConductor.spec.js b/tests/unit/lifecycle/LifecycleConductor.spec.js index 834146b7f..ae67eb955 100644 --- a/tests/unit/lifecycle/LifecycleConductor.spec.js +++ b/tests/unit/lifecycle/LifecycleConductor.spec.js @@ -730,6 +730,116 @@ describe('Lifecycle Conductor', () => { done(); }); }); + + it('should return v1: bucket has a mass-expiration rule', done => { + const client = new BackbeatMetadataProxyMock(); + conductor.clientManager.getBackbeatMetadataProxy = () => client; + conductor.activeIndexingJobsRetrieved = true; + conductor.activeIndexingJobs = []; + conductor._bucketSource = 'mongodb'; + client.indexesObj = []; + client.error = null; + + const task = { ...getTask(true), hasMassExpirationRule: true }; + conductor._indexesGetOrCreate(task, log, (err, taskVersion) => { + assert.ifError(err); + assert.deepStrictEqual(client.receivedIdxObj, null); + assert.deepStrictEqual(conductor.activeIndexingJobs, []); + assert.deepStrictEqual(taskVersion, lifecycleTaskVersions.v1); + done(); + }); + }); + + it('mass-expiration guard short-circuits before fetching diskUsage/collStats', done => { + const client = new BackbeatMetadataProxyMock(); + conductor.clientManager.getBackbeatMetadataProxy = () => client; + conductor.activeIndexingJobsRetrieved = true; + conductor.activeIndexingJobs = []; + conductor._bucketSource = 'mongodb'; + // Throw if either MongoDB call is made — the guard must short-circuit before. + conductor._mongodbClient = { + getDiskUsage: () => { throw new Error('getDiskUsage should not be called'); }, + getCollectionStats: () => { throw new Error('getCollectionStats should not be called'); }, + }; + client.indexesObj = []; + client.error = null; + + const task = { ...getTask(true), hasMassExpirationRule: true }; + conductor._indexesGetOrCreate(task, log, (err, taskVersion) => { + assert.ifError(err); + assert.deepStrictEqual(taskVersion, lifecycleTaskVersions.v1); + done(); + }); + }); + + it('should still return v2 when mass-expiration bucket already has indexes', done => { + const client = new BackbeatMetadataProxyMock(); + conductor.clientManager.getBackbeatMetadataProxy = () => client; + conductor.activeIndexingJobsRetrieved = true; + conductor.activeIndexingJobs = []; + conductor._bucketSource = 'mongodb'; + client.indexesObj = indexesForFeature.lifecycle.v2; + client.error = null; + + const task = { ...getTask(true), hasMassExpirationRule: true }; + conductor._indexesGetOrCreate(task, log, (err, taskVersion) => { + assert.ifError(err); + assert.deepStrictEqual(taskVersion, lifecycleTaskVersions.v2); + done(); + }); + }); + + it('should return v1: collection too big (docCount over threshold)', done => { + const client = new BackbeatMetadataProxyMock(); + conductor.clientManager.getBackbeatMetadataProxy = () => client; + conductor.activeIndexingJobsRetrieved = true; + conductor.activeIndexingJobs = []; + conductor._bucketSource = 'mongodb'; + conductor._mongodbClient = { + getDiskUsage: cb => cb(null, { available: 1e12, free: 1e12, total: 2e12 }), + getCollectionStats: (bucketName, _log, cb) => cb(null, { + indexSizes: { _id_: 1000 }, + count: 20000000, + storageSize: 100, + }), + }; + client.indexesObj = []; + client.error = null; + + conductor._indexesGetOrCreate(getTask(true), log, (err, taskVersion) => { + assert.ifError(err); + assert.deepStrictEqual(client.receivedIdxObj, null); + assert.deepStrictEqual(conductor.activeIndexingJobs, []); + assert.deepStrictEqual(taskVersion, lifecycleTaskVersions.v1); + done(); + }); + }); + + it('should return v1: collection too big (storageSize over threshold)', done => { + const client = new BackbeatMetadataProxyMock(); + conductor.clientManager.getBackbeatMetadataProxy = () => client; + conductor.activeIndexingJobsRetrieved = true; + conductor.activeIndexingJobs = []; + conductor._bucketSource = 'mongodb'; + conductor._mongodbClient = { + getDiskUsage: cb => cb(null, { available: 1e12, free: 1e12, total: 2e12 }), + getCollectionStats: (bucketName, _log, cb) => cb(null, { + indexSizes: { _id_: 1000 }, + count: 100, + storageSize: 20 * 1024 * 1024 * 1024, + }), + }; + client.indexesObj = []; + client.error = null; + + conductor._indexesGetOrCreate(getTask(true), log, (err, taskVersion) => { + assert.ifError(err); + assert.deepStrictEqual(client.receivedIdxObj, null); + assert.deepStrictEqual(conductor.activeIndexingJobs, []); + assert.deepStrictEqual(taskVersion, lifecycleTaskVersions.v1); + done(); + }); + }); }); describe('listBuckets', () => { @@ -1101,5 +1211,70 @@ describe('Lifecycle Conductor', () => { done(); }); }); + + it('should populate hasMassExpirationRule from lifecycle rules when listing from mongodb', done => { + const lcConductor = makeLifecycleConductorWithFilters({ + bucketSource: 'mongodb', + }, []); + + const docs = [ + { + _id: 'mass-exp-bucket', + value: { + owner: 'acc', + lifecycleConfiguration: { + rules: [ + { ruleStatus: 'Enabled', + actions: [{ actionName: 'Expiration', days: 0 }] }, + ], + }, + }, + }, + { + _id: 'selective-bucket', + value: { + owner: 'acc', + lifecycleConfiguration: { + rules: [ + { ruleStatus: 'Enabled', actions: [{ actionName: 'Expiration', days: 30 }] }, + ], + }, + }, + }, + { _id: 'no-lifecycle-bucket', value: { owner: 'acc' } }, + ]; + let idx = 0; + const cursor = { + hasNext: () => Promise.resolve(idx < docs.length), + next: () => Promise.resolve(docs[idx++]), + }; + const projectStub = sinon.stub().returns(cursor); + const findStub = sinon.stub().returns({ project: projectStub }); + + lcConductor._mongodbClient = { + getIndexingJobs: (_, cb) => cb(null, []), + getCollection: name => { + if (name === '__metastore') {return { find: findStub };} + return { collectionName: name }; + }, + _isSpecialCollection: () => false, + }; + sinon.stub(lcConductor._zkClient, 'getData').yields(null, null, null); + + lcConductor.listBuckets(queue, fakeLogger, err => { + assert.ifError(err); + // lifecycleConfiguration must be in the projection so we can read its rules + assert.strictEqual(projectStub.getCall(0).args[0]['value.lifecycleConfiguration'], 1); + const tasks = queue.list(); + assert.strictEqual(tasks.length, 3); + const massExp = tasks.find(t => t.bucketName === 'mass-exp-bucket'); + const selective = tasks.find(t => t.bucketName === 'selective-bucket'); + const noLifecycle = tasks.find(t => t.bucketName === 'no-lifecycle-bucket'); + assert.strictEqual(massExp.hasMassExpirationRule, true); + assert.strictEqual(selective.hasMassExpirationRule, false); + assert.strictEqual(noLifecycle.hasMassExpirationRule, false); + done(); + }); + }); }); }); diff --git a/tests/unit/lifecycle/mongoRules.spec.js b/tests/unit/lifecycle/mongoRules.spec.js new file mode 100644 index 000000000..c9610fa02 --- /dev/null +++ b/tests/unit/lifecycle/mongoRules.spec.js @@ -0,0 +1,120 @@ +'use strict'; + +const assert = require('assert'); + +const { + isMassExpirationRule, + hasMassExpirationRule, +} = require('../../../extensions/lifecycle/util/mongoRules'); + +function rule(overrides = {}) { + return Object.assign({ + ruleID: 'r1', + ruleStatus: 'Enabled', + actions: [{ actionName: 'Expiration', days: 0 }], + }, overrides); +} + +describe('mongoRules.isMassExpirationRule', () => { + it('returns true for Expiration days=0, no filter, enabled', () => { + assert.strictEqual(isMassExpirationRule(rule()), true); + }); + + it('returns false for Expiration days=1', () => { + assert.strictEqual(isMassExpirationRule( + rule({ actions: [{ actionName: 'Expiration', days: 1 }] })), false); + }); + + it('returns false for Expiration days=30', () => { + assert.strictEqual(isMassExpirationRule( + rule({ actions: [{ actionName: 'Expiration', days: 30 }] })), false); + }); + + it('returns false for date-based Expiration (not the days=0 signal)', () => { + assert.strictEqual(isMassExpirationRule( + rule({ actions: [{ actionName: 'Expiration', date: '2099-01-01T00:00:00Z' }] })), false); + }); + + it('returns false when ruleStatus is Disabled', () => { + assert.strictEqual(isMassExpirationRule( + rule({ ruleStatus: 'Disabled' })), false); + }); + + it('returns false when filter.rulePrefix is set', () => { + assert.strictEqual(isMassExpirationRule( + rule({ filter: { rulePrefix: 'foo/' } })), false); + }); + + it('returns false when filter.tags is non-empty', () => { + assert.strictEqual(isMassExpirationRule( + rule({ filter: { tags: [{ key: 'k', val: 'v' }] } })), false); + }); + + it('returns true when filter is present but empty', () => { + assert.strictEqual(isMassExpirationRule( + rule({ filter: {} })), true); + }); + + it('returns false for non-Expiration action types with days=0', () => { + assert.strictEqual(isMassExpirationRule( + rule({ actions: [{ actionName: 'Transition', days: 0 }] })), false); + assert.strictEqual(isMassExpirationRule( + rule({ actions: [{ actionName: 'NoncurrentVersionExpiration', days: 0 }] })), false); + }); + + it('returns false when actions is missing', () => { + const r = rule(); + delete r.actions; + assert.strictEqual(isMassExpirationRule(r), false); + }); + + it('returns true when any action is an Expiration days=0', () => { + assert.strictEqual(isMassExpirationRule(rule({ + actions: [ + { actionName: 'NoncurrentVersionExpiration', days: 30 }, + { actionName: 'Expiration', days: 0 }, + ], + })), true); + }); + + it('returns false for null/undefined rule', () => { + assert.strictEqual(isMassExpirationRule(null), false); + assert.strictEqual(isMassExpirationRule(undefined), false); + }); +}); + +describe('mongoRules.hasMassExpirationRule', () => { + it('returns false for empty/missing rules', () => { + assert.strictEqual(hasMassExpirationRule([]), false); + assert.strictEqual(hasMassExpirationRule(null), false); + assert.strictEqual(hasMassExpirationRule(undefined), false); + }); + + it('returns false when all rules are Disabled', () => { + assert.strictEqual(hasMassExpirationRule([ + rule({ ruleStatus: 'Disabled' }), + rule({ ruleStatus: 'Disabled' }), + ]), false); + }); + + it('returns true when any enabled rule is mass-expiration', () => { + assert.strictEqual(hasMassExpirationRule([ + rule({ ruleID: 'r1', filter: { rulePrefix: 'foo/' } }), + rule({ ruleID: 'r2' }), + ]), true); + }); + + it('returns false when no enabled rule is mass-expiration', () => { + assert.strictEqual(hasMassExpirationRule([ + rule({ filter: { rulePrefix: 'foo/' } }), + rule({ ruleID: 'r2', actions: [{ actionName: 'Expiration', days: 1 }] }), + ]), false); + }); + + it('ignores disabled rules when scanning for a mass-expiration rule', () => { + assert.strictEqual(hasMassExpirationRule([ + rule({ ruleStatus: 'Disabled' }), + rule({ ruleID: 'r2', actions: [{ actionName: 'Expiration', days: 30 }] }), + ]), false); + }); +});