solid_objects 0.15.2 → 0.16.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +1 -1
  4. data/app/models/solid_objects/administration_event.rb +7 -0
  5. data/app/models/solid_objects/redrive.rb +7 -0
  6. data/benchmark/support.rb +2 -8
  7. data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
  8. data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
  9. data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
  10. data/docs/adr/0011-wake-up-strategy.md +11 -1
  11. data/docs/architecture.md +41 -5
  12. data/docs/dashboard.md +10 -0
  13. data/docs/fit.md +6 -0
  14. data/docs/operations.md +153 -7
  15. data/docs/realtime.md +32 -26
  16. data/docs/roadmap.md +60 -32
  17. data/examples/at_least_once/boot.rb +2 -8
  18. data/lib/solid_objects/activation.rb +13 -6
  19. data/lib/solid_objects/actor.rb +134 -8
  20. data/lib/solid_objects/actor_snapshot.rb +8 -5
  21. data/lib/solid_objects/administration_audit.rb +30 -0
  22. data/lib/solid_objects/client.rb +81 -0
  23. data/lib/solid_objects/configuration.rb +47 -2
  24. data/lib/solid_objects/context.rb +10 -4
  25. data/lib/solid_objects/dead_letter_manager.rb +42 -6
  26. data/lib/solid_objects/dead_letter_scope.rb +143 -0
  27. data/lib/solid_objects/dead_row.rb +15 -0
  28. data/lib/solid_objects/doctor.rb +24 -5
  29. data/lib/solid_objects/errors.rb +11 -0
  30. data/lib/solid_objects/executor.rb +41 -3
  31. data/lib/solid_objects/message_reference.rb +29 -9
  32. data/lib/solid_objects/outcome.rb +31 -0
  33. data/lib/solid_objects/process_registry.rb +3 -1
  34. data/lib/solid_objects/redrive_manager.rb +130 -0
  35. data/lib/solid_objects/redrive_runner.rb +60 -0
  36. data/lib/solid_objects/redrive_task.rb +14 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/schema_bootstrap.rb +27 -0
  39. data/lib/solid_objects/supervisor.rb +43 -0
  40. data/lib/solid_objects/test_helper.rb +2 -0
  41. data/lib/solid_objects/version.rb +1 -1
  42. data/lib/solid_objects/wake_up.rb +12 -0
  43. data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
  44. data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
  45. data/lib/solid_objects/wake_up_adapters.rb +206 -11
  46. data/lib/solid_objects/wake_up_capability.rb +15 -0
  47. data/lib/solid_objects.rb +46 -3
  48. data/sig/generated/lib/solid_objects/activation.rbs +7 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +93 -2
  50. data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
  51. data/sig/generated/lib/solid_objects/client.rbs +12 -0
  52. data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
  53. data/sig/generated/lib/solid_objects/context.rbs +11 -6
  54. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
  56. data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
  57. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +8 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +9 -0
  60. data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
  61. data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
  62. data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
  63. data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
  64. data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
  65. data/sig/generated/lib/solid_objects/reference.rbs +5 -2
  66. data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
  67. data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
  68. data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
  69. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
  70. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
  71. data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
  72. data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
  73. data/sig/generated/lib/solid_objects.rbs +14 -2
  74. data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
  75. data/sig/generated/models/solid_objects/redrive.rbs +6 -0
  76. data/sig/public/reminder_payload.rbs +3 -0
  77. metadata +28 -2
data/docs/realtime.md CHANGED
@@ -140,46 +140,52 @@ explicitly serve the module. Turbo's normal morph rules still apply; use
140
140
 
141
141
  ## Cross-process wake-up
142
142
 
143
- Runtime roles poll for work and are woken early by an in-process signal. That
144
- signal cannot cross process boundaries, so a commit in a Puma process does not
145
- wake a broadcast executor in a worker process, and delivery waits out
143
+ Runtime roles poll for work and are woken early by a signal. An in-process
144
+ signal cannot cross process boundaries, so a commit in a Puma process would not
145
+ wake a broadcast executor in a worker process, and delivery would wait out
146
146
  `polling_interval`, 100 ms by default.
