-
Notifications
You must be signed in to change notification settings - Fork 25.9k
Avoid contacting master on noop mapping update #102915
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
Changes from all commits
56856c2
9f23295
c5ea066
e3dd8dd
74845eb
e91f8e3
3bfdc15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| import org.elasticsearch.action.update.UpdateResponse; | ||
| import org.elasticsearch.client.internal.Requests; | ||
| import org.elasticsearch.cluster.metadata.IndexMetadata; | ||
| import org.elasticsearch.common.compress.CompressedXContent; | ||
| import org.elasticsearch.common.lucene.uid.Versions; | ||
| import org.elasticsearch.common.settings.Settings; | ||
| import org.elasticsearch.common.util.concurrent.EsRejectedExecutionException; | ||
|
|
@@ -36,6 +37,7 @@ | |
| import org.elasticsearch.index.bulk.stats.ShardBulkStats; | ||
| import org.elasticsearch.index.engine.Engine; | ||
| import org.elasticsearch.index.engine.VersionConflictEngineException; | ||
| import org.elasticsearch.index.mapper.DocumentMapper; | ||
| import org.elasticsearch.index.mapper.MapperService; | ||
| import org.elasticsearch.index.mapper.Mapping; | ||
| import org.elasticsearch.index.mapper.MetadataFieldMapper; | ||
|
|
@@ -262,7 +264,13 @@ public void testExecuteBulkIndexRequestWithMappingUpdates() throws Exception { | |
| when(shard.applyIndexOperationOnPrimary(anyLong(), any(), any(), anyLong(), anyLong(), anyLong(), anyBoolean())).thenReturn( | ||
| mappingUpdate | ||
| ); | ||
| when(shard.mapperService()).thenReturn(mock(MapperService.class)); | ||
| MapperService mapperService = mock(MapperService.class); | ||
| when(shard.mapperService()).thenReturn(mapperService); | ||
|
|
||
| // merged mapping source needs to be different from previous one for the master node to be invoked | ||
| DocumentMapper mergedDoc = mock(DocumentMapper.class); | ||
| when(mapperService.merge(any(), any(CompressedXContent.class), any())).thenReturn(mergedDoc); | ||
| when(mergedDoc.mappingSource()).thenReturn(CompressedXContent.fromJSON("{}")); | ||
|
|
||
| randomlySetIgnoredPrimaryResponse(items[0]); | ||
|
|
||
|
|
@@ -875,9 +883,14 @@ public void testRetries() throws Exception { | |
| }); | ||
| when(shard.indexSettings()).thenReturn(indexSettings); | ||
| when(shard.shardId()).thenReturn(shardId); | ||
| when(shard.mapperService()).thenReturn(mock(MapperService.class)); | ||
| MapperService mapperService = mock(MapperService.class); | ||
| when(shard.mapperService()).thenReturn(mapperService); | ||
| when(shard.getBulkOperationListener()).thenReturn(mock(ShardBulkStats.class)); | ||
|
|
||
| DocumentMapper mergedDocMapper = mock(DocumentMapper.class); | ||
| when(mergedDocMapper.mappingSource()).thenReturn(CompressedXContent.fromJSON("{}")); | ||
| when(mapperService.merge(any(), any(CompressedXContent.class), any())).thenReturn(mergedDocMapper); | ||
|
|
||
| UpdateHelper updateHelper = mock(UpdateHelper.class); | ||
| when(updateHelper.prepare(any(), eq(shard), any())).thenReturn( | ||
| new UpdateHelper.Result( | ||
|
|
@@ -964,7 +977,13 @@ public void testForceExecutionOnRejectionAfterMappingUpdate() throws Exception { | |
| success2 | ||
| ); | ||
| when(shard.getFailedIndexResult(any(EsRejectedExecutionException.class), anyLong(), anyString())).thenCallRealMethod(); | ||
| when(shard.mapperService()).thenReturn(mock(MapperService.class)); | ||
| MapperService mapperService = mock(MapperService.class); | ||
| when(shard.mapperService()).thenReturn(mapperService); | ||
|
|
||
| // merged mapping source needs to be different from previous one for the master node to be invoked | ||
| DocumentMapper mergedDoc = mock(DocumentMapper.class); | ||
| when(mapperService.merge(any(), any(CompressedXContent.class), any())).thenReturn(mergedDoc); | ||
| when(mergedDoc.mappingSource()).thenReturn(CompressedXContent.fromJSON("{}")); | ||
|
|
||
| randomlySetIgnoredPrimaryResponse(items[0]); | ||
|
|
||
|
|
@@ -1072,6 +1091,136 @@ public void testPerformOnPrimaryReportsBulkStats() throws Exception { | |
| latch.await(); | ||
| } | ||
|
|
||
| public void testNoopMappingUpdateInfiniteLoopPrevention() throws Exception { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this test tests a non-existing scenario? The noop prevention would only concern a case where the mapping used during parsing is different from the mapping used during preflight, containing the mapping changes added. And if so, we'd expect any retry to always succeed?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I hope it does :) This is meant as a safety net in case there's an unexpected bug that lead to a situation where we accept a field in
felixbarny marked this conversation as resolved.
|
||
| Engine.IndexResult mappingUpdate = new Engine.IndexResult( | ||
| new Mapping(mock(RootObjectMapper.class), new MetadataFieldMapper[0], Collections.emptyMap()), | ||
| "id" | ||
| ); | ||
|
|
||
| IndexShard shard = mockShard(); | ||
| when(shard.applyIndexOperationOnPrimary(anyLong(), any(), any(), anyLong(), anyLong(), anyLong(), anyBoolean())).thenReturn( | ||
| mappingUpdate | ||
| ); | ||
| MapperService mapperService = mock(MapperService.class); | ||
| when(shard.mapperService()).thenReturn(mapperService); | ||
|
|
||
| DocumentMapper documentMapper = mock(DocumentMapper.class); | ||
| when(documentMapper.mappingSource()).thenReturn(CompressedXContent.fromJSON("{}")); | ||
| // returning the current document mapper as the merge result to simulate a noop mapping update | ||
| when(mapperService.documentMapper()).thenReturn(documentMapper); | ||
| when(mapperService.merge(any(), any(CompressedXContent.class), any())).thenReturn(documentMapper); | ||
|
|
||
| UpdateHelper updateHelper = mock(UpdateHelper.class); | ||
| when(updateHelper.prepare(any(), eq(shard), any())).thenReturn( | ||
| new UpdateHelper.Result( | ||
| new IndexRequest("index").id("id").source(Requests.INDEX_CONTENT_TYPE, "field", "value"), | ||
| randomBoolean() ? DocWriteResponse.Result.CREATED : DocWriteResponse.Result.UPDATED, | ||
| Collections.singletonMap("field", "value"), | ||
| Requests.INDEX_CONTENT_TYPE | ||
| ) | ||
| ); | ||
|
|
||
| BulkItemRequest[] items = new BulkItemRequest[] { | ||
| new BulkItemRequest(0, new UpdateRequest("index", "id").doc(Requests.INDEX_CONTENT_TYPE, "field", "value")) }; | ||
| BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, RefreshPolicy.NONE, items); | ||
|
|
||
| AssertionError error = expectThrows( | ||
| AssertionError.class, | ||
| () -> TransportShardBulkAction.performOnPrimary( | ||
| bulkShardRequest, | ||
| shard, | ||
| updateHelper, | ||
| threadPool::absoluteTimeInMillis, | ||
| (update, shardId, listener) -> fail("the master should not be contacted as the operation yielded a noop mapping update"), | ||
| listener -> listener.onResponse(null), | ||
| ActionTestUtils.assertNoFailureListener(result -> {}), | ||
| threadPool, | ||
| Names.WRITE | ||
| ) | ||
| ); | ||
| assertThat( | ||
| error.getMessage(), | ||
| equalTo( | ||
| "On retry, this indexing request resulted in another noop mapping update." | ||
| + " Failing the indexing operation to prevent an infinite retry loop." | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| public void testNoopMappingUpdateSuccessOnRetry() throws Exception { | ||
| Engine.IndexResult mappingUpdate = new Engine.IndexResult( | ||
| new Mapping(mock(RootObjectMapper.class), new MetadataFieldMapper[0], Collections.emptyMap()), | ||
| "id" | ||
| ); | ||
| Translog.Location resultLocation = new Translog.Location(42, 42, 42); | ||
| Engine.IndexResult successfulResult = new FakeIndexResult(1, 1, 10, true, resultLocation, "id"); | ||
|
|
||
| IndexShard shard = mockShard(); | ||
| when(shard.applyIndexOperationOnPrimary(anyLong(), any(), any(), anyLong(), anyLong(), anyLong(), anyBoolean())).thenReturn( | ||
| // on the first invocation, return a result that attempts a mapping update | ||
| // the mapping update will be a noop and the operation is retired without contacting the master | ||
| mappingUpdate, | ||
| // the second invocation also returns a mapping update result | ||
| // this doesn't trigger the infinite loop detection because MapperService#mappingVersion returns a different mapping version | ||
| mappingUpdate, | ||
| // on the third attempt, return a successful result, indicating that no mapping update needs to be executed | ||
| successfulResult | ||
| ); | ||
|
|
||
| MapperService mapperService = mock(MapperService.class); | ||
| when(shard.mapperService()).thenReturn(mapperService); | ||
|
|
||
| DocumentMapper documentMapper = mock(DocumentMapper.class); | ||
| when(documentMapper.mappingSource()).thenReturn(CompressedXContent.fromJSON("{}")); | ||
| when(mapperService.documentMapper()).thenReturn(documentMapper); | ||
| // returning the current document mapper as the merge result to simulate a noop mapping update | ||
| when(mapperService.merge(any(), any(CompressedXContent.class), any())).thenReturn(documentMapper); | ||
| // on the second invocation, the mapping version is incremented | ||
| // so that the second mapping update attempt doesn't trigger the infinite loop prevention | ||
| when(mapperService.mappingVersion()).thenReturn(0L, 1L); | ||
|
|
||
| UpdateHelper updateHelper = mock(UpdateHelper.class); | ||
| when(updateHelper.prepare(any(), eq(shard), any())).thenReturn( | ||
| new UpdateHelper.Result( | ||
| new IndexRequest("index").id("id").source(Requests.INDEX_CONTENT_TYPE, "field", "value"), | ||
| randomBoolean() ? DocWriteResponse.Result.CREATED : DocWriteResponse.Result.UPDATED, | ||
| Collections.singletonMap("field", "value"), | ||
| Requests.INDEX_CONTENT_TYPE | ||
| ) | ||
| ); | ||
|
|
||
| BulkItemRequest[] items = new BulkItemRequest[] { | ||
| new BulkItemRequest(0, new UpdateRequest("index", "id").doc(Requests.INDEX_CONTENT_TYPE, "field", "value")) }; | ||
| BulkShardRequest bulkShardRequest = new BulkShardRequest(shardId, RefreshPolicy.NONE, items); | ||
|
|
||
| final CountDownLatch latch = new CountDownLatch(1); | ||
| TransportShardBulkAction.performOnPrimary( | ||
| bulkShardRequest, | ||
| shard, | ||
| updateHelper, | ||
| threadPool::absoluteTimeInMillis, | ||
| (update, shardId, listener) -> fail("the master should not be contacted as the operation yielded a noop mapping update"), | ||
| listener -> listener.onFailure(new IllegalStateException("no failure expected")), | ||
| new LatchedActionListener<>(ActionTestUtils.assertNoFailureListener(result -> { | ||
| BulkItemResponse primaryResponse = result.replicaRequest().items()[0].getPrimaryResponse(); | ||
| assertFalse(primaryResponse.isFailed()); | ||
| }), latch), | ||
| threadPool, | ||
| Names.WRITE | ||
| ); | ||
|
|
||
| latch.await(); | ||
| verify(mapperService, times(2)).merge(any(), any(CompressedXContent.class), any()); | ||
| } | ||
|
|
||
| private IndexShard mockShard() { | ||
| IndexShard shard = mock(IndexShard.class); | ||
| when(shard.shardId()).thenReturn(shardId); | ||
| when(shard.getBulkOperationListener()).thenReturn(mock(ShardBulkStats.class)); | ||
| when(shard.getFailedIndexResult(any(Exception.class), anyLong(), anyString())).thenCallRealMethod(); | ||
| return shard; | ||
| } | ||
|
|
||
| private void randomlySetIgnoredPrimaryResponse(BulkItemRequest primaryRequest) { | ||
| if (randomBoolean()) { | ||
| // add a response to the request and thereby check that it is ignored for the primary. | ||
|
|
||
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 am not sure I follow why this infinite loop detection is necessary. I'd expect that since all dynamic fields from previous parse is now in the mappings, the retry would always succeed in one go?
The only case where I could see this not being true is updates, where a new version of the doc could appear and thus a new dynamic mapping update could potentially be required. That would be at odds with the detection here I think?
To some extent having some detection in assertions would be good, but it would have to not trigger for updates. And then be the same for other mapping update retries. I could accept not adding that now though.
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 all goes right, this is true. However, there's no strict guarantee about that. And the failure mode I have in mind is not related to concurrency. The risk comes from the fact that there are two distinct places that reason about a mapping's size. One is
DocumentParserContext, and the other isMapping::merge/MappingLookup. InDocumentParserContext, we're just estimating the size of the mapping when adding dynamic mappers. If there's a bug in that estimation (#102885 tries to make that less likely), there's a chance that we add more dynamic mappers than the mapping can hold. We normally find out if we have accepted too many dynamic mappers inDocumentParserContextif the preflight check throws an exception. However, with #96235, for indices that have enabled the settingignore_dynamic_beyond_limit, the preflight check won't fail but silently ignore fields that are beyond the limit. After realizing that a noop mapping update has been requested, the indexing request gets retried. However, the mapping hasn't changed in the meantime. Therefore, when the document gets parsed, we're adding the same dynamic mapper inDocumentParserContextagain, which leads to the same dynamic mapping update that the preflight check will ignore again and we've entered an infinite loop.Maybe another solution to this could be that the preflight check merge reason, in contrast to the auto-update merge reason doesn't ignore fields that are beyond the limit but throws an exception instead. However, due to a race condition, we might have added a dynamic mapper under the assumption that enough space is still available. After accepting the mapper, but before the preflight check, the mapping might get updated with a version that already puts us at the limit. If we then throw a validation error during the preflight check, it will lead to data loss instead of ignoring the fields that are above the limit.
You're referring to the situation when the
retry_on_conflictparameter is set, right? I think this case is covered by the fact thatresetForExecutionRetry()resetsnoopMappingUpdateRetryForMappingVersion. This means that the infinite loop prevention only kicks in if theBulkPrimaryExecutionContexthas been reset vianoopMappingUpdateRetryForMappingVersiontwice in a row, and if the mapping also hasn't changed in the meantime.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.
A way to reproduce the infinite loop is to purposefully introduce a bug in how
DocumentParserContextcounts the size of aMapper.Builderin #96235:elasticsearch/server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java
Line 325 in 94f25f5
If you change that to
int builderMapperSize = 1;and executeorg.elasticsearch.index.mapper.DynamicMappingIT#testIgnoreDynamicBeyondLimitSingleMultiField, it will trigger this.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.
No, I am referring to a case where an update results in a mapping update and thus a retry afterwards. That could potentially be a noop mapping update (due to concurrency). The retried update could then result in new dynamic fields.
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 it is safe towards such updates in the current form, since:
I think #96235 does not change this, but the argument that we'll not run into it becomes more subtle.
A worry is that an update will fail with this error if it first adds a field f (noop detected) and then another field g (that is also a noop, towards same version). That seems impossible now, but hard to verify.
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.
About the potential discrepancy between what the mapper results in and what the document parser holds, I suggest to add testing to verify this as well as some assertion to ensure they are aligned at relevant times. That belongs to the other PR though (perhaps we can have the size tracking as a separate PR?).
Uh oh!
There was an error while loading. Please reload this page.
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.
This is what #102885 is about. It significantly simplifies the calculation of how a mapper counts against the field limit and aligns the implementations as much as possible (
Mapper.Builder#getTotalFieldsCount,Mapper#getTotalFieldsCount,MappingLookup#getTotalFieldsCount, andMappingLookup#checkFieldLimit).Uh oh!
There was an error while loading. Please reload this page.
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've also added an assertion here:
befd7f6