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,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ # @api internal
6
+ # Fiber-local context stack used by the runtime facade. Use Julewire.context
7
+ # and Julewire.with_execution instead of reaching into this class directly.
8
+ class ContextStore # rubocop:disable Metrics/ClassLength
9
+ EMPTY_HASH = {}.freeze
10
+ private_constant :EMPTY_HASH
11
+
12
+ class << self
13
+ def current
14
+ LocalStorage.context_store
15
+ end
16
+
17
+ # Reset only clears the caller's current thread/fiber context.
18
+ def reset_current! = LocalStorage.reset_context_store!
19
+ end
20
+
21
+ def initialize
22
+ reset!
23
+ end
24
+
25
+ def reset!
26
+ @scopes = []
27
+ @ambient_fields = Fields::StackSet.new
28
+ @execution_overlays = []
29
+ @execution_lineage_overlays = []
30
+ @propagation_execution_hash = nil
31
+ @propagation_scope_snapshot = nil
32
+ @linked_propagation_scope_snapshot = nil
33
+ end
34
+
35
+ def current_scope = @scopes.last
36
+
37
+ def current_scope? = !!current_scope
38
+
39
+ def current_scope_or_snapshot
40
+ current_scope || propagation_scope_snapshot
41
+ end
42
+
43
+ def context_proxy
44
+ @context_proxy ||= Fields::ContextProxy.new(self)
45
+ end
46
+
47
+ def carry_proxy
48
+ @carry_proxy ||= Fields::CarryProxy.new(self)
49
+ end
50
+
51
+ def attributes_proxy
52
+ @attributes_proxy ||= Fields::AttributesProxy.new(self)
53
+ end
54
+
55
+ def summary_proxy
56
+ @summary_proxy ||= Fields::SummaryProxy.new(self)
57
+ end
58
+
59
+ def context_hash
60
+ current_field_hash(:context)
61
+ end
62
+
63
+ def carry_hash
64
+ current_field_hash(:carry)
65
+ end
66
+
67
+ def attributes_hash
68
+ current_field_hash(:attributes)
69
+ end
70
+
71
+ def neutral_hash
72
+ current_field_hash(:neutral)
73
+ end
74
+
75
+ def context_value(key, default:)
76
+ current_field_stack(:context).value_for(key, default: default)
77
+ end
78
+
79
+ def carry_value(key, default:)
80
+ current_field_stack(:carry).value_for(key, default: default)
81
+ end
82
+
83
+ def attributes_value(key, default:)
84
+ current_field_stack(:attributes).value_for(key, default: default)
85
+ end
86
+
87
+ def add_context(fields = EMPTY_HASH, owned: false, **keyword_fields)
88
+ add_field(:context, field_input(fields, keyword_fields), owned: owned)
89
+ end
90
+
91
+ def add_carry(fields = EMPTY_HASH, owned: false, **keyword_fields)
92
+ add_field(:carry, field_input(fields, keyword_fields), owned: owned)
93
+ end
94
+
95
+ def add_attributes(fields = EMPTY_HASH, owned: false, **keyword_fields)
96
+ add_field(:attributes, field_input(fields, keyword_fields), owned: owned)
97
+ end
98
+
99
+ def add_neutral(fields = EMPTY_HASH, owned: false, **keyword_fields)
100
+ add_field(:neutral, field_input(fields, keyword_fields), owned: owned)
101
+ end
102
+
103
+ def delete_carry(path)
104
+ path = Fields::Internal.normalize_path(path)
105
+ return if path.empty?
106
+
107
+ if current_scope
108
+ current_scope.delete_carry(path)
109
+ else
110
+ @ambient_fields.delete(:carry, path)
111
+ end
112
+ end
113
+
114
+ def with_context(fields = EMPTY_HASH, owned: false, **keyword_fields, &)
115
+ with_scope_or_ambient_overlay(:context, field_input(fields, keyword_fields), owned: owned, &)
116
+ end
117
+
118
+ def with_carry(fields = EMPTY_HASH, owned: false, **keyword_fields, &)
119
+ with_scope_or_ambient_overlay(:carry, field_input(fields, keyword_fields), owned: owned, &)
120
+ end
121
+
122
+ def with_attributes(fields = EMPTY_HASH, owned: false, **keyword_fields, &)
123
+ with_scope_or_ambient_overlay(:attributes, field_input(fields, keyword_fields), owned: owned, &)
124
+ end
125
+
126
+ def with_neutral(fields = EMPTY_HASH, owned: false, **keyword_fields, &)
127
+ with_scope_or_ambient_overlay(:neutral, field_input(fields, keyword_fields), owned: owned, &)
128
+ end
129
+
130
+ def without_carry(path, &)
131
+ scope = current_scope
132
+ normalized_path = Fields::Internal.normalize_path(path)
133
+ raise ArgumentError, "carry path is required" if normalized_path.empty?
134
+
135
+ if scope
136
+ scope.without_carry(normalized_path, &)
137
+ else
138
+ @ambient_fields.without(:carry, normalized_path, &)
139
+ end
140
+ end
141
+
142
+ def with_propagation(context: {}, carry: {}, execution: {}, link_executions: false, &)
143
+ scope = current_scope
144
+ execution = Fields::FieldSet.deep_symbolize_keys(execution)
145
+ @execution_overlays.push(execution)
146
+ @execution_lineage_overlays.push(link_executions ? Execution::Lineage.from_execution_hash(execution) : nil)
147
+ invalidate_propagation_cache!
148
+
149
+ begin
150
+ if scope
151
+ scope.with_carry(carry) do
152
+ scope.with_context(context, &)
153
+ end
154
+ else
155
+ @ambient_fields.with(:carry, carry) do
156
+ @ambient_fields.with(:context, context, &)
157
+ end
158
+ end
159
+ ensure
160
+ @execution_overlays.pop
161
+ @execution_lineage_overlays.pop
162
+ invalidate_propagation_cache!
163
+ end
164
+ end
165
+
166
+ def with_execution(**options)
167
+ scope = build_scope(options)
168
+ active_exception = nil
169
+
170
+ @scopes.push(scope)
171
+ begin
172
+ yield Execution::View.new(scope)
173
+ rescue Exception => e # rubocop:disable Lint/RescueException
174
+ active_exception = e
175
+ raise
176
+ ensure
177
+ scope.record_error(active_exception) if active_exception
178
+ @scopes.pop
179
+ finish_scope(
180
+ scope,
181
+ options[:on_finish],
182
+ options[:on_finish_failure],
183
+ active_exception: active_exception
184
+ )
185
+ end
186
+ end
187
+
188
+ def start_execution(**options)
189
+ scope = build_scope(options)
190
+ Execution::Handle.new(
191
+ scope: scope,
192
+ on_finish: options[:on_finish],
193
+ on_finish_failure: options[:on_finish_failure]
194
+ )
195
+ end
196
+
197
+ def with_scope(scope)
198
+ @scopes.push(scope)
199
+ yield Execution::View.new(scope)
200
+ ensure
201
+ @scopes.pop
202
+ end
203
+
204
+ private
205
+
206
+ def build_scope(options)
207
+ parent_scope = current_scope
208
+ fields = inherited_fields(options, parent_scope)
209
+ Execution::Scope.new(
210
+ type: options.fetch(:type),
211
+ id: options[:id],
212
+ execution: merged_execution_hash(options.fetch(:execution, EMPTY_HASH)),
213
+ execution_owned: true,
214
+ context: fields.stack(:context),
215
+ attributes: fields.stack(:attributes),
216
+ neutral: fields.stack(:neutral),
217
+ labels: options.fetch(:labels, EMPTY_HASH),
218
+ carry: fields.stack(:carry),
219
+ parent: parent_scope || linked_propagation_scope_snapshot,
220
+ started_at: options[:started_at],
221
+ summary_event: options[:summary_event],
222
+ summary_severity: options[:summary_severity],
223
+ summary_source: options[:summary_source]
224
+ )
225
+ end
226
+
227
+ def inherited_fields(options, parent_scope)
228
+ inherit = options.fetch(:inherit_attributes, true)
229
+ fields = inherited_stack_set(parent_scope, inherit_attributes: inherit)
230
+ add_scope_stack(fields, options, section: :attributes, key: :attributes)
231
+ add_scope_stack(fields, options, section: :neutral, key: :neutral)
232
+ fields
233
+ end
234
+
235
+ def add_scope_stack(stack_set, options, section:, key:)
236
+ value = options.fetch(key, EMPTY_HASH)
237
+ if options.fetch(:owned, false)
238
+ stack_set.add(section, value, owned: true)
239
+ else
240
+ stack_set.add(section, value)
241
+ end
242
+ end
243
+
244
+ def add_field(section, fields, owned: false)
245
+ scope = current_scope
246
+ if scope
247
+ scope.add_field(section, fields, owned: owned)
248
+ elsif owned
249
+ @ambient_fields.add(section, fields, owned: true)
250
+ else
251
+ @ambient_fields.add(section, fields)
252
+ end
253
+ end
254
+
255
+ def field_input(fields, keyword_fields)
256
+ return fields if keyword_fields.empty?
257
+ return keyword_fields if empty_field_input?(fields)
258
+
259
+ fields.is_a?(Hash) ? fields.merge(keyword_fields) : fields
260
+ end
261
+
262
+ def empty_field_input?(fields)
263
+ fields.nil? || (fields.respond_to?(:empty?) && fields.empty?)
264
+ end
265
+
266
+ def with_scope_or_ambient_overlay(section, fields, owned: false, &)
267
+ scope = current_scope
268
+ return scope.with_field(section, fields, owned: owned, &) if scope
269
+
270
+ if owned
271
+ @ambient_fields.with(section, fields, owned: true, &)
272
+ else
273
+ @ambient_fields.with(section, fields, &)
274
+ end
275
+ end
276
+
277
+ def current_field_stack(section)
278
+ scope = current_scope
279
+ return @ambient_fields.stack(section) unless scope
280
+
281
+ scope.field_stack(section)
282
+ end
283
+
284
+ def current_field_hash(section)
285
+ scope = current_scope
286
+ scope ? scope.field_hash(section) : @ambient_fields.snapshot(section)
287
+ end
288
+
289
+ def inherited_stack_set(parent_scope, inherit_attributes:)
290
+ source = parent_scope ? parent_scope.field_stacks : @ambient_fields
291
+ Fields::StackSet.inherit_from(source, inherit_attributes: inherit_attributes)
292
+ end
293
+
294
+ def execution_hash
295
+ return {} if @execution_overlays.empty?
296
+
297
+ Fields::FieldSet.deep_dup(propagation_execution_hash)
298
+ end
299
+
300
+ def propagation_execution_hash
301
+ @propagation_execution_hash ||= Fields::Internal.frozen_copy(@execution_overlays.reduce({}) do |memo, overlay|
302
+ Fields::FieldSet.merge!(memo, overlay)
303
+ end)
304
+ end
305
+
306
+ def propagation_scope_snapshot
307
+ execution = propagation_execution_hash
308
+ return if execution.empty?
309
+
310
+ @propagation_scope_snapshot ||= Execution::ScopeSnapshot.new(execution: execution)
311
+ end
312
+
313
+ def linked_propagation_scope_snapshot
314
+ lineage = linked_propagation_lineage
315
+ return unless lineage
316
+
317
+ execution = propagation_execution_hash
318
+ return if execution.empty?
319
+
320
+ @linked_propagation_scope_snapshot ||= Execution::ScopeSnapshot.new(execution: execution, lineage: lineage)
321
+ end
322
+
323
+ def invalidate_propagation_cache!
324
+ @propagation_execution_hash = nil
325
+ @propagation_scope_snapshot = nil
326
+ @linked_propagation_scope_snapshot = nil
327
+ end
328
+
329
+ def linked_propagation_lineage
330
+ (@execution_overlays.length - 1).downto(0) do |index|
331
+ execution = @execution_overlays.fetch(index)
332
+ next if execution.empty?
333
+
334
+ return @execution_lineage_overlays.fetch(index)
335
+ end
336
+ nil
337
+ end
338
+
339
+ def merged_execution_hash(execution)
340
+ inherited = inherited_execution_hash
341
+ return inherited unless execution.is_a?(Hash) && !execution.empty?
342
+
343
+ Fields::FieldSet.merge!(inherited, execution)
344
+ end
345
+
346
+ def inherited_execution_hash
347
+ scope = current_scope
348
+ return execution_hash unless scope
349
+
350
+ inherited = scope.inheritable_execution_hash
351
+ return inherited if @execution_overlays.empty?
352
+
353
+ overlay = execution_hash
354
+ return inherited if overlay.empty?
355
+
356
+ Fields::FieldSet.merge!(inherited, overlay)
357
+ end
358
+
359
+ def finish_scope(scope, on_finish, on_finish_failure, active_exception: nil)
360
+ return unless on_finish
361
+
362
+ contain_finish_failure(on_finish_failure, active_exception) { scope.finish_owned unless scope.finished? }
363
+ contain_finish_failure(on_finish_failure, active_exception) { on_finish.call(scope) }
364
+ end
365
+
366
+ def contain_finish_failure(on_finish_failure, active_exception)
367
+ yield
368
+ rescue StandardError => e
369
+ report_finish_failure(on_finish_failure, e)
370
+ rescue SystemStackError => e
371
+ # Preserve the app's active stack error during unwind.
372
+ raise unless active_exception
373
+
374
+ report_finish_failure(on_finish_failure, e)
375
+ end
376
+
377
+ def report_finish_failure(on_finish_failure, error)
378
+ on_finish_failure&.call(error)
379
+ rescue StandardError
380
+ nil
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Destinations
6
+ class ChaosOutput
7
+ MODES = %i[mixed raise reject sleep].freeze
8
+ DEFAULT_RATE = 0.1
9
+ DEFAULT_SLEEP_MS = 10
10
+
11
+ def initialize(output, rate: DEFAULT_RATE, mode: :mixed, sleep_ms: DEFAULT_SLEEP_MS, seed: nil)
12
+ Sink.validate_writeable!(output)
13
+ @output = output
14
+ @rate = validate_rate(rate)
15
+ @mode = validate_mode(mode)
16
+ @sleep_seconds = validate_sleep_ms(sleep_ms) / 1000.0
17
+ @seed = seed
18
+ @random = random
19
+ end
20
+
21
+ def write(value)
22
+ return @output.write(value) unless trigger?
23
+
24
+ case chaos_mode
25
+ when :raise then raise "julewire punk chaos output failure"
26
+ when :reject then false
27
+ when :sleep
28
+ sleep(@sleep_seconds)
29
+ @output.write(value)
30
+ end
31
+ end
32
+
33
+ def flush
34
+ @output.flush if @output.respond_to?(:flush)
35
+ end
36
+
37
+ def close
38
+ @output.close if @output.respond_to?(:close)
39
+ end
40
+
41
+ def closed?
42
+ @output.closed? if @output.respond_to?(:closed?)
43
+ end
44
+
45
+ def after_fork!
46
+ @random = random
47
+ @output.after_fork! if @output.respond_to?(:after_fork!)
48
+ self
49
+ end
50
+
51
+ def resource_identity = @output
52
+
53
+ private
54
+
55
+ def validate_rate(value)
56
+ return value if finite_orderable_number?(value) && value.between?(0, 1)
57
+
58
+ raise ArgumentError, "chaos rate must be a finite Numeric between 0 and 1"
59
+ end
60
+
61
+ def validate_mode(value)
62
+ Validation.validate_symbol_choice!(value, name: "chaos mode", choices: MODES)
63
+ end
64
+
65
+ def validate_sleep_ms(value)
66
+ return value if finite_orderable_number?(value) && value >= 0
67
+
68
+ raise ArgumentError, "chaos sleep_ms must be a non-negative finite Numeric"
69
+ end
70
+
71
+ def finite_orderable_number?(value)
72
+ value.is_a?(Numeric) && value.finite? && value.respond_to?(:between?)
73
+ end
74
+
75
+ def random
76
+ @seed ? Random.new(@seed) : Random.new
77
+ end
78
+
79
+ def trigger?
80
+ @rate.positive? && @random.rand < @rate
81
+ end
82
+
83
+ def chaos_mode
84
+ return @mode unless @mode == :mixed
85
+
86
+ %i[raise reject sleep].fetch(@random.rand(3))
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ # @api internal
6
+ module Destinations
7
+ class Collection
8
+ def initialize(destinations, on_drop:, on_failure:)
9
+ @destinations = destinations.freeze
10
+ @on_drop = on_drop
11
+ @on_failure = on_failure
12
+ end
13
+
14
+ class << self
15
+ def build(configuration:, defaults:, on_drop:, on_failure:)
16
+ new(
17
+ validate_destinations(configuration.destinations.build(defaults: defaults)),
18
+ on_drop: on_drop,
19
+ on_failure: on_failure
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def validate_destinations(destinations)
26
+ destinations.map do |destination|
27
+ Registry.validate!(destination)
28
+ end.freeze
29
+ end
30
+ end
31
+
32
+ def empty? = @destinations.empty?
33
+
34
+ def emit(record)
35
+ @destinations.each do |destination|
36
+ emit_to_destination(destination, record)
37
+ end
38
+ end
39
+
40
+ def after_fork!
41
+ @destinations.each do |destination|
42
+ call_destination_after_fork(destination)
43
+ end
44
+ self
45
+ end
46
+
47
+ def flush(timeout: nil)
48
+ call_lifecycle(:flush, timeout: timeout)
49
+ end
50
+
51
+ def close(timeout: nil, skip_resource_identities: nil)
52
+ call_lifecycle(:close, timeout: timeout, skip_resource_identities: skip_resource_identities)
53
+ end
54
+
55
+ def lifecycle_resource_identities
56
+ @destinations.each_with_object({}.compare_by_identity) do |destination, identities|
57
+ identities[resource_identity(destination)] = true
58
+ end
59
+ end
60
+
61
+ def health
62
+ @destinations.to_h { [destination_name(it), destination_health(it)] }
63
+ end
64
+
65
+ private
66
+
67
+ def call_lifecycle(method_name, timeout:, skip_resource_identities: nil)
68
+ Validation.validate_timeout!(timeout, name: :timeout)
69
+ call_lifecycle_safely(method_name, timeout, skip_resource_identities)
70
+ end
71
+
72
+ def call_lifecycle_safely(method_name, timeout, skip_resource_identities)
73
+ deadline = Scheduling::Deadline.for(timeout)
74
+ ok = true
75
+ attempted = false
76
+
77
+ lifecycle_destinations(skip_resource_identities).each do |destination|
78
+ remaining_timeout = Scheduling::Deadline.remaining(deadline)
79
+ if attempted && deadline && remaining_timeout <= 0
80
+ ok = false
81
+ break
82
+ end
83
+
84
+ attempted = true
85
+ ok = false if destination.public_send(method_name, timeout: remaining_timeout) == false
86
+ rescue StandardError => e
87
+ notify_failure(e, action: method_name, destination: destination.name, phase: :destination_lifecycle)
88
+ ok = false
89
+ end
90
+ ok
91
+ rescue StandardError => e
92
+ notify_failure(e, action: method_name, phase: :output_lifecycle)
93
+ false
94
+ end
95
+
96
+ def lifecycle_destinations(skip_resource_identities)
97
+ return @destinations unless skip_resource_identities
98
+
99
+ @destinations.reject { skip_lifecycle_destination?(it, skip_resource_identities) }
100
+ end
101
+
102
+ def call_destination_after_fork(destination)
103
+ destination.after_fork! if destination.respond_to?(:after_fork!)
104
+ rescue StandardError => e
105
+ notify_failure(
106
+ e,
107
+ action: :after_fork,
108
+ destination: destination_name(destination),
109
+ phase: :destination_lifecycle
110
+ )
111
+ nil
112
+ end
113
+
114
+ def skip_lifecycle_destination?(destination, identities)
115
+ return false unless identities
116
+
117
+ identities.key?(resource_identity(destination))
118
+ end
119
+
120
+ def resource_identity(destination)
121
+ return destination.resource_identity if destination.respond_to?(:resource_identity)
122
+
123
+ destination
124
+ end
125
+
126
+ def emit_to_destination(destination, record)
127
+ result = destination.emit(record)
128
+ record_drop(:destination_rejected, destination, record) if result == false
129
+ rescue StandardError => e
130
+ metadata = destination_metadata(destination, record)
131
+ notify_failure(
132
+ e,
133
+ **metadata,
134
+ phase: :destination
135
+ )
136
+ record_drop(:destination_exception, destination, record, metadata: metadata)
137
+ nil
138
+ end
139
+
140
+ def destination_name(destination)
141
+ destination.name
142
+ rescue StandardError
143
+ destination.class.name
144
+ end
145
+
146
+ def destination_health(destination)
147
+ destination.health
148
+ rescue StandardError => e
149
+ {
150
+ status: :unknown,
151
+ type: "destination",
152
+ last_failure: Diagnostics::FailureSnapshot.build(
153
+ e,
154
+ destination: destination_name(destination),
155
+ phase: :destination_health
156
+ )
157
+ }
158
+ end
159
+
160
+ def notify_failure(error, **metadata)
161
+ @on_failure.call(error, **metadata)
162
+ end
163
+
164
+ def record_drop(reason, destination, record, metadata: destination_metadata(destination, record))
165
+ @on_drop.call(reason, phase: :destination, **metadata)
166
+ end
167
+
168
+ def destination_metadata(destination, record)
169
+ {
170
+ destination: destination_name(destination),
171
+ record_metadata: Records::Metadata.call(record)
172
+ }
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end