solid_objects 0.14.2 → 0.14.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.
@@ -0,0 +1,5 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "support"
4
+
5
+ SolidObjectsBenchmark.state_size
data/benchmark/support.rb CHANGED
@@ -7,6 +7,25 @@ require "solid_objects"
7
7
 
8
8
  module SolidObjectsBenchmark
9
9
  DATABASE_PATH = File.expand_path("../tmp/solid_objects_benchmark.sqlite3", __dir__)
10
+ STATE_SIZES = [ 0, 16 * 1_024, 128 * 1_024, 1_024 * 1_024 ].freeze
11
+ STATE_ENTRY_BYTES = 26
12
+
13
+ class StateSizeActor < SolidObjects::Actor
14
+ actor_type "benchmark-state-size"
15
+
16
+ attribute :count, default: 0
17
+ attribute :filler, default: -> { {} }
18
+
19
+ def fill(size:)
20
+ entries = size / STATE_ENTRY_BYTES
21
+ self.filler = Array.new(entries) { |index| [ "key-#{index}", "value-#{index}" ] }.to_h
22
+ filler.length
23
+ end
24
+
25
+ def increment
26
+ self.count = count + 1
27
+ end
28
+ end
10
29
 
11
30
  class CounterActor < SolidObjects::Actor
12
31
  actor_type "benchmark-counter"
@@ -289,6 +308,12 @@ module SolidObjectsBenchmark
289
308
  )
290
309
  end
291
310
 
311
+ # @rbs () -> void
312
+ def state_size
313
+ warm_up_state_size
314
+ STATE_SIZES.each { |size| measure_state_size(size) }
315
+ end
316
+
292
317
  # @rbs () -> void
293
318
  def query_count
294
319
  turn = message_turn_query_count
@@ -433,6 +458,44 @@ module SolidObjectsBenchmark
433
458
  count.times { |index| references[index % actor_count].async.increment }
434
459
  end
435
460
 
461
+ # @rbs () -> void
462
+ def warm_up_state_size
463
+ reference = StateSizeActor.ref("state-size-warm-up")
464
+ reference.fill(size: 0)
465
+ count.times { reference.async.increment }
466
+ worker = SolidObjects::Worker.new
467
+ drain(worker)
468
+ ensure
469
+ worker&.stop
470
+ end
471
+
472
+ # @rbs (Integer) -> void
473
+ def measure_state_size(size)
474
+ silence_large_state_warning
475
+ actor_id = "state-size-#{size}"
476
+ reference = StateSizeActor.ref(actor_id)
477
+ reference.fill(size:)
478
+ state_bytes = committed_state_bytes(actor_id)
479
+ count.times { reference.async.increment }
480
+ worker = SolidObjects::Worker.new
481
+ measure("process #{count} messages with #{state_bytes} bytes of state") { drain(worker) }
482
+ ensure
483
+ worker&.stop
484
+ end
485
+
486
+ # @rbs () -> void
487
+ def silence_large_state_warning
488
+ return unless SolidObjects.configuration.respond_to?(:warn_state_bytes=)
489
+
490
+ SolidObjects.configuration.warn_state_bytes = SolidObjects.configuration.max_state_bytes
491
+ end
492
+
493
+ # @rbs (String) -> Integer
494
+ def committed_state_bytes(actor_id)
495
+ instance = SolidObjects::Instance.find_by!(actor_type: "benchmark-state-size", actor_id:)
496
+ JSON.generate(instance.state).bytesize
497
+ end
498
+
436
499
  # @rbs (SolidObjects::Worker) -> Integer
437
500
  def drain(worker)
438
501
  processed = 0
@@ -13,7 +13,7 @@ Mailbox delivery is at least once. State mutation, message completion, result pe
13
13
 
14
14
  Actor code receives message ID, request ID, attempt, enqueue time, and idempotency key. Documentation requires idempotency for effects outside the actor commit.
15
15
 
16
- Message handlers themselves can run more than once. Sequential execution means one valid activation runs one turn at a time; it does not mean a handler runs once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency.
16
+ Message handlers themselves can run more than once. Sequential execution means that one valid activation runs one turn at a time. It does not guarantee that a handler runs only once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency.
17
17
 
18
18
  ## Consequences
19
19
 
data/docs/architecture.md CHANGED
@@ -7,8 +7,9 @@ It is a database-backed virtual actor runtime for MySQL, PostgreSQL, and
7
7
  SQLite. A virtual actor is a logical object addressed by type and ID whose
8
8
  in-memory activation is created on demand, processes one mailbox turn at a
9
9
  time, persists JSON state, and can disappear when idle without losing its
10
- identity or state. This ports the programming model, not Cloudflare's
11
- serverless runtime, global placement, storage API, or platform guarantees.
10
+ identity or state. This gem ports the programming model. It does not port
11
+ Cloudflare's serverless runtime, global placement, storage API, or platform
12
+ guarantees.
12
13
 
13
14
  The runtime contract is:
14
15
 
@@ -128,8 +129,8 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
128
129
  The broadcast worker claims committed observable-change rows, renders
129
130
  idempotent scalar Turbo replacements with component invalidation metadata,
130
131
  broadcasts to a signed actor stream, and records delivery. It never renders
131
- personalized component HTML. Current actor state remains the reconnect and
132
- request-time component source of truth.
132
+ personalized component HTML. On reconnect, and on a request-time component
133
+ render, Solid Objects reads the current actor state.
133
134
 
134
135
  ### Process registry
135
136
 
@@ -482,7 +483,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON
482
483
  schedule(at: 30.minutes.from_now).expire
483
484
  ```
484
485
 
485
- Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
486
+ Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, so re-arming is safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
486
487
 
487
488
  When due, the scheduler locks the source instance and creates a normal mailbox
488
489
  row with an idempotency key derived from reminder ID and occurrence. The
@@ -698,7 +699,7 @@ Backoff and a retry limit prevent tight loops. The poison message blocks its act
698
699
 
699
700
  ### Handler redelivery
700
701
 
701
- Sequential processing does not mean single execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:
702
+ Ordered processing does not prevent repeated execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:
702
703
 
703
704
  ```ruby
704
705
  def launch
@@ -1,9 +1,9 @@
1
1
  # Authorization policies
2
2
 
3
- Solid Objects treats actor identities as identifiers, never capabilities.
4
- Knowing an actor ID, message ID, or signed stream token grants no permission.
5
- All five policies deny by default, so a generated installation is
6
- intentionally inert until the host application defines its trust boundary.
3
+ Solid Objects treats actor identities as identifiers. They are not
4
+ capabilities. An actor ID, a message ID, or a signed stream token grants no
5
+ permission. All five policies deny by default, so a generated installation
6
+ answers nothing until the host application defines its trust boundary.
7
7
 
8
8
  ## Policy reference
9
9
 
data/docs/benchmarks.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Performance and storage costs
2
2
 
3
- These numbers are development measurements, not universal capacity guarantees.
4
- They include the runtime's Active Record and database query overhead and will
5
- vary with hardware, schema size, connection pools, durability settings, and
3
+ These numbers are development measurements. They do not guarantee capacity.
4
+ They include the runtime's Active Record and database query overhead, and they
5
+ change with hardware, schema size, connection pools, durability settings, and
6
6
  contention.
7
7
 
8
8
  ## Idle SQLite polling
@@ -109,6 +109,51 @@ result is why Solid Objects does not publish one latency promise. Network
109
109
  topology, adapter behavior, host schema, logging, callbacks, and contention all
110
110
  matter.
111
111
 