147
147
 
148
- On PostgreSQL, install the notification adapter to remove that delay:
148
+ Solid Objects selects the adapter for you. `wake_up_adapter` defaults to
149
+ `:automatic`, which prefers a configured Redis URL, then PostgreSQL
150
+ notifications, then polling:
149
151
 
150
152
  ```ruby
151
153
  # config/initializers/solid_objects.rb
152
- configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
154
+ configuration.wake_up_adapter = :automatic # the default
155
+ configuration.wake_up_adapter = :in_process # opt out
156
+ configuration.wake_up_adapter = :postgresql # force one
157
+ configuration.wake_up_adapter = MyAdapter.new # your own
153
158
  ```
154
159
 
155
- `WakeUpAdapters.for` returns notifications on PostgreSQL and the in-process
156
- default on SQLite and MySQL, so the same line is safe across adapters. Name
157
- `SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.
158
-
159
- MySQL has no notification primitive. MySQL applications either keep polling and
160
- tune `polling_interval`, or configure the Redis adapter:
161
-
162
- ```ruby
163
- configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
164
- url: ENV["REDIS_URL"]
165
- )
166
- ```
167
-
168
- Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
169
- p50. The `redis` gem is not a dependency of this gem, so applications add it
170
- themselves. One background subscription per process fans out to every waiting
171
- role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
172
- does not select it: Redis is infrastructure this gem otherwise does not require,
173
- so choosing it is explicit.
160
+ `SolidObjects.wake_up.capability` reports what was installed, whether it crosses
161
+ processes, its measured floor, and why. `bin/rails solid_objects:doctor` reports
162
+ the same record.
174
163
 
164
+ On PostgreSQL, selection proves the path before it chooses it. It listens on a
165
+ probe channel, sends one `NOTIFY` from a second connection, and waits for it to
166
+ arrive, because `LISTEN` does not survive a transaction pooler such as
167
+ PgBouncer. A probe that does not deliver falls back to polling and warns once.
175
168
  Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
176
169
  p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
177
170
  notification costs latency, never correctness, and signalling never raises into
178
171
  the caller that committed. `LISTEN` needs its own connection, so the adapter
179
172
  opens one outside the pool and releases it on `stop`.
180
173
 
181
- Applications on SQLite or MySQL, or that do not configure the adapter, keep the
182
- existing polling behaviour.
174
+ MySQL has no notification primitive, so MySQL applications either keep polling
175
+ and tune `polling_interval`, or set `SOLID_OBJECTS_REDIS_URL`, which selects
176
+ Redis on any database:
177
+
178
+ ```bash
179
+ SOLID_OBJECTS_REDIS_URL=redis://localhost:6379/0
180
+ ```
181
+
182
+ Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
183
+ p50. The `redis` gem is not a dependency of this gem, so applications add it
184
+ themselves, and selection polls and says so when the gem is missing. One
185
+ background subscription per process fans out to every waiting role in memory,
186
+ rather than one connection per thread. Name
187
+ `SolidObjects::WakeUpAdapters::Redis.new(url:)` directly for a URL that does not
188
+ come from the environment.
183
189
 
184
190
  ## Batched component refreshes
185
191
 
data/docs/roadmap.md CHANGED
@@ -36,6 +36,14 @@
36
36
  listed here while broken in that worker: the scheduler reached a constant the
37
37
  caller path happened to load, so reminders never fired in production and
38
38
  every in-process test still passed
39
+ - Reminder cancellation and reading. `schedule` returns a durable handle,
40
+ `unschedule` and `unschedule_all` cancel by name, key, or handle, and
41
+ `reminder` and `reminders` read the schedule. A cancel is an intent, so it
42
+ commits with the state change that decided it. A read applies the intents
43
+ staged so far, so it agrees with what the commit will leave behind. A cancel
44
+ cannot recall an occurrence the scheduler already turned into a message. It
45
+ does pre-empt one the scheduler claimed but has not yet enqueued, and the
46
+ scheduler treats that as ordinary work rather than a failure
39
47
  - Durable invalidation-only observable broadcasts by default, explicit
40
48
  `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
