julewire-core 1.1.2 → 1.1.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caa063f4c5d3e39efe092df078811e29ec8ace41ab8b459d9f77276ba16bbee9
4
- data.tar.gz: 1af9d19915aee53ff7abfcd6532cfb7d2f0aa836d1ee3a953f3f28217280b189
3
+ metadata.gz: 1706c04d0b1576af41d10b18b1e7288b2e9a80f3ab599298cf7fafefa9c6887b
4
+ data.tar.gz: 7e7a34704951f6e420dbf0a91d5eab8bad5ce099f4c70e5913e14d4f585ef8e4
5
5
  SHA512:
6
- metadata.gz: a9577774281e4a434eed8862a93b08551ed8ef634f89bea232ce90165ccdc640275c039546a4df847bb01eba2d6049833957873d83c6dd13ec9b30f93328dbfb
7
- data.tar.gz: c1e6ba4c5c6ecbd33cd611063de5a8f2f12f9062654ae8cce586c7d8a0dbb2c6dd41f183f4d061ff5905d1842f01b4cedaf6660ed8ef6b9a121e5891185754b0
6
+ metadata.gz: e5aaa3aeec9061a87b47d54a1b5a36ff4c7b901492f450325a9f3e91d10857700c648e837f88b50f0c3b9dd7c75f9810ebaaff196c8c96235d13a655788d234e
7
+ data.tar.gz: 2f7dc4648b5587e2e498005204b5af7e9f02757336d89524a2309a3515c22e08e151063d41b531a28f14efd3716d338817f090fb8cdfcb47e328bc2f746d42e2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 1.1.3 - 2026-08-09
4
+
5
+ - Add fail-loud pre-fork hooks with shared timeout budgets and reverse rollback.
6
+ - Refresh development tooling and compatibility locksets.
7
+
3
8
  ## 1.1.2 - 2026-08-02
4
9
 
5
10
  - Refresh development and compatibility dependency locksets.
data/docs/contracts.md CHANGED
@@ -9,7 +9,9 @@ These APIs are for application code and scripts:
9
9
 
10
10
  - Runtime configuration and lifecycle: `Julewire.configure`,
11
11
  `Julewire.config`, `Julewire.reset!`, `Julewire.flush`, `Julewire.close`,
12
- `Julewire.after_fork!`, and `Julewire.health`.
12
+ `Julewire.before_fork!`, `Julewire.after_fork!`, and `Julewire.health`.
13
+ - `Julewire::UnsafeForkError` for pre-fork safety failures that must abort
14
+ process creation.
13
15
  - `Julewire.runtime` for explicit secondary pipelines with independent
14
16
  configuration, destinations, processors, health, and lifecycle.
15
17
  - `Julewire.labels` for process labels shared by the active runtime.
@@ -91,7 +93,8 @@ but they are not intended as general application API:
91
93
  - `Julewire::Core::Integration::Values::Shape` for normalized timestamps, payload
92
94
  normalization, field appends, and source-location shaping.
93
95
  - `Julewire::Core::Integration::Lifecycle` for optional require containment and
94
- process-local `after_fork` hooks.
96
+ process-local before/after-fork hooks. Hook identifiers are strict Symbols;
97
+ before-fork failures propagate while after-fork failures are health-contained.
95
98
  - `Julewire::Core::Integration` helper classes/modules for one-time ivar state,
96
99
  subscriber install helpers, event-subscriber health wrappers, config settings
97
100
  helpers, and subscription handles.
@@ -281,10 +281,12 @@ return without raising unless they return `false`.
281
281
  dropped record; a plain `false` is a rejected record and calls `on_drop` with
282
282
  `:destination_rejected`.
283
283
 
