elasticgraph-support 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +6 -0
- data/elasticgraph-support.gemspec +16 -0
- data/lib/elastic_graph/constants.rb +220 -0
- data/lib/elastic_graph/error.rb +99 -0
- data/lib/elastic_graph/support/faraday_middleware/msearch_using_get_instead_of_post.rb +31 -0
- data/lib/elastic_graph/support/faraday_middleware/support_timeouts.rb +36 -0
- data/lib/elastic_graph/support/from_yaml_file.rb +53 -0
- data/lib/elastic_graph/support/graphql_formatter.rb +66 -0
- data/lib/elastic_graph/support/hash_util.rb +191 -0
- data/lib/elastic_graph/support/logger.rb +82 -0
- data/lib/elastic_graph/support/memoizable_data.rb +147 -0
- data/lib/elastic_graph/support/monotonic_clock.rb +20 -0
- data/lib/elastic_graph/support/threading.rb +42 -0
- data/lib/elastic_graph/support/time_set.rb +293 -0
- data/lib/elastic_graph/support/time_util.rb +108 -0
- data/lib/elastic_graph/support/untyped_encoder.rb +67 -0
- data/lib/elastic_graph/version.rb +15 -0
- metadata +256 -0
@@ -0,0 +1,191 @@
|
|
1
|
+
# Copyright 2024 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
|
+
module Support
|
11
|
+
class HashUtil
|
12
|
+
# Recursively transforms any hash keys in the given object to string keys, without
|
13
|
+
# mutating the provided argument.
|
14
|
+
def self.stringify_keys(object)
|
15
|
+
recursively_transform(object) do |key, value, hash|
|
16
|
+
hash[key.to_s] = value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Recursively transforms any hash keys in the given object to symbol keys, without
|
21
|
+
# mutating the provided argument.
|
22
|
+
#
|
23
|
+
# Important note: this should never be used on untrusted input. Symbols are not GCd in
|
24
|
+
# Ruby in the same way as strings.
|
25
|
+
def self.symbolize_keys(object)
|
26
|
+
recursively_transform(object) do |key, value, hash|
|
27
|
+
hash[key.to_sym] = value
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Recursively prunes nil values from the hash, at any level of its structure, without
|
32
|
+
# mutating the provided argument. Key paths that are pruned are yielded to the caller
|
33
|
+
# to allow the caller to have awareness of what was pruned.
|
34
|
+
def self.recursively_prune_nils_from(object, &block)
|
35
|
+
recursively_prune_if(object, block, &:nil?)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Recursively prunes nil values or empty hash/array values from the hash, at any level
|
39
|
+
# of its structure, without mutating the provided argument. Key paths that are pruned
|
40
|
+
# are yielded to the caller to allow the caller to have awareness of what was pruned.
|
41
|
+
def self.recursively_prune_nils_and_empties_from(object, &block)
|
42
|
+
recursively_prune_if(object, block) do |value|
|
43
|
+
if value.is_a?(::Hash) || value.is_a?(::Array)
|
44
|
+
value.empty?
|
45
|
+
else
|
46
|
+
value.nil?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Recursively flattens the provided source hash, converting keys to strings along the way
|
52
|
+
# with dots used to separate nested parts. For example:
|
53
|
+
#
|
54
|
+
# flatten_and_stringify_keys({ a: { b: 3 }, c: 5 }, prefix: "foo") returns:
|
55
|
+
# { "foo.a.b" => 3, "foo.c" => 5 }
|
56
|
+
def self.flatten_and_stringify_keys(source_hash, prefix: nil)
|
57
|
+
# @type var flat_hash: ::Hash[::String, untyped]
|
58
|
+
flat_hash = {}
|
59
|
+
prefix = prefix ? "#{prefix}." : ""
|
60
|
+
# `_ =` is needed by steep because it thinks `prefix` could be `nil` in spite of the above line.
|
61
|
+
populate_flat_hash(source_hash, _ = prefix, flat_hash)
|
62
|
+
flat_hash
|
63
|
+
end
|
64
|
+
|
65
|
+
# Recursively merges the values from `hash2` into `hash1`, without mutating either `hash1` or `hash2`.
|
66
|
+
# When a key is in both `hash2` and `hash1`, takes the value from `hash2` just like `Hash#merge` does.
|
67
|
+
def self.deep_merge(hash1, hash2)
|
68
|
+
# `_ =` needed to satisfy steep--the types here are quite complicated.
|
69
|
+
_ = hash1.merge(hash2) do |key, hash1_value, hash2_value|
|
70
|
+
if ::Hash === hash1_value && ::Hash === hash2_value
|
71
|
+
deep_merge(hash1_value, hash2_value)
|
72
|
+
else
|
73
|
+
hash2_value
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Fetches a list of (potentially) nested value from a hash. The `key_path` is expected
|
79
|
+
# to be a string with dots between the nesting levels (e.g. `foo.bar`). Returns `[]` if
|
80
|
+
# the value at any parent key is `nil`. Returns a flat array of values if the structure
|
81
|
+
# at any level is an array.
|
82
|
+
#
|
83
|
+
# Raises an error if the key is not found unless a default block is provided.
|
84
|
+
# Raises an error if any parent value is not a hash as expected.
|
85
|
+
# Raises an error if the provided path is not a full path to a leaf in the nested structure.
|
86
|
+
def self.fetch_leaf_values_at_path(hash, key_path, &default)
|
87
|
+
do_fetch_leaf_values_at_path(hash, key_path.split("."), 0, &default)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Fetches a single value from the hash at the given path. The `key_path` is expected
|
91
|
+
# to be a string with dots between the nesting levels (e.g. `foo.bar`).
|
92
|
+
#
|
93
|
+
# If any parent value is not a hash as expected, raises an error.
|
94
|
+
# If the key at any level is not found, yields to the provided block (which can provide a default value)
|
95
|
+
# or raises an error if no block is provided.
|
96
|
+
def self.fetch_value_at_path(hash, key_path)
|
97
|
+
path_parts = key_path.split(".")
|
98
|
+
|
99
|
+
path_parts.each.with_index(1).reduce(hash) do |inner_hash, (key, num_parts)|
|
100
|
+
if inner_hash.is_a?(::Hash)
|
101
|
+
inner_hash.fetch(key) do
|
102
|
+
missing_path = path_parts.first(num_parts).join(".")
|
103
|
+
return yield missing_path if block_given?
|
104
|
+
raise KeyError, "Key not found: #{missing_path.inspect}"
|
105
|
+
end
|
106
|
+
else
|
107
|
+
raise KeyError, "Value at key #{path_parts.first(num_parts - 1).join(".").inspect} is not a `Hash` as expected; " \
|
108
|
+
"instead, was a `#{(_ = inner_hash).class}`"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
private_class_method def self.recursively_prune_if(object, notify_pruned_path)
|
114
|
+
recursively_transform(object) do |key, value, hash, key_path|
|
115
|
+
if yield(value)
|
116
|
+
notify_pruned_path&.call(key_path)
|
117
|
+
else
|
118
|
+
hash[key] = value
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
private_class_method def self.recursively_transform(object, key_path = nil, &hash_entry_handler)
|
124
|
+
case object
|
125
|
+
when ::Hash
|
126
|
+
# @type var initial: ::Hash[key, value]
|
127
|
+
initial = {}
|
128
|
+
object.each_with_object(initial) do |(key, value), hash|
|
129
|
+
updated_path = key_path ? "#{key_path}.#{key}" : key.to_s
|
130
|
+
value = recursively_transform(value, updated_path, &hash_entry_handler)
|
131
|
+
hash_entry_handler.call(key, value, hash, updated_path)
|
132
|
+
end
|
133
|
+
when ::Array
|
134
|
+
object.map.with_index do |item, index|
|
135
|
+
recursively_transform(item, "#{key_path}[#{index}]", &hash_entry_handler)
|
136
|
+
end
|
137
|
+
else
|
138
|
+
object
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
private_class_method def self.populate_flat_hash(source_hash, prefix, flat_hash)
|
143
|
+
source_hash.each do |key, value|
|
144
|
+
if value.is_a?(::Hash)
|
145
|
+
populate_flat_hash(value, "#{prefix}#{key}.", flat_hash)
|
146
|
+
elsif value.is_a?(::Array) && value.grep(::Hash).any?
|
147
|
+
raise ArgumentError, "`flatten_and_stringify_keys` cannot handle nested arrays of hashes, but got: #{value.inspect}"
|
148
|
+
else
|
149
|
+
flat_hash["#{prefix}#{key}"] = value
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
private_class_method def self.do_fetch_leaf_values_at_path(object, path_parts, level_index, &default)
|
155
|
+
if level_index == path_parts.size
|
156
|
+
if object.is_a?(::Hash)
|
157
|
+
raise KeyError, "Key was not a path to a leaf field: #{path_parts.join(".").inspect}"
|
158
|
+
else
|
159
|
+
return Array(object)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
case object
|
164
|
+
when nil
|
165
|
+
[]
|
166
|
+
when ::Hash
|
167
|
+
key = path_parts[level_index]
|
168
|
+
if object.key?(key)
|
169
|
+
do_fetch_leaf_values_at_path(object.fetch(key), path_parts, level_index + 1, &default)
|
170
|
+
else
|
171
|
+
missing_path = path_parts.first(level_index + 1).join(".")
|
172
|
+
if default
|
173
|
+
Array(default.call(missing_path))
|
174
|
+
else
|
175
|
+
raise KeyError, "Key not found: #{missing_path.inspect}"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
when ::Array
|
179
|
+
object.flat_map do |element|
|
180
|
+
do_fetch_leaf_values_at_path(element, path_parts, level_index, &default)
|
181
|
+
end
|
182
|
+
else
|
183
|
+
# Note: we intentionally do not put the value (`current_level_hash`) in the
|
184
|
+
# error message, as that would risk leaking PII. But the class of the value should be OK.
|
185
|
+
raise KeyError, "Value at key #{path_parts.first(level_index).join(".").inspect} is not a `Hash` as expected; " \
|
186
|
+
"instead, was a `#{object.class}`"
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "elastic_graph/error"
|
10
|
+
require "json"
|
11
|
+
require "logger"
|
12
|
+
require "pathname"
|
13
|
+
|
14
|
+
module ElasticGraph
|
15
|
+
module Support
|
16
|
+
module Logger
|
17
|
+
# Builds a logger instance from the given parsed YAML config.
|
18
|
+
def self.from_parsed_yaml(parsed_yaml)
|
19
|
+
Factory.build(config: Config.from_parsed_yaml(parsed_yaml))
|
20
|
+
end
|
21
|
+
|
22
|
+
module Factory
|
23
|
+
def self.build(config:, device: nil)
|
24
|
+
::Logger.new(
|
25
|
+
device || config.prepared_device,
|
26
|
+
level: config.level,
|
27
|
+
formatter: config.formatter
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
class JSONAwareFormatter
|
33
|
+
def initialize
|
34
|
+
@original_formatter = ::Logger::Formatter.new
|
35
|
+
end
|
36
|
+
|
37
|
+
def call(severity, datetime, progname, msg)
|
38
|
+
msg = msg.is_a?(::Hash) ? ::JSON.generate(msg, space: " ") : msg
|
39
|
+
@original_formatter.call(severity, datetime, progname, msg)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Config < ::Data.define(
|
44
|
+
# Determines what severity level we log. Valid values are `DEBUG`, `INFO`, `WARN`,
|
45
|
+
# `ERROR`, `FATAL` and `UNKNOWN`.
|
46
|
+
:level,
|
47
|
+
# Determines where we log to. Must be a string. "stdout" or "stderr" are interpreted
|
48
|
+
# as being those output streams; any other value is assumed to be a file path.
|
49
|
+
:device,
|
50
|
+
# Object used to format log messages. Defaults to an instance of `JSONAwareFormatter`.
|
51
|
+
:formatter
|
52
|
+
)
|
53
|
+
def prepared_device
|
54
|
+
case device
|
55
|
+
when "stdout" then $stdout
|
56
|
+
when "stderr" then $stderr
|
57
|
+
else
|
58
|
+
::Pathname.new(device).parent.mkpath
|
59
|
+
device
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.from_parsed_yaml(hash)
|
64
|
+
hash = hash.fetch("logger")
|
65
|
+
extra_keys = hash.keys - EXPECTED_KEYS
|
66
|
+
|
67
|
+
unless extra_keys.empty?
|
68
|
+
raise ConfigError, "Unknown `logger` config settings: #{extra_keys.join(", ")}"
|
69
|
+
end
|
70
|
+
|
71
|
+
new(
|
72
|
+
level: hash["level"] || "INFO",
|
73
|
+
device: hash.fetch("device"),
|
74
|
+
formatter: ::Object.const_get(hash.fetch("formatter", JSONAwareFormatter.name)).new
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
EXPECTED_KEYS = members.map(&:to_s)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# Copyright 2024 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
|
+
require "delegate"
|
10
|
+
require "stringio"
|
11
|
+
|
12
|
+
module ElasticGraph
|
13
|
+
module Support
|
14
|
+
# `::Data.define` in Ruby 3.2+ is *very* handy for defining immutable value objects. However, one annoying
|
15
|
+
# aspect: instances are frozen, which gets in the way of defining a memoized method (e.g. a method that
|
16
|
+
# caches the result of an expensive computation). While memoization is not always safe (e.g. if you rely
|
17
|
+
# on an impure side-effect...) it's safe if what you're memoizing is a pure function of the immutable state
|
18
|
+
# of the value object. We rely on that very heavily in ElasticGraph (and used it with a prior "value objects"
|
19
|
+
# library we use before upgrading to Ruby 3.2).
|
20
|
+
#
|
21
|
+
# This abstraction aims to behave just like `::Data.define`, but with the added ability to define memoized methods.
|
22
|
+
# It makes this possible by combining `::Data.define` with `SimpleDelegator`: that defines a data class, but then
|
23
|
+
# wraps instances of it in a `SimpleDelegator` instance which is _not_ frozen. The memoized methods can then be
|
24
|
+
# defined on the wrapper.
|
25
|
+
#
|
26
|
+
# Note: getting this code to typecheck with steep is quite difficult, so we're just skipping it.
|
27
|
+
__skip__ =
|
28
|
+
module MemoizableData
|
29
|
+
# Defines a data class using the provided attributes.
|
30
|
+
#
|
31
|
+
# A block can be provided in order to define custom methods (including memoized methods!) or to override
|
32
|
+
# `initialize` in order to provide field defaults.
|
33
|
+
def self.define(*attributes, &block)
|
34
|
+
data_class = ::Data.define(*attributes)
|
35
|
+
|
36
|
+
DelegateClass(data_class) do
|
37
|
+
# Store a reference to our wrapped data class so we can use it in `ClassMethods` below.
|
38
|
+
const_set(:DATA_CLASS, data_class)
|
39
|
+
|
40
|
+
# Define default version of` after_initialize`. This is a hook that a user may override.
|
41
|
+
# standard:disable Lint/NestedMethodDefinition
|
42
|
+
private def after_initialize
|
43
|
+
end
|
44
|
+
# standard:enable Lint/NestedMethodDefinition
|
45
|
+
|
46
|
+
# If a block is provided, we evaluate it so that it can define memoized methods.
|
47
|
+
if block
|
48
|
+
original_initialize = instance_method(:initialize)
|
49
|
+
module_eval(&block)
|
50
|
+
|
51
|
+
# It's useful for the caller to be define `initialize` in order to provide field defaults, as
|
52
|
+
# shown in the `Data` docs:
|
53
|
+
#
|
54
|
+
# https://rubyapi.org/3.2/o/data
|
55
|
+
#
|
56
|
+
# However, to make that work, we need the `initialize` definition to be included in the data class,
|
57
|
+
# rather than in our `DelegateClass` wrapper.
|
58
|
+
#
|
59
|
+
# Here we detect when the block defines an `initialize` method.
|
60
|
+
if instance_method(:initialize) != original_initialize
|
61
|
+
# To mix the `initialize` override into the data class, we re-evaluate the block in a new module here.
|
62
|
+
# The module ignores all method definitions except `initialize`.
|
63
|
+
init_override_module = ::Module.new do
|
64
|
+
# We want to ignore all methods except the `initialize` method so that this module only contains `initialize`.
|
65
|
+
def self.method_added(method_name)
|
66
|
+
remove_method(method_name) unless method_name == :initialize
|
67
|
+
end
|
68
|
+
|
69
|
+
module_eval(&block)
|
70
|
+
end
|
71
|
+
|
72
|
+
data_class.include(init_override_module)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# `DelegateClass` objects are mutable via the `__setobj__` method. We don't want to allow mutation, so we undefine it here.
|
77
|
+
undef_method :__setobj__
|
78
|
+
|
79
|
+
prepend MemoizableData::InstanceMethods
|
80
|
+
extend MemoizableData::ClassMethods
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
module InstanceMethods
|
85
|
+
# SimpleDelegator#== automatically defines `==` so that it unwraps the wrapped type and calls `==` on it.
|
86
|
+
# However, the wrapped type doesn't automatically define `==` when given an equivalent wrapped instance.
|
87
|
+
#
|
88
|
+
# For `==` to work correctly we need to unwrap _both_ sides before delegating, which this takes care of.
|
89
|
+
def ==(other)
|
90
|
+
case other
|
91
|
+
when MemoizableData::InstanceMethods
|
92
|
+
__getobj__ == other.__getobj__
|
93
|
+
else
|
94
|
+
super
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# `with` is a standard `Data` API that returns a new instance with the specified fields updated.
|
99
|
+
#
|
100
|
+
# Since `DelegateClass` delegates all methods to the wrapped object, `with` will return an instance of the
|
101
|
+
# data class and not our wrapper. To overcome that, we redefine it here so that the new instance is re-wrapped.
|
102
|
+
def with(**updates)
|
103
|
+
# Note: we intentionally do _not_ `super` to the `Date#with` method here, because in Ruby 3.2 it has a bug that
|
104
|
+
# impacts us: `with` does not call `initialize` as it should. Some of our value classes (based on the old `values` gem)
|
105
|
+
# depend on this behavior, so here we work around it by delegating to `new` after merging the attributes.
|
106
|
+
#
|
107
|
+
# This bug is fixed in Ruby 3.3 so we should be able to revert back to an implementation that delegates with `super`
|
108
|
+
# after we are on Ruby 3.3. For more info, see:
|
109
|
+
# - https://bugs.ruby-lang.org/issues/19259
|
110
|
+
# - https://github.com/ruby/ruby/pull/7031
|
111
|
+
self.class.new(**to_h.merge(updates))
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
module ClassMethods
|
116
|
+
# `new` on a `SimpleDelegator` class accepts an instance of the wrapped type to wrap. `MemoizableData` is intended to
|
117
|
+
# hide the wrapping we're doing here, so here we want `new` to accept the direct arguments that `new` on the `Data` class
|
118
|
+
# would accept. Here we instantiate the data class and the wrap it.
|
119
|
+
def new(*args, **kwargs)
|
120
|
+
data_instance = self::DATA_CLASS.new(*args, **kwargs)
|
121
|
+
|
122
|
+
# Here we re-implement `new` (rather than using `super`) because `initialize` may be overridden.
|
123
|
+
allocate.instance_eval do
|
124
|
+
# Match `__setobj__` behavior: https://github.com/ruby/ruby/blob/v3_2_2/lib/delegate.rb#L411
|
125
|
+
@delegate_dc_obj = data_instance
|
126
|
+
after_initialize
|
127
|
+
self
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# `SimpleDelegator` delegates instance methods but not class methods. This is a standard `Data` class method
|
132
|
+
# that is worth delegating.
|
133
|
+
def members
|
134
|
+
self::DATA_CLASS.members
|
135
|
+
end
|
136
|
+
|
137
|
+
def method_added(method_name)
|
138
|
+
return unless method_name == :initialize
|
139
|
+
|
140
|
+
raise "`#{name}` overrides `initialize` in a subclass of `#{MemoizableData.name}`, but that can break things. Instead:\n\n" \
|
141
|
+
" 1) If you want to coerce field values or provide default field values, define `initialize` in a block passed to `#{MemoizableData.name}.define`.\n" \
|
142
|
+
" 2) If you want to perform some post-initialization setup, define an `after_initialize` method."
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# Copyright 2024 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
|
+
module Support
|
11
|
+
# A simple abstraction that provides a monotonic clock.
|
12
|
+
class MonotonicClock
|
13
|
+
# Returns an abstract "now" value in integer milliseconds, suitable for calculating
|
14
|
+
# a duration or deadline, without being impacted by leap seconds, etc.
|
15
|
+
def now_in_ms
|
16
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :millisecond)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# Copyright 2024 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
|
+
module Support
|
11
|
+
module Threading
|
12
|
+
# Like Enumerable#map, but performs the map in parallel using one thread per list item.
|
13
|
+
# Exceptions that happen in the threads will propagate to the caller at the end.
|
14
|
+
# Due to Ruby's GVL, this will never be helpful for pure computation, but can be
|
15
|
+
# quite helpful when dealing with blocking I/O. However, the cost of threads is
|
16
|
+
# such that this method should not be used when you have a large list of items to
|
17
|
+
# map over (say, hundreds or thousands of items or more).
|
18
|
+
def self.parallel_map(items)
|
19
|
+
threads = _ = items.map do |item|
|
20
|
+
::Thread.new do
|
21
|
+
# Disable reporting of exceptions. We use `value` at the end of this method, which
|
22
|
+
# propagates any exception that happened in the thread to the calling thread. If
|
23
|
+
# this is true (the default), then the exception is also printed to $stderr which
|
24
|
+
# is quite noisy.
|
25
|
+
::Thread.current.report_on_exception = false
|
26
|
+
|
27
|
+
yield item
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# `value` here either returns the value of the final expression in the thread, or raises
|
32
|
+
# whatever exception happened in the thread. `join` doesn't propagate the exception in
|
33
|
+
# the same way, so we always want to use `Thread#value` even if we are just using threads
|
34
|
+
# for side effects.
|
35
|
+
threads.map(&:value)
|
36
|
+
rescue => e
|
37
|
+
e.set_backtrace(e.backtrace + caller)
|
38
|
+
raise e
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|