solid_objects 0.16.1 → 0.17.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/README.md +9 -0
  4. data/Rakefile +5 -0
  5. data/app/models/solid_objects/message.rb +15 -0
  6. data/docs/agents.md +253 -0
  7. data/docs/architecture.md +16 -6
  8. data/docs/authorization.md +10 -3
  9. data/docs/observability.md +186 -0
  10. data/docs/operations.md +10 -8
  11. data/docs/realtime.md +4 -3
  12. data/docs/reminders.md +3 -1
  13. data/docs/roadmap.md +17 -1
  14. data/docs/security.md +2 -2
  15. data/docs/transmission.md +17 -6
  16. data/docs/virtual-actors.md +226 -0
  17. data/examples/quickstart/README.md +246 -0
  18. data/examples/quickstart/app/actors/ticket_sale.rb +22 -0
  19. data/examples/quickstart/smoke.rb +421 -0
  20. data/lib/solid_objects/activation.rb +6 -3
  21. data/lib/solid_objects/actor.rb +29 -2
  22. data/lib/solid_objects/actor_channel.rb +9 -1
  23. data/lib/solid_objects/actor_snapshot.rb +14 -8
  24. data/lib/solid_objects/broadcast_executor.rb +1 -0
  25. data/lib/solid_objects/client.rb +15 -1
  26. data/lib/solid_objects/configuration.rb +4 -0
  27. data/lib/solid_objects/database_adapters/sqlite.rb +7 -1
  28. data/lib/solid_objects/diagnostics.rb +83 -0
  29. data/lib/solid_objects/effect_executor.rb +4 -0
  30. data/lib/solid_objects/errors.rb +3 -0
  31. data/lib/solid_objects/executor.rb +34 -16
  32. data/lib/solid_objects/instrumentation.rb +22 -1
  33. data/lib/solid_objects/log_subscriber.rb +1 -1
  34. data/lib/solid_objects/mailbox.rb +5 -1
  35. data/lib/solid_objects/message_reference.rb +9 -9
  36. data/lib/solid_objects/observer_registry.rb +47 -0
  37. data/lib/solid_objects/payload_broadcast.rb +9 -8
  38. data/lib/solid_objects/reference.rb +19 -0
  39. data/lib/solid_objects/reminder_scheduler.rb +5 -0
  40. data/lib/solid_objects/state_snapshot.rb +1 -0
  41. data/lib/solid_objects/supervisor.rb +3 -6
  42. data/lib/solid_objects/synchronous_invocation.rb +1 -21
  43. data/lib/solid_objects/telemetry.rb +137 -0
  44. data/lib/solid_objects/version.rb +1 -1
  45. data/lib/solid_objects/wake_up_adapters/postgresql.rb +1 -2
  46. data/lib/solid_objects/wake_up_adapters/redis.rb +1 -2
  47. data/lib/solid_objects/worker.rb +1 -2
  48. data/lib/solid_objects.rb +10 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +9 -0
  50. data/sig/generated/lib/solid_objects/actor_channel.rbs +3 -0
  51. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +7 -2
  52. data/sig/generated/lib/solid_objects/client.rbs +3 -0
  53. data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
  54. data/sig/generated/lib/solid_objects/diagnostics.rbs +25 -0
  55. data/sig/generated/lib/solid_objects/errors.rbs +3 -0
  56. data/sig/generated/lib/solid_objects/executor.rbs +10 -2
  57. data/sig/generated/lib/solid_objects/message_reference.rbs +6 -6
  58. data/sig/generated/lib/solid_objects/observer_registry.rbs +29 -0
  59. data/sig/generated/lib/solid_objects/payload_broadcast.rbs +2 -4
  60. data/sig/generated/lib/solid_objects/reference.rbs +9 -0
  61. data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +0 -3
  62. data/sig/generated/lib/solid_objects/telemetry.rbs +30 -0
  63. data/sig/generated/lib/solid_objects.rbs +3 -0
  64. data/sig/generated/models/solid_objects/message.rbs +3 -0
  65. data/sig/public/json_value.rbs +3 -0
  66. data/sig/public/telemetry.rbs +49 -0
  67. data/sig/support/framework.rbs +5 -0
  68. metadata +24 -9
