julewire-core 1.0.0 → 1.0.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/docs/advanced-configuration.md +2 -1
  4. data/docs/context-and-propagation.md +6 -4
  5. data/docs/contracts.md +2 -1
  6. data/docs/extensions-and-api.md +9 -6
  7. data/docs/internals.md +3 -3
  8. data/docs/outputs-and-lifecycle.md +5 -2
  9. data/docs/records-and-data-policy.md +2 -2
  10. data/lib/julewire/core/cli/log_formats/record_decoder.rb +1 -1
  11. data/lib/julewire/core/context_store.rb +11 -6
  12. data/lib/julewire/core/destinations/destination.rb +3 -3
  13. data/lib/julewire/core/destinations/synchronized_output.rb +51 -15
  14. data/lib/julewire/core/diagnostics/doctor.rb +11 -11
  15. data/lib/julewire/core/diagnostics/failure_snapshot.rb +3 -1
  16. data/lib/julewire/core/execution/handle.rb +2 -0
  17. data/lib/julewire/core/execution/lineage.rb +10 -6
  18. data/lib/julewire/core/execution/scope.rb +4 -4
  19. data/lib/julewire/core/execution/scope_fields.rb +2 -2
  20. data/lib/julewire/core/facade_methods.rb +2 -2
  21. data/lib/julewire/core/fields/field_set.rb +32 -6
  22. data/lib/julewire/core/fields/field_stack.rb +40 -14
  23. data/lib/julewire/core/fields/internal.rb +41 -13
  24. data/lib/julewire/core/fields/stack_set.rb +2 -2
  25. data/lib/julewire/core/integration/destination_health.rb +13 -2
  26. data/lib/julewire/core/propagation/carrier.rb +63 -9
  27. data/lib/julewire/core/propagation.rb +3 -2
  28. data/lib/julewire/core/records/draft.rb +12 -6
  29. data/lib/julewire/core/records/record.rb +43 -13
  30. data/lib/julewire/core/runtime.rb +30 -11
  31. data/lib/julewire/core/serialization/bounded_transform.rb +11 -0
  32. data/lib/julewire/core/serialization/bounded_traversal.rb +33 -27
  33. data/lib/julewire/core/serialization/serializer.rb +1 -1
  34. data/lib/julewire/core/serialization/truncation_metadata.rb +142 -0
  35. data/lib/julewire/core/serialization/value_copy.rb +292 -51
  36. data/lib/julewire/core/testing/contracts/integration.rb +1 -1
  37. data/lib/julewire/core/version.rb +1 -1
  38. metadata +2 -1
@@ -3,30 +3,206 @@
3
3
  module Julewire
4
4
  module Core
5
5
  module Serialization
