Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def inner_meta
INNER_META
end

def handles_missing_values?
false
end

INNER_META = {
# On a date histogram aggregation, the `key` is formatted as a number (milliseconds since epoch). We
# need it formatted as a string, which `key_as_string` provides.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,29 @@
module ElasticGraph
class GraphQL
module Aggregation
class FieldTermGrouping < Support::MemoizableData.define(:field_path)
# @dynamic field_path
class FieldTermGrouping < Support::MemoizableData.define(:field_path, :field)
# @dynamic field_path, field
include TermGrouping

private
# Random 18 bytes converted to base64. Used 18 bytes instead of 16 to avoid base64 padding.
# Since it uses more bytes, this has a lower probability of collission than a 16 byte random UUID.
MISSING_STRING_PLACEHOLDER = "f1TXKoApWwIG3U8ks9vVduvU"
MISSING_NUMERIC_PLACEHOLDER = "NaN"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd sooner maybe expect a collision with NaN than maybe some random 64-bit number high up... but ES does support shorts/bytes/half_floats etc. Potentially we could make this PR string-only and probably still get 80% of the value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the general approach of only taking this approach for data types that have high confidence of avoiding a chance of collision.

For numeric types, the more I look into it, the more confident I am that we can use NaN without collision. JSON doesn't have a way to serialized NaN. And the top Google results say that NaN can't be stored in ElasticSearch. I tested trying to store NaN in a double field in OpenSearch and when I tried "quantityFloat": NaN then OpenSearch returns a failed to parse error with reason Non-standard token 'NaN'. And when I tried "quantityFloat": "NaN" it failed with reason Double value passed as String.

Even if someone solved how to send NaN in JSON and how to store NaN in ElasticSearch or OpenSearch, we could confidently use NaN for ElasticGraph data types Int, JsonSafeLong, and LongString because ElasticGraph would reject NaN when validating those data types.

One risk with NaN is that ElasticSearch or OpenSearch could change to no longer support "NaN" for missing value aggregation. But that would be a breaking change, so seems unlikely that they would do so.

Another risk is that ES/OS could eventually support storing NaN, which would then open up the possibility of collision.


def missing_value_placeholder
unwrapped_type = field.type.unwrap_fully
case unwrapped_type.name
when "String", "ID"
MISSING_STRING_PLACEHOLDER
when "Int", "JsonSafeLong", "Float"
MISSING_NUMERIC_PLACEHOLDER
else
unwrapped_type.enum? ? MISSING_STRING_PLACEHOLDER : nil
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about the approach here: this isn't a complete list of scalar types (for example, Boolean, DateTime, Date, "LongString", etc). Plus there could be user-defined custom scalar types. There's no way for this bit of code to know about all scalar types (nor should it).

I'd prefer that we "invert" this, where scalar types themselves would define what their missing value placeholder is, and this would then just use the type's missing value, if there is one. Here's a sketch of how that could work:

In elasticgraph-schema_definition, we'd update scalar_type to allow missing value placeholders to be defined on them:

schema.scalar_type "JsonSafeLong" do |t|
  # ...
  t.grouping_missing_value_placeholder "NaN"
end

In elasticgraph-schema_artifacts, the ScalarType runtime metadata class would have a grouping_missing_value_placeholder attribute. The scalar type definition in elasticgraph-schema_definition would record the grouping_missing_value_placeholder on runtime metadata when dumping schema artifacts.

In elasticgraph-graphql, we'd wire up the runtime metadata ScalarType to make it available to the ElasticGraph::GraphQL::Schema::Type (you'd probably have to pass it through a couple layers).

Finally, you could read the grouping_missing_value_placeholder value here and return it. Scalar types for which we've defined the grouping_missing_value_placeholder would get this new treatment, while types for which no grouping_missing_value_placeholder has been defined would get the old treatment (a missing subaggregation).

Note: if we go this route, I'd ask that you break it up into multiple PRs as that's going to be way too big to easily review. As a suggested breakdown:

  • A PR to add grouping_missing_value_placeholder as an attribute to the elasticgraph-schema_artifacts runtime metadata class. (There are some tests, etc that you'll need to update as part of this PR, so it won't be a one-liner!).
  • A PR to update the scalar_type API offered by elasticgraph-schema_definition so that it offers grouping_missing_value_placeholder and records it in the runtime metadata.
    • As part of this PR, you could update the scalar_type definition of all the built-in scalar types to define the grouping_missing_value_placeholder for each.
  • A PR to update elasticgraph-graphql to make grouping_missing_value_placeholder available from ElasticGraph::GraphQL::Schema::Type by wiring it up.
  • A final PR to use grouping_missing_value_placeholder in the grouping logic as you've done here.

(If some of these PRs seem really small, feel free to combine them.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to see some before/after examples of Elasticsearch queries

Yes, I intend to.

I'd prefer that we "invert" this, where scalar types themselves would define what their missing value placeholder is

Yes, I see that as a better approach. Regardless of how/where we define the missing value placeholder, we'll continue to support data types for which we haven't defined a missing value placeholder.

Before doing all the work to create 4 PRs as you've outlined it, I want to have confidence that making these changes will alleviate the performance/availability issues we're experiencing due to the complex generated queries.

end

private

def terms_subclause
{"field" => encoded_index_field_path}
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,29 +65,36 @@ def grouping_detail(groupings, query)
def format_buckets(sub_agg, buckets_path, parent_key_fields: {}, parent_key_values: [])
agg_with_buckets = sub_agg.dig(*buckets_path)

missing_bucket = {
# Doc counts in missing value buckets are always perfectly accurate.
"doc_count_error_upper_bound" => 0
}.merge(sub_agg.dig(*missing_bucket_path_from(buckets_path))) # : ::Hash[::String, untyped]

meta = agg_with_buckets.fetch("meta")

grouping_field_names = meta.fetch("grouping_fields") # provides the names of the fields being grouped on
key_path = meta.fetch("key_path") # indicates whether we want to get the key values from `key` or `key_as_string`.
sub_buckets_path = meta["buckets_path"] # buckets_path is optional, so we don't use fetch.
missing_values = meta["missing_values"] # missing_values is optional, so we don't use fetch.
merge_into_bucket = meta.fetch("merge_into_bucket")

raw_buckets = agg_with_buckets.fetch("buckets") # : ::Array[::Hash[::String, untyped]]

# If the missing bucket is non-empty, include it. This matches the behavior of composite aggregations
# when the `missing_bucket` option is used.
raw_buckets += [missing_bucket] if missing_bucket.fetch("doc_count") > 0
missing_bucket = sub_agg.dig(*missing_bucket_path_from(buckets_path))
raw_buckets << {
# Doc counts in missing value buckets are always perfectly accurate.
"doc_count_error_upper_bound" => 0
}.merge(missing_bucket) if missing_bucket["doc_count"] > 0

raw_buckets.flat_map do |raw_bucket|
# The key will either be a single value (e.g. `47`) if we used a `terms`/`date_histogram` aggregation,
# or a tuple of values (e.g. `[47, "abc"]`) if we used a `multi_terms` aggregation. Here we convert it
# to the form needed for resolving `grouped_by` fields: a hash like `{"size" => 47, "tag" => "abc"}`.
key_values = Array(raw_bucket.dig(*key_path))

# if missing_values are present, then for each element in key_values, replace with nil if it matches the placeholder value from missing_values
key_values = key_values.each_with_index.map do |value, index|
value == missing_values[index] ? nil : value
end if missing_values

key_fields_hash = grouping_field_names.zip(key_values).to_h

# If we have multiple levels of aggregations, we need to merge the key fields hash with the key fields from the parent levels.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,32 +149,34 @@ def wrap_with_grouping(grouping, query:)
"grouping_fields" => [agg_key]
})

