Skip to content
Open
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
6 changes: 5 additions & 1 deletion extensions/gc/tasks/GarbageCollectorTask.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
francoisferrand marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const isDirect = objMD.getAmzStorageClass() === newLocation;
const isDirectToCold = objMD.getAmzStorageClass() === newLocation;

Comment thread
francoisferrand marked this conversation as resolved.

objMD.setLocation()
.setDataStoreName(newLocation)
.setAmzStorageClass(newLocation)
.setOriginOp('s3:LifecycleTransition')
.setOriginOp(isDirect ? 's3:LifecycleTransition:Direct' : 's3:LifecycleTransition')
Comment thread
francoisferrand marked this conversation as resolved.
.setTransitionInProgress(false)
.setUserMetadata({
'x-amz-meta-scal-s3-transition-attempt': undefined,
Expand Down
182 changes: 156 additions & 26 deletions extensions/lifecycle/LifecycleQueuePopulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
coldStorageRestoreAdjustTopicPrefix,
coldStorageRestoreTopicPrefix,
coldStorageGCTopicPrefix,
coldStorageArchiveTopicPrefix,
} = config.extensions.lifecycle;
const BackbeatProducer = require('../../lib/BackbeatProducer');
const locations = require('../../conf/locationConfig.json') || {};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_isDirectTransition(value) {
_isDirectToColdTransition(value) {

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) {
Comment thread
francoisferrand marked this conversation as resolved.
// accountId is mandatory in the message, for Sorbet to assume the bucket owner's role

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Comment thread
francoisferrand marked this conversation as resolved.
if (this._isVersionedObject(value) && isMasterKey(entry.key)) {
Comment thread
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'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add " ? Not sure about this one here

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;
}

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 ?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

const isObjectAlreadyRestored = !!value.archive && !!value.archive.restoreCompletedAt;
if (isObjectAlreadyRestored) {
this._adjustRestoreMaxAge(value);

// 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', {
Expand All @@ -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,
Expand All @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion extensions/lifecycle/tasks/LifecycleColdStatusArchiveTask.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ class LifecycleColdStatusArchiveTask extends LifecycleUpdateTransitionTask {
objectMD.setOriginOp('s3:LifecycleTransition:SetArchive');

if (skipLocationDeletion) {
// The object already exposes the location it is transitioned to, so this
// is a direct-to-cold transition.
const isDirect = objectMD.getAmzStorageClass() === coldLocation;

objectMD.setDataStoreName(coldLocation)
.setAmzStorageClass(coldLocation)
.setTransitionInProgress(false)
.setOriginOp('s3:LifecycleTransition')
.setOriginOp(isDirect ? 's3:LifecycleTransition:Direct' : 's3:LifecycleTransition')
.setUserMetadata({
'x-amz-meta-scal-s3-transition-attempt': undefined,
});
Expand Down
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 {
/**
Expand All @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* declared in the object metadata, while the data still lies in a hot location.
* Check that the object is a direct to cold transition (without lifecycle trigger)

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_isDirectTransition(md) {
_isDirectToColdTransition(md) {

return locationsConfig[md.getAmzStorageClass()]?.isCold
&& !locationsConfig[md.getDataStoreName()]?.isCold;
}

shouldSkipObject(md, expectedEtag, log) {
try {
const etag = JSON.parse(expectedEtag);
Expand Down
7 changes: 7 additions & 0 deletions extensions/lifecycle/tasks/LifecycleTask.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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()) {
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/gc/GarbageCollectorTask.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,47 @@ describe('GarbageCollectorTask', () => {
assert.strictEqual(updatedMD.getDataStoreName(), 'new-location');
assert.strictEqual(updatedMD.getAmzStorageClass(), 'new-location');
assert.strictEqual(updatedMD.getTransitionInProgress(), false);
assert.strictEqual(updatedMD.getOriginOp(), 's3:LifecycleTransition');
done();
});
});

it('should set the direct transition origin op if the new location was requested', done => {
backbeatClient.batchDeleteResponse = { error: null, res: null };

const entry = ActionQueueEntry.create('deleteArchivedSourceData')
.addContext({
origin: 'lifecycle',
ruleType: 'archive',
bucketName: bucket,
objectKey: key,
versionId: version,
})
.setAttribute('serviceName', 'lifecycle-transition')
.setAttribute('target.oldLocation', 'old-location')
.setAttribute('target.newLocation', 'new-location')
.setAttribute('target.bucket', bucket)
.setAttribute('target.key', version)
.setAttribute('target.version', key)
.setAttribute('target.accountId', accountId)
.setAttribute('target.owner', owner);

mdObj.setLocation(loc)
.setDataStoreName('old-location')
.setAmzStorageClass('new-location')
.setTransitionInProgress(true);
backbeatMetadataProxyClient.setMdObj(mdObj);

gcTask.processActionEntry(entry, err => {
assert.ifError(err);

const updatedMD = backbeatMetadataProxyClient.mdObj;
assert.strictEqual(updatedMD.getDataStoreName(), 'new-location');
assert.strictEqual(updatedMD.getTransitionInProgress(), false);
assert.strictEqual(updatedMD.getOriginOp(), 's3:LifecycleTransition:Direct');
done();
});
});

it('should delete archived location info if gc failed with 404', done => {
backbeatClient.batchDeleteResponse = { error: { statusCode: 404 }, res: null };
Expand Down
Loading
Loading