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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +1 -1
- data/app/models/solid_objects/administration_event.rb +7 -0
- data/app/models/solid_objects/redrive.rb +7 -0
- data/benchmark/support.rb +2 -8
- data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
- data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
- data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
- data/docs/adr/0011-wake-up-strategy.md +11 -1
- data/docs/architecture.md +41 -5
- data/docs/dashboard.md +10 -0
- data/docs/fit.md +6 -0
- data/docs/operations.md +153 -7
- data/docs/realtime.md +32 -26
- data/docs/roadmap.md +60 -32
- data/examples/at_least_once/boot.rb +2 -8
- data/lib/solid_objects/activation.rb +13 -6
- data/lib/solid_objects/actor.rb +134 -8
- data/lib/solid_objects/actor_snapshot.rb +8 -5
- data/lib/solid_objects/administration_audit.rb +30 -0
- data/lib/solid_objects/client.rb +81 -0
- data/lib/solid_objects/configuration.rb +47 -2
- data/lib/solid_objects/context.rb +10 -4
- data/lib/solid_objects/dead_letter_manager.rb +42 -6
- data/lib/solid_objects/dead_letter_scope.rb +143 -0
- data/lib/solid_objects/dead_row.rb +15 -0
- data/lib/solid_objects/doctor.rb +24 -5
- data/lib/solid_objects/errors.rb +11 -0
- data/lib/solid_objects/executor.rb +41 -3
- data/lib/solid_objects/message_reference.rb +29 -9
- data/lib/solid_objects/outcome.rb +31 -0
- data/lib/solid_objects/process_registry.rb +3 -1
- data/lib/solid_objects/redrive_manager.rb +130 -0
- data/lib/solid_objects/redrive_runner.rb +60 -0
- data/lib/solid_objects/redrive_task.rb +14 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/schema_bootstrap.rb +27 -0
- data/lib/solid_objects/supervisor.rb +43 -0
- data/lib/solid_objects/test_helper.rb +2 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
- data/lib/solid_objects/wake_up_adapters.rb +206 -11
- data/lib/solid_objects/wake_up_capability.rb +15 -0
- data/lib/solid_objects.rb +46 -3
- data/sig/generated/lib/solid_objects/activation.rbs +7 -0
- data/sig/generated/lib/solid_objects/actor.rbs +93 -2
- data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
- data/sig/generated/lib/solid_objects/client.rbs +12 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
- data/sig/generated/lib/solid_objects/context.rbs +11 -6
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
- data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
- data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/errors.rbs +8 -0
- data/sig/generated/lib/solid_objects/executor.rbs +9 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
- data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
- data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
- data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
- data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
- data/sig/generated/lib/solid_objects/reference.rbs +5 -2
- data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
- data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +14 -2
- data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
- data/sig/generated/models/solid_objects/redrive.rbs +6 -0
- data/sig/public/reminder_payload.rbs +3 -0
- 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
|
|
144
|
-
signal cannot cross process boundaries, so a commit in a Puma process
|
|
145
|
-
wake a broadcast executor in a worker process, and delivery
|
|
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
|
-
|
|
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 =
|
|
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
|
-
`
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
172
|
-
letter retry and instance pause/resume.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
188
|
-
2. Add Turbo append intents.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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
|
-
|
|
259
|
-
|
|
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
|
-
)
|
|
266
|
-
|
|
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.
|
|
20
|
-
|
|
21
|
-
|
|
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
|
data/lib/solid_objects/client.rb
CHANGED
|
@@ -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 &&
|