-
Notifications
You must be signed in to change notification settings - Fork 23
BB-786: trigger cold transition from oplog #2830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development/9.6
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -286,10 +286,14 @@ class GarbageCollectorTask extends BackbeatTask { | |||||
| version, | ||||||
| }); | ||||||
|
|
||||||
| // The object already exposes the location it is transitioned to, so this is a | ||||||
| // direct-to-cold transition. | ||||||
| const isDirect = objMD.getAmzStorageClass() === newLocation; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
francoisferrand marked this conversation as resolved.
|
||||||
|
|
||||||
| objMD.setLocation() | ||||||
| .setDataStoreName(newLocation) | ||||||
| .setAmzStorageClass(newLocation) | ||||||
| .setOriginOp('s3:LifecycleTransition') | ||||||
| .setOriginOp(isDirect ? 's3:LifecycleTransition:Direct' : 's3:LifecycleTransition') | ||||||
|
francoisferrand marked this conversation as resolved.
|
||||||
| .setTransitionInProgress(false) | ||||||
| .setUserMetadata({ | ||||||
| 'x-amz-meta-scal-s3-transition-attempt': undefined, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,6 +20,7 @@ const { | |||||||
| coldStorageRestoreAdjustTopicPrefix, | ||||||||
| coldStorageRestoreTopicPrefix, | ||||||||
| coldStorageGCTopicPrefix, | ||||||||
| coldStorageArchiveTopicPrefix, | ||||||||
| } = config.extensions.lifecycle; | ||||||||
| const BackbeatProducer = require('../../lib/BackbeatProducer'); | ||||||||
| const locations = require('../../conf/locationConfig.json') || {}; | ||||||||
|
|
@@ -100,6 +101,7 @@ class LifecycleQueuePopulator extends QueuePopulatorExtension { | |||||||
| next => this._setupProducer(`${coldStorageRestoreAdjustTopicPrefix}${location}`, next), | ||||||||
| next => this._setupProducer(`${coldStorageRestoreTopicPrefix}${location}`, next), | ||||||||
| next => this._setupProducer(`${coldStorageGCTopicPrefix}${location}`, next), | ||||||||
| next => this._setupProducer(`${coldStorageArchiveTopicPrefix}${location}`, next), | ||||||||
| ], done); | ||||||||
| }, cb); | ||||||||
| } else { | ||||||||
|
|
@@ -239,23 +241,130 @@ class LifecycleQueuePopulator extends QueuePopulatorExtension { | |||||||
| return new Date(date.$date || date); | ||||||||
| } | ||||||||
|
|
||||||||
| _handleRestoreOp(entry) { | ||||||||
| _isColdLocation(locationName) { | ||||||||
| return !!this.locationConfigs[locationName]?.isCold; | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * A transition is "direct" when the object declares a cold storage class while its data still | ||||||||
| * sits in a hot location, it has not been archived yet, and cloudserver has flagged it as in | ||||||||
| * progress. | ||||||||
| * | ||||||||
| * @param {Object} value - The object metadata. | ||||||||
| * @return {boolean} true if a direct transition is pending for this object. | ||||||||
| */ | ||||||||
| _isDirectTransition(value) { | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I think it's important because it's how the product call it and we miss context without it. |
||||||||
| return value['x-amz-scal-transition-in-progress'] | ||||||||
| && this._isColdLocation(value['x-amz-storage-class']) | ||||||||
| && !this._isColdLocation(value.dataStoreName) | ||||||||
| && !value.archive?.archiveInfo; | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Handle a "direct-to-cold" transition: cloudserver has written the object data to a hot | ||||||||
| * location, but the user requested a cold storage class in the PUT request. Cloudserver | ||||||||
| * flags the object as "transition in progress", and it is up to the queue populator to | ||||||||
| * trigger the archival, as there is no lifecycle rule (nor lifecycle scan) involved. | ||||||||
| * | ||||||||
| * The message published here is strictly the same as the one the lifecycle bucket processor | ||||||||
| * publishes (c.f. ReplicationAPI.sendDataMoverAction), so that the whole downstream pipeline | ||||||||
| * (Sorbet, cold status processor, garbage collector) is reused unchanged. | ||||||||
| * | ||||||||
| * @param {Object} entry - The record log entry from metadata. | ||||||||
| * @param {Object} [value] - The object metadata, already decoded by the caller. | ||||||||
| * @return {undefined} | ||||||||
| */ | ||||||||
| _handleTransitionOp(entry, value) { | ||||||||
|
francoisferrand marked this conversation as resolved.
|
||||||||
| // accountId is mandatory in the message, for Sorbet to assume the bucket owner's role | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure what the goal of this comment ? why accountId ? It is managed by vaultClientWrapper ? Is it useful to check that here ? As we'll have an error message later |
||||||||
| if (!this.vaultClientWrapper) { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| if (entry.type !== 'put' || | ||||||||
| entry.key.startsWith(mpuBucketPrefix)) { | ||||||||
| if (entry.key.startsWith(mpuBucketPrefix)) { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| const value = JSON.parse(entry.value); | ||||||||
| if (!this._isDirectTransition(value)) { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| // if entry is a versioned object and is the master entry, skip task as | ||||||||
| // the non-master entry will be processed | ||||||||
|
francoisferrand marked this conversation as resolved.
|
||||||||
| if (this._isVersionedObject(value) && isMasterKey(entry.key)) { | ||||||||
|
francoisferrand marked this conversation as resolved.
|
||||||||
| this.log.trace('skip processing of object master entry'); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| const coldLocation = value['x-amz-storage-class']; | ||||||||
| const attemptHeader = value['x-amz-meta-scal-s3-transition-attempt']; | ||||||||
| const attempt = attemptHeader ? Number.parseInt(attemptHeader, 10) : undefined; | ||||||||
| const ownerId = value['owner-id']; | ||||||||
| this.vaultClientWrapper.getAccountId(ownerId, (err, accountId) => { | ||||||||
| if (err) { | ||||||||
| this.log.error('unable to get account', { | ||||||||
| method: 'LifecycleQueuePopulator._handleTransitionOp', | ||||||||
| ownerId, | ||||||||
| err, | ||||||||
| }); | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| this.log.trace( | ||||||||
| 'publishing object transition entry', | ||||||||
| { bucket: entry.bucket, key: entry.key, version: value.versionId, coldLocation }, | ||||||||
| ); | ||||||||
|
|
||||||||
| const topic = `${coldStorageArchiveTopicPrefix}${coldLocation}`; | ||||||||
| const key = `${entry.bucket}/${entry.key}`; | ||||||||
|
|
||||||||
| let version; | ||||||||
| if (value.versionId) { | ||||||||
| version = encode(value.versionId); | ||||||||
| } | ||||||||
|
|
||||||||
| const operation = value.originOp; | ||||||||
| // supporting both 's3:ObjectRestore' and 's3:ObjectRestore:Post' to keep | ||||||||
| // compatibility with older cloudserver versions, the switch to 's3:ObjectRestore:Post' | ||||||||
| // was made to have the correct event type for bucket notifications | ||||||||
| if (!['s3:ObjectRestore', 's3:ObjectRestore:Post', 's3:ObjectRestore:Retry'].includes(operation)) { | ||||||||
| const transitionTime = this._parseDate( | ||||||||
| value['x-amz-scal-transition-time'] || value['last-modified']); | ||||||||
| const message = JSON.stringify({ | ||||||||
| accountId, | ||||||||
| bucketName: entry.bucket, | ||||||||
| objectKey: value.key, | ||||||||
| objectVersion: version, | ||||||||
| requestId: uuid(), | ||||||||
| size: value['content-length'], | ||||||||
| eTag: value['content-md5'], | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add |
||||||||
| try: attempt, | ||||||||
| transitionTime: transitionTime.toISOString(), | ||||||||
| }); | ||||||||
|
|
||||||||
| const producer = this._producers[topic]; | ||||||||
| if (producer) { | ||||||||
| LifecycleMetrics.onLifecycleTriggered(this.log, 'queuePopulator', 'archive', | ||||||||
| coldLocation, Date.now() - transitionTime.getTime()); | ||||||||
|
|
||||||||
| const kafkaEntry = { key: encodeURIComponent(key), message }; | ||||||||
| producer.send([kafkaEntry], err => { | ||||||||
| LifecycleMetrics.onKafkaPublish(this.log, 'ColdStorageArchiveTopic', 'queuePopulator', err, 1); | ||||||||
| if (err) { | ||||||||
| this.log.error('error publishing object transition request entry', { | ||||||||
| error: err, | ||||||||
| method: 'LifecycleQueuePopulator._handleTransitionOp', | ||||||||
| }); | ||||||||
| } | ||||||||
| }); | ||||||||
| } else { | ||||||||
| this.log.error(`producer not available for location ${coldLocation}`, { | ||||||||
| method: 'LifecycleQueuePopulator._handleTransitionOp', | ||||||||
| }); | ||||||||
| } | ||||||||
| }); | ||||||||
| } | ||||||||
|
|
||||||||
| _handleRestoreOp(entry, value) { | ||||||||
| if (!this.vaultClientWrapper) { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
| if (entry.key.startsWith(mpuBucketPrefix)) { | ||||||||
| return; | ||||||||
| } | ||||||||
|
|
||||||||
|
|
@@ -504,7 +613,42 @@ class LifecycleQueuePopulator extends QueuePopulatorExtension { | |||||||
| return undefined; | ||||||||
| } | ||||||||
|
|
||||||||
| this._handleRestoreOp(entry); | ||||||||
| // The entry is decoded once here, then dispatched to the single handler which may be | ||||||||
| // interested in it: most entries are of no interest to any of them. | ||||||||
| const { error, result: value } = safeJsonParse(entry.value); | ||||||||
| if (error) { | ||||||||
| this.log.error('could not parse log entry', { | ||||||||
| method: 'LifecycleQueuePopulator.filter', | ||||||||
| bucket: entry.bucket, | ||||||||
| key: entry.key, | ||||||||
| error, | ||||||||
| }); | ||||||||
| return undefined; | ||||||||
| } | ||||||||
|
|
||||||||
| switch (value.originOp) { | ||||||||
| // supporting both 's3:ObjectRestore' and 's3:ObjectRestore:Post' to keep compatibility with | ||||||||
| // older cloudserver versions, the switch to 's3:ObjectRestore:Post' was made to have the | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you define older ? When the switch was done (because if it's an old one, can we consider it as done and just don't manage old message ?)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not easily. This is not new code, this code (comment) was moved from backbeat/extensions/lifecycle/LifecycleQueuePopulator.js Lines 384 to 386 in 85b3f9f
|
||||||||
| // correct event type for bucket notifications | ||||||||
| case 's3:ObjectRestore': | ||||||||
| case 's3:ObjectRestore:Post': | ||||||||
| case 's3:ObjectRestore:Retry': | ||||||||
| this._handleRestoreOp(entry, value); | ||||||||
| break; | ||||||||
|
|
||||||||
| // Object creation is the only operation which may declare a cold storage class, and a retry | ||||||||
| // asks for a new attempt: any other originOp comes from backbeat itself, and must not | ||||||||
| // re-trigger a transition. | ||||||||
| case 's3:ObjectCreated:Put': | ||||||||
| case 's3:ObjectCreated:CompleteMultipartUpload': | ||||||||
| case 's3:ObjectCreated:Copy': | ||||||||
| case 's3:LifecycleTransition:Retry': | ||||||||
| this._handleTransitionOp(entry, value); | ||||||||
| break; | ||||||||
|
|
||||||||
| default: | ||||||||
| break; | ||||||||
| } | ||||||||
|
|
||||||||
| if (this.extConfig.conductor.bucketSource !== 'zookeeper') { | ||||||||
| this.log.debug('bucket source is not zookeeper, skipping entry', { | ||||||||
|
|
@@ -515,15 +659,7 @@ class LifecycleQueuePopulator extends QueuePopulatorExtension { | |||||||
|
|
||||||||
| let bucketValue = {}; | ||||||||
| if (this._isBucketEntryFromBucketd(entry)) { | ||||||||
| const parsedEntry = safeJsonParse(entry.value); | ||||||||
| if (parsedEntry.error) { | ||||||||
| this.log.error('could not parse raft log entry', { | ||||||||
| value: entry.value, | ||||||||
| error: parsedEntry.error, | ||||||||
| }); | ||||||||
| return undefined; | ||||||||
| } | ||||||||
| const parsedAttr = safeJsonParse(parsedEntry.result.attributes); | ||||||||
| const parsedAttr = safeJsonParse(value.attributes); | ||||||||
| if (parsedAttr.error) { | ||||||||
| this.log.error('could not parse raft log entry attribute', { | ||||||||
| value: entry.value, | ||||||||
|
|
@@ -533,13 +669,7 @@ class LifecycleQueuePopulator extends QueuePopulatorExtension { | |||||||
| } | ||||||||
| bucketValue = parsedAttr.result; | ||||||||
| } else if (this._isBucketEntryFromFileMD(entry)) { | ||||||||
| const { error, result } = safeJsonParse(entry.value); | ||||||||
| if (error) { | ||||||||
| this.log.error('could not parse file md log entry', | ||||||||
| { value: entry.value, error }); | ||||||||
| return undefined; | ||||||||
| } | ||||||||
| bucketValue = result; | ||||||||
| bucketValue = value; | ||||||||
| } else { | ||||||||
| // not a bucket entry | ||||||||
| return undefined; | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||
| 'use strict'; | ||||||
|
|
||||||
| const { LifecycleRequeueTask } = require('./LifecycleRequeueTask'); | ||||||
| const locationsConfig = require('../../../conf/locationConfig.json') || {}; | ||||||
|
|
||||||
| class LifecycleResetTransitionInProgressTask extends LifecycleRequeueTask { | ||||||
| /** | ||||||
|
|
@@ -18,13 +19,30 @@ class LifecycleResetTransitionInProgressTask extends LifecycleRequeueTask { | |||||
| return false; | ||||||
| } | ||||||
| md.setOriginOp('s3:LifecycleTransition:Retry'); | ||||||
| md.setTransitionInProgress(false); | ||||||
| // Keep the flag set for a direct transition: the queue populator keys on it to trigger | ||||||
| // the next attempt. | ||||||
| if (!this._isDirectTransition(md)) { | ||||||
| md.setTransitionInProgress(false); | ||||||
| } | ||||||
| md.setUserMetadata({ | ||||||
| 'x-amz-meta-scal-s3-transition-attempt': try_, | ||||||
| }); | ||||||
| return true; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Check whether the object transition was requested directly in the PUT request (as opposed | ||||||
| * to being triggered by a lifecycle rule): in that case the requested cold storage class is | ||||||
| * declared in the object metadata, while the data still lies in a hot location. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
The next of the description describe the inside of the function |
||||||
| * | ||||||
| * @param {ObjectMD} md - object metadata | ||||||
| * @return {boolean} true if this is a pending direct transition | ||||||
| */ | ||||||
| _isDirectTransition(md) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| return locationsConfig[md.getAmzStorageClass()]?.isCold | ||||||
| && !locationsConfig[md.getDataStoreName()]?.isCold; | ||||||
| } | ||||||
|
|
||||||
| shouldSkipObject(md, expectedEtag, log) { | ||||||
| try { | ||||||
| const etag = JSON.parse(expectedEtag); | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,8 @@ const errorTransitionInProgress = errors.InternalError. | |
| customizeDescription('transition is currently in progress'); | ||
| const errorTransitionColdObject = errors.InternalError. | ||
| customizeDescription('transitioning a cold object is forbidden'); | ||
| const errorTransitionDeclaredColdObject = errors.InternalError. | ||
| customizeDescription('transitioning an object declared as cold is forbidden'); | ||
| const errorObjectTemporarilyRestored = errors.InternalError. | ||
| customizeDescription('object temporarily restored'); | ||
| const errorReplicationInProgress = errors.InternalError. | ||
|
|
@@ -1276,6 +1278,11 @@ class LifecycleTask extends BackbeatTask { | |
| if (isObjectCold) { | ||
| return next(errorTransitionColdObject); | ||
| } | ||
| // Skip direct-to-cold objects, whose transition is triggered by the queue | ||
| // populator and require the transition in progress flag to be set | ||
| if (locationsConfig[objectMD.getAmzStorageClass()]?.isCold) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| return next(errorTransitionDeclaredColdObject); | ||
| } | ||
| // If transition is in progress, do not re-publish entry | ||
| // to data-mover or cold-archive topic. | ||
| if (objectMD.getTransitionInProgress()) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.