Skip to content

Conversation

@DaveCTurner
Copy link
Contributor

In #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 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.
@DaveCTurner DaveCTurner added >test Issues or PRs that are addressing/adding tests :Distributed Coordination/Snapshot/Restore Anything directly related to the `_snapshot/*` APIs v9.3.0 labels Nov 24, 2025
@elasticsearchmachine elasticsearchmachine added the Team:Distributed Coordination Meta label for Distributed Coordination team label Nov 24, 2025
@elasticsearchmachine
Copy link
Collaborator

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))) {
Copy link
Contributor

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.

https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-writes.html#conditional-error-response

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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()
Copy link
Contributor

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)

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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()
Copy link
Contributor

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));
Copy link
Contributor

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?

Copy link
Contributor Author

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
joshua-adams-1 previously approved these changes Nov 25, 2025
Copy link
Contributor

@joshua-adams-1 joshua-adams-1 left a 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

@DaveCTurner DaveCTurner dismissed joshua-adams-1’s stale review November 25, 2025 15:16

Now that #138569 is merged this is simpler, but at least different enough to deserve another look.

Comment on lines +651 to +657
final var iterator = ifMatch.iterator();
if (iterator.hasNext()) {
final var result = iterator.next();
if (iterator.hasNext() == false) {
return result;
}
}
Copy link
Contributor

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.

Comment on lines +434 to +439
responseCode.set(
ESTestCase.randomFrom(
existingContents == null ? RestStatus.NOT_FOUND : RestStatus.PRECONDITION_FAILED,
RestStatus.CONFLICT
)
);
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

@mhl-b mhl-b left a comment

Choose a reason for hiding this comment

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

LGTM

@DaveCTurner DaveCTurner merged commit 594a373 into elastic:main Nov 26, 2025
34 checks passed
ncordon pushed a commit to ncordon/elasticsearch that referenced this pull request Nov 26, 2025
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Distributed Coordination/Snapshot/Restore Anything directly related to the `_snapshot/*` APIs Team:Distributed Coordination Meta label for Distributed Coordination team >test Issues or PRs that are addressing/adding tests v9.3.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants