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
150 changes: 120 additions & 30 deletions extensions/replication/tasks/ReplicateObject.js
Comment thread
SylvainSenechal marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
const async = require('async');
const { S3Client, GetBucketReplicationCommand, GetObjectCommand } = require('@aws-sdk/client-s3');

const errors = require('arsenal').errors;
const jsutil = require('arsenal').jsutil;
const ObjectMDLocation = require('arsenal').models.ObjectMDLocation;
const ReplicationConfiguration = require('arsenal').models.ReplicationConfiguration;
const { errors, jsutil, versioning } = require('arsenal');
const { ObjectMDLocation } = require('arsenal').models;
const { ReplicationConfiguration } = require('arsenal').models;
const {
encode: encodeMicroVersionId,
decode: decodeMicroVersionId,
compare: compareMicroVersionId,
Ordering,
} = versioning.VersionID;

const ClientManager = require('../../../lib/clients/ClientManager');
const BackbeatMetadataProxy = require('../../../lib/BackbeatMetadataProxy');
const {
const {
BackbeatRoutesClient,
PutDataCommand,
BatchDeleteCommand,
PutMetadataCommand,
GetMetadataCommand,
addContentLengthMiddleware,
attachReqUids,
attachExpectContinueMiddleware,
VersionIdCollisionException,
StaleMicroVersionIdException,
MicroVersionIdAlreadyStoredException,
} = require('@scality/cloudserverclient');

const mapLimitWaitPendingIfError = require('../../../lib/util/mapLimitWaitPendingIfError');
Expand Down Expand Up @@ -452,11 +461,32 @@ class ReplicateObject extends BackbeatTask {
const mpuConcLimit = this.repConfig.queueProcessor.mpuPartsConcurrency;
return mapLimitWaitPendingIfError(locations, mpuConcLimit, (part, done) => {
this._getAndPutPart(sourceEntry, destEntry, part, log, done);
}, (err, destLocations) => {
if (err) {
return this._deleteOrphans(destEntry, destLocations, log, () => cb(err));
}, (err, partResults) => {
let collisionResult;
const nonCollisionParts = [];
for (const result of (partResults || [])) {
if (!result) {
continue;
}
if (result.isCollision) {
collisionResult = collisionResult || result;
} else {
nonCollisionParts.push(result);
}
}
return cb(null, destLocations);
const hasPutDataConflict = collisionResult !== undefined;
if (err || hasPutDataConflict) {
// On error or conflict, drop all parts written
return this._deleteOrphans(destEntry, nonCollisionParts, log, () => {
if (hasPutDataConflict) {
return cb(null, [], {
microVersionId: collisionResult.destinationMicroVersionId,
});
}
return cb(err);
});
}
return cb(null, partResults, undefined);
});
}

Expand Down Expand Up @@ -570,7 +600,9 @@ class ReplicateObject extends BackbeatTask {
// destination bucket has to be versioning enabled.
VersioningRequired: true,
RequestUids: log.getSerializedUids(),
VersionId: sourceEntry.getEncodedVersionId(),
});
attachExpectContinueMiddleware(putCommand, this.backbeatDest.config?.requestHandler);
addContentLengthMiddleware(
putCommand,
response.ContentLength,
Expand Down Expand Up @@ -600,6 +632,17 @@ class ReplicateObject extends BackbeatTask {
incomingMsg.destroy();
}
}

if (err instanceof VersionIdCollisionException) {
log.info('cascade putData: data already at destination', {
method: 'ReplicateObject._getAndPutPartOnce',
entry: destEntry.getLogInfo(),
});
return doneOnce(null, {
isCollision: true,
destinationMicroVersionId: err.microVersionId,
});
}
// eslint-disable-next-line no-param-reassign
err.origin = 'target';
log.error('an error occurred on putData to S3',
Expand Down Expand Up @@ -657,9 +700,9 @@ class ReplicateObject extends BackbeatTask {
accountId = _extractAccountIdFromRole(this.targetRole);
}

// sends extra header x-scal-replication-content to the target
// if it's a metadata operation only
const replicationContent = (mdOnly ? 'METADATA' : undefined);
// Send x-scal-replication-content so cloudserver know the putMetadata api
// is used in the context of a replication
const replicationContent = (mdOnly ? 'METADATA' : 'DATA,METADATA');

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.

This is required as otherwise, in non mdOnly situation, there is no replicationContent header, and cloudserver has no way no know we are calling putMetadata in the context of a replication.

Anyways i think sending data, metadata for non mdOnly should've been added earlier.

const mdBlob = entry.getSerialized();
const command = new PutMetadataCommand({
Bucket: entry.getBucket(),
Expand All @@ -671,6 +714,8 @@ class ReplicateObject extends BackbeatTask {
// destination bucket has to be versioning enabled.
VersioningRequired: true,
RequestUids: log.getSerializedUids(),
MicroVersionId: entry.getMicroVersionId()
? encodeMicroVersionId(entry.getMicroVersionId()) : '',
Comment on lines +717 to +718

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the empty-string convention is one of the open topics on scality/cloudserver#6179. Is the shape settled now?

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.

yep this is very intentional, using undefined would cause the sdk to not even send the header and would break the cascade replication. A bit fragile if you ask me but this is the current design

});
const writeStartTime = Date.now();
return this.backbeatDest.send(command)
Expand All @@ -681,7 +726,9 @@ class ReplicateObject extends BackbeatTask {
.catch(err => {
// eslint-disable-next-line no-param-reassign
err.origin = 'target';
if (err.ObjNotFound || err.name === 'ObjNotFound') {
if (err.ObjNotFound || err.name === 'ObjNotFound' ||
err instanceof MicroVersionIdAlreadyStoredException ||
err instanceof StaleMicroVersionIdException) {
return cbOnce(err);
}
log.error('an error occurred when putting metadata to S3',
Comment thread
SylvainSenechal marked this conversation as resolved.
Expand Down Expand Up @@ -920,16 +967,27 @@ class ReplicateObject extends BackbeatTask {
return this._getAndPutData(sourceEntry, destEntry, log,
next);
}
return next(null, []);
return next(null, [], undefined);
},
// update location, replication status and put metadata in
// target bucket
(destLocations, next) => {
(destLocations, conflict, next) => {
if (this._shouldSkipMetadata(sourceEntry.getMicroVersionId(), conflict, log)) {
log.info('skipping putMetadata: destination already has same or newer revision', {
entry: destEntry.getLogInfo(),
});
return next();
}
// METADATA tells cloudserver to UPDATE the existing metadata document
// (preserving the stored location). DATA,METADATA tells cloudserver to
// CREATE a new document. Use METADATA when:
// - this is a metadata-only replication entry (mdOnly), or
// - data was already written to the destination from another path (conflict).
const localMdOnly = mdOnly || conflict !== undefined;
destEntry.setLocation(destLocations);
this._putMetadata(destEntry, mdOnly, log, err => {
return this._putMetadata(destEntry, localMdOnly, log, err => {
if (err) {
return this._deleteOrphans(
destEntry, destLocations, log, () => next(err));
return this._deleteOrphans(destEntry, destLocations, log, () => next(err));
}
return next();
});
Expand All @@ -938,30 +996,63 @@ class ReplicateObject extends BackbeatTask {
err, sourceEntry, destEntry, kafkaEntry, log, done));
}

_processQueueEntryRetryFull(sourceEntry, destEntry, kafkaEntry, log, done) {
log.debug('reprocessing entry as full replication',
{ entry: sourceEntry.getLogInfo() });
// Returns true if putMetadata can be skipped because the destination already
// holds this revision or a newer one. Returns false when there is no conflict,
// when the destination microVersionId is absent or can't be parsed (proceed
// conservatively), or when the source holds a newer revision.
_shouldSkipMetadata(sourceMicroVersionId, conflict, log) {

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.

Discuss : Could be moved into putMetada so that we don't have to call this from 2 places, and instead inline it.

But imo it's a weird pattern to add an extra param to a function, then call that function and use that extra param at the top to decide to potentially not run this function

if (!conflict) {
return false;
}
let destMvId = null;
if (conflict.microVersionId) {
const decoded = decodeMicroVersionId(conflict.microVersionId);
if (decoded instanceof Error) {
log.warn('could not decode microVersionId from putData 409, ' +
'proceeding to putMetadata without skip optimisation', {
error: decoded.message,
});
} else {
destMvId = decoded;
}
}
const comparison = compareMicroVersionId(sourceMicroVersionId, destMvId);
return destMvId !== null &&

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.

May completely remove the null check if I end up update the compareMicroVersionId function.
Still reviewing cloudserver, haven't done it/decided yet

(comparison === Ordering.OLDER || comparison === Ordering.EQUAL);
}

_processQueueEntryRetryFull(sourceEntry, destEntry, kafkaEntry, log, done) {
return async.waterfall([
next => this._getAndPutData(sourceEntry, destEntry, log, next),
// update location, replication status and put metadata in
// target bucket
(location, next) => {
destEntry.setLocation(location);
this._putMetadata(destEntry, false, log, next);
(destLocations, conflict, next) => {
if (this._shouldSkipMetadata(sourceEntry.getMicroVersionId(), conflict, log)) {
log.info('skipping putMetadata: destination already has same or newer revision', {
entry: destEntry.getLogInfo(),
});
return next();
}
const localMdOnly = conflict !== undefined;
destEntry.setLocation(destLocations);
return this._putMetadata(destEntry, localMdOnly, log, next);
},
], err => this._handleReplicationOutcome(
err, sourceEntry, destEntry, kafkaEntry, log, done));
}

_handleReplicationOutcome(err, sourceEntry, destEntry, kafkaEntry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This section has quite a bit of nested conditional logic and duplicate checks that make it hard to read and maintain.

Could we flatten this using guard clauses (early returns) and abstract the err.XYZ || err.name === 'XYZ' checks into a helper function? It would drastically reduce the cognitive load of this function. Let me know if you want to pair on it!

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.

I agree its trash code and the diff is hard to read with the last else

I just changed it and tried something that i didn't want to do first but i think it's fine : for each condition, directly publish/return, without having to do it at the end of the function. I believe its quite readable this way

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.

Could still use some refactor maybe, although all the erro don't have the same form, I think the diff is reasonnable here

log, done) {
if (err instanceof MicroVersionIdAlreadyStoredException ||
err instanceof StaleMicroVersionIdException) {
log.info('replication completed: object already at destination',
{ entry: sourceEntry.getLogInfo(), reason: err.name });
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
return done(null, { committable: false });
}
if (!err) {
log.debug('replication succeeded for object, publishing ' +
'replication status as COMPLETED',
{ entry: sourceEntry.getLogInfo() });
this._publishReplicationStatus(
sourceEntry, 'COMPLETED', { kafkaEntry, log });
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
return done(null, { committable: false });
}
if (err.BadRole || err.name === 'BadRole' ||
Expand Down Expand Up @@ -991,8 +1082,7 @@ class ReplicateObject extends BackbeatTask {
{ entry: sourceEntry.getLogInfo() });
return done();
}
log.info('target object version does not exist, retrying ' +
'a full replication',
log.info('replication target object not found, retrying with full data write',
{ entry: sourceEntry.getLogInfo() });
// TODO: Is this the right place to capture retry metrics?
return this._processQueueEntryRetryFull(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@aws-sdk/client-s3": "^3.921.0",
"@aws-sdk/client-sts": "^3.921.0",
"@aws-sdk/credential-providers": "^3.921.0",
"@scality/cloudserverclient": "^1.0.8",
"@scality/cloudserverclient": "^1.0.9",
"@smithy/node-http-handler": "^3.3.3",
"JSONStream": "^1.3.5",
"arsenal": "git+https://github.com/scality/arsenal#8.5.2",
Expand Down
Loading
Loading