284
- Custom destinations may also implement `after_fork!` for fork reset and
285
- `resource_identity` when multiple destinations share the same closeable
286
- resource. Transport adapters may expose adapter-specific lifecycle methods such
287
- as `reopen`.
284
+ Custom destinations may implement `before_fork!(timeout:)` to drain and stop
285
+ resources that cannot survive a process fork. Returning `false` rejects fork
286
+ preparation; raising reports the failure and aborts preparation. Destinations
287
+ may also implement `after_fork!` for fork reset and `resource_identity` when
288
+ multiple destinations share the same closeable resource. Transport adapters
289
+ may expose adapter-specific lifecycle methods such as `reopen`.
288
290
 
289
291
  The registered `:tail_sampling` destination kind wraps another destination for
290
292
  execution-level tail sampling. It buffers execution records until a summary
@@ -399,12 +401,17 @@ should accept keyword metadata.
399
401
  `contracts.md` owns the public facade inventory. This section covers usage
400
402
  details that extension and integration authors usually need.
401
403
 
404
+ Integrations can register a fail-loud safety hook with
405
+ `Julewire::Core::Integration::Lifecycle.register_before_fork(:integration_name,
406
+ component: :component_name) { ... }`. It runs after destinations have quiesced;
407
+ an exception aborts preparation and resumes destinations already prepared.
408
+
402
409
  Integrations that keep process-local state can register a reset hook with
403
410
  `Julewire::Core::Integration::Lifecycle.register_after_fork(:integration_name,
404
411
  component: :component_name) { ... }`. The hook runs after core has refreshed its
405
412
  own process-local state and after the active pipeline has forwarded
406
413
  `after_fork!` to destinations.
407
- Both hook names are strict integration identifiers: pass non-empty Symbols.
414
+ All integration and component hook names are strict identifiers: pass non-empty Symbols.
408
415
  String or non-Symbol names raise at registration and are never normalized.
409
416
 
410
417
  `Julewire.observe_self!(runtime_name = :default, target: :meta)` starts a
@@ -431,6 +438,8 @@ Lifecycle:
431
438
 
432
439
  - `Integration::Lifecycle.require_optional(path)` for contained optional
433
440
  requires.
441
+ - `Integration::Lifecycle.register_before_fork(:integration_name,
442
+ component: :component_name) { ... }` for fail-loud process preparation.
434
443
  - `Integration::Lifecycle.register_after_fork(:integration_name,
435
444
  component: :component_name) { ... }` for process-local integration state.
436
445
  - `Integration::IvarState` for idempotent framework subscriber state.
@@ -151,6 +151,11 @@ attempts later destinations after a previous lifecycle call returns.
151
151
  out, `Julewire.close` returns `false`, but later emits still drop as
152
152
  `runtime_closed` until the next `configure` or `reset!`.
153
153
 
154
+ `Julewire.before_fork!` gives destinations a bounded opportunity to drain and
155
+ quiesce resources that cannot safely survive `Process.fork`. Preparation
156
+ failures propagate so the process manager can abort the fork. Stop application
157
+ work that can emit or create process-local workers before calling it.
158
+
154
159
  `Julewire.after_fork!` resets process-local counters, failure snapshots,
155
160
  current context, warning state, schedulers, and registries that cannot be
156
161
  shared with the child process. It also forwards `after_fork!` to destinations
@@ -159,6 +164,16 @@ registered through core. File, socket, queue, and async transports should
159
164
  reopen worker-local resources from their destination or output `after_fork!`
160
165
  method.
161
166
 
167
+ Forking servers must pair the lifecycle calls around the actual fork:
168
+
169
+ ```ruby
170
+ before_fork { Julewire.before_fork! }
171
+ on_worker_boot { Julewire.after_fork! }
172
+ ```
173
+
174
+ The parent may also call `Julewire.after_fork!` when it must resume destinations
175
+ between worker forks.
176
+
162
177
  Core does not install an `at_exit` hook. Small scripts should call close from
163
178
  their own shutdown path:
164
179
 
@@ -48,6 +48,13 @@ module Julewire
48
48
  self
49
49
  end
50
50
 
