Skip to content

Commit 14aa3a0

Browse files
Implement CRR Cascaded feature
Issue: CLDSRV-897
1 parent 9314dc5 commit 14aa3a0

8 files changed

Lines changed: 788 additions & 18 deletions

File tree

lib/api/apiUtils/object/getReplicationInfo.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,33 @@ function _canUserReplicate(authInfo) {
6666
* @param {string} operationType - The type of operation to replicate
6767
* @param {object} objectMD - The object metadata
6868
* @param {AuthInfo} [authInfo] - authentication info of object owner
69+
* @param {string[]} [blockedSiteTypes=[]] - location types to exclude from the returned backends
6970
* @return {object|undefined}
7071
*/
71-
function getReplicationInfo(s3config, objKey, bucketMD, isMD, objSize, operationType, objectMD, authInfo) {
72+
function getReplicationInfo(
73+
s3config, objKey, bucketMD, isMD, objSize,
74+
operationType, objectMD, authInfo, blockedSiteTypes = []) {
7275
const config = bucketMD.getReplicationConfiguration();
7376
if (!config || !_canUserReplicate(authInfo)) {
7477
return undefined;
7578
}
7679

7780
const isCloud = site => !!replicationBackends[s3config.locationConstraints[site]?.type];
7881
const rules = _withDefaultStorageClass(config.rules || [], s3config);
79-
const backends = ReplicationConfiguration.resolveBackends(
82+
const allBackends = ReplicationConfiguration.resolveBackends(
8083
{ ...config, rules },
8184
objKey,
8285
isCloud,
8386
objectMD?.replicationInfo?.backends,
8487
);
8588

89+
const backends = blockedSiteTypes.length > 0
90+
? allBackends.filter(b => {
91+
const loc = s3config.locationConstraints[b.site];
92+
return !loc || !blockedSiteTypes.includes(loc.type);
93+
})
94+
: allBackends;
95+
8696
if (backends.length === 0) {
8797
return undefined;
8898
}

lib/routes/routeBackbeat.js

Lines changed: 161 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const { constants: { HTTP_STATUS_CONFLICT } } = require('http2');
12
const url = require('url');
23
const async = require('async');
34
const httpProxy = require('http-proxy');
@@ -7,7 +8,13 @@ const joi = require('@hapi/joi');
78
const backbeatProxy = httpProxy.createProxyServer({
89
ignorePath: true,
910
});
10-
const { auth, errors, errorInstances, s3middleware, s3routes, models, storage } = require('arsenal');
11+
const { auth, errors, errorInstances, s3middleware, s3routes, models, storage, versioning } = require('arsenal');
12+
const { decode, encode } = versioning.VersionID;
13+
const {
14+
VersionIdCollisionException,
15+
StaleMicroVersionIdException,
16+
MicroVersionIdAlreadyStoredException,
17+
} = require('@scality/cloudserverclient');
1118

1219
const { responseJSONBody } = s3routes.routesUtils;
1320
const { getSubPartIds } = s3middleware.azureHelper.mpuUtils;
@@ -21,6 +28,7 @@ const locationStorageCheck = require('../api/apiUtils/object/locationStorageChec
2128
const { dataStore } = require('../api/apiUtils/object/storeObject');
2229
const prepareRequestContexts = require('../api/apiUtils/authorization/prepareRequestContexts');
2330
const { decodeVersionId } = require('../api/apiUtils/object/versioning');
31+
const getReplicationInfo = require('../api/apiUtils/object/getReplicationInfo');
2432
const locationKeysHaveChanged = require('../api/apiUtils/object/locationKeysHaveChanged');
2533
const { standardMetadataValidateBucketAndObj, metadataGetObject } = require('../metadata/metadataUtils');
2634
const { config } = require('../Config');
@@ -32,6 +40,7 @@ const {
3240
} = require('../api/apiUtils/integrity/validateChecksums');
3341
const { BackendInfo } = models;
3442
const { pushReplicationMetric } = require('./utilities/pushReplicationMetric');
43+
const writeContinue = require('../utilities/writeContinue');
3544
const kms = require('../kms/wrapper');
3645
const { listLifecycleCurrents } = require('../api/backbeat/listLifecycleCurrents');
3746
const { listLifecycleNonCurrents } = require('../api/backbeat/listLifecycleNonCurrents');
@@ -93,7 +102,7 @@ function _isObjectRequest(req) {
93102
return ['data', 'metadata', 'multiplebackenddata', 'multiplebackendmetadata'].includes(req.resourceType);
94103
}
95104

96-
function _respondWithHeaders(response, payload, extraHeaders, log, callback) {
105+
function _respondWithHeaders(response, payload, extraHeaders, log, callback, statusCode = 200) {
97106
let body = '';
98107
if (typeof payload === 'string') {
99108
body = payload;
@@ -115,10 +124,10 @@ function _respondWithHeaders(response, payload, extraHeaders, log, callback) {
115124
// eslint-disable-next-line no-param-reassign
116125
response.serverAccessLog.endTurnAroundTime = process.hrtime.bigint();
117126
}
118-
response.writeHead(200, httpHeaders);
127+
response.writeHead(statusCode, httpHeaders);
119128
response.end(body, 'utf8', () => {
120129
log.end().info('responded with payload', {
121-
httpCode: 200,
130+
httpCode: statusCode,
122131
contentLength: Buffer.byteLength(body),
123132
});
124133
callback();
@@ -129,6 +138,15 @@ function _respond(response, payload, log, callback) {
129138
_respondWithHeaders(response, payload, {}, log, callback);
130139
}
131140

141+
function _respondWithHeaderCrrConflict(response, log, callback, code, message, mvId) {
142+
return _respondWithHeaders(
143+
response,
144+
{ code, message },
145+
{ 'x-scal-micro-version-id': mvId ? encode(mvId) : '' },
146+
log, callback, HTTP_STATUS_CONFLICT,
147+
);
148+
}
149+
132150
function _getRequestPayload(req, cb) {
133151
const payload = [];
134152
let payloadLen = 0;
@@ -414,6 +432,38 @@ function putData(request, response, bucketInfo, objMd, log, callback) {
414432
log.error(errMessage);
415433
return callback(errorInstances.BadRequest.customizeDescription(errMessage));
416434
}
435+
436+
const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
437+
if (incomingVersionIdEncoded != null && incomingVersionIdEncoded !== '') {
438+
const incomingVersionIdDecoded = decode(incomingVersionIdEncoded);
439+
if (incomingVersionIdDecoded instanceof Error) {
440+
log.error('crr putData: failed to decode x-scal-version-id header', {
441+
method: 'putData',
442+
error: incomingVersionIdDecoded.message,
443+
});
444+
return callback(errorInstances.BadRequest.customizeDescription(
445+
'bad request: invalid x-scal-version-id header'));
446+
}
447+
if (objMd && objMd.versionId === incomingVersionIdDecoded) {
448+
// Data already at destination for this version; return 409 with the existing
449+
// microVersionId so backbeat can decide if putMetadata is still needed.
450+
log.debug('crr putData: version already at destination', {
451+
method: 'putData',
452+
bucketName: request.bucketName,
453+
objectKey: request.objectKey,
454+
hasMicroVersionId: !!objMd.microVersionId,
455+
});
456+
request.resume();
457+
return _respondWithHeaderCrrConflict(
458+
response, log, callback,
459+
VersionIdCollisionException.name,
460+
'version id already at destination',
461+
objMd.microVersionId,
462+
);
463+
}
464+
}
465+
466+
writeContinue(request, response);
417467
const context = {
418468
bucketName: request.bucketName,
419469
owner: canonicalID,
@@ -539,6 +589,64 @@ function getCanonicalIdsByAccountId(accountId, log, cb) {
539589
}
540590

541591
function putMetadata(request, response, bucketInfo, objMd, log, callback) {
592+
const { bucketName, objectKey } = request;
593+
594+
const encodedMicroVersionId = request.headers['x-scal-micro-version-id'];
595+
// When the header is present this is a conditional write: accept only if
596+
// the incoming microVersionId is newer than the one already stored.
597+
// '' is a valid value meaning the source has no microVersionId (null revision).
598+
// Note: '' is falsy so the presence check must be !== undefined, not truthy.
599+
const hasMicroVersionId = encodedMicroVersionId !== undefined;
600+
let incomingMicroVersionId = null;
601+
if (hasMicroVersionId && objMd) {
602+
// '' means source has no microVersionId, treated as older revision
603+
incomingMicroVersionId = encodedMicroVersionId === '' ? null : decode(encodedMicroVersionId);
604+
if (incomingMicroVersionId instanceof Error) {
605+
log.error('putMetadata: failed to decode x-scal-micro-version-id header', {
606+
method: 'putMetadata',
607+
error: incomingMicroVersionId.message,
608+
});
609+
return callback(errorInstances.BadRequest.customizeDescription(
610+
'bad request: invalid x-scal-micro-version-id header'));
611+
}
612+
613+
// null = oldest revision (original putObject, before any metadata update).
614+
// VersionID strings are in reverse chronological order: a lexicographically
615+
// larger string is an older revision.
616+
const isIncomingOlderThanCurrent = (incoming, current) =>
617+
current !== null &&
618+
(incoming === null || incoming > current);
619+
620+
const objectMicroVersionId = objMd.microVersionId || null;
621+
if (incomingMicroVersionId === objectMicroVersionId) {
622+
log.debug('putMetadata: incoming microVersionId already stored, skipping write', {
623+
method: 'putMetadata',
624+
bucketName,
625+
objectKey,
626+
});
627+
request.resume();
628+
return _respondWithHeaderCrrConflict(
629+
response, log, callback,
630+
MicroVersionIdAlreadyStoredException.name,
631+
'incoming microVersionId already at destination',
632+
);
633+
}
634+
if (isIncomingOlderThanCurrent(incomingMicroVersionId, objectMicroVersionId)) {
635+
log.debug('putMetadata: incoming microVersionId is older than stored, rejecting', {
636+
method: 'putMetadata',
637+
bucketName,
638+
objectKey,
639+
});
640+
request.resume();
641+
return _respondWithHeaderCrrConflict(
642+
response, log, callback,
643+
StaleMicroVersionIdException.name,
644+
'incoming revision is older than destination',
645+
objMd?.microVersionId,
646+
);
647+
}
648+
}
649+
542650
return _getRequestPayload(request, (err, payload) => {
543651
if (err) {
544652
return callback(err);
@@ -552,15 +660,20 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
552660
return callback(errors.MalformedPOSTRequest);
553661
}
554662

555-
const { headers, bucketName, objectKey } = request;
663+
if (incomingMicroVersionId !== null && incomingMicroVersionId !== (omVal.microVersionId ?? null)) {
664+
return callback(errors.BadRequest.customizeDescription(
665+
'bad request: x-scal-micro-version-id header does not match body microVersionId'));
666+
}
667+
668+
const { headers } = request;
556669

557670
// Destination-side delete-marker replication.
558671
// We need the REPLICA status to distinguish from
559672
// source-side replication status updates that also carry isDeleteMarker=true.
560673
if (
561674
omVal.isDeleteMarker &&
562675
omVal.replicationInfo &&
563-
omVal.replicationInfo.status === 'REPLICA' &&
676+
(omVal.replicationInfo.isReplica === true || omVal.replicationInfo.status === 'REPLICA') &&
564677
request.serverAccessLog
565678
) {
566679
// eslint-disable-next-line no-param-reassign
@@ -576,7 +689,7 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
576689
// The REPLICA status excludes source-side replication-status updates.
577690
if (
578691
omVal.replicationInfo &&
579-
omVal.replicationInfo.status === 'REPLICA' &&
692+
(omVal.replicationInfo.isReplica === true || omVal.replicationInfo.status === 'REPLICA') &&
580693
(omVal.originOp === 's3:ObjectTagging:Put' || omVal.originOp === 's3:ObjectTagging:Delete') &&
581694
request.serverAccessLog
582695
) {
@@ -593,7 +706,7 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
593706
// The REPLICA status excludes source-side replication-status updates.
594707
if (
595708
omVal.replicationInfo &&
596-
omVal.replicationInfo.status === 'REPLICA' &&
709+
(omVal.replicationInfo.isReplica === true || omVal.replicationInfo.status === 'REPLICA') &&
597710
omVal.originOp === 's3:ObjectAcl:Put' &&
598711
request.serverAccessLog
599712
) {
@@ -672,7 +785,8 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
672785
// then we want to create a version for the replica object even though
673786
// none was provided in the object metadata value.
674787
if (omVal.replicationInfo.isNFS) {
675-
const { isReplica } = omVal.replicationInfo;
788+
const isReplica = omVal.replicationInfo.isReplica === true
789+
|| omVal.replicationInfo.status === 'REPLICA';
676790
versioning = isReplica;
677791
omVal.replicationInfo.isNFS = !isReplica;
678792
}
@@ -724,6 +838,44 @@ function putMetadata(request, response, bucketInfo, objMd, log, callback) {
724838
options.isNull = isNull;
725839
}
726840

841+
const isReplicationWrite = !!headers['x-scal-replication-content'];
842+
if (isReplicationWrite) {
843+
const isMDOnly = headers['x-scal-replication-content'] === 'METADATA';
844+
const objSize = omVal['content-length'] || 0;
845+
846+
// These S3-compatible Scality locations are excluded as cascade
847+
// targets because they use the MultiBackend S3 path which bypasses
848+
// the putData/putMetadata routes, so loop detection cannot fire
849+
// on those destinations.
850+
const cascadeBlockedLocationTypes = [
851+
'location-scality-ring-s3-v1',
852+
'location-scality-artesca-s3-v1',
853+
];
854+
const nextReplInfo = getReplicationInfo(
855+
config, objectKey, bucketInfo, isMDOnly, objSize,
856+
null, null, null, cascadeBlockedLocationTypes);
857+
858+
const hasNextHop = nextReplInfo && nextReplInfo.backends.length > 0;
859+
860+
// Replicating further requires x-scal-micro-version-id for loop detection.
861+
if (hasNextHop && !hasMicroVersionId) {
862+
return callback(errors.InternalError.customizeDescription(
863+
'x-scal-micro-version-id is required when replication would trigger a next hop'));
864+
}
865+
866+
omVal.replicationInfo = hasNextHop ? nextReplInfo : {
867+
status: '',
868+
backends: [],
869+
content: [],
870+
destination: '',
871+
storageClass: '',
872+
role: '',
873+
storageType: '',
874+
dataStoreVersionId: '',
875+
};
876+
omVal.replicationInfo.isReplica = true;
877+
}
878+
727879
return async.series(
728880
[
729881
// Zenko's CRR delegates replacing the account

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@
6565
"vaultclient": "scality/vaultclient#8.5.3",
6666
"werelogs": "scality/werelogs#semver:^8.2.4",
6767
"ws": "^8.18.0",
68+
"@scality/cloudserverclient": "1.0.9",
6869
"xml2js": "^0.6.2"
6970
},
7071
"devDependencies": {
7172
"@eslint/compat": "^1.2.2",
72-
"@scality/cloudserverclient": "1.0.7",
7373
"@scality/eslint-config-scality": "scality/Guidelines#8.3.1",
7474
"eslint": "^9.14.0",
7575
"eslint-plugin-import": "^2.31.0",

0 commit comments

Comments
 (0)