Skip to content

Add CRR Cascaded capabilities#6179

Open
SylvainSenechal wants to merge 1 commit into
development/9.4from
improvement/CLDSRV-897
Open

Add CRR Cascaded capabilities#6179
SylvainSenechal wants to merge 1 commit into
development/9.4from
improvement/CLDSRV-897

Conversation

@SylvainSenechal

@SylvainSenechal SylvainSenechal commented May 29, 2026

Copy link
Copy Markdown
Contributor

ISSUE : CLDSRV-897

Crr cascaded design : https://github.com/scality/citadel/pull/349

Related PRs :
Arsenal : scality/Arsenal#2628
CloudserverClient : scality/cloudserverclient#24
Backbeat : scality/backbeat#2747
S3utils : scality/s3utils#395

@bert-e

bert-e commented May 29, 2026

Copy link
Copy Markdown
Contributor

Hello sylvainsenechal,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request TBA
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

Comment thread lib/api/apiUtils/object/versioning.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js
Comment thread package.json Outdated
Comment thread package.json Outdated
Comment thread tests/functional/backbeat/crrCascade.js Outdated
Comment thread tests/functional/backbeat/putMetadata.js
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
  • Dependency pinning: arsenal and @scality/cloudserverclient in package.json use file: paths pointing to local directories. These must be pinned to git tags or published versions before merge.
    • arsenal on line 36: file:../Arsenal → should be a git tag (e.g. 8.4.2)
    • @scality/cloudserverclient on line 68: file:../cloudserverclient → should be a versioned reference
  • Test naming: it() descriptions in tests/functional/backbeat/crrCascade.js should start with should per project conventions.
  • Test cleanup: No after() hook to delete the three buckets created in before(). Leftover buckets will accumulate across test runs.

Review by Claude Code

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated

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.

all these worked locally, need to wait for the bumps now

Comment thread package.json Outdated
@SylvainSenechal SylvainSenechal changed the title Improvement/cldsrv 897 Add CRR Cascaded capabilities May 29, 2026
@SylvainSenechal SylvainSenechal requested review from a team, delthas and maeldonn May 29, 2026 18:11
@SylvainSenechal SylvainSenechal force-pushed the improvement/CLDSRV-897 branch from c15fb2b to 7912480 Compare May 29, 2026 18:38
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

❌ 5 Tests Failed:

Tests completed Failed Passed Skipped
9639 5 9634 0
View the top 3 failed test(s) by shortest run time
should succeed on first write of a zero-byte object with METADATA replication content::putMetadata : cascade replication behavior (with replication content) should succeed on first write of a zero-byte object with METADATA replication content
Stack Traces | 0.004s run time
This object does not exist
should return 400 when MicroVersionId header does not match body microVersionId::putMetadata : microVersionId conditional updates (no replication content) should return 400 when MicroVersionId header does not match body microVersionId
Stack Traces | 0.011s run time
Missing expected rejection.
should set replication status to PENDING and preserve isReplica when bucket has CRR rules::putMetadata : cascade replication behavior (with replication content) should set replication status to PENDING and preserve isReplica when bucket has CRR rules
Stack Traces | 0.021s run time
replication status should be PENDING when a CRR rule matches
+ actual - expected

+ 'REPLICA'
- 'PENDING'
should clear replicationInfo when no CRR rules match on destination bucket::putMetadata : cascade replication behavior (with replication content) should clear replicationInfo when no CRR rules match on destination bucket
Stack Traces | 0.026s run time
replication status should be cleared when no CRR rules match

'REPLICA' !== ''
should retrieve a part copied from an MPU::GET object With default signature With PartNumber field uploadPartCopy should retrieve a part copied from an MPU
Stack Traces | 40s run time
Timeout of 40000ms exceeded. For async tests and hooks, ensure "done()" is called; if returning a Promise, ensure it resolves. (.../test/object/get.js)
View the full list of 1 ❄️ flaky test(s)
"after each" hook for "should fail if trying to overwrite a delete marker"::MPU with x-scal-s3-version-id header With v4 signature "after each" hook for "should fail if trying to overwrite a delete marker"

Flake rate in main: 100.00% (Passed 0 times, Failed 219 times)

