Jelajahi Sumber

Add ignored field values to synthetic source (#107567)

* Add ignored field values to synthetic source

* Update docs/changelog/107567.yaml

* initialize map

* yaml fix

* add node feature

* add comments

* small fixes

* missing cluster feature in yaml

* constants for chars, stored fields

* remove duplicate method

* throw exception on parse failure

* remove Base64 encoding

* add assert on IgnoredValuesFieldMapper::write

* changes from review

* simplify logic

* add comment

* rename classes

* rename _ignored_values to _ignored_source

* rename _ignored_values to _ignored_source
Kostas Krikellas 1 tahun lalu
induk
melakukan
3183e6d6c9
18 mengubah file dengan 1059 tambahan dan 166 penghapusan
  1. 5 0
      docs/changelog/107567.yaml
  2. 151 0
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.create/20_synthetic_source.yml
  3. 26 25
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/nodes.stats/11_indices_metrics.yml
  4. 1 0
      server/src/main/java/module-info.java
  5. 33 0
      server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java
  6. 3 138
      server/src/main/java/org/elasticsearch/index/mapper/IgnoreMalformedStoredValues.java
  7. 133 0
      server/src/main/java/org/elasticsearch/index/mapper/IgnoredSourceFieldMapper.java
  8. 24 0
      server/src/main/java/org/elasticsearch/index/mapper/MapperFeatures.java
  9. 19 1
      server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java
  10. 17 0
      server/src/main/java/org/elasticsearch/index/mapper/SourceLoader.java
  11. 399 0
      server/src/main/java/org/elasticsearch/index/mapper/XContentDataHelper.java
  12. 2 0
      server/src/main/java/org/elasticsearch/indices/IndicesModule.java
  13. 1 0
      server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification
  14. 3 2
      server/src/test/java/org/elasticsearch/index/mapper/DocCountFieldMapperTests.java
  15. 2 0
      server/src/test/java/org/elasticsearch/index/mapper/DocumentMapperTests.java
  16. 148 0
      server/src/test/java/org/elasticsearch/index/mapper/IgnoredSourceFieldMapperTests.java
  17. 90 0
      server/src/test/java/org/elasticsearch/index/mapper/XContentDataHelperTests.java
  18. 2 0
      server/src/test/java/org/elasticsearch/indices/IndicesModuleTests.java

+ 5 - 0
docs/changelog/107567.yaml

@@ -0,0 +1,5 @@
+pr: 107567
+summary: Add ignored field values to synthetic source
+area: Mapping
+type: enhancement
+issues: []

+ 151 - 0
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.create/20_synthetic_source.yml

@@ -36,3 +36,154 @@ nested is disabled:
                 properties:
                   foo:
                     type: keyword
+
+---
+object with unmapped fields:
+  - requires:
+      cluster_features: ["mapper.track_ignored_source"]
+      reason: requires tracking ignored values
+
+  - do:
+      indices.create:
+        index: test
+        body:
+          settings:
+            index:
+              mapping:
+                total_fields:
+                  ignore_dynamic_beyond_limit: true
+                  limit: 1
+
+          mappings:
+            _source:
+              mode: synthetic
+            properties:
+              name:
+                type: keyword
+
+  - do:
+      bulk:
+        index: test
+        refresh: true
+        body:
+          - '{ "create": { } }'
+          - '{ "name": "aaaa", "some_string": "AaAa", "some_int": 1000, "some_double": 123.456789, "some_bool": true, "a.very.deeply.nested.field": "AAAA" }'
+          - '{ "create": { } }'
+          - '{ "name": "bbbb", "some_string": "BbBb", "some_int": 2000, "some_double": 321.987654, "some_bool": false, "a.very.deeply.nested.field": "BBBB" }'
+
+  - do:
+      search:
+        index: test
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._source.name: aaaa }
+  - match: { hits.hits.0._source.some_string: AaAa }
+  - match: { hits.hits.0._source.some_int: 1000 }
+  - match: { hits.hits.0._source.some_double: 123.456789 }
+  - match: { hits.hits.0._source.a.very.deeply.nested.field: AAAA }
+  - match: { hits.hits.0._source.some_bool: true }
+  - match: { hits.hits.1._source.name: bbbb }
+  - match: { hits.hits.1._source.some_string: BbBb }
+  - match: { hits.hits.1._source.some_int: 2000 }
+  - match: { hits.hits.1._source.some_double: 321.987654 }
+  - match: { hits.hits.1._source.a.very.deeply.nested.field: BBBB }
+
+
+---
+nested object with unmapped fields:
+  - requires:
+      cluster_features: ["mapper.track_ignored_source"]
+      reason: requires tracking ignored values
+
+  - do:
+      indices.create:
+        index: test
+        body:
+          settings:
+            index:
+              mapping:
+                total_fields:
+                  ignore_dynamic_beyond_limit: true
+                  limit: 3
+
+          mappings:
+            _source:
+              mode: synthetic
+            properties:
+              path:
+                properties:
+                  to:
+                    properties:
+                      name:
+                        type: keyword
+
+  - do:
+      bulk:
+        index: test
+        refresh: true
+        body:
+          - '{ "create": { } }'
+          - '{ "path.to.name": "aaaa", "path.to.surname": "AaAa", "path.some.other.name": "AaAaAa"  }'
+          - '{ "create": { } }'
+          - '{ "path.to.name": "bbbb", "path.to.surname": "BbBb", "path.some.other.name": "BbBbBb"  }'
+
+  - do:
+      search:
+        index: test
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._source.path.to.name: aaaa }
+  - match: { hits.hits.0._source.path.to.surname: AaAa }
+  - match: { hits.hits.0._source.path.some.other.name: AaAaAa }
+  - match: { hits.hits.1._source.path.to.name: bbbb }
+  - match: { hits.hits.1._source.path.to.surname: BbBb }
+  - match: { hits.hits.1._source.path.some.other.name: BbBbBb }
+
+
+---
+empty object with unmapped fields:
+  - requires:
+      cluster_features: ["mapper.track_ignored_source"]
+      reason: requires tracking ignored values
+
+  - do:
+      indices.create:
+        index: test
+        body:
+          settings:
+            index:
+              mapping:
+                total_fields:
+                  ignore_dynamic_beyond_limit: true
+                  limit: 3
+
+          mappings:
+            _source:
+              mode: synthetic
+            properties:
+              path:
+                properties:
+                  to:
+                    properties:
+                      name:
+                        type: keyword
+
+  - do:
+      bulk:
+        index: test
+        refresh: true
+        body:
+          - '{ "create": { } }'
+          - '{ "path.to.surname": "AaAa", "path.some.other.name": "AaAaAa"  }'
+          - '{ "create": { } }'
+          - '{ "path.to.surname": "BbBb", "path.some.other.name": "BbBbBb"  }'
+
+  - do:
+      search:
+        index: test
+
+  - match: { hits.total.value: 2 }
+  - match: { hits.hits.0._source.path.to.surname: AaAa }
+  - match: { hits.hits.0._source.path.some.other.name: AaAaAa }
+  - match: { hits.hits.1._source.path.to.surname: BbBb }
+  - match: { hits.hits.1._source.path.some.other.name: BbBbBb }

+ 26 - 25
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/nodes.stats/11_indices_metrics.yml

