Skip to content

Commit c72908c

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

9 files changed

Lines changed: 148 additions & 98 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: 19 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
require "elastic_graph/admin/cluster_configurator/action_reporter"
1010
require "elastic_graph/admin/index_definition_configurator/for_index"
11+
require "elastic_graph/admin/index_definition_configurator/mapping_update"
1112
require "elastic_graph/datastore_core/index_config_normalizer"
1213
require "elastic_graph/indexer/hash_differ"
1314
require "elastic_graph/support/hash_util"
@@ -36,9 +37,8 @@ def initialize(datastore_client, index_template, env_agnostic_index_config_paren
3637
# and the state of the index in the datastore, does one of the following:
3738
#
3839
# - 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.
40+
# - If the desired mapping has fewer fields than what is in the index: leaves the existing fields
41+
# alone, because the datastore provides no way to remove fields from a mapping.
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_for_update)
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)
@@ -108,7 +98,7 @@ def mapping_type_changes
10898
end
10999

110100
def has_mapping_updates?
111-
current_mapping != desired_mapping
101+
current_mapping != desired_mapping_for_update
112102
end
113103

114104
def settings_updates
@@ -119,15 +109,8 @@ 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
112+
def desired_mapping_for_update
113+
@desired_mapping_for_update ||= MappingUpdate.merge_existing_fields_into(desired_mapping, current_mapping)
131114
end
132115

133116
def desired_mapping
@@ -158,6 +141,13 @@ def desired_config_parent
158141
end
159142
end
160143

144+
def desired_config_parent_for_update
145+
@desired_config_parent_for_update ||= Support::HashUtil.deep_merge(
146+
desired_config_parent,
147+
{"template" => {"mappings" => desired_mapping_for_update}}
148+
)
149+
end
150+
161151
def current_mapping
162152
current_config_parent.dig("template", "mappings") || {}
163153
end
@@ -178,43 +168,13 @@ def current_config_parent
178168
end
179169

180170
def config_diff
181-
@config_diff ||= Indexer::HashDiffer.diff(current_config_parent, desired_config_parent) || "(no diff)"
171+
@config_diff ||= Indexer::HashDiffer.diff(current_config_parent, desired_config_parent_for_update) || "(no diff)"
182172
end
183173

184174
def report_action(message)
185175
@reporter.report_action(message)
186176
end
187177

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-
218178
def related_index_configurators
219179
# Here we fan out and get a configurator for each related index. These are generally concrete
220180
# index that are based on a template, either via being specified in our config YAML, or via
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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. Index templates
17+
# allow it, but doing so can break old indexer versions that still expect those fields to exist.
18+
# Preserve current fields when building update payloads and diffs, while still allowing updates to
19+
# existing field parameters and additions of new fields.
20+
def self.merge_existing_fields_into(desired_object, current_object)
21+
desired_properties = desired_object.fetch("properties", EMPTY_PROPERTIES)
22+
current_properties = current_object.fetch("properties", EMPTY_PROPERTIES)
23+
24+
merged_properties = desired_properties.merge(current_properties) do |_key, desired, current|
25+
if current.is_a?(::Hash) && current.key?("properties") && desired.key?("properties")
26+
merge_existing_fields_into(desired, current)
27+
else
28+
desired
29+
end
30+
end
31+
32+
desired_object.merge("properties" => merged_properties)
33+
end
34+
end
35+
end
36+
end
37+
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: 5 additions & 5 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,7 +37,8 @@ module ElasticGraph
4037
@settings_updates: DatastoreCore::indexSettingsHash?
4138
def settings_updates: () -> DatastoreCore::indexSettingsHash
4239

43-
def mapping_fields_from: (DatastoreCore::indexMappingHash, ?::String) -> ::Array[::String]
40+
@desired_mapping_for_update: DatastoreCore::indexMappingHash?
41+
def desired_mapping_for_update: () -> DatastoreCore::indexMappingHash
4442

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

@@ -50,6 +48,9 @@ module ElasticGraph
5048
@desired_config_parent: ::Hash[::String, untyped]
5149
def desired_config_parent: () -> ::Hash[::String, untyped]
5250

51+
@desired_config_parent_for_update: ::Hash[::String, untyped]
52+
def desired_config_parent_for_update: () -> ::Hash[::String, untyped]
53+
5354
def current_mapping: () -> DatastoreCore::indexMappingHash
5455

5556
@current_settings: DatastoreCore::indexSettingsHash?
@@ -62,7 +63,6 @@ module ElasticGraph
6263
def config_diff: () -> ::String
6364

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

6767
@related_index_configurators: ::Array[ForIndex]?
6868
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/shared_examples.rb

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
4141
RSpec.shared_examples_for IndexDefinitionConfigurator, :uses_datastore, :builds_indexer do
4242
let(:output_io) { StringIO.new }
4343
let(:clock) { class_double(::Time, now: ::Time.utc(2024, 3, 20, 12, 0, 0)) }
44-
let(:mapping_removal_note_snippet) { "extra fields listed here will not actually get removed" }
4544
let(:index_meta_fields) { ["__sources", "__typename", "__versions"] }
4645

4746
it "idempotently creates an index or index template, avoiding unneeded datastore write calls" do
@@ -60,7 +59,7 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
6059
}.to maintain { get_index_definition_configuration(unique_index_name) }
6160
.and make_no_datastore_write_calls("main")
6261

63-
expect(output_io.string).not_to include(mapping_removal_note_snippet)
62+
expect(output_io.string).to exclude("+ mappings", "+ settings")
6463
end
6564

6665
it "allows new top-level fields to be added to an existing index or index template" do
@@ -77,7 +76,6 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
7776
# The printed description of what was changed should not mention settings that are not being updated.
7877
# (Requires us to normalize settings properly in the logic for this to be the case).
7978
expect(output_io.string).to include("properties.amount_cents").and exclude("coerce", "ignore_malformed", "number_of_replicas", "number_of_shards")
80-
expect(output_io.string).not_to include(mapping_removal_note_snippet)
8179
end
8280

8381
it "handles both `object` lists and `nested` lists" do
@@ -153,13 +151,16 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
153151
end
154152
}
155153
))
154+
output_io.string = +"" # use `+` so it is not a frozen string literal.
156155

157156
expect {
158157
configure_index_definition(schema_def)
159158
}.to change { get_index_definition_configuration(unique_index_name).dig("mappings", "properties", "name") }
160159
.from({"type" => "keyword", "meta" => {"foo" => "1"}})
161160
.to({"type" => "keyword"})
162161
.and make_datastore_calls_to_configure_index_def(unique_index_name, :mappings)
162+
163+
expect(output_io.string).to include("properties.name.meta")
163164
end
164165

165166
it "allows some previously unset settings to be changed on an existing index or index template" do
@@ -204,6 +205,7 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
204205

205206
it "is a no-op when we attempt to drop a field because the datastore doesn't support dropping mapping fields" do
206207
configure_index_definition(schema_def)
208+
output_io.string = +"" # use `+` so it is not a frozen string literal.
207209

208210
expect {
209211
# Here we remove the `name` field and the `options.size` field to verify it works for both root and nested fields.
@@ -215,9 +217,9 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
215217
props = get_index_definition_configuration(unique_index_name).dig("mappings", "properties")
216218
[props.keys.sort, props.dig("options", "properties").keys.sort]
217219
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
218-
.and make_datastore_calls_to_configure_index_def(unique_index_name, :mappings)
220+
.and make_no_datastore_write_calls("main")
219221

220-
expect(output_io.string).to include(mapping_removal_note_snippet)
222+
expect(output_io.string).to exclude("Updated mappings", "properties.name", "properties.options.properties.size")
221223
end
222224

223225
it "maintains `_meta.ElasticGraph.sources` as a stateful append-only-set that remembers sources that were once active but we no longer have" do

0 commit comments

Comments
 (0)