solid_objects 0.1.0 → 0.2.1

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +160 -43
  4. data/app/models/solid_objects/claimed_message.rb +2 -0
  5. data/app/models/solid_objects/message.rb +6 -1
  6. data/benchmark/adoption_latency.rb +5 -0
  7. data/benchmark/support.rb +45 -15
  8. data/benchmark/{ask_latency.rb → sync_latency.rb} +1 -1
  9. data/db/migrate/20260805000000_create_solid_objects_tables.rb +15 -3
  10. data/docs/adr/0006-at-least-once-delivery.md +1 -1
  11. data/docs/adr/0008-actor-communication.md +4 -1
  12. data/docs/adr/0011-wake-up-strategy.md +13 -4
  13. data/docs/architecture.md +51 -20
  14. data/docs/authorization.md +85 -0
  15. data/docs/benchmarks.md +80 -10
  16. data/docs/correctness.md +28 -9
  17. data/docs/database-schema.md +15 -7
  18. data/docs/development.md +6 -5
  19. data/docs/fit.md +90 -0
  20. data/docs/implementation-plan.md +22 -16
  21. data/docs/migrating-existing-state.md +133 -0
  22. data/docs/operations.md +56 -4
  23. data/docs/research/solid_queue.md +2 -1
  24. data/docs/roadmap.md +9 -3
  25. data/docs/security.md +7 -2
  26. data/docs/state-migrations.md +4 -0
  27. data/lib/generators/solid_objects/templates/solid_objects.rb +17 -0
  28. data/lib/solid_objects/activation.rb +35 -19
  29. data/lib/solid_objects/activation_manager.rb +22 -6
  30. data/lib/solid_objects/actor.rb +16 -3
  31. data/lib/solid_objects/caller_process.rb +57 -0
  32. data/lib/solid_objects/client.rb +6 -37
  33. data/lib/solid_objects/configuration.rb +4 -4
  34. data/lib/solid_objects/doctor.rb +311 -0
  35. data/lib/solid_objects/engine.rb +1 -0
  36. data/lib/solid_objects/errors.rb +17 -1
  37. data/lib/solid_objects/executor.rb +42 -2
  38. data/lib/solid_objects/lease.rb +22 -10
  39. data/lib/solid_objects/message_reference.rb +1 -0
  40. data/lib/solid_objects/process_registry.rb +5 -1
  41. data/lib/solid_objects/reference.rb +8 -8
  42. data/lib/solid_objects/synchronous_invocation.rb +93 -0
  43. data/lib/solid_objects/version.rb +1 -1
  44. data/lib/solid_objects.rb +7 -0
  45. data/lib/tasks/solid_objects_tasks.rake +10 -0
  46. data/sig/generated/lib/solid_objects/activation.rbs +6 -0
  47. data/sig/generated/lib/solid_objects/activation_manager.rbs +6 -0
  48. data/sig/generated/lib/solid_objects/actor.rbs +9 -6
  49. data/sig/generated/lib/solid_objects/caller_process.rbs +32 -0
  50. data/sig/generated/lib/solid_objects/client.rbs +2 -8
  51. data/sig/generated/lib/solid_objects/configuration.rbs +2 -2
  52. data/sig/generated/lib/solid_objects/doctor.rbs +111 -0
  53. data/sig/generated/lib/solid_objects/errors.rbs +18 -1
  54. data/sig/generated/lib/solid_objects/executor.rbs +3 -0
  55. data/sig/generated/lib/solid_objects/lease.rbs +14 -10
  56. data/sig/generated/lib/solid_objects/reference.rbs +3 -3
  57. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +28 -0
  58. data/sig/generated/lib/solid_objects.rbs +3 -0
  59. data/sig/generated/models/solid_objects/message.rbs +3 -0
  60. metadata +15 -8
@@ -161,7 +161,7 @@ Create the seven domain tables plus ready- and claimed-message membership tables
161
161
 
162
162
  The schema installs in PostgreSQL, MySQL, and SQLite dummy databases and database constraints reject invalid states independently of Rails validations.
163
163
 
164
- ## Milestone 3: Durable enqueue, references, tell, and ask
164
+ ## Milestone 3: Durable enqueue and invocation modes
165
165
 
166
166
  ### Files
167
167
 
@@ -171,14 +171,15 @@ The schema installs in PostgreSQL, MySQL, and SQLite dummy databases and databas
171
171
  - `lib/solid_objects/message_reference.rb`
172
172
  - `lib/solid_objects/wake_up.rb`
173
173
  - `test/integration/enqueue_test.rb`
174
- - `test/integration/tell_test.rb`
175
- - `test/integration/ask_test.rb`
174
+ - `test/integration/enqueue_test.rb`
175
+ - `test/integration/sync_test.rb`
176
+ - `test/integration/synchronous_invocation_test.rb`
176
177
 
177
178
  ### Public API
178
179
 
179
- - `Reference#tell`
180
- - `Reference#ask`
181
- - Method-style message, query, and read-only attribute dispatch
180
+ - `Reference#async`
181
+ - `Reference#sync`
182
+ - Synchronous method-style message, query, and read-only attribute dispatch
182
183
  - `MessageReference#id`, `#status`, `#result`
183
184
  - Authorization context and hooks
184
185
 
@@ -191,8 +192,10 @@ No new tables. Use instance sequence and message request/idempotency columns.
191
192
  - Per-actor sequence allocation under concurrent connections
192
193
  - Independent sequences for different actors
193
194
  - Idempotency key deduplication
194
- - Tell return value
195
- - Ask success, failure, and timeout
195
+ - Async return value
196
+ - Synchronous success, rejection, failure, and timeout
197
+ - Caller-assisted processing behind earlier asynchronous work
198
+ - Same-actor serialization and different-actor concurrency
196
199
  - Mailbox and payload limits
197
200
  - Message/query authorization failure
198
201
 
@@ -201,12 +204,15 @@ No new tables. Use instance sequence and message request/idempotency columns.
201
204
  - Concurrent first enqueue
202
205
  - Lock timeout or deadlock
203
206
  - Duplicate idempotency key with different payload
204
- - Ask caller timeout
207
+ - Synchronous caller timeout
205
208
  - Oversized payload or mailbox
206
209
 
207
210
  ### Completion criteria
208
211
 
209
- Messages and ready membership enqueue durably in strict per-actor sequence and `ask` can observe a manually completed result. Polling-only `ask` is documented as unsuitable for latency-sensitive request handlers.
212
+ Messages and ready membership enqueue durably in strict per-actor sequence.
213
+ Direct methods and `sync` claim and execute the actor locally when possible,
214
+ while `async` returns immediately for worker execution. Every path uses the
215
+ same mailbox, lease, fencing, and durable result.
210
216
 
211
217
  ## Milestone 4: Fenced, runnable vertical slice
212
218
 
@@ -236,7 +242,7 @@ No new tables.
236
242
 
237
243
  ### Tests
238
244
 
239
- - Shopping cart tell and ask
245
+ - Shopping cart synchronous and asynchronous invocation
240
246
  - One actor processes messages sequentially
241
247
  - Different actors can execute concurrently
242
248
  - Lease acquire, renew, expire, and release
@@ -246,7 +252,7 @@ No new tables.
246
252
  - State and completion are atomic
247
253
  - Basic retry and strict head-of-mailbox blocking
248
254
  - Handler-level duplicate-delivery guards
249
- - Actor-to-actor tell outside actor context
255
+ - Actor-to-actor asynchronous delivery
250
256
 
251
257
  ### Failure modes
252
258
 
@@ -325,7 +331,7 @@ Use the effects table. Add delivery-token or outcome columns only through a migr
325
331
  - Stable idempotency context
326
332
  - Success/failure outcome messages
327
333
  - Transactional actor-to-actor delivery
328
- - `ask` rejected in actor context
334
+ - Direct and `sync` actor-to-actor calls rejected in actor context
329
335
 
330
336
  ### Failure modes
331
337
 