extra_inner_meta["missing_values"] = [grouping.missing_value_placeholder] if grouping.handles_missing_values?

inner_agg_hash = {
"aggs" => (clauses unless (clauses || {}).empty?),
"meta" => meta.merge(extra_inner_meta)
}.compact

missing_bucket_inner_agg_hash = inner_agg_hash.key?("aggs") ? inner_agg_hash : {} # : ::Hash[::String, untyped]

AggregationDetail.new(
{
agg_key => grouping.non_composite_clause_for(query).merge(inner_agg_hash),

# Here we include a `missing` aggregation as a sibling to the main grouping aggregation. We do this
# so that we get a bucket of documents that have `null` values for the field we are grouping on, in
# order to provide the same behavior as the `CompositeGroupingAdapter` (which uses the built-in
# `missing_bucket` option).
#
# To work correctly, we need to include this `missing` aggregation as a sibling at _every_ level of
# the aggregation structure, and the `missing` aggregation needs the same child aggregations as the
# main grouping aggregation has. Given the recursive nature of how this is applied, this results in
# a fairly complex structure, even though conceptually the idea behind this isn't _too_ bad.
Key.missing_value_bucket_key(agg_key) => {
"missing" => {"field" => grouping.encoded_index_field_path}
}.merge(missing_bucket_inner_agg_hash)
},
{"buckets_path" => [agg_key]}
)
wrapped_clauses = {
agg_key => grouping.non_composite_clause_for(query).merge(inner_agg_hash)
}

unless grouping.handles_missing_values?
# Here we include a `missing` aggregation as a sibling to the main grouping aggregation. We do this
# so that we get a bucket of documents that have `null` values for the field we are grouping on, in
# order to provide the same behavior as the `CompositeGroupingAdapter` (which uses the built-in
# `missing_bucket` option).
#
# To work correctly, we need to include this `missing` aggregation as a sibling at _every_ level of
# the aggregation structure, and the `missing` aggregation needs the same child aggregations as the
# main grouping aggregation has. Given the recursive nature of how this is applied, this results in
# a fairly complex structure, even though conceptually the idea behind this isn't _too_ bad.
missing_bucket_inner_agg_hash = inner_agg_hash.key?("aggs") ? inner_agg_hash : {} # : ::Hash[::String, untyped]
wrapped_clauses[Key.missing_value_bucket_key(agg_key)] = {
"missing" => {"field" => grouping.encoded_index_field_path}
}.merge(missing_bucket_inner_agg_hash)
end

AggregationDetail.new(wrapped_clauses, {"buckets_path" => [agg_key]})
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ def build_groupings_from(node_node, aggregation_name, from_field_path: [])
date_time_groupings_from(field_path: field_path, node: node)
elsif !field.type.object?
# Non-date/time grouping
[FieldTermGrouping.new(field_path: field_path)]
[FieldTermGrouping.new(field_path: field_path, field: field)]
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def composite_clause(grouping_options: {})

def non_composite_clause_for(query)
clause_value = work_around_elasticsearch_bug(terms_subclause)
clause_value["missing"] = missing_value_placeholder if handles_missing_values?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
clause_value["missing"] = missing_value_placeholder if handles_missing_values?
clause_value = clause_value.merge({"missing" => missing_value_placeholder}) if handles_missing_values?

As a general rule, we favor a coding style that avoids mutating objects unless strictly necessary. Re-assigning a local variable to a different object is fine though--local variables, by their nature, are localized.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll watch out for that. There are probably a couple places in this PR that mutate instead of re-assign.

{
"terms" => clause_value.merge({
"size" => query.paginator.requested_page_size,
Expand All @@ -39,6 +40,14 @@ def non_composite_clause_for(query)
}
end

def missing_value_placeholder
nil
end

def handles_missing_values?
missing_value_placeholder != nil
end

INNER_META = {"key_path" => ["key"], "merge_into_bucket" => {}}

def inner_meta
Expand Down
Loading