julewire-core 1.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.
Files changed (164) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +73 -0
  5. data/docs/advanced-configuration.md +66 -0
  6. data/docs/attribute-keys.md +74 -0
  7. data/docs/configuration.md +327 -0
  8. data/docs/context-and-propagation.md +353 -0
  9. data/docs/contracts.md +211 -0
  10. data/docs/development.md +49 -0
  11. data/docs/extensions-and-api.md +567 -0
  12. data/docs/health-schema.md +104 -0
  13. data/docs/instrumentation-cheatsheet.md +29 -0
  14. data/docs/internals.md +135 -0
  15. data/docs/outputs-and-lifecycle.md +206 -0
  16. data/docs/quickstart.md +133 -0
  17. data/docs/record-sources.md +17 -0
  18. data/docs/records-and-data-policy.md +230 -0
  19. data/docs/security-and-wire.md +45 -0
  20. data/docs/tail.md +91 -0
  21. data/exe/julewire +6 -0
  22. data/julewire-core.gemspec +41 -0
  23. data/lib/julewire/core/cli/doctor.rb +143 -0
  24. data/lib/julewire/core/cli/line_helpers.rb +77 -0
  25. data/lib/julewire/core/cli/log_formats/console_text.rb +25 -0
  26. data/lib/julewire/core/cli/log_formats/core_json_decoder.rb +46 -0
  27. data/lib/julewire/core/cli/log_formats/core_json_encoder.rb +21 -0
  28. data/lib/julewire/core/cli/log_formats/record_decoder.rb +39 -0
  29. data/lib/julewire/core/cli/log_formats.rb +123 -0
  30. data/lib/julewire/core/cli/tail.rb +153 -0
  31. data/lib/julewire/core/cli/transcode.rb +105 -0
  32. data/lib/julewire/core/cli.rb +73 -0
  33. data/lib/julewire/core/configuration.rb +99 -0
  34. data/lib/julewire/core/context_store.rb +384 -0
  35. data/lib/julewire/core/destinations/chaos_output.rb +91 -0
  36. data/lib/julewire/core/destinations/collection.rb +177 -0
  37. data/lib/julewire/core/destinations/definition.rb +125 -0
  38. data/lib/julewire/core/destinations/destination.rb +268 -0
  39. data/lib/julewire/core/destinations/registry.rb +81 -0
  40. data/lib/julewire/core/destinations/sink.rb +35 -0
  41. data/lib/julewire/core/destinations/synchronized_output.rb +57 -0
  42. data/lib/julewire/core/destinations/tail_sampling.rb +321 -0
  43. data/lib/julewire/core/destinations/write_step.rb +119 -0
  44. data/lib/julewire/core/destinations.rb +33 -0
  45. data/lib/julewire/core/diagnostics/callback_notifier.rb +63 -0
  46. data/lib/julewire/core/diagnostics/doctor.rb +114 -0
  47. data/lib/julewire/core/diagnostics/failure_snapshot.rb +39 -0
  48. data/lib/julewire/core/diagnostics/health.rb +144 -0
  49. data/lib/julewire/core/diagnostics/integration_health_store.rb +64 -0
  50. data/lib/julewire/core/diagnostics/internal_records.rb +61 -0
  51. data/lib/julewire/core/diagnostics/invalid_severity_reporter.rb +112 -0
  52. data/lib/julewire/core/diagnostics/meta_observer.rb +161 -0
  53. data/lib/julewire/core/diagnostics/process_integration_health.rb +26 -0
  54. data/lib/julewire/core/diagnostics/tail/renderer.rb +36 -0
  55. data/lib/julewire/core/diagnostics/tail.rb +168 -0
  56. data/lib/julewire/core/diagnostics.rb +8 -0
  57. data/lib/julewire/core/error.rb +7 -0
  58. data/lib/julewire/core/execution/boundary.rb +106 -0
  59. data/lib/julewire/core/execution/handle.rb +77 -0
  60. data/lib/julewire/core/execution/lineage.rb +192 -0
  61. data/lib/julewire/core/execution/measurement_handle.rb +28 -0
  62. data/lib/julewire/core/execution/no_current_error.rb +9 -0
  63. data/lib/julewire/core/execution/scope.rb +246 -0
  64. data/lib/julewire/core/execution/scope_fields.rb +76 -0
  65. data/lib/julewire/core/execution/scope_identity.rb +71 -0
  66. data/lib/julewire/core/execution/scope_snapshot.rb +92 -0
  67. data/lib/julewire/core/execution/summary_state.rb +206 -0
  68. data/lib/julewire/core/execution/view.rb +56 -0
  69. data/lib/julewire/core/facade_methods.rb +181 -0
  70. data/lib/julewire/core/fields/attribute_keys.rb +54 -0
  71. data/lib/julewire/core/fields/attributes_proxy.rb +11 -0
  72. data/lib/julewire/core/fields/bags.rb +123 -0
  73. data/lib/julewire/core/fields/carry_proxy.rb +22 -0
  74. data/lib/julewire/core/fields/context_proxy.rb +11 -0
  75. data/lib/julewire/core/fields/field_set.rb +78 -0
  76. data/lib/julewire/core/fields/field_stack.rb +269 -0
  77. data/lib/julewire/core/fields/internal/deletion.rb +68 -0
  78. data/lib/julewire/core/fields/internal.rb +87 -0
  79. data/lib/julewire/core/fields/lookup.rb +35 -0
  80. data/lib/julewire/core/fields/section_proxy.rb +88 -0
  81. data/lib/julewire/core/fields/stack_set.rb +69 -0
  82. data/lib/julewire/core/fields/static_labels.rb +43 -0
  83. data/lib/julewire/core/fields/summary_proxy.rb +62 -0
  84. data/lib/julewire/core/integration/configurable.rb +52 -0
  85. data/lib/julewire/core/integration/destination_health.rb +43 -0
  86. data/lib/julewire/core/integration/event_subscriber.rb +62 -0
  87. data/lib/julewire/core/integration/facade.rb +131 -0
  88. data/lib/julewire/core/integration/fork_hooks.rb +79 -0
  89. data/lib/julewire/core/integration/health.rb +41 -0
  90. data/lib/julewire/core/integration/ivar_state.rb +38 -0
  91. data/lib/julewire/core/integration/lifecycle.rb +22 -0
  92. data/lib/julewire/core/integration/scoped.rb +34 -0
  93. data/lib/julewire/core/integration/settings.rb +92 -0
  94. data/lib/julewire/core/integration/subscriber_install.rb +39 -0
  95. data/lib/julewire/core/integration/subscription.rb +29 -0
  96. data/lib/julewire/core/integration/values.rb +192 -0
  97. data/lib/julewire/core/lifecycle_error.rb +7 -0
  98. data/lib/julewire/core/local_storage.rb +91 -0
  99. data/lib/julewire/core/processing/level_threshold.rb +53 -0
  100. data/lib/julewire/core/processing/match.rb +74 -0
  101. data/lib/julewire/core/processing/pipeline.rb +360 -0
  102. data/lib/julewire/core/processing/processor_chain.rb +69 -0
  103. data/lib/julewire/core/processing/processor_registry.rb +115 -0
  104. data/lib/julewire/core/processing/processor_wrapper.rb +44 -0
  105. data/lib/julewire/core/processing/record_field_transform.rb +124 -0
  106. data/lib/julewire/core/processing/sampling.rb +109 -0
  107. data/lib/julewire/core/processing.rb +41 -0
  108. data/lib/julewire/core/propagation/carrier.rb +93 -0
  109. data/lib/julewire/core/propagation.rb +50 -0
  110. data/lib/julewire/core/records/console_formatter.rb +24 -0
  111. data/lib/julewire/core/records/deconstruct.rb +19 -0
  112. data/lib/julewire/core/records/display_message.rb +166 -0
  113. data/lib/julewire/core/records/draft.rb +576 -0
  114. data/lib/julewire/core/records/formatter.rb +14 -0
  115. data/lib/julewire/core/records/lazy_emit_input.rb +99 -0
  116. data/lib/julewire/core/records/metadata.rb +23 -0
  117. data/lib/julewire/core/records/public_projection.rb +51 -0
  118. data/lib/julewire/core/records/raw_input.rb +41 -0
  119. data/lib/julewire/core/records/record.rb +175 -0
  120. data/lib/julewire/core/records/severity.rb +44 -0
  121. data/lib/julewire/core/runtime.rb +515 -0
  122. data/lib/julewire/core/runtime_locator.rb +20 -0
  123. data/lib/julewire/core/runtime_registry.rb +48 -0
  124. data/lib/julewire/core/runtime_state.rb +39 -0
  125. data/lib/julewire/core/scheduling/deadline.rb +24 -0
  126. data/lib/julewire/core/scheduling/deadline_scheduler.rb +207 -0
  127. data/lib/julewire/core/scheduling/shared_scheduler.rb +48 -0
  128. data/lib/julewire/core/sentinel.rb +18 -0
  129. data/lib/julewire/core/serialization/backtrace_limiter.rb +50 -0
  130. data/lib/julewire/core/serialization/bounded_transform.rb +55 -0
  131. data/lib/julewire/core/serialization/bounded_traversal.rb +274 -0
  132. data/lib/julewire/core/serialization/deep_compact_empty.rb +67 -0
  133. data/lib/julewire/core/serialization/deep_freeze.rb +63 -0
  134. data/lib/julewire/core/serialization/encoding_sanitizer.rb +40 -0
  135. data/lib/julewire/core/serialization/exception_shape.rb +88 -0
  136. data/lib/julewire/core/serialization/json_encoder.rb +69 -0
  137. data/lib/julewire/core/serialization/serializer.rb +233 -0
  138. data/lib/julewire/core/serialization/serializer_pool.rb +21 -0
  139. data/lib/julewire/core/serialization/text_encoder.rb +147 -0
  140. data/lib/julewire/core/serialization/value_copy.rb +209 -0
  141. data/lib/julewire/core/serialization/value_traversal.rb +150 -0
  142. data/lib/julewire/core/testing/chaos/catalog.rb +72 -0
  143. data/lib/julewire/core/testing/chaos/core_runtime.rb +120 -0
  144. data/lib/julewire/core/testing/chaos/destination.rb +55 -0
  145. data/lib/julewire/core/testing/chaos/emitter.rb +20 -0
  146. data/lib/julewire/core/testing/chaos/raising_output.rb +42 -0
  147. data/lib/julewire/core/testing/chaos.rb +80 -0
  148. data/lib/julewire/core/testing/contracts/component.rb +162 -0
  149. data/lib/julewire/core/testing/contracts/deadline_scheduler.rb +59 -0
  150. data/lib/julewire/core/testing/contracts/integration.rb +166 -0
  151. data/lib/julewire/core/testing/contracts/integration_fields.rb +36 -0
  152. data/lib/julewire/core/testing/contracts/record_draft.rb +37 -0
  153. data/lib/julewire/core/testing/contracts/runtime.rb +178 -0
  154. data/lib/julewire/core/testing/contracts/wire.rb +60 -0
  155. data/lib/julewire/core/testing/contracts.rb +24 -0
  156. data/lib/julewire/core/testing/coverage.rb +58 -0
  157. data/lib/julewire/core/testing/test_reports.rb +78 -0
  158. data/lib/julewire/core/testing.rb +122 -0
  159. data/lib/julewire/core/validation.rb +69 -0
  160. data/lib/julewire/core/version.rb +7 -0
  161. data/lib/julewire/core.rb +80 -0
  162. data/lib/julewire/error.rb +5 -0
  163. data/lib/julewire-core.rb +3 -0
  164. metadata +237 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Julewire
