From 54f6a60c423dd293e570996db15dbff5445433a2 Mon Sep 17 00:00:00 2001 From: Maha Benzekri Date: Thu, 30 Apr 2026 10:12:18 +0200 Subject: [PATCH 1/2] Resolve lifecycle metric locations before queuing actions. Use archive-aware HeadObject, object metadata, and bucket location fallback to avoid reporting STANDARD when a real lifecycle location is available. Issue: BB-721 --- .../lifecycle/bucketProcessor/policy.json | 1 + extensions/lifecycle/tasks/LifecycleTask.js | 224 +++++++++++++++-- .../lifecycle/util/lifecycleLocation.js | 50 ++++ .../LifecycleBucketProcessorPolicy.spec.js | 13 + tests/unit/lifecycle/LifecycleTask.spec.js | 232 +++++++++++++++++- 5 files changed, 493 insertions(+), 27 deletions(-) create mode 100644 extensions/lifecycle/util/lifecycleLocation.js create mode 100644 tests/unit/lifecycle/LifecycleBucketProcessorPolicy.spec.js diff --git a/extensions/lifecycle/bucketProcessor/policy.json b/extensions/lifecycle/bucketProcessor/policy.json index 0de6c175b..0406acc83 100644 --- a/extensions/lifecycle/bucketProcessor/policy.json +++ b/extensions/lifecycle/bucketProcessor/policy.json @@ -6,6 +6,7 @@ "Effect": "Allow", "Action": [ "s3:GetLifecycleConfiguration", + "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:ListBucket", "s3:ListBucketVersions", diff --git a/extensions/lifecycle/tasks/LifecycleTask.js b/extensions/lifecycle/tasks/LifecycleTask.js index 0ee99d200..c3f226903 100644 --- a/extensions/lifecycle/tasks/LifecycleTask.js +++ b/extensions/lifecycle/tasks/LifecycleTask.js @@ -15,6 +15,7 @@ const { GetObjectTaggingCommand, HeadObjectCommand, GetBucketVersioningCommand, + GetBucketLocationCommand, } = require('@aws-sdk/client-s3'); const { attachReqUids } = require('@scality/cloudserverclient'); const config = require('../../../lib/Config'); @@ -24,6 +25,11 @@ const ReplicationAPI = require('../../replication/ReplicationAPI'); const { LifecycleMetrics, LIFECYCLE_MARKER_METRICS_LOCATION } = require('../LifecycleMetrics'); const locationsConfig = require('../../../conf/locationConfig.json') || {}; const { rulesSupportTransition } = require('../util/rules'); +const { + isRealLocation, + shouldResolveLifecycleMetricLocation, + resolveLifecycleMetricObjectLocation, +} = require('../util/lifecycleLocation'); const { decode } = versioning.VersionID; const errorTransitionInProgress = errors.InternalError. @@ -58,6 +64,19 @@ const MAX_RETRIES = 4; // parallel tasks, so the total delay of retries should about 1m30s. const MAX_RETRIES_TOTAL = CONCURRENCY_DEFAULT * MAX_RETRIES * 10; +function attachArchiveInfoHeader(command) { + command.middlewareStack.add(next => async args => { + if (args.request && args.request.headers) { + // eslint-disable-next-line no-param-reassign + args.request.headers['x-amz-scal-archive-info'] = 'true'; + } + return next(args); + }, { + step: 'build', + name: 'attachArchiveInfoHeader', + }); +} + /** * compare 2 version by their stale dates returning: * - LT (-1) if v1 is less than v2 @@ -104,6 +123,7 @@ class LifecycleTask extends BackbeatTask { this.setSupportedRules(this.supportedRules); this._totalRetries = 0; + this._bucketLocationCache = new Map(); } setSupportedRules(supportedRules) { @@ -197,30 +217,176 @@ class LifecycleTask extends BackbeatTask { * @return {undefined} */ _sendObjectAction(entry, cb) { - const location = entry.getAttribute('details.dataStoreName'); + const initialLocation = entry.getAttribute('details.dataStoreName'); - const shouldBreak = this.circuitBreakers.tripped( - 'expiration', - location, - this.objectTasksTopic, - ); - if (shouldBreak) { - process.nextTick(() => cb(errorCircuitBreakerTripped)); - return; + this._resolveLifecycleMetricLocation(entry, initialLocation, this.log, (err, location) => { + if (err) { + return cb(err); + } + const metricLocation = location || ''; + entry.setAttribute('details.dataStoreName', metricLocation); + + const shouldBreak = this.circuitBreakers.tripped( + 'expiration', + metricLocation, + this.objectTasksTopic, + ); + if (shouldBreak) { + return process.nextTick(() => cb(errorCircuitBreakerTripped)); + } + + LifecycleMetrics.onLifecycleTriggered(this.log, 'bucket', + entry.getActionType() === 'deleteMPU' ? 'expiration:mpu' : 'expiration', + metricLocation, + Date.now() - entry.getAttribute('transitionTime')); + + const entries = [{ message: entry.toKafkaMessage() }]; + return this.producer.sendToTopic(this.objectTasksTopic, entries, err => { + LifecycleMetrics.onKafkaPublish(null, 'ObjectTopic', 'bucket', err, 1); + return cb(err); + }); + }); + } + + _getBucketLocationConstraint(bucket, log, cb) { + if (this._bucketLocationCache.has(bucket)) { + return process.nextTick(cb, null, this._bucketLocationCache.get(bucket)); } - LifecycleMetrics.onLifecycleTriggered(this.log, 'bucket', - entry.getActionType() === 'deleteMPU' ? 'expiration:mpu' : 'expiration', - location, - Date.now() - entry.getAttribute('transitionTime')); + if (!this.s3target) { + return process.nextTick(cb); + } - const entries = [{ message: entry.toKafkaMessage() }]; - this.producer.sendToTopic(this.objectTasksTopic, entries, err => { - LifecycleMetrics.onKafkaPublish(null, 'ObjectTopic', 'bucket', err, 1); - return cb(err); + const command = new GetBucketLocationCommand({ Bucket: bucket }); + attachReqUids(command, log.getSerializedUids()); + return this.s3target.send(command) + .then(data => { + LifecycleMetrics.onS3Request(log, 'getBucketLocation', 'bucket', null); + const location = data && data.LocationConstraint; + this._bucketLocationCache.set(bucket, location); + return cb(null, location); + }) + .catch(err => { + LifecycleMetrics.onS3Request(log, 'getBucketLocation', 'bucket', err); + log.debug('failed to get bucket location for lifecycle metrics', { + method: 'LifecycleTask._getBucketLocationConstraint', + bucket, + error: err.message, + }); + return cb(); + }); + } + + _resolveLifecycleMetricLocationFromBucket(entry, fallbackLocation, log, cb) { + if (!shouldResolveLifecycleMetricLocation(fallbackLocation)) { + return process.nextTick(cb, null, fallbackLocation); + } + + const bucket = entry.getAttribute('target.bucket'); + return this._getBucketLocationConstraint(bucket, log, (err, bucketLocation) => { + if (err || !isRealLocation(bucketLocation)) { + return cb(null, fallbackLocation); + } + return cb(null, bucketLocation); + }); + } + + _getArchiveInfoLocation(entry, log, cb) { + if (!this.s3target) { + return process.nextTick(cb); + } + + const params = { + Bucket: entry.getAttribute('target.bucket'), + Key: entry.getAttribute('target.key'), + }; + const versionId = entry.getAttribute('target.version'); + if (versionId) { + params.VersionId = versionId; + } + + const command = new HeadObjectCommand(params); + attachArchiveInfoHeader(command); + attachReqUids(command, log.getSerializedUids()); + return this.s3target.send(command) + .then(data => { + LifecycleMetrics.onS3Request(log, 'headObjectArchiveInfo', 'bucket', null); + return cb(null, data.StorageClass); + }) + .catch(err => { + LifecycleMetrics.onS3Request(log, 'headObjectArchiveInfo', 'bucket', err); + log.debug('failed to get object archive info for lifecycle metric location', { + method: 'LifecycleTask._getArchiveInfoLocation', + bucket: params.Bucket, + objectKey: params.Key, + versionId, + error: err.message, + }); + return cb(); + }); + } + + _resolveLifecycleMetricLocationFromArchiveInfo(entry, fallbackLocation, log, cb) { + if (!shouldResolveLifecycleMetricLocation(fallbackLocation)) { + return process.nextTick(cb, null, fallbackLocation); + } + + return this._getArchiveInfoLocation(entry, log, (err, archiveInfoLocation) => { + if (err || !isRealLocation(archiveInfoLocation)) { + return this._resolveLifecycleMetricLocationFromBucket(entry, fallbackLocation, log, cb); + } + return cb(null, archiveInfoLocation); }); } + _resolveLifecycleMetricLocationFromMetadata(entry, fallbackLocation, log, cb) { + if (!this.backbeatMetadataProxy) { + return this._resolveLifecycleMetricLocationFromArchiveInfo(entry, fallbackLocation, log, cb); + } + + if (entry.getActionType() === 'deleteMPU') { + return this._resolveLifecycleMetricLocationFromBucket(entry, fallbackLocation, log, cb); + } + + const params = { + bucket: entry.getAttribute('target.bucket'), + objectKey: entry.getAttribute('target.key'), + versionId: entry.getAttribute('target.version'), + }; + + return this._getObjectMD(params, log, (err, objectMD) => { + LifecycleMetrics.onS3Request(log, 'getMetadata', 'bucket', err); + if (err) { + log.debug('failed to get object metadata for lifecycle metric location', { + method: 'LifecycleTask._resolveLifecycleMetricLocationFromMetadata', + bucket: params.bucket, + objectKey: params.objectKey, + versionId: params.versionId, + error: err.message, + }); + return this._resolveLifecycleMetricLocationFromArchiveInfo(entry, fallbackLocation, log, cb); + } + + const metadataLocation = resolveLifecycleMetricObjectLocation(objectMD, fallbackLocation); + if (!shouldResolveLifecycleMetricLocation(metadataLocation)) { + return cb(null, metadataLocation); + } + return this._resolveLifecycleMetricLocationFromArchiveInfo(entry, metadataLocation, log, cb); + }, false); + } + + _resolveLifecycleMetricLocation(entry, fallbackLocation, log, cb) { + if (!shouldResolveLifecycleMetricLocation(fallbackLocation)) { + return process.nextTick(cb, null, fallbackLocation); + } + + if (entry.getActionType() === 'deleteMPU') { + return this._resolveLifecycleMetricLocationFromBucket(entry, fallbackLocation, log, cb); + } + + return this._resolveLifecycleMetricLocationFromMetadata(entry, fallbackLocation, log, cb); + } + /** * Handles non-versioned objects * @param {object} bucketData - bucket data @@ -1103,16 +1269,18 @@ class LifecycleTask extends BackbeatTask { return false; } - _getObjectMD(params, log, cb) { + _getObjectMD(params, log, cb, logError = true) { this.backbeatMetadataProxy.getMetadata(params, log, (err, blob) => { if (err) { - log.error('failed to get object metadata', { - method: 'LifecycleTask._getObjectMD', - error: err, - bucket: params.bucket, - objectKey: params.objectKey, - versionId: params.versionId, - }); + if (logError) { + log.error('failed to get object metadata', { + method: 'LifecycleTask._getObjectMD', + error: err, + bucket: params.bucket, + objectKey: params.objectKey, + versionId: params.versionId, + }); + } return cb(err); } const { error, result } = ObjectMD.createFromBlob(blob.Body); @@ -1580,12 +1748,16 @@ class LifecycleTask extends BackbeatTask { params.VersionId = obj.VersionId; } const command = new HeadObjectCommand(params); + attachArchiveInfoHeader(command); attachReqUids(command, log.getSerializedUids()); return this.s3target.send(command) .then(data => { LifecycleMetrics.onS3Request(log, 'headObject', 'bucket', null); const object = Object.assign({}, obj, - { LastModified: data.LastModified }); + { + LastModified: data.LastModified, + StorageClass: data.StorageClass || obj.StorageClass, + }); // There is an order of importance in cases of conflicts // Expiration and NoncurrentVersionExpiration should be priority diff --git a/extensions/lifecycle/util/lifecycleLocation.js b/extensions/lifecycle/util/lifecycleLocation.js new file mode 100644 index 000000000..3ffba8f51 --- /dev/null +++ b/extensions/lifecycle/util/lifecycleLocation.js @@ -0,0 +1,50 @@ +'use strict'; + +const STANDARD_LOCATION = 'STANDARD'; + +function _getObjectMDValue(objectMD, getterName, fieldName) { + if (!objectMD) { + return undefined; + } + if (typeof objectMD[getterName] === 'function') { + return objectMD[getterName](); + } + return objectMD[fieldName]; +} + +function isRealLocation(location) { + return !!location && location !== STANDARD_LOCATION; +} + +function shouldResolveLifecycleMetricLocation(location) { + return !isRealLocation(location); +} + +function resolveLifecycleMetricObjectLocation(objectMD, fallbackLocation) { + const archive = _getObjectMDValue(objectMD, 'getArchive', 'archive'); + const amzStorageClass = _getObjectMDValue(objectMD, 'getAmzStorageClass', 'x-amz-storage-class'); + if (archive && isRealLocation(amzStorageClass)) { + return amzStorageClass; + } + + const dataStoreName = _getObjectMDValue(objectMD, 'getDataStoreName', 'dataStoreName'); + if (isRealLocation(dataStoreName)) { + return dataStoreName; + } + + const locations = _getObjectMDValue(objectMD, 'getLocation', 'location'); + const locationDataStoreName = Array.isArray(locations) && locations[0] + && locations[0].dataStoreName; + if (isRealLocation(locationDataStoreName)) { + return locationDataStoreName; + } + + return fallbackLocation; +} + +module.exports = { + STANDARD_LOCATION, + isRealLocation, + shouldResolveLifecycleMetricLocation, + resolveLifecycleMetricObjectLocation, +}; diff --git a/tests/unit/lifecycle/LifecycleBucketProcessorPolicy.spec.js b/tests/unit/lifecycle/LifecycleBucketProcessorPolicy.spec.js new file mode 100644 index 000000000..215b5ad43 --- /dev/null +++ b/tests/unit/lifecycle/LifecycleBucketProcessorPolicy.spec.js @@ -0,0 +1,13 @@ +const assert = require('assert'); + +const bucketProcessorPolicy = require('../../../extensions/lifecycle/bucketProcessor/policy.json'); + +describe('LifecycleBucketProcessor policy', () => { + it('should allow S3 actions required for lifecycle metric location resolution', () => { + const actions = bucketProcessorPolicy.Statement + .find(statement => statement.Sid === 'LifecycleExpirationBucketProcessor') + .Action; + + assert(actions.includes('s3:GetBucketLocation')); + }); +}); diff --git a/tests/unit/lifecycle/LifecycleTask.spec.js b/tests/unit/lifecycle/LifecycleTask.spec.js index 10f1b4f2c..2e3adf43e 100644 --- a/tests/unit/lifecycle/LifecycleTask.spec.js +++ b/tests/unit/lifecycle/LifecycleTask.spec.js @@ -4,12 +4,14 @@ const assert = require('assert'); const async = require('async'); const sinon = require('sinon'); const { errors } = require('arsenal'); -const { ValidLifecycleRules } = require('arsenal').models; +const { ObjectMD, ValidLifecycleRules } = require('arsenal').models; const LifecycleTask = require( '../../../extensions/lifecycle/tasks/LifecycleTask'); const LifecycleTaskV2 = require( '../../../extensions/lifecycle/tasks/LifecycleTaskV2'); +const ActionQueueEntry = require('../../../lib/models/ActionQueueEntry'); +const { LifecycleMetrics } = require('../../../extensions/lifecycle/LifecycleMetrics'); const fakeLogger = require('../../utils/fakeLogger'); const { timeOptions } = require('../../functional/lifecycle/configObjects'); @@ -2464,4 +2466,232 @@ describe('lifecycle task helper methods', () => { }); }); }); + + describe('_sendObjectAction', () => { + let lifecycleTask; + let sentEntries; + let sentCommands; + + function setupTask(mdObj, bucketLocation, metadataError, bucketLocationError, + archiveInfoLocation, archiveInfoError) { + sentEntries = []; + sentCommands = []; + lifecycleTask = new LifecycleTask(lp); + lifecycleTask.objectTasksTopic = 'object-topic'; + lifecycleTask.circuitBreakers = { + tripped: sinon.stub().returns(false), + }; + lifecycleTask.producer = { + sendToTopic: (topic, entries, cb) => { + sentEntries.push({ topic, entries }); + cb(); + }, + }; + lifecycleTask.backbeatMetadataProxy = { + getMetadata: metadataError ? + sinon.stub().yields(metadataError) : + sinon.stub().yields(null, { Body: mdObj.getSerialized() }), + }; + lifecycleTask.s3target = { + send: sinon.stub().callsFake(command => { + sentCommands.push(command); + if (command.constructor.name === 'HeadObjectCommand') { + if (archiveInfoError) { + return Promise.reject(archiveInfoError); + } + return Promise.resolve({ StorageClass: archiveInfoLocation }); + } + if (bucketLocationError) { + return Promise.reject(bucketLocationError); + } + return Promise.resolve({ LocationConstraint: bucketLocation }); + }), + }; + } + + function createExpirationEntry() { + return ActionQueueEntry.create('deleteObject') + .setAttribute('target.owner', 'test-owner') + .setAttribute('target.bucket', 'test-bucket') + .setAttribute('target.accountId', 'test-account') + .setAttribute('target.key', 'test-key') + .setAttribute('details.dataStoreName', 'STANDARD') + .setAttribute('transitionTime', Date.now() - HOUR); + } + + it('should resolve STANDARD from object metadata before emitting trigger metrics', done => { + const mdObj = new ObjectMD() + .setDataStoreName('us-east-1') + .setAmzStorageClass('STANDARD'); + setupTask(mdObj); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'us-east-1'); + assert.strictEqual(lifecycleTask.circuitBreakers.tripped.firstCall.args[1], 'us-east-1'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'us-east-1'); + assert.deepStrictEqual(sentCommands, []); + assert.strictEqual(sentEntries.length, 1); + done(); + }); + }); + + it('should resolve STANDARD from archive info HeadObject when metadata remains STANDARD', done => { + const mdObj = new ObjectMD() + .setDataStoreName('STANDARD') + .setAmzStorageClass('STANDARD'); + setupTask(mdObj, undefined, null, null, 'archive-info-location'); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(lifecycleTask.backbeatMetadataProxy.getMetadata.calledOnce, true); + assert.deepStrictEqual(sentCommands.map(c => c.constructor.name), ['HeadObjectCommand']); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'archive-info-location'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'archive-info-location'); + done(); + }); + }); + + it('should resolve restored object metrics to the cold storage location', done => { + const requestedAt = new Date(Date.now() - (2 * DAY)).toISOString(); + const completedAt = new Date(Date.now() - DAY).toISOString(); + const expireAt = new Date(Date.now() + DAY).toISOString(); + const mdObj = new ObjectMD() + .setDataStoreName('STANDARD') + .setAmzStorageClass('cold-location') + .setLocation([{ + key: 'restored-key', + size: 10, + start: 0, + dataStoreName: 'STANDARD', + dataStoreETag: 'restored-etag', + dataStoreVersionId: '', + }]) + .setArchive({ + archiveInfo: { + archiveId: 'archive-id', + }, + restoreRequestedAt: requestedAt, + restoreRequestedDays: 1, + restoreCompletedAt: completedAt, + restoreWillExpireAt: expireAt, + }); + setupTask(mdObj); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'cold-location'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'cold-location'); + assert.deepStrictEqual(sentCommands, []); + done(); + }); + }); + + it('should fall back to bucket location when metadata location remains STANDARD', done => { + const mdObj = new ObjectMD() + .setDataStoreName('STANDARD') + .setAmzStorageClass('STANDARD'); + setupTask(mdObj, 'bucket-location'); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'bucket-location'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'bucket-location'); + assert.deepStrictEqual(sentCommands.map(c => c.constructor.name), [ + 'HeadObjectCommand', + 'GetBucketLocationCommand', + ]); + done(); + }); + }); + + it('should fall back to bucket location when metadata is unavailable', done => { + const mdObj = new ObjectMD(); + setupTask(mdObj, 'bucket-location', errors.NoSuchKey); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(lifecycleTask.backbeatMetadataProxy.getMetadata.calledOnce, true); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'bucket-location'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'bucket-location'); + assert.deepStrictEqual(sentCommands.map(c => c.constructor.name), [ + 'HeadObjectCommand', + 'GetBucketLocationCommand', + ]); + done(); + }); + }); + + it('should keep original location when metadata and bucket location are unavailable', done => { + const mdObj = new ObjectMD(); + setupTask(mdObj, undefined, errors.NoSuchKey, errors.AccessDenied); + const triggeredMetric = sinon.stub(LifecycleMetrics, 'onLifecycleTriggered'); + + const entry = createExpirationEntry(); + lifecycleTask._sendObjectAction(entry, err => { + assert.ifError(err); + assert.strictEqual(lifecycleTask.backbeatMetadataProxy.getMetadata.calledOnce, true); + assert.strictEqual(entry.getAttribute('details.dataStoreName'), 'STANDARD'); + assert.strictEqual(triggeredMetric.firstCall.args[3], 'STANDARD'); + assert.deepStrictEqual(sentCommands.map(c => c.constructor.name), [ + 'HeadObjectCommand', + 'GetBucketLocationCommand', + ]); + done(); + }); + }); + }); + + describe('_compareObject location metrics', () => { + it('should reuse HeadObject archive info storage class for expiration metrics', done => { + class LifecycleTaskMock extends LifecycleTask { + _sendObjectAction(entry, cb) { + this.latestEntry = entry; + return cb(); + } + } + + const lifecycleTask = new LifecycleTaskMock(lp); + lifecycleTask.s3target = { + send: sinon.stub().resolves({ + LastModified: new Date().toISOString(), + StorageClass: 'archive-info-location', + }), + }; + + const bucketData = { + target: { + owner: 'test-owner', + bucket: 'test-bucket', + accountId: 'test-account', + }, + details: {}, + }; + const rules = { + Expiration: { + Date: new Date(Date.now() - DAY), + }, + }; + + lifecycleTask._compareObject(bucketData, OBJECT, rules, fakeLogger, err => { + assert.ifError(err); + assert.strictEqual(lifecycleTask.s3target.send.calledOnce, true); + assert.strictEqual( + lifecycleTask.latestEntry.getAttribute('details.dataStoreName'), + 'archive-info-location' + ); + done(); + }); + }); + }); }); From 326294c8a1d10aef0f71962544372d469243e24c Mon Sep 17 00:00:00 2001 From: Maha Benzekri Date: Thu, 30 Apr 2026 10:12:27 +0200 Subject: [PATCH 2/2] Use real lifecycle locations for object task metrics. Resolve delete and restored-object expiration metrics from object metadata so restored objects report their cold location. Issue: BB-721 --- .../tasks/LifecycleDeleteObjectTask.js | 6 +++- .../LifecycleDeleteObjectTask.spec.js | 36 +++++++++++++++++++ .../LifecycleUpdateExpirationTask.spec.js | 10 ++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/extensions/lifecycle/tasks/LifecycleDeleteObjectTask.js b/extensions/lifecycle/tasks/LifecycleDeleteObjectTask.js index a8d793be4..d992680de 100644 --- a/extensions/lifecycle/tasks/LifecycleDeleteObjectTask.js +++ b/extensions/lifecycle/tasks/LifecycleDeleteObjectTask.js @@ -5,6 +5,7 @@ const { HeadObjectCommand, AbortMultipartUploadCommand, DeleteObjectCommand } = const BackbeatTask = require('../../../lib/tasks/BackbeatTask'); const { LifecycleMetrics } = require('../LifecycleMetrics'); +const { resolveLifecycleMetricObjectLocation } = require('../util/lifecycleLocation'); const { DeleteObjectFromExpirationCommand, attachReqUids, @@ -195,7 +196,10 @@ class LifecycleDeleteObjectTask extends BackbeatTask { const actionType = entry.getActionType(); const transitionTime = entry.getAttribute('transitionTime'); - const location = this.objectMD?.dataStoreName || entry.getAttribute('details.dataStoreName'); + const location = resolveLifecycleMetricObjectLocation( + this.objectMD, + entry.getAttribute('details.dataStoreName') + ); let reqMethod = 'deleteObject'; let actionMethod = this._deleteObject.bind(this); diff --git a/tests/unit/lifecycle/LifecycleDeleteObjectTask.spec.js b/tests/unit/lifecycle/LifecycleDeleteObjectTask.spec.js index 0c1b0b59d..7a68843b8 100644 --- a/tests/unit/lifecycle/LifecycleDeleteObjectTask.spec.js +++ b/tests/unit/lifecycle/LifecycleDeleteObjectTask.spec.js @@ -7,6 +7,7 @@ const { ObjectMD } = require('arsenal').models; const ActionQueueEntry = require('../../../lib/models/ActionQueueEntry'); const LifecycleDeleteObjectTask = require( '../../../extensions/lifecycle/tasks/LifecycleDeleteObjectTask'); +const { LifecycleMetrics } = require('../../../extensions/lifecycle/LifecycleMetrics'); const day = 1000 * 60 * 60 * 24; @@ -51,6 +52,7 @@ describe('LifecycleDeleteObjectTask', () => { }); afterEach(() => { + sinon.restore(); backbeatMdProxyClient.setError(null); }); @@ -279,6 +281,40 @@ describe('LifecycleDeleteObjectTask', () => { }); }); + it('should emit expiration metrics with restored object cold location', done => { + const requestedAt = new Date(Date.now() - (2 * day)).toISOString(); + const completedAt = new Date(Date.now() - day).toISOString(); + const expireAt = new Date(Date.now() + day).toISOString(); + objMd.setDataStoreName('STANDARD') + .setAmzStorageClass('cold-location') + .setArchive({ + archiveInfo: { + archiveId: 'archive-id', + }, + restoreRequestedAt: requestedAt, + restoreRequestedDays: 1, + restoreCompletedAt: completedAt, + restoreWillExpireAt: expireAt, + }); + const startedMetric = sinon.stub(LifecycleMetrics, 'onLifecycleStarted'); + const completedMetric = sinon.stub(LifecycleMetrics, 'onLifecycleCompleted'); + const entry = ActionQueueEntry.create('deleteObject') + .setAttribute('target.owner', 'testowner') + .setAttribute('target.bucket', 'testbucket') + .setAttribute('target.accountId', 'testid') + .setAttribute('target.key', 'testkey') + .setAttribute('target.version', 'testversion') + .setAttribute('details.dataStoreName', 'STANDARD') + .setAttribute('transitionTime', Date.now() - day); + backbeatClient.setResponse(null, {}); + task.processActionEntry(entry, err => { + assert.ifError(err); + assert.strictEqual(startedMetric.firstCall.args[2], 'cold-location'); + assert.strictEqual(completedMetric.firstCall.args[2], 'cold-location'); + done(); + }); + }); + it('should fallback to deleteObject method if deleteObjectFromExpiration is not supported', done => { const entry = ActionQueueEntry.create('deleteObject') .setAttribute('target.owner', 'testowner') diff --git a/tests/unit/lifecycle/LifecycleUpdateExpirationTask.spec.js b/tests/unit/lifecycle/LifecycleUpdateExpirationTask.spec.js index f7ed09c10..305956cf3 100644 --- a/tests/unit/lifecycle/LifecycleUpdateExpirationTask.spec.js +++ b/tests/unit/lifecycle/LifecycleUpdateExpirationTask.spec.js @@ -1,10 +1,12 @@ const assert = require('assert'); +const sinon = require('sinon'); const werelogs = require('werelogs'); const { ObjectMD } = require('arsenal').models; const ActionQueueEntry = require('../../../lib/models/ActionQueueEntry'); const LifecycleUpdateExpirationTask = require( '../../../extensions/lifecycle/tasks/LifecycleUpdateExpirationTask'); +const { LifecycleMetrics } = require('../../../extensions/lifecycle/LifecycleMetrics'); const { GarbageCollectorProducerMock, @@ -60,7 +62,13 @@ describe('LifecycleUpdateExpirationTask', () => { task = new LifecycleUpdateExpirationTask(objectProcessor); }); + afterEach(() => { + sinon.restore(); + }); + it('should expire restored object', done => { + const startedMetric = sinon.stub(LifecycleMetrics, 'onLifecycleStarted'); + const completedMetric = sinon.stub(LifecycleMetrics, 'onLifecycleCompleted'); const requestedAt = new Date(); const restoreCompletedAt = new Date(); const expireDate = new Date(); @@ -94,6 +102,8 @@ describe('LifecycleUpdateExpirationTask', () => { const receivedGcEntry = gcProducer.getReceivedEntry(); assert.strictEqual(receivedGcEntry.getActionType(), 'deleteData'); assert.deepStrictEqual(receivedGcEntry.getAttribute('target.locations'), oldLocation); + assert.strictEqual(startedMetric.firstCall.args[2], 'location-dmf-v1'); + assert.strictEqual(completedMetric.firstCall.args[2], 'location-dmf-v1'); done(); }); });