elasticgraph-support 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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