-
Notifications
You must be signed in to change notification settings - Fork 25.6k
Add full conditional write support to S3 test fixture #138542
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add full conditional write support to S3 test fixture #138542
Conversation
In elastic#133030 we added limited support for conditional writes in `S3HttpHandler`, allowing callers to prevent overwriting an existing blob with an `If-None-Match: *` precondition header. This commit extends the implementation to include support for the `If-Match: <etag>` precondition header allowing callers to perform atomic compare-and-set operations which overwrite existing objects.
|
Pinging @elastic/es-distributed-coordination (Team:Distributed Coordination) |
| if (requireExistingETag != null) { | ||
| final var success = new AtomicBoolean(true); | ||
| blobs.compute(path, (ignoredPath, existingContents) -> { | ||
| if (existingContents != null && requireExistingETag.equals(getEtagFromContents(existingContents))) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there is no existing object and it's If-Match it should return 404. I assume in this case it would return 412 precondition failed.
If there's no current object version with the same name, or if the current object version is a delete marker, the operation fails with a 404 Not Found error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe a test for If-Match for non-existing object.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah thanks, well spotted. Bit of an odd choice tbh but I'll try and find a way to match that behaviour.
| if (e instanceof SdkServiceException sdkServiceException | ||
| && sdkServiceException.statusCode() == RestStatus.NOT_FOUND.getStatus()) { | ||
| // NOT_FOUND is what we wanted | ||
| logger.atDebug() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think there is a change of behaviour. CMIIW, but previously if abortMultiPartUpload threw a SdkServiceException then the exception would propagate upwards and get caught in the outer catch block here, and then throw a NoSuchFileException to the user. Now, since we're swallowing the exception, this isn't true.
If this is an intentional change in behaviour then I think it worthy enough for a unit test (as we don't want this code to change in future and then start returning an exception were previously there was none)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's correct. This is covered by tests already in this PR - it's the reason why 5a9eace failed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll open a separate PR to address this, it's somewhat orthogonal to the point of this PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok see #138569
| .log("multipart upload of [{}] with ID [{}] not found on abort", blobName, uploadId); | ||
| } else { | ||
| // aborting the upload on failure is a best-effort cleanup step - if it fails then we must just move on | ||
| logger.atWarn() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TIL that there was an alternate logging format!
| if (task.status == RestStatus.PRECONDITION_FAILED) { | ||
| assertNotNull(handler.getUpload(task.uploadId)); | ||
| } else { | ||
| assertThat(task.status, oneOf(RestStatus.OK, RestStatus.CONFLICT)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically, this test is only checking that of the four requests, exactly one succeeds, but it doesn't assert on the order we expect the rest statuses to be returned.
List<TestWriteTask> successfulTasks = tasks.stream().filter(task -> task.status == RestStatus.OK).toList();
assertThat(successfulTasks, hasSize(1));
passes if the first three requests fail and the fourth succeeds, and then again
assertThat(task.status, oneOf(RestStatus.OK, RestStatus.CONFLICT));
is indifferent to the order.
Is there value in making stronger assertions about when we expect the RestStatus to be OK versus CONFLICT? Ie, we put an object that doesn't exist, then expect OK, then put the same object again N times each expecting CONFLICT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're running the requests in parallel and have no expectations about which one of them might win the race to succeed. We just need to know that the other 3 fail.
joshua-adams-1
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM but best to wait for @mhl-b to approve too in case I've missed something
Now that #138569 is merged this is simpler, but at least different enough to deserve another look.
| final var iterator = ifMatch.iterator(); | ||
| if (iterator.hasNext()) { | ||
| final var result = iterator.next(); | ||
| if (iterator.hasNext() == false) { | ||
| return result; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think it should be exactly-one-item, not last-item.
| responseCode.set( | ||
| ESTestCase.randomFrom( | ||
| existingContents == null ? RestStatus.NOT_FOUND : RestStatus.PRECONDITION_FAILED, | ||
| RestStatus.CONFLICT | ||
| ) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you need randomization for the conflict? Is it because we synchronize on blobs and cant detect conflicting uploads, i.e. handler linearize writes?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AWS docs are not 100% clear on exactly what conditions lead to a 409 here, but it doesn't really matter from our point of view anyway: we just need to make sure we handle both 409 and 412s as acceptable failures.
mhl-b
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
In elastic#133030 we added limited support for conditional writes in `S3HttpHandler`, allowing callers to prevent overwriting an existing blob with an `If-None-Match: *` precondition header. This commit extends the implementation to include support for the `If-Match: <etag>` precondition header allowing callers to perform atomic compare-and-set operations which overwrite existing objects.
In #133030 we added limited support for conditional writes in
S3HttpHandler, allowing callers to prevent overwriting an existingblob with an
If-None-Match: *precondition header. This commit extendsthe implementation to include support for the
If-Match: <etag>precondition header allowing callers to perform atomic compare-and-set
operations which overwrite existing objects.