Stack Traces | 0.108s run time
We encountered an internal error. Please try again.

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Comment thread tests/functional/backbeat/crrCascade.js Outdated
Comment thread package.json Outdated
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown
  • package.json: arsenal and @scality/cloudserverclient use file: local path references instead of pinned tags/versions — these will break installs for anyone else and must be updated before merge
    - Pin arsenal to the Arsenal PR tag once merged (was 8.4.2)
    - Pin @scality/cloudserverclient to a published version (was 1.0.7)
    - tests/functional/backbeat/crrCascade.js: test names in it() blocks should start with should

    Review by Claude Code

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread tests/functional/backbeat/crrCascade.js Outdated
Comment thread package.json Outdated
Comment thread lib/utilities/collectResponseHeaders.js Outdated
@claude

claude Bot commented Jun 1, 2026

Copy link
Copy Markdown
  • package.json: arsenal and @scality/cloudserverclient point to local file:../ paths — must be pinned to git tags before merging.
    - lib/utilities/collectResponseHeaders.js:103: Cascade replicas without a next hop have status: '' and isReplica: true, but the outer if only checks status (falsy for empty string), so x-amz-replication-status: REPLICA header will be missing from S3 responses for these objects.
    - lib/routes/routeBackbeat.js:438: putData and putMetadata handle decodeMicroVersionId error cases differently — consider aligning for consistency.
    - tests/functional/backbeat/crrCascade.js: Missing after() hook to clean up the three test buckets created in before().

    Review by Claude Code

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
// as cascade targets because they use the MultiBackend S3 path which
// bypasses the putData/putMetadata routes, so loop detection cannot fire
// on those destinations.
const BLOCKED_LOCATION_TYPES = ['location-scality-ring-s3-v1', 'location-scality-artesca-s3-v1'];

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.

Could it be tested on a real lab ?

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.

should be honestly, i think i need to double check this together with the design and the code

replicationInfo: getReplicationInfo(config,
objectKey, bucketMD, false, size, null, null, authInfo),
overheadField,
updateMicroVersionId: true,

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.

Do we really need this ?

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.

yes, actually this is important, I added this after making my tests in codespaces :
Without this :
You put an object : object isn't assigned an micro version Id
You setup the whole replication, backbeat picks up that source object, it doesn't have any microversion id so cloudserver just doesn't try to process it as a potential cascade replication

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.

  • should "new" objects not keep having an empty microVersionId (to avoid wasting metadata space)?
    and just consider "no micro version id" is the oldest micro versionId (the only thing it cannot compare to is another no micro version id - but that would not happen on new object, each get their own versionId)
  • in case of un-versioned buckets (i.e. only master objects), we may need to update the micro version id (because we have no version id) ; however there is no replication in that case, so no problem not to do it

@maeldonn maeldonn Jun 30, 2026

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.

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.

We discussed this a bit with Maël.
I think we could try to not do it all the time, and do it only for putObject (but not even sure).
But we were wondering what happens if we don't write microVersionId on putObject : how do we compare two objects that don't have any micro version ID, it may be possible on the first replication to consider no micro version id => source is newer, but then we must write the microVersionID anyways to block loop and make later write work.
Also in the design we said microVersionId would necessarily be used for this feature only but more generally to be able to track and compare update time, I'm very worried we're gonna have issues later if we start writing microVersionID only on certain conditiion. Actually the code in arsenal, backbeat and cloudserver is already filled with quite a lot of defensive and smelly if (microVersionID) because we never bothered adding this from the start

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.

we were wondering what happens if we don't write microVersionId on putObject : how do we compare two objects that don't have any micro version ID

no issue there:

  • there are 2 "independent" putObject on 2 different sites : they will have different versionId, so no need to compare microVersionId
  • "within" a specific version (versionID), the empty/missing versionID is inequivoquality the oldest revision of metadata (the one from the original putObject). Any followup updates to that versionID need to bump the micro version id

This case may be a "lack" of microVersionID in the metadata (field not set, for metadata size efficiency), but it is really just a sentinel value for the oldest microVersionID; and it compares exactly the same:

  • "empty" versionID on both side → already up to date (equality)
  • replicate "empty" versoinID while the target already have a versionID → stale versionID (older)
  • replicate versionID while the target has an empty versionID → proceed with replication (newer)

we start writing microVersionID only on certain conditiion.