7
+ module Core
8
+ module Serialization
9
+ # @api extension
10
+ class TextEncoder
11
+ SEVERITY_STYLES = {
12
+ "debug" => 36,
13
+ "info" => 32,
14
+ "warn" => 33,
15
+ "error" => 31,
16
+ "fatal" => 35,
17
+ "unknown" => 37
18
+ }.freeze
19
+ PUNK_SEVERITY_STYLES = {
20
+ "debug" => 36,
21
+ "info" => 92,
22
+ "warn" => 93,
23
+ "error" => 91,
24
+ "fatal" => 95,
25
+ "unknown" => 97
26
+ }.freeze
27
+ PUNK_SEVERITY_GLYPHS = {
28
+ "debug" => "..",
29
+ "info" => ">>",
30
+ "warn" => "!!",
31
+ "error" => "XX",
32
+ "fatal" => "##",
33
+ "unknown" => "??"
34
+ }.freeze
35
+ THEMES = %i[plain punk].freeze
36
+ DEFAULT_MAX_VALUE_BYTES = 160
37
+
38
+ class << self
39
+ def punk_glyph(severity)
40
+ PUNK_SEVERITY_GLYPHS.fetch(severity.to_s, PUNK_SEVERITY_GLYPHS.fetch("unknown"))
41
+ end
42
+ end
43
+
44
+ def initialize(max_value_bytes: DEFAULT_MAX_VALUE_BYTES, color: false, append_newline: true, theme: :plain)
45
+ @max_value_bytes = Validation.validate_integer_limit!(
46
+ max_value_bytes,
47
+ name: :max_value_bytes,
48
+ positive: true
49
+ )
50
+ @color = color
51
+ @line_suffix = append_newline ? "\n" : ""
52
+ @theme = validate_theme(theme)
53
+ end
54
+
55
+ def call(payload)
56
+ text = payload.is_a?(String) ? payload : line_for(payload)
57
+ "#{text}#{@line_suffix}"
58
+ end
59
+
60
+ private
61
+
62
+ def line_for(payload)
63
+ fields = [
64
+ timestamp(payload),
65
+ severity(payload),
66
+ label(payload, :event),
67
+ label(payload, :source),
68
+ message(payload),
69
+ compact_hash(:payload, value_at(payload, :payload)),
70
+ compact_hash(:labels, value_at(payload, :labels))
71
+ ].compact
72
+ fields.join(" ")
73
+ end
74
+
75
+ def validate_theme(theme)
76
+ Validation.validate_symbol_choice!(theme, name: "text encoder theme", choices: THEMES)
77
+ end
78
+
79
+ def timestamp(payload)
80
+ value = value_at(payload, :timestamp)
81
+ return if blank?(value)
82
+ # Console output is human-facing; JSON keeps nanosecond precision.
83
+ return value.iso8601(6) if value.respond_to?(:iso8601)
84
+
85
+ value.to_s
86
+ end
87
+
88
+ def severity(payload)
89
+ value = (value_at(payload, :severity) || :info).to_s
90
+ label = severity_label(value)
91
+ return label unless @color
92
+
93
+ code = severity_styles.fetch(value, 37)
94
+ "\e[#{code}m#{label}\e[0m"
95
+ end
96
+
97
+ def severity_label(value)
98
+ label = value.upcase.ljust(5)
99
+ return label unless @theme == :punk
100
+
101
+ glyph = self.class.punk_glyph(value)
102
+ "#{glyph} #{value.upcase} #{glyph}"
103
+ end
104
+
105
+ def severity_styles
106
+ @theme == :punk ? PUNK_SEVERITY_STYLES : SEVERITY_STYLES
107
+ end
108
+
109
+ def label(payload, key)
110
+ value = value_at(payload, key)
111
+ return if blank?(value)
112
+
113
+ "#{key}=#{value}"
114
+ end
115
+
116
+ def message(payload)
117
+ value = value_at(payload, :message)
118
+ return if blank?(value)
119
+
120
+ truncate(value.to_s)
121
+ end
122
+
123
+ def compact_hash(name, value)
124
+ return unless value.is_a?(Hash) && !value.empty?
125
+
126
+ "#{name}=#{truncate(JSON.generate(value, allow_nan: false))}"
127
+ rescue StandardError
128
+ "#{name}=#{truncate(value.inspect)}"
129
+ end
130
+
131
+ def truncate(value)
132
+ return value if value.bytesize <= @max_value_bytes
133
+
134
+ "#{value.byteslice(0, @max_value_bytes).scrub("?")}..."
135
+ end
136
+
137
+ def value_at(payload, key)
138
+ Fields::Lookup.value(payload, key)
139
+ end
140
+
141
+ def blank?(value)
142
+ Fields::Lookup.blank?(value)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Serialization
6
+ class ValueCopy
7
+ include ValueTraversal
8
+
9
+ CIRCULAR_REFERENCE = Core::CIRCULAR_REFERENCE
10
+ EMPTY_ARRAY = [].freeze
11
+ EMPTY_HASH = {}.freeze
12
+ POOL_KEY = :julewire_core_value_copy_pool
13
+ private_constant :EMPTY_ARRAY, :EMPTY_HASH, :POOL_KEY
14
+
15
+ class << self
16
+ def call(
17
+ value,
18
+ compact_empty: false,
19
+ freeze_values: false,
20
+ max_depth: Core::NORMALIZATION_MAX_DEPTH,
21
+ symbolize_keys: false
22
+ )
23
+ return copy_leaf(value, freeze_values: freeze_values) unless container?(value)
24
+
25
+ copy_with(
26
+ cached_copier(
27
+ compact_empty: compact_empty,
28
+ freeze_values: freeze_values,
29
+ max_depth: max_depth,
30
+ symbolize_keys: symbolize_keys
31
+ ),
32
+ value
33
+ )
34
+ end
35
+
36
+ def omitted_empty?(value)
37
+ value.nil? || (value.is_a?(Hash) && value.empty?) || (value.is_a?(Array) && value.empty?)
38
+ end
39
+
40
+ private
41
+
42
+ def container?(value) = value.is_a?(Hash) || value.is_a?(Array)
43
+
44
+ def cached_copier(compact_empty:, freeze_values:, max_depth:, symbolize_keys:)
45
+ # One copier per thread/options avoids per-record walker allocation.
46
+ pool = Thread.current.thread_variable_get(POOL_KEY)
47
+ unless pool
48
+ pool = {}
49
+ Thread.current.thread_variable_set(POOL_KEY, pool)
50
+ end
51
+
52
+ key = cache_key(
53
+ compact_empty: compact_empty,
54
+ freeze_values: freeze_values,
55
+ max_depth: max_depth,
56
+ symbolize_keys: symbolize_keys
57
+ )
58
+ pool[key] ||= new(
59
+ compact_empty: compact_empty,
60
+ freeze_values: freeze_values,
61
+ max_depth: max_depth,
62
+ symbolize_keys: symbolize_keys
63
+ )
64
+ end
65
+
66
+ def cache_key(compact_empty:, freeze_values:, max_depth:, symbolize_keys:)
67
+ depth_key = max_depth || -1
68
+ flags = 0
69
+ flags |= 1 if compact_empty
70
+ flags |= 2 if freeze_values
71
+ flags |= 4 if symbolize_keys
72
+ (depth_key << 3) | flags
73
+ end
74
+
75
+ def copy_with(copier, value)
76
+ return copier.call_reusable(value) unless copier.in_use?
77
+
78
+ new(
79
+ compact_empty: copier.compact_empty,
80
+ freeze_values: copier.freeze_values,
81
+ max_depth: copier.max_depth,
82
+ symbolize_keys: copier.symbolize_keys
83
+ ).call(value)
84
+ end
85
+
86
+ def copy_leaf(value, freeze_values:)
87
+ return copy_string(value, freeze_values: freeze_values) if value.is_a?(String)
88
+ return copy_time(value, freeze_values: freeze_values) if value.is_a?(Time)
89
+
90
+ value
91
+ end
92
+
93
+ def copy_string(value, freeze_values:)
94
+ copy = value.frozen? ? value : value.dup
95
+ freeze_values ? copy.freeze : copy
96
+ end
97
+
98
+ def copy_time(value, freeze_values:)
99
+ return value unless freeze_values
100
+ return value if value.frozen?
101
+
102
+ value.dup.freeze
103
+ end
104
+ end
105
+
106
+ attr_reader :compact_empty, :freeze_values, :max_depth, :symbolize_keys
107
+
108
+ def initialize(compact_empty:, freeze_values:, max_depth:, symbolize_keys:)
109
+ @compact_empty = compact_empty
110
+ @freeze_values = freeze_values
111
+ @max_depth = max_depth
112
+ @symbolize_keys = symbolize_keys
113
+ @in_use = false
114
+ end
115
+
116
+ def call(value)
117
+ traverse(value) { |root, depth| copy_value(root, depth) }
118
+ end
119
+
120
+ def call_reusable(value)
121
+ @in_use = true
122
+ call(value)
123
+ ensure
124
+ @in_use = false
125
+ end
126
+
127
+ def in_use? = @in_use
128
+
129
+ private
130
+
131
+ def copy_value(value, depth)
132
+ return copy_container(value, depth) if value.is_a?(Hash) || value.is_a?(Array)
133
+ return copy_string(value) if value.is_a?(String)
134
+ return copy_time(value) if value.is_a?(Time)
135
+
136
+ value
137
+ end
138
+
139
+ def copy_container(value, depth)
140
+ return copy_string(Serializer::MAX_DEPTH_VALUE) if depth_limited?(depth)
141
+ return frozen_empty_container(value) if @freeze_values && value.empty?
142
+
143
+ with_traversal_container(value, CIRCULAR_REFERENCE) do
144
+ value.is_a?(Hash) ? copy_hash(value, depth) : copy_array(value, depth)
145
+ end
146
+ end
147
+
148
+ def depth_limited?(depth)
149
+ @max_depth && depth >= @max_depth
150
+ end
151
+
152
+ def frozen_empty_container(value)
153
+ value.is_a?(Hash) ? EMPTY_HASH : EMPTY_ARRAY
154
+ end
155
+
156
+ def copy_hash(value, depth)
157
+ result = {}
158
+ value.each do |key, item|
159
+ next if @compact_empty && self.class.omitted_empty?(item)
160
+
161
+ copied = copy_value(item, depth + 1)
162
+ next if @compact_empty && self.class.omitted_empty?(copied)
163
+
164
+ result[copy_key(key)] = copied
165
+ end
166
+ freeze_container(result)
167
+ end
168
+
169
+ def copy_array(value, depth)
170
+ result = []
171
+ value.each do |item|
172
+ next if @compact_empty && self.class.omitted_empty?(item)
173
+
174
+ copied = copy_value(item, depth + 1)
175
+ next if @compact_empty && self.class.omitted_empty?(copied)
176
+
177
+ result << copied
178
+ end
179
+ freeze_container(result)
180
+ end
181
+
182
+ def copy_key(key)
183
+ return key.to_sym if @symbolize_keys && key.is_a?(String)
184
+ return copy_string(key) if key.is_a?(String)
185
+
186
+ key
187
+ end
188
+
189
+ def copy_string(value)
190
+ return value unless value.is_a?(String)
191
+
192
+ copy = value.frozen? ? value : value.dup
193
+ @freeze_values ? copy.freeze : copy
194
+ end
195
+
196
+ def copy_time(value)
197
+ return value unless @freeze_values
198
+ return value if value.frozen?
199
+
200
+ value.dup.freeze
201
+ end
202
+
203
+ def freeze_container(value)
204
+ @freeze_values ? value.freeze : value
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Serialization
6
+ module ValueTraversal
7
+ def traverse(value)
8
+ previous_seen = @traversal_seen
9
+ previous_first_seen = @traversal_first_seen
10
+ previous_second_seen = @traversal_second_seen
11
+ previous_third_seen = @traversal_third_seen
12
+ previous_fourth_seen = @traversal_fourth_seen
13
+ @traversal_seen = nil
14
+ @traversal_first_seen = nil
15
+ @traversal_second_seen = nil
16
+ @traversal_third_seen = nil
17
+ @traversal_fourth_seen = nil
18
+ yield(value, 0)
19
+ ensure
20
+ @traversal_seen = previous_seen
21
+ @traversal_first_seen = previous_first_seen
22
+ @traversal_second_seen = previous_second_seen
23
+ @traversal_third_seen = previous_third_seen
24
+ @traversal_fourth_seen = previous_fourth_seen
25
+ end
26
+
27
+ private
28
+
29
+ def with_traversal_container(value, circular_value)
30
+ added = false
31
+ seen = @traversal_seen
32
+ first_seen = @traversal_first_seen
33
+ second_seen = @traversal_second_seen
34
+ third_seen = @traversal_third_seen
35
+ fourth_seen = @traversal_fourth_seen
36
+ return circular_value if seen&.key?(value) || first_seen.equal?(value) || second_seen.equal?(value) ||
37
+ third_seen.equal?(value) || fourth_seen.equal?(value)
38
+
39
+ added = mark_traversal_container(value, seen, first_seen, second_seen, third_seen, fourth_seen)
40
+ yield
41
+ ensure
42
+ unmark_traversal_container(value, added)
43
+ end
44
+
45
+ def traversal_seen?(value)
46
+ @traversal_seen&.key?(value) || @traversal_first_seen.equal?(value) ||
47
+ @traversal_second_seen.equal?(value) || @traversal_third_seen.equal?(value) ||
48
+ @traversal_fourth_seen.equal?(value)
49
+ end
50
+
51
+ def with_marked_traversal_container(value)
52
+ added = mark_traversal_container(
53
+ value,
54
+ @traversal_seen,
55
+ @traversal_first_seen,
56
+ @traversal_second_seen,
57
+ @traversal_third_seen,
58
+ @traversal_fourth_seen
59
+ )
60
+ yield
61
+ ensure
62
+ unmark_traversal_container(value, added)
63
+ end
64
+
65
+ def mark_traversal_container(value, seen, first_seen, second_seen, third_seen, fourth_seen)
66
+ # Most records visit only a handful of live containers. Keep those in
67
+ # slots and allocate the identity hash only for genuinely deep walks.
68
+ if seen
69
+ seen[value] = true
70
+ :hash
71
+ elsif first_seen.nil?
72
+ mark_first_seen(value)
73
+ elsif second_seen.nil?
74
+ mark_second_seen(value)
75
+ elsif third_seen.nil?
76
+ mark_third_seen(value)
77
+ elsif fourth_seen.nil?
78
+ mark_fourth_seen(value)
79
+ else
80
+ promote_traversal_seen(value, first_seen, second_seen, third_seen, fourth_seen)
81
+ end
82
+ end
83
+
84
+ def unmark_traversal_container(value, added)
85
+ case added
86
+ when :hash
87
+ @traversal_seen.delete(value)
88
+ when :first, :second, :third, :fourth
89
+ unmark_traversal_slot(value, added)
90
+ end
91
+ end
92
+
93
+ def mark_first_seen(value)
94
+ @traversal_first_seen = value
95
+ :first
96
+ end
97
+
98
+ def mark_second_seen(value)
99
+ @traversal_second_seen = value
100
+ :second
101
+ end
102
+
103
+ def mark_third_seen(value)
104
+ @traversal_third_seen = value
105
+ :third
106
+ end
107
+
108
+ def mark_fourth_seen(value)
109
+ @traversal_fourth_seen = value
110
+ :fourth
111
+ end
112
+
113
+ def promote_traversal_seen(value, first_seen, second_seen, third_seen, fourth_seen)
114
+ @traversal_seen = {}.compare_by_identity
115
+ @traversal_seen[first_seen] = true
116
+ @traversal_seen[second_seen] = true
117
+ @traversal_seen[third_seen] = true
118
+ @traversal_seen[fourth_seen] = true
119
+ @traversal_seen[value] = true
120
+ clear_traversal_slots!
121
+ :hash
122
+ end
123
+
124
+ def unmark_traversal_slot(value, added)
125
+ return @traversal_seen.delete(value) if @traversal_seen
126
+
127
+ clear_traversal_slot(added)
128
+ end
129
+
130
+ def clear_traversal_slot(slot)
131
+ case slot
132
+ when :first then @traversal_first_seen = nil
133
+ when :second then @traversal_second_seen = nil
134
+ when :third then @traversal_third_seen = nil
135
+ when :fourth then @traversal_fourth_seen = nil
136
+ end
137
+ end
138
+
139
+ def clear_traversal_slots!
140
+ @traversal_first_seen = nil
141
+ @traversal_second_seen = nil
142
+ @traversal_third_seen = nil
143
+ @traversal_fourth_seen = nil
144
+ end
145
+ end
146
+
147
+ private_constant :ValueTraversal
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Testing
6
+ module Chaos
7
+ class Catalog
8
+ Entry = Data.define(:kind, :name, :exercise)
9
+ KINDS = %i[processor formatter encoder destination subscriber listener].freeze
10
+
11
+ attr_reader :entries
12
+
13
+ class << self
14
+ def build
15
+ catalog = new
16
+ yield catalog if block_given?
17
+ catalog
18
+ end
19
+
20
+ def assert_contract(test_context, catalog:, errors:)
21
+ entries = catalog.entries
22
+ raise ArgumentError, "chaos catalog must have entries" if entries.empty?
23
+
24
+ entries.each do |entry|
25
+ assert_entry(test_context, entry, errors)
26
+ end
27
+ nil
28
+ end
29
+
30
+ private
31
+
32
+ def assert_entry(test_context, entry, errors)
33
+ Chaos.assert_contained(
34
+ test_context,
35
+ errors: errors,
36
+ description: "#{entry.kind} #{entry.name}"
37
+ ) do |error|
38
+ entry.exercise.call(error)
39
+ end
40
+ end
41
+ end
42
+
43
+ def initialize
44
+ @entries = []
45
+ end
46
+
47
+ def processor(name, &) = register(:processor, name, &)
48
+
49
+ def formatter(name, &) = register(:formatter, name, &)
50
+
51
+ def encoder(name, &) = register(:encoder, name, &)
52
+
53
+ def destination(name, &) = register(:destination, name, &)
54
+
55
+ def subscriber(name, &) = register(:subscriber, name, &)
56
+
57
+ def listener(name, &) = register(:listener, name, &)
58
+
59
+ private
60
+
61
+ def register(kind, name, &exercise)
62
+ raise ArgumentError, "unknown chaos component kind #{kind.inspect}" unless KINDS.include?(kind)
63
+ raise ArgumentError, "chaos component exercise block required" unless exercise
64
+
65
+ @entries << Entry.new(kind, Core.normalize_name(name), exercise)
66
+ self
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Testing
6
+ module Chaos
7
+ module CoreRuntime
8
+ SCENARIOS = %i[
9
+ destination_processor
10
+ drop_callback
11
+ encoder
12
+ failure_callback
13
+ formatter
14
+ lifecycle_after_fork
15
+ lifecycle_flush
16
+ output
17
+ pipeline_processor
18
+ ].freeze
19
+
20
+ private_constant :SCENARIOS
21
+
22
+ class << self
23
+ def assert_contract(test_context, runtime:, reset:, errors:)
24
+ SCENARIOS.each do |scenario|
25
+ errors.each do |error|
26
+ assert_scenario(test_context, runtime, reset, scenario, error)
27
+ end
28
+ end
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ def assert_scenario(test_context, runtime, reset, scenario, error)
35
+ reset.call
36
+ send(:"exercise_#{scenario}", runtime, error)
37
+ rescue StandardError => e
38
+ test_context.flunk(
39
+ "expected core #{scenario} failure to be contained, " \
40
+ "#{error.class} leaked #{e.class}: #{e.message}"
41
+ )
42
+ ensure
43
+ reset.call
44
+ end
45
+
46
+ def exercise_destination_processor(runtime, error)
47
+ configure_destination(runtime, processors: [Chaos.raiser(error)])
48
+ runtime.emit("chaos")
49
+ end
50
+
51
+ def exercise_drop_callback(runtime, error)
52
+ trigger = RuntimeError.new("julewire chaos formatter trigger")
53
+ configure_destination(runtime, formatter: Chaos.raiser(trigger), on_drop: Chaos.raiser(error))
54
+ runtime.emit("chaos")
55
+ end
56
+
57
+ def exercise_encoder(runtime, error)
58
+ configure_destination(runtime, encoder: Chaos.raiser(error))
59
+ runtime.emit("chaos")
60
+ end
61
+
62
+ def exercise_failure_callback(runtime, error)
63
+ trigger = RuntimeError.new("julewire chaos output trigger")
64
+ configure_destination(
65
+ runtime,
66
+ output: RaisingOutput.new(trigger, failures: %i[write]),
67
+ runtime_on_failure: Chaos.raiser(error)
68
+ )
69
+ runtime.emit("chaos")
70
+ end
71
+
72
+ def exercise_formatter(runtime, error)
73
+ configure_destination(runtime, formatter: Chaos.raiser(error))
74
+ runtime.emit("chaos")
75
+ end
76
+
77
+ def exercise_lifecycle_after_fork(runtime, error)
78
+ configure_destination(runtime, output: RaisingOutput.new(error, failures: %i[after_fork]))
79
+ runtime.after_fork!
80
+ end
81
+
82
+ def exercise_lifecycle_flush(runtime, error)
83
+ configure_destination(runtime, output: RaisingOutput.new(error, failures: %i[flush]))
84
+ runtime.flush
85
+ end
86
+
87
+ def exercise_output(runtime, error)
88
+ configure_destination(runtime, output: RaisingOutput.new(error, failures: %i[write]))
89
+ runtime.emit("chaos")
90
+ end
91
+
92
+ def exercise_pipeline_processor(runtime, error)
93
+ runtime.configure do |config|
94
+ config.destinations.use(:default, output: NullOutput.new)
95
+ config.processors.use(Chaos.raiser(error))
96
+ end
97
+ runtime.emit("chaos")
98
+ end
99
+
100
+ def configure_destination(runtime, output: NullOutput.new, formatter: nil, encoder: nil,
101
+ on_drop: nil, processors: nil, runtime_on_failure: nil)
102
+ runtime.configure do |config|
103
+ config.on_failure = runtime_on_failure if runtime_on_failure
104
+ config.destinations.clear
105
+ config.destinations.use(
106
+ :default,
107
+ encoder: encoder || Julewire::Core::Serialization::JsonEncoder.new,
108
+ formatter: formatter || Julewire::Core::Records::Formatter.new,
109
+ on_drop: on_drop,
110
+ output: output,
111
+ processors: processors || []
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end