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.
@@ -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