112
+ ## State size and committed throughput
113
+
114
+ A turn commits the whole state image. It copies the state, encodes it, and
115
+ writes the row, so every message pays for the size of the state its actor
116
+ keeps. Run the scenario with:
117
+
118
+ ```bash
119
+ COUNT=300 bundle exec ruby -Ilib benchmark/state_size.rb
120
+ ```
121
+
122
+ Measured 2026-08-29 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
123
+ and SQLite 3.53.2. One hot actor received 300 messages at each state size. Each
124
+ figure is the median of five runs, and the two trees ran one after the other in
125
+ each round. The state holds many small entries, because a copy visits every
126
+ node, and one long string of the same length costs much less. The harness sets
127
+ `warn_state_bytes` to the hard limit, so neither tree pays for an event that
128
+ only one of them can emit.
129
+
130
+ | Committed state | Before | After | Change |
131
+ | ---: | ---: | ---: | ---: |
132
+ | 23 bytes | 1,268.0 messages/s | 1,252.5 messages/s | -1.2% |
133
+ | 13,662 bytes | 624.1 messages/s | 654.1 messages/s | +4.8% |
134
+ | 118,786 bytes | 169.3 messages/s | 174.6 messages/s | +3.1% |
135
+ | 1,026,356 bytes | 21.6 messages/s | 23.8 messages/s | +10.2% |
136
+
137
+ The "before" tree copied the whole state three times per committed turn and
138
+ encoded a string that it discarded whenever the caller gave no byte limit. The
139
+ "after" tree copies it twice and encodes only where a limit applies. It also
140
+ checks the encoding of every string it normalizes, which the discarded encoding
141
+ used to do, so part of the saving pays for that check. The gain grows with the
142
+ state, because the database write dominates a small turn. The empty-state row
143
+ sits inside run-to-run variance.
144
+
145
+ The curve matters more than the change. Throughput falls about 28 times between
146
+ 13 KB and 1 MB of state, and about 53 times between an empty state and 1 MB.
147
+ The `max_state_bytes` default of 5 MB is therefore a limit rather than an
148
+ operating point. `warn_state_bytes` defaults to 64 KB, and each commit above it
149
+ reports `solid_objects.state.large`. The Node package carries the same setting
150
+ as `warnStateBytes` and defaults it to 128 KB, because its measured curve falls
151
+ later: it keeps 98% of its empty-state throughput at 16 KB, where this gem
152
+ keeps 52% at 13 KB.
153
+
154
+ These are developer-laptop numbers on one adapter. They show shape and ratio,
155
+ not a capacity guarantee.
156
+
112
157
  ## Reactive delivery paths
113
158
 
114
159
  Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation
data/docs/correctness.md CHANGED
@@ -78,8 +78,8 @@ Destruction is synchronous, forbidden from actor context, authorized by
78
78
 
79
79
  ## Handler idempotency
80
80
 
81
- Sequential does not mean once. A message such as `launch` still needs a durable
82
- guard:
81
+ Ordered execution does not prevent repeated execution. A message such as
82
+ `launch` still needs a durable guard:
83
83
 