@@ -464,13 +470,13 @@ No expected changes.
464
470
  - Sensitive data in logs
465
471
  - Unbounded admin queries
466
472
  - Retrying wrong dead letter
467
- - Cleanup racing with ask waiter
468
- - Reconciliation code mutating actor state outside `tell`
473
+ - Cleanup racing with a synchronous waiter
474
+ - Reconciliation code mutating actor state outside `async`
469
475
  - Reconciliation stampedes without delayed `available_at`
470
476
 
471
477
  ### Completion criteria
472
478
 
473
- Operators can inspect health and failures without direct SQL, locate lost alarms and orphaned actors, and observe every required transition without raw arguments. Documentation requires reconciliation repairs to use delayed `tell` rather than direct instance updates.
479
+ Operators can inspect health and failures without direct SQL, locate lost alarms and orphaned actors, and observe every required transition without raw arguments. Documentation requires reconciliation repairs to use delayed `async` delivery rather than direct instance updates.
474
480
 
475
481
  ## Milestone 10: Examples, benchmarks, documentation, and release hardening
476
482
 
@@ -0,0 +1,133 @@
1
+ # Migrating existing state
2
+
3
+ Moving an existing Redis, cache, or key-value state machine into Solid Objects
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.
6
+
7
+ ## 1. Write down the existing contract
8
+
9
+ Inventory:
10
+
11
+ - every read and write path;
12
+ - the current canonical and secondary keys;
13
+ - expiration and cleanup behavior;
14
+ - concurrency guards and idempotency keys;
15
+ - external effects;
16
+ - expected request latency and volume; and
17
+ - rollback requirements.
18
+
19
+ Run the [fit checklist](fit.md#decision-checklist) before migrating. A hot
20
+ counter or append pipeline may be better left in its existing store.
21
+
22
+ ## 2. Choose one canonical identity
23
+
24
+ Solid Objects addresses an actor with one `(actor_type, actor_id)` pair. Do not
25
+ hide two competing identities inside actor code or reintroduce a scan.
26
+
27
+ When existing state is written by `(user_id, assessment_short)` but read by
28
+ `session_id`, create a normalized lookup record:
29
+
30
+ ```ruby
31
+ class AssessmentSession < ApplicationRecord
32
+ validates :session_id, uniqueness: true
33
+ validates :assessment_short, uniqueness: { scope: :user_id }
34
+
35
+ def actor
36
+ Assessment.ref(id)
37
+ end
38
+ end
39
+ ```
40
+
41
+ The lookup row gives both old keys one stable primary key. The actor ID is the
42
+ lookup record ID, and ordinary indexed Active Record queries resolve either
43
+ external key. This is clearer and safer than delimiter-joining composite values
44
+ or preserving a `LIKE` scan.
45
+
46
+ Create and backfill the lookup table before actor traffic begins. Enforce every
47
+ identity invariant with unique database indexes.
48
+
49
+ ## 3. Add an idempotent bootstrap message
50
+
51
+ Never bulk-update `solid_objects_instances.state`. Direct writes bypass actor
52
+ ordering, state migrations, observables, activation ownership, and fencing.
53
+
54
+ Import through a normal actor message:
55
+
56
+ ```ruby
57
+ class Assessment < SolidObjects::Actor
58
+ attribute :imported, default: false
59
+ attribute :answers, default: -> { [] }
60
+
61
+ def bootstrap(answers:)
62
+ return if imported
63
+
64
+ self.answers = answers
65
+ self.imported = true
66
+ end
67
+ end
68
+ ```
69
+
70
+ Give every bootstrap call an idempotency key derived from the legacy record:
71
+
72
+ ```ruby
73
+ session.actor.async(
74
+ :bootstrap,
75
+ answers: legacy.answers,
76
+ idempotency_key: "legacy-assessment:#{legacy.id}",
77
+ available_at: jittered_time
78
+ )
79
+ ```
80
+
81
+ Spread large backfills over a dispatch window and monitor mailbox age,
82
+ failures, and dead letters. Asynchronous backfill requires the worker runtime.
83
+
84
+ ## 4. Prefer shadow comparison over blind dual writes
85
+
86
+ Two independent stores cannot be updated atomically without a shared
87
+ transaction or outbox. A controller that writes Redis and an actor in sequence
88
+ can leave them divergent after a timeout or crash.
89
+
90
+ A safer rollout:
91
+
92
+ 1. Keep the legacy store authoritative.
93
+ 2. Bootstrap the actor from a consistent legacy snapshot.
94
+ 3. Mirror new changes to the actor with stable idempotency keys.
95
+ 4. Read both stores in a background comparison path.
96
+ 5. Record divergence counts without changing the user response.
97
+ 6. Repair through actor messages, never direct actor-state SQL.
98
+ 7. Cut reads over only after divergence remains acceptably low.
99
+
100
+ If the actor becomes authoritative before the legacy system is retired, emit a
101
+ transactional effect that updates the legacy store. The effect is at least once,
102
+ so the legacy write still needs idempotency.
103
+
104
+ ## 5. Cut over in reversible stages
105
+
106
+ A typical zero-downtime sequence is:
107
+
108
+ 1. Deploy the lookup table and dual-key resolution.
109
+ 2. Deploy actor code and policies with reads still on the legacy store.
110
+ 3. Start the required runtime roles.
111
+ 4. Backfill actors in bounded batches.
112
+ 5. Enable shadow comparison and reconcile drift.
113
+ 6. Move a small cohort of reads to actors.
114
+ 7. Expand the cohort while watching latency, database growth, retries, and
115
+ divergence.
116
+ 8. Move writes to the actor.
117
+ 9. Retain the legacy state through an explicit rollback window.
118
+ 10. Remove dual writes and legacy data only after the rollback window closes.
119
+
120
+ Use a feature flag whose rollback restores legacy reads and writes without
121
+ requiring actor deletion. Do not assume a timed-out synchronous actor call did
122
+ not commit; query the durable result or use an idempotency key before retrying.
123
+
124
+ ## 6. Plan for dormant state and future changes
125
+
126
+ Actor state migrations and legacy-store migration solve different problems:
127
+
128
+ - this cookbook moves ownership from another store into an actor;
129
+ - `state_version` evolves actor JSON after that ownership exists.
130
+
131
+ Keep every published actor migration step. A dormant actor can reactivate years
132
+ later with an old state representation. See the
133
+ [state migration guide](state-migrations.md) for rolling-deployment rules.
data/docs/operations.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Operations guide
2
2
 
3
+ ## Installation verification
4
+
5
+ Run the installation doctor after generating the initializer and migrating:
6
+
7
+ ```bash
8
+ bin/rails solid_objects:doctor
9
+ ```
10
+
11
+ It validates runtime configuration, required tables and columns, neutral policy
12
+ posture, live runtime roles, and a real workerless synchronous actor round-trip.
13
+ Engine migration timestamps are rewritten when copied into a host application,
14
+ so the schema check compares the required shape instead of a fixed timestamp.
15
+ Warnings such as an all-deny neutral policy do not fail the command because a
16
+ context-aware production policy may correctly deny the probe.
17
+
3
18
  ## Runtime
4
19
 
5
20
  Start all configured roles:
@@ -61,7 +76,7 @@ operational task until that roadmap item lands.
61
76
 
62
77
  Self-scheduling actors need a daily or similarly low-frequency reconciliation
63
78
  job because an application-level alarm can be lost. The reconciler reads state
64
- but sends every repair through `tell`.
79
+ but sends every repair through `async`.
65
80
 
66
81
  Use:
67
82
 
@@ -114,9 +129,46 @@ Alert on:
114
129
  ## Retention and backups
115
130
 
116
131
  The schema has cleanup indexes, but automatic pruning commands are still
117
- roadmap work. Until implemented, define application-owned bounded deletes that
118
- preserve unfinished messages, dead letters under investigation, and ask results
119
- for the promised lookup period.
132
+ roadmap work. Every actor call creates a durable message-history row, including
133
+ queries and attribute reads. Choose a retention period from measured call
134
+ volume, storage budget, audit needs, and the longest promised synchronous-result
135
+ lookup window.
136
+
137
+ An application-owned pruning job can start from this conservative relation:
138
+
139
+ ```ruby
140
+ cutoff = 30.days.ago
141
+
142
+ prunable_messages = SolidObjects::Message
143
+ .where(completed_at: ...cutoff)
144
+ .where.not(id: SolidObjects::ReadyMessage.select(:message_id))
145
+ .where.not(id: SolidObjects::ClaimedMessage.select(:message_id))
146
+ .where.not(id: SolidObjects::DeadLetter.select(:message_id))
147
+ .where.not(
148
+ id: SolidObjects::DeadLetter
149
+ .where.not(retried_message_id: nil)
150
+ .select(:retried_message_id)
151
+ )
152
+ .where.not(
153
+ id: SolidObjects::Effect
154
+ .where.not(status: "completed")
155
+ .select(:message_id)
156
+ )
157
+ .where.not(
158
+ id: SolidObjects::Broadcast
159
+ .where.not(status: "delivered")
160
+ .select(:message_id)
161
+ )
162
+
163
+ prunable_messages.in_batches(of: 1_000).delete_all
164
+ ```
165
+
166
+ Deleting a message cascades to its completed effects, delivered broadcasts, and
167
+ other message-owned records. Test the exact relation against a restored
168
+ production snapshot before scheduling it. Keep source and retried messages for
169
+ dead letters under investigation, and never prune pending, processing, ready, or
170
+ claimed work. Choose a cutoff longer than every `sync` timeout because a caller
171
+ whose result row disappears can no longer observe that result.
120
172
 
121
173
  Back up actor tables with the same consistency guarantees as application data.
122
174
  Restoring only instances without their mailboxes/outboxes, or vice versa, can
@@ -82,7 +82,8 @@ The equivalent thin public surface is:
82
82
 
83
83
  - `SolidObjects::Actor` for definitions.
84
84
  - `ActorClass.ref(actor_id)` for logical addressing.
85
- - `SolidObjects::Reference#tell` and `#ask` for invocation.
85
+ - Direct methods and `SolidObjects::Reference#sync` for request/response
86
+ invocation, plus `#async` for durable enqueue.
86
87
  - Explicit helpers for reminders, effects, observables, and lifecycle hooks.
87
88
 
88
89
  Mailbox rows, leases, worker records, and outboxes remain internal. Public message and dead-letter references should expose identifiers and safe inspection methods without leaking Active Record mutation APIs.
data/docs/roadmap.md CHANGED
@@ -4,16 +4,21 @@
4
4
 
5
5
  - Rails engine, install generator, migration, and CLI
6
6
  - Explicit actor registry, references, JSON state, and state migrations
7
+ - Direct synchronous RPC, explicit `sync`, and durable `async`
7
8
  - Durable message history plus ready/claimed membership tables
8
9
  - Concurrent sequence allocation and actor creation
9
- - Activation leases, renewal, generations, and fenced commits
10
+ - Activation leases, renewal, unique activation tokens, generations, and
11
+ fenced commits
10
12
  - Bounded activation passes, idle cache, hot-actor yield, and process records
11
- - At-least-once retries, strict poison ordering, dead letters, and tail retry
13
+ - At-least-once retries, terminal domain rejection, strict poison ordering,
14
+ dead letters, and tail retry
12
15
  - Transactional effects with success/failure actor messages
13
16
  - Actor-to-actor asynchronous outbox delivery
14
17
  - One-shot and recurring reminders with `:latest` or `:all` catch-up
15
18
  - Durable observable broadcast outbox and authorized Action Cable refresh
16
19
  - Reconciliation read APIs
20
+ - Installation doctor, authorization reference, fit guide, and legacy-state
21
+ migration cookbook
17
22
  - SQLite, PostgreSQL, and MySQL integration suites
18
23
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
19
24
  RuboCop policy, and a warning-free Brakeman scan
@@ -47,7 +52,8 @@
47
52
  eviction.
48
53
  8. Expand security scanning and run compatibility CI across supported Rails and
49
54
  Ruby versions.
50
- 9. Benchmark all workloads under documented hardware/database settings.
55
+ 9. Benchmark all workloads under documented hardware/database settings and
56
+ publish adapter-specific adoption measurements.
51
57
 
52
58
  No production-ready claim should be made until these hardening milestones have
53
59
  operational soak evidence.
data/docs/security.md CHANGED
@@ -7,10 +7,15 @@ destroying actors, subscribing to actor streams, and administration. The host
7
7
  application supplies the authenticated request or connection as
8
8
  `authorization_context`. All five hooks deny by default.
9
9
 
10
+ The [authorization reference](authorization.md) lists the caller context and
11
+ risk for every hook and includes a tenant-aware policy example.
12
+
10
13
  Method-style reference calls do not bypass these hooks. Public instance methods
11
14
  declared on an actor are part of its remotely addressable message surface and
12
- delegate to the authorized `tell` path. Keep implementation helpers private or
13
- protected. Query and attribute methods delegate to the authorized `ask` path.
15
+ delegate to the authorized synchronous invocation path. Keep implementation
16
+ helpers private or protected. Query and attribute methods use the separate
17
+ query authorization policy. Explicit `async` message delivery uses the same
18
+ message authorization policy as direct calls.
14
19
  `reference.destroy` delegates to `authorize_destroy` before checking whether
15
20
  the actor exists, so denial does not reveal actor existence.
16
21
 
@@ -44,3 +44,7 @@ A safe destructive rollout normally uses:
44
44
 
45
45
  Never update actor JSON in a bulk SQL migration. Use actor messages so fencing,
46
46
  ordering, observables, and outboxes remain intact.
47
+
48
+ This guide covers evolution after state belongs to Solid Objects. For moving
49
+ existing Redis, key-value, or relational state into actors without downtime,
50
+ use the [legacy-state migration cookbook](migrating-existing-state.md).
@@ -5,6 +5,23 @@ SolidObjects.configure do |configuration|
5
5
  configuration.effect_worker_count = 1
6
6
  configuration.broadcast_worker_count = 1
7
7
  configuration.reminder_scheduler_count = 1
8
+
9
+ # Every policy denies by default, so a fresh installation is intentionally
10
+ # inert. Replace these policies before invoking actors.
11
+ #
12
+ # Message and query policies gate direct calls, sync, async, and state reads.
13
+ # Destroy removes an actor and all of its durable work. Subscription gates
14
+ # Action Cable streams. Administration gates engine pages and operational
15
+ # commands. Keep the last three denied until their callers are authenticated.
16
+ #
17
+ # Prefer policies that bind actor_type and actor_id to a trusted
18
+ # authorization_context. See:
19
+ # https://github.com/cardmagic/solid_objects/blob/main/docs/authorization.md
20
+ # and run:
21
+ #
22
+ # bin/rails solid_objects:doctor
23
+ #
24
+ # after configuring the application.
8
25
  configuration.authorize_message = ->(**) { false }
9
26
  configuration.authorize_query = ->(**) { false }
10
27
  configuration.authorize_destroy = ->(**) { false }
@@ -31,26 +31,12 @@ module SolidObjects
31
31
 
32
32
  # @rbs () -> Integer
33
33
  def drain
34
- processed_count = 0
35
- started_at = monotonic_now
36
- @pass_exhausted = false
37
-
38
- loop do
39
- if processed_count >= SolidObjects.configuration.max_messages_per_activation_pass ||
40
- monotonic_now - started_at >= SolidObjects.configuration.max_activation_duration
41
- @pass_exhausted = true
42
- break
43
- end
44
-
45
- message = claim_next_message
46
- break unless message
47
-
48
- Executor.new(activation: self, message:).call
49
- processed_count += 1
50
- @last_used_at = monotonic_now
51
- end
34
+ drain_messages
35
+ end
52
36
 
53
- processed_count
37
+ # @rbs (message_id: Integer, deadline: Float) -> Integer
38
+ def drain_until(message_id:, deadline:)
39
+ drain_messages(message_id:, deadline:)
54
40
  end
55
41
 
56
42
  # @rbs () -> bool
@@ -60,6 +46,7 @@ module SolidObjects
60
46
  ClaimedMessage.where(
61
47
  instance_id: lease.instance_id,
62
48
  process_id: lease.owner_id,
49
+ activation_token: lease.activation_token,
63
50
  activation_generation: lease.generation
64
51
  ).exists?
65
52
  end
@@ -113,6 +100,33 @@ module SolidObjects
113
100
 
114
101
  attr_reader :actor_class
115
102
 
103
+ # @rbs (?message_id: Integer?, ?deadline: Float?) -> Integer
104
+ def drain_messages(message_id: nil, deadline: nil)
105
+ processed_count = 0
106
+ started_at = monotonic_now
107
+ @pass_exhausted = false
108
+
109
+ loop do
110
+ break if deadline && monotonic_now >= deadline
111
+
112
+ if processed_count >= SolidObjects.configuration.max_messages_per_activation_pass ||
113
+ monotonic_now - started_at >= SolidObjects.configuration.max_activation_duration
114
+ @pass_exhausted = true
115
+ break
116
+ end
117
+
118
+ message = claim_next_message
119
+ break unless message
120
+
121
+ Executor.new(activation: self, message:).call
122
+ processed_count += 1
123
+ @last_used_at = monotonic_now
124
+ break if message.id == message_id
125
+ end
126
+
127
+ processed_count
128
+ end
129
+
116
130
  # @rbs (Instance) -> Actor
117
131
  def build_actor(instance)
118
132
  state_data = actor_class.definition.migrate_state(instance.state_version, instance.state)
@@ -145,6 +159,7 @@ module SolidObjects
145
159
  message:,
146
160
  instance:,
147
161
  process_id: lease.owner_id,
162
+ activation_token: lease.activation_token,
148
163
  activation_generation: lease.generation,
149
164
  claimed_at: now
150
165
  )
