solid_objects 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7fee6db10af36996dbe9c8484fab405b53e2463a3fd7d2f7414310fedab68d5
4
- data.tar.gz: 8b71bb8fbf0dc0359ce8a00097428dbbcd189e4d2b4071ba0339b9c3ec1cd933
3
+ metadata.gz: 935a2d2bb12fbc5b39121fe44004d0cc0671c3bef82012cf9b024089c3546ac9
4
+ data.tar.gz: 15de97ea8547bfb552ae848b2ae523fdd6c48a557313ba3fd87d931ee770f9b1
5
5
  SHA512:
6
- metadata.gz: 62f2ce7ecfa4899f82af3682c5a8fb97d7005e6ba9152a8a63433b2ec345d2edc3d795c29a7f6de24d25f275afd4857986ed34302359d0f3aab018f936656acd
7
- data.tar.gz: 2114e1d8b7757bf598562fba6e1b8ecafeea852d6925a5161f74b1dcab6dab9b5750ca71327b0cfa23b927869cbeee348f1bd6d03cbfc5d964d2bfd53a625435
6
+ metadata.gz: 6633846db212684a9e6d687f76e559cd51448171bd9e067534024e9a067534f1ff7eab223e3c1ab80b7a02765b909a324f2dbb9b1f527e5846d933de5b3639c7
7
+ data.tar.gz: ce930198b48379439c4947dd41ea5d3a0577ad8ce49eb1513715075ccf23c03b23237bcee77f06a898ea5460b83e3bf6b0c8a54f7b9c16e03c159c2572a03306
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-08-10
4
+
5
+ - Report a denied CLI command as a policy decision rather than a crash.
6
+ Administration denies by default, so an unconfigured host met a thirty-line
7
+ Ruby backtrace on its first `solid_objects` command. The executable now
8
+ prints the refusal and the setting that grants access, and exits 1.
9
+ - Measure the query count for a synchronous call. `benchmark/query_count.rb`
10
+ only measured a worker turn, so the documented synchronous number had no
11
+ script behind it. It reports three now: a message turn costs 26 queries
12
+ rather than the documented 29, the caller of a synchronous call costs 49, and
13
+ a synchronous call in total costs 75, being a caller plus the turn it waits
14
+ on. Counting is scoped to the measuring thread, since a worker loop polls
15
+ whether or not a call is in flight and a process-wide count folds those polls
16
+ into the result.
17
+
18
+ - Run payload broadcast blocks against the actor instance, like every other
19
+ block in the actor DSL. `self` was the actor class, so an actor instance
20
+ method called from a payload block raised
21
+ `NoMethodError: undefined method 'x' for class PlaymatRoom`. Blocks keep
22
+ receiving the actor and the authorization context as arguments, so the
23
+ documented signature is unaffected. A block that relied on the class receiver
24
+ now raises `InvalidPayloadBroadcast` naming the method and the change instead
25
+ of an unexplained `NameError`.
26
+ - Add `payload_authorization_context`, the payload counterpart to
27
+ `component_authorization_context`. Payloads are computed inside the channel,
28
+ so without a resolver the payload block and its `authorize_query` call
29
+ received the raw Action Cable connection while a controller render passed an
30
+ application object, and the authorization hook had to tell them apart. The
31
+ resolver may also accept `payload_name:`. It defaults to returning the
32
+ connection unchanged.
33
+ - Confine a failing payload to itself. A raising payload block propagated out of
34
+ the channel: on subscribe it rejected the subscription, and on a broadcast it
35
+ abandoned the remaining payload names, which showed up in the browser only as
36
+ reactive updates that stopped arriving. A failure is now reported as
37
+ `solid_objects.payload_broadcast_failed` with the actor type, actor id,
38
+ payload name, and exception class, and delivery continues. The exception
39
+ message is deliberately excluded so subscriber state cannot leak into logs. A
40
+ revision with a failed payload does not advance the delivery watermark, so a
41
+ transient failure is retried on the next broadcast instead of being recorded
42
+ as delivered and deduplicated away.
43
+ - Run retention on the supervisor rather than leaving it configured but
44
+ unscheduled. Every actor call writes a durable message row, so a policy that
45
+ nothing invokes let history grow without bound until an application scheduled
46
+ its own job. `retention_interval` defaults to one hour, and zero disables it.
47
+ Retention runs on its own thread, so a slow pass cannot delay replacing a
48
+ crashed role, and a failed pass retries at monitor cadence with a doubling
49
+ backoff rather than deferring for the whole interval.
50
+ - Batch component refreshes on reconnect. A reconnecting subscription refreshed
51
+ every stale component individually, ignoring the batches those components
52
+ declared, so a page with twenty batched components issued twenty requests
53
+ instead of one. That happens at the worst moment: a server restart reconnects
54
+ every client at once. Reconnect now shares the batching the live invalidation
55
+ path uses.
56
+ - Cover the reconnect burst in the browser suite: convergence of batched and
57
+ unbatched components, an inert replay of an already-applied revision,
58
+ cancellation of the request left in flight by the drop, incarnation ordering
59
+ after a destroy and recreate, and payload delivery exactly once per revision.
60
+ - Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
61
+ 4.0 against Rails 8.0 and 8.1.
62
+
3
63
  ## 0.9.0 - 2026-08-10