this case will be present anyway: all objects before the upgrade will not have versionID, so we must be able to manage this state: we cannot assume it will be present.
(and crrExistingObject may add microVersionID, but I'd rather not be protected by this alone: with DR/... there may be other cases coming, best be prepared)

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.

discussed live and code is updated :
microVersionId is only used for metadata diff checks now

header microVersionId absent => no crr cascade
header microVersionId present but empty string '' => replicate without asking question
header microVersionId present with a value => do comparison

Comment on lines +589 to +598
request.resume();
return _respondWithHeaders(
response,
{ code: MicroVersionIdAlreadyStoredException.name,
message: 'incoming microVersionId already at destination' },
{},
log,
callback,
409,
);

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.

could you remove duplication to simplify here ?

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.

Ok I did something, you take a look but honestly it's really not that great 🤔

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 code is completely duplicated, the only difference is really name of exception and "text" of message.

as discussed in cloudserverclient, do we really need cloudserver to make that distinction? just returning the same error (micro version id conflict) AND the micro version id of the object stored allows the caller to make the distinction, if they need - without duplication.

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.

ahhh, I missed the extra type MicroVersionIdAlreadyStoredException, c.f. https://github.com/scality/cloudserverclient/pull/24/changes#r3496549728

we had the discussion and ended adding microVersionId in StaleMicroVersionIdException, but it is useless if we generate 2 kinds of errors...
→ IMHO we should drop MicroVersionIdAlreadyStoredException, which allows deduplicating here and simplifying backbeat?


otherwise we shoud still dedup, something like:

Suggested change
request.resume();
return _respondWithHeaders(
response,
{ code: MicroVersionIdAlreadyStoredException.name,
message: 'incoming microVersionId already at destination' },
{},
log,
callback,
409,
);
if (cmp !=== Ordering.NEWER) {
request.resume();
return _respondWithHeaderCrrConflict(
response, log, callback,
cmp === Ordering.EQUAL ? MicroVersionIdAlreadyStoredException.name : StaleMicroVersionIdException.name,
'incoming microVersionId already at destination',
);
}

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.

Sending back the microVersionID is indeed not super useful, I added it because it was suggested it could become useful or nice to have the info for the caller. I'm not really gonna focus on this to much, i can remove it or keep it, imo I think it's fine that an api returns it.

But for the double error, well tbh i don't really mind either as I just wanna put an end to this feature, but for me it's better to have backbeat check the errors like this :

Image

Than having it just receive a microVersion id, then does another loop if microversion == null, if microversion < > xxx, then we don't do exactly the same thing (cbOnce(cascadeLoopDetected) vs cbOnce(cascadeDataStale)).

In the screenshot, we directly have errors that we can nicely catch and that's pretty much it.

I just see calling the client like calling any functions, and if the function I call return an error, I want the error to be as detailed as possible and not redo the checks that was already done, because anyways cloudserver has to do those comparisons here.

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 will recheck the suggested simpification after we settle the discussion, not fully sure it should be done though because compare can return 4 values
older
younger
equal
not comparable

so doing this cmp !=== Ordering.NEWER
still leaves room for NOT_COMPARABLE, I think it's safer to assert on what we want that exclude only value

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.

Sending back the microVersionID is indeed not super useful, I added it because it was suggested it could become useful or nice to have the info for the caller

not sure where it was lost in the discussion, but the suggestion was really to add microVersionID in the result so we could have a single error : i.e. reduce the logic to minimal in cloudserver, and keep the most in backbeat.

(btw, the code you mention also has similar comment about duplication: my feeling -to be confirmed- is that this duplication at every layer is due at least in part to the qualification happening too early, and would be avoided by propagating a single error [wiht microVersionId] accross all layers, and differentiating the cases only at the end of the chain)

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 reviewed everything, but I don't really see any code duplication on putMetadata, backbeat does a putMetada, receive the error which directly goes to "handleReplicationOutcome", without needing to do any comparison again. Looks good to me, the functions called sends an error that's as precise as possible

@SylvainSenechal SylvainSenechal force-pushed the improvement/CLDSRV-897 branch from 3c698dc to c8acd08 Compare June 25, 2026 19:23
@SylvainSenechal SylvainSenechal requested a review from maeldonn June 25, 2026 19:23
Comment thread lib/routes/routeBackbeat.js Outdated
@SylvainSenechal SylvainSenechal force-pushed the improvement/CLDSRV-897 branch from c8acd08 to 51638dd Compare June 26, 2026 09:01

@maeldonn maeldonn left a comment

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.

LGTM. Please bump package.json and create a new release. Could you just confirm that the location guard is working ?

@bert-e

bert-e commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • 2 peers

Comment thread lib/routes/routeBackbeat.js Outdated
const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
const decoded = incomingVersionIdEncoded ? decode(incomingVersionIdEncoded) : null;
const incomingVersionIdDecoded = decoded instanceof Error ? null : decoded;
if (incomingVersionIdDecoded && objMd && objMd.versionId === incomingVersionIdDecoded) {

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.

objMd.versionId === incomingVersionIdDecoded

  • Can this condition actually fail?
  • Is it really part of the condition?

AFAIU, versionId is used (or should be?) to find the existing objMD:

  • so if not found, we would get objMD=null ;
  • and if found, it should have the versionID...
  • Unless there is a some logic here which gets the matching version if it exist, else the latest one : in that case objMd.versionId !== incomingVersionIdDecoded means we actually did not an object already

→ should clarify how we get there, and nice if we can drop this condition
(remember that with CRR with cannot work in non-versioned buckets -so no "overwrites"-, and that versions may be replicated out of order)

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 rewrote some part of the if statement into 2 stages, but your comment still stands.

I am not sure i understand it though, I think you are saying that if we were able to get objMd, then necessarily objMd will be equal to incomingVersionIdDecoded ?

  • objMd is obtained through a middleware, from the provided bucket + the key, and it's the latest available version of that bucket
  • the x-scal-micro-version-id is anything people using the client decide to put inside. I don't see a situation where we would skip checking objMd.version === incomingVersionId

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.

objMd is obtained through a middleware, from the provided bucket + the key, and it's the latest available version of that bucket

  • if by middleware you mean the "router" in the same routeBackbeat file, indeed; but still this is something under our control and closely related to this code
  • I thought this would return the specific versionId associated with the result ; if it returns the latest, then the issue is much larger : we need to try and check if we have this specific versionId. We may be replicating the "previous" version, but the logic must apply in that case as well...

the x-scal-micro-version-id is anything people using the client decide to put inside. I don't see a situation where we would skip checking objMd.version === incomingVersionId

  • this API cannot be used by "people", it is used only by scality software
  • if the x-scal-version-id is set to tell which version we are interested in (and should be retrieved). If there is no such revision, there is no conflict (and responsibility/bug of the caller if they did not provide the right versionId) ; if this revision is present, this is a conflict. We don't care if there is another revision of the object, we should only attempt to retrieve the ObjMD of the revision we are interested in.

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 outdated (was discussed in meeting and addressed with code change)

replicationInfo: getReplicationInfo(config,
objectKey, bucketMD, false, size, null, null, authInfo),
overheadField,
updateMicroVersionId: true,

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.

  • should "new" objects not keep having an empty microVersionId (to avoid wasting metadata space)?
    and just consider "no micro version id" is the oldest micro versionId (the only thing it cannot compare to is another no micro version id - but that would not happen on new object, each get their own versionId)
  • in case of un-versioned buckets (i.e. only master objects), we may need to update the micro version id (because we have no version id) ; however there is no replication in that case, so no problem not to do it

Comment thread lib/routes/routeBackbeat.js Outdated

const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
const decoded = incomingVersionIdEncoded ? decode(incomingVersionIdEncoded) : null;
const incomingVersionIdDecoded = decoded instanceof Error ? null : decoded;

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.

should we not actually fail if the version id cannot be decoded? (this would be a bad request)

@SylvainSenechal SylvainSenechal Jun 30, 2026

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.

yes actually i changed the single if into 2 ifs :
If the versionId is provided, but we can't decode it, it means something is wrong and we shouldn't continue anything

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js
Comment on lines +589 to +598
request.resume();
return _respondWithHeaders(
response,
{ code: MicroVersionIdAlreadyStoredException.name,
message: 'incoming microVersionId already at destination' },
{},
log,
callback,
409,
);

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 code is completely duplicated, the only difference is really name of exception and "text" of message.

as discussed in cloudserverclient, do we really need cloudserver to make that distinction? just returning the same error (micro version id conflict) AND the micro version id of the object stored allows the caller to make the distinction, if they need - without duplication.

Comment on lines +589 to +598
request.resume();
return _respondWithHeaders(
response,
{ code: MicroVersionIdAlreadyStoredException.name,
message: 'incoming microVersionId already at destination' },
{},
log,
callback,
409,
);

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.

ahhh, I missed the extra type MicroVersionIdAlreadyStoredException, c.f. https://github.com/scality/cloudserverclient/pull/24/changes#r3496549728

we had the discussion and ended adding microVersionId in StaleMicroVersionIdException, but it is useless if we generate 2 kinds of errors...
→ IMHO we should drop MicroVersionIdAlreadyStoredException, which allows deduplicating here and simplifying backbeat?


otherwise we shoud still dedup, something like:

Suggested change
request.resume();
return _respondWithHeaders(
response,
{ code: MicroVersionIdAlreadyStoredException.name,
message: 'incoming microVersionId already at destination' },
{},
log,
callback,
409,
);
if (cmp !=== Ordering.NEWER) {
request.resume();
return _respondWithHeaderCrrConflict(
response, log, callback,
cmp === Ordering.EQUAL ? MicroVersionIdAlreadyStoredException.name : StaleMicroVersionIdException.name,
'incoming microVersionId already at destination',
);
}

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated

@francoisferrand francoisferrand left a comment

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.

@SylvainSenechal seems there are quite the a few open points, which don't look like they will converge quickly through review comments.

Can you prepare a small doc to list each of these topics (both on this PR and the backbeat one, likely the same but best be sure), with the different approaches and arguments in favor of each?
→ then we can go through them in a short meeting (30min should be enough?), settle the question and move forward

(note: some of these questions may be refined/addressed incrementally in followup PRs, so we could merge this PR already; and for others it may make sense to make the change first. But either way we need to settle the points ASAP -and implement the followup, if any- before we are ready to ship)

@SylvainSenechal SylvainSenechal force-pushed the improvement/CLDSRV-897 branch 6 times, most recently from b721b8c to da34813 Compare July 2, 2026 15:20
Comment thread lib/routes/routeBackbeat.js Outdated
}
}


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