@@ -417,8 +417,8 @@
 
   - requires:
       test_runner_features: [arbitrary_key]
-      cluster_features: ["gte_v8.5.0"]
-      reason:  "mappings added in version 8.5.0"
+      cluster_features: ["mapper.track_ignored_source"]
+      reason:  "_ignored_source added to mappings"
 
   - do:
       indices.create:
@@ -476,35 +476,36 @@
   # 4. _field_names
   # 5. _id
   # 6. _ignored
-  # 7. _index
-  # 8. _nested_path
-  # 9. _routing
-  # 10. _seq_no
-  # 11. _source
-  # 12. _tier
-  # 13. _version
-  # 14. @timestamp
-  # 15. authors.age
-  # 16. authors.company
-  # 17. authors.company.keyword
-  # 18. authors.name.last_name
-  # 19. authors.name.first_name
-  # 20. authors.name.full_name
-  # 21. link
-  # 22. title
-  # 23. url
+  # 7. _ignored_source
+  # 8. _index
+  # 9. _nested_path
+  # 10. _routing
+  # 11. _seq_no
+  # 12. _source
+  # 13. _tier
+  # 14. _version
+  # 15. @timestamp
+  # 16. authors.age
+  # 17. authors.company
+  # 18. authors.company.keyword
+  # 19. authors.name.last_name
+  # 20. authors.name.first_name
+  # 21. authors.name.full_name
+  # 22. link
+  # 23. title
+  # 24. url
   # Object mappers:
-  # 24. authors
-  # 25. authors.name
+  # 25. authors
+  # 26. authors.name
   # Runtime field mappers:
-  # 26. a_source_field
+  # 27. a_source_field
 
-  - gte: { nodes.$node_id.indices.mappings.total_count: 26 }
+  - gte: { nodes.$node_id.indices.mappings.total_count: 27 }
   - is_true: nodes.$node_id.indices.mappings.total_estimated_overhead
   - gte: { nodes.$node_id.indices.mappings.total_estimated_overhead_in_bytes: 26624 }
-  - match: { nodes.$node_id.indices.indices.index1.mappings.total_count: 26 }
+  - match: { nodes.$node_id.indices.indices.index1.mappings.total_count: 27 }
   - is_true: nodes.$node_id.indices.indices.index1.mappings.total_estimated_overhead
-  - match: { nodes.$node_id.indices.indices.index1.mappings.total_estimated_overhead_in_bytes: 26624 }
+  - match: { nodes.$node_id.indices.indices.index1.mappings.total_estimated_overhead_in_bytes: 27648 }
 
 ---
 "indices mappings does not exist in shards level":

+ 1 - 0
server/src/main/java/module-info.java

@@ -426,6 +426,7 @@ module org.elasticsearch.server {
             org.elasticsearch.rest.RestFeatures,
             org.elasticsearch.indices.IndicesFeatures,
             org.elasticsearch.action.admin.cluster.allocation.AllocationStatsFeatures,
+            org.elasticsearch.index.mapper.MapperFeatures,
             org.elasticsearch.search.retriever.RetrieversFeatures;
 
     uses org.elasticsearch.plugins.internal.SettingsExtension;

+ 33 - 0
server/src/main/java/org/elasticsearch/index/mapper/DocumentParserContext.java

@@ -104,6 +104,7 @@ public abstract class DocumentParserContext {
     private final MappingParserContext mappingParserContext;
     private final SourceToParse sourceToParse;
     private final Set<String> ignoredFields;
+    private final List<IgnoredSourceFieldMapper.NameValue> ignoredFieldValues;
     private final Map<String, List<Mapper>> dynamicMappers;
     private final DynamicMapperSize dynamicMappersSize;
     private final Map<String, ObjectMapper> dynamicObjectMappers;
@@ -122,6 +123,7 @@ public abstract class DocumentParserContext {
         MappingParserContext mappingParserContext,
         SourceToParse sourceToParse,
         Set<String> ignoreFields,
+        List<IgnoredSourceFieldMapper.NameValue> ignoredFieldValues,
         Map<String, List<Mapper>> dynamicMappers,
         Map<String, ObjectMapper> dynamicObjectMappers,
         Map<String, List<RuntimeField>> dynamicRuntimeFields,
@@ -139,6 +141,7 @@ public abstract class DocumentParserContext {
         this.mappingParserContext = mappingParserContext;
         this.sourceToParse = sourceToParse;
         this.ignoredFields = ignoreFields;
+        this.ignoredFieldValues = ignoredFieldValues;
         this.dynamicMappers = dynamicMappers;
         this.dynamicObjectMappers = dynamicObjectMappers;
         this.dynamicRuntimeFields = dynamicRuntimeFields;
@@ -159,6 +162,7 @@ public abstract class DocumentParserContext {
             in.mappingParserContext,
             in.sourceToParse,
             in.ignoredFields,
+            in.ignoredFieldValues,
             in.dynamicMappers,
             in.dynamicObjectMappers,
             in.dynamicRuntimeFields,
@@ -186,6 +190,7 @@ public abstract class DocumentParserContext {
             mappingParserContext,
             source,
             new HashSet<>(),
+            new ArrayList<>(),
             new HashMap<>(),
             new HashMap<>(),
             new HashMap<>(),
@@ -251,6 +256,20 @@ public abstract class DocumentParserContext {
         return Collections.unmodifiableCollection(ignoredFields);
     }
 
+    /**
+     * Add the given ignored values to the corresponding list.
+     */
+    public final void addIgnoredField(IgnoredSourceFieldMapper.NameValue values) {
+        ignoredFieldValues.add(values);
+    }
+
+    /**
+     * Return the collection of values for fields that have been ignored so far.
+     */
+    public final Collection<IgnoredSourceFieldMapper.NameValue> getIgnoredFieldValues() {
+        return Collections.unmodifiableCollection(ignoredFieldValues);
+    }
+
     /**
      * Add the given {@code field} to the _field_names field
      *
@@ -345,6 +364,20 @@ public abstract class DocumentParserContext {
             int additionalFieldsToAdd = getNewFieldsSize() + mapperSize;
             if (indexSettings().isIgnoreDynamicFieldsBeyondLimit()) {
                 if (mappingLookup.exceedsLimit(indexSettings().getMappingTotalFieldsLimit(), additionalFieldsToAdd)) {
+                    if (indexSettings().getMode().isSyntheticSourceEnabled() || mappingLookup.isSourceSynthetic()) {
+                        try {
+                            int parentOffset = parent() instanceof RootObjectMapper ? 0 : parent().fullPath().length() + 1;
+                            addIgnoredField(
+                                new IgnoredSourceFieldMapper.NameValue(
+                                    mapper.name(),
+                                    parentOffset,
+                                    XContentDataHelper.encodeToken(parser())
+                                )
+                            );
+                        } catch (IOException e) {
+                            throw new IllegalArgumentException("failed to parse field [" + mapper.name() + " ]", e);
+                        }
+                    }
                     addIgnoredField(mapper.name());
                     return false;
                 }

+ 3 - 138
server/src/main/java/org/elasticsearch/index/mapper/IgnoreMalformedStoredValues.java

@@ -10,17 +10,10 @@ package org.elasticsearch.index.mapper;
 
 import org.apache.lucene.document.StoredField;
 import org.apache.lucene.util.BytesRef;
-import org.apache.lucene.util.BytesRefIterator;
-import org.elasticsearch.common.bytes.BytesReference;
-import org.elasticsearch.common.util.ByteUtils;
 import org.elasticsearch.xcontent.XContentBuilder;
 import org.elasticsearch.xcontent.XContentParser;
-import org.elasticsearch.xcontent.XContentParserConfiguration;
-import org.elasticsearch.xcontent.XContentType;
 
 import java.io.IOException;
-import java.math.BigDecimal;
-import java.math.BigInteger;
 import java.util.List;
 import java.util.Map;
 import java.util.stream.Stream;
@@ -32,38 +25,8 @@ import static java.util.Collections.emptyList;
  * {@code _source}.
  */
 public abstract class IgnoreMalformedStoredValues {
-    /**
-     * Build a {@link StoredField} for the value on which the parser is
-     * currently positioned.
-     * <p>
-     * We try to use {@link StoredField}'s native types for fields where
-     * possible but we have to preserve more type information than
-     * stored fields support, so we encode all of those into stored fields'
-     * {@code byte[]} type and then encode type information in the first byte.
-     * </p>
-     */
-    public static StoredField storedField(String fieldName, XContentParser parser) throws IOException {
-        String name = name(fieldName);
-        return switch (parser.currentToken()) {
-            case VALUE_STRING -> new StoredField(name, parser.text());
-            case VALUE_NUMBER -> switch (parser.numberType()) {
-                case INT -> new StoredField(name, parser.intValue());
-                case LONG -> new StoredField(name, parser.longValue());
-                case DOUBLE -> new StoredField(name, parser.doubleValue());
-                case FLOAT -> new StoredField(name, parser.floatValue());
-                case BIG_INTEGER -> new StoredField(name, encode((BigInteger) parser.numberValue()));
-                case BIG_DECIMAL -> new StoredField(name, encode((BigDecimal) parser.numberValue()));
-            };
-            case VALUE_BOOLEAN -> new StoredField(name, new byte[] { parser.booleanValue() ? (byte) 't' : (byte) 'f' });
-            case VALUE_EMBEDDED_OBJECT -> new StoredField(name, encode(parser.binaryValue()));
-            case START_OBJECT, START_ARRAY -> {
-                try (XContentBuilder builder = XContentBuilder.builder(parser.contentType().xContent())) {
-                    builder.copyCurrentStructure(parser);
-                    yield new StoredField(name, encode(builder));
-                }
-            }
-            default -> throw new IllegalArgumentException("synthetic _source doesn't support malformed objects");
-        };
+    public static StoredField storedField(String name, XContentParser parser) throws IOException {
+        return XContentDataHelper.storedField(name(name), parser);
     }
 
     /**
@@ -136,114 +99,16 @@ public abstract class IgnoreMalformedStoredValues {
         public void write(XContentBuilder b) throws IOException {
             for (Object v : values) {
                 if (v instanceof BytesRef r) {
-                    decodeAndWrite(b, r);
+                    XContentDataHelper.decodeAndWrite(b, r);
                 } else {
                     b.value(v);
                 }
             }
             values = emptyList();
         }
-
-        private static void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
-            switch (r.bytes[r.offset]) {
-                case 'b':
-                    b.value(r.bytes, r.offset + 1, r.length - 1);
-                    return;
-                case 'c':
-                    decodeAndWriteXContent(b, XContentType.CBOR, r);
-                    return;
-                case 'd':
-                    if (r.length < 5) {
-                        throw new IllegalArgumentException("Can't decode " + r);
-                    }
-                    int scale = ByteUtils.readIntLE(r.bytes, r.offset + 1);
-                    b.value(new BigDecimal(new BigInteger(r.bytes, r.offset + 5, r.length - 5), scale));
-                    return;
-                case 'f':
-                    if (r.length != 1) {
-                        throw new IllegalArgumentException("Can't decode " + r);
-                    }
-                    b.value(false);
-                    return;
-                case 'i':
-                    b.value(new BigInteger(r.bytes, r.offset + 1, r.length - 1));
-                    return;
-                case 'j':
-                    decodeAndWriteXContent(b, XContentType.JSON, r);
-                    return;
-                case 's':
-                    decodeAndWriteXContent(b, XContentType.SMILE, r);
-                    return;
-                case 't':
-                    if (r.length != 1) {
-                        throw new IllegalArgumentException("Can't decode " + r);
-                    }
-                    b.value(true);
-                    return;
-                case 'y':
-                    decodeAndWriteXContent(b, XContentType.YAML, r);
-                    return;
-                default:
-                    throw new IllegalArgumentException("Can't decode " + r);
-            }
-        }
-
-        private static void decodeAndWriteXContent(XContentBuilder b, XContentType type, BytesRef r) throws IOException {
-            try (
-                XContentParser parser = type.xContent().createParser(XContentParserConfiguration.EMPTY, r.bytes, r.offset + 1, r.length - 1)
-            ) {
-                b.copyCurrentStructure(parser);
-            }
-        }
     }
 
     private static String name(String fieldName) {
         return fieldName + "._ignore_malformed";
     }
-
-    private static byte[] encode(BigInteger n) {
-        byte[] twosCompliment = n.toByteArray();
-        byte[] encoded = new byte[1 + twosCompliment.length];
-        encoded[0] = 'i';
-        System.arraycopy(twosCompliment, 0, encoded, 1, twosCompliment.length);
-        return encoded;
-    }
-
-    private static byte[] encode(BigDecimal n) {
-        byte[] twosCompliment = n.unscaledValue().toByteArray();
-        byte[] encoded = new byte[5 + twosCompliment.length];
-        encoded[0] = 'd';
-        ByteUtils.writeIntLE(n.scale(), encoded, 1);
-        System.arraycopy(twosCompliment, 0, encoded, 5, twosCompliment.length);
-        return encoded;
-    }
-
-    private static byte[] encode(byte[] b) {
-        byte[] encoded = new byte[1 + b.length];
-        encoded[0] = 'b';
-        System.arraycopy(b, 0, encoded, 1, b.length);
-        return encoded;
-    }
-
-    private static byte[] encode(XContentBuilder builder) throws IOException {
-        BytesReference b = BytesReference.bytes(builder);
-        byte[] encoded = new byte[1 + b.length()];
-        encoded[0] = switch (builder.contentType()) {
-            case JSON -> 'j';
-            case SMILE -> 's';
-            case YAML -> 'y';
-            case CBOR -> 'c';
-            default -> throw new IllegalArgumentException("unsupported type " + builder.contentType());
-        };
-
-        int position = 1;
-        BytesRefIterator itr = b.iterator();
-        BytesRef ref;
-        while ((ref = itr.next()) != null) {
-            System.arraycopy(ref.bytes, ref.offset, encoded, position, ref.length);
-            position += ref.length;
-        }
-        assert position == encoded.length;
-        return encoded;
-    }
 }

+ 133 - 0
server/src/main/java/org/elasticsearch/index/mapper/IgnoredSourceFieldMapper.java

@@ -0,0 +1,133 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.util.ByteUtils;
+import org.elasticsearch.features.NodeFeature;
+import org.elasticsearch.index.query.SearchExecutionContext;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+/**
+
+ * Mapper for the {@code _ignored_source} field.
+ *
+ * A field mapper that records fields that have been ignored, along with their values. It's intended for use
+ * in indexes with synthetic source to reconstruct the latter, taking into account fields that got ignored during
+ * indexing.
+ *
+ * This overlaps with {@link IgnoredFieldMapper} that tracks just the ignored field names. It's worth evaluating
+ * if we can replace it for all use cases to avoid duplication, assuming that the storage tradeoff is favorable.
+ */
+public class IgnoredSourceFieldMapper extends MetadataFieldMapper {
+
+    // This factor is used to combine two offsets within the same integer:
+    // - the offset of the end of the parent field within the field name (N / PARENT_OFFSET_IN_NAME_OFFSET)
+    // - the offset of the field value within the encoding string containing the offset (first 4 bytes), the field name and value
+    // (N % PARENT_OFFSET_IN_NAME_OFFSET)
+    private static final int PARENT_OFFSET_IN_NAME_OFFSET = 1 << 16;
+
+    public static final String NAME = "_ignored_source";
+
+    public static final IgnoredSourceFieldMapper INSTANCE = new IgnoredSourceFieldMapper();
+
+    public static final TypeParser PARSER = new FixedTypeParser(context -> INSTANCE);
+
+    static final NodeFeature TRACK_IGNORED_SOURCE = new NodeFeature("mapper.track_ignored_source");
+
+    /*
+     * Container for the ignored field data:
+     *  - the full name
+     *  - the offset in the full name indicating the end of the substring matching
+     *    the full name of the parent field
+     *  - the value, encoded as a byte array
+     */
+    public record NameValue(String name, int parentOffset, BytesRef value) {
+        String getParentFieldName() {
+            // _doc corresponds to the root object
+            return (parentOffset == 0) ? "_doc" : name.substring(0, parentOffset - 1);
+        }
+
+        String getFieldName() {
+            return parentOffset() == 0 ? name() : name().substring(parentOffset());
+        }
+    }
+
+    static final class IgnoredValuesFieldMapperType extends StringFieldType {
+
+        private static final IgnoredValuesFieldMapperType INSTANCE = new IgnoredValuesFieldMapperType();
+
+        private IgnoredValuesFieldMapperType() {
+            super(NAME, false, true, false, TextSearchInfo.NONE, Collections.emptyMap());
+        }
+
+        @Override
+        public String typeName() {
+            return NAME;
+        }
+
+        @Override
+        public ValueFetcher valueFetcher(SearchExecutionContext context, String format) {
+            return new StoredValueFetcher(context.lookup(), NAME);
+        }
+    }
+
+    private IgnoredSourceFieldMapper() {
+        super(IgnoredValuesFieldMapperType.INSTANCE);
+    }
+
+    @Override
+    protected String contentType() {
+        return NAME;
+    }
+
+    @Override
+    public void postParse(DocumentParserContext context) {
+        // Ignored values are only expected in synthetic mode.
+        assert context.getIgnoredFieldValues().isEmpty()
+            || context.indexSettings().getMode().isSyntheticSourceEnabled()
+            || context.mappingLookup().isSourceSynthetic();
+        for (NameValue nameValue : context.getIgnoredFieldValues()) {
+            context.doc().add(new StoredField(NAME, encode(nameValue)));
+        }
+    }
+
+    static byte[] encode(NameValue values) {
+        assert values.parentOffset < PARENT_OFFSET_IN_NAME_OFFSET;
+        assert values.parentOffset * (long) PARENT_OFFSET_IN_NAME_OFFSET < Integer.MAX_VALUE;
+
+        byte[] nameBytes = values.name.getBytes(StandardCharsets.UTF_8);
+        byte[] bytes = new byte[4 + nameBytes.length + values.value.length];
+        ByteUtils.writeIntLE(values.name.length() + PARENT_OFFSET_IN_NAME_OFFSET * values.parentOffset, bytes, 0);
+        System.arraycopy(nameBytes, 0, bytes, 4, nameBytes.length);
+        System.arraycopy(values.value.bytes, values.value.offset, bytes, 4 + nameBytes.length, values.value.length);
+        return bytes;
+    }
+
+    static NameValue decode(Object field) {
+        byte[] bytes = ((BytesRef) field).bytes;
+        int encodedSize = ByteUtils.readIntLE(bytes, 0);
+        int nameSize = encodedSize % PARENT_OFFSET_IN_NAME_OFFSET;
+        int parentOffset = encodedSize / PARENT_OFFSET_IN_NAME_OFFSET;
+        String name = new String(bytes, 4, nameSize, StandardCharsets.UTF_8);
+        BytesRef value = new BytesRef(bytes, 4 + nameSize, bytes.length - nameSize - 4);
+        return new NameValue(name, parentOffset, value);
+    }
+
+    // This mapper doesn't contribute to source directly as it has no access to the object structure. Instead, its contents
+    // are loaded by SourceLoader and passed to object mappers that, in turn, write their ignore fields at the appropriate level.
+    @Override
+    public SourceLoader.SyntheticFieldLoader syntheticFieldLoader() {
+        return SourceLoader.SyntheticFieldLoader.NOTHING;
+    }
+
+}

+ 24 - 0
server/src/main/java/org/elasticsearch/index/mapper/MapperFeatures.java

@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.elasticsearch.features.FeatureSpecification;
+import org.elasticsearch.features.NodeFeature;
+
+import java.util.Set;
+
+/**
+ * Spec for mapper-related features.
+ */
+public class MapperFeatures implements FeatureSpecification {
+    @Override
+    public Set<NodeFeature> getFeatures() {
+        return Set.of(IgnoredSourceFieldMapper.TRACK_IGNORED_SOURCE);
+    }
+}

+ 19 - 1
server/src/main/java/org/elasticsearch/index/mapper/ObjectMapper.java

@@ -730,6 +730,7 @@ public class ObjectMapper extends Mapper {
     private class SyntheticSourceFieldLoader implements SourceLoader.SyntheticFieldLoader {
         private final List<SourceLoader.SyntheticFieldLoader> fields;
         private boolean hasValue;
+        private List<IgnoredSourceFieldMapper.NameValue> ignoredValues;
 
         private SyntheticSourceFieldLoader(List<SourceLoader.SyntheticFieldLoader> fields) {
             this.fields = fields;
@@ -793,8 +794,25 @@ public class ObjectMapper extends Mapper {
                     field.write(b);
                 }
             }
-            b.endObject();
             hasValue = false;
+            if (ignoredValues != null) {
+                for (IgnoredSourceFieldMapper.NameValue ignored : ignoredValues) {
+                    b.field(ignored.getFieldName());
+                    XContentDataHelper.decodeAndWrite(b, ignored.value());
+                }
+                ignoredValues = null;
+            }
+            b.endObject();
+        }
+
+        @Override
+        public boolean setIgnoredValues(Map<String, List<IgnoredSourceFieldMapper.NameValue>> objectsWithIgnoredFields) {
+            ignoredValues = objectsWithIgnoredFields.get(name());
+            hasValue |= ignoredValues != null;
+            for (SourceLoader.SyntheticFieldLoader loader : fields) {
+                hasValue |= loader.setIgnoredValues(objectsWithIgnoredFields);
+            }
+            return this.ignoredValues != null;
         }
     }
 

+ 17 - 0
server/src/main/java/org/elasticsearch/index/mapper/SourceLoader.java

@@ -17,6 +17,8 @@ import org.elasticsearch.xcontent.json.JsonXContent;
 
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -89,6 +91,7 @@ public interface SourceLoader {
                 .storedFieldLoaders()
                 .map(Map.Entry::getKey)
                 .collect(Collectors.toSet());
+            this.requiredStoredFields.add(IgnoredSourceFieldMapper.NAME);
         }
 
         @Override
@@ -122,12 +125,22 @@ public interface SourceLoader {
 
             @Override
             public Source source(LeafStoredFieldLoader storedFieldLoader, int docId) throws IOException {
+                // Maps the names of existing objects to lists of ignored fields they contain.
+                Map<String, List<IgnoredSourceFieldMapper.NameValue>> objectsWithIgnoredFields = new HashMap<>();
+
                 for (Map.Entry<String, List<Object>> e : storedFieldLoader.storedFields().entrySet()) {
                     SyntheticFieldLoader.StoredFieldLoader loader = storedFieldLoaders.get(e.getKey());
                     if (loader != null) {
                         loader.load(e.getValue());
                     }
+                    if (IgnoredSourceFieldMapper.NAME.equals(e.getKey())) {
+                        for (Object value : e.getValue()) {
+                            IgnoredSourceFieldMapper.NameValue nameValue = IgnoredSourceFieldMapper.decode(value);
+                            objectsWithIgnoredFields.computeIfAbsent(nameValue.getParentFieldName(), k -> new ArrayList<>()).add(nameValue);
+                        }
+                    }
                 }
+                loader.setIgnoredValues(objectsWithIgnoredFields);
                 if (docValuesLoader != null) {
                     docValuesLoader.advanceToDoc(docId);
                 }
@@ -224,6 +237,10 @@ public interface SourceLoader {
          */
         void write(XContentBuilder b) throws IOException;
 
+        default boolean setIgnoredValues(Map<String, List<IgnoredSourceFieldMapper.NameValue>> objectsWithIgnoredFields) {
+            return false;
+        }
+
         /**
          * Sync for stored field values.
          */

+ 399 - 0
server/src/main/java/org/elasticsearch/index/mapper/XContentDataHelper.java

@@ -0,0 +1,399 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.BytesRefIterator;
+import org.elasticsearch.common.bytes.BytesReference;
+import org.elasticsearch.common.util.ByteUtils;
+import org.elasticsearch.core.CheckedFunction;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.XContentParserConfiguration;
+import org.elasticsearch.xcontent.XContentType;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+/**
+ * Helper class for processing field data of any type, as provided by the {@link XContentParser}.
+ */
+final class XContentDataHelper {
+    /**
+     * Build a {@link StoredField} for the value on which the parser is
+     * currently positioned.
+     * <p>
+     * We try to use {@link StoredField}'s native types for fields where
+     * possible but we have to preserve more type information than
+     * stored fields support, so we encode all of those into stored fields'
+     * {@code byte[]} type and then encode type information in the first byte.
+     * </p>
+     */
+    static StoredField storedField(String name, XContentParser parser) throws IOException {
+        return (StoredField) processToken(parser, typeUtils -> typeUtils.buildStoredField(name, parser));
+    }
+
+    /**
+     * Build a {@link BytesRef} wrapping a byte array containing an encoded form
+     * the value on which the parser is currently positioned.
+     */
+    static BytesRef encodeToken(XContentParser parser) throws IOException {
+        return new BytesRef((byte[]) processToken(parser, (typeUtils) -> typeUtils.encode(parser)));
+    }
+
+    /**
+     * Decode the value in the passed {@link BytesRef} and add it as a value to the
+     * passed build. The assumption is that the passed value has encoded using the function
+     * {@link #encodeToken(XContentParser)} above.
+     */
+    static void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+        switch ((char) r.bytes[r.offset]) {
+            case BINARY_ENCODING -> TypeUtils.EMBEDDED_OBJECT.decodeAndWrite(b, r);
+            case CBOR_OBJECT_ENCODING, JSON_OBJECT_ENCODING, YAML_OBJECT_ENCODING, SMILE_OBJECT_ENCODING -> {
+                TypeUtils.START.decodeAndWrite(b, r);
+            }
+            case BIG_DECIMAL_ENCODING -> TypeUtils.BIG_DECIMAL.decodeAndWrite(b, r);
+            case FALSE_ENCODING, TRUE_ENCODING -> TypeUtils.BOOLEAN.decodeAndWrite(b, r);
+            case BIG_INTEGER_ENCODING -> TypeUtils.BIG_INTEGER.decodeAndWrite(b, r);
+            case STRING_ENCODING -> TypeUtils.STRING.decodeAndWrite(b, r);
+            case INTEGER_ENCODING -> TypeUtils.INTEGER.decodeAndWrite(b, r);
+            case LONG_ENCODING -> TypeUtils.LONG.decodeAndWrite(b, r);
+            case DOUBLE_ENCODING -> TypeUtils.DOUBLE.decodeAndWrite(b, r);
+            case FLOAT_ENCODING -> TypeUtils.FLOAT.decodeAndWrite(b, r);
+            default -> throw new IllegalArgumentException("Can't decode " + r);
+        }
+    }
+
+    private static Object processToken(XContentParser parser, CheckedFunction<TypeUtils, Object, IOException> visitor) throws IOException {
+        return switch (parser.currentToken()) {
+            case VALUE_STRING -> visitor.apply(TypeUtils.STRING);
+            case VALUE_NUMBER -> switch (parser.numberType()) {
+                case INT -> visitor.apply(TypeUtils.INTEGER);
+                case LONG -> visitor.apply(TypeUtils.LONG);
+                case DOUBLE -> visitor.apply(TypeUtils.DOUBLE);
+                case FLOAT -> visitor.apply(TypeUtils.FLOAT);
+                case BIG_INTEGER -> visitor.apply(TypeUtils.BIG_INTEGER);
+                case BIG_DECIMAL -> visitor.apply(TypeUtils.BIG_DECIMAL);
+            };
+            case VALUE_BOOLEAN -> visitor.apply(TypeUtils.BOOLEAN);
+            case VALUE_EMBEDDED_OBJECT -> visitor.apply(TypeUtils.EMBEDDED_OBJECT);
+            case START_OBJECT, START_ARRAY -> visitor.apply(TypeUtils.START);
+            default -> throw new IllegalArgumentException("synthetic _source doesn't support malformed objects");
+        };
+    }
+
+    private static final char STRING_ENCODING = 'S';
+    private static final char INTEGER_ENCODING = 'I';
+    private static final char LONG_ENCODING = 'L';
+    private static final char DOUBLE_ENCODING = 'D';
+    private static final char FLOAT_ENCODING = 'F';
+    private static final char BIG_INTEGER_ENCODING = 'i';
+    private static final char BIG_DECIMAL_ENCODING = 'd';
+    private static final char FALSE_ENCODING = 'f';
+    private static final char TRUE_ENCODING = 't';
+    private static final char BINARY_ENCODING = 'b';
+    private static final char CBOR_OBJECT_ENCODING = 'c';
+    private static final char JSON_OBJECT_ENCODING = 'j';
+    private static final char YAML_OBJECT_ENCODING = 'y';
+    private static final char SMILE_OBJECT_ENCODING = 's';
+
+    private enum TypeUtils {
+        STRING(STRING_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, parser.text());
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] text = parser.text().getBytes(StandardCharsets.UTF_8);
+                byte[] bytes = new byte[text.length + 1];
+                bytes[0] = getEncoding();
+                System.arraycopy(text, 0, bytes, 1, text.length);
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(new BytesRef(r.bytes, r.offset + 1, r.length - 1).utf8ToString());
+            }
+        },
+        INTEGER(INTEGER_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, parser.intValue());
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = new byte[5];
+                bytes[0] = getEncoding();
+                ByteUtils.writeIntLE(parser.intValue(), bytes, 1);
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(ByteUtils.readIntLE(r.bytes, 1 + r.offset));
+            }
+        },
+        LONG(LONG_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, parser.longValue());
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = new byte[9];
+                bytes[0] = getEncoding();
+                ByteUtils.writeLongLE(parser.longValue(), bytes, 1);
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(ByteUtils.readLongLE(r.bytes, 1 + r.offset));
+            }
+        },
+        DOUBLE(DOUBLE_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, parser.doubleValue());
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = new byte[9];
+                bytes[0] = getEncoding();
+                ByteUtils.writeDoubleLE(parser.doubleValue(), bytes, 1);
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(ByteUtils.readDoubleLE(r.bytes, 1 + r.offset));
+            }
+        },
+        FLOAT(FLOAT_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, parser.floatValue());
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = new byte[5];
+                bytes[0] = getEncoding();
+                ByteUtils.writeFloatLE(parser.floatValue(), bytes, 1);
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(ByteUtils.readFloatLE(r.bytes, 1 + r.offset));
+            }
+        },
+        BIG_INTEGER(BIG_INTEGER_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, encode(parser));
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = encode((BigInteger) parser.numberValue(), getEncoding());
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(new BigInteger(r.bytes, r.offset + 1, r.length - 1));
+            }
+        },
+        BIG_DECIMAL(BIG_DECIMAL_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, encode(parser));
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = encode((BigDecimal) parser.numberValue(), getEncoding());
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                if (r.length < 5) {
+                    throw new IllegalArgumentException("Can't decode " + r);
+                }
+                int scale = ByteUtils.readIntLE(r.bytes, r.offset + 1);
+                b.value(new BigDecimal(new BigInteger(r.bytes, r.offset + 5, r.length - 5), scale));
+            }
+        },
+        BOOLEAN(new Character[] { TRUE_ENCODING, FALSE_ENCODING }) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, encode(parser));
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = new byte[] { parser.booleanValue() ? (byte) 't' : (byte) 'f' };
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                if (r.length != 1) {
+                    throw new IllegalArgumentException("Can't decode " + r);
+                }
+                assert r.bytes[r.offset] == 't' || r.bytes[r.offset] == 'f' : r.bytes[r.offset];
+                b.value(r.bytes[r.offset] == 't');
+            }
+        },
+        EMBEDDED_OBJECT(BINARY_ENCODING) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, encode(parser.binaryValue()));
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                byte[] bytes = encode(parser.binaryValue());
+                assertValidEncoding(bytes);
+                return bytes;
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                b.value(r.bytes, r.offset + 1, r.length - 1);
+            }
+        },
+        START(new Character[] { CBOR_OBJECT_ENCODING, JSON_OBJECT_ENCODING, YAML_OBJECT_ENCODING, SMILE_OBJECT_ENCODING }) {
+            @Override
+            StoredField buildStoredField(String name, XContentParser parser) throws IOException {
+                return new StoredField(name, encode(parser));
+            }
+
+            @Override
+            byte[] encode(XContentParser parser) throws IOException {
+                try (XContentBuilder builder = XContentBuilder.builder(parser.contentType().xContent())) {
+                    builder.copyCurrentStructure(parser);
+                    byte[] bytes = encode(builder);
+                    assertValidEncoding(bytes);
+                    return bytes;
+                }
+            }
+
+            @Override
+            void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException {
+                switch ((char) r.bytes[r.offset]) {
+                    case CBOR_OBJECT_ENCODING -> decodeAndWriteXContent(b, XContentType.CBOR, r);
+                    case JSON_OBJECT_ENCODING -> decodeAndWriteXContent(b, XContentType.JSON, r);
+                    case SMILE_OBJECT_ENCODING -> decodeAndWriteXContent(b, XContentType.SMILE, r);
+                    case YAML_OBJECT_ENCODING -> decodeAndWriteXContent(b, XContentType.YAML, r);
+                    default -> throw new IllegalArgumentException("Can't decode " + r);
+                }
+            }
+        };
+
+        TypeUtils(char encoding) {
+            this.encoding = new Character[] { encoding };
+        }
+
+        TypeUtils(Character[] encoding) {
+            this.encoding = encoding;
+        }
+
+        byte getEncoding() {
+            assert encoding.length == 1;
+            return (byte) encoding[0].charValue();
+        }
+
+        void assertValidEncoding(byte[] encodedValue) {
+            assert Arrays.asList(encoding).contains((char) encodedValue[0]);
+        }
+
+        final Character[] encoding;
+
+        abstract StoredField buildStoredField(String name, XContentParser parser) throws IOException;
+
+        abstract byte[] encode(XContentParser parser) throws IOException;
+
+        abstract void decodeAndWrite(XContentBuilder b, BytesRef r) throws IOException;
+
+        static byte[] encode(BigInteger n, Byte encoding) throws IOException {
+            byte[] twosCompliment = n.toByteArray();
+            byte[] encoded = new byte[1 + twosCompliment.length];
+            encoded[0] = encoding;
+            System.arraycopy(twosCompliment, 0, encoded, 1, twosCompliment.length);
+            return encoded;
+        }
+
+        static byte[] encode(BigDecimal n, Byte encoding) {
+            byte[] twosCompliment = n.unscaledValue().toByteArray();
+            byte[] encoded = new byte[5 + twosCompliment.length];
+            encoded[0] = 'd';
+            ByteUtils.writeIntLE(n.scale(), encoded, 1);
+            System.arraycopy(twosCompliment, 0, encoded, 5, twosCompliment.length);
+            return encoded;
+        }
+
+        static byte[] encode(byte[] b) {
+            byte[] encoded = new byte[1 + b.length];
+            encoded[0] = 'b';
+            System.arraycopy(b, 0, encoded, 1, b.length);
+            return encoded;
+        }
+
+        static byte[] encode(XContentBuilder builder) throws IOException {
+            BytesReference b = BytesReference.bytes(builder);
+            byte[] encoded = new byte[1 + b.length()];
+            encoded[0] = switch (builder.contentType()) {
+                case JSON -> JSON_OBJECT_ENCODING;
+                case SMILE -> SMILE_OBJECT_ENCODING;
+                case YAML -> YAML_OBJECT_ENCODING;
+                case CBOR -> CBOR_OBJECT_ENCODING;
+                default -> throw new IllegalArgumentException("unsupported type " + builder.contentType());
+            };
+
+            int position = 1;
+            BytesRefIterator itr = b.iterator();
+            BytesRef ref;
+            while ((ref = itr.next()) != null) {
+                System.arraycopy(ref.bytes, ref.offset, encoded, position, ref.length);
+                position += ref.length;
+            }
+            assert position == encoded.length;
+            return encoded;
+        }
+
+        static void decodeAndWriteXContent(XContentBuilder b, XContentType type, BytesRef r) throws IOException {
+            try (
+                XContentParser parser = type.xContent().createParser(XContentParserConfiguration.EMPTY, r.bytes, r.offset + 1, r.length - 1)
+            ) {
+                b.copyCurrentStructure(parser);
+            }
+        }
+    }
+}

+ 2 - 0
server/src/main/java/org/elasticsearch/indices/IndicesModule.java

@@ -39,6 +39,7 @@ import org.elasticsearch.index.mapper.GeoPointFieldMapper;
 import org.elasticsearch.index.mapper.GeoPointScriptFieldType;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.IgnoredFieldMapper;
+import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
 import org.elasticsearch.index.mapper.IndexFieldMapper;
 import org.elasticsearch.index.mapper.IpFieldMapper;
 import org.elasticsearch.index.mapper.IpScriptFieldType;
@@ -258,6 +259,7 @@ public class IndicesModule extends AbstractModule {
         builtInMetadataMappers.put(TimeSeriesRoutingHashFieldMapper.NAME, TimeSeriesRoutingHashFieldMapper.PARSER);
         builtInMetadataMappers.put(IndexFieldMapper.NAME, IndexFieldMapper.PARSER);
         builtInMetadataMappers.put(SourceFieldMapper.NAME, SourceFieldMapper.PARSER);
+        builtInMetadataMappers.put(IgnoredSourceFieldMapper.NAME, IgnoredSourceFieldMapper.PARSER);
         builtInMetadataMappers.put(NestedPathFieldMapper.NAME, NestedPathFieldMapper.PARSER);
         builtInMetadataMappers.put(VersionFieldMapper.NAME, VersionFieldMapper.PARSER);
         builtInMetadataMappers.put(SeqNoFieldMapper.NAME, SeqNoFieldMapper.PARSER);

+ 1 - 0
server/src/main/resources/META-INF/services/org.elasticsearch.features.FeatureSpecification

@@ -13,4 +13,5 @@ org.elasticsearch.cluster.metadata.MetadataFeatures
 org.elasticsearch.rest.RestFeatures
 org.elasticsearch.indices.IndicesFeatures
 org.elasticsearch.action.admin.cluster.allocation.AllocationStatsFeatures
+org.elasticsearch.index.mapper.MapperFeatures
 org.elasticsearch.search.retriever.RetrieversFeatures

+ 3 - 2
server/src/test/java/org/elasticsearch/index/mapper/DocCountFieldMapperTests.java

@@ -13,6 +13,7 @@ import org.elasticsearch.index.fieldvisitor.LeafStoredFieldLoader;
 import org.elasticsearch.index.fieldvisitor.StoredFieldLoader;
 import org.elasticsearch.xcontent.XContentParserConfiguration;
 import org.elasticsearch.xcontent.json.JsonXContent;
+import org.hamcrest.Matchers;
 
 import java.io.IOException;
 import java.util.List;
@@ -97,7 +98,7 @@ public class DocCountFieldMapperTests extends MetadataMapperTestCase {
             }
         }, reader -> {
             SourceLoader loader = mapper.mappingLookup().newSourceLoader();
-            assertTrue(loader.requiredStoredFields().isEmpty());
+            assertThat(loader.requiredStoredFields(), Matchers.contains("_ignored_source"));
             for (LeafReaderContext leaf : reader.leaves()) {
                 int[] docIds = IntStream.range(0, leaf.reader().maxDoc()).toArray();
                 SourceLoader.Leaf sourceLoaderLeaf = loader.leaf(leaf.reader(), docIds);
@@ -129,7 +130,7 @@ public class DocCountFieldMapperTests extends MetadataMapperTestCase {
             }
         }, reader -> {
             SourceLoader loader = mapper.mappingLookup().newSourceLoader();
-            assertTrue(loader.requiredStoredFields().isEmpty());
+            assertThat(loader.requiredStoredFields(), Matchers.contains("_ignored_source"));
             for (LeafReaderContext leaf : reader.leaves()) {
                 int[] docIds = IntStream.range(0, leaf.reader().maxDoc()).toArray();
                 SourceLoader.Leaf sourceLoaderLeaf = loader.leaf(leaf.reader(), docIds);

+ 2 - 0
server/src/test/java/org/elasticsearch/index/mapper/DocumentMapperTests.java

@@ -319,6 +319,7 @@ public class DocumentMapperTests extends MapperServiceTestCase {
                 .item(DocCountFieldMapper.class)
                 .item(FieldNamesFieldMapper.class)
                 .item(IgnoredFieldMapper.class)
+                .item(IgnoredSourceFieldMapper.class)
                 .item(IndexFieldMapper.class)
                 .item(NestedPathFieldMapper.class)
                 .item(ProvidedIdFieldMapper.class)
@@ -336,6 +337,7 @@ public class DocumentMapperTests extends MapperServiceTestCase {
                 .item(FieldNamesFieldMapper.CONTENT_TYPE)
                 .item(IdFieldMapper.CONTENT_TYPE)
                 .item(IgnoredFieldMapper.CONTENT_TYPE)
+                .item(IgnoredSourceFieldMapper.NAME)
                 .item(IndexFieldMapper.CONTENT_TYPE)
                 .item(NestedPathFieldMapper.NAME)
                 .item(RoutingFieldMapper.CONTENT_TYPE)

+ 148 - 0
server/src/test/java/org/elasticsearch/index/mapper/IgnoredSourceFieldMapperTests.java

@@ -0,0 +1,148 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.core.CheckedConsumer;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.hamcrest.Matchers;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.Base64;
+
+public class IgnoredSourceFieldMapperTests extends MapperServiceTestCase {
+
+    private String getSyntheticSource(CheckedConsumer<XContentBuilder, IOException> build) throws IOException {
+        DocumentMapper documentMapper = createMapperService(
+            Settings.builder()
+                .put("index.mapping.total_fields.limit", 2)
+                .put("index.mapping.total_fields.ignore_dynamic_beyond_limit", true)
+                .build(),
+            syntheticSourceMapping(b -> {
+                b.startObject("foo").field("type", "keyword").endObject();
+                b.startObject("bar").field("type", "object").endObject();
+            })
+        ).documentMapper();
+        return syntheticSource(documentMapper, build);
+    }
+
+    public void testIgnoredBoolean() throws IOException {
+        boolean value = randomBoolean();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredString() throws IOException {
+        String value = randomAlphaOfLength(5);
+        assertEquals("{\"my_value\":\"" + value + "\"}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredInt() throws IOException {
+        int value = randomInt();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredLong() throws IOException {
+        long value = randomLong();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredFloat() throws IOException {
+        float value = randomFloat();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredDouble() throws IOException {
+        double value = randomDouble();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredBigInteger() throws IOException {
+        BigInteger value = randomBigInteger();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testIgnoredBytes() throws IOException {
+        byte[] value = randomByteArrayOfLength(10);
+        assertEquals(
+            "{\"my_value\":\"" + Base64.getEncoder().encodeToString(value) + "\"}",
+            getSyntheticSource(b -> b.field("my_value", value))
+        );
+    }
+
+    public void testIgnoredObjectBoolean() throws IOException {
+        boolean value = randomBoolean();
+        assertEquals("{\"my_value\":" + value + "}", getSyntheticSource(b -> b.field("my_value", value)));
+    }
+
+    public void testMultipleIgnoredFieldsRootObject() throws IOException {
+        boolean booleanValue = randomBoolean();
+        int intValue = randomInt();
+        String stringValue = randomAlphaOfLength(20);
+        String syntheticSource = getSyntheticSource(b -> {
+            b.field("boolean_value", booleanValue);
+            b.field("int_value", intValue);
+            b.field("string_value", stringValue);
+        });
+        assertThat(syntheticSource, Matchers.containsString("\"boolean_value\":" + booleanValue));
+        assertThat(syntheticSource, Matchers.containsString("\"int_value\":" + intValue));
+        assertThat(syntheticSource, Matchers.containsString("\"string_value\":\"" + stringValue + "\""));
+    }
+
+    public void testMultipleIgnoredFieldsSameObject() throws IOException {
+        boolean booleanValue = randomBoolean();
+        int intValue = randomInt();
+        String stringValue = randomAlphaOfLength(20);
+        String syntheticSource = getSyntheticSource(b -> {
+            b.startObject("bar");
+            {
+                b.field("boolean_value", booleanValue);
+                b.field("int_value", intValue);
+                b.field("string_value", stringValue);
+            }
+            b.endObject();
+        });
+        assertThat(syntheticSource, Matchers.containsString("{\"bar\":{"));
+        assertThat(syntheticSource, Matchers.containsString("\"boolean_value\":" + booleanValue));
+        assertThat(syntheticSource, Matchers.containsString("\"int_value\":" + intValue));
+        assertThat(syntheticSource, Matchers.containsString("\"string_value\":\"" + stringValue + "\""));
+    }
+
+    public void testMultipleIgnoredFieldsManyObjects() throws IOException {
+        boolean booleanValue = randomBoolean();
+        int intValue = randomInt();
+        String stringValue = randomAlphaOfLength(20);
+        String syntheticSource = getSyntheticSource(b -> {
+            b.field("boolean_value", booleanValue);
+            b.startObject("path");
+            {
+                b.startObject("to");
+                {
+                    b.field("int_value", intValue);
+                    b.startObject("some");
+                    {
+                        b.startObject("deeply");
+                        {
+                            b.startObject("nested");
+                            b.field("string_value", stringValue);
+                            b.endObject();
+                        }
+                        b.endObject();
+                    }
+                    b.endObject();
+                }
+                b.endObject();
+            }
+            b.endObject();
+        });
+        assertThat(syntheticSource, Matchers.containsString("\"boolean_value\":" + booleanValue));
+        assertThat(syntheticSource, Matchers.containsString("\"path\":{\"to\":{\"int_value\":" + intValue));
+        assertThat(syntheticSource, Matchers.containsString("\"some\":{\"deeply\":{\"nested\":{\"string_value\":\"" + stringValue + "\""));
+    }
+}

+ 90 - 0
server/src/test/java/org/elasticsearch/index/mapper/XContentDataHelperTests.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.index.mapper;
+
+import org.elasticsearch.common.Strings;
+import org.elasticsearch.test.ESTestCase;
+import org.elasticsearch.xcontent.XContentBuilder;
+import org.elasticsearch.xcontent.XContentFactory;
+import org.elasticsearch.xcontent.XContentParser;
+import org.elasticsearch.xcontent.json.JsonXContent;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.List;
+
+import static org.hamcrest.Matchers.equalTo;
+
+public class XContentDataHelperTests extends ESTestCase {
+
+    private String encodeAndDecode(String value) throws IOException {
+        XContentParser p = createParser(JsonXContent.jsonXContent, "{ \"foo\": " + value + " }");
+        assertThat(p.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        assertThat(p.nextToken(), equalTo(XContentParser.Token.FIELD_NAME));
+        assertThat(p.currentName(), equalTo("foo"));
+        p.nextToken();
+
+        XContentBuilder builder = XContentFactory.jsonBuilder();
+        builder.humanReadable(true);
+        XContentDataHelper.decodeAndWrite(builder, XContentDataHelper.encodeToken(p));
+        return Strings.toString(builder);
+    }
+
+    public void testBoolean() throws IOException {
+        boolean b = randomBoolean();
+        assertEquals(b, Boolean.parseBoolean(encodeAndDecode(Boolean.toString(b))));
+    }
+
+    public void testString() throws IOException {
+        String s = "\"" + randomAlphaOfLength(5) + "\"";
+        assertEquals(s, encodeAndDecode(s));
+    }
+
+    public void testInt() throws IOException {
+        int i = randomInt();
+        assertEquals(i, Integer.parseInt(encodeAndDecode(Integer.toString(i))));
+    }
+
+    public void testLong() throws IOException {
+        long l = randomLong();
+        assertEquals(l, Long.parseLong(encodeAndDecode(Long.toString(l))));
+    }
+
+    public void testFloat() throws IOException {
+        float f = randomFloat();
+        assertEquals(0, Float.compare(f, Float.parseFloat(encodeAndDecode(Float.toString(f)))));
+    }
+
+    public void testDouble() throws IOException {
+        double d = randomDouble();
+        assertEquals(0, Double.compare(d, Double.parseDouble(encodeAndDecode(Double.toString(d)))));
+    }
+
+    public void testBigInteger() throws IOException {
+        BigInteger i = randomBigInteger();
+        assertEquals(i, new BigInteger(encodeAndDecode(i.toString()), 10));
+    }
+
+    public void testObject() throws IOException {
+        String object = "{\"name\":\"foo\"}";
+        XContentParser p = createParser(JsonXContent.jsonXContent, object);
+        assertThat(p.nextToken(), equalTo(XContentParser.Token.START_OBJECT));
+        XContentBuilder builder = XContentFactory.jsonBuilder();
+        builder.humanReadable(true);
+        XContentDataHelper.decodeAndWrite(builder, XContentDataHelper.encodeToken(p));
+        assertEquals(object, Strings.toString(builder));
+    }
+
+    public void testArrayInt() throws IOException {
+        String values = "["
+            + String.join(",", List.of(Integer.toString(randomInt()), Integer.toString(randomInt()), Integer.toString(randomInt())))
+            + "]";
+        assertEquals(values, encodeAndDecode(values));
+    }
+}

+ 2 - 0
server/src/test/java/org/elasticsearch/indices/IndicesModuleTests.java

@@ -15,6 +15,7 @@ import org.elasticsearch.index.mapper.DocCountFieldMapper;
 import org.elasticsearch.index.mapper.FieldNamesFieldMapper;
 import org.elasticsearch.index.mapper.IdFieldMapper;
 import org.elasticsearch.index.mapper.IgnoredFieldMapper;
+import org.elasticsearch.index.mapper.IgnoredSourceFieldMapper;
 import org.elasticsearch.index.mapper.IndexFieldMapper;
 import org.elasticsearch.index.mapper.KeywordFieldMapper;
 import org.elasticsearch.index.mapper.Mapper;
@@ -85,6 +86,7 @@ public class IndicesModuleTests extends ESTestCase {
         TimeSeriesRoutingHashFieldMapper.NAME,
         IndexFieldMapper.NAME,
         SourceFieldMapper.NAME,
+        IgnoredSourceFieldMapper.NAME,
         NestedPathFieldMapper.NAME,
         VersionFieldMapper.NAME,
         SeqNoFieldMapper.NAME,