@@ -161,6 +176,7 @@ module SolidObjects
161
176
  .first
162
177
  return unless claimed_message
163
178
  return if claimed_message.process_id == lease.owner_id &&
179
+ claimed_message.activation_token == lease.activation_token &&
164
180
  claimed_message.activation_generation == lease.generation
165
181
 
166
182
  message = claimed_message.message
@@ -13,17 +13,37 @@ module SolidObjects
13
13
 
14
14
  # @rbs () -> Activation?
15
15
  def claim_next
16
+ claim_from(candidate_instance_ids(database_adapter.database_now))
17
+ end
18
+
19
+ # @rbs (instance_id: Integer) -> Activation?
20
+ def claim(instance_id:)
21
+ claim_from([ instance_id ])
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :owner_id, :database_adapter
27
+
28
+ # @rbs (Array[Integer]) -> Activation?
29
+ def claim_from(instance_ids)
16
30
  lease = database_adapter.transaction do
17
31
  now = database_adapter.database_now
18
32
  claimed_lease = nil
19
- candidate_instance_ids(now).each do |instance_id|
33
+ instance_ids.each do |instance_id|
20
34
  instance = database_adapter.lock_candidates(
21
35
  Instance.where(id: instance_id)
22
36
  ).first
23
37
  next unless instance