84
84
  ```ruby
85
85
  def launch
data/docs/fit.md CHANGED
@@ -24,7 +24,13 @@ Solid Objects is a good candidate when most of these are true:
24
24
  asynchronous features, a Solid Objects runtime process.
25
25
 
26
26
  Typical fits include checkout state machines, collaborative rooms, device
27
- twins, durable assessments, approval workflows, and user-specific scheduling.
27
+ twins, durable assessments, approval workflows, user-specific scheduling, and
28
+ low-rate quotas that a reminder refills.
29
+
30
+ A workflow fits when one entity owns the mutable state and its mailbox holds
31
+ the step order. A durable execution engine that replays named steps from a step
32
+ log is a different tool: Solid Objects redelivers an ordered message and
33
+ retries it, and does not replay a handler from a step log.
28
34
 
29
35
  ## Poor fit and anti-patterns
30
36
 
@@ -46,10 +52,21 @@ when any of these dominate:
46
52
  - State that is clearer as a normal record with database constraints and direct
47
53
  service methods.
48
54
 
49
- A rate limiter is usually a poor actor: it is hot, request-critical, and often
50
- expires rather than requiring permanent message history. An impressions
51
- pipeline is also a poor actor: its value is high-throughput append and
52
- aggregation, not serialized mutable state.
55
+ A request-path rate limiter is usually a poor actor: it is hot,
56
+ request-critical, and often expires rather than requiring permanent message
57
+ history. A low-rate quota is the case that does fit, such as five password
58
+ resets an hour for one account, where a reminder refills the bucket and each
59
+ check is one durable ordered message. An impressions pipeline is also a poor
60
+ actor, because its value comes from high-throughput append and aggregation. It
61
+ does not need serialized mutable state.
62
+
63
+ [Solid Objects Pro](https://solidobjects.pro/) is the commercial scaling layer
64
+ for the high-QPS cases in this section. Grouped operations coalesce concurrent
65
+ calls into one bulk insert. Ephemeral operations hold a loss-tolerant call in
66
+ process memory and write no journal row, which is the mode for an abuse limiter,
67
+ a presence signal, or a view count. Reactive projections materialize a read
68
+ model from the durable broadcast outbox, so request-path reads stop competing
69
+ with mailbox work.
53
70
 
54
71
  ## Cost model
55
72
 
@@ -94,5 +111,5 @@ Before adopting an actor, answer:
94
111
  10. How will existing state be cut over and rolled back?
95
112
 
96
113
  Benchmark the actual host database and deployment topology before committing a
97
- latency-sensitive surface. Local benchmark results are evidence about query
98
- shape, not universal capacity guarantees.
114
+ latency-sensitive surface. Local benchmark results show query shape. They do
115
+ not guarantee capacity.
@@ -24,9 +24,9 @@ SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:54
24
24
  bundle exec rake test
25
25
  ```
26
26
 
27
- Running this locally is worth the setup: it is what caught the PostgreSQL
28
- version comparison reading a packed integer, where `170010` compared greater
29
- than any minimum and made the check useless on the adapter it mattered most for.
27
+ The setup is worth the effort. This local suite caught a PostgreSQL version
28
+ comparison that read a packed integer, where `170010` compared greater than any
29
+ minimum and made the check useless on the adapter that needed it most.
30
30
 
31
31
  ## MySQL and Redis in Docker
32
32
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Moving an existing Redis, cache, or key-value state machine into Solid Objects
4
4
  is a data migration and a coordination cutover. Treat it as a staged production
5
- change, not a rewrite that switches storage in one deploy.
5
+ change. Do not switch the storage in one deploy.
6
6
 
7
7
  ## 1. Write down the existing contract
8
8
 
data/docs/operations.md CHANGED
@@ -143,9 +143,9 @@ loader participates in Rails preparation callbacks so a development reload can
143
143
  replace a registered actor class without loading unrelated application code.
144
144
 
145
145
  An actor registers itself as its class loads, and a web process resolves
146
- actors by name for Cable subscriptions and component renders. Loading them in
147
- every process is what lets a freshly booted web process serve a live card for
148
- an actor no request in that process has rendered yet.
146
+ actors by name for Cable subscriptions and component renders. Solid Objects
147
+ loads the actors in every process, so a freshly booted web process can serve a
148
+ live card for an actor that no request in that process has rendered yet.
149
149
 
150
150
  Worker and outbox counts can be overridden on the command line:
151
151
 
@@ -200,6 +200,7 @@ end
200
200
  | `max_messages_per_activation_pass` | 50 |
201
201
  | `max_activation_duration` | 5 seconds |
202
202
  | `max_mailbox_length` | 10,000 |
203
+ | `warn_state_bytes` | 64 KB |
203
204
  | `max_attempts` | 5 |
204
205
  | `process_heartbeat_interval` | 15 seconds |
205
206
  | `process_alive_threshold` | 60 seconds |