41
49
  component locals, and authorized replace or morph refresh. Default
@@ -50,7 +58,9 @@
50
58
  cannot reject the subscription or stop its siblings
51
59
  - Reconciliation read APIs
52
60
  - Installation doctor, authorization reference, fit guide, and legacy-state
53
- migration cookbook
61
+ migration cookbook. The doctor names every column that a migration after the
62
+ first adds, so a half-applied migration fails the schema check rather than
63
+ reaching a worker. A test holds the list to that rule
54
64
  - Database server verification: each adapter reports its version against a
55
65
  tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the
56
66
  doctor warns rather than refusing to run on an untested server
@@ -109,26 +119,37 @@
109
119
  batched and unbatched components, an inert replay of an applied revision,
110
120
  cancellation of the request left in flight by the drop, incarnation ordering
111
121
  after a destroy and recreate, and payload delivery exactly once per revision
122
+ - Result lookup by request ID and by idempotency key, authorized with the hook
123
+ the original call ran and against the stored operation and arguments. An
124
+ actor remembers the idempotency keys of its own last `retained_idempotency_keys`
125
+ finished turns, written in the instance row the executor updates anyway, so a
126
+ lookup by key raises `MessagePruned` for a message that retention removed and
127
+ answers `nil` for a message that never existed. A lookup by request ID cannot
128
+ make that distinction, because the runtime generates a request ID and no
129
+ actor remembers one. Pruned lookups retain the original arguments for authorization
130
+ within the byte limit; older entries without arguments answer `nil`
112
131
 
113
132
  ## Partially implemented
114
133
 
115
134
  - Wake-up strategy: in-process signaling, durable polling, injection, and
116
135
  cross-process adapters for PostgreSQL and Redis are implemented and tested.