51
+ def before_fork!(timeout: nil)
52
+ return self unless @output.respond_to?(:before_fork!)
53
+ return false if @output.before_fork!(timeout: timeout) == false
54
+
55
+ self
56
+ end
57
+
51
58
  def resource_identity = @output
52
59
 
53
60
  private
@@ -9,6 +9,7 @@ module Julewire
9
9
  @destinations = destinations.dup.freeze
10
10
  @on_drop = on_drop
11
11
  @on_failure = on_failure
12
+ @prepared_destinations = []
12
13
  end
13
14
 
14
15
  class << self
@@ -41,6 +42,21 @@ module Julewire
41
42
  @destinations.each do |destination|
42
43
  call_destination_after_fork(destination)
43
44
  end
45
+ @prepared_destinations = []
46
+ self
47
+ end
48
+
49
+ def before_fork!(timeout:)
50
+ Validation.validate_timeout!(timeout, name: :timeout)
51
+ return self unless @prepared_destinations.empty?
52
+
53
+ prepare_destinations_before_fork(timeout)
54
+ self
55
+ end
56
+
57
+ def cancel_before_fork!
58
+ @prepared_destinations.reverse_each { call_destination_after_fork(it) }
59
+ @prepared_destinations = []
44
60
  self
45
61
  end
46
62
 
@@ -64,6 +80,23 @@ module Julewire
64
80
 
65
81
  private
66
82
 
83
+ def prepare_destinations_before_fork(timeout)
84
+ deadline = Scheduling::Deadline.for(timeout)
85
+ prepared = []
86
+ @destinations.each do |destination|
87
+ next unless destination.respond_to?(:before_fork!)
88
+
89
+ remaining = Scheduling::Deadline.remaining(deadline)
90
+ prepared << destination
91
+ result = destination.before_fork!(timeout: remaining)
92
+ raise Error, "destination #{destination_name(destination)} rejected before_fork" if result == false
93
+ end
94
+ @prepared_destinations = prepared
95
+ rescue StandardError
96
+ prepared.reverse_each { call_destination_after_fork(it) }
97
+ raise
98
+ end
99
+
67
100
  def call_lifecycle(method_name, timeout:, skip_resource_identities: nil)
68
101
  Validation.validate_timeout!(timeout, name: :timeout)
69
102
  call_lifecycle_safely(method_name, timeout, skip_resource_identities)
@@ -101,6 +134,8 @@ module Julewire
101
134
 
102
135
  def call_destination_after_fork(destination)
103
136
  destination.after_fork! if destination.respond_to?(:after_fork!)
137
+ rescue UnsafeForkError
138
+ raise
104
139
  rescue StandardError => e
105
140
  notify_failure(
106
141
  e,
@@ -74,6 +74,8 @@ module Julewire
74
74
  initialize_tracking
75
75
  @output.after_fork!
76
76
  self
77
+ rescue UnsafeForkError
78
+ raise
77
79
  rescue StandardError => e
78
80
  notify_failure(
79
81
  e,
@@ -84,6 +86,12 @@ module Julewire
84
86
  self
85
87
  end
86
88
 
89
+ def before_fork!(timeout: nil)
90
+ return false if @output.before_fork!(timeout: timeout) == false
91
+
92
+ self
93
+ end
94
+
87
95
  def resource_identity
88
96
  @output.resource_identity
89
97
  end
@@ -22,6 +22,17 @@ module Julewire
22
22
  self
23
23
  end
24
24
 
25
+ def before_fork!(timeout: nil)
26
+ result = @lifecycle_mutex.synchronize do
27
+ @mutex.synchronize do
28
+ @output.before_fork!(timeout: timeout) if @output.respond_to?(:before_fork!)
29
+ end
30
+ end
31
+ return false if result == false
32
+
33
+ self
34
+ end
35
+
25
36
  def output_class_name = @output.class.name
26
37
 
27
38
  def resource_identity = @output
@@ -86,11 +86,28 @@ module Julewire
86
86
  @mutex.synchronize { initialize_buffer }
87
87
  @destination.after_fork! if @destination.respond_to?(:after_fork!)
88
88
  self
89
+ rescue UnsafeForkError
90
+ raise
89
91
  rescue StandardError => e
90
92
  record_failure(e, nil, phase: :after_fork)
91
93
  self
92
94
  end
93
95
 
96
+ def before_fork!(timeout: nil)
97
+ return self unless @destination.respond_to?(:before_fork!)
98
+
99
+ Validation.validate_timeout!(timeout, name: :timeout)
100
+ deadline = Scheduling::Deadline.for(timeout)
101
+ unless flush(timeout: Scheduling::Deadline.remaining(deadline))
102
+ raise Error, "tail-sampling destination could not flush before fork"
103
+ end
104
+
105
+ result = @destination.before_fork!(timeout: Scheduling::Deadline.remaining(deadline))
106
+ return false if result == false
107
+
108
+ self
109
+ end
110
+
94
111
  def resource_identity = self
95
112
 
96
113
  def health
@@ -152,6 +152,11 @@ module Julewire
152
152
 
153
153
  def labels = runtime.labels
154
154
  def after_fork! = runtime.after_fork!
155
+
156
+ def before_fork!(timeout: nil)
157
+ runtime.before_fork!(timeout: timeout)
158
+ end
159
+
155
160
  def reset! = runtime.reset_facade!
156
161
 
157
162
  def close(timeout: UNSET)
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ module Julewire
6
+ module Core
7
+ module Integration
8
+ module BeforeForkHooks
9
+ @entries = Concurrent::Map.new
10
+
11
+ class << self
12
+ def register(integration, component:, &callback)
13
+ raise ArgumentError, "block required" unless callback
14
+
15
+ HookNames.validate!(integration, name: :integration)
16
+ HookNames.validate!(component, name: :component)
17
+ entries[[integration, component]] = callback
18
+ end
19
+
20
+ def run
21
+ entries.each_value(&:call)
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :entries
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -15,8 +15,8 @@ module Julewire
15
15
  def register(integration, component:, &callback)
16
16
  raise ArgumentError, "block required" unless callback
17
17
 
18
- validate_symbol_name!(integration, name: :integration)
19
- validate_symbol_name!(component, name: :component)
18
+ HookNames.validate!(integration, name: :integration)
19
+ HookNames.validate!(component, name: :component)
20
20
  register_entry(integration, component, callback)
21
21
  end
22
22
 
@@ -43,11 +43,6 @@ module Julewire
43
43
  component: entry.component
44
44
  )
45
45
  end
46
-
47
- def validate_symbol_name!(value, name:)
48
- raise TypeError, "#{name} must be a Symbol" unless value.instance_of?(Symbol)
49
- raise ArgumentError, "#{name} is required" if value == :""
50
- end
51
46
  end
52
47
  end
53
48
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ module Integration
6
+ module HookNames
7
+ class << self
8
+ def validate!(value, name:)
9
+ raise TypeError, "#{name} must be a Symbol" unless value.instance_of?(Symbol)
10
+ raise ArgumentError, "#{name} is required" if value == :""
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -15,6 +15,10 @@ module Julewire
15
15
  def register_after_fork(integration, component:, &)
16
16
  ForkHooks.register(integration, component: component, &)
17
17
  end
18
+
19
+ def register_before_fork(integration, component:, &)
20
+ BeforeForkHooks.register(integration, component: component, &)
21
+ end
18
22
  end
19
23
  end
20
24
  end
@@ -69,6 +69,10 @@ module Julewire
69
69
  self
70
70
  end
71
71
 
72
+ def before_fork!(timeout:) = @destinations.before_fork!(timeout: timeout)
73
+
74
+ def cancel_before_fork! = @destinations.cancel_before_fork!
75
+
72
76
  def flush(timeout: nil)
73
77
  @destinations.flush(timeout: timeout)
74
78
  end
@@ -155,6 +155,19 @@ module Julewire
155
155
  RuntimeRegistry.reset_after_fork(primary: self)
156
156
  end
157
157
 
158
+ def before_fork!(timeout: nil)
159
+ reject_runtime_call_during_configure!(:before_fork!)
160
+ RuntimeRegistry.prepare_before_fork(primary: self, timeout: timeout)
161
+ end
162
+
163
+ def before_fork_runtime!(timeout:)
164
+ runtime_state.pipeline.before_fork!(timeout: timeout)
165
+ end
166
+
167
+ def cancel_before_fork_runtime!
168
+ runtime_state.pipeline.cancel_before_fork!
169
+ end
170
+
158
171
  def reset_after_fork_runtime!
159
172
  reset_after_fork_state!
160
173
  runtime_state.pipeline.after_fork!
@@ -47,6 +47,22 @@ module Julewire
47
47
  runtimes.each(&:reset_after_fork_runtime!)
48
48
  Integration::ForkHooks.run
49
49
  end
50
+
51
+ def prepare_before_fork(primary:, timeout:)
52
+ Validation.validate_timeout!(timeout, name: :timeout)
53
+ runtimes = [primary] + @runtimes.get.values
54
+ deadline = Scheduling::Deadline.for(timeout)
55
+
56
+ begin
57
+ runtimes.each do |runtime|
58
+ runtime.before_fork_runtime!(timeout: Scheduling::Deadline.remaining(deadline))
59
+ end
60
+ Integration::BeforeForkHooks.run
61
+ rescue StandardError
62
+ runtimes.reverse_each(&:cancel_before_fork_runtime!)
63
+ raise
64
+ end
65
+ end
50
66
  end
51
67
  end
52
68
  end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Julewire
4
+ module Core
5
+ # @api integration_spi
6
+ class UnsafeForkError < Error; end
7
+ end
8
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Julewire
4
4
  module Core
5
- VERSION = "1.1.2"
5
+ VERSION = "1.1.3"
6
6
  end
7
7
  end
data/lib/julewire/core.rb CHANGED
@@ -65,6 +65,7 @@ module Julewire
65
65
  Tail = Core::Diagnostics::Tail
66
66
  TailSampling = Core::Destinations::TailSampling
67
67
  TextEncoder = Core::Serialization::TextEncoder
68
+ UnsafeForkError = Core::UnsafeForkError
68
69
 
69
70
  Core::Processing.register(:sampling) do |rate:, key: nil|
70
71
  Core::Processing::Sampling.head(rate: rate, key: key)
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.1.2
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Grebennik
@@ -132,12 +132,14 @@ files:
132
132
  - lib/julewire/core/fields/stack_set.rb
133
133
  - lib/julewire/core/fields/static_labels.rb
134
134
  - lib/julewire/core/fields/summary_proxy.rb
135
+ - lib/julewire/core/integration/before_fork_hooks.rb
135
136
  - lib/julewire/core/integration/configurable.rb
136
137
  - lib/julewire/core/integration/destination_health.rb
137
138
  - lib/julewire/core/integration/event_subscriber.rb
138
139
  - lib/julewire/core/integration/facade.rb
139
140
  - lib/julewire/core/integration/fork_hooks.rb
140
141
  - lib/julewire/core/integration/health.rb
142
+ - lib/julewire/core/integration/hook_names.rb
141
143
  - lib/julewire/core/integration/ivar_state.rb
142
144
  - lib/julewire/core/integration/lifecycle.rb
143
145
  - lib/julewire/core/integration/protocol.rb
@@ -194,6 +196,7 @@ files:
194
196
  - lib/julewire/core/serialization/value_copy.rb
195
197
  - lib/julewire/core/serialization/value_traversal.rb
196
198
  - lib/julewire/core/testing.rb
199
+ - lib/julewire/core/unsafe_fork_error.rb
197
200
  - lib/julewire/core/validation.rb
198
201
  - lib/julewire/core/version.rb
199
202
  - lib/julewire/error.rb