solid_objects 0.14.0 → 0.14.2

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.
data/Rakefile CHANGED
@@ -32,6 +32,11 @@ task :steep do
32
32
  sh "bundle exec steep check"
33
33
  end
34
34
 
35
+ desc "Prove the at-least-once clause by crashing an effect worker at a sink"
36
+ task :at_least_once do
37
+ sh "bundle exec ruby examples/at_least_once/demo.rb"
38
+ end
39
+
35
40
  desc "Scan the Rails engine for security warnings"
36
41
  task :security do
37
42
  sh "bundle exec brakeman --force --no-pager -q ."
data/docs/architecture.md CHANGED
@@ -435,6 +435,45 @@ send_to(InventoryActor.ref("sku-123")).reserve(order_id: id, quantity: 2)
435
435
 
436
436
  The source actor commit never waits for the target. The outbox worker allocates the target's sequence after source commit. There is no global order across actors.
437
437
 
438
+ ## Registering effect and commit-action handlers
439
+
440
+ Register an effect handler during application boot. The stable effect ID is the
441
+ idempotency key for the provider call:
442
+
443
+ ```ruby
444
+ SolidObjects.register_effect(:charge_payment) do |arguments, context|
445
+ Payments.charge(
446
+ idempotency_key: context.id,
447
+ payment_id: arguments.fetch("payment_id"),
448
+ amount_cents: arguments.fetch("amount_cents")
449
+ )
450
+ end
451
+ ```
452
+
453
+ A success callback receives `effect_id:`, the originally staged `arguments:`,
454
+ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
455
+ `error:`, so an actor can correlate concurrent effects without storing a
456
+ separate callback ledger.
457
+
458
+ A commit action is registered the same way and runs inside the short fenced
459
+ transaction:
460
+
461
+ ```ruby
462
+ SolidObjects.register_commit_action(:complete_attempt) do |arguments, context|
463
+ AssessmentAttempt.find(arguments.fetch("attempt_id")).update!(
464
+ score: arguments.fetch("score"),
465
+ actor_message_id: context.message_id
466
+ )
467
+ end
468
+ ```
469
+
470
+ Commit actions require Solid Objects and `ActiveRecord::Base` to share one
471
+ connection pool, and may be invoked again after a database rollback, so keep
472
+ them deterministic, bounded, and database-only. When Solid Objects uses a
473
+ separate actor database, use `emit` and an idempotent effect consumer instead;
474
+ two databases cannot share one transaction.
475
+
476
+
438
477
  ## Reminders
439
478
 
440
479
  A reminder record contains actor identity, a reminder name, target message, JSON arguments, next run time, optional interval, status, and occurrence counter.
@@ -443,7 +482,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON
443
482
  schedule(at: 30.minutes.from_now).expire