24
38
  next unless claimable?(instance, now)
25
39
 
26
- claimed_lease = Lease.claim(instance:, owner_id:, now:, database_adapter:)
40
+ claimed_lease = Lease.claim(
41
+ instance:,
42
+ owner_id:,
43
+ activation_token: SecureRandom.uuid,
44
+ now:,
45
+ database_adapter:
46
+ )
27
47
  break if claimed_lease
28
48
  end
29
49
  claimed_lease
@@ -42,10 +62,6 @@ module SolidObjects
42
62
  raise
43
63
  end
44
64
 
45
- private
46
-
47
- attr_reader :owner_id, :database_adapter
48
-
49
65
  # @rbs (Time) -> Array[Integer]
50
66
  def candidate_instance_ids(now)
51
67
  (ready_instance_ids(now) + claimed_instance_ids(now)).uniq
@@ -150,7 +150,17 @@ module SolidObjects
150
150
  Context.current_message
151
151
  end
152
152
 
153
- # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> EffectIntent
153
+ # @rbs (Symbol | String, String, ?details: Hash[String | Symbol, untyped]) -> bot
154
+ def reject(code, message, details: {})
155
+ rejection_code = code.to_s
156
+ unless rejection_code.match?(/\A[a-z][a-z0-9_]*\z/)
157
+ raise ArgumentError, "rejection code must contain lowercase letters, digits, and underscores"
158
+ end
159
+
160
+ raise Rejected.new(code: rejection_code, message:, details:)
161
+ end
162
+
163
+ # @rbs (Symbol | String, ?on_success: Symbol | String?, ?on_failure: Symbol | String?, **untyped) -> nil
154
164
  def emit(name, on_success: nil, on_failure: nil, **arguments)
