1+ const { constants : { HTTP_STATUS_CONFLICT } } = require ( 'http2' ) ;
12const url = require ( 'url' ) ;
23const async = require ( 'async' ) ;
34const httpProxy = require ( 'http-proxy' ) ;
@@ -7,7 +8,13 @@ const joi = require('@hapi/joi');
78const 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
1219const { responseJSONBody } = s3routes . routesUtils ;
1320const { getSubPartIds } = s3middleware . azureHelper . mpuUtils ;
@@ -21,6 +28,7 @@ const locationStorageCheck = require('../api/apiUtils/object/locationStorageChec
2128const { dataStore } = require ( '../api/apiUtils/object/storeObject' ) ;
2229const prepareRequestContexts = require ( '../api/apiUtils/authorization/prepareRequestContexts' ) ;
2330const { decodeVersionId } = require ( '../api/apiUtils/object/versioning' ) ;
31+ const getReplicationInfo = require ( '../api/apiUtils/object/getReplicationInfo' ) ;
2432const locationKeysHaveChanged = require ( '../api/apiUtils/object/locationKeysHaveChanged' ) ;
2533const { standardMetadataValidateBucketAndObj, metadataGetObject } = require ( '../metadata/metadataUtils' ) ;
2634const { config } = require ( '../Config' ) ;
@@ -32,6 +40,7 @@ const {
3240} = require ( '../api/apiUtils/integrity/validateChecksums' ) ;
3341const { BackendInfo } = models ;
3442const { pushReplicationMetric } = require ( './utilities/pushReplicationMetric' ) ;
43+ const writeContinue = require ( '../utilities/writeContinue' ) ;
3544const kms = require ( '../kms/wrapper' ) ;
3645const { listLifecycleCurrents } = require ( '../api/backbeat/listLifecycleCurrents' ) ;
3746const { 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+
132150function _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
541591function 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
0 commit comments