Skip to content

Commit 5d8214f

Browse files
committed
Drop index template fields once the schema no longer references them
1 parent bc2d25d commit 5d8214f

27 files changed

Lines changed: 359 additions & 42 deletions

File tree

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

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ def initialize(datastore_client, index_template, env_agnostic_index_config_paren
3737
# and the state of the index in the datastore, does one of the following:
3838
#
3939
# - If the index did not already exist: creates the index with the desired mappings and settings.
40-
# - If the desired mapping has fewer fields than what is in the index template: leaves the existing
41-
# fields alone (see `put_index_template` for why).
40+
# - If the desired mapping has fewer fields than what is in the index template: preserves fields
41+
# that the schema definition still references (via `deleted_field`/`renamed_from` declarations)
42+
# and drops fields whose last schema reference has been removed (see `put_index_template`).
4243
# - If the settings have desired changes: updates the settings, restoring any setting that
4344
# no longer has a desired value to its default.
4445
# - If the mapping has desired changes: updates the mappings.
@@ -66,16 +67,21 @@ def validate
6667

6768
private
6869

69-
# Creates or updates the index template. While the datastore allows template fields to be dropped
70-
# (in contrast to concrete indices), we preserve any fields that exist on the current template but
71-
# are no longer desired (see `MappingUpdate.build_mapping_update`): new rollover indices are
72-
# auto-created from the template at indexing time with `dynamic: strict` mappings, so a dropped
73-
# field would cause indexing failures on those new indices for as long as any indexer (or replayed
74-
# event) can still write that field. We have previously had a near-SEV from dropping a template
75-
# field while deployed indexers were still running an old version of the code that used it.
70+
# Creates or updates the index template. Fields that exist on the current template but not in the
71+
# desired configuration are dropped only when it is provably safe. New rollover indices are
72+
# auto-created from the template at indexing time with `dynamic: strict` mappings, so dropping a
73+
# field that something can still write would cause indexing failures on those new indices--we have
74+
# previously had a near-SEV from dropping a template field while deployed indexers were still
75+
# running an old version of the code that used it. So, as long as a field is referenced by a
76+
# `deleted_field` or `renamed_from` declaration in the schema definition (meaning an old JSON
77+
# schema version may still reference it, and an indexer deployed with an older schema version or a
78+
# replayed old event may still write it), we preserve it via `field_paths_protected_from_removal`.
79+
# Once the last reference is removed from the schema definition--which `eg-schema_def` only
80+
# sanctions once no JSON schema version references the field--the field is dropped on the next
81+
# admin run, keeping templates from accumulating stale fields forever.
7682
def put_index_template
7783
action_description = if index_template_exists?
78-
"Updated index template: `#{@index_template.name}`:\n#{config_diff}"
84+
"Updated index template: `#{@index_template.name}`:\n#{config_diff}#{removed_fields_note}"
7985
else
8086
"Created index template: `#{@index_template.name}`"
8187
end
@@ -84,6 +90,25 @@ def put_index_template
8490
report_action action_description
8591
end
8692

93+
# Dropped fields show up as deletions in the reported diff, but we also call them out explicitly
94+
# since a field removal is the part of a mapping update most worth a second look from operators.
95+
def removed_fields_note
96+
removed_fields = mapping_field_paths_of(current_mapping) - mapping_field_paths_of(desired_mapping_for_update)
97+
return "" if removed_fields.empty?
98+
99+
"\n\n" + <<~EOS.chomp
100+
Removed #{removed_fields.size} stale field(s) from index template `#{@index_template.name}` that are no longer referenced by the schema: #{removed_fields.join(", ")}.
101+
New rollover indices created from this template will reject documents that still contain these fields (mappings are `dynamic: strict`).
102+
EOS
103+
end
104+
105+
def mapping_field_paths_of(mapping, parent_path: "")
106+
mapping.fetch("properties", MappingUpdate::EMPTY_PROPERTIES).flat_map do |field_name, field_mapping|
107+
path = "#{parent_path}#{field_name}"
108+
[path] + mapping_field_paths_of(field_mapping, parent_path: "#{path}.")
109+
end
110+
end
111+
87112
def cannot_modify_mapping_field_type_error
88113
"The datastore does not support modifying the type of a field from an existing index definition. " \
89114
"You are attempting to update type of fields (#{mapping_type_changes.inspect}) from the #{@index_template.name} index definition."
@@ -117,7 +142,11 @@ def settings_updates
117142
end
118143

119144
def desired_mapping_for_update
120-
@desired_mapping_for_update ||= MappingUpdate.build_mapping_update(desired: desired_mapping, current: current_mapping)
145+
@desired_mapping_for_update ||= MappingUpdate.build_mapping_update(
146+
desired: desired_mapping,
147+
current: current_mapping,
148+
protected_field_paths: @index_template.field_paths_protected_from_removal
149+
)
121150
end
122151

123152
def desired_config_parent_for_update

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,43 @@ module MappingUpdate
1818
# Elasticsearch/OpenSearch do not support removing mapping fields from an index. Preserve current
1919
# fields when building index mapping update payloads and diffs, while still allowing updates to
2020
# existing field parameters and additions of new fields.
21-
def self.build_mapping_update(desired:, current:)
21+
#
22+
# `protected_field_paths` limits which fields missing from the desired mapping get preserved: when
23+
# provided, a missing field is preserved only if its path (or a descendant's path) is in the set,
24+
# and other missing fields are dropped from the built update. When `nil`, all missing fields are
25+
# preserved.
26+
def self.build_mapping_update(desired:, current:, protected_field_paths: nil, parent_path: "")
2227
desired_properties = desired.fetch("properties", EMPTY_PROPERTIES)
2328
current_properties = current.fetch("properties", EMPTY_PROPERTIES)
2429

25-
merged_properties = desired_properties.merge(current_properties) do |_key, desired_value, current_value|
30+
preserved_current_properties = current_properties.select do |field_name, _|
31+
desired_properties.key?(field_name) || preserve_missing_field?(protected_field_paths, "#{parent_path}#{field_name}")
32+
end
33+
34+
merged_properties = desired_properties.merge(preserved_current_properties) do |field_name, desired_value, current_value|
2635
if current_value.is_a?(::Hash) && current_value.key?("properties") && desired_value.key?("properties")
27-
build_mapping_update(desired: desired_value, current: current_value)
36+
build_mapping_update(
37+
desired: desired_value,
38+
current: current_value,
39+
protected_field_paths: protected_field_paths,
40+
parent_path: "#{parent_path}#{field_name}."
41+
)
2842
else
2943
desired_value
3044
end
3145
end
3246

3347
desired.merge("properties" => merged_properties)
3448
end
49+
50+
# A missing field is preserved if it is protected itself, or if any protected path sits underneath
51+
# it (dropping the field would drop the protected descendant along with it).
52+
def self.preserve_missing_field?(protected_field_paths, path)
53+
return true if protected_field_paths.nil?
54+
55+
protected_field_paths.include?(path) || protected_field_paths.any? { |protected_path| protected_path.start_with?("#{path}.") }
56+
end
57+
private_class_method :preserve_missing_field?
3558
end
3659
end
3760
end

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ module ElasticGraph
2626
@clock: singleton(::Time)
2727

2828
def put_index_template: () -> void
29+
def removed_fields_note: () -> ::String
30+
def mapping_field_paths_of: (DatastoreCore::indexMappingHash, ?parent_path: ::String) -> ::Array[::String]
2931
def cannot_modify_mapping_field_type_error: () -> ::String
3032
def index_template_exists?: () -> bool
3133

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ module ElasticGraph
44
module MappingUpdate
55
EMPTY_PROPERTIES: ::Hash[::String, untyped]
66

7-
def self.build_mapping_update: (desired: ::Hash[::String, untyped], current: ::Hash[::String, untyped]) -> ::Hash[::String, untyped]
7+
def self.build_mapping_update: (
8+
desired: ::Hash[::String, untyped],
9+
current: ::Hash[::String, untyped],
10+
?protected_field_paths: ::Set[::String]?,
11+
?parent_path: ::String
12+
) -> ::Hash[::String, untyped]
13+
14+
def self.preserve_missing_field?: (::Set[::String]?, ::String) -> bool
815
end
916
end
1017
end

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

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

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

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,56 @@ 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 "drops mapping fields whose last schema reference has been removed, 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(
120+
"Updated index template: `#{unique_index_name}`",
121+
"properties.name", "properties.options.properties.size",
122+
"Removed 2 stale field(s) from index template `#{unique_index_name}` that are no longer referenced by the schema: name, options.size.",
123+
"New rollover indices created from this template will reject documents that still contain these fields (mappings are `dynamic: strict`)."
124+
)
125+
end
126+
127+
it "preserves removed mapping fields that `deleted_field` declarations still reference, since indexers running an older schema version may still write them" do
128+
configure_index_definition(schema_def)
129+
output_io.string = +"" # use `+` so it is not a frozen string literal.
130+
131+
expect {
132+
# Here we remove the `name` field and the `options.size` field to verify it works for both root and nested fields.
133+
configure_index_definition(schema_def(
134+
avoid_defining_widget_fields: %w[name],
135+
avoid_defining_widget_options_fields: %w[size],
136+
configure_widget: ->(t) { t.deleted_field "name" },
137+
configure_widget_options: ->(t) { t.deleted_field "size" }
138+
))
139+
}.to maintain {
140+
props = get_index_template_definition_configuration(unique_index_name).dig("mappings", "properties")
141+
[props.keys.sort, props.dig("options", "properties").keys.sort]
142+
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
143+
.and make_no_datastore_write_calls("main")
144+
145+
expect(output_io.string).to exclude("Updated index template", "Removed")
146+
end
147+
98148
it "creates concrete indices based on `setting_overrides_by_timestamp` configuration, and avoids creating an extra index for 'now'" do
99149
jan_2020_index_name = unique_index_name + "_rollover__2020-01"
100150

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

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -203,25 +203,6 @@ def simulate_presence_of_extra_setting(admin, index_definition_name, name, value
203203
}.to make_no_datastore_write_calls("main")
204204
end
205205

206-
it "is a no-op when attempting to drop a mapping field, preserving the existing field" do
207-
configure_index_definition(schema_def)
208-
output_io.string = +"" # use `+` so it is not a frozen string literal.
209-
210-
expect {
211-
# Here we remove the `name` field and the `options.size` field to verify it works for both root and nested fields.
212-
configure_index_definition(schema_def(
213-
avoid_defining_widget_fields: %w[name],
214-
avoid_defining_widget_options_fields: %w[size]
215-
))
216-
}.to maintain {
217-
props = get_index_definition_configuration(unique_index_name).dig("mappings", "properties")
218-
[props.keys.sort, props.dig("options", "properties").keys.sort]
219-
}.from([[*index_meta_fields, "created_at", "id", "name", "options"], ["color", "size"]])
220-
.and make_no_datastore_write_calls("main")
221-
222-
expect(output_io.string).to exclude("Updated", "properties.name", "properties.options.properties.size")
223-
end
224-
225206
it "maintains `_meta.ElasticGraph.sources` as a stateful append-only-set that remembers sources that were once active but we no longer have" do
226207
expect {
227208
configure_index_definition(schema_def(

elasticgraph-admin/spec/unit/elastic_graph/admin/index_definition_configurator/mapping_update_spec.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,83 @@ module IndexDefinitionConfigurator
5151
})
5252
end
5353

54+
it "drops current-only fields that are not protected when `protected_field_paths` is provided, while preserving protected ones" do
55+
current = {
56+
"properties" => {
57+
"name" => {"type" => "keyword"},
58+
"legacy_field" => {"type" => "keyword"},
59+
"options" => {
60+
"properties" => {
61+
"color" => {"type" => "keyword"},
62+
"size" => {"type" => "keyword"},
63+
"weight" => {"type" => "integer"}
64+
}
65+
}
66+
}
67+
}
68+
69+
desired = {
70+
"properties" => {
71+
"name" => {"type" => "keyword"},
72+
"options" => {
73+
"properties" => {
74+
"color" => {"type" => "keyword"}
75+
}
76+
}
77+
}
78+
}
79+
80+
result = described_class.build_mapping_update(
81+
desired: desired,
82+
current: current,
83+
protected_field_paths: ["options.size"].to_set
84+
)
85+
86+
expect(result).to eq({
87+
"properties" => {
88+
"name" => {"type" => "keyword"},
89+
"options" => {
90+
"properties" => {
91+
"color" => {"type" => "keyword"},
92+
"size" => {"type" => "keyword"}
93+
}
94+
}
95+
}
96+
})
97+
end
98+
99+
it "preserves an entire current-only parent field when a protected path sits underneath it, since dropping the parent would drop the protected field" do
100+
current = {
101+
"properties" => {
102+
"old_parent" => {
103+
"properties" => {
104+
"keep_me" => {"type" => "keyword"},
105+
"sibling" => {"type" => "keyword"}
106+
}
107+
}
108+
}
109+
}
110+
111+
desired = {"properties" => {}}
112+
113+
result = described_class.build_mapping_update(
114+
desired: desired,
115+
current: current,
116+
protected_field_paths: ["old_parent.keep_me"].to_set
117+
)
118+
119+
expect(result).to eq({
120+
"properties" => {
121+
"old_parent" => {
122+
"properties" => {
123+
"keep_me" => {"type" => "keyword"},
124+
"sibling" => {"type" => "keyword"}
125+
}
126+
}
127+
}
128+
})
129+
end
130+
54131
it "favors the desired mapping for fields present in both, allowing existing field parameters to be removed" do
55132
current = {
56133
"properties" => {

elasticgraph-datastore_core/lib/elastic_graph/datastore_core/index_definition.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def self.with(name:, runtime_metadata:, config:, datastore_clients_by_name:, sch
3030
default_sort_clauses: runtime_metadata.default_sort_fields.map(&:to_query_clause),
3131
current_sources: runtime_metadata.current_sources,
3232
fields_by_path: runtime_metadata.fields_by_path,
33+
field_paths_protected_from_removal: runtime_metadata.field_paths_protected_from_removal,
3334
env_index_config: env_index_config,
3435
defined_clusters: config.clusters.keys.to_set,
3536
datastore_clients_by_name: datastore_clients_by_name,

0 commit comments

Comments
 (0)