@@ -0,0 +1,246 @@
1
+ # Rails quickstart
2
+
3
+ This recipe puts Solid Objects into a new Rails application. It uses SQLite,
4
+ one actor, and one reminder. A person or a coding agent can follow it on a
5
+ clean machine.
6
+
7
+ The recipe shows three behaviors:
8
+
9
+ - Concurrent calls to one actor identity commit one at a time.
10
+ - The actor state stays in the application's SQL database.
11
+ - A reminder that is due while the runtime is stopped runs after the runtime
12
+ starts again.
13
+
14
+ `bundle exec rake quickstart` runs the same recipe against a gem that it builds
15
+ from this repository. See [What the check proves](#what-the-check-proves).
16
+
17
+ ## Requirements
18
+
19
+ - Ruby 3.3 or newer
20
+ - Rails 7.1 or newer
21
+ - SQLite 3.35 or newer
22
+
23
+ Solid Objects needs Rails. It is not a Ruby library that you can use without
24
+ Rails.
25
+
26
+ ## 1. Create the application
27
+
28
+ ```bash
29
+ rails new ticket_demo
30
+ cd ticket_demo
31
+ ```
32
+
33
+ The `json` gem 3.x works only with Active Support 8.1.4 or newer. Solid
34
+ Objects stores its state in JSON columns, so an older Active Support with
35
+ `json` 3.x fails on every actor call. A new Rails application resolves Rails
36
+ 8.1.4 or newer, which works. For an older Rails application, read
37
+ [Installing and upgrading](../../docs/operations.md#installing-and-upgrading)
38
+ before you continue.
39
+
40
+ ## 2. Install Solid Objects
41
+
42
+ ```bash
43
+ bundle add solid_objects
44
+ bin/rails generate solid_objects:install
45
+ bin/rails db:migrate
46
+ bin/rails solid_objects:doctor
47
+ ```
48
+
49
+ The generator writes `config/initializers/solid_objects.rb` and copies the
50
+ migrations. The migrations add the Solid Objects tables to the application's
51
+ existing database.
52
+
53
+ The generated initializer denies every operation. The doctor reports this
54
+ condition as a warning:
55
+
56
+ ```text
57
+ WARN authorization: all five policies denied a neutral context; review the generated initializer before use
58
+ ```
59
+
60
+ On SQLite, the doctor also warns that the runtime polls for work. SQLite has no
61
+ notification channel, so a commit in one process cannot wake another process.
62
+ The runtime finds the work at the next poll.
63
+
64
+ ## 3. Grant only what the local demo needs
65
+
66
+ The demo calls the actor and reads its state from a console. It needs the
67
+ message policy and the query policy. In
68
+ `config/initializers/solid_objects.rb`, change these two lines:
69
+
70
+ ```ruby
71
+ configuration.authorize_message = ->(**) { false }
72
+ configuration.authorize_query = ->(**) { false }
73
+ ```
74
+
75
+ to:
76
+
77
+ ```ruby
78
+ configuration.authorize_message = ->(**) { true }
79
+ configuration.authorize_query = ->(**) { true }
80
+ ```
81
+
82
+ Do not change `authorize_destroy`, `authorize_subscription`,
83
+ `authorize_administration`, or `authorize_transmission`. They stay denied.
84
+
85
+ These two grants are for a local demo only. They let any caller send any
86
+ message to any actor and read any actor state. A production policy must bind
87
+ the actor type, the actor ID, and the operation to the authenticated user or
88
+ tenant. Pass that principal at each call site:
89
+
90
+ ```ruby
91
+ TicketSale.ref(event.id.to_s).hold(
92
+ buyer: Current.user.id.to_s,
93
+ authorization_context: Current.user
94
+ )
95
+ ```
96
+
97
+ Then check it in the policy. `can_buy_tickets_for?` is an example method of
98
+ your application:
99
+
100
+ ```ruby
101
+ configuration.authorize_message = lambda do |actor_type:, actor_id:, operation:, authorization_context:, **|
102
+ user = authorization_context
103
+
104
+ actor_type == "TicketSale" &&
105
+ operation == "hold" &&
106
+ user.present? &&
107
+ user.can_buy_tickets_for?(actor_id)
108
+ end
109
+ ```
110
+
111
+ The reminder that calls `expire` comes from a committed runtime row. It does
112
+ not go through `authorize_message` again.
113
+
114
+ [Authorization](../../docs/authorization.md) describes every policy and its
115
+ arguments.
116
+
117
+ ## 4. Add the actor
118
+
119
+ Put this class in `app/actors/ticket_sale.rb`:
120
+
121
+ ```ruby
122
+ class TicketSale < SolidObjects::Actor
123
+ attribute :available, default: 1
124
+ attribute :holds, default: -> { {} }
125
+
126
+ def hold(buyer:)
127
+ return { held: false, available: } if available.zero? || holds.key?(buyer)
128
+
129
+ self.available -= 1
130
+ self.holds = holds.merge(buyer => Time.current.to_i)
131
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
132
+ { held: true, available: }
133
+ end
134
+
135
+ def expire(buyer:)
136
+ return available unless holds.key?(buyer)
137
+
138
+ self.holds = holds.except(buyer)
139
+ self.available += 1
140
+ end
141
+ end
142
+ ```
143
+
144
+ One actor owns the state of one event. The event ID is the actor ID. A
145
+ successful `hold` stores the hold and a keyed ten-minute reminder in the same
146
+ commit as the state change. The `expire` method checks that the hold still
147
+ exists, so a second delivery of the same reminder does not add a ticket.
148
+
149
+ ## 5. Start the runtime
150
+
151
+ Direct calls do not need a runtime process. The caller runs the turn. Reminders,
152
+ `async` messages, effects, and broadcasts need the runtime:
153
+
154
+ ```bash
155
+ bundle exec solid_objects start
156
+ ```
157
+
158
+ When the runtime is stopped, due work stays in the database. Nothing else runs
159
+ it. Run the runtime as a separate process in every environment that uses
160
+ reminders.
161
+
162
+ ## 6. Hold a ticket
163
+
164
+ Open a second terminal:
165
+
166
+ ```bash
167
+ bin/rails console
168
+ ```
169
+
170
+ ```ruby
171
+ sale = TicketSale.ref("concert")
172
+ sale.hold(buyer: "ada")
173
+ sale.hold(buyer: "grace")
174
+ sale.available
175
+ ```
176
+
177
+ The first call returns `{"held" => true, "available" => 0}`. The second call
178
+ returns `{"held" => false, "available" => 0}`. A synchronous call returns the
179
+ committed result as JSON data, so the keys are strings.
180
+
181
+ ## 7. Restart the runtime before the reminder runs
182
+
183
+ 1. Stop the runtime with Ctrl-C before ten minutes pass.
184
+ 2. Wait until the ten minutes pass.
185
+ 3. In the console, read `sale.available`. The value is still `0`, because no
186
+ process ran the reminder.
187
+ 4. Start the runtime again with `bundle exec solid_objects start`.
188
+ 5. Read `sale.available` again. The runtime runs the due reminder, and the value
189
+ is `1`.
190
+
191
+ ## Guarantees in this demo
192
+
193
+ - Calls to one actor ID commit in order, one at a time. Different actor IDs can
194
+ run concurrently.
195
+ - Delivery is at least once. A handler can run again after a crash. The guard
196
+ in `expire` makes a second delivery harmless.
197
+ - Each turn commits the state and the reminder together, or commits neither.
198
+ - The demo has no external effects. An external effect, such as an email or a
199
+ payment, can repeat. It must use the stable effect ID or another durable
200
+ idempotency key.
201
+ - One actor ID is a sequential bottleneck. Do not use one actor ID for the
202
+ whole application.
203
+
204
+ [Correctness](../../docs/correctness.md) gives the full contract.
205
+
206
+ ## What the check proves
207
+
208
+ `bundle exec rake quickstart` runs [`smoke.rb`](smoke.rb). The check does these
209
+ steps:
210
+
211
+ 1. It confirms that this recipe and every `TicketSale` sample in the README and
212
+ in `docs/` show the exact actor in
213
+ [`app/actors/ticket_sale.rb`](app/actors/ticket_sale.rb).
214
+ 2. It builds the gem with `gem build`.
215
+ 3. It runs `rails new --minimal` with SQLite in a temporary directory and runs
216
+ `bundle install` into a temporary bundle path.
217
+ 4. It copies the built gem into `vendor/cache`, adds
218
+ `gem "solid_objects", "= <version>"` to the `Gemfile`, and runs
219
+ `bundle install --local`. The application cannot get the gem from the
220
+ repository or from rubygems.org.
221
+ 5. It confirms which gem the application loads:
222
+ - the `Gemfile` has no `path:` option for `solid_objects`;
223
+ - the `Gemfile.lock` checksum is the checksum of the built gem;
224
+ - the loaded gem files are in the temporary bundle path, not in the
225
+ repository;
226
+ - the installed gem file has the checksum of the built gem.
227
+ 6. It runs the install generator, the migrations, and the doctor. Then it
228
+ writes the actor and the two demo grants. It confirms that the other
229
+ policies stay denied.
230
+ 7. It starts `bundle exec solid_objects start`. Eight separate `bin/rails runner`
231
+ processes wait at a barrier and then hold the same event at the same time.
232
+ The check confirms that exactly one buyer holds the only ticket and that the
233
+ durable state agrees.
234
+ 8. It stops the runtime. One more process places a hold for a second event. That
235
+ process shifts its own clock back with `travel_to`, so the ten-minute
236
+ reminder is due eight seconds later. The reminder scheduler compares due
237
+ times with the database clock, so the runtime does not need a shifted
238
+ clock.
239
+ 9. It waits until the reminder is past due and confirms that the hold is still
240
+ there while no runtime runs. Then it starts the runtime again and confirms
241
+ that the reminder released the hold exactly once.
242
+ 10. It stops every child process and removes the temporary directory, also
243
+ when a step fails.
244
+
245
+ The check needs network access for `rails new` and `bundle install`. It takes
246
+ one to three minutes. The `quickstart` CI job runs it on every push.
@@ -0,0 +1,22 @@
1
+ # rbs_inline: enabled
2
+
3
+ class TicketSale < SolidObjects::Actor
4
+ attribute :available, default: 1
5
+ attribute :holds, default: -> { {} }
6
+
7
+ def hold(buyer:)
8
+ return { held: false, available: } if available.zero? || holds.key?(buyer)
9
+
10
+ self.available -= 1
11
+ self.holds = holds.merge(buyer => Time.current.to_i)
12
+ schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
13
+ { held: true, available: }
14
+ end
15
+
16
+ def expire(buyer:)
17
+ return available unless holds.key?(buyer)
18
+
19
+ self.holds = holds.except(buyer)
20
+ self.available += 1
21
+ end
22
+ end
@@ -0,0 +1,421 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "bundler"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+ require "rbconfig"
8
+ require "rubygems/package"
9
+ require "timeout"
10
+ require "tmpdir"
11
+
12
+ class QuickstartSmoke
13
+ class Failure < StandardError; end
14
+
15
+ HoldProcess = Data.define(:buyer, :pid, :input, :output)
16
+
17
+ REPOSITORY_ROOT = File.expand_path("../..", __dir__)
18
+ QUICKSTART_ROOT = __dir__
19
+ ACTOR_PATH = File.join(QUICKSTART_ROOT, "app/actors/ticket_sale.rb")
20
+ RECIPE_PATH = File.join(QUICKSTART_ROOT, "README.md")
21
+ INITIALIZER = "config/initializers/solid_objects.rb"
22
+ DEMO_GRANTS = {
23
+ "configuration.authorize_message = ->(**) { false }" => "configuration.authorize_message = ->(**) { true }",
24
+ "configuration.authorize_query = ->(**) { false }" => "configuration.authorize_query = ->(**) { true }"
25
+ }.freeze
26
+ DENIED_POLICIES = %w[authorize_destroy authorize_subscription authorize_administration authorize_transmission].freeze
27
+ CONCURRENT_BUYERS = 8
28
+ RESTART_DEADLINE_SECONDS = 8
29
+ COMMAND_TIMEOUT_SECONDS = 600
30
+ RECOVERY_TIMEOUT_SECONDS = 60
31
+
32
+ HOLD_PROGRAM = <<~RUBY
33
+ $stdout.sync = true
34
+ puts "ready"
35
+ $stdin.gets
36
+ puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).hold(buyer: ARGV.fetch(1)))
37
+ RUBY
38
+
39
+ SHIFTED_CLOCK_HOLD_PROGRAM = <<~RUBY
40
+ require "active_support/testing/time_helpers"
41
+ include ActiveSupport::Testing::TimeHelpers
42
+ travel_to(Time.current - 10.minutes + Integer(ARGV.fetch(2)).seconds) do
43
+ puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).hold(buyer: ARGV.fetch(1)))
44
+ end
45
+ RUBY
46
+
47
+ STATE_PROGRAM = <<~RUBY
48
+ puts JSON.generate(TicketSale.ref(ARGV.fetch(0)).snapshot.to_h)
49
+ RUBY
50
+
51
+ RECOVERY_PROGRAM = <<~RUBY
52
+ sale = TicketSale.ref(ARGV.fetch(0))
53
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Integer(ARGV.fetch(1))
54
+ state = sale.snapshot.to_h
55
+ until state.fetch("holds").empty? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
56
+ sleep 0.25
57
+ state = sale.snapshot.to_h
58
+ end
59
+ puts JSON.generate(state)
60
+ RUBY
61
+
62
+ RESOLUTION_PROGRAM = <<~RUBY
63
+ specification = Gem.loaded_specs.fetch("solid_objects")
64
+ puts JSON.generate(
65
+ version: SolidObjects::VERSION,
66
+ rails: Rails.version,
67
+ json: JSON::VERSION,
68
+ full_gem_path: specification.full_gem_path,
69
+ cache_file: specification.cache_file,
70
+ loaded_feature: $LOADED_FEATURES.find { |feature| feature.end_with?("/solid_objects/version.rb") }
71
+ )
72
+ RUBY
73
+
74
+ # @rbs @root: String
75
+ # @rbs @log_directory: String
76
+ # @rbs @application_path: String
77
+ # @rbs @bundle_path: String
78
+ # @rbs @child_pids: Array[Integer]
79
+ # @rbs @started_at: Float
80
+
81
+ # @rbs () -> void
82
+ def initialize
83
+ @root = Dir.mktmpdir("solid_objects_quickstart_")
84
+ @log_directory = File.join(@root, "logs")
85
+ @application_path = File.join(@root, "ticket_demo")
86
+ @bundle_path = File.join(@root, "bundle")
87
+ @child_pids = []
88
+ @started_at = monotonic_now
89
+ end
90
+
91
+ # @rbs () -> void
92
+ def call
93
+ actor_source = verify_recipe
94
+ artifact = build_gem
95
+ generate_application
96
+ install_bundle(artifact)
97
+ resolution = verify_resolution(artifact)
98
+ install_solid_objects(actor_source)
99
+ concurrency = prove_concurrent_holds
100
+ restart = prove_restart_recovery
101
+ puts JSON.pretty_generate(
102
+ artifact: { version: artifact.fetch(:version), sha256: artifact.fetch(:sha256), resolved_from: resolution.fetch("full_gem_path") },
103
+ application: { rails: resolution.fetch("rails"), json: resolution.fetch("json") },
104
+ concurrency:,
105
+ restart:,
106
+ seconds: (monotonic_now - @started_at).round(1)
107
+ )
108
+ ensure
109
+ stop_children
110
+ FileUtils.remove_entry(@root) if File.exist?(@root)
111
+ end
112
+
113
+ private
114
+
115
+ # @rbs () -> String
116
+ def verify_recipe
117
+ prove("examples/quickstart/README.md exists") { File.file?(RECIPE_PATH) }
118
+ prove("examples/quickstart/app/actors/ticket_sale.rb exists") { File.file?(ACTOR_PATH) }
119
+ actor_source = File.read(ACTOR_PATH).delete_prefix("# rbs_inline: enabled\n\n")
120
+ recipe = File.read(RECIPE_PATH)
121
+ prove("the recipe shows the exact actor that the check runs") { recipe.include?(actor_source) }
122
+ DEMO_GRANTS.each_value do |grant|
123
+ prove("the recipe shows the demo grant #{grant}") { recipe.include?(grant) }
124
+ end
125
+ published_samples.each do |path, sample|
126
+ prove("#{path} shows the same TicketSale actor") { sample == actor_source.strip }
127
+ end
128
+ actor_source
129
+ end
130
+
131
+ # @rbs () -> Array[[String, String]]
132
+ def published_samples
133
+ paths = Dir[File.join(REPOSITORY_ROOT, "{README.md,docs/**/*.md,examples/**/*.md}")]
134
+ paths.flat_map do |path|
135
+ relative_path = path.delete_prefix("#{REPOSITORY_ROOT}/")
136
+ File.read(path).scan(/```ruby\n(class TicketSale < SolidObjects::Actor\n.*?\nend)\n```/m).map do |(sample)|
137
+ [ relative_path, sample ]
138
+ end
139
+ end
140
+ end
141
+
142
+ # @rbs () -> Hash[Symbol, String]
143
+ def build_gem
144
+ artifact_directory = File.join(@root, "artifact")
145
+ FileUtils.mkdir_p(artifact_directory)
146
+ version = File.read(File.join(REPOSITORY_ROOT, "lib/solid_objects/version.rb"))[/VERSION = "([^"]+)"/, 1] ||
147
+ raise(Failure, "lib/solid_objects/version.rb declares no version")
148
+ path = File.join(artifact_directory, "solid_objects-#{version}.gem")
149
+ run_in_repository("gem-build", RbConfig.ruby, gem_executable, "build", "solid_objects.gemspec", "--output", path)
150
+ prove("the built gem declares version #{version}") { Gem::Package.new(path).spec.version.to_s == version }
151
+ { path:, version:, sha256: Digest::SHA256.file(path).hexdigest }
152
+ end
153
+
154
+ # @rbs () -> void
155
+ def generate_application
156
+ run_in_repository(
157
+ "rails-new",
158
+ RbConfig.ruby, Gem.bin_path("railties", "rails"), "new", @application_path,
159
+ "--minimal", "--skip-bundle", "--skip-git", "--skip-test", "--skip-system-test", "--quiet"
160
+ )
161
+ end
162
+
163
+ # @rbs (Hash[Symbol, String]) -> void
164
+ def install_bundle(artifact)
165
+ gemfile = File.join(@application_path, "Gemfile")
166
+ run_in_application("bundle-install", RbConfig.ruby, bundle_executable, "install")
167
+ cache_directory = File.join(@application_path, "vendor/cache")
168
+ FileUtils.mkdir_p(cache_directory)
169
+ FileUtils.cp(artifact.fetch(:path), cache_directory)
170
+ File.write(gemfile, "#{File.read(gemfile)}\ngem \"solid_objects\", \"= #{artifact.fetch(:version)}\"\n")
171
+ run_in_application("bundle-install-local", RbConfig.ruby, bundle_executable, "install", "--local")
172
+ end
173
+
174
+ # @rbs (Hash[Symbol, String]) -> Hash[String, String]
175
+ def verify_resolution(artifact)
176
+ gemfile = File.read(File.join(@application_path, "Gemfile"))
177
+ prove("the Gemfile names no path dependency") { !gemfile.match?(/^\s*gem\s+"solid_objects".*path:/) }
178
+ lockfile = File.read(File.join(@application_path, "Gemfile.lock"))
179
+ prove("Gemfile.lock records the checksum of the built gem") do
180
+ lockfile.include?("solid_objects (#{artifact.fetch(:version)}) sha256=#{artifact.fetch(:sha256)}")
181
+ end
182
+ resolution = JSON.parse(rails_runner("resolution", RESOLUTION_PROGRAM).lines.last)
183
+ prove("the application loads the built version") { resolution.fetch("version") == artifact.fetch(:version) }
184
+ prove("the gem resolves inside the isolated bundle") { resolution.fetch("full_gem_path").start_with?(@bundle_path) }
185
+ loaded_feature = resolution.fetch("loaded_feature").to_s
186
+ prove("the gem loads its files from the isolated bundle") { loaded_feature.start_with?(@bundle_path) }
187
+ prove("the gem does not resolve to the repository") { !loaded_feature.start_with?(REPOSITORY_ROOT) }
188
+ prove("the installed gem is the built artifact") do
189
+ Digest::SHA256.file(resolution.fetch("cache_file")).hexdigest == artifact.fetch(:sha256)
190
+ end
191
+ resolution
192
+ end
193
+
194
+ # @rbs (String) -> void
195
+ def install_solid_objects(actor_source)
196
+ run_in_application("generate", RbConfig.ruby, "bin/rails", "generate", "solid_objects:install")
197
+ run_in_application("migrate", RbConfig.ruby, "bin/rails", "db:migrate")
198
+ doctor = run_in_application("doctor", RbConfig.ruby, "bin/rails", "solid_objects:doctor")
199
+ prove("the doctor warns that every policy denies by default") { doctor.include?("all five policies denied") }
200
+ actor_path = File.join(@application_path, "app/actors/ticket_sale.rb")
201
+ FileUtils.mkdir_p(File.dirname(actor_path))
202
+ File.write(actor_path, actor_source)
203
+ grant_demo_policies
204
+ end
205
+
206
+ # @rbs () -> void
207
+ def grant_demo_policies
208
+ path = File.join(@application_path, INITIALIZER)
209
+ initializer = File.read(path)
210
+ DEMO_GRANTS.each do |denial, grant|
211
+ prove("the generated initializer denies with #{denial}") { initializer.include?(denial) }
212
+ initializer = initializer.sub(denial, grant)
213
+ end
214
+ DENIED_POLICIES.each do |policy|
215
+ prove("#{policy} stays denied") { initializer.include?("configuration.#{policy} = ->(**) { false }") }
216
+ end
217
+ File.write(path, initializer)
218
+ end
219
+
220
+ # @rbs () -> Hash[Symbol, Integer]
221
+ def prove_concurrent_holds
222
+ runtime = start_runtime("runtime-concurrency")
223
+ buyers = (1..CONCURRENT_BUYERS).map { |number| "buyer-#{number}" }
224
+ holders = buyers.map { |buyer| spawn_hold(event: "concert", buyer:) }
225
+ holders.each { |holder| await_ready(holder) }
226
+ holders.each do |holder|
227
+ holder.input.puts("go")
228
+ holder.input.close
229
+ end
230
+ results = holders.to_h { |holder| [ holder.buyer, read_hold(holder) ] }
231
+ prove("the runtime kept running during the concurrent holds") { alive?(runtime) }
232
+ stop_runtime(runtime)
233
+ winners = results.select { |_, result| result.fetch("held") }.keys
234
+ state = read_state("concert")
235
+ prove("exactly one concurrent buyer held the only ticket") { winners.length == 1 }
236
+ prove("no concurrent update was lost") do
237
+ state.fetch("available").zero? && state.fetch("holds").keys == winners
238
+ end
239
+ { calls: buyers.length, held: winners.length, available: state.fetch("available") }
240
+ end
241
+
242
+ # @rbs () -> Hash[Symbol, Integer]
243
+ def prove_restart_recovery
244
+ hold = JSON.parse(
245
+ rails_runner("shifted-hold", SHIFTED_CLOCK_HOLD_PROGRAM, "matinee", "ada", RESTART_DEADLINE_SECONDS.to_s).lines.last
246
+ )
247
+ due_at = Time.now + RESTART_DEADLINE_SECONDS
248
+ prove("the restart hold succeeded while the runtime was stopped") { hold.fetch("held") }
249
+ sleep [ due_at + 2 - Time.now, 0 ].max
250
+ stopped = read_state("matinee")
251
+ prove("the due reminder waited while no runtime ran") do
252
+ stopped.fetch("available").zero? && stopped.fetch("holds").key?("ada")
253
+ end
254
+ runtime = start_runtime("runtime-recovery")
255
+ recovered = JSON.parse(
256
+ rails_runner("recovery", RECOVERY_PROGRAM, "matinee", RECOVERY_TIMEOUT_SECONDS.to_s).lines.last
257
+ )
258
+ stop_runtime(runtime)
259
+ prove("the reminder ran after the restart and released the hold once") do
260
+ recovered.fetch("available") == 1 && recovered.fetch("holds").empty?
261
+ end
262
+ { available_while_stopped: stopped.fetch("available"), available_after_restart: recovered.fetch("available") }
263
+ end
264
+
265
+ # @rbs (event: String, buyer: String) -> HoldProcess
266
+ def spawn_hold(event:, buyer:)
267
+ input_reader, input_writer = IO.pipe
268
+ output_reader, output_writer = IO.pipe
269
+ pid = spawn_in_application(
270
+ "hold-#{buyer}", RbConfig.ruby, "bin/rails", "runner", HOLD_PROGRAM, event, buyer,
271
+ in: input_reader, out: output_writer
272
+ )
273
+ input_reader.close
274
+ output_writer.close
275
+ HoldProcess.new(buyer:, pid:, input: input_writer, output: output_reader)
276
+ end
277
+
278
+ # @rbs (HoldProcess) -> void
279
+ def await_ready(holder)
280
+ Timeout.timeout(COMMAND_TIMEOUT_SECONDS) do
281
+ line = holder.output.gets
282
+ raise Failure, "#{holder.buyer} exited before it was ready\n#{log_tail("hold-#{holder.buyer}")}" if line.nil?
283
+ raise Failure, "#{holder.buyer} printed #{line.inspect} before it was ready" unless line == "ready\n"
284
+ end
285
+ end
286
+
287
+ # @rbs (HoldProcess) -> Hash[String, untyped]
288
+ def read_hold(holder)
289
+ output = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { holder.output.read }
290
+ holder.output.close
291
+ status = wait(holder.pid)
292
+ raise Failure, "#{holder.buyer} failed\n#{log_tail("hold-#{holder.buyer}")}" unless status.success?
293
+ JSON.parse(output.lines.last)
294
+ end
295
+
296
+ # @rbs (String) -> Hash[String, untyped]
297
+ def read_state(event)
298
+ JSON.parse(rails_runner("state-#{event}", STATE_PROGRAM, event).lines.last)
299
+ end
300
+
301
+ # @rbs (String) -> Integer
302
+ def start_runtime(name)
303
+ spawn_in_application(name, RbConfig.ruby, bundle_executable, "exec", "solid_objects", "start")
304
+ end
305
+
306
+ # @rbs (Integer) -> void
307
+ def stop_runtime(pid)
308
+ ::Process.kill("TERM", pid)
309
+ status = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { wait(pid) }
310
+ raise Failure, "the runtime did not stop cleanly: #{status.inspect}" unless status.success?
311
+ end
312
+
313
+ # @rbs (String, String, *String) -> String
314
+ def rails_runner(name, program, *arguments)
315
+ run_in_application(name, RbConfig.ruby, "bin/rails", "runner", program, *arguments)
316
+ end
317
+
318
+ # @rbs (String, *String) -> String
319
+ def run_in_repository(name, *command)
320
+ pid = spawn_logged(name, command, environment: {}, chdir: REPOSITORY_ROOT)
321
+ finish(name, pid)
322
+ end
323
+
324
+ # @rbs (String, *String) -> String
325
+ def run_in_application(name, *command)
326
+ pid = Bundler.with_unbundled_env do
327
+ spawn_logged(name, command, environment: application_environment, chdir: @application_path)
328
+ end
329
+ finish(name, pid)
330
+ end
331
+
332
+ # @rbs (String, *untyped, **untyped) -> Integer
333
+ def spawn_in_application(name, *command, **options)
334
+ Bundler.with_unbundled_env do
335
+ spawn_logged(name, command, environment: application_environment, chdir: @application_path, **options)
336
+ end
337
+ end
338
+
339
+ # @rbs (String, Array[String], environment: Hash[String, String], chdir: String, **untyped) -> Integer
340
+ def spawn_logged(name, command, environment:, chdir:, **options)
341
+ FileUtils.mkdir_p(@log_directory)
342
+ log_path = File.join(@log_directory, "#{name}.log")
343
+ pid = ::Process.spawn(environment, *command, { chdir:, out: log_path, err: log_path }.merge(options))
344
+ @child_pids << pid
345
+ pid
346
+ end
347
+
348
+ # @rbs (String, Integer) -> String
349
+ def finish(name, pid)
350
+ status = Timeout.timeout(COMMAND_TIMEOUT_SECONDS) { wait(pid) }
351
+ raise Failure, "#{name} failed with #{status.inspect}\n#{log_tail(name)}" unless status.success?
352
+ File.read(File.join(@log_directory, "#{name}.log"))
353
+ end
354
+
355
+ # @rbs (Integer) -> Process::Status
356
+ def wait(pid)
357
+ _pid, status = ::Process.wait2(pid)
358
+ @child_pids.delete(pid)
359
+ status
360
+ end
361
+
362
+ # @rbs (Integer) -> bool
363
+ def alive?(pid)
364
+ ::Process.waitpid(pid, ::Process::WNOHANG).nil?
365
+ end
366
+
367
+ # @rbs () -> void
368
+ def stop_children
369
+ @child_pids.dup.each do |pid|
370
+ ::Process.kill("TERM", pid)
371
+ Timeout.timeout(20) { wait(pid) }
372
+ rescue Errno::ESRCH, Errno::ECHILD
373
+ @child_pids.delete(pid)
374
+ rescue Timeout::Error
375
+ ::Process.kill("KILL", pid)
376
+ wait(pid)
377
+ end
378
+ end
379
+
380
+ # @rbs () -> Hash[String, String]
381
+ def application_environment
382
+ {
383
+ "BUNDLE_GEMFILE" => File.join(@application_path, "Gemfile"),
384
+ "BUNDLE_PATH" => @bundle_path,
385
+ "BUNDLE_DEPLOYMENT" => "false",
386
+ "BUNDLE_FROZEN" => "false",
387
+ "BUNDLE_JOBS" => "4",
388
+ "RAILS_ENV" => "development"
389
+ }
390
+ end
391
+
392
+ # @rbs (String) -> String
393
+ def log_tail(name)
394
+ path = File.join(@log_directory, "#{name}.log")
395
+ return "(no log)" unless File.file?(path)
396
+
397
+ File.readlines(path).last(40).join
398
+ end
399
+
400
+ # @rbs () -> String
401
+ def gem_executable
402
+ File.join(RbConfig::CONFIG.fetch("bindir"), "gem")
403
+ end
404
+
405
+ # @rbs () -> String
406
+ def bundle_executable
407
+ Gem.bin_path("bundler", "bundle")
408
+ end
409
+
410
+ # @rbs () -> Float
411
+ def monotonic_now
412
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
413
+ end
414
+
415
+ # @rbs (String) { () -> boolish } -> void
416
+ def prove(claim)
417
+ raise Failure, "quickstart proof failed: #{claim}" unless yield
418
+ end
419
+ end
420
+
421
+ QuickstartSmoke.new.call