444
483
  ```
445
484
 
446
- Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](../README.md#a-reminder-is-one-named-alarm-per-actor) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
485
+ Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, which is what makes re-arming safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
447
486
 
448
487
  When due, the scheduler locks the source instance and creates a normal mailbox
449
488
  row with an idempotency key derived from reminder ID and occurrence. The
data/docs/correctness.md CHANGED
@@ -93,6 +93,15 @@ end
93
93
  The guard prevents a repeated state transition. The effect consumer still
94
94
  deduplicates with `context.id`.
95
95
 
96
+ This clause is observable, not decorative. `bundle exec rake at_least_once`
97
+ crashes an effect worker between the external sink write and the
98
+ acknowledgement, restarts one after the liveness threshold, and shows the sink
99
+ reading 2 with deduplication off. Both deliveries carry the same `context.id`
100
+ at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
101
+ sink reads 1. The actor state commits exactly once in both runs. The source is
102
+ `examples/at_least_once/`; solid-objects-js runs the same proof with
103
+ `pnpm run test:at-least-once`.
104
+
96
105
  ## Atomic boundaries
97
106
 
98
107
  The following are atomic:
data/docs/operations.md CHANGED
@@ -23,6 +23,103 @@ process, its activations, and its claimed messages untouched, including when an
23
23
  application call overlaps the probe. A database busy enough to block cleanup
24
24
  reports a failed or warned check rather than raising out of the command.
25
25
 
26
+ ## Installing and upgrading
27
+
28
+ Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
29
+ notes, then update the gem:
30
+
31
+ ```bash
32
+ bundle update solid_objects
33
+ ```
34
+
35
+ If the `Gemfile` pins an exact version, update that constraint first and run
36
+ `bundle install`. Commit both `Gemfile.lock` and the copied Solid Objects
37
+ migrations.
38
+
39
+ Copy only migrations that the newer gem has added, migrate, and verify the
40
+ installation:
41
+
42
+ ```bash
43
+ bin/rails solid_objects:install:migrations
44
+ bin/rails db:migrate
45
+ bin/rails solid_objects:doctor
46
+ ```
47
+
48
+ The migration task skips engine migrations already present in the application
49
+ and gives new migrations host-specific timestamps. Inspect the resulting
50
+ `db/migrate/*.solid_objects.rb` files before applying them. Do not rerun
51
+ `generate solid_objects:install` during an upgrade because that also attempts
52
+ to regenerate the application initializer.
53
+
54
+ When Solid Objects uses a separate database configuration named `actors`, copy
55
+ and run migrations through that database's configured migration path:
56
+
57
+ ```bash
58
+ DATABASE=actors bin/rails solid_objects:install:migrations
59
+ bin/rails db:migrate:actors
60
+ bin/rails solid_objects:doctor
61
+ ```
62
+
63
+ For production, back up the actor database and run new migrations before
64
+ starting application or Solid Objects worker processes that require the new
65
+ schema. Restart the web and Solid Objects worker fleet after the bundle and
66
+ schema are current. For releases that change actor state versions, also follow
67
+ the [state migration and rolling-deployment guide](docs/state-migrations.md);
68
+ Rails schema migrations and actor state migrations are separate concerns.
69
+
70
+ ### Host application tooling
71
+
72
+ Installed engine migrations are copied as
73
+ `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables
74
+ `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than
75
+ editing their intentionally specialized hot tables:
76
+
77
+ ```yaml
78
+ Rails/CreateTableWithTimestamps:
79
+ Exclude:
80
+ - "db/migrate/*.solid_objects.rb"
81
+ ```
82
+
83
+ Solid Objects ships inline RBS signatures, not RBI files. Sorbet applications
84
+ can generate the gem RBI with:
85
+
86
+ ```bash
87
+ bundle exec tapioca gem solid_objects
88
+ ```
89
+
90
+ ### Running an extension in the same process
91
+
92
+ An extension gem can register its own long-running component, and
93
+ `solid_objects start` runs it beside the built-in roles. The component joins the
94
+ same supervision, the same replacement after a crash, and the same shutdown
95
+ timeout, so an operator deploys and monitors one process instead of two:
96
+
97
+ ```ruby
98
+ SolidObjects.configure do |configuration|
99
+ configuration.register_component { MyExtension::FlushEngine.new }
100
+ end
101
+ ```
102
+
103
+ Pass `count:` for more than one instance. The block runs once for each instance,
104
+ and again when the supervisor replaces a crashed one, so no two components share
105
+ an object.
106
+
107
+ A registered component answers four methods, the contract the built-in roles
108
+ already keep:
109
+
110
+ | Method | Purpose |
111
+ | --- | --- |
112
+ | `run` | Runs the loop. The supervisor calls it in its own thread |
113
+ | `request_shutdown` | Asks the loop to finish. It must make `run` return |
114
+ | `stopped?` | Reports whether the component already finished |
115
+ | `stop` | Forces cleanup when the shutdown timeout expires first |
116
+
117
+ The supervisor checks that contract when it builds the component, and a missing
118
+ method raises `ArgumentError` as the supervisor starts, rather than hanging a
119
+ shutdown later. Registration itself never calls the block, so a component is
120
+ free to need a database connection that the application does not have while it
121
+ boots.
122
+
26
123
  ## Runtime
27
124
 
28
125
  Start all configured roles:
@@ -31,12 +128,35 @@ Start all configured roles:
31
128
  bundle exec solid_objects start
32
129
  ```
33
130
 
34
- The command loads the host application's `app/actors` directories before
131
+ The generator and the migrations prepare the database and start nothing. A
132
+ process claims ready messages only after this command starts its roles, so an
133
+ application that serves web requests alone leaves every `async` message ready.
134
+ The message is durable and waits for the first process that runs the roles. A
135
+ direct call or an explicit `sync` needs no running role, because the caller's
136
+ own path executes it.
137
+
138
+ The engine loads the host application's `app/actors` directories in every
139
+ process that boots the application, and the command repeats that load before
35
140
  starting any runtime role, even when Rails eager loading is disabled. Actors in
36
141
  the conventional directory do not need initializer references. The targeted
37
142
  loader participates in Rails preparation callbacks so a development reload can
38
143
  replace a registered actor class without loading unrelated application code.
39
144
 
145
+ An actor registers itself as its class loads, and a web process resolves
146
+ actors by name for Cable subscriptions and component renders. Loading them in
147
+ every process is what lets a freshly booted web process serve a live card for
148
+ an actor no request in that process has rendered yet.
149
+
150
+ Worker and outbox counts can be overridden on the command line:
151
+
152
+ ```bash
153
+ bundle exec solid_objects start \
154
+ --workers 4 \
155
+ --effect-workers 2 \
156
+ --broadcast-workers 2 \
157
+ --reminder-schedulers 1
158
+ ```
159
+
40
160
  Inspect process records and clean stale ownership:
41
161
 
42
162
  ```bash
@@ -57,26 +177,45 @@ bundle exec solid_objects retry_dead_letter 123
57
177
 
58
178
  ## Configuration
59
179
 
60
- Important controls include:
61
-
62
- - `worker_count`
63
- - `effect_worker_count`
64
- - `broadcast_worker_count`
65
- - `reminder_scheduler_count`
66
- - `max_messages_per_activation_pass`
67
- - `max_activation_duration`
68
- - `idle_deactivation_timeout`
69
- - `lease_duration`
70
- - `lease_renewal_interval`
71
- - `polling_interval`
72
- - `idle_polling_interval`
73
- - `max_mailbox_length`
74
- - payload, state, and result byte limits
75
- - retry attempts and delay
76
- - heartbeat interval and alive threshold
77
- - message retention and per-actor-type overrides
78
- - opt-in actor-instance retention by actor type
79
- - stopped-process retention and prune batch size
180
+ Configure Solid Objects in `config/initializers/solid_objects.rb`:
181
+
182
+ ```ruby
183
+ SolidObjects.configure do |configuration|
184
+ configuration.worker_count = 4
185
+ configuration.lease_duration = 30.seconds
186
+ configuration.lease_renewal_interval = 10.seconds
187
+ configuration.max_messages_per_activation_pass = 50
188
+ configuration.max_activation_duration = 5.seconds
189
+ end
190
+ ```
191
+
192
+ | Setting | Default |
193
+ | --- | ---: |
194
+ | `polling_interval` | 0.1 seconds |
195
+ | `idle_polling_interval` | 1 second |
196
+ | `sync_polling_interval` | 0.05 seconds |
197
+ | `lease_duration` | 30 seconds |
198
+ | `lease_renewal_interval` | 10 seconds |
199
+ | `idle_deactivation_timeout` | 30 seconds |
200
+ | `max_messages_per_activation_pass` | 50 |
201
+ | `max_activation_duration` | 5 seconds |
202
+ | `max_mailbox_length` | 10,000 |
203
+ | `max_attempts` | 5 |
204
+ | `process_heartbeat_interval` | 15 seconds |
205
+ | `process_alive_threshold` | 60 seconds |
206
+ | `message_retention` | 30 days |
207
+ | `message_retention_by_actor_type` | `{}` |
208
+ | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
209
+ | `process_retention` | 7 days |
210
+ | `prune_batch_size` | 1,000 |
211
+ | `worker_count` | 1 |
212
+ | `effect_worker_count` | 1 |
213
+ | `broadcast_worker_count` | 1 |
214
+ | `reminder_scheduler_count` | 1 |
215
+
216
+ Payload, state, and result byte limits; retry delay; table prefix; logging;
217
+ wake-up; broadcast; database; and authorization adapters are also configurable.
218
+ Invalid lease intervals, component counts, and size limits fail fast at boot.
80
219
 
81
220
  Keep lease duration comfortably above renewal interval and expected database
82
221
  pause time. A handler can exceed the pass-duration budget because Ruby code is
@@ -187,6 +326,15 @@ transaction rejection, commit-action start/completion/failure, effect and
187
326
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
188
327
  retention pruning, process cleanup, and supervisor lifecycle.
189
328
 
329
+ `solid_objects.subscription.rejected` reports a rejected Cable subscription.
330
+ A rejection closes the socket and leaves the page holding a stale card, and
331
+ the browser cannot say which of the conditions applied. The event carries the
332
+ `reason`, the actor identity, and the `error_class` where an exception caused
333
+ it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
334
+ `invalid_component_token`, `malformed_component_registration`,
335
+ `missing_subscription_parameter`, or `unauthorized`. Exception messages are
336
+ excluded, because a component or payload failure can carry actor state.
337
+
190
338
  `solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
191
339
  already armed under the same name on the same actor, carrying the actor
192
340
  identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
data/docs/reminders.md ADDED
@@ -0,0 +1,112 @@
1
+ # Reminders
2
+
3
+ Reminders are Solid Objects' durable equivalent of the Durable Objects Alarms
4
+ API. One-shot and recurring alarms are actor-owned database records:
5
+
6
+ ```ruby
7
+ def schedule_evaluation
8
+ schedule(
9
+ at: 1.hour.from_now,
10
+ every: 1.hour,
11
+ missed: :latest
12
+ ).evaluate(account_id:)
13
+ end
14
+ ```
15
+
16
+ Use `missed: :latest` to coalesce missed occurrences or `missed: :all` to
17
+ enqueue each one.
18
+
19
+ ## A reminder is one named alarm per actor
20
+
21
+ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
22
+ already armed **moves the existing alarm** rather than adding a second one. The
23
+ database enforces this with a unique index on `(instance_id, name)`.
24
+
25
+ This is the same model as Orleans reminders and Durable Objects alarms, and it
26
+ is what makes a reminder safe to re-arm from a handler that may run more than
27
+ once. Without a key the name is the operation, so this is a data-loss bug:
28
+
29
+ ```ruby
30
+ # Wrong. Every entry overwrites the previous entry's alarm.
31
+ def add(entry:)
32
+ self.entries = entries + [ entry ]
33
+ schedule(at: entry.fetch("wait_until")).deliver
34
+ end
35
+ ```
36
+
37
+ Two entries leave one reminder. The earlier wake-up never happens, nothing
38
+ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
39
+
40
+ ## An alarm per item, with `key:`
41
+
42
+ Pass `key:` when an actor is waiting on several things at once. The key is your
43
+ own identifier for the item, and it names that item's alarm, so each item gets
44
+ one:
45
+
46
+ ```ruby
47
+ def add(entry:)
48
+ self.entries = entries + [ entry ]
49
+ schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
50
+ end
51
+ ```
52
+
53
+ Two entries now leave two reminders. Scheduling the same key again moves that
54
+ item's alarm and leaves the others alone, which is what makes a keyed reminder
55
+ as safe to re-arm as an unkeyed one. The operation still decides which handler
56
+ runs; the key only decides which alarm is which.
57
+
58
+ A key must be non-empty, and the name it becomes must fit the 191-character
59
+ column, which is checked on the composed name rather than the key alone so a
60
+ long operation and a short key are caught too.
61
+
62
+ The key is separated from the operation by a colon, so an operation may not hold
63
+ one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
64
+ one name, and the second would silently take the first one's alarm. A key may
65
+ hold colons of its own, because the operation before the first one cannot.
66
+
67
+ ## One alarm for a whole queue
68
+
69
+ A key per item is not always what you want. An actor that only ever needs to
70
+ know "what is next" can keep one alarm and let the handler drain everything now
71
+ due before arming the next:
72
+
73
+ ```ruby
74
+ def add(entry:)
75
+ self.entries = (entries + [ entry ]).sort_by { |item| item.fetch("wait_until") }
76
+ arm_next
77
+ end
78
+
79
+ def deliver
80
+ now = Time.current.to_i
81
+ due, pending = entries.partition { |item| item.fetch("wait_until") <= now }
82
+ due.each { |item| emit :send_push, **item.symbolize_keys }
83
+ self.entries = pending
84
+ arm_next
85
+ end
86
+
87
+ private
88
+
89
+ def arm_next
90
+ earliest = entries.first
91
+ return unless earliest
92
+
93
+ schedule(at: Time.at(earliest.fetch("wait_until"))).deliver
94
+ end
95
+ ```
96
+
97
+ That costs one reminder row instead of one per item, and a coalesced occurrence
98
+ cannot strand an entry because the handler drains by time rather than by alarm.
99
+ Prefer it when the queue is large and the items are interchangeable; prefer
100
+ `key:` when an item needs its own alarm that can be moved on its own.
101
+
102
+ Solid Objects has no `unschedule`. A reminder stops when its handler does not
103
+ re-arm it, and destroying an actor removes its reminders.
104
+
105
+ Self-scheduling actors should also have a low-frequency application reconciler.
106
+ It may read `SolidObjects::Instance.states_for`, `.without_pending_work`, and
107
+ `.orphaned`, but every repair must go through `async`. Never bulk-update actor
108
+ state around the lease and fencing checks.
109
+
110
+ Suspended actors should be reported rather than silently resumed. Spread large
111
+ repair batches with `available_at:` so reconciliation cannot stampede one
112
+ mailbox or the worker fleet.
data/docs/roadmap.md CHANGED
@@ -126,7 +126,13 @@
126
126
  untested end to end, which is how a raising payload block came to reject the
127
127
  subscription; it is now covered and confined, and the payload authorization
128
128
  context is resolved through `payload_authorization_context` rather than
129
- handing the block a raw Cable connection.
129
+ handing the block a raw Cable connection. Actor registration in a web process
130
+ was assumed rather than arranged: an actor registered only as a side effect
131
+ of its class loading, and only the worker CLI loaded the host's `app/actors`,
132
+ so a lazily loading web process rejected subscriptions for actors it could
133
+ serve until some earlier request happened to load the class. The engine now
134
+ loads them in every process, and a rejected subscription reports which
135
+ condition caused it instead of closing the socket silently.
130
136
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
131
137
  distributed per-actor rate limits and global admission control do not.
132
138
  - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
@@ -0,0 +1,14 @@
1
+ # rbs_inline: enabled
2
+
3
+ class DeliveryCounter < SolidObjects::Actor
4
+ actor_type "delivery-counter"
5
+
6
+ attribute :count, default: 0
7
+
8
+ # @rbs () -> Integer
9
+ def deliver
10
+ self.count += 1
11
+ emit :record
12
+ count
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "bundler/setup"
4
+ require "active_record"
5
+ require "solid_objects"
6
+
7
+ # Boots a standalone runtime against a shared SQLite file, the way the
8
+ # demo's parent and its crashing children all attach to one database.
9
+ module AtLeastOnceBoot
10
+ ROOT = File.expand_path("../..", __dir__)
11
+
12
+ # @rbs (String database_path) -> void
13
+ def self.call(database_path)
14
+ ActiveRecord::Base.establish_connection(
15
+ adapter: "sqlite3",
16
+ database: database_path,
17
+ pool: 5,
18
+ timeout: 5_000
19
+ )
20
+ ActiveRecord::Migration.verbose = false
21
+ migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
22
+
23
+ require "solid_objects/database_adapter"
24
+ %w[
25
+ record process instance message ready_message claimed_message
26
+ reminder effect broadcast dead_letter
27
+ ].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
28
+
29
+ SolidObjects.configuration.authorize_message = ->(**) { true }
30
+ SolidObjects.configuration.authorize_query = ->(**) { true }
31
+ SolidObjects.configuration.polling_interval = 0.01
32
+ SolidObjects.configuration.process_heartbeat_interval = 0.075
33
+ SolidObjects.configuration.process_alive_threshold = 0.3
34
+ SolidObjects.configuration.lease_duration = 0.25
35
+ SolidObjects.configuration.lease_renewal_interval = 0.05
36
+ end
37
+
38
+ # @rbs () -> void
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
+ CreateSolidObjectsTables.new.migrate(:up)
44
+ AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
45
+ RenameMessageDispatchColumns.new.migrate(:up)
46
+ end
47
+ end
@@ -0,0 +1,92 @@
1
+ # rbs_inline: enabled
2
+
3
+ # An executable proof for the at-least-once clause: a contract clause
4
+ # nobody can observe firing is decoration. Run with:
5
+ #
6
+ # bundle exec rake at_least_once
7
+ #
8
+ # Phase one crashes an effect worker between the external sink write and
9
+ # the acknowledgement, restarts one, and shows the sink reading 2 with
10
+ # deduplication off. Both deliveries carry the same stable effect id.
11
+ # Phase two repeats the crash with a guard on that id; the sink reads 1.
12
+ # The actor state commits exactly once in both phases.
13
+
14
+ require_relative "boot"
15
+ require_relative "actor"
16
+ require_relative "sink"
17
+ require "fileutils"
18
+ require "json"
19
+ require "rbconfig"
20
+ require "tmpdir"
21
+
22
+ directory = Dir.mktmpdir("solid_objects_at_least_once_")
23
+ database_path = File.join(directory, "state.sqlite3")
24
+ AtLeastOnceBoot.call(database_path)
25
+
26
+ # @rbs (String message) -> void
27
+ def prove(message)
28
+ raise "proof failed: #{message}" unless yield
29
+ end
30
+
31
+ # @rbs (String actor_id) -> void
32
+ def stage_one_delivery(actor_id)
33
+ DeliveryCounter.ref(actor_id).async.deliver
34
+ worker = SolidObjects::Worker.new
35
+ begin
36
+ worker.run_until_idle
37
+ ensure
38
+ worker.stop
39
+ end
40
+ end
41
+
42
+ # @rbs (database_path: String, sink_path: String, mode: String, deduplication: String) -> Integer?
43
+ def run_effect_worker(database_path:, sink_path:, mode:, deduplication:)
44
+ script = File.expand_path("effect_worker.rb", __dir__)
45
+ pid = Process.spawn(
46
+ RbConfig.ruby, script, database_path, sink_path, mode, deduplication,
47
+ chdir: AtLeastOnceBoot::ROOT
48
+ )
49
+ _pid, status = Process.wait2(pid)
50
+ status.exitstatus
51
+ end
52
+
53
+ # @rbs (database_path: String, sink_path: String, deduplication: String) -> void
54
+ def crash_then_recover(database_path:, sink_path:, deduplication:)
55
+ crash = run_effect_worker(database_path:, sink_path:, mode: "crash", deduplication:)
56
+ prove("the first delivery crashed before acknowledgement") { crash == 1 }
57
+ sleep 0.4
58
+ recovery = run_effect_worker(database_path:, sink_path:, mode: "complete", deduplication:)
59
+ prove("the second delivery completed and acknowledged") { recovery == 0 }
60
+ end
61
+
62
+ begin
63
+ sink_off = File.join(directory, "sink-dedup-off.json")
64
+ stage_one_delivery("dedup-off")
65
+ crash_then_recover(database_path:, sink_path: sink_off, deduplication: "off")
66
+ deliveries = AtLeastOnceSink.read(sink_off)
67
+ effect_ids = deliveries.map { |delivery| delivery.fetch("effect_id") }
68
+ state_off = SolidObjects::Instance.find_by!(actor_id: "dedup-off").state.fetch("count")
69
+ prove("the state commit happened exactly once") { state_off == 1 }
70
+ prove("the sink observed the duplicate") { deliveries.length == 2 }
71
+ prove("both deliveries carried the same stable effect id") { effect_ids.uniq.length == 1 }
72
+
73
+ sink_on = File.join(directory, "sink-dedup-on.json")
74
+ stage_one_delivery("dedup-on")
75
+ crash_then_recover(database_path:, sink_path: sink_on, deduplication: "on")
76
+ guarded = AtLeastOnceSink.read(sink_on)
77
+ state_on = SolidObjects::Instance.find_by!(actor_id: "dedup-on").state.fetch("count")
78
+ prove("the state commit happened exactly once") { state_on == 1 }
79
+ prove("the stable effect id absorbed the duplicate") { guarded.length == 1 }
80
+
81
+ puts JSON.pretty_generate(
82
+ duplicate: {
83
+ state_commits: state_off,
84
+ sink_deliveries: deliveries.length,
85
+ same_effect_id: effect_ids.uniq.length == 1,
86
+ attempts: deliveries.map { |delivery| delivery.fetch("attempt") }
87
+ },
88
+ remedy: { state_commits: state_on, sink_deliveries: guarded.length }
89
+ )
90
+ ensure
91
+ FileUtils.remove_entry(directory) if directory
92
+ end
@@ -0,0 +1,40 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "boot"
4
+ require_relative "actor"
5
+ require_relative "sink"
6
+
7
+ database_path, sink_path, mode, deduplication = ARGV
8
+ raise ArgumentError, "usage: effect_worker.rb DATABASE SINK crash|complete on|off" unless deduplication
9
+
10
+ AtLeastOnceBoot.call(database_path.to_s)
11
+
12
+ SolidObjects.register_effect(:record) do |_arguments, context|
13
+ AtLeastOnceSink.record(
14
+ path: sink_path.to_s,
15
+ effect_id: context.id,
16
+ attempt: context.attempt,
17
+ deduplication: deduplication.to_sym
18
+ )
19
+ # A crash between the external write and the acknowledgement: the sink
20
+ # has the delivery, the effect row never completes.
21
+ Process.exit!(1) if mode == "crash"
22
+ nil
23
+ end
24
+
25
+ # Production runs this on the dead-process-cleanup interval; the demo runs
26
+ # it once, after the liveness threshold, to release the crashed claim.
27
+ SolidObjects::ProcessRegistry.cleanup_dead
28
+
29
+ effect_executor = SolidObjects::EffectExecutor.new
30
+ begin
31
+ worked = false
32
+ 200.times do
33
+ worked = effect_executor.run_once
34
+ break if worked
35
+ sleep 0.01
36
+ end
37
+ raise "no effect became claimable" unless worked
38
+ ensure
39
+ effect_executor.stop
40
+ end
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "json"
4
+
5
+ # The external system in the at-least-once demo: a JSON file that records
6
+ # every delivery it accepts. With deduplication :off it accepts everything,
7
+ # which makes an at-least-once duplicate visible. With deduplication :on it
8
+ # accepts each stable effect id once, which is the documented remedy.
9
+ module AtLeastOnceSink
10
+ # @rbs (String path) -> Array[Hash[String, untyped]]
11
+ def self.read(path)
12
+ JSON.parse(File.read(path))
13
+ rescue Errno::ENOENT
14
+ []
15
+ end
16
+
17
+ # @rbs (path: String, effect_id: String, attempt: Integer, deduplication: Symbol) -> bool
18
+ def self.record(path:, effect_id:, attempt:, deduplication:)
19
+ deliveries = read(path)
20
+ seen = deliveries.any? { |delivery| delivery.fetch("effect_id") == effect_id }
21
+ return false if deduplication == :on && seen
22
+
23
+ deliveries << { "effect_id" => effect_id, "attempt" => attempt }
24
+ File.write(path, JSON.pretty_generate(deliveries))
25
+ true
26
+ end
27
+ end