155
165
  validate_effect_callback!(on_success)
156
166
  validate_effect_callback!(on_failure)
@@ -162,9 +172,10 @@ module SolidObjects
162
172
  ).tap do |intent|
163
173
  effect_intents << intent
164
174
  end
175
+ nil
165
176
  end
166
177
 
167
- # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> ReminderIntent
178
+ # @rbs (Symbol | String, at: Time, ?every: Numeric?, ?missed: Symbol | String, arguments: Hash[Symbol | String, untyped]) -> nil
168
179
  def schedule(name, at:, every: nil, missed: :latest, arguments: {})
169
180
  interval_seconds = every&.to_f
170
181
  if interval_seconds && !interval_seconds.positive?
@@ -184,11 +195,13 @@ module SolidObjects
184
195
  ).tap do |intent|
185
196
  reminder_intents << intent
186
197
  end
198
+ nil
187
199
  end
188
200
 
189
- # @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> OutboundMessageIntent
201
+ # @rbs (Reference, Symbol | String, ?available_at: Time?, ?idempotency_key: String?, **untyped) -> nil
190
202
  def send_to(reference, message_name, available_at: nil, idempotency_key: nil, **arguments)
191
203
  stage_outbound_message(reference, message_name, arguments, available_at:, idempotency_key:)
