Browse Source

Add 'mode' option to `_source` field mapper (#88211)

Currently we have two parameters that control how the source of a document
is stored, `enabled` and `synthetic`, both booleans. However, there are only
three possible combinations of these, with `enabled:false` and `synthetic:true`
being disallowed. To make this easier to reason about, this commit replaces
the `enabled` parameter with a new `mode` parameter, which can take the values
`stored`, `synthetic` and `disabled`. The `mode` parameter cannot be set
in combination with `enabled`, and we will subsequently move towards
deprecating `enabled` entirely.
Alan Woodward 3 years ago
parent
commit
5c11a81913
25 changed files with 183 additions and 95 deletions
  1. 5 0
      docs/changelog/88211.yaml
  2. 2 2
      docs/reference/mapping/fields/synthetic-source.asciidoc
  3. 1 1
      docs/reference/mapping/types/boolean.asciidoc
  4. 2 2
      docs/reference/mapping/types/geo-point.asciidoc
  5. 1 1
      docs/reference/mapping/types/ip.asciidoc
  6. 1 1
      docs/reference/mapping/types/keyword.asciidoc
  7. 2 2
      docs/reference/mapping/types/numeric.asciidoc
  8. 1 1
      docs/reference/mapping/types/text.asciidoc
  9. 1 1
      modules/reindex/src/yamlRestTest/resources/rest-api-spec/test/reindex/110_synthetic_source.yml
  10. 1 1
      modules/reindex/src/yamlRestTest/resources/rest-api-spec/test/update_by_query/100_synthetic_source.yml
  11. 2 2
      qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/IndexingIT.java
  12. 8 8
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/get/100_synthetic_source.yml
  13. 6 6
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/index/91_metrics_no_subobjects.yml
  14. 2 2
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.create/20_synthetic_source.yml
  15. 10 12
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.put_mapping/10_basic.yml
  16. 5 5
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/mget/90_synthetic_source.yml
  17. 1 1
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search.highlight/50_synthetic_source.yml
  18. 5 5
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search/400_synthetic_source.yml
  19. 3 3
      rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/update/100_synthetic_source.yml
  20. 71 29
      server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java
  21. 45 4
      server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java
  22. 1 1
      server/src/test/java/org/elasticsearch/index/mapper/SourceLoaderTests.java
  23. 2 1
      server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java
  24. 3 2
      test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java
  25. 2 2
      x-pack/plugin/ccr/qa/multi-cluster/src/test/java/org/elasticsearch/xpack/ccr/FollowIndexIT.java

+ 5 - 0
docs/changelog/88211.yaml

@@ -0,0 +1,5 @@
+pr: 88211
+summary: Add 'mode' option to `_source` field mapper
+area: Search
+type: feature
+issues: []

+ 2 - 2
docs/reference/mapping/fields/synthetic-source.asciidoc

@@ -4,7 +4,7 @@
 Though very handy to have around, the source field takes up a significant amount
 of space on disk. Instead of storing source documents on disk exactly as you
 send them, Elasticsearch can reconstruct source content on the fly upon retrieval.
-Enable this by setting `synthetic: true` in `_source`:
+Enable this by setting `mode: synthetic` in `_source`:
 
 [source,console,id=enable-synthetic-source-example]
 ----
@@ -12,7 +12,7 @@ PUT idx
 {
   "mappings": {
     "_source": {
-      "synthetic": true
+      "mode": "synthetic"
     }
   }
 }

+ 1 - 1
docs/reference/mapping/types/boolean.asciidoc

@@ -228,7 +228,7 @@ Synthetic source always sorts `boolean` fields. For example:
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "bool": { "type": "boolean" }
     }

+ 2 - 2
docs/reference/mapping/types/geo-point.asciidoc

@@ -208,7 +208,7 @@ ifeval::["{release-state}"=="unreleased"]
 [[geo-point-synthetic-source]]
 ==== Synthetic source
 `geo_point` fields support <<synthetic-source,synthetic `_source`>> in their
-default configuration. Synthetic `_source` cannot be used together with 
+default configuration. Synthetic `_source` cannot be used together with
 <<ignore-malformed,`ignore_malformed`>>, <<copy-to,`copy_to`>>, or with
 <<doc-values,`doc_values`>> disabled.
 
@@ -219,7 +219,7 @@ longitude) and reduces them to their stored precision. For example:
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "point": { "type": "geo_point" }
     }

+ 1 - 1
docs/reference/mapping/types/ip.asciidoc

@@ -165,7 +165,7 @@ Synthetic source always sorts `ip` fields and removes duplicates. For example:
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "ip": { "type": "ip" }
     }

+ 1 - 1
docs/reference/mapping/types/keyword.asciidoc

@@ -189,7 +189,7 @@ example:
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "kwd": { "type": "keyword" }
     }

+ 2 - 2
docs/reference/mapping/types/numeric.asciidoc

@@ -243,7 +243,7 @@ Synthetic source always sorts numeric fields and removes duplicates. For example
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "long": { "type": "long" }
     }
@@ -271,7 +271,7 @@ Scaled floats will always apply their scaling factor so:
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "f": { "type": "scaled_float", "scaling_factor": 0.01 }
     }

+ 1 - 1
docs/reference/mapping/types/text.asciidoc

@@ -173,7 +173,7 @@ Synthetic source always sorts `keyword` fields and removes duplicates, so
 PUT idx
 {
   "mappings": {
-    "_source": { "synthetic": true },
+    "_source": { "mode": "synthetic" },
     "properties": {
       "text": {
         "type": "text",

+ 1 - 1
modules/reindex/src/yamlRestTest/resources/rest-api-spec/test/reindex/110_synthetic_source.yml

@@ -5,7 +5,7 @@ setup:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword

+ 1 - 1
modules/reindex/src/yamlRestTest/resources/rest-api-spec/test/update_by_query/100_synthetic_source.yml

@@ -5,7 +5,7 @@ update:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword

+ 2 - 2
qa/rolling-upgrade/src/test/java/org/elasticsearch/upgrades/IndexingIT.java

@@ -361,7 +361,7 @@ public class IndexingIT extends AbstractRollingTestCase {
     }
 
     public void testSyntheticSource() throws IOException {
-        assumeTrue("added in 8.3.0", UPGRADE_FROM_VERSION.onOrAfter(Version.V_8_3_0));
+        assumeTrue("added in 8.4.0", UPGRADE_FROM_VERSION.onOrAfter(Version.V_8_4_0));
 
         switch (CLUSTER_TYPE) {
             case OLD -> {
@@ -369,7 +369,7 @@ public class IndexingIT extends AbstractRollingTestCase {
                 XContentBuilder indexSpec = XContentBuilder.builder(XContentType.JSON.xContent()).startObject();
                 indexSpec.startObject("mappings");
                 {
-                    indexSpec.startObject("_source").field("synthetic", true).endObject();
+                    indexSpec.startObject("_source").field("mode", "synthetic").endObject();
                     indexSpec.startObject("properties").startObject("kwd").field("type", "keyword").endObject().endObject();
                 }
                 indexSpec.endObject();

+ 8 - 8
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/get/100_synthetic_source.yml

@@ -1,7 +1,7 @@
 keyword:
   - skip:
-      version: " - 8.2.99"
-      reason: introduced in 8.3.0
+      version: " - 8.3.99"
+      reason: introduced in 8.4.0
 
   - do:
       indices.create:
@@ -9,7 +9,7 @@ keyword:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword
@@ -37,8 +37,8 @@ keyword:
 ---
 fetch without refresh also produces synthetic source:
   - skip:
-      version: " - 8.2.99"
-      reason: introduced in 8.3.0
+      version: " - 8.3.99"
+      reason: introduced in 8.4.0
 
   - do:
       indices.create:
@@ -49,7 +49,7 @@ fetch without refresh also produces synthetic source:
               refresh_interval: -1
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               obj:
                 properties:
@@ -89,7 +89,7 @@ force_synthetic_source_ok:
         body:
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               obj:
                 properties:
@@ -138,7 +138,7 @@ force_synthetic_source_bad_mapping:
             number_of_shards: 1 # Use a single shard to get consistent error messages
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               text:
                 type: text

+ 6 - 6
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/index/91_metrics_no_subobjects.yml

@@ -125,8 +125,8 @@
 "Metrics object indexing with synthetic source":
   - skip:
       features: allowed_warnings_regex
-      version: " - 8.2.99"
-      reason: added in 8.3.0
+      version: " - 8.3.99"
+      reason: added in 8.4.0
 
   - do:
       indices.put_template:
@@ -135,7 +135,7 @@
           index_patterns: test-*
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             dynamic_templates:
               - no_subobjects:
                   match: metrics
@@ -192,8 +192,8 @@
 "Root without subobjects with synthetic source":
   - skip:
       features: allowed_warnings_regex
-      version: " - 8.2.99"
-      reason: added in 8.3.0
+      version: " - 8.3.99"
+      reason: added in 8.4.0
 
   - do:
       indices.put_template:
@@ -202,7 +202,7 @@
           index_patterns: test-*
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             subobjects: false
             properties:
               host.name:

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

@@ -10,7 +10,7 @@ invalid:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword
@@ -29,7 +29,7 @@ nested is disabled:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               n:
                 type: nested

+ 10 - 12
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/indices.put_mapping/10_basic.yml

@@ -156,18 +156,18 @@
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
 
   - do:
-      catch: /Cannot update parameter \[synthetic\] from \[true\] to \[false\]/
+      catch: /Cannot update parameter \[mode\] from \[synthetic\] to \[stored\]/
       indices.put_mapping:
         index: test_index
         body:
           _source:
-            synthetic: false
+            mode: stored
 
 ---
-"enabling synthetic source from explicit fails":
+"enabling synthetic source from explicit succeeds":
   - skip:
       version:     " - 8.3.99"
       reason:      "Added in 8.4.0"
@@ -178,18 +178,17 @@
         body:
           mappings:
             _source:
-              synthetic: false
+              mode: stored
 
   - do:
-      catch: /Cannot update parameter \[synthetic\] from \[false\] to \[true\]/
       indices.put_mapping:
         index: test_index
         body:
           _source:
-            synthetic: true
+            mode: synthetic
 
 ---
-"enabling synthetic source fails":
+"enabling synthetic source succeeds":
   - skip:
       version:     " - 8.3.99"
       reason:      "Added in 8.4.0"
@@ -205,15 +204,14 @@
         id:      1
         refresh: true
         body:
-          kwd: foo
+          value: 4
 
   - do:
-      catch: /Cannot update parameter \[synthetic\] from \[false\] to \[true\]/
       indices.put_mapping:
         index: test_index
         body:
           _source:
-            synthetic: true
+            mode: synthetic
 
 ---
 "enabling synthetic source when no mapping succeeds":
@@ -233,4 +231,4 @@
         index: test_index
         body:
           _source:
-            synthetic: true
+            mode: synthetic

+ 5 - 5
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/mget/90_synthetic_source.yml

@@ -1,7 +1,7 @@
 keyword:
   - skip:
-      version: " - 8.2.99"
-      reason: introduced in 8.3.0
+      version: " - 8.3.99"
+      reason: introduced in 8.4.0
 
   - do:
       indices.create:
@@ -9,7 +9,7 @@ keyword:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword
@@ -58,7 +58,7 @@ force_synthetic_source_ok:
         body:
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               kwd:
                 type: keyword
@@ -118,7 +118,7 @@ force_synthetic_source_bad_mapping:
             number_of_shards: 1 # Use a single shard to get consistent error messages
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               text:
                 type: text

+ 1 - 1
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search.highlight/50_synthetic_source.yml

@@ -11,7 +11,7 @@ setup:
             number_of_shards: 1
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               foo:
                 type: keyword

+ 5 - 5
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search/400_synthetic_source.yml

@@ -1,7 +1,7 @@
 keyword:
   - skip:
-      version: " - 8.2.99"
-      reason: introduced in 8.3.0
+      version: " - 8.3.99"
+      reason: introduced in 8.4.0
 
   - do:
       indices.create:
@@ -9,7 +9,7 @@ keyword:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword
@@ -45,7 +45,7 @@ force_synthetic_source_ok:
         body:
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               obj:
                 properties:
@@ -100,7 +100,7 @@ force_synthetic_source_bad_mapping:
             number_of_shards: 1 # Use a single shard to get consistent error messages
           mappings:
             _source:
-              synthetic: false
+              mode: stored
             properties:
               text:
                 type: text

+ 3 - 3
rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/update/100_synthetic_source.yml

@@ -1,7 +1,7 @@
 keyword:
   - skip:
-      version: " - 8.2.99"
-      reason: introduced in 8.3.0
+      version: " - 8.3.99"
+      reason: introduced in 8.4.0
 
   - do:
       indices.create:
@@ -9,7 +9,7 @@ keyword:
         body:
           mappings:
             _source:
-              synthetic: true
+              mode: synthetic
             properties:
               kwd:
                 type: keyword

+ 71 - 29
server/src/main/java/org/elasticsearch/index/mapper/SourceFieldMapper.java

@@ -14,6 +14,7 @@ import org.apache.lucene.document.StoredField;
 import org.apache.lucene.index.IndexOptions;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.util.BytesRef;
+import org.elasticsearch.common.Explicit;
 import org.elasticsearch.common.Strings;
 import org.elasticsearch.common.bytes.BytesReference;
 import org.elasticsearch.common.util.CollectionUtils;
@@ -28,6 +29,7 @@ import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 
 public class SourceFieldMapper extends MetadataFieldMapper {
     public static final String NAME = "_source";
@@ -36,17 +38,22 @@ public class SourceFieldMapper extends MetadataFieldMapper {
     public static final String CONTENT_TYPE = "_source";
     private final XContentFieldFilter filter;
 
+    /** The source mode */
+    private enum Mode {
+        DISABLED,
+        STORED,
+        SYNTHETIC
+    }
+
     private static final SourceFieldMapper DEFAULT = new SourceFieldMapper(
-        Defaults.ENABLED,
-        Defaults.SYNTHETIC,
+        null,
+        Explicit.IMPLICIT_TRUE,
         Strings.EMPTY_ARRAY,
         Strings.EMPTY_ARRAY
     );
 
     public static class Defaults {
         public static final String NAME = SourceFieldMapper.NAME;
-        public static final boolean ENABLED = true;
-        public static final boolean SYNTHETIC = false;
 
         public static final FieldType FIELD_TYPE = new FieldType();
 
@@ -64,10 +71,22 @@ public class SourceFieldMapper extends MetadataFieldMapper {
 
     public static class Builder extends MetadataFieldMapper.Builder {
 
-        private final Parameter<Boolean> enabled = Parameter.boolParam("enabled", false, m -> toType(m).enabled, Defaults.ENABLED)
+        private final Parameter<Explicit<Boolean>> enabled = Parameter.explicitBoolParam("enabled", false, m -> toType(m).enabled, true)
+            .setSerializerCheck((includeDefaults, isConfigured, value) -> value.explicit())
             // this field mapper may be enabled but once enabled, may not be disabled
-            .setMergeValidator((previous, current, conflicts) -> (previous == current) || (previous && current == false));
-        private final Parameter<Boolean> synthetic = Parameter.boolParam("synthetic", false, m -> toType(m).synthetic, false);
+            .setMergeValidator(
+                (previous, current, conflicts) -> (previous.value() == current.value()) || (previous.value() && current.value() == false)
+            );
+        private final Parameter<Mode> mode = new Parameter<>(
+            "mode",
+            true,
+            () -> null,
+            (n, c, o) -> Mode.valueOf(o.toString().toUpperCase(Locale.ROOT)),
+            m -> toType(m).enabled.explicit() ? null : toType(m).mode,
+            (b, n, v) -> b.field(n, v.toString().toLowerCase(Locale.ROOT)),
+            v -> v.toString().toLowerCase(Locale.ROOT)
+        ).setMergeValidator((previous, current, conflicts) -> (previous == current) || current != Mode.STORED)
+            .setSerializerCheck((includeDefaults, isConfigured, value) -> value != null); // don't emit if `enabled` is configured
         private final Parameter<List<String>> includes = Parameter.stringArrayParam(
             "includes",
             false,
@@ -86,25 +105,32 @@ public class SourceFieldMapper extends MetadataFieldMapper {
         @Override
         protected Parameter<?>[] getParameters() {
             if (IndexSettings.isTimeSeriesModeEnabled()) {
-                return new Parameter<?>[] { enabled, synthetic, includes, excludes };
+                return new Parameter<?>[] { enabled, mode, includes, excludes };
             }
             return new Parameter<?>[] { enabled, includes, excludes };
         }
 
+        private boolean isDefault() {
+            if (mode.get() != null) {
+                return false;
+            }
+            if (enabled.get().value() == false) {
+                return false;
+            }
+            return includes.getValue().isEmpty() && excludes.getValue().isEmpty();
+        }
+
         @Override
         public SourceFieldMapper build() {
-            if (enabled.getValue() == Defaults.ENABLED
-                && synthetic.getValue() == Defaults.SYNTHETIC
-                && includes.getValue().isEmpty()
-                && excludes.getValue().isEmpty()) {
-                return DEFAULT;
+            if (enabled.getValue().explicit() && mode.get() != null) {
+                throw new MapperParsingException("Cannot set both [mode] and [enabled] parameters");
             }
-            if (enabled.getValue() == false && synthetic.getValue()) {
-                throw new IllegalArgumentException("_source may not be disabled when setting [synthetic: true]");
+            if (isDefault()) {
+                return DEFAULT;
             }
             return new SourceFieldMapper(
-                enabled.getValue(),
-                synthetic.getValue(),
+                mode.get(),
+                enabled.get(),
                 includes.getValue().toArray(String[]::new),
                 excludes.getValue().toArray(String[]::new)
             );
@@ -140,32 +166,48 @@ public class SourceFieldMapper extends MetadataFieldMapper {
         }
     }
 
-    private final boolean enabled;
+    // nullable for bwc reasons
+    private final @Nullable Mode mode;
+    private final Explicit<Boolean> enabled;
+
     /** indicates whether the source will always exist and be complete, for use by features like the update API */
     private final boolean complete;
-    private final boolean synthetic;
 
     private final String[] includes;
     private final String[] excludes;
 
-    private SourceFieldMapper(boolean enabled, boolean synthetic, String[] includes, String[] excludes) {
-        super(new SourceFieldType(enabled));
+    private SourceFieldMapper(Mode mode, Explicit<Boolean> enabled, String[] includes, String[] excludes) {
+        super(new SourceFieldType((enabled.explicit() && enabled.value()) || (enabled.explicit() == false && mode != Mode.DISABLED)));
+        assert enabled.explicit() == false || mode == null;
+        this.mode = mode;
         this.enabled = enabled;
-        this.synthetic = synthetic;
         this.includes = includes;
         this.excludes = excludes;
         final boolean filtered = CollectionUtils.isEmpty(includes) == false || CollectionUtils.isEmpty(excludes) == false;
-        if (filtered && synthetic) {
+        if (filtered && mode == Mode.SYNTHETIC) {
             throw new IllegalArgumentException("filtering the stored _source is incompatible with synthetic source");
         }
-        this.filter = enabled && filtered
+        this.filter = stored() && filtered
             ? XContentFieldFilter.newFieldFilter(includes, excludes)
             : (sourceBytes, contentType) -> sourceBytes;
-        this.complete = enabled && synthetic == false && CollectionUtils.isEmpty(includes) && CollectionUtils.isEmpty(excludes);
+        this.complete = stored() && CollectionUtils.isEmpty(includes) && CollectionUtils.isEmpty(excludes);
+    }
+
+    private boolean stored() {
+        if (enabled.explicit() || mode == null) {
+            return enabled.value();
+        }
+        return mode == Mode.STORED;
     }
 
     public boolean enabled() {
-        return enabled;
+        if (enabled.explicit()) {
+            return enabled.value();
+        }
+        if (mode != null) {
+            return mode != Mode.DISABLED;
+        }
+        return enabled.value();
     }
 
     public boolean isComplete() {
@@ -193,7 +235,7 @@ public class SourceFieldMapper extends MetadataFieldMapper {
 
     @Nullable
     public BytesReference applyFilters(@Nullable BytesReference originalSource, @Nullable XContentType contentType) throws IOException {
-        if (enabled && synthetic == false && originalSource != null) {
+        if (stored() && originalSource != null) {
             // Percolate and tv APIs may not set the source and that is ok, because these APIs will not index any data
             return filter.apply(originalSource, contentType);
         } else {
@@ -215,13 +257,13 @@ public class SourceFieldMapper extends MetadataFieldMapper {
      * Build something to load source {@code _source}.
      */
     public <T> SourceLoader newSourceLoader(Mapping mapping) {
-        if (synthetic) {
+        if (mode == Mode.SYNTHETIC) {
             return new SourceLoader.Synthetic(mapping);
         }
         return SourceLoader.FROM_STORED_SOURCE;
     }
 
     public boolean isSynthetic() {
-        return synthetic;
+        return mode == Mode.SYNTHETIC;
     }
 }

+ 45 - 4
server/src/test/java/org/elasticsearch/index/mapper/SourceFieldMapperTests.java

@@ -42,9 +42,18 @@ public class SourceFieldMapperTests extends MetadataMapperTestCase {
             topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("enabled", false).endObject()),
             dm -> assertFalse(dm.metadataMapper(SourceFieldMapper.class).enabled())
         );
+        checker.registerUpdateCheck(
+            topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject()),
+            topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()),
+            dm -> assertTrue(dm.metadataMapper(SourceFieldMapper.class).isSynthetic())
+        );
         checker.registerConflictCheck("includes", b -> b.array("includes", "foo*"));
         checker.registerConflictCheck("excludes", b -> b.array("excludes", "foo*"));
-        checker.registerConflictCheck("synthetic", b -> b.field("synthetic", true));
+        checker.registerConflictCheck(
+            "mode",
+            topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "synthetic").endObject()),
+            topMapping(b -> b.startObject(SourceFieldMapper.NAME).field("mode", "stored").endObject())
+        );
     }
 
     public void testNoFormat() throws Exception {
@@ -172,13 +181,45 @@ public class SourceFieldMapperTests extends MetadataMapperTestCase {
         assertThat(exception.getRootCause().getMessage(), containsString("Unexpected close marker '}'"));
     }
 
-    public void testSyntheticDisabledNotSupported() throws Exception {
+    public void testSyntheticDisabledNotSupported() {
         Exception e = expectThrows(
             MapperParsingException.class,
             () -> createDocumentMapper(
-                topMapping(b -> b.startObject("_source").field("enabled", false).field("synthetic", true).endObject())
+                topMapping(b -> b.startObject("_source").field("enabled", false).field("mode", "synthetic").endObject())
             )
         );
-        assertThat(e.getMessage(), containsString("_source may not be disabled when setting [synthetic: true]"));
+        assertThat(e.getMessage(), containsString("Cannot set both [mode] and [enabled] parameters"));
+    }
+
+    public void testSyntheticUpdates() throws Exception {
+        MapperService mapperService = createMapperService("""
+            { "_doc" : { "_source" : { "mode" : "synthetic" } } }
+            """);
+
+        SourceFieldMapper mapper = mapperService.documentMapper().sourceMapper();
+        assertTrue(mapper.enabled());
+        assertTrue(mapper.isSynthetic());
+
+        merge(mapperService, """
+            { "_doc" : { "_source" : { "mode" : "synthetic" } } }
+            """);
+        mapper = mapperService.documentMapper().sourceMapper();
+        assertTrue(mapper.enabled());
+        assertTrue(mapper.isSynthetic());
+
+        ParsedDocument doc = mapperService.documentMapper().parse(source("{}"));
+        assertNull(doc.rootDoc().get(SourceFieldMapper.NAME));
+
+        Exception e = expectThrows(IllegalArgumentException.class, () -> merge(mapperService, """
+            { "_doc" : { "_source" : { "mode" : "stored" } } }
+            """));
+        assertThat(e.getMessage(), containsString("Cannot update parameter [mode] from [synthetic] to [stored]"));
+
+        merge(mapperService, """
+            { "_doc" : { "_source" : { "mode" : "disabled" } } }
+            """);
+        mapper = mapperService.documentMapper().sourceMapper();
+        assertFalse(mapper.enabled());
+        assertFalse(mapper.isSynthetic());
     }
 }

+ 1 - 1
server/src/test/java/org/elasticsearch/index/mapper/SourceLoaderTests.java

@@ -74,7 +74,7 @@ public class SourceLoaderTests extends MapperServiceTestCase {
 
     public void testNoSubobjectsRootObject() throws IOException {
         XContentBuilder mappings = topMapping(b -> {
-            b.startObject("_source").field("synthetic", true).endObject();
+            b.startObject("_source").field("mode", "synthetic").endObject();
             b.field("subobjects", false);
             b.startObject("properties");
             b.startObject("foo.bar.baz").field("type", "keyword").endObject();

+ 2 - 1
server/src/test/java/org/elasticsearch/index/shard/ShardGetServiceTests.java

@@ -112,7 +112,8 @@ public class ShardGetServiceTests extends IndexShardTestCase {
         String expectedFetchedSource = """
             {"bar":42,"foo":7}""";
         String sourceOptions = """
-            "synthetic": true""";
+            "mode": "synthetic"
+            """;
         runGetFromTranslogWithOptions(docToIndex, sourceOptions, expectedFetchedSource, "\"long\"", 7L, true);
     }
 

+ 3 - 2
test/framework/src/main/java/org/elasticsearch/index/mapper/MapperServiceTestCase.java

@@ -651,7 +651,8 @@ public abstract class MapperServiceTestCase extends ESTestCase {
     protected final String syntheticSource(DocumentMapper mapper, CheckedConsumer<XContentBuilder, IOException> build) throws IOException {
         try (Directory directory = newDirectory()) {
             RandomIndexWriter iw = new RandomIndexWriter(random(), directory);
-            iw.addDocument(mapper.parse(source(build)).rootDoc());
+            LuceneDocument doc = mapper.parse(source(build)).rootDoc();
+            iw.addDocument(doc);
             iw.close();
             try (DirectoryReader reader = DirectoryReader.open(directory)) {
                 SourceLoader loader = mapper.sourceMapper().newSourceLoader(mapper.mapping());
@@ -701,7 +702,7 @@ public abstract class MapperServiceTestCase extends ESTestCase {
 
     protected final XContentBuilder syntheticSourceMapping(CheckedConsumer<XContentBuilder, IOException> buildFields) throws IOException {
         return topMapping(b -> {
-            b.startObject("_source").field("synthetic", true).endObject();
+            b.startObject("_source").field("mode", "synthetic").endObject();
             b.startObject("properties");
             buildFields.accept(b);
             b.endObject();

+ 2 - 2
x-pack/plugin/ccr/qa/multi-cluster/src/test/java/org/elasticsearch/xpack/ccr/FollowIndexIT.java

@@ -388,7 +388,7 @@ public class FollowIndexIT extends ESCCRRestTestCase {
         if ("leader".equals(targetCluster)) {
             logger.info("Running against leader cluster");
             createIndex(adminClient(), leaderIndexName, Settings.EMPTY, """
-                "_source": {"synthetic": true},
+                "_source": {"mode": "synthetic"},
                 "properties": {"kwd": {"type": "keyword"}}}""", null);
             for (int i = 0; i < numDocs; i++) {
                 logger.info("Indexing doc [{}]", i);
@@ -413,7 +413,7 @@ public class FollowIndexIT extends ESCCRRestTestCase {
             }
             assertBusy(() -> {
                 verifyDocuments(client(), followIndexName, numDocs);
-                assertMap(getIndexMappingAsMap(followIndexName), matchesMap().extraOk().entry("_source", Map.of("synthetic", true)));
+                assertMap(getIndexMappingAsMap(followIndexName), matchesMap().extraOk().entry("_source", Map.of("mode", "synthetic")));
                 if (overrideNumberOfReplicas) {
                     assertMap(getIndexSettingsAsMap(followIndexName), matchesMap().extraOk().entry("index.number_of_replicas", "0"));
                 } else {