@@ -217,6 +218,15 @@ Payload, state, and result byte limits; retry delay; table prefix; logging;
217
218
  wake-up; broadcast; database; and authorization adapters are also configurable.
218
219
  Invalid lease intervals, component counts, and size limits fail fast at boot.
219
220
 
221
+ `max_state_bytes` defaults to 5 MB, which is a limit rather than an operating
222
+ point. A turn copies the whole state, encodes it, and writes the row, so
223
+ committed throughput falls long before that limit: measured on SQLite, about 28
224
+ times between 13 KB and 1 MB of state. See `docs/benchmarks.md` for the curve.
225
+ `warn_state_bytes` is the soft threshold, and it must not exceed
226
+ `max_state_bytes`. Each commit above it reports `solid_objects.state.large` and
227
+ nothing else changes, so an application that already keeps a large state keeps
228
+ working while its operator learns the cost.
229
+
220
230
  Keep lease duration comfortably above renewal interval and expected database
221
231
  pause time. A handler can exceed the pass-duration budget because Ruby code is
222
232
  not safely preempted; alert on message duration and isolate untrusted work.
@@ -278,7 +288,7 @@ Use:
278
288
 
279
289
  Spread large repairs with `available_at:`. Report at least bootstrapped,
280
290
  reconfigured, revived, suspended, and orphaned counts. A nonzero revived count
281
- is evidence that alarms are being lost.
291
+ shows that alarms are lost.
282
292
 
283
293
  Never bulk-update actor state. That bypasses lease ownership and fencing.
284
294
 
@@ -308,6 +318,7 @@ Alert on:
308
318
  - ready and claimed membership counts;
309
319
  - mailbox-full rejections;
310
320
  - actor turn duration and failures;
321
+ - committed state above `warn_state_bytes`;
311
322
  - lost-activation rate;
312
323
  - dead-letter creation;
313
324
  - actor destruction rate;
@@ -344,6 +355,23 @@ per queued item keeps only the last, and the earlier wake-up never happens.
344
355
  Watch this event if your actors schedule from a loop or from a handler that can
345
356
  run more than once. Rescheduling to the same time reports nothing.
346
357
 
358
+ `solid_objects.state.large` reports a committed turn whose state exceeded
359
+ `warn_state_bytes`. The payload carries the actor identity, the `byte_count`
360
+ the commit wrote, and the `threshold_bytes` it passed. It never carries the
361
+ state. The event reports after the commit, so a turn that rolled back reports
362
+ nothing. Every commit above the threshold reports, including a synchronous
363
+ query and a turn that changed nothing, so a hot actor reports once per message.
364
+ Aggregate by actor rather than alert on each event, and watch the reported size
365
+ rather than the event rate: a state that grows without a bound is what this
366
+ event exists to find. `solid-objects-js` emits the same event under the same
367
+ name and payload.
368
+
369
+ `solid_objects.instrumentation.failed` reports a subscriber that raised while
370
+ the runtime reported a committed turn. The payload carries the
371
+ `instrumentation_event` that failed and the `error_class`. The turn itself is
372
+ unaffected, because it already committed. Watch this event to find a broken
373
+ subscriber, which would otherwise be silent.
374
+
347
375
  `solid_objects.component.refreshed` covers every authorized component refresh
348
376
  request. Its payload carries the actor identity, `component_name`,
349
377
  `component_key`, declared `dependencies`, `refresh_method`, the rendered
data/docs/realtime.md CHANGED
@@ -421,8 +421,8 @@ configuration.component_authorization_context = ->(controller:) { controller.cur
421
421
  configuration.payload_authorization_context = ->(connection:) { connection.current_account }
422
422
  ```
423
423
 
424
- The resolved value is what the payload block receives as its second argument and
425
- what `authorize_query` receives as `authorization_context`. A resolver may also
424
+ The payload block receives the resolved value as its second argument, and
425
+ `authorize_query` receives it as `authorization_context`. A resolver may also
426
426
  accept `payload_name:` when the subject depends on which payload was requested.
427
427
  The default returns the connection unchanged, so an application that has not
428
428
  configured one is unaffected.
data/docs/reminders.md CHANGED
@@ -22,9 +22,9 @@ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
22
22
  already armed **moves the existing alarm** rather than adding a second one. The
23
23
  database enforces this with a unique index on `(instance_id, name)`.
24
24
 
25
- This is the same model as Orleans reminders and Durable Objects alarms, and it
26
- is what makes a reminder safe to re-arm from a handler that may run more than
27
- once. Without a key the name is the operation, so this is a data-loss bug:
25
+ This is the same model as Orleans reminders and Durable Objects alarms. It
26
+ makes a reminder safe to re-arm from a handler that may run more than once.
27
+ Without a key the name is the operation, so this is a data-loss bug:
28
28
 
29
29
  ```ruby
30
30
  # Wrong. Every entry overwrites the previous entry's alarm.
@@ -51,9 +51,9 @@ end
51
51
  ```
52
52
 
53
53
  Two entries now leave two reminders. Scheduling the same key again moves that
54
- item's alarm and leaves the others alone, which is what makes a keyed reminder
55
- as safe to re-arm as an unkeyed one. The operation still decides which handler
56
- runs; the key only decides which alarm is which.
54
+ item's alarm and leaves the others alone, so a keyed reminder is as safe to
55
+ re-arm as an unkeyed one. The operation still decides which handler runs; the
56
+ key only decides which alarm is which.
57
57
 
58
58
  A key must be non-empty, and the name it becomes must fit the 191-character
59
59
  column, which is checked on the composed name rather than the key alone so a
@@ -472,7 +472,7 @@ The current `cardmagic/classifier` source uses RBS::Inline directly in Ruby file
472
472
  - [`.github/workflows/ruby.yml`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/.github/workflows/ruby.yml) generates signatures, validates them with RBS, and runs Steep.
473
473
  - [`Steepfile`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/Steepfile) enables strict diagnostics for the typed library while explicitly isolating incompatible extension files.
474
474
 
475
- Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. Generated signatures are checked rather than hand-maintained as a competing source of truth.
475
+ Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. The build checks the generated signatures. Nobody maintains them by hand as a second authority.
476
476
 
477
477
  ## Related primary-source findings
478
478
 
data/docs/roadmap.md CHANGED
@@ -134,7 +134,15 @@
134
134
  loads them in every process, and a rejected subscription reports which
135
135
  condition caused it instead of closing the socket silently.
136
136
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
137
- distributed per-actor rate limits and global admission control do not.
137
+ distributed per-actor rate limits and global admission control do not. The
138
+ state cap is a limit rather than an operating point. `max_state_bytes`
139
+ defaults to 5 MB, and committed throughput measured on SQLite falls about 53
140
+ times between an empty state and 1 MB of state, which `docs/benchmarks.md`
141
+ records. A soft `warn_state_bytes` threshold, 64 KB by default, now
142
+ reports each commit above it as `solid_objects.state.large`. The hard default
143
+ stays at 5 MB, because lowering it would break an application whose actors
144
+ already exceed a lower value; a major release can lower it from the measured
145
+ curve.
138
146
  - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
139
147
  instances, mailbox, reminders, effects, broadcasts, dead letters, and
140
148
  processes, with actor-type and actor-id filtering, status filters, paging, a
@@ -15,6 +15,7 @@ module SolidObjects
15
15
  # @rbs @claim_scan_limit: Integer
16
16
  # @rbs @max_payload_bytes: Integer
17
17
  # @rbs @max_state_bytes: Integer
18
+ # @rbs @warn_state_bytes: Integer
18
19
  # @rbs @max_result_bytes: Integer
19
20
  # @rbs @max_attempts: Integer
20
21
  # @rbs @retry_delay: Proc
@@ -63,6 +64,7 @@ module SolidObjects
63
64
  :claim_scan_limit,
64
65
  :max_payload_bytes,
65
66
  :max_state_bytes,
67
+ :warn_state_bytes,
66
68
  :max_result_bytes,
67
69
  :max_attempts,
68
70
  :retry_delay,
@@ -116,6 +118,7 @@ module SolidObjects
116
118
  @claim_scan_limit = 100
117
119
  @max_payload_bytes = 1.megabyte
118
120
  @max_state_bytes = 5.megabytes
121
+ @warn_state_bytes = 64.kilobytes
119
122
  @max_result_bytes = 1.megabyte
120
123
  @max_attempts = 5
121
124
  @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
@@ -223,6 +226,9 @@ module SolidObjects
223
226
  positive_values.each do |name, value|
224
227
  raise ArgumentError, "#{name} must be positive" unless value.positive?
225
228
  end
229
+ if warn_state_bytes > max_state_bytes
230
+ raise ArgumentError, "warn_state_bytes must not exceed max_state_bytes"
231
+ end
226
232
  message_retention_by_actor_type.each do |actor_type, retention|
227
233
  raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
228
234
  raise ArgumentError, "message retention must be positive" unless retention.positive?
@@ -260,6 +266,7 @@ module SolidObjects
260
266
  claim_scan_limit:,
261
267
  max_payload_bytes:,
262
268
  max_state_bytes:,
269
+ warn_state_bytes:,
263
270
  max_result_bytes:,
264
271
  max_attempts:,
265
272
  process_heartbeat_interval:,
@@ -25,9 +25,15 @@ module SolidObjects
25
25
 
26
26
  SolidObjects.instrument(:"message.started", **instrumentation_payload)
27
27
  result = invoke_actor(message_context)
28
- ensure_query_did_not_mutate_state!(state_before)
29
28
  observable_changes = changed_observables(observables_before, actor.observable_values)
30
- complete(result, observable_changes, state_changed: actor.state.to_h != state_before)
29
+ state_after = actor.state.to_h
30
+ ensure_query_did_not_mutate_state!(state_before, state_after)
31
+ complete(
32
+ result,
33
+ observable_changes,
34
+ state_after:,
35
+ state_changed: state_after != state_before
36
+ )
31
37
  true
32
38
  rescue LostActivation
33
39
  raise
@@ -59,11 +65,11 @@ module SolidObjects
59
65
  end
60
66
  end
61
67
 
62
- # @rbs (Hash[String, untyped]) -> void
63
- def ensure_query_did_not_mutate_state!(state_before)
68
+ # @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
69
+ def ensure_query_did_not_mutate_state!(state_before, state_after)
64
70
  return unless message.delivery_mode == "sync"
65
71
  return unless actor.class.definition.queries.key?(message.operation.to_sym)
66
- return if actor.state.to_h == state_before
72
+ return if state_after == state_before
67
73
 
68
74
  raise InvalidActor, "query #{message.operation.inspect} mutated actor state"
69
75
  end
@@ -75,10 +81,10 @@ module SolidObjects
75
81
  end
76
82
  end
77
83
 
78
- # @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
79
- def complete(result, observable_changes, state_changed:)
80
- serialized_state = Serialization.dump(
81
- actor.state.to_h,
84
+ # @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
85
+ def complete(result, observable_changes, state_after:, state_changed:)
86
+ dumped_state = Serialization.dump_with_byte_size(
87
+ state_after,
82
88
  max_bytes: SolidObjects.configuration.max_state_bytes
83
89
  )
84
90
  serialized_result = Serialization.dump(
@@ -102,7 +108,7 @@ module SolidObjects
102
108
  locked_message = Message.lock.find(message.id)
103
109
  execute_commit_actions(commit_action_intents)
104
110
  instance.update!(
105
- state: serialized_state,
111
+ state: dumped_state.value,
106
112
  state_version: actor.class.state_version,
107
113
  state_revision: locked_message.sequence,
108
114
  last_used_at: SolidObjects.database_adapter.database_now
@@ -129,17 +135,17 @@ module SolidObjects
129
135
  end
130
136
 
131
137
  observable_changes.each_key do |observable_name|
132
- SolidObjects.instrument(
138
+ SolidObjects.instrument_after_commit(
133
139
  :"broadcast.enqueued",
134
140
  **instrumentation_payload,
135
141
  observable_name:
136
142
  )
137
143
  end
138
144
  moved_reminders.each do |moved|
139
- SolidObjects.instrument(:"reminder.replaced", **moved)
145
+ SolidObjects.instrument_after_commit(:"reminder.replaced", **moved)
140
146
  end
141
147
  enqueued_effects.each do |effect|
142
- SolidObjects.instrument(
148
+ SolidObjects.instrument_after_commit(
143
149
  :"effect.enqueued",
144
150
  effect_id: effect.effect_id,
145
151
  effect_name: effect.name,
@@ -148,10 +154,25 @@ module SolidObjects
148
154
  actor_id: message.actor_id
149
155
  )
150
156
  end
151
- SolidObjects.instrument(:"message.completed", **instrumentation_payload)
157
+ report_large_state(dumped_state.byte_size)
158
+ SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
152
159
  SolidObjects.wake_up.signal
153
160
  end
154
161
 
162
+ # @rbs (Integer) -> void
163
+ def report_large_state(byte_count)
164
+ threshold = SolidObjects.configuration.warn_state_bytes
165
+ return if byte_count <= threshold
166
+
167
+ SolidObjects.instrument_after_commit(
168
+ :"state.large",
169
+ actor_type: message.actor_type,
170
+ actor_id: message.actor_id,
171
+ byte_count:,
172
+ threshold_bytes: threshold
173
+ )
174
+ end
175
+
155
176
  # @rbs (Array[Actor::CommitActionIntent]) -> void
156
177
  def execute_commit_actions(intents)
157
178
  ensure_application_database_is_shared! if intents.any?
@@ -352,7 +373,7 @@ module SolidObjects
352
373
  end
353
374
  end
354
375
 
355
- SolidObjects.instrument(
376
+ SolidObjects.instrument_after_commit(
356
377
  :"message.failed",
357
378
  **instrumentation_payload,
358
379
  error_class: error.class.name,
@@ -387,7 +408,7 @@ module SolidObjects
387
408
  claimed_message.destroy!
388
409
  end
389
410
 
390
- SolidObjects.instrument(
411
+ SolidObjects.instrument_after_commit(
391
412
  :"message.rejected",
392
413
  **instrumentation_payload,
393
414
  code: rejection.code
@@ -6,5 +6,38 @@ module SolidObjects
6
6
  def instrument(event, **payload, &block)
7
7
  ActiveSupport::Notifications.instrument("solid_objects.#{event}", payload, &block)
8
8
  end
9
+
10
+ # @rbs (Symbol, **untyped) -> void
11
+ def instrument_after_commit(event, **payload)
12
+ instrument(event, **payload)
13
+ rescue => error
14
+ report_instrumentation_failure(event, error)
15
+ end
16
+
17
+ private
18
+
19
+ # @rbs (Symbol, Exception) -> void
20
+ def report_instrumentation_failure(event, error)
21
+ instrument(
22
+ :"instrumentation.failed",
23
+ instrumentation_event: "solid_objects.#{event}",
24
+ error_class: error.class.name
25
+ )
26
+ rescue => failure
27
+ log_instrumentation_failure(event, failure)
28
+ end
29
+
30
+ # @rbs (Symbol, Exception) -> void
31
+ def log_instrumentation_failure(event, error)
32
+ SolidObjects.configuration.logger.error(
33
+ {
34
+ event: "solid_objects.instrumentation.failed",
35
+ instrumentation_event: "solid_objects.#{event}",
36
+ error_class: error.class.name
37
+ }
38
+ )
39
+ rescue
40
+ nil
41
+ end
9
42
  end
10
43
  end