6
+ module ValueCopyTruncation
7
+ private
8
+
9
+ def validate_optional_limit(value, name:)
10
+ return unless value
11
+
12
+ Validation.validate_integer_limit!(value, name: name)
13
+ end
14
+
15
+ def record_hash_truncation(fields, key, truncated)
16
+ return fields unless truncated
17
+
18
+ append_truncation_field(fields, key.to_s)
19
+ end
20
+
21
+ def finish_hash(result, fields)
22
+ add_truncation_metadata!(result, fields)
23
+ finish_container(result, fields)
24
+ end
25
+
26
+ def finish_array(result, fields)
27
+ if @track_truncation && fields
28
+ result << freeze_container({ Serializer::TRUNCATION_METADATA_KEY.to_sym => truncation_metadata(fields) })
29
+ end
30
+ finish_container(result, fields)
31
+ end
32
+
33
+ def add_truncation_metadata!(result, fields)
34
+ return unless @track_truncation && fields
35
+
36
+ result[Serializer::TRUNCATION_METADATA_KEY.to_sym] = truncation_metadata(fields)
37
+ end
38
+
39
+ def finish_container(result, fields)
40
+ value = freeze_container(result)
41
+ fields ? mark_truncated(value) : clear_truncated(value)
42
+ end
43
+
44
+ def truncation_metadata(fields)
45
+ TruncationMetadata.build(
46
+ fields,
47
+ key_style: :symbol,
48
+ compact_limits: true,
49
+ freeze_values: @freeze_values,
50
+ max_array_items: @max_array_items,
51
+ max_depth: @max_depth,
52
+ max_hash_keys: @max_hash_keys,
53
+ max_string_bytes: @max_string_bytes
54
+ )
55
+ end
56
+
57
+ def mark_truncated(value)
58
+ @last_truncated = true
59
+ value
60
+ end
61
+
62
+ def clear_truncated(value)
63
+ @last_truncated = false
64
+ value
65
+ end
66
+
67
+ def consume_truncated
68
+ truncated = @last_truncated
69
+ @last_truncated = false
70
+ truncated
71
+ end
72
+
73
+ def append_truncation_field(fields, field)
74
+ TruncationMetadata.append_field(fields, field)
75
+ end
76
+ end
77
+ private_constant :ValueCopyTruncation
78
+
79
+ module ValueCopyCache
80
+ POOL_KEY = :julewire_core_value_copy_pool
81
+ private_constant :POOL_KEY
82
+
83
+ private
84
+
85
+ def cached_copier(compact_empty:, freeze_values:, max_array_items:, max_depth:, max_hash_keys:,
86
+ max_string_bytes:, preserve_truncation_metadata:, symbolize_keys:)
87
+ options = copier_options(
88
+ compact_empty: compact_empty,
89
+ freeze_values: freeze_values,
90
+ max_array_items: max_array_items,
91
+ max_depth: max_depth,
92
+ max_hash_keys: max_hash_keys,
93
+ max_string_bytes: max_string_bytes,
94
+ preserve_truncation_metadata: preserve_truncation_metadata,
95
+ symbolize_keys: symbolize_keys
96
+ )
97
+ return new(**options) unless cacheable_options?(options)
98
+
99
+ reusable_copier(options)
100
+ end
101
+
102
+ def reusable_copier(options)
103
+ # One copier per thread/options avoids per-record walker allocation.
104
+ pool = Thread.current.thread_variable_get(POOL_KEY)
105
+ unless pool
106
+ pool = {}
107
+ Thread.current.thread_variable_set(POOL_KEY, pool)
108
+ end
109
+
110
+ bucket = cache_bucket(
111
+ pool,
112
+ compact_empty: options.fetch(:compact_empty),
113
+ freeze_values: options.fetch(:freeze_values),
114
+ max_array_items: options.fetch(:max_array_items),
115
+ max_depth: options.fetch(:max_depth),
116
+ max_hash_keys: options.fetch(:max_hash_keys),
117
+ preserve_truncation_metadata: options.fetch(:preserve_truncation_metadata),
118
+ symbolize_keys: options.fetch(:symbolize_keys)
119
+ )
120
+ bucket[options.fetch(:max_string_bytes)] ||= new(**options)
121
+ end
122
+
123
+ def copier_options(compact_empty:, freeze_values:, max_array_items:, max_depth:, max_hash_keys:,
124
+ max_string_bytes:, preserve_truncation_metadata:, symbolize_keys:)
125
+ {
126
+ compact_empty: compact_empty,
127
+ freeze_values: freeze_values,
128
+ max_array_items: max_array_items,
129
+ max_depth: max_depth,
130
+ max_hash_keys: max_hash_keys,
131
+ max_string_bytes: max_string_bytes,
132
+ preserve_truncation_metadata: preserve_truncation_metadata,
133
+ symbolize_keys: symbolize_keys
134
+ }
135
+ end
136
+
137
+ def cache_bucket(pool, compact_empty:, freeze_values:, max_array_items:, max_depth:, max_hash_keys:,
138
+ preserve_truncation_metadata:, symbolize_keys:)
139
+ flags = cache_flags(
140
+ compact_empty: compact_empty,
141
+ freeze_values: freeze_values,
142
+ preserve_truncation_metadata: preserve_truncation_metadata,
143
+ symbolize_keys: symbolize_keys
144
+ )
145
+ by_depth = (pool[flags] ||= {})
146
+ by_array = (by_depth[max_depth] ||= {})
147
+ by_hash = (by_array[max_array_items] ||= {})
148
+ by_hash[max_hash_keys] ||= {}
149
+ end
150
+
151
+ def cache_flags(compact_empty:, freeze_values:, preserve_truncation_metadata:, symbolize_keys:)
152
+ flags = 0
153
+ flags |= 1 if compact_empty
154
+ flags |= 2 if freeze_values
155
+ flags |= 4 if symbolize_keys
156
+ flags |= 8 if preserve_truncation_metadata
157
+ flags
158
+ end
159
+
160
+ def cacheable_options?(options)
161
+ # Only default ingress bounds use the thread-local pool; custom limits instantiate ad hoc.
162
+ options.fetch(:max_depth) == Core::NORMALIZATION_MAX_DEPTH &&
163
+ [nil, Serializer::DEFAULT_MAX_ARRAY_ITEMS].include?(options.fetch(:max_array_items)) &&
164
+ [nil, Serializer::DEFAULT_MAX_HASH_KEYS].include?(options.fetch(:max_hash_keys)) &&
165
+ [nil, Serializer::DEFAULT_MAX_STRING_BYTES].include?(options.fetch(:max_string_bytes))
166
+ end
167
+ end
168
+ private_constant :ValueCopyCache
169
+
6
170
  class ValueCopy
