elasticgraph-support 0.18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|