Skip to content

Commit 7d2eda2

Browse files
Add Crr Cascade capabilities to backbeat crr replication
Issue: BB-767
1 parent 7342dfa commit 7d2eda2

4 files changed

Lines changed: 414 additions & 28 deletions

File tree

extensions/replication/tasks/ReplicateObject.js

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
const async = require('async');
22
const { S3Client, GetBucketReplicationCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
33

4-
const errors = require('arsenal').errors;
5-
const jsutil = require('arsenal').jsutil;
6-
const ObjectMDLocation = require('arsenal').models.ObjectMDLocation;
7-
const ReplicationConfiguration = require('arsenal').models.ReplicationConfiguration;
4+
const { errors, jsutil, versioning } = require('arsenal');
5+
const { ObjectMDLocation } = require('arsenal').models;
6+
const { ReplicationConfiguration } = require('arsenal').models;
7+
const { encode: encodeMicroVersionId, decode } = versioning.VersionID;
88

99
const ClientManager = require('../../../lib/clients/ClientManager');
1010
const BackbeatMetadataProxy = require('../../../lib/BackbeatMetadataProxy');
11-
const {
11+
const {
1212
BackbeatRoutesClient,
1313
PutDataCommand,
1414
BatchDeleteCommand,
1515
PutMetadataCommand,
1616
GetMetadataCommand,
1717
addContentLengthMiddleware,
1818
attachReqUids,
19+
attachExpectContinueMiddleware,
20+
VersionIdCollisionException,
21+
StaleMicroVersionIdException,
22+
MicroVersionIdAlreadyStoredException,
1923
} = require('@scality/cloudserverclient');
2024

2125
const mapLimitWaitPendingIfError = require('../../../lib/util/mapLimitWaitPendingIfError');
@@ -31,6 +35,7 @@ const ObjectQueueEntry = require('../../../lib/models/ObjectQueueEntry');
3135
const { authTypeAssumeRole } = require('../../../lib/constants');
3236

3337
const errorAlreadyCompleted = {};
38+
const partAlreadyAtDest = {};
3439

3540
function _extractAccountIdFromRole(role) {
3641
return role.split(':')[4];
@@ -452,11 +457,18 @@ class ReplicateObject extends BackbeatTask {
452457
const mpuConcLimit = this.repConfig.queueProcessor.mpuPartsConcurrency;
453458
return mapLimitWaitPendingIfError(locations, mpuConcLimit, (part, done) => {
454459
this._getAndPutPart(sourceEntry, destEntry, part, log, done);
455-
}, (err, destLocations) => {
460+
}, (err, partResults) => {
461+
// partAlreadyAtDest signals data already at dest (cascade putData 409);
462+
// exclude it so it is not stored as a new location or orphan-deleted.
463+
const destLocations = (partResults || [])
464+
.filter(result => result && result !== partAlreadyAtDest);
456465
if (err) {
457466
return this._deleteOrphans(destEntry, destLocations, log, () => cb(err));
458467
}
459-
return cb(null, destLocations);
468+
// When no new data locations were written, force metadata-only mode (noNewDataLocations)
469+
// so cloudserver preserves the destination's existing location field instead of overwriting it
470+
const noNewDataLocations = destLocations.length === 0;
471+
return cb(null, destLocations, noNewDataLocations);
460472
});
461473
}
462474

@@ -570,7 +582,9 @@ class ReplicateObject extends BackbeatTask {
570582
// destination bucket has to be versioning enabled.
571583
VersioningRequired: true,
572584
RequestUids: log.getSerializedUids(),
585+
VersionId: sourceEntry.getEncodedVersionId(),
573586
});
587+
attachExpectContinueMiddleware(putCommand, this.backbeatDest.config?.requestHandler);
574588
addContentLengthMiddleware(
575589
putCommand,
576590
response.ContentLength,
@@ -600,6 +614,12 @@ class ReplicateObject extends BackbeatTask {
600614
incomingMsg.destroy();
601615
}
602616
}
617+
618+
if (err instanceof VersionIdCollisionException) {
619+
const { err: collisionErr, result } = this._resolveVersionIdCollision(
620+
err, sourceEntry, destEntry, log);
621+
return doneOnce(collisionErr, result);
622+
}
603623
// eslint-disable-next-line no-param-reassign
604624
err.origin = 'target';
605625
log.error('an error occurred on putData to S3',
@@ -642,6 +662,43 @@ class ReplicateObject extends BackbeatTask {
642662
});
643663
}
644664

665+
_resolveVersionIdCollision(collisionErr, sourceEntry, destEntry, log) {
666+
const logMeta = {
667+
method: 'ReplicateObject._resolveVersionIdCollision',
668+
entry: destEntry.getLogInfo(),
669+
};
670+
const decodedDestinationMicroVersionId = collisionErr.microVersionId
671+
? decode(collisionErr.microVersionId) : null;
672+
if (decodedDestinationMicroVersionId instanceof Error) {
673+
log.error('failed to decode microVersionId from putData 409',
674+
{ ...logMeta, error: decodedDestinationMicroVersionId.message });
675+
return { err: decodedDestinationMicroVersionId };
676+
}
677+
// null = oldest revision (original putObject, before any metadata update).
678+
// VersionID strings are in reverse chronological order: a lexicographically
679+
// larger string is an older revision.
680+
const isSourceOlderThanDestination = (sourceMicroVersionId, destinationMicroVersionId) =>
681+
destinationMicroVersionId !== null &&
682+
(sourceMicroVersionId === null || sourceMicroVersionId > destinationMicroVersionId);
683+
684+
// Treat '' and undefined as null (no microVersionId set).
685+
const sourceMicroVersionId = sourceEntry.getMicroVersionId() || null;
686+
const destinationMicroVersionId = decodedDestinationMicroVersionId || null;
687+
if (sourceMicroVersionId === destinationMicroVersionId) {
688+
log.info('cascade loop detected on putData: ' +
689+
'destination already has this exact revision, skipping', logMeta);
690+
return { err: new MicroVersionIdAlreadyStoredException({}) };
691+
}
692+
if (isSourceOlderThanDestination(sourceMicroVersionId, destinationMicroVersionId)) {
693+
log.info('cascade stale on putData: ' +
694+
'destination already has a newer revision', logMeta);
695+
return { err: new StaleMicroVersionIdException({}) };
696+
}
697+
log.info('cascade putData: data already at destination, ' +
698+
'proceeding with metadata update', logMeta);
699+
return { err: null, result: partAlreadyAtDest };
700+
}
701+
645702
_putMetadataOnce(entry, mdOnly, log, cb) {
646703
log.debug('putting metadata', {
647704
where: 'target', entry: entry.getLogInfo(),
@@ -671,6 +728,8 @@ class ReplicateObject extends BackbeatTask {
671728
// destination bucket has to be versioning enabled.
672729
VersioningRequired: true,
673730
RequestUids: log.getSerializedUids(),
731+
MicroVersionId: entry.getMicroVersionId()
732+
? encodeMicroVersionId(entry.getMicroVersionId()) : '',
674733
});
675734
const writeStartTime = Date.now();
676735
return this.backbeatDest.send(command)
@@ -685,14 +744,14 @@ class ReplicateObject extends BackbeatTask {
685744
return cbOnce(err);
686745
}
687746
log.error('an error occurred when putting metadata to S3',
688-
{
689-
method: 'ReplicateObject._putMetadataOnce',
690-
entry: entry.getLogInfo(),
691-
origin: 'target',
692-
peer: this.destBackbeatHost,
693-
error: err.message,
694-
err,
695-
});
747+
{
748+
method: 'ReplicateObject._putMetadataOnce',
749+
entry: entry.getLogInfo(),
750+
origin: 'target',
751+
peer: this.destBackbeatHost,
752+
error: err.message,
753+
err,
754+
});
696755
return cbOnce(err);
697756
});
698757
}
@@ -920,13 +979,14 @@ class ReplicateObject extends BackbeatTask {
920979
return this._getAndPutData(sourceEntry, destEntry, log,
921980
next);
922981
}
923-
return next(null, []);
982+
return next(null, [], false);
924983
},
925984
// update location, replication status and put metadata in
926985
// target bucket
927-
(destLocations, next) => {
986+
(destLocations, noNewDataLocations, next) => {
987+
const localMdOnly = mdOnly || noNewDataLocations;
928988
destEntry.setLocation(destLocations);
929-
this._putMetadata(destEntry, mdOnly, log, err => {
989+
this._putMetadata(destEntry, localMdOnly, log, err => {
930990
if (err) {
931991
return this._deleteOrphans(
932992
destEntry, destLocations, log, () => next(err));
@@ -946,22 +1006,35 @@ class ReplicateObject extends BackbeatTask {
9461006
next => this._getAndPutData(sourceEntry, destEntry, log, next),
9471007
// update location, replication status and put metadata in
9481008
// target bucket
949-
(location, next) => {
950-
destEntry.setLocation(location);
951-
this._putMetadata(destEntry, false, log, next);
1009+
(destLocations, noNewDataLocations, next) => {
1010+
destEntry.setLocation(destLocations);
1011+
this._putMetadata(destEntry, noNewDataLocations, log, next);
9521012
},
9531013
], err => this._handleReplicationOutcome(
9541014
err, sourceEntry, destEntry, kafkaEntry, log, done));
9551015
}
9561016

9571017
_handleReplicationOutcome(err, sourceEntry, destEntry, kafkaEntry,
9581018
log, done) {
1019+
if (err instanceof MicroVersionIdAlreadyStoredException) {
1020+
log.info('replication completed via cascade loop: ' +
1021+
'object already at destination with the same revision',
1022+
{ entry: sourceEntry.getLogInfo() });
1023+
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
1024+
return done(null, { committable: false });
1025+
}
1026+
if (err instanceof StaleMicroVersionIdException) {
1027+
log.info('replication completed: destination already holds ' +
1028+
'this version with a newer revision',
1029+
{ entry: sourceEntry.getLogInfo() });
1030+
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
1031+
return done(null, { committable: false });
1032+
}
9591033
if (!err) {
9601034
log.debug('replication succeeded for object, publishing ' +
9611035
'replication status as COMPLETED',
9621036
{ entry: sourceEntry.getLogInfo() });
963-
this._publishReplicationStatus(
964-
sourceEntry, 'COMPLETED', { kafkaEntry, log });
1037+
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
9651038
return done(null, { committable: false });
9661039
}
9671040
if (err.BadRole || err.name === 'BadRole' ||
@@ -1020,3 +1093,4 @@ class ReplicateObject extends BackbeatTask {
10201093
}
10211094

10221095
module.exports = ReplicateObject;
1096+
module.exports._cascadeSignals = { partAlreadyAtDest };

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@aws-sdk/client-s3": "^3.921.0",
5757
"@aws-sdk/client-sts": "^3.921.0",
5858
"@aws-sdk/credential-providers": "^3.921.0",
59-
"@scality/cloudserverclient": "^1.0.8",
59+
"@scality/cloudserverclient": "^1.0.9",
6060
"@smithy/node-http-handler": "^3.3.3",
6161
"JSONStream": "^1.3.5",
6262
"arsenal": "git+https://github.com/scality/arsenal#8.5.2",

0 commit comments

Comments
 (0)