Add Crr Cascade capabilities to backbeat crr replication#2747
Add Crr Cascade capabilities to backbeat crr replication#2747SylvainSenechal wants to merge 1 commit into
Conversation
Hello sylvainsenechal,My role is to assist you with the merge of this Available options
Available commands
Status report is not available. |
|
There was a problem hiding this comment.
I think we can functional tests instead of just these,
But waiting for Arsenal/cloudserver to be merged, as it will be easier to make these tests (functional tests in backbeat rely on an image of cloudserver)
There was a problem hiding this comment.
keeping unit test is good, functional test should just be an addition?
Codecov Report❌ Patch coverage is
Additional details and impacted files
... and 1 file with indirect coverage changes
@@ Coverage Diff @@
## development/9.5 #2747 +/- ##
===================================================
- Coverage 75.42% 75.36% -0.07%
===================================================
Files 201 201
Lines 13868 13903 +35
===================================================
+ Hits 10460 10478 +18
- Misses 3398 3415 +17
Partials 10 10
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
|
|
4c64ed6 to
3237f9e
Compare
9da41bb to
5c4ed70
Compare
| this._getAndPutPart(sourceEntry, destEntry, part, log, done); | ||
| }, (err, destLocations) => { | ||
| }, (err, partResults) => { | ||
| // partAlreadyAtDest signals data already at dest (cascade putData 409); |
There was a problem hiding this comment.
partAlreadyAtDest name is not correct : we cannot detect that a "part" is already at destination, only that there is already an object (i.e. a document in mongo with the specified key and versionId).
→ should really be objectAlreadyAtDest
....and it changes the logic a bit : when it happen (even on a single part), all successful parts must be deleted.
| // partAlreadyAtDest signals data already at dest (cascade putData 409); | |
| if (err) { | |
| return this._deleteOrphans(destEntry, destLocations, log, () => cb(err)); | |
| } | |
| const destLocations = (partResults || []).filter(result => result && result !== partAlreadyAtDest); | |
| if (destLocations.length != partResults.length) { | |
| // object already exist, release all parts then check if metadata needs to be updated | |
| return this._deleteOrphans(destEntry, destLocations, log, () => | |
| cb(null, destLocations, true); | |
| } | |
| return this._deleteOrphans(destEntry, destLocations, log, () => | |
| cb(null, destLocations, false); |
i.e. we cannot make a partial write of object data. To create the metadata, all parts must be known: so all of them must have been written successfully. If there is a "conflict" on any single part, it means another replicant has finished uploading parts and created the metadata document : so we must drop all the parts we wrote, and just update the object metadata if needed....
There was a problem hiding this comment.
also please create followups to
- ignore (other) error if a single part has a conflict
- abort other parts upload as soon as we identify a conflict (i.e. imagine a 1000 parts object: if we have a conflict on first part, no point trying the other parts)
- consider changing the putPartData protocol to create actual MPU parts (which can be garbage-collected by lifecycle after transfer is aborted), for extra safety (not strictly related to CRR, but best to track it)
There was a problem hiding this comment.
Yeah I rewrote this because I think from the start I overcomplicated it.
The flow is :
if any err or conflict : delete all written data, else we can all parts like usually.
For the follow up :
- I already created the follow up to do an early exit on multi part upload
- The error handling, I think no need for a follow up, I added it in the code by checking conflict before checking error (although maybe its a bit simplistic, it kinda raise some questions about whether we still want to act on the errors, at least logs, maybe more depending on the errors, not sure)
- Created a ticket for putPartDataMPU https://scality.atlassian.net/browse/BB-801
| }); | ||
| } | ||
|
|
||
| _resolveVersionIdCollision(collisionErr, sourceEntry, destEntry, log) { |
There was a problem hiding this comment.
this whole function is not correct in case of putData(Parts) : if we receive VersionIdCollision, it means there is already a object with this versionId.
- We must not create or replace the metadata with the new data (data is immutable)
- We must immediately delete whatever we already uploaded
- The
microVersionIdcomparison can be used only after this, to decide if we need to proceed with the metadata update (e.g. not the location, but the other fields: tags, ...) or can skip it
→ here the function is used to compute partAlreadyAtDest, which would make _getAndPutData silently "hide" the error if there is already some data at the destination BUT the metadata we are trying to replicate is newer.
→ _getAndPutData() should probably return the max/last microVersionId it received (in case of conflict) or nothing it is wrote all data successfully (i.e. no conflict)
(the logic here is what should happen on microVersionId conflict on metadata, not here on VersionIdConflict)
There was a problem hiding this comment.
also in that case the x-scal-replication-content sent to cloudserver MUST not be DATA+METADATA anymore, but be "downgraded" to METADATA (to keep the existing data)
There was a problem hiding this comment.
Ok I see this is a left over from some missunderstanding that I had.
This function can be completely removed, but before I do it, there is one thing i wanna confirm :
In the design, we said in case of putData collision, we would still do the putMetadata, and let cloudserver on putMetadata deal with stale microVersionId
But as you said, since with the putData error we get a microVersionId returned, it is possible for backbeat to do the comparison directly here to avoid a useless putMetadata.
So I wanna decide which thing we should do : I would prefer skipping the putMetadata in backbeat if its useless, but also I wonder if there was a specific reason (that i can't find) we went with the design of sending putMetada regardless of the result of putData ?
There was a problem hiding this comment.
Ok I talked with Maël and review again, I'm removing this function and making the improvement to check the microversion id on conflict to avoid useless put metadata
| if (err instanceof MicroVersionIdAlreadyStoredException) { | ||
| log.info('replication completed via cascade loop: ' + | ||
| 'object already at destination with the same revision', | ||
| { entry: sourceEntry.getLogInfo() }); | ||
| this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log }); | ||
| return done(null, { committable: false }); | ||
| } | ||
| if (err instanceof StaleMicroVersionIdException) { | ||
| log.info('replication completed: destination already holds ' + | ||
| 'this version with a newer revision', | ||
| { entry: sourceEntry.getLogInfo() }); | ||
| 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 }); |
There was a problem hiding this comment.
we do exactly the same in all 3 branches : do we need 2 errors and different logs?
if (!err || err instanceof ...) {
log.debug('replication succeeded for object, publishing ' +
'replication status as COMPLETED',
{ entry: sourceEntry.getLogInfo(), err });
this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log });
}
There was a problem hiding this comment.
I think thats what I had a few week ago + the logs but I got suggested a refactoring
Are you sure you want to drop log in case of conflicts on microVersionId ? Seems like something we might wanna know about in case of issue
francoisferrand
left a comment
There was a problem hiding this comment.
handling of MPU is not correct : each "replicant" must write their own object fully, there is no situation where 2 "sources" each replicate part of the data (nor a way for these to identify the data they have written). Each source create the metadata document with the parts they uploaded, so they MUST successfully upload all parts data and MUST not have a conflict on any part.
Waiting for approvalThe following approvals are needed before I can proceed with the merge:
The following reviewers are expecting changes from the author, or must review again: |
| if (err instanceof MicroVersionIdAlreadyStoredException) { | ||
| log.info('replication completed via cascade loop: ' + | ||
| 'object already at destination with the same revision', | ||
| { entry: sourceEntry.getLogInfo() }); | ||
| this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log }); | ||
| return done(null, { committable: false }); | ||
| } | ||
| if (err instanceof StaleMicroVersionIdException) { | ||
| log.info('replication completed: destination already holds ' + | ||
| 'this version with a newer revision', | ||
| { entry: sourceEntry.getLogInfo() }); | ||
| this._publishReplicationStatus(sourceEntry, 'COMPLETED', { kafkaEntry, log }); | ||
| return done(null, { committable: false }); | ||
| } |
There was a problem hiding this comment.
there is a risk of creating orphans here, please create a followup:
- new object was created → try to replicate DATA+META
- putDataParts succeeds (no conflict)
- before we could put metadata, another site was able to putData (same as us, not metadata → impossible to detect conflict) and putMetadata
- putMetadata will thus fail with MicroVersionIdAlreadyStoredException/StaleMicroVersionIdException
→ in that case, data created in 2. must be deleted, i.e. call _deleteOrphans. _deleteOrphans MUST NOT be called if this was a META-only update / if we did not call putDataParts
There was a problem hiding this comment.
Yeah I see, created this : https://scality.atlassian.net/browse/BB-802
Looking from afar i think it shouldn't be too hard to handle, I will address it when we merge this pr otherwise this one will never be merged
5c4ed70 to
7d2eda2
Compare
67f10a1 to
7afe54b
Compare
| return cb(null, destLocations); | ||
| // When no new data locations were written, force metadata-only mode (noNewDataLocations) | ||
| // so cloudserver preserves the destination's existing location field instead of overwriting it | ||
| const noNewDataLocations = destLocations.length === 0; |
There was a problem hiding this comment.
AFAIU this still does not implement what was agreed in the MPU thread: if some parts return partAlreadyAtDest and some were written by us, destLocations is a partial list, noNewDataLocations is false, and we proceed to putMetadata with only the parts we wrote → corrupted location metadata at destination. A conflict on any part means another replicant already wrote the metadata: we must delete all the parts we uploaded and downgrade to a metadata-only update. i.e. the check should be destLocations.length !== partResults.length, not === 0
There was a problem hiding this comment.
Yes sorry I had not addressed comments yet when you did the review I was on cloudserver, it's updated now
| MicroVersionId: entry.getMicroVersionId() | ||
| ? encodeMicroVersionId(entry.getMicroVersionId()) : '', |
There was a problem hiding this comment.
the empty-string convention is one of the open topics on scality/cloudserver#6179. Is the shape settled now?
There was a problem hiding this comment.
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 { errors, jsutil, versioning } = require('arsenal'); | ||
| const { ObjectMDLocation } = require('arsenal').models; | ||
| const { ReplicationConfiguration } = require('arsenal').models; | ||
| const { encode: encodeMicroVersionId, decode } = versioning.VersionID; |
There was a problem hiding this comment.
nit: rename both or neither; decode unqualified next to encodeMicroVersionId reads as if they operate on different things
7afe54b to
acde116
Compare
acde116 to
50b6765
Compare
38d87c8 to
29d498b
Compare
| if (err instanceof MicroVersionIdAlreadyStoredException || | ||
| err instanceof StaleMicroVersionIdException) { | ||
| return cbOnce(err); | ||
| } |
There was a problem hiding this comment.
do we need the condition here? Can't we just log the error (like all other errors) and pass it up in these case as well?
(the main difference is that we would get a ERROR log instead of INFO... and log here instead of in the continuation callback)
There was a problem hiding this comment.
I think its nicer to keep, these arent unexpected errors, and they are logged already in the replication outcome.
I moved them with the if (err.ObjNotFound) above though, as its a similar situation : expected error
| entry: destEntry.getLogInfo(), | ||
| error: destMvIdRaw.message, | ||
| }); | ||
| return doneOnce(destMvIdRaw); |
There was a problem hiding this comment.
should still be a collision : even if we can't parse the microVersionID, we are still in the case "data already at destination", so we shoud still try to write the metadata anyway ?
(the only difference is that we can't really make the optimization of skipping the metadata updated if the microVersionId is already up to date on target)
There was a problem hiding this comment.
Yes, I updated the code, this will be a situation where we still do a putMetadata.
I also moved the decoding to another place, more centralized
| // Another replicant already stored the object data | ||
| // Compare microVersionIds to decide what to do with metadata: | ||
| // - source is newer : proceed in metadata-only mode | ||
| // - same or older : skip putMetadata | ||
| const destMvId = collisionResult.destMvIdRaw; | ||
| const srcMvId = sourceEntry.getMicroVersionId() || null; | ||
| const isLoop = srcMvId === destMvId; | ||
| const isStale = destMvId !== null && | ||
| (srcMvId === null || srcMvId > destMvId); |
There was a problem hiding this comment.
this is where compareMicroVersionId shines, please use the arsenal function:
- it makes the intent explicit (just comparing micro version id)
- the function should handle the corner cases itself ("null" micro version id...)
- we don't actually need to differentiate loop vs stale here
It would look something like this, i.e. we skip if we receive the destination micro version id (field present and no parse error) AND the micro version id is newer or equal at destination
const skipPutMetadata = destMvId !== undefined && compareMicroVersoinId(sourceEntry.getMicroVersionId(), destMvId) <= 0;
return cb(null, [], true, skipPutMetadata);
| if (err instanceof VersionIdCollisionException) { | ||
| const destMvIdRaw = err.microVersionId | ||
| ? decodeMicroVersionId(err.microVersionId) : null; | ||
| if (destMvIdRaw instanceof Error) { |
There was a problem hiding this comment.
nit: I wonder if it is best to do the decoding here, or just pass the field "verbatim" in the part and let _getAndPutData() decode
- it would avoid useless decoding of every parts' microVersionId
- it would increase locally (
- but the code here still needs to make sure if forwards both the microVersionId "value" AND the information that the field was indeed provided
| // update location, replication status and put metadata in | ||
| // target bucket | ||
| (destLocations, next) => { | ||
| (destLocations, noNewDataLocations, skipMetadata, next) => { |
There was a problem hiding this comment.
these 2 parameters are not independent, there are actually only 3 cases:
- no conflict (i.e. as before)
- conflict with metadata already up to date
- conflict but we have newer metadata which need to be replicated
not sure how best to handle this, maybe an object ("conflict") with the microVersionId : so we also improve locality by performing the microVersionId comparison (optimization) here?
→ undefined in the usual case (no conflict), don't even need the false like line 972
→ { microVersionId: ... } (possibly with the "conflict" field you already have) otherwise ; skip if compareMicroVersionId() <= 0
There was a problem hiding this comment.
I updated it with a conflict object and move some logic around into a helper function as we still need it in 2 different places
| destEntry.setLocation(location); | ||
| this._putMetadata(destEntry, false, log, next); | ||
| (destLocations, noNewDataLocations, skipMetadata, next) => { | ||
| if (skipMetadata) { |
There was a problem hiding this comment.
this block is duplicated and not trivial (it handles putData conflict resolution) : should be written just once... does it makes sense do do this in _putMetadata, by adding it another parameter for putData conflict ?
There was a problem hiding this comment.
I'm gonna refactor this but I prefer extracting a helper functoin that putting it in putMetadata.
It looks very weird to call a function with an object that's used at the top of the function so that the function itself decides whether it should conitnue or not
| 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'); |
There was a problem hiding this comment.
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.
e214df0 to
b4a44d3
Compare
| // 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) { |
There was a problem hiding this comment.
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
b4a44d3 to
8580536
Compare
|
The code is getting smaller after each review 😆 |
| } | ||
| } | ||
| const comparison = compareMicroVersionId(sourceMicroVersionId, destMvId); | ||
| return destMvId !== null && |
There was a problem hiding this comment.
May completely remove the null check if I end up update the compareMicroVersionId function.
Still reviewing cloudserver, haven't done it/decided yet
Issue: BB-767
Related PRs :
Arsenal : scality/Arsenal#2628
Cloudserver : scality/cloudserver#6179
CloudserverClient : scality/cloudserverclient#24
S3utils : scality/s3utils#395