4
64
 
5
65
  - Add a browser test suite running the refresh modules against real Chromium and
@@ -138,26 +138,11 @@ module SolidObjects
138
138
  # @rbs (Array[ComponentRegistration]) -> untyped
139
139
  def component_authorization_context(registrations)
140
140
  callable = SolidObjects.configuration.component_authorization_context
141
- return callable.call(controller: self) unless accepts_registrations?(callable)
142
-
143
- callable.call(controller: self, registrations:)
144
- end
145
-
146
- # A lambda answers `parameters` directly; a callable object answers it
147
- # through its `call` method.
148
- # @rbs (untyped) -> bool
149
- def accepts_registrations?(callable)
150
- callable_parameters(callable).any? do |type, name|
151
- type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
141
+ unless CallableKeywords.accepts?(callable, :registrations)
142
+ return callable.call(controller: self)
152
143
  end
153
- end
154
-
155
- # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
156
- def callable_parameters(callable)
157
- return callable.parameters if callable.respond_to?(:parameters)
158
- return callable.method(:call).parameters if callable.respond_to?(:call)
159
144
 
160
- []
145
+ callable.call(controller: self, registrations:)
161
146
  end
162
147
 
163
148
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
data/benchmark/support.rb CHANGED
@@ -291,24 +291,66 @@ module SolidObjectsBenchmark
291
291
 
292
292
  # @rbs () -> void
293
293
  def query_count
294
+ turn = message_turn_query_count
295
+ caller_queries = synchronous_caller_query_count
296
+ puts "database queries: #{turn} for 1 message turn"
297
+ puts "database queries: #{caller_queries} for the caller of 1 synchronous call"
298
+ puts "database queries: #{caller_queries + turn} for 1 synchronous call, caller plus the turn it waits on"
299
+ end
300
+
301
+ private
302
+
303
+ # Runs the turn on the measuring thread, so nothing else can contribute.
304
+ # @rbs () -> Integer
305
+ def message_turn_query_count
294
306
  CounterActor.ref("queries").async(:increment)
295
307
  worker = SolidObjects::Worker.new
308
+ count_queries { worker.run_once }
309
+ ensure
310
+ worker&.stop
311
+ end
312
+
313
+ # A synchronous call also registers or heartbeats the caller process,
314
+ # claims the activation, and observes the result, so the caller costs more
315
+ # than the turn it waits on. Only the caller thread is counted; the worker
316
+ # runs on its own thread and its turn is measured separately, because a
317
+ # worker loop also polls and those polls belong to no particular call. The
318
+ # first call is discarded because it pays for activation and caller
319
+ # registration that a steady-state call does not.
320
+ # @rbs () -> Integer
321
+ def synchronous_caller_query_count
322
+ reference = CounterActor.ref("sync-queries")
323
+ worker = SolidObjects::Worker.new
324
+ runner = Thread.new { worker.run }
325
+ reference.sync(:increment)
326
+ count_queries { reference.sync(:increment) }
327
+ ensure
328
+ worker&.request_shutdown
329
+ runner&.join(5)
330
+ worker&.stop
331
+ end
332
+
333
+ # Subscriptions are process-wide and notifications run on the thread that
334
+ # issued the query, so counting is scoped to the measuring thread. Without
335
+ # that, a worker polling in the background inflates the count by however
336
+ # many times it happened to poll during the window.
337
+ # @rbs () { () -> untyped } -> Integer
338
+ def count_queries
339
+ measuring = Thread.current
296
340
  queries = 0
