Skip to content

Commit a335f6c

Browse files
committed
Improve admin mapping change output
1 parent f3818af commit a335f6c

11 files changed

Lines changed: 171 additions & 115 deletions

File tree

elasticgraph-admin/lib/elastic_graph/admin/index_definition_configurator/for_index.rb

Lines changed: 8 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# frozen_string_literal: true
88

99
require "elastic_graph/admin/cluster_configurator/action_reporter"
10+
require "elastic_graph/admin/index_definition_configurator/mapping_update"
1011
require "elastic_graph/datastore_core/index_config_normalizer"
1112
require "elastic_graph/indexer/hash_differ"
1213
require "elastic_graph/support/hash_util"
@@ -32,9 +33,8 @@ def initialize(datastore_client, index, env_agnostic_index_config, output)
3233
# and the state of the index in the datastore, does one of the following:
3334
#
3435
# - If the index did not already exist: creates the index with the desired mappings and settings.
35-
# - If the desired mapping has fewer fields than what is in the index: raises an exception,
36-
# because the datastore provides no way to remove fields from a mapping and it would be confusing
37-
# for this method to silently ignore the issue.
36+
# - If the desired mapping has fewer fields than what is in the index: leaves the existing fields
37+
# alone, because the datastore provides no way to remove fields from a mapping.
3838
# - If the settings have desired changes: updates the settings, restoring any setting that
3939
# no longer has a desired value to its default.
4040
# - If the mapping has desired changes: updates the mappings.
@@ -75,14 +75,9 @@ def create_new_index
7575
end
7676

7777
def update_mapping
78-
@datastore_client.put_index_mapping(index: @index.name, body: desired_mapping)
78+
@datastore_client.put_index_mapping(index: @index.name, body: desired_mapping_for_update)
7979
action_description = "Updated mappings for index `#{@index.name}`:\n#{mapping_diff}"
8080

81-
if mapping_removals.any?
82-
action_description += "\n\nNote: the extra fields listed here will not actually get removed. " \
83-
"Mapping removals are unsupported (but ElasticGraph will leave them alone and they'll cause no problems)."
84-
end
85-
8681
report_action action_description
8782
end
8883

@@ -100,10 +95,6 @@ def index_exists?
10095
!current_config.empty?
10196
end
10297

103-
def mapping_removals
104-
@mapping_removals ||= mapping_fields_from(current_mapping) - mapping_fields_from(desired_mapping)
105-
end
106-
10798
def mapping_type_changes
10899
@mapping_type_changes ||= begin
109100
flattened_current = Support::HashUtil.flatten_and_stringify_keys(current_mapping)
@@ -116,7 +107,7 @@ def mapping_type_changes
116107
end
117108

118109
def has_mapping_updates?
119-
current_mapping != desired_mapping
110+
current_mapping != desired_mapping_for_update
120111
end
121112

122113
def settings_updates
@@ -127,15 +118,8 @@ def settings_updates
127118
end
128119
end
129120

130-
def mapping_fields_from(mapping_hash, prefix = "")
131-
(mapping_hash["properties"] || []).flat_map do |key, params|
132-
field = prefix + key
133-
if params.key?("properties")
134-
[field] + mapping_fields_from(params, "#{field}.")
135-
else
136-
[field]
137-
end
138-
end
121+
def desired_mapping_for_update
122+
@desired_mapping_for_update ||= MappingUpdate.merge_existing_fields_into(desired_mapping, current_mapping)
139123
end
140124

141125
def desired_mapping
@@ -178,7 +162,7 @@ def current_config
178162
end
179163

180164
def mapping_diff
181-
@mapping_diff ||= Indexer::HashDiffer.diff(current_mapping, desired_mapping) || "(no diff)"
165+
@mapping_diff ||= Indexer::HashDiffer.diff(current_mapping, desired_mapping_for_update) || "(no diff)"
182166
end
183167

184168
def settings_diff

elasticgraph-admin/lib/elastic_graph/admin/index_definition_configurator/for_index_template.rb

Lines changed: 8 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ def initialize(datastore_client, index_template, env_agnostic_index_config_paren
3636
# and the state of the index in the datastore, does one of the following:
3737
#
3838
# - If the index did not already exist: creates the index with the desired mappings and settings.
39-
# - If the desired mapping has fewer fields than what is in the index: raises an exception,
40-
# because the datastore provides no way to remove fields from a mapping and it would be confusing
41-
# for this method to silently ignore the issue.
39+
# - If the desired mapping has fewer fields than what is in the index template: updates the template
40+
# to drop those fields. Related concrete indices preserve their existing fields because the datastore
41+
# provides no way to remove fields from index mappings.
4242
# - If the settings have desired changes: updates the settings, restoring any setting that
4343
# no longer has a desired value to its default.
4444
# - If the mapping has desired changes: updates the mappings.
@@ -67,19 +67,13 @@ def validate
6767
private
6868

6969
def put_index_template
70-
desired_template_config_payload = Support::HashUtil.deep_merge(
71-
desired_config_parent,
72-
{"template" => {"mappings" => merge_properties(desired_mapping, current_mapping)}}
73-
)
74-
75-
action_description = "Updated index template: `#{@index_template.name}`:\n#{config_diff}"
76-
77-
if mapping_removals.any?
78-
action_description += "\n\nNote: the extra fields listed here will not actually get removed. " \
79-
"Mapping removals are unsupported (but ElasticGraph will leave them alone and they'll cause no problems)."
70+
action_description = if index_template_exists?
71+
"Updated index template: `#{@index_template.name}`:\n#{config_diff}"
72+
else
73+
"Created index template: `#{@index_template.name}`"
8074
end
8175

82-
@datastore_client.put_index_template(name: @index_template.name, body: desired_template_config_payload)
76+
@datastore_client.put_index_template(name: @index_template.name, body: desired_config_parent)
8377
report_action action_description
8478
end
8579

@@ -92,10 +86,6 @@ def index_template_exists?
9286
!current_config_parent.empty?
9387
end
9488

95-
def mapping_removals
96-
@mapping_removals ||= mapping_fields_from(current_mapping) - mapping_fields_from(desired_mapping)
97-
end
98-
9989
def mapping_type_changes
10090
@mapping_type_changes ||= begin
10191
flattened_current = Support::HashUtil.flatten_and_stringify_keys(current_mapping)
@@ -119,17 +109,6 @@ def settings_updates
119109
end
120110
end
121111

122-
def mapping_fields_from(mapping_hash, prefix = "")
123-
(mapping_hash["properties"] || []).flat_map do |key, params|
124-
field = prefix + key
125-
if params.key?("properties")
126-
[field] + mapping_fields_from(params, "#{field}.")
127-
else
128-
[field]
129-
end
130-
end
131-
end
132-
133112
def desired_mapping
134113
desired_config_parent.fetch("template").fetch("mappings")
135114
end
@@ -185,36 +164,6 @@ def report_action(message)
185164
@reporter.report_action(message)
186165
end
187166

188-
# Helper method used to merge properties between a _desired_ configuration and a _current_ configuration.
189-
# This is used when we are figuring out how to update an index template. We do not want to delete existing
190-
# fields from a template--while the datastore would allow it, our schema evolution strategy depends upon
191-
# us not dropping old unused fields. The datastore doesn't allow it on indices, anyway (though it does allow
192-
# it on index templates). We've ran into trouble (a near SEV) when allowing the logic here to delete an unused
193-
# field from an index template. The indexer "mapping completeness" check started failing because an old version
194-
# of the code (from back when the field in question was still used) noticed the expected field was missing and
195-
# started failing on every event.
196-
#
197-
# This helps us avoid that problem by retaining any currently existing fields.
198-
#
199-
# Long term, if we want to support fully "garbage collecting" these old fields on templates, we will need
200-
# to have them get dropped in a follow up step. We could have our `update_datastore_config` script notice that
201-
# the deployed prod indexers are at a version that will tolerate the fields being dropped, or support it
202-
# via an opt-in flag or something.
203-
def merge_properties(desired_object, current_object)
204-
desired_properties = desired_object.fetch("properties") { _ = {} }
205-
current_properties = current_object.fetch("properties") { _ = {} }
206-
207-
merged_properties = desired_properties.merge(current_properties) do |key, desired, current|
208-
if current.is_a?(::Hash) && current.key?("properties") && desired.key?("properties")
209-
merge_properties(desired, current)
210-
else
211-
desired
212-
end
213-
end
214-
215-
desired_object.merge("properties" => merged_properties)
216-
end
217-
218167
def related_index_configurators
219168
# Here we fan out and get a configurator for each related index. These are generally concrete
220169
# index that are based on a template, either via being specified in our config YAML, or via
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright 2024 - 2026 Block, Inc.
2+
#
3+
# Use of this source code is governed by an MIT-style
4+
# license that can be found in the LICENSE file or at
5+
# https://opensource.org/licenses/MIT.
6+
#
7+
# frozen_string_literal: true
8+
9+
module ElasticGraph
10+
class Admin
11+
module IndexDefinitionConfigurator
12+
module MappingUpdate
13+
empty_properties = {} # : ::Hash[::String, untyped]
14+
EMPTY_PROPERTIES = empty_properties.freeze
15+
16+
# Elasticsearch/OpenSearch do not support removing mapping fields from an index. Preserve current
17+
# fields when building concrete index update payloads and diffs, while still allowing updates to
18+
# existing field parameters and additions of new fields.
19+
def self.merge_existing_fields_into(desired_object, current_object)
20+
desired_properties = desired_object.fetch("properties", EMPTY_PROPERTIES)
21+
current_properties = current_object.fetch("properties", EMPTY_PROPERTIES)
22+
23+
merged_properties = desired_properties.merge(current_properties) do |_key, desired, current|
24+
if current.is_a?(::Hash) && current.key?("properties") && desired.key?("properties")
25+
merge_existing_fields_into(desired, current)
26+
else
27+
desired
28+
end
29+
end
30+
31+
desired_object.merge("properties" => merged_properties)
32+
end
33+
end
34+
end
35+
end
36+
end

elasticgraph-admin/sig/elastic_graph/admin/index_definition_configurator/for_index.rbs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ module ElasticGraph
2828
def cannot_modify_mapping_field_type_error: () -> ::String
2929
def index_exists?: () -> bool
3030

31-
@mapping_removals: ::Array[::String]?
32-
def mapping_removals: () -> ::Array[::String]
33-
3431
@mapping_type_changes: ::Array[::String]?
3532
def mapping_type_changes: () -> ::Array[::String]
3633

@@ -39,7 +36,8 @@ module ElasticGraph
3936
@settings_updates: DatastoreCore::indexSettingsHash?
4037
def settings_updates: () -> DatastoreCore::indexSettingsHash
4138

42-
def mapping_fields_from: (DatastoreCore::indexMappingHash, ?::String) -> ::Array[::String]
39+
@desired_mapping_for_update: DatastoreCore::indexMappingHash?
40+
def desired_mapping_for_update: () -> DatastoreCore::indexMappingHash
4341

4442
def desired_mapping: () -> DatastoreCore::indexMappingHash
4543

elasticgraph-admin/sig/elastic_graph/admin/index_definition_configurator/for_index_template.rbs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ module ElasticGraph
2929
def cannot_modify_mapping_field_type_error: () -> ::String
3030
def index_template_exists?: () -> bool
3131

32-
@mapping_removals: ::Array[::String]?
33-
def mapping_removals: () -> ::Array[::String]
34-
3532
@mapping_type_changes: ::Array[::String]?
3633
def mapping_type_changes: () -> ::Array[::String]
3734

@@ -40,8 +37,6 @@ module ElasticGraph
4037
@settings_updates: DatastoreCore::indexSettingsHash?
4138
def settings_updates: () -> DatastoreCore::indexSettingsHash
4239

43-
def mapping_fields_from: (DatastoreCore::indexMappingHash, ?::String) -> ::Array[::String]
44-
4540
def desired_mapping: () -> DatastoreCore::indexMappingHash
4641

4742
@desired_settings: DatastoreCore::indexSettingsHash?
@@ -62,7 +57,6 @@ module ElasticGraph
6257
def config_diff: () -> ::String
6358

6459
def report_action: (::String) -> void
65-
def merge_properties: (::Hash[::String, untyped], ::Hash[::String, untyped]) -> ::Hash[::String, untyped]
6660

6761
@related_index_configurators: ::Array[ForIndex]?
6862
def related_index_configurators: () -> ::Array[ForIndex]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module ElasticGraph
2+
class Admin
3+
module IndexDefinitionConfigurator
4+
module MappingUpdate
5+
EMPTY_PROPERTIES: ::Hash[::String, untyped]
6+
7+
def self.merge_existing_fields_into: (::Hash[::String, untyped], ::Hash[::String, untyped]) -> ::Hash[::String, untyped]
8+
end
9+
end
10+
end
11+
end

elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/for_index_spec.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@ module IndexDefinitionConfigurator
3535
}.to make_no_datastore_write_calls("main")
3636
end
3737

38+
it "is a no-op when attempting to drop a mapping field because the datastore does not support it" do
39+
configure_index_definition(schema_def)
40+
output_io.string = +"" # use `+` so it is not a frozen string literal.
41+
42+
expect {
43+
# Here we remove the `name` field and the `options.size` field to verify it works for both root and nested fields.
44+
configure_index_definition(schema_def(
45+
avoid_defining_widget_fields: %w[name],
46+
avoid_defining_widget_options_fields: %w[size]
47+
))
48+
}.to maintain {
49+
props = get_index_definition_configuration(unique_index_name).dig("mappings", "properties")
50+
[props.keys.sort, props.dig("options", "properties").keys.sort]
51+
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
52+
.and make_no_datastore_write_calls("main")
53+
54+
expect(output_io.string).to exclude("Updated mappings", "properties.name", "properties.options.properties.size")
55+
end
56+
3857
def make_datastore_calls_to_configure_index_def(index_name, subresource = nil)
3958
make_datastore_write_calls("main", "PUT #{put_index_definition_url(index_name, subresource)}")
4059
end

elasticgraph-admin/spec/integration/elastic_graph/admin/index_definition_configurator/for_index_template_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,30 @@ def fetch_artifact_configuration(schema_artifacts, index_def_name)
9595
.and make_datastore_calls_to_configure_index_def(unique_index_name, :settings)
9696
end
9797

98+
it "removes mapping fields from the index template while leaving concrete indices unchanged" do
99+
configure_index_definition(schema_def)
100+
output_io.string = +"" # use `+` so it is not a frozen string literal.
101+
102+
expect {
103+
# Here we remove the `name` field and the `options.size` field to verify it works for both root and nested fields.
104+
configure_index_definition(schema_def(
105+
avoid_defining_widget_fields: %w[name],
106+
avoid_defining_widget_options_fields: %w[size]
107+
))
108+
}.to change {
109+
props = get_index_template_definition_configuration(unique_index_name).dig("mappings", "properties")
110+
[props.keys.sort, props.dig("options", "properties").keys.sort]
111+
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
112+
.to([[*index_meta_fields, "created_at", "id", "options"], ["color"]])
113+
.and maintain {
114+
props = main_datastore_client.get_index(concrete_index_name_for_now(unique_index_name)).dig("mappings", "properties")
115+
[props.keys.sort, props.dig("options", "properties").keys.sort]
116+
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
117+
.and make_datastore_write_calls("main", "PUT #{put_index_template_definition_url(unique_index_name)}")
118+
119+
expect(output_io.string).to include("Updated index template: `#{unique_index_name}`", "properties.name", "properties.options.properties.size")
120+
end
121+
98122
it "creates concrete indices based on `setting_overrides_by_timestamp` configuration, and avoids creating an extra index for 'now'" do
99123
jan_2020_index_name = unique_index_name + "_rollover__2020-01"
100124

0 commit comments

Comments
 (0)