putData correctly calls writeContinue(request, response) before consuming the body (line 466), but putMetadata doesn't call it before _getRequestPayload. The cloudserverclient upgrade from 1.0.7 to 1.0.9 adds @aws-sdk/middleware-expect-continue, so clients will now send Expect: 100-continue for metadata requests. Since the server registers a checkContinue listener (lib/server.js:296), Node.js won't auto-send 100 Continue — the client will stall until its expectContinueTimeout (default 1s) before sending the body, adding latency to every metadata replication.

Suggested change
writeContinue(request, response);

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.

Mhhh, no ?

We introduce 100-continue for putData as it's data heavy
We don't particularly care about it for putMetada 🤔 Or else we will start using it everywhere

Comment thread lib/routes/routeBackbeat.js Outdated
}

const incomingVersionIdEncoded = request.headers['x-scal-version-id'];
if (incomingVersionIdEncoded) {

@francoisferrand francoisferrand Jul 3, 2026

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.

header may be present but empty, for null versions

Suggested change
if (incomingVersionIdEncoded) {
if (incomingVersionIdEncoded != undefined) {

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.

Mhh, looking at the way this is sent from backbeat, we are very unlikely to send '' unless we hardcode '' in the client.
This is because in backbeat, we are getting the versionID from an Arsenal helper that will returns null when there is no version ID

Also, if we let '' go though this if, it will fail right after on the decode function as its an invalid version id.

We should still make the api here a bit more self documenting, how about this :

"We expect either no version ID, or a real version id that isnt an empty string" :
if (incomingVersionIdEncoded != null && incomingVersionIdEncoded !== '') ?

@francoisferrand francoisferrand left a comment

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 condition microVersionId update is not atomic → must somehow involve the mongo/metadata layer, i.e. perform an actual conditional metadata update on microVersionId

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
Comment on lines +613 to +615
const isSourceOlderThanDestination = (sourceMicroVersionId, destinationMicroVersionId) =>
destinationMicroVersionId !== null &&
(sourceMicroVersionId === null || sourceMicroVersionId > destinationMicroVersionId);

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.

is this not handled already in arsenal's compareMicroVersionId function ?

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.

So, I ended up not using the Arsenal utility function. If found it was getting over complicated to use, as I tried to make some kind of generic Arsenal comparison function that deals with null values, and multiple cases, but here we already have more context and focus only on doing comparison that we care about.

Basically, I think if we wanted to use Arsenal utility, we would have to introduce Crr cascade business into arsenal.
And if we want to use the generic Arsenal function here, we will need to add logic on top of it to fit it for Crr Cascade

Comment thread lib/routes/routeBackbeat.js Outdated
Comment thread lib/routes/routeBackbeat.js Outdated
function putMetadata(request, response, bucketInfo, objMd, log, callback) {
const { bucketName, objectKey } = request;

const encodedMicroVersionId = request.headers['x-scal-micro-version-id'];

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.

when the header is set, maybe we should have a sanity check (later) to verify the metadata contains the same microVersionId as the header

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.

A check that the microVersionId in the header is the same as the microVersionId in the metadata body ? Yes why not, I added it as soon as the request payload is decoded

Comment thread lib/routes/routeBackbeat.js Outdated
Comment on lines +719 to +722
// For metadata-only writes, the destination's data location must be
// preserved from the existing object. If there is no existing object
// and the source has data, there is nothing to copy the location from.
// Zero-byte objects have no data location, so this is safe to skip.

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.

it is safe to skip, but also safe to keep as well : and the previous code was much simpler. If there is no need change it, best not to change it.

Especially semantically, headers['x-scal-replication-content'] === 'METADATA' means we expect to update the metadata of an object: so that object MUST exist. Removing the check could mask bugs (e.g. backbeat set METADATA instead of DATA+METADATA) or lead to re-creating the object/version (empty) after it was deleted.

→ keep the previous chunk, this should not be changed

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.

Ok I went back on this :
This modification should indeed not be there, and instead it requires some small changes on backbeat to deal with metadata only writes on zero sized object, but yeah there is no need to make that modification here

Comment thread lib/routes/routeBackbeat.js Outdated
// as cascade targets because they use the MultiBackend S3 path which
// bypasses the putData/putMetadata routes, so loop detection cannot fire
// on those destinations.
const BLOCKED_LOCATION_TYPES = ['location-scality-ring-s3-v1', 'location-scality-artesca-s3-v1'];

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.

can we know the "source" (sender) of the request here, and map it to a location?
It would not solve the loop problem, but passing that information would allow skipping extra API call(s) in case of bidirectional CRR, and keep same "load" as the existing code.

→ please create followup, could be a nice optimization?

(kind of flimsy, but we could extract the "replication group" from versionId ; not sure how to map to locations though. Or checking if source IP is in the location's bootstrap list. Other maybe other ways...)

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.

Ah you want to have something like
a<->b, and when b receives the requests, it identifies the next replication is A, which is also where the current request came from, so it stops

I think from just 2 minutes of thinking we would need to pass a new header, that contains the location name. And here in backbeat compare the 2 locations names.

Anyways creating a follow-up ticket

https://scality.atlassian.net/browse/CLDSRV-951

Comment thread lib/routes/routeBackbeat.js Outdated

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.

microVersionID may be specified as well when NOT doing replication, i.e. when headers['x-scal-replication-content'] is not set.
→ need to test both microVersionID semantics (i.e. conditional updates) AND the interaction with replication (when replicating, should cascade).
→ maybe more readable to split into 2 files putMetadata.js and putData.js tests matching the actual APIs, and cover both aspects (and possibly combinations)

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.

Yeah the file is big enough that a split makes sense, also some tests can be added

@bert-e

bert-e commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • 2 peers

The following reviewers are expecting changes from the author, or must review again:

@delthas

delthas commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Removing myself as reviewer since alrdy covered by 2 people; and quite complex. Re-add me if needed.

@delthas delthas removed their request for review July 6, 2026 08:39
@SylvainSenechal SylvainSenechal force-pushed the improvement/CLDSRV-897 branch from da34813 to dc58e7d Compare July 6, 2026 16:43
const olderMvId = makeMicroVersionId();
const newerMvId = makeMicroVersionId();

await backbeatClient.send(new PutMetadataCommand({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both PutMetadataCommand calls in this test (lines 338 and 344) are missing ReplicationContent. Without x-scal-replication-content header, the cascade block in routeBackbeat.js:841 (const isReplicationWrite = !!headers['x-scal-replication-content']) won't fire. The body's replicationInfo.status ('REPLICA' from buildMetadataBody) is stored as-is, so the assertion at line 356 expecting 'PENDING' will fail.

Same issue applies to the test at line 315 ("should clear replicationInfo when no CRR rules match") which uses the putMetadata() helper — documented at line 68 as "without x-scal-replication-content" — but asserts status: '', a value only set when the cascade block fires and finds no matching rules.

Suggested change
await backbeatClient.send(new PutMetadataCommand({
await backbeatClient.send(new PutMetadataCommand({
Bucket: TEST_BUCKET_CRR,
Key: key,
MicroVersionId: olderMvId.encoded,
ReplicationContent: 'METADATA',
Body: buildMetadataBody({ microVersionId: olderMvId.raw }),
}));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants