solid_objects 0.8.0 → 0.10.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/app/controllers/solid_objects/components_controller.rb +3 -18
  4. data/benchmark/support.rb +47 -5
  5. data/docs/benchmarks.md +19 -2
  6. data/docs/local-testing.md +91 -0
  7. data/docs/realtime.md +74 -6
  8. data/docs/roadmap.md +56 -36
  9. data/exe/solid_objects +12 -1
  10. data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
  11. data/lib/solid_objects/actor_channel.rb +50 -10
  12. data/lib/solid_objects/callable_keywords.rb +29 -0
  13. data/lib/solid_objects/component_subscriptions.rb +19 -11
  14. data/lib/solid_objects/configuration.rb +13 -0
  15. data/lib/solid_objects/database_adapter.rb +37 -0
  16. data/lib/solid_objects/database_adapters/mysql.rb +28 -0
  17. data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
  18. data/lib/solid_objects/database_adapters/sqlite.rb +5 -0
  19. data/lib/solid_objects/doctor.rb +16 -0
  20. data/lib/solid_objects/payload_broadcast.rb +29 -1
  21. data/lib/solid_objects/supervisor.rb +76 -0
  22. data/lib/solid_objects/version.rb +1 -1
  23. data/lib/solid_objects/wake_up_adapters/redis.rb +183 -0
  24. data/lib/solid_objects.rb +2 -0
  25. data/sig/generated/controllers/solid_objects/components_controller.rbs +0 -8
  26. data/sig/generated/lib/solid_objects/actor_channel.rbs +19 -0
  27. data/sig/generated/lib/solid_objects/callable_keywords.rbs +16 -0
  28. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +5 -0
  29. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  30. data/sig/generated/lib/solid_objects/database_adapter.rbs +17 -0
  31. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
  32. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  33. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +3 -0
  34. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  35. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
  36. data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
  37. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
  38. metadata +6 -1
@@ -78,19 +78,59 @@ module SolidObjects
78
78
  return if payload_names.nil? || payload_names.empty?
79
79
  return unless newer_payload_revision?(snapshot)
80
80
 
81
- payload_names.each do |name|
82
- payload = PayloadBroadcast.new(
83
- snapshot:,
84
- name:,
85
- authorization_context: connection
86
- ).call
87
- transmit TurboStreamRenderer.state_payload(payload)
88
- rescue Unauthorized
89
- next
90
- end
81
+ # The watermark records what the subscriber has, so a revision with a
82
+ # failed payload must not advance it: dedup would skip every later
83
+ # attempt at that revision and the actor may not mutate again for a long
84
+ # time. Every name is still attempted before the decision is made.
85
+ attempts = payload_names.map { |name| transmit_state_payload(snapshot, name) }
86
+ return if attempts.any?(false)
87
+
91
88
  @payload_revision = [ snapshot.instance_id, snapshot.revision ]
92
89
  end
93
90
 
91
+ # A payload is one subscriber's view of one name. Letting it raise through
92
+ # here would reject the subscription or abandon the rest of a broadcast, so
93
+ # a failure is confined to the payload that caused it and reported. The
94
+ # exception message is deliberately not instrumented: a payload block reads
95
+ # actor state, so its message is the one place subscriber state could leak
96
+ # into logs.
97
+ #
98
+ # Returns whether this revision was settled for the name. An unauthorized
99
+ # payload is settled: the decision is stable, so retrying it would only
100
+ # re-deliver its authorized siblings.
101
+ # @rbs (ActorSnapshot, String) -> bool
102
+ def transmit_state_payload(snapshot, name)
103
+ payload = PayloadBroadcast.new(
104
+ snapshot:,
105
+ name:,
106
+ authorization_context: payload_authorization_context(name)
107
+ ).call
108
+ transmit TurboStreamRenderer.state_payload(payload)
109
+ true
110
+ rescue Unauthorized
111
+ true
112
+ rescue => error
113
+ SolidObjects.instrument(
114
+ :payload_broadcast_failed,
115
+ actor_type: reference.actor_type,
116
+ actor_id: reference.actor_id,
117
+ payload_name: name,
118
+ error_class: error.class.name
119
+ )
120
+ false
121
+ end
122
+
123
+ # Resolves the Cable connection to whatever the application uses as an
124
+ # authorization subject, so a payload block and `authorize_query` see the
125
+ # same object a controller render would pass.
126
+ # @rbs (String) -> untyped
127
+ def payload_authorization_context(name)
128
+ callable = SolidObjects.configuration.payload_authorization_context
129
+ return callable.call(connection:) unless CallableKeywords.accepts?(callable, :payload_name)
130
+
131
+ callable.call(connection:, payload_name: name)
132
+ end
133
+
94
134
  # @rbs (ActorSnapshot) -> bool
95
135
  def newer_payload_revision?(snapshot)
96
136
  current = @payload_revision
@@ -0,0 +1,29 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ # Authorization context resolvers gained keywords after applications had
5
+ # already written them, so a resolver is called with what it declared it
6
+ # accepts rather than with everything the caller could offer.
7
+ module CallableKeywords
8
+ class << self
9
+ # @rbs (untyped, Symbol) -> bool
10
+ def accepts?(callable, keyword)
11
+ parameters(callable).any? do |type, name|
12
+ type == :keyrest || (%i[key keyreq].include?(type) && name == keyword)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ # A lambda answers `parameters` directly; a callable object answers it
19
+ # through its `call` method.
20
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
21
+ def parameters(callable)
22
+ return callable.parameters if callable.respond_to?(:parameters)
23
+ return callable.method(:call).parameters if callable.respond_to?(:call)
24
+
25
+ []
26
+ end
27
+ end
28
+ end
29
+ end
@@ -52,26 +52,19 @@ module SolidObjects
52
52
  registration.dependencies.include?(observable_name) &&
53
53
  newer_revision?(registration.dom_id, instance_id, revision)
54
54
  end
55
- batched, individual = changed.partition(&:batch)
56
- streams = individual.map { |registration| refresh(registration, instance_id, revision) }
57
- batched.group_by(&:batch).each_value do |group|
58
- group.each { |registration| record_revision(registration, instance_id, revision) }
59
- streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
60
- end
61
- streams
55
+ refresh_streams(changed, instance_id, revision)
62
56
  end
63
57
 
64
58
  # @rbs (ActorSnapshot) -> Array[String]
65
59
  def reconnect_refreshes(snapshot)
66
- registrations.filter_map do |registration|
67
- next unless newer_revision?(
60
+ stale = registrations.select do |registration|
61
+ newer_revision?(
68
62
  registration.dom_id,
69
63
  snapshot.instance_id,
70
64
  snapshot.revision
71
65
  )
72
-
73
- refresh(registration, snapshot.instance_id, snapshot.revision)
74
66
  end
67
+ refresh_streams(stale, snapshot.instance_id, snapshot.revision)
75
68
  end
76
69
 
77
70
  class << self
@@ -91,6 +84,21 @@ module SolidObjects
91
84
 
92
85
  attr_reader :registrations, :revisions
93
86
 
87
+ # Live invalidations and reconnect replays share this, so a reconnecting
88
+ # client pays the same number of requests a connected one does.
89
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
90
+ def refresh_streams(changed, instance_id, revision)
91
+ batched, individual = changed.partition(&:batch)
92
+ streams = individual.map do |registration|
93
+ refresh(registration, instance_id, revision)
94
+ end
95
+ batched.group_by(&:batch).each_value do |group|
96
+ group.each { |registration| record_revision(registration, instance_id, revision) }
97
+ streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
98
+ end
99
+ streams
100
+ end
101
+
94
102
  # @rbs (ComponentRegistration, Integer, Integer) -> void
95
103
  def record_revision(registration, instance_id, revision)
96
104
  revisions[registration.dom_id] = [ instance_id, revision ]
@@ -22,6 +22,7 @@ module SolidObjects
22
22
  # @rbs @process_alive_threshold: Float
23
23
  # @rbs @shutdown_timeout: Float
24
24
  # @rbs @supervisor_monitor_interval: Float
25
+ # @rbs @retention_interval: Float
25
26
  # @rbs @dead_process_cleanup_interval: Float
26
27
  # @rbs @message_retention: Numeric
27
28
  # @rbs @message_retention_by_actor_type: Hash[String, Numeric]
@@ -39,6 +40,7 @@ module SolidObjects
39
40
  # @rbs @wake_up_adapter: untyped
40
41
  # @rbs @component_path_resolver: Proc?
41
42
  # @rbs @component_authorization_context: Proc
43
+ # @rbs @payload_authorization_context: Proc
42
44
  # @rbs @authorize_message: Proc
43
45
  # @rbs @authorize_query: Proc
44
46
  # @rbs @authorize_destroy: Proc
@@ -65,6 +67,7 @@ module SolidObjects
65
67
  :process_alive_threshold,
66
68
  :shutdown_timeout,
67
69
  :supervisor_monitor_interval,
70
+ :retention_interval,
68
71
  :dead_process_cleanup_interval,
69
72
  :message_retention,
70
73
  :message_retention_by_actor_type,
@@ -82,6 +85,7 @@ module SolidObjects
82
85
  :wake_up_adapter,
83
86
  :component_path_resolver,
84
87
  :component_authorization_context,
88
+ :payload_authorization_context,
85
89
  :authorize_message,
86
90
  :authorize_query,
87
91
  :authorize_destroy,
@@ -108,6 +112,7 @@ module SolidObjects
108
112
  @lock_retry_attempts = 10
109
113
  @supervisor_monitor_interval = 1.0
110
114
  @dead_process_cleanup_interval = 60.0
115
+ @retention_interval = 3600.0
111
116
  @process_heartbeat_interval = 15.0
112
117
  @process_alive_threshold = 60.0
113
118
  @shutdown_timeout = 15.0
@@ -126,6 +131,7 @@ module SolidObjects
126
131
  @wake_up_adapter = nil
127
132
  @component_path_resolver = nil
128
133
  @component_authorization_context = ->(controller:) { controller }
134
+ @payload_authorization_context = ->(connection:) { connection }
129
135
  @logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
130
136
  Rails.logger
131
137
  else
@@ -144,6 +150,10 @@ module SolidObjects
144
150
  raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
145
151
  end
146
152
 
153
+ if retention_interval.negative?
154
+ raise ArgumentError, "retention_interval must not be negative"
155
+ end
156
+
147
157
  unless supervisor_monitor_interval.positive?
148
158
  raise ArgumentError, "supervisor_monitor_interval must be positive"
149
159
  end
@@ -176,6 +186,9 @@ module SolidObjects
176
186
  unless component_authorization_context.respond_to?(:call)
177
187
  raise ArgumentError, "component_authorization_context must respond to call"
178
188
  end
189
+ unless payload_authorization_context.respond_to?(:call)
190
+ raise ArgumentError, "payload_authorization_context must respond to call"
191
+ end
179
192
 
180
193
  self
181
194
  end
@@ -32,6 +32,43 @@ module SolidObjects
32
32
  @fixed_connection = connection_pool ? nil : connection
33
33
  end
34
34
 
35
+ # The oldest server the adapter has been exercised against. Reported rather
36
+ # than enforced: refusing to boot on an untested server would be a worse
37
+ # failure than running on one.
38
+ # @rbs () -> Gem::Version?
39
+ def minimum_server_version
40
+ nil
41
+ end
42
+
43
+ # @rbs () -> Gem::Version
44
+ def server_version
45
+ with_connection do |connection|
46
+ Gem::Version.new(connection.database_version.to_s)
47
+ end
48
+ end
49
+
50
+ # One observed version decides both the status and the message. Reading it
51
+ # again could let a transient failure replace an already determined result.
52
+ # @rbs (?Gem::Version?) -> Array[String]
53
+ def unsupported_server_reasons(observed = nil)
54
+ observed ||= server_version
55
+ reasons = []
56
+ minimum = minimum_server_version
57
+ if minimum && observed < minimum
58
+ reasons << "#{self.class.name.demodulize} #{observed} is older than " \
59
+ "Solid Objects requires, which is #{minimum}"
60
+ end
61
+ reasons.concat(additional_server_reasons)
62
+ reasons
63
+ rescue => error
64
+ [ "the database server could not be verified: #{error.class}: #{error.message}" ]
65
+ end
66
+
67
+ # @rbs () -> Array[String]
68
+ def additional_server_reasons
69
+ []
70
+ end
71
+
35
72
  # @rbs () -> bool
36
73
  def supports_skip_locked?
37
74
  false
@@ -13,6 +13,34 @@ module SolidObjects
13
13
  "FOR UPDATE SKIP LOCKED"
14
14
  end
15
15
 
16
+ # A non-transactional engine would silently break fenced commits, so the
17
+ # storage engine is verified rather than assumed.
18
+ # @rbs () -> Array[String]
19
+ def additional_server_reasons
20
+ tables = non_innodb_tables
21
+ return [] if tables.empty?
22
+
23
+ [ "these Solid Objects tables do not use InnoDB, so their commits are " \
24
+ "not transactional: #{tables.join(", ")}" ]
25
+ end
26
+
27
+ # @rbs () -> Array[String]
28
+ def non_innodb_tables
29
+ names = SolidObjects::Doctor::EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
30
+ with_connection do |connection|
31
+ connection.select_rows(<<~SQL.squish).filter_map { |table, engine| table if engine != "InnoDB" }
32
+ SELECT table_name, engine FROM information_schema.tables
33
+ WHERE table_schema = DATABASE()
34
+ AND table_name IN (#{names.map { |name| connection.quote(name) }.join(", ")})
35
+ SQL
36
+ end
37
+ end
38
+
39
+ # @rbs () -> Gem::Version?
40
+ def minimum_server_version
41
+ Gem::Version.new("8.0")
42
+ end
43
+
16
44
  # @rbs () -> String
17
45
  def current_time_expression
18
46
  "CURRENT_TIMESTAMP(6)"
@@ -3,6 +3,21 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  class Postgresql < DatabaseAdapter
6
+ # @rbs () -> Gem::Version?
7
+ def minimum_server_version
8
+ Gem::Version.new("13")
9
+ end
10
+
11
+ # PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
12
+ # directly would make every server look newer than any minimum.
13
+ # @rbs () -> Gem::Version
14
+ def server_version
15
+ packed = with_connection { |connection| connection.database_version.to_i }
16
+ return super unless packed.positive?
17
+
18
+ Gem::Version.new("#{packed / 10_000}.#{packed % 10_000}")
19
+ end
20
+
6
21
  # @rbs () -> bool
7
22
  def supports_skip_locked?
8
23
  true
@@ -8,6 +8,11 @@ module SolidObjects
8
8
  LOCK_RETRY_MUTEX = Thread::Mutex.new
9
9
  LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
10
10
 
11
+ # @rbs () -> Gem::Version?
12
+ def minimum_server_version
13
+ Gem::Version.new("3.35")
14
+ end
15
+
11
16
  # @rbs () -> String
12
17
  def current_time_expression
13
18
  "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
@@ -111,6 +111,7 @@ module SolidObjects
111
111
  configuration_check,
112
112
  schema_check,
113
113
  check_authorization,
114
+ check_database_server,
114
115
  schema_check.failed? ? skipped_runtime : check_runtime,
115
116
  ready_for_round_trip?(configuration_check, schema_check) ?
116
117
  check_sync_round_trip :
@@ -191,6 +192,21 @@ module SolidObjects
191
192
  pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
192
193
  end
193
194
 
195
+ # @rbs () -> Check
196
+ def check_database_server
197
+ adapter = SolidObjects.database_adapter
198
+ observed = adapter.server_version
199
+ reasons = adapter.unsupported_server_reasons(observed)
200
+ return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?
201
+
202
+ pass(
203
+ :database_server,
204
+ "#{adapter.class.name.demodulize} #{observed} meets the tested minimum"
205
+ )
206
+ rescue => error
207
+ warn_check(:database_server, "#{error.class}: #{error.message}")
208
+ end
209
+
194
210
  # @rbs () -> Check
195
211
  def check_runtime
196
212
  cutoff = SolidObjects.database_adapter.database_now -
@@ -41,7 +41,7 @@ module SolidObjects
41
41
  # @rbs (ActorDefinition::Handler) -> untyped
42
42
  def rendered_payload(handler)
43
43
  payload = Serialization.dump(
44
- handler.block.call(snapshot.actor, authorization_context),
44
+ evaluated_payload(handler),
45
45
  max_bytes: MAXIMUM_PAYLOAD_BYTES
46
46
  )
47
47
  return payload if payload.is_a?(Hash) || payload.is_a?(Array)
@@ -50,6 +50,34 @@ module SolidObjects
50
50
  "payload broadcast #{name.inspect} must return a JSON object or array"
51
51
  end
52
52
 
53
+ # The block runs against the actor instance, like every other block in the
54
+ # actor DSL, and still receives the actor and the resolved authorization
55
+ # context as arguments, so blocks written to the documented signature are
56
+ # unaffected.
57
+ # @rbs (ActorDefinition::Handler) -> untyped
58
+ def evaluated_payload(handler)
59
+ actor = snapshot.actor
60
+ actor.instance_exec(actor, authorization_context, &handler.block)
61
+ rescue NameError => error
62
+ raise unless class_level_receiver?(error)
63
+
64
+ raise InvalidPayloadBroadcast,
65
+ "payload broadcast #{name.inspect} called #{error.name.inspect} on the " \
66
+ "actor class. Payload blocks now run against the actor instance, like " \
67
+ "every other actor block. Call it on the class explicitly."
68
+ end
69
+
70
+ # Distinguishes a block that relied on the old class-level receiver from an
71
+ # ordinary typo, so the one behaviour change reports itself instead of
72
+ # surfacing as an unexplained NameError.
73
+ # @rbs (NameError[untyped]) -> bool
74
+ def class_level_receiver?(error)
75
+ error.receiver.equal?(snapshot.actor) &&
76
+ snapshot.actor_class.respond_to?(error.name)
77
+ rescue ArgumentError, NameError
78
+ false
79
+ end
80
+
53
81
  # @rbs () -> void
54
82
  def authorize!
55
83
  authorized = SolidObjects.configuration.authorize_query.call(
@@ -2,11 +2,14 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Supervisor
5
+ MAXIMUM_RETENTION_BACKOFF_DOUBLINGS = 16
6
+
5
7
  # @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
6
8
  # @rbs @threads: Array[Thread]
7
9
  # @rbs @monitor: Thread?
8
10
  # @rbs @started: bool
9
11
  # @rbs @cleaned_up_at: Float
12
+ # @rbs @retention: Thread?
10
13
  # @rbs @lifecycle: Thread::Mutex
11
14
 
12
15
  # @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
@@ -26,6 +29,7 @@ module SolidObjects
26
29
  @monitor = nil
27
30
  @started = false
28
31
  @cleaned_up_at = nil
32
+ @retention = nil
29
33
  @lifecycle = Thread::Mutex.new
30
34
  end
31
35
 
@@ -44,6 +48,7 @@ module SolidObjects
44
48
  @started = true
45
49
  @threads = components.map { |component| supervise(component) }
46
50
  @monitor = Thread.new { monitor_loop }
51
+ @retention = Thread.new { retention_loop }
47
52
  SolidObjects.instrument(:"supervisor.started", component_count: components.length)
48
53
  end
49
54
 
@@ -57,6 +62,7 @@ module SolidObjects
57
62
  # list, or never starts.
58
63
  @lifecycle.synchronize { @started = false }
59
64
  stop_monitor
65
+ stop_retention
60
66
  components.each(&:request_shutdown)
61
67
  join_until_timeout
62
68
  components.reject(&:stopped?).each(&:stop)
@@ -133,6 +139,76 @@ module SolidObjects
133
139
  error.class.name
134
140
  end
135
141
 
142
+ # Retention gets its own thread rather than sharing the monitor's. A large
143
+ # backlog or a lock wait can make a pass slow, and role replacement must not
144
+ # wait behind housekeeping.
145
+ # @rbs () -> void
146
+ def retention_loop
147
+ failures = 0
148
+ while @started
149
+ begin
150
+ prune_expired_records
151
+ failures = 0
152
+ rescue => error
153
+ failures += 1
154
+ SolidObjects.instrument(
155
+ :"supervisor.retention_failed",
156
+ error_class: error.class.name,
157
+ error_message: error.message
158
+ )
159
+ end
160
+ wait_for_next_retention(failures)
161
+ end
162
+ end
163
+
164
+ # Sleeping the whole interval would make shutdown wait out an hour-long
165
+ # nap, so the pause is taken in short steps that notice a stop request.
166
+ # @rbs (Integer) -> void
167
+ def wait_for_next_retention(failures)
168
+ deadline = monotonic_now + retention_pause(failures)
169
+ step = SolidObjects.configuration.supervisor_monitor_interval
170
+ while @started && monotonic_now < deadline
171
+ sleep [ step, deadline - monotonic_now ].min
172
+ end
173
+ end
174
+
175
+ # Every actor call writes a durable message row, so retention that is only
176
+ # configured and never run leaves those rows to grow without bound. The
177
+ # supervisor runs it rather than requiring every application to schedule
178
+ # its own job.
179
+ # @rbs () -> void
180
+ def prune_expired_records
181
+ return unless SolidObjects.configuration.retention_interval.positive?
182
+
183
+ MessagePruner.new.prune
184
+ ProcessPruner.new.prune
185
+ end
186
+
187
+ # A transient lock or connection error must not defer retention for the
188
+ # whole interval, so a failed pass retries at monitor cadence. The pause
189
+ # then doubles per consecutive failure, capped by the interval, so a
190
+ # database that stays down is not polled once a second forever.
191
+ # @rbs (Integer) -> Float
192
+ def retention_pause(failures)
193
+ interval = SolidObjects.configuration.retention_interval
194
+ interval = SolidObjects.configuration.supervisor_monitor_interval unless interval.positive?
195
+ return interval if failures.zero?
196
+
197
+ backoff = SolidObjects.configuration.supervisor_monitor_interval *
198
+ (2**[ failures - 1, MAXIMUM_RETENTION_BACKOFF_DOUBLINGS ].min)
199
+ [ backoff, interval ].min
200
+ end
201
+
202
+ # @rbs () -> void
203
+ def stop_retention
204
+ retention = @retention
205
+ @retention = nil
206
+ return unless retention
207
+
208
+ retention.join(SolidObjects.configuration.shutdown_timeout)
209
+ retention.kill if retention.alive?
210
+ end
211
+
136
212
  # @rbs () -> void
137
213
  def cleanup_dead_processes
138
214
  interval = SolidObjects.configuration.dead_process_cleanup_interval
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end