7
171
  include ValueTraversal
172
+ include ValueCopyTruncation
8
173
 
9
174
  CIRCULAR_REFERENCE = Core::CIRCULAR_REFERENCE
10
175
  EMPTY_ARRAY = [].freeze
11
176
  EMPTY_HASH = {}.freeze
12
- POOL_KEY = :julewire_core_value_copy_pool
13
- private_constant :EMPTY_ARRAY, :EMPTY_HASH, :POOL_KEY
177
+ RESERVED_KEYS = [Serializer::TRUNCATION_METADATA_KEY, Serializer::TRUNCATION_METADATA_KEY.to_sym].freeze
178
+ private_constant :EMPTY_ARRAY, :EMPTY_HASH, :RESERVED_KEYS
14
179
 
15
180
  class << self
16
- def call(
181
+ include ValueCopyCache
182
+
183
+ def call( # rubocop:disable Metrics/ParameterLists
17
184
  value,
18
185
  compact_empty: false,
19
186
  freeze_values: false,
187
+ max_array_items: nil,
20
188
  max_depth: Core::NORMALIZATION_MAX_DEPTH,
189
+ max_hash_keys: nil,
190
+ max_string_bytes: nil,
191
+ preserve_truncation_metadata: false,
21
192
  symbolize_keys: false
22
193
  )
23
- return copy_leaf(value, freeze_values: freeze_values) unless container?(value)
194
+ needs_string_limit = value.is_a?(String) && max_string_bytes
195
+ return copy_leaf(value, freeze_values: freeze_values) unless container?(value) || needs_string_limit
24
196
 
25
197
  copy_with(
26
198
  cached_copier(
27
199
  compact_empty: compact_empty,
28
200
  freeze_values: freeze_values,
201
+ max_array_items: max_array_items,
29
202
  max_depth: max_depth,
203
+ max_hash_keys: max_hash_keys,
204
+ max_string_bytes: max_string_bytes,
205
+ preserve_truncation_metadata: preserve_truncation_metadata,
30
206
  symbolize_keys: symbolize_keys
31
207
  ),
32
208
  value
@@ -41,44 +217,17 @@ module Julewire
41
217
 
42
218
  def container?(value) = value.is_a?(Hash) || value.is_a?(Array)
43
219
 
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
220
  def copy_with(copier, value)
76
221
  return copier.call_reusable(value) unless copier.in_use?
77
222
 
78
223
  new(
79
224
  compact_empty: copier.compact_empty,
80
225
  freeze_values: copier.freeze_values,
226
+ max_array_items: copier.max_array_items,
81
227
  max_depth: copier.max_depth,
228
+ max_hash_keys: copier.max_hash_keys,
229
+ max_string_bytes: copier.max_string_bytes,
230
+ preserve_truncation_metadata: copier.preserve_truncation_metadata,
82
231
  symbolize_keys: copier.symbolize_keys
83
232
  ).call(value)
84
233
  end
@@ -103,18 +252,29 @@ module Julewire
103
252
  end
104
253
  end
105
254
 
106
- attr_reader :compact_empty, :freeze_values, :max_depth, :symbolize_keys
255
+ attr_reader :compact_empty, :freeze_values, :max_array_items, :max_depth, :max_hash_keys, :max_string_bytes,
256
+ :preserve_truncation_metadata, :symbolize_keys
107
257
 
108
- def initialize(compact_empty:, freeze_values:, max_depth:, symbolize_keys:)
258
+ def initialize(compact_empty:, freeze_values:, max_array_items:, max_depth:, max_hash_keys:, max_string_bytes:,
259
+ preserve_truncation_metadata:, symbolize_keys:)
109
260
  @compact_empty = compact_empty
110
261
  @freeze_values = freeze_values
262
+ @max_array_items = validate_optional_limit(max_array_items, name: :max_array_items)
111
263
  @max_depth = max_depth
264
+ @max_hash_keys = validate_optional_limit(max_hash_keys, name: :max_hash_keys)
265
+ @max_string_bytes = validate_optional_limit(max_string_bytes, name: :max_string_bytes)
266
+ @preserve_truncation_metadata = preserve_truncation_metadata
112
267
  @symbolize_keys = symbolize_keys
113
268
  @in_use = false
269
+ @last_truncated = false
270
+ @track_truncation = !!(@max_array_items || @max_hash_keys || @max_string_bytes)
114
271
  end
115
272
 
116
273
  def call(value)
274
+ @last_truncated = false
117
275
  traverse(value) { |root, depth| copy_value(root, depth) }
276
+ ensure
277
+ @last_truncated = false
118
278
  end
119
279
 
120
280
  def call_reusable(value)
@@ -137,7 +297,7 @@ module Julewire
137
297
  end
138
298
 
139
299
  def copy_container(value, depth)
140
- return copy_string(Serializer::MAX_DEPTH_VALUE) if depth_limited?(depth)
300
+ return mark_truncated(copy_string(Serializer::MAX_DEPTH_VALUE)) if depth_limited?(depth)
141
301
  return frozen_empty_container(value) if @freeze_values && value.empty?
142
302
 
143
303
  with_traversal_container(value, CIRCULAR_REFERENCE) do
@@ -154,43 +314,124 @@ module Julewire
154
314
  end
155
315
 
156
316
  def copy_hash(value, depth)
317
+ fields = nil
157
318
  result = {}
319
+ visited = 0
158
320
  value.each do |key, item|
321
+ if hash_limit_reached?(visited)
322
+ fields = append_truncation_field(fields, "hash_keys")
323
+ break
324
+ end
325
+
326
+ visited += 1
327
+ # Raw-empty values still spend work budget. The limit protects traversal work, not output size.
159
328
  next if @compact_empty && self.class.omitted_empty?(item)
160
329
 
161
- copied = copy_value(item, depth + 1)
162
- next if @compact_empty && self.class.omitted_empty?(copied)
330
+ fields = copy_hash_entry(result, fields, key, item, depth)
331
+ end
332
+ finish_hash(result, fields)
333
+ end
334
+
335
+ def hash_limit_reached?(visited)
336
+ @max_hash_keys && visited >= @max_hash_keys
337
+ end
338
+
339
+ def copy_hash_entry(result, fields, key, item, depth)
340
+ return copy_truncation_metadata_entry(result, fields, key, item, depth) if reserved_truncation_key?(key)
341
+
342
+ copied = copy_value(item, depth + 1)
343
+ child_truncated = consume_truncated
344
+ return fields if @compact_empty && self.class.omitted_empty?(copied)
163
345
 
164
- result[copy_key(key)] = copied
346
+ copied_key = copy_key(key)
347
+ key_truncated = consume_truncated
348
+ result[copied_key] = copied
349
+ record_hash_truncation(fields, copied_key, key_truncated || child_truncated)
350
+ end
351
+
352
+ def copy_truncation_metadata_entry(result, fields, key, item, depth)
353
+ unless @preserve_truncation_metadata &&
354
+ allowed_truncation_metadata_key?(key) &&
355
+ TruncationMetadata.valid?(item, max_fields: truncation_metadata_field_limit)
356
+ raise_reserved_key!(key)
165
357
  end
166
- freeze_container(result)
358
+
359
+ result[copy_truncation_metadata_key(key)] = copy_value(item, depth + 1)
360
+ consume_truncated
361
+ fields
362
+ end
363
+
364
+ def truncation_metadata_field_limit
365
+ @max_array_items || Serializer::DEFAULT_MAX_ARRAY_ITEMS
366
+ end
367
+
368
+ def allowed_truncation_metadata_key?(key)
369
+ key.is_a?(Symbol) || @symbolize_keys
370
+ end
371
+
372
+ def reserved_truncation_key?(key)
373
+ key == Serializer::TRUNCATION_METADATA_KEY || key == Serializer::TRUNCATION_METADATA_KEY.to_sym
374
+ end
375
+
376
+ def copy_truncation_metadata_key(key)
377
+ @symbolize_keys && key.is_a?(String) ? key.to_sym : key
167
378
  end
168
379
 
169
380
  def copy_array(value, depth)
381
+ fields = nil
170
382
  result = []
383
+ visited = 0
171
384
  value.each do |item|
172
- next if @compact_empty && self.class.omitted_empty?(item)
385
+ if array_limit_reached?(visited)
386
+ fields = append_truncation_field(fields, "array_items")
387
+ break
388
+ end
173
389
 
174
- copied = copy_value(item, depth + 1)
175
- next if @compact_empty && self.class.omitted_empty?(copied)
390
+ visited += 1
391
+ next if @compact_empty && self.class.omitted_empty?(item)
176
392
 
177
- result << copied
393
+ fields = copy_array_item(result, fields, item, depth)
178
394
  end
179
- freeze_container(result)
395
+ finish_array(result, fields)
396
+ end
397
+
398
+ def array_limit_reached?(visited)
399
+ @max_array_items && visited >= @max_array_items
400
+ end
401
+
402
+ def copy_array_item(result, fields, item, depth)
403
+ copied = copy_value(item, depth + 1)
404
+ child_truncated = consume_truncated
405
+ return fields if @compact_empty && self.class.omitted_empty?(copied)
406
+
407
+ result << copied
408
+ child_truncated ? append_truncation_field(fields, "array_item_values") : fields
180
409
  end
181
410
 
182
411
  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)
412
+ copied = key.is_a?(String) ? copy_string(key) : key
413
+ copied = copied.to_sym if @symbolize_keys && copied.is_a?(String)
414
+ raise_reserved_key!(copied)
415
+
416
+ copied
417
+ end
185
418
 
186
- key
419
+ def raise_reserved_key!(key)
420
+ return unless RESERVED_KEYS.include?(key)
421
+
422
+ raise ArgumentError, "#{Serializer::TRUNCATION_METADATA_KEY} is reserved for Julewire truncation metadata"
187
423
  end
188
424
 
189
425
  def copy_string(value)
190
426
  return value unless value.is_a?(String)
191
427
 
428
+ if @max_string_bytes && value.bytesize > @max_string_bytes
429
+ copy = "#{value.byteslice(0, @max_string_bytes).scrub("?")}#{Serializer::TRUNCATED_SUFFIX}"
430
+ return mark_truncated(freeze_container(copy))
431
+ end
432
+
192
433
  copy = value.frozen? ? value : value.dup
193
- @freeze_values ? copy.freeze : copy
434
+ clear_truncated(freeze_container(copy))
194
435
  end
195
436
 
196
437
  def copy_time(value)
@@ -76,7 +76,7 @@ module Julewire
76
76
 
77
77
  assert_equal "[FI...[Truncated]", result.fetch(:secret)
78
78
  assert_equal "abc...[Truncated]", result.fetch(:long)
79
- assert_equal ["array_items"], result.dig(:list, 1, marker_key, "truncated_fields")
79
+ assert_equal ["array_items"], result.dig(:list, 1, marker_key, :truncated_fields)
80
80
  end
81
81
 
82
82
  def assert_julewire_integration_failure_contract(integration:, component:, exercise:)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Julewire
4
4
  module Core
5
- VERSION = "1.0.0"
5
+ VERSION = "1.0.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: julewire-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Grebennik
@@ -187,6 +187,7 @@ files:
187
187
  - lib/julewire/core/serialization/serializer.rb
188
188
  - lib/julewire/core/serialization/serializer_pool.rb
189
189
  - lib/julewire/core/serialization/text_encoder.rb
190
+ - lib/julewire/core/serialization/truncation_metadata.rb
190
191
  - lib/julewire/core/serialization/value_copy.rb
191
192
  - lib/julewire/core/serialization/value_traversal.rb
192
193
  - lib/julewire/core/testing.rb