297
341
  subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
342
+ next unless Thread.current.equal?(measuring)
298
343
  next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
299
344
  next if event.payload[:cached]
300
345
 
301
346
  queries += 1
302
347
  end
303
- processed = worker.run_once
304
- puts "database queries: #{queries} for #{processed} message"
348
+ yield
349
+ queries
305
350
  ensure
306
351
  ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
307
- worker&.stop
308
352
  end
309
353
 
310
- private
311
-
312
354
  # @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
313
355
  def render_component(registration, view_context, snapshot: nil)
314
356
  SolidObjects::ComponentRenderer.new(
data/docs/benchmarks.md CHANGED
@@ -40,8 +40,25 @@ scenario used four worker threads.
40
40
  | Process, four workers | 556.5 messages/s |
41
41
  | Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
42
42
  | Activation reuse | 98.0%, four activations for 200 messages |
43
- | Queries for one message turn | 29 |
44
- | Queries for one synchronous call | 49 |
43
+
44
+ Query counts are a property of the code rather than the host, so they are
45
+ tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
46
+ `bundle exec ruby benchmark/query_count.rb`, which is deterministic across runs:
47
+
48
+ | Scenario | Queries |
49
+ | --- | ---: |
50
+ | One message turn | 26 |
51
+ | The caller of one synchronous call | 49 |
52
+ | One synchronous call, caller plus the turn it waits on | 75 |
53
+
54
+ A worker turn fell from the 29 recorded earlier. The caller count is unchanged
55
+ at 49, and the combined figure is new: a synchronous call is a caller and a
56
+ worker turn, and only the sum says what the database actually serves.
57
+
58
+ Counting is scoped to the measuring thread. A worker loop polls whether or not
59
+ a call is in flight, so a process-wide count folds however many polls happened
60
+ to land inside the window into the result. That is also why the caller and the
61
+ turn are measured separately rather than by watching both threads at once.
45
62
 
46
63
  A synchronous call costs far more queries than a worker turn because the caller
47
64
  also registers or heartbeats its caller process, claims the activation, and
data/docs/realtime.md CHANGED
@@ -237,9 +237,11 @@ single actor mutation changes several components, an application pays several
237
237
  round trips for one logical update. A payload broadcast collapses that into one
238
238
  message on the stream the page already has open.
239
239
 
240
- Declare the payload on the actor. The block receives the actor and the
241
- subscriber's authorization context, and it runs **once per subscriber**, so two
242
- sessions watching the same actor never see each other's private state:
240
+ Declare the payload on the actor. The block runs against the actor instance,
241
+ like every other block in the actor DSL, and receives the actor and the
242
+ subscriber's authorization context as arguments. It runs **once per
243
+ subscriber**, so two sessions watching the same actor never see each other's
244
+ private state:
243
245
 
244
246
  ```ruby
245
247
  class PlaymatRoom < SolidObjects::Actor
@@ -259,6 +261,16 @@ class PlaymatRoom < SolidObjects::Actor
259
261
  end
260
262
  ```
261
263
 
264
+ Because the block runs against the actor, an actor instance method is reachable
265
+ without a receiver, so shared logic does not have to be duplicated into the
266
+ block:
267
+
268
+ ```ruby
269
+ broadcast_payload :playmat_state do |_room, authorization|
270
+ { "turn" => turn, "hand" => hand_for(authorization.session_id) }
271
+ end
272
+ ```
273
+
262
274
  Subscribe the scope to it:
263
275
 
264
276
  ```erb
@@ -298,6 +310,26 @@ Payload blocks read committed actor state through the same snapshot components
298
310
  use. They cannot write application records, and the return value must be a JSON
299
311
  object or array so the wire format stays inspectable.
300
312
 
313
+ A payload is one subscriber's view of one name, so a failure is confined to it.
314
+ A raising block does not reject the subscription, stop the other payload names,
315
+ or stop component refreshes on the same connection. The failure is reported as
316
+ `solid_objects.payload_broadcast_failed` carrying the actor type, actor id,
317
+ payload name, and exception class. The exception message is deliberately not
318
+ included: a payload block reads subscriber state, so its message is the one
319
+ place that state could leak into logs.
320
+
321
+ A revision with a failed payload does not advance the delivery watermark, so a
322
+ transient failure is retried on the next broadcast rather than being recorded as
323
+ delivered. Retries are driven by broadcasts rather than a timer, so a payload
324
+ that fails persistently retries once per actor mutation and reports each
325
+ attempt. A repeating stream of `payload_broadcast_failed` for one `payload_name`
326
+ therefore means a persistent fault in that block, not a one-off; a single event
327
+ that does not recur was transient and has already been recovered.
328
+
329
+ A payload the subscriber cannot query is skipped rather than served partially;
330
+ that decision is stable, so it settles the revision and the skip is silent by
331
+ design.
332
+
301
333
  ### mtg-playmat before and after
302
334
 
303
335
  Before, one mutation that touched three observables produced three refresh
@@ -350,13 +382,31 @@ end
350
382
  Callbacks that accept only `controller:` continue to work; the extra keyword is
351
383
  passed only to callables that declare it.
352
384
 
353
- The three contexts are intentionally different:
385
+ Payloads have the same resolver, because they are computed inside the channel
386
+ rather than in a controller. Without one, a payload block and its
387
+ `authorize_query` call receive the Cable connection while a controller render
388
+ passes an application object, and the authorization hook has to tell them
389
+ apart. Resolve both to the same type and it does not:
390
+
391
+ ```ruby
392
+ configuration.component_authorization_context = ->(controller:) { controller.current_account }
393
+ configuration.payload_authorization_context = ->(connection:) { connection.current_account }
394
+ ```
395
+
396
+ The resolved value is what the payload block receives as its second argument and
397
+ what `authorize_query` receives as `authorization_context`. A resolver may also
398
+ accept `payload_name:` when the subject depends on which payload was requested.
399
+ The default returns the connection unchanged, so an application that has not
400
+ configured one is unaffected.
401
+
402
+ The contexts are intentionally different:
354
403
 
355
404
  | Boundary | Authorization context |
356
405
  | --- | --- |
357
406
  | Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
358
407
  | Action Cable subscription | The authenticated Cable connection |
359
408
  | Component refresh | Value returned by `component_authorization_context` for the engine controller request |
409
+ | State payload | Value returned by `payload_authorization_context` for the Cable connection |
360
410
 
361
411
  Do not substitute a signed token for any of them. Keys and locals are visible
362
412
  to the browser and signed for integrity, not encrypted or authorized. Never
@@ -393,6 +443,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
393
443
  the last transmitted pair are ignored. The durable state row remains source of
394
444
  truth.
395
445
 
446
+ Stale components that share a `batch:` are refreshed together, exactly as a
447
+ live invalidation refreshes them, so reconnecting costs one request per batch
448
+ rather than one per component. That matters most on a restart, when every
449
+ client reconnects at once.
450
+
396
451
  The component endpoint rejects a requested revision newer than the committed
397
452
  snapshot. This is a final server-side guard; browser safety primarily comes
398
453
  from monotonic channel filtering plus replace-frame detachment or morph
data/docs/roadmap.md CHANGED
@@ -13,14 +13,21 @@
13
13
  - At-least-once retries, terminal domain rejection, strict poison ordering,
14
14
  dead letters, and tail retry
15
15
  - Transactional effects with success/failure actor messages
16
- - Actor-to-actor asynchronous outbox delivery
16
+ - Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
17
+ portable status rows with polling indexes and database check constraints on
18
+ status, which works on all three adapters; a future version may add narrow
19
+ ready/claimed membership tables for very large outboxes, as messages already
20
+ have
17
21
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
18
22
  - Durable observable invalidations, scalar Turbo replacement, keyed ERB
19
23
  components, signed component locals, and authorized replace or morph refresh
20
24
  - Batched component refreshes: components sharing a signed `batch:` collapse to
21
25
  one browser request per revision, served as HTML frames in a JSON envelope
22
26
  - Personalized state payload broadcasts computed per subscriber under that
23
- subscriber's authorization context, fenced by actor revision
27
+ subscriber's authorization context, fenced by actor revision, resolved through
28
+ `payload_authorization_context` so the block and `authorize_query` see the
29
+ same subject a controller render passes, and confined so one failing payload
30
+ cannot reject the subscription or stop its siblings
24
31
  - Reconciliation read APIs
25
32
  - Installation doctor, authorization reference, fit guide, and legacy-state
26
33
  migration cookbook
@@ -35,7 +42,9 @@
35
42
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
36
43
  helper
37
44
  - Supervisor role replacement: a role whose thread dies is restarted until
38
- shutdown is requested, and dead process records are pruned on an interval
45
+ shutdown is requested, and dead process records plus expired message and
46
+ process history are pruned on their own intervals without an application
47
+ scheduling its own job
39
48
  - SQLite, PostgreSQL, and MySQL integration suites
40
49
  - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
41
50
  a listening connection per waiting thread and release on supervisor shutdown
@@ -44,51 +53,61 @@
44
53
  gem's dependencies
45
54
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
46
55
  RuboCop policy, and a warning-free Brakeman scan
47
- - Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
48
- and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
49
- rather than assumed
56
+ - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
57
+ Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
58
+ verified rather than assumed
50
59
  - A JavaScript suite covering every browser module, run in CI with Node's test
51
60
  runner and jsdom, plus a browser suite running the same modules against real
52
61
  Chromium and a real Turbo build, with every GitHub Actions reference pinned to
53
- a commit SHA
62
+ a commit SHA. The browser suite covers the reconnect burst: convergence of
63
+ batched and unbatched components, an inert replay of an applied revision,
64
+ cancellation of the request left in flight by the drop, incarnation ordering
65
+ after a destroy and recreate, and payload delivery exactly once per revision
54
66
 
55
67
  ## Partially implemented
56
68
 
57
- - Wake-up strategy: in-process signaling, durable polling, injection, and an
58
- opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
59
- not. In-process signaling cannot cross process boundaries, so without the
60
- adapter a commit in a web process does not wake a broadcast executor in a
61
- worker process and that delivery waits up to `polling_interval`, 100 ms by
62
- default. `WakeUpAdapters.for` removes that delay on PostgreSQL, measured at
63
- 103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
64
- connection per waiting thread outside the pool, and `LISTEN` does not survive
65
- a transaction-pooling proxy such as PgBouncer. MySQL has no notification
66
- primitive, so MySQL applications keep polling unless they configure the Redis
67
- adapter.
69
+ - Wake-up strategy: in-process signaling, durable polling, injection, and
70
+ cross-process adapters for PostgreSQL and Redis are implemented and tested.
71
+ What is not done is making any of them automatic. In-process signaling cannot
72
+ cross process boundaries, so by default a commit in a web process does not
73
+ wake a broadcast executor in a worker process and that delivery waits up to
74
+ `polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
75
+ to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
76
+ opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
77
+ thread outside the pool and `LISTEN` does not survive a transaction-pooling
78
+ proxy such as PgBouncer, and Redis is not a dependency of this gem.
79
+ `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
80
+ default elsewhere; it never selects Redis. An application that configures
81
+ nothing keeps polling, and MySQL applications keep polling unless they
82
+ configure Redis explicitly.
68
83
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
69
84
  morphing, personalized refresh authorization, revision fencing, coalescing,
70
85
  reconnect convergence, batched refreshes, and personalized state payloads are
71
86
  implemented; application-directed Turbo append intents are not. Batch
72
87
  coalescing happens in the browser rather than the broadcast executor, so one
73
88
  commit still sends one Action Cable message per changed observable even
74
- though it costs one browser request.
89
+ though it costs one browser request. Reconnect convergence previously
90
+ bypassed batching entirely, issuing one request per stale component at the
91
+ moment a restart reconnects every client at once; it now shares the batching
92
+ the live invalidation path uses. Payload delivery over Action Cable was
93
+ untested end to end, which is how a raising payload block came to reject the
94
+ subscription; it is now covered and confined, and the payload authorization
95
+ context is resolved through `payload_authorization_context` rather than
96
+ handing the block a raw Cable connection.
75
97
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
76
98
  distributed per-actor rate limits and global admission control do not.
77
99
  - Administration: actor and dead-letter views plus policy hooks exist; richer
78
100
  filtering, audit records, and bulk-safe tools do not.
79
- - Outboxes use portable status rows with polling indexes; future versions may
80
- introduce narrow ready/claimed membership tables for very large outboxes.
81
101
 
82
102
  ## Next milestones
83
103
 
84
104
  1. Add result lookup by request ID and broader deadlock retry classification.
85
- 2. Add scheduled retention and stale-process maintenance.
86
- 3. Add Turbo append intents and expand reconnect coverage in a full browser.
87
- 4. Add distributed rate limits, global admission hooks, and cache-capacity
105
+ 2. Add Turbo append intents.
106
+ 3. Add distributed rate limits, global admission hooks, and cache-capacity
88
107
  eviction.
89
- 5. Expand security scanning. Compatibility CI across supported Rails and Ruby
90
- versions is implemented; Ruby 4.0 is not yet in the matrix.
91
- 6. Benchmark all workloads under documented hardware/database settings and
108
+ 4. Expand security scanning beyond the Brakeman scan, such as dependency
109
+ auditing and secret scanning.
110
+ 5. Benchmark all workloads under documented hardware/database settings and
92
111
  publish adapter-specific adoption measurements. Throughput, synchronous
93
112
  latency, query counts, and the three reactive delivery paths are measured on
94
113
  SQLite; adapter-specific and end-to-end browser measurements are not.
data/exe/solid_objects CHANGED
@@ -6,4 +6,15 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
6
6
  require "solid_objects"
7
7
  require "solid_objects/cli"
8
8
 
9
- SolidObjects::CLI.start(ARGV)
9
+ begin
10
+ SolidObjects::CLI.start(ARGV)
11
+ rescue SolidObjects::Unauthorized => error
12
+ # Authorization denies by default, so this is what an unconfigured host sees
13
+ # from its first command. A policy decision is not a crash, and printing a
14
+ # backtrace for one buries the single line that says how to grant access.
15
+ warn "solid_objects: #{error.message}"
16
+ warn "Set configuration.authorize_administration in your Solid Objects " \
17
+ "initializer to allow this command. See the commented example in " \
18
+ "config/initializers/solid_objects.rb."
19
+ exit 1
20
+ end
@@ -52,6 +52,14 @@ SolidObjects.configure do |configuration|
52
52
  # Configure component_authorization_context to return the authenticated
53
53
  # principal used for reactive component refreshes.
54
54
 
55
+ # Payloads are delivered over Action Cable, so without a resolver the payload
56
+ # block and authorize_query receive the Cable connection while a controller
57
+ # render passes an application object. Resolve both to the same type and the
58
+ # authorization hook stops having to tell them apart:
59
+ #
60
+ # configuration.component_authorization_context = ->(controller:) { controller.current_account }
61
+ # configuration.payload_authorization_context = ->(connection:) { connection.current_account }
62
+
55
63
  # On hosts where shell access is already an authenticated administrative
56
64
  # boundary, this enables only gem commands that pass the CLI context:
57
65
  #
@@ -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
@@ -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.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/solid_objects.rb CHANGED
@@ -13,6 +13,7 @@ require "securerandom"
13
13
  require "solid_objects/version"
14
14
  require "solid_objects/errors"
15
15
  require "solid_objects/sync_deadline"
16
+ require "solid_objects/callable_keywords"
16
17
  require "solid_objects/configuration"
17
18
  require "solid_objects/instrumentation"
18
19
  require "solid_objects/log_subscriber"
@@ -27,14 +27,6 @@ module SolidObjects
27
27
  # @rbs (Array[ComponentRegistration]) -> untyped
28
28
  def component_authorization_context: (Array[ComponentRegistration]) -> untyped
29
29
 
30
- # A lambda answers `parameters` directly; a callable object answers it
31
- # through its `call` method.
32
- # @rbs (untyped) -> bool
33
- def accepts_registrations?: (untyped) -> bool
34
-
35
- # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
36
- def callable_parameters: (untyped) -> Array[[ Symbol, Symbol ]]
37
-
38
30
  # @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
39
31
  def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped]
40
32
 
@@ -21,6 +21,25 @@ module SolidObjects
21
21
  # @rbs (ActorSnapshot) -> void
22
22
  def transmit_state_payloads: (ActorSnapshot) -> void
23
23
 
24
+ # A payload is one subscriber's view of one name. Letting it raise through
25
+ # here would reject the subscription or abandon the rest of a broadcast, so
26
+ # a failure is confined to the payload that caused it and reported. The
27
+ # exception message is deliberately not instrumented: a payload block reads
28
+ # actor state, so its message is the one place subscriber state could leak
29
+ # into logs.
30
+ #
31
+ # Returns whether this revision was settled for the name. An unauthorized
32
+ # payload is settled: the decision is stable, so retrying it would only
33
+ # re-deliver its authorized siblings.
34
+ # @rbs (ActorSnapshot, String) -> bool
35
+ def transmit_state_payload: (ActorSnapshot, String) -> bool
36
+
37
+ # Resolves the Cable connection to whatever the application uses as an
38
+ # authorization subject, so a payload block and `authorize_query` see the
39
+ # same object a controller render would pass.
40
+ # @rbs (String) -> untyped
41
+ def payload_authorization_context: (String) -> untyped
42
+
24
43
  # @rbs (ActorSnapshot) -> bool
25
44
  def newer_payload_revision?: (ActorSnapshot) -> bool
26
45
 
@@ -0,0 +1,16 @@
1
+ # Generated from lib/solid_objects/callable_keywords.rb with RBS::Inline
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
+ # @rbs (untyped, Symbol) -> bool
9
+ def self.accepts?: (untyped, Symbol) -> bool
10
+
11
+ # A lambda answers `parameters` directly; a callable object answers it
12
+ # through its `call` method.
13
+ # @rbs (untyped) -> Array[[ Symbol, Symbol ]]
14
+ private def self.parameters: (untyped) -> Array[[ Symbol, Symbol ]]
15
+ end
16
+ end
@@ -31,6 +31,11 @@ module SolidObjects
31
31
 
32
32
  attr_reader revisions: untyped
33
33
 
34
+ # Live invalidations and reconnect replays share this, so a reconnecting
35
+ # client pays the same number of requests a connected one does.
36
+ # @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
37
+ def refresh_streams: (Array[ComponentRegistration], Integer, Integer) -> Array[String]
38
+
34
39
  # @rbs (ComponentRegistration, Integer, Integer) -> void
35
40
  def record_revision: (ComponentRegistration, Integer, Integer) -> void
36
41
 
@@ -2,10 +2,10 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
- @shutdown_timeout: Float
6
-
7
5
  @supervisor_monitor_interval: Float
8
6
 
7
+ @retention_interval: Float
8
+
9
9
  @dead_process_cleanup_interval: Float
10
10
 
11
11
  @message_retention: Numeric
@@ -40,6 +40,8 @@ module SolidObjects
40
40
 
41
41
  @component_authorization_context: Proc
42
42
 
43
+ @payload_authorization_context: Proc
44
+
43
45
  @authorize_message: Proc
44
46
 
45
47
  @authorize_query: Proc
@@ -86,6 +88,8 @@ module SolidObjects
86
88
 
87
89
  @process_alive_threshold: Float
88
90
 
91
+ @shutdown_timeout: Float
92
+
89
93
  attr_accessor table_name_prefix: untyped
90
94
 
91
95
  attr_accessor polling_interval: untyped
@@ -126,6 +130,8 @@ module SolidObjects
126
130
 
127
131
  attr_accessor supervisor_monitor_interval: untyped
128
132
 
133
+ attr_accessor retention_interval: untyped
134
+
129
135
  attr_accessor dead_process_cleanup_interval: untyped
130
136
 
131
137
  attr_accessor message_retention: untyped
@@ -160,6 +166,8 @@ module SolidObjects
160
166
 
161
167
  attr_accessor component_authorization_context: untyped
162
168
 
169
+ attr_accessor payload_authorization_context: untyped
170
+
163
171
  attr_accessor authorize_message: untyped
164
172
 
165
173
  attr_accessor authorize_query: untyped
@@ -29,6 +29,19 @@ module SolidObjects
29
29
  # @rbs (ActorDefinition::Handler) -> untyped
30
30
  def rendered_payload: (ActorDefinition::Handler) -> untyped
31
31
 
32
+ # The block runs against the actor instance, like every other block in the
33
+ # actor DSL, and still receives the actor and the resolved authorization
34
+ # context as arguments, so blocks written to the documented signature are
35
+ # unaffected.
36
+ # @rbs (ActorDefinition::Handler) -> untyped
37
+ def evaluated_payload: (ActorDefinition::Handler) -> untyped
38
+
39
+ # Distinguishes a block that relied on the old class-level receiver from an
40
+ # ordinary typo, so the one behaviour change reports itself instead of
41
+ # surfacing as an unexplained NameError.
42
+ # @rbs (NameError[untyped]) -> bool
43
+ def class_level_receiver?: (NameError[untyped]) -> bool
44
+
32
45
  # @rbs () -> void
33
46
  def authorize!: () -> void
34
47
  end
@@ -2,8 +2,12 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Supervisor
5
+ MAXIMUM_RETENTION_BACKOFF_DOUBLINGS: ::Integer
6
+
5
7
  @lifecycle: Thread::Mutex
6
8
 
9
+ @retention: Thread?
10
+
7
11
  @cleaned_up_at: Float
8
12
 
9
13
  @started: bool
@@ -52,6 +56,34 @@ module SolidObjects
52
56
  # @rbs (Thread?) -> String?
53
57
  def thread_error: (Thread?) -> String?
54
58
 
59
+ # Retention gets its own thread rather than sharing the monitor's. A large
60
+ # backlog or a lock wait can make a pass slow, and role replacement must not
61
+ # wait behind housekeeping.
62
+ # @rbs () -> void
63
+ def retention_loop: () -> void
64
+
65
+ # Sleeping the whole interval would make shutdown wait out an hour-long
66
+ # nap, so the pause is taken in short steps that notice a stop request.
67
+ # @rbs (Integer) -> void
68
+ def wait_for_next_retention: (Integer) -> void
69
+
70
+ # Every actor call writes a durable message row, so retention that is only
71
+ # configured and never run leaves those rows to grow without bound. The
72
+ # supervisor runs it rather than requiring every application to schedule
73
+ # its own job.
74
+ # @rbs () -> void
75
+ def prune_expired_records: () -> void
76
+
77
+ # A transient lock or connection error must not defer retention for the
78
+ # whole interval, so a failed pass retries at monitor cadence. The pause
79
+ # then doubles per consecutive failure, capped by the interval, so a
80
+ # database that stays down is not polled once a second forever.
81
+ # @rbs (Integer) -> Float
82
+ def retention_pause: (Integer) -> Float
83
+
84
+ # @rbs () -> void
85
+ def stop_retention: () -> void
86
+
55
87
  # @rbs () -> void
56
88
  def cleanup_dead_processes: () -> void
57
89
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
@@ -354,6 +354,7 @@ files:
354
354
  - lib/solid_objects/application_actor_loader.rb
355
355
  - lib/solid_objects/application_write_guard.rb
356
356
  - lib/solid_objects/broadcast_executor.rb
357
+ - lib/solid_objects/callable_keywords.rb
357
358
  - lib/solid_objects/caller_process.rb
358
359
  - lib/solid_objects/cli.rb
359
360
  - lib/solid_objects/client.rb
@@ -428,6 +429,7 @@ files:
428
429
  - sig/generated/lib/solid_objects/application_actor_loader.rbs
429
430
  - sig/generated/lib/solid_objects/application_write_guard.rbs
430
431
  - sig/generated/lib/solid_objects/broadcast_executor.rbs
432
+ - sig/generated/lib/solid_objects/callable_keywords.rbs
431
433
  - sig/generated/lib/solid_objects/caller_process.rbs
432
434
  - sig/generated/lib/solid_objects/cli.rbs
433
435
  - sig/generated/lib/solid_objects/client.rbs