204
+ nil
192
205
  end
193
206
 
194
207
  # @rbs (Symbol | String, Hash[String, untyped]) -> untyped
@@ -0,0 +1,57 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class CallerProcess
5
+ # @rbs @mutex: Thread::Mutex
6
+ # @rbs @process_id: Integer?
7
+ # @rbs @registry: ProcessRegistry?
8
+
9
+ # @rbs () -> void
10
+ def initialize
11
+ @mutex = Thread::Mutex.new
12
+ @process_id = nil
13
+ @registry = nil
14
+ end
15
+
16
+ # @rbs () -> ProcessRegistry
17
+ def process_registry
18
+ mutex.synchronize do
19
+ reset_after_fork
20
+ register unless reusable_registry?
21
+ registry.heartbeat
22
+ registry
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :mutex, :registry
29
+
30
+ # @rbs () -> void
31
+ def reset_after_fork
32
+ return if @process_id == ::Process.pid
33
+
34
+ @process_id = ::Process.pid
35
+ @registry = nil
36
+ end
37
+
38
+ # @rbs () -> bool
39
+ def reusable_registry?
40
+ return false unless registry&.process_record
41
+
42
+ registry.process_record.reload.shutdown_state == "running"
43
+ rescue ActiveRecord::RecordNotFound
44
+ false
45
+ end
46
+
47
+ # @rbs () -> ProcessRegistry
48
+ def register
49
+ @registry = ProcessRegistry.new
50
+ registry.register(
51
+ kind: "caller",
52
+ metadata: { execution: "synchronous" }
53
+ )
54
+ registry
55
+ end
56
+ end
57
+ end