117
- What is not done is making any of them automatic. In-process signaling cannot
118
- cross process boundaries, so by default a commit in a web process does not
119
- wake a broadcast executor in a worker process and that delivery waits up to
120
- the current adaptive polling interval, up to the one-second
121
- `idle_polling_interval` default. The runtime warns once when it observes this
122
- topology without an adapter. An adapter removes that floor, measured before
123
- adaptive polling at 103.7 ms to 2.9 ms at p50 on PostgreSQL and 103.8 ms to
124
- 5.7 ms on Redis, but each stays opt-in for a reason: the PostgreSQL adapter
125
- opens a connection per waiting thread outside the pool and `LISTEN` does not
126
- survive a transaction-pooling proxy such as PgBouncer, and Redis is not a
127
- dependency of this gem.
128
- `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
129
- default elsewhere; it never selects Redis. An application that configures
130
- nothing keeps polling, and MySQL applications keep polling unless they
131
- configure Redis explicitly.
136
+ Selection is automatic. `config.wake_up_adapter` defaults to `:automatic` and
137
+ prefers a configured Redis URL, then PostgreSQL notifications, then polling,
138
+ so an application that configures nothing no longer polls on PostgreSQL. An
139
+ adapter removes the one-second floor, measured before adaptive polling at
140
+ 103.7 ms to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis. Each
141
+ carries a cost that selection now states rather than hides: the PostgreSQL
142
+ adapter opens a connection per waiting thread outside the pool and adds one
143
+ `NOTIFY` per enqueue, and Redis is not a dependency of this gem.
144
+ `LISTEN` does not survive a transaction-pooling proxy such as PgBouncer, so
145
+ selection listens, sends one `NOTIFY` from a second connection, and waits for
146
+ it to arrive. A probe that does not deliver falls back to polling and warns
147
+ once, as does a requested adapter the environment cannot provide, so a
148
+ downgrade is recorded rather than hidden.
149
+ `SolidObjects.wake_up.capability` reports the adapter, whether it crosses
150
+ processes, its floor, and why, and the doctor shows the same record.
151
+ MySQL still polls. It has no notification channel, and no MySQL notifier has
152
+ been measured against polling on the same hardware, so none is shipped.
132
153
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
133
154
  morphing, personalized refresh authorization, revision fencing, coalescing,
134
155
  reconnect convergence, batched refreshes, and personalized state payloads are
@@ -150,7 +171,11 @@
150
171
  loads them in every process, and a rejected subscription reports which
151
172
  condition caused it instead of closing the socket silently.
152
173
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
153
- distributed per-actor rate limits and global admission control do not. The
174
+ distributed per-actor rate limits, global admission control, and
175
+ cache-capacity eviction are not planned here. They are hot, request-path, and
176
+ loss-tolerant, so one durable ordered message per check is the wrong shape,
177
+ which [fit](fit.md) already says. Solid Objects Pro answers them with grouped
178
+ and ephemeral operations. The
154
179
  state cap is a limit rather than an operating point. `max_state_bytes`
155
180
  defaults to 5 MB, and committed throughput measured on SQLite falls about 53
156
181
  times between an empty state and 1 MB of state, which `docs/benchmarks.md`
@@ -168,15 +193,18 @@
168
193
  or turn off. Every route declares its
169
194
  own administration policy and a route declared without one raises at load
170
195
  time, so the deny-by-default posture is enforced by construction rather than
171
- by remembering to add a check. It changes only two things: an idempotent dead
172
- letter retry and instance pause/resume. What does not exist is audit records
173
- of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
174
- because `DeadLetterManager` exposes no bulk operation. Pause is an operator
175
- brake and not a stop, since a pass already in flight finishes its turn and a
176
- synchronous caller waiting on a paused instance times out. Retry also only
177
- exists for message dead letters: a dead effect or broadcast has no retry
178
- API, which matters for transmit effects because a dead one is a lost
179
- replay until an operator returns its row to pending. The page cost was
196
+ by remembering to add a check. It changes only three things: an idempotent dead
197
+ letter retry, a redrive, and instance pause/resume. Retry covers all three
198
+ kinds. `SolidObjects.dead_letters` keeps its message meaning and answers
199
+ `effects` and `broadcasts`, so a dead effect or broadcast returns to pending
200
+ through an API rather than an operator's `UPDATE`, and a dead transmit effect
201
+ is no longer a lost replay. `redrive` moves a whole scope as a durable task
202
+ that is idempotent over its filters, cancellable, and advanced in bounded
203
+ batches by the supervisor. Every retry and task transition writes one row to
204
+ `solid_objects_administration_events`, so who pressed what is recorded. Pause
205
+ is an operator brake and not a stop, since a pass already in flight finishes
206
+ its turn and a synchronous caller waiting on a paused instance times out. The
207
+ dashboard does not yet surface the scopes or redrive; the API does. The page cost was
180
208
  reasoned about rather than measured: the summary bar issues a fixed set of
181
209
  indexed aggregate queries per page, which is why `HEAD /` exists for uptime
182
210
  monitors, but no dashboard latency has been benchmarked against a large
@@ -184,13 +212,13 @@
184
212
 
185
213
  ## Next milestones
186
214
 
187
- 1. Add result lookup by request ID and broader deadlock retry classification.
188
- 2. Add Turbo append intents.
189
- 3. Add distributed rate limits, global admission hooks, and cache-capacity
190
- eviction.
191
- 4. Expand security scanning beyond the Brakeman scan, such as dependency
215
+ 1. Broaden deadlock retry classification.
216
+ 2. Add Turbo append intents. The renderer already emits the `append` action for
217
+ batch refreshes and payload delivery, so what remains is letting an
218
+ application direct one.
219
+ 3. Expand security scanning beyond the Brakeman scan, such as dependency
192
220
  auditing and secret scanning.
193
- 5. Benchmark all workloads under documented hardware/database settings and
221
+ 4. Benchmark all workloads under documented hardware/database settings and
194
222
  publish adapter-specific adoption measurements. Throughput, synchronous
195
223
  latency, query counts, and the three reactive delivery paths are measured on
196
224
  SQLite; adapter-specific and end-to-end browser measurements are not.
@@ -37,13 +37,7 @@ module AtLeastOnceBoot
37
37
 
38
38
  # @rbs () -> void
39
39
  def self.migrate
40
- require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
41
- require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
42
- require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
43
- require File.join(ROOT, "db/migrate/20260915000000_add_solid_objects_effect_recoveries")
44
- CreateSolidObjectsTables.new.migrate(:up)
45
- AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
46
- RenameMessageDispatchColumns.new.migrate(:up)
47
- AddSolidObjectsEffectRecoveries.new.migrate(:up)
40
+ require "solid_objects/schema_bootstrap"
41
+ SolidObjects::SchemaBootstrap.install
48
42
  end
49
43
  end
@@ -3,6 +3,8 @@
3
3
  module SolidObjects
4
4
  class Activation
5
5
  # @rbs @lease: Lease
6
+ # @rbs @actor_id: String
7
+ # @rbs @instance_id: Integer
6
8
  # @rbs @actor_class: Class
7
9
  # @rbs @actor: Actor
8
10
  # @rbs @last_used_at: Float
@@ -17,6 +19,8 @@ module SolidObjects
17
19
  Instance.find(lease.instance_id)
18
20
  end
19
21
  @actor_class = SolidObjects.registry.fetch(instance.actor_type)
22
+ @actor_id = instance.actor_id
23
+ @instance_id = instance.id
20
24
  @actor = build_actor(instance)
21
25
  @last_used_at = monotonic_now
22
26
  @pass_exhausted = false
@@ -86,10 +90,7 @@ module SolidObjects
86
90
 
87
91
  # @rbs (Hash[String, untyped]) -> void
88
92
  def restore_state(state_data)
89
- @actor = actor_class.new(
90
- actor_id: actor.actor_id,
91
- state: State.new(actor_class.definition.state_definition, state_data)
92
- )
93
+ @actor = new_actor(state_data)
93
94
  end
94
95
 
95
96
  # @rbs () -> void
@@ -156,9 +157,15 @@ module SolidObjects
156
157
  ) do
157
158
  actor_class.definition.migrate_state(instance.state_version, instance.state)
158
159
  end
160
+ new_actor(state_data)
161
+ end
162
+
163
+ # @rbs (Hash[String, untyped]) -> Actor
164
+ def new_actor(state_data)
159
165
  actor_class.new(
160
- actor_id: instance.actor_id,
161
- state: State.new(actor_class.definition.state_definition, state_data)
166
+ actor_id: @actor_id,
167
+ state: State.new(actor_class.definition.state_definition, state_data),
168
+ instance_id: @instance_id
162
169
  )
163
170
  end
164
171
 
@@ -10,7 +10,13 @@ module SolidObjects
10
10
  REMINDER_NAME_LIMIT = 191
11
11
  REMINDER_KEY_SEPARATOR = ":"
12
12
 
13
+ REMINDER_HANDLE_KEY = "reminder_name"
14
+
13
15
  ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
16
+ UnscheduleIntent = Data.define(:name)
17
+ UnscheduleAllIntent = Data.define(:operation)
18
+ ReminderStatus = Data.define(:name, :operation, :key, :next_run_at, :interval_seconds,
19
+ :missed_policy, :occurrence, :status, :handle)
14
20
  OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
15
21
 
16
22
  class << self
@@ -152,10 +158,11 @@ module SolidObjects
152
158
 
153
159
  attr_reader :actor_id, :state
154
160
 
155
- # @rbs (actor_id: String, state: State) -> void
156
- def initialize(actor_id:, state:)
161
+ # @rbs (actor_id: String, state: State, ?instance_id: Integer?) -> void
162
+ def initialize(actor_id:, state:, instance_id: nil)
157
163
  @actor_id = actor_id
158
164
  @state = state
165
+ @instance_id = instance_id
159
166
  @effect_intents = []
160
167
  @effect_recovery_intents = []
161
168
  @commit_action_intents = []
@@ -255,20 +262,139 @@ module SolidObjects
255
262
  actor_type: self.class.actor_type,
256
263
  handlers: self.class.definition.messages
257
264
  ) do |operation, arguments|
258
- ReminderIntent.new(
259
- name: reminder_name(operation:, key: reminder_key),
265
+ name = reminder_name(operation:, key: reminder_key)
266
+ reminder_intents << ReminderIntent.new(
267
+ name:,
260
268
  operation: operation.to_s,
261
269
  at:,
262
270
  arguments: Serialization.dump(arguments),
263
271
  interval_seconds:,
264
272
  missed_policy:
265
- ).tap do |intent|
266
- reminder_intents << intent
267
- end
268
- nil
273
+ )
274
+ { REMINDER_HANDLE_KEY => name }
269
275
  end
270
276
  end
271
277
 
278
+ # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> nil
279
+ def unschedule(operation_or_handle, key: nil)
280
+ return unschedule_name(handle_name(operation_or_handle, key:)) if operation_or_handle.is_a?(Hash)
281
+
282
+ validated_reminder_operation(operation_or_handle)
283
+ unschedule_name(reminder_name(operation: operation_or_handle, key: validated_reminder_key(key)))
284
+ end
285
+
286
+ # @rbs (Symbol | String) -> nil
287
+ def unschedule_all(operation)
288
+ reminder_intents << UnscheduleAllIntent.new(operation: validated_reminder_operation(operation))
289
+ nil
290
+ end
291
+
292
+ # @rbs (Symbol | String | reminder_handle, ?key: (String | Symbol | Integer)?) -> ReminderStatus?
293
+ def reminder(operation_or_handle, key: nil)
294
+ return reminder_view[handle_name(operation_or_handle, key:)] if operation_or_handle.is_a?(Hash)
295
+
296
+ validated_reminder_operation(operation_or_handle)
297
+ reminder_view[reminder_name(operation: operation_or_handle, key: validated_reminder_key(key))]
298
+ end
299
+
300
+ # @rbs (Symbol | String) -> Array[ReminderStatus]
301
+ def reminders(operation)
302
+ wanted = validated_reminder_operation(operation)
303
+ reminder_view.each_value.select { |status| status.operation == wanted }
304
+ end
305
+
306
+ attr_reader :instance_id
307
+
308
+ # @rbs (Symbol | String) -> String
309
+ def validated_reminder_operation(operation)
310
+ name = operation.to_s
311
+ return name if self.class.definition.messages.key?(name.to_sym)
312
+
313
+ raise UnknownMessage, "unknown message #{name.inspect} for #{self.class.actor_type}"
314
+ end
315
+
316
+ # @rbs (String) -> nil
317
+ def unschedule_name(name)
318
+ reminder_intents << UnscheduleIntent.new(name:)
319
+ nil
320
+ end
321
+
322
+ # @rbs (reminder_handle, key: untyped) -> String
323
+ def handle_name(handle, key:)
324
+ raise ArgumentError, "a reminder handle already names its key" unless key.nil?
325
+
326
+ name = handle[REMINDER_HANDLE_KEY]
327
+ unless name.is_a?(String) && !name.empty?
328
+ raise InvalidPayload, "expected a reminder handle returned by schedule"
329
+ end
330
+
331
+ name
332
+ end
333
+
334
+ # The view is the committed schedule with this turn's staged intents applied
335
+ # in order, so a read agrees with what the commit will leave behind.
336
+ # @rbs () -> Hash[String, ReminderStatus]
337
+ def reminder_view
338
+ reminder_intents.each_with_object(committed_reminders) do |intent, view|
339
+ apply_reminder_intent(view, intent)
340
+ end
341
+ end
342
+
343
+ # @rbs () -> Hash[String, ReminderStatus]
344
+ def committed_reminders
345
+ return {} unless instance_id
346
+
347
+ Reminder.where(instance_id:).where.not(status: "completed").each_with_object({}) do |row, view|
348
+ view[row.name] = reminder_status(
349
+ name: row.name,
350
+ operation: row.operation,
351
+ next_run_at: row.next_run_at,
352
+ interval_seconds: row.interval_seconds,
353
+ missed_policy: row.missed_policy,
354
+ occurrence: row.occurrence,
355
+ status: row.status
356
+ )
357
+ end
358
+ end
359
+
360
+ # @rbs (Hash[String, ReminderStatus], untyped) -> void
361
+ def apply_reminder_intent(view, intent)
362
+ return view.delete_if { |_name, status| status.operation == intent.operation } if intent.is_a?(UnscheduleAllIntent)
363
+ return view.delete(intent.name) if intent.is_a?(UnscheduleIntent)
364
+
365
+ view[intent.name] = reminder_status(
366
+ name: intent.name,
367
+ operation: intent.operation,
368
+ next_run_at: intent.at,
369
+ interval_seconds: intent.interval_seconds,
370
+ missed_policy: intent.missed_policy,
371
+ occurrence: view[intent.name]&.occurrence || 0,
372
+ status: "scheduled"
373
+ )
374
+ end
375
+
376
+ # @rbs (name: String, operation: String, next_run_at: Time?, interval_seconds: untyped, missed_policy: String, occurrence: Integer, status: String) -> ReminderStatus
377
+ def reminder_status(name:, operation:, next_run_at:, interval_seconds:, missed_policy:, occurrence:, status:)
378
+ ReminderStatus.new(
379
+ name:,
380
+ operation:,
381
+ key: reminder_key_of(name:, operation:),
382
+ next_run_at:,
383
+ interval_seconds: interval_seconds&.to_f,
384
+ missed_policy:,
385
+ occurrence:,
386
+ status:,
387
+ handle: { REMINDER_HANDLE_KEY => name }
388
+ )
389
+ end
390
+
391
+ # @rbs (name: String, operation: String) -> String?
392
+ def reminder_key_of(name:, operation:)
393
+ return nil if name == operation
394
+
395
+ name.delete_prefix("#{operation}#{REMINDER_KEY_SEPARATOR}")
396
+ end
397
+
272
398
  # @rbs ((String | Symbol | Integer)?) -> String?
273
399
  def validated_reminder_key(key)
274
400
  return nil if key.nil?
@@ -16,10 +16,12 @@ module SolidObjects
16
16
  def initialize(reference)
17
17
  @reference = reference
18
18
  @actor_class = SolidObjects.registry.fetch(reference.actor_type)
19
- @instance = Instance.find_by(
20
- actor_type: reference.actor_type,
21
- actor_id: reference.actor_id
22
- )
19
+ @instance = Instance.uncached do
20
+ Instance.find_by(
21
+ actor_type: reference.actor_type,
22
+ actor_id: reference.actor_id
23
+ )
24
+ end
23
25
  @instance_id = @instance&.id || 0
24
26
  @revision = @instance&.state_revision || 0
25
27
  @actor = build_actor
@@ -60,7 +62,8 @@ module SolidObjects
60
62
  end
61
63
  actor_class.new(
62
64
  actor_id: reference.actor_id,
63
- state: State.new(actor_class.definition.state_definition, state_data)
65
+ state: State.new(actor_class.definition.state_definition, state_data),
66
+ instance_id: @instance&.id
64
67
  )
65
68
  end
66
69
  end
@@ -0,0 +1,30 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ module AdministrationAudit
5
+ module_function
6
+
7
+ # @rbs (action: String, kind: String, ?subject_id: untyped, ?filters: Hash[Symbol | String, untyped]?, ?actor: String?) -> void
8
+ def record(action:, kind:, subject_id: nil, filters: nil, actor: nil)
9
+ AdministrationEvent.create!(
10
+ action:,
11
+ kind:,
12
+ subject_id: subject_id&.to_s,
13
+ filters:,
14
+ actor:,
15
+ occurred_at: SolidObjects.database_adapter.database_now
16
+ )
17
+ nil
18
+ end
19
+
20
+ # @rbs (untyped) -> String?
21
+ def identity(authorization_context)
22
+ return nil if authorization_context.nil?
23
+
24
+ SolidObjects.configuration.administration_identity
25
+ .call(authorization_context)
26
+ &.to_s
27
+ &.byteslice(0, 255)
28
+ end
29
+ end
30
+ end
@@ -94,6 +94,45 @@ module SolidObjects
94
94
  raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:)
95
95
  end
96
96
 
97
+ # @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference?
98
+ def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization_context: nil)
99
+ unless [ request_id, idempotency_key ].compact.one?
100
+ raise ArgumentError, "find_by expects exactly one of request_id: or idempotency_key:"
101
+ end
102
+ if idempotency_key && reference.nil?
103
+ raise ArgumentError, "find_by with idempotency_key: requires reference:"
104
+ end
105
+
106
+ if request_id
107
+ return readable_message(
108
+ Message.uncached { Message.find_by(request_id:) },
109
+ authorization_context:
110
+ )
111
+ end
112
+
113
+ instance = Instance.find_by(
114
+ actor_type: reference.actor_type,
115
+ actor_id: reference.actor_id
116
+ )
117
+ return nil unless instance
118
+
119
+ message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) }
120
+ return readable_message(message, authorization_context:) if message
121
+
122
+ remembered = Array(instance.completed_idempotency_keys)
123
+ .find { |entry| entry["key"] == idempotency_key }
124
+ return nil unless remembered && remembered["arguments"].is_a?(Hash)
125
+ return nil unless authorized_to_invoke?(
126
+ actor_type: reference.actor_type,
127
+ actor_id: reference.actor_id,
128
+ operation: remembered["operation"],
129
+ arguments: remembered["arguments"],
130
+ authorization_context:
131
+ )
132
+
133
+ raise MessagePruned, idempotency_key
134
+ end
135
+
97
136
  # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
98
137
  def snapshot(reference, authorization_context: nil)
99
138
  SolidObjects.registry.fetch(reference.actor_type)
@@ -164,6 +203,48 @@ module SolidObjects
164
203
  )
165
204
  end
166
205
 
206
+ # @rbs (Message?, authorization_context: untyped) -> MessageReference?
207
+ def readable_message(message, authorization_context:)
208
+ return nil unless message
209
+ return nil unless authorized_to_read?(message, authorization_context:)
210
+
211
+ MessageReference.from_message(message)
212
+ end
213
+
214
+ # @rbs (Message, authorization_context: untyped) -> bool
215
+ def authorized_to_read?(message, authorization_context:)
216
+ authorized_to_invoke?(
217
+ actor_type: message.actor_type,
218
+ actor_id: message.actor_id,
219
+ operation: message.operation,
220
+ arguments: message.arguments,
221
+ authorization_context:
222
+ )
223
+ end
224
+
225
+ # @rbs (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool
226
+ def authorized_to_invoke?(actor_type:, actor_id:, operation:, arguments:, authorization_context:)
227
+ actor_class = SolidObjects.registry.fetch(actor_type)
228
+ operation_symbol = operation.to_sym
229
+ query = actor_class.definition.queries.key?(operation_symbol)
230
+ return false unless query || actor_class.definition.messages.key?(operation_symbol)
231
+
232
+ hook = if query
233
+ SolidObjects.configuration.authorize_query
234
+ else
235
+ SolidObjects.configuration.authorize_message
236
+ end
237
+ hook.call(
238
+ actor_type:,
239
+ actor_id:,
240
+ operation: operation.to_s,
241
+ arguments:,
242
+ authorization_context:
243
+ )
244
+ rescue UnknownActorType
245
+ false
246
+ end
247
+
167
248
  # @rbs (MessageReference, Message) -> void
168
249
  def validate_message_reference!(message_reference, message)
169
250
  valid = message_reference.request_id == message.request_id &&