solid_objects 0.17.0 → 0.17.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +10 -1
- data/Rakefile +5 -0
- data/docs/agents.md +273 -0
- data/docs/operations.md +10 -8
- data/docs/virtual-actors.md +226 -0
- data/examples/quickstart/README.md +249 -0
- data/examples/quickstart/app/actors/ticket_sale.rb +22 -0
- data/examples/quickstart/smoke.rb +421 -0
- data/lib/solid_objects/version.rb +1 -1
- metadata +16 -9
|
@@ -0,0 +1,249 @@
|
|
|
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
|
+
`bundle add solid_objects` installs the current release. Do not pin an older
|
|
50
|
+
version from memory; the API changed between releases.
|
|
51
|
+
|
|
52
|
+
The generator writes `config/initializers/solid_objects.rb` and copies the
|
|
53
|
+
migrations. The migrations add the Solid Objects tables to the application's
|
|
54
|
+
existing database.
|
|
55
|
+
|
|
56
|
+
The generated initializer denies every operation. The doctor reports this
|
|
57
|
+
condition as a warning:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
WARN authorization: all five policies denied a neutral context; review the generated initializer before use
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
On SQLite, the doctor also warns that the runtime polls for work. SQLite has no
|
|
64
|
+
notification channel, so a commit in one process cannot wake another process.
|
|
65
|
+
The runtime finds the work at the next poll.
|
|
66
|
+
|
|
67
|
+
## 3. Grant only what the local demo needs
|
|
68
|
+
|
|
69
|
+
The demo calls the actor and reads its state from a console. It needs the
|
|
70
|
+
message policy and the query policy. In
|
|
71
|
+
`config/initializers/solid_objects.rb`, change these two lines:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
configuration.authorize_message = ->(**) { false }
|
|
75
|
+
configuration.authorize_query = ->(**) { false }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
to:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
configuration.authorize_message = ->(**) { true }
|
|
82
|
+
configuration.authorize_query = ->(**) { true }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Do not change `authorize_destroy`, `authorize_subscription`,
|
|
86
|
+
`authorize_administration`, or `authorize_transmission`. They stay denied.
|
|
87
|
+
|
|
88
|
+
These two grants are for a local demo only. They let any caller send any
|
|
89
|
+
message to any actor and read any actor state. A production policy must bind
|
|
90
|
+
the actor type, the actor ID, and the operation to the authenticated user or
|
|
91
|
+
tenant. Pass that principal at each call site:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
TicketSale.ref(event.id.to_s).hold(
|
|
95
|
+
buyer: Current.user.id.to_s,
|
|
96
|
+
authorization_context: Current.user
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then check it in the policy. `can_buy_tickets_for?` is an example method of
|
|
101
|
+
your application:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
configuration.authorize_message = lambda do |actor_type:, actor_id:, operation:, authorization_context:, **|
|
|
105
|
+
user = authorization_context
|
|
106
|
+
|
|
107
|
+
actor_type == "TicketSale" &&
|
|
108
|
+
operation == "hold" &&
|
|
109
|
+
user.present? &&
|
|
110
|
+
user.can_buy_tickets_for?(actor_id)
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The reminder that calls `expire` comes from a committed runtime row. It does
|
|
115
|
+
not go through `authorize_message` again.
|
|
116
|
+
|
|
117
|
+
[Authorization](../../docs/authorization.md) describes every policy and its
|
|
118
|
+
arguments.
|
|
119
|
+
|
|
120
|
+
## 4. Add the actor
|
|
121
|
+
|
|
122
|
+
Put this class in `app/actors/ticket_sale.rb`:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class TicketSale < SolidObjects::Actor
|
|
126
|
+
attribute :available, default: 1
|
|
127
|
+
attribute :holds, default: -> { {} }
|
|
128
|
+
|
|
129
|
+
def hold(buyer:)
|
|
130
|
+
return { held: false, available: } if available.zero? || holds.key?(buyer)
|
|
131
|
+
|
|
132
|
+
self.available -= 1
|
|
133
|
+
self.holds = holds.merge(buyer => Time.current.to_i)
|
|
134
|
+
schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
|
|
135
|
+
{ held: true, available: }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def expire(buyer:)
|
|
139
|
+
return available unless holds.key?(buyer)
|
|
140
|
+
|
|
141
|
+
self.holds = holds.except(buyer)
|
|
142
|
+
self.available += 1
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
One actor owns the state of one event. The event ID is the actor ID. A
|
|
148
|
+
successful `hold` stores the hold and a keyed ten-minute reminder in the same
|
|
149
|
+
commit as the state change. The `expire` method checks that the hold still
|
|
150
|
+
exists, so a second delivery of the same reminder does not add a ticket.
|
|
151
|
+
|
|
152
|
+
## 5. Start the runtime
|
|
153
|
+
|
|
154
|
+
Direct calls do not need a runtime process. The caller runs the turn. Reminders,
|
|
155
|
+
`async` messages, effects, and broadcasts need the runtime:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
bundle exec solid_objects start
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
When the runtime is stopped, due work stays in the database. Nothing else runs
|
|
162
|
+
it. Run the runtime as a separate process in every environment that uses
|
|
163
|
+
reminders.
|
|
164
|
+
|
|
165
|
+
## 6. Hold a ticket
|
|
166
|
+
|
|
167
|
+
Open a second terminal:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
bin/rails console
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
sale = TicketSale.ref("concert")
|
|
175
|
+
sale.hold(buyer: "ada")
|
|
176
|
+
sale.hold(buyer: "grace")
|
|
177
|
+
sale.available
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The first call returns `{"held" => true, "available" => 0}`. The second call
|
|
181
|
+
returns `{"held" => false, "available" => 0}`. A synchronous call returns the
|
|
182
|
+
committed result as JSON data, so the keys are strings.
|
|
183
|
+
|
|
184
|
+
## 7. Restart the runtime before the reminder runs
|
|
185
|
+
|
|
186
|
+
1. Stop the runtime with Ctrl-C before ten minutes pass.
|
|
187
|
+
2. Wait until the ten minutes pass.
|
|
188
|
+
3. In the console, read `sale.available`. The value is still `0`, because no
|
|
189
|
+
process ran the reminder.
|
|
190
|
+
4. Start the runtime again with `bundle exec solid_objects start`.
|
|
191
|
+
5. Read `sale.available` again. The runtime runs the due reminder, and the value
|
|
192
|
+
is `1`.
|
|
193
|
+
|
|
194
|
+
## Guarantees in this demo
|
|
195
|
+
|
|
196
|
+
- Calls to one actor ID commit in order, one at a time. Different actor IDs can
|
|
197
|
+
run concurrently.
|
|
198
|
+
- Delivery is at least once. A handler can run again after a crash. The guard
|
|
199
|
+
in `expire` makes a second delivery harmless.
|
|
200
|
+
- Each turn commits the state and the reminder together, or commits neither.
|
|
201
|
+
- The demo has no external effects. An external effect, such as an email or a
|
|
202
|
+
payment, can repeat. It must use the stable effect ID or another durable
|
|
203
|
+
idempotency key.
|
|
204
|
+
- One actor ID is a sequential bottleneck. Do not use one actor ID for the
|
|
205
|
+
whole application.
|
|
206
|
+
|
|
207
|
+
[Correctness](../../docs/correctness.md) gives the full contract.
|
|
208
|
+
|
|
209
|
+
## What the check proves
|
|
210
|
+
|
|
211
|
+
`bundle exec rake quickstart` runs [`smoke.rb`](smoke.rb). The check does these
|
|
212
|
+
steps:
|
|
213
|
+
|
|
214
|
+
1. It confirms that this recipe and every `TicketSale` sample in the README and
|
|
215
|
+
in `docs/` show the exact actor in
|
|
216
|
+
[`app/actors/ticket_sale.rb`](app/actors/ticket_sale.rb).
|
|
217
|
+
2. It builds the gem with `gem build`.
|
|
218
|
+
3. It runs `rails new --minimal` with SQLite in a temporary directory and runs
|
|
219
|
+
`bundle install` into a temporary bundle path.
|
|
220
|
+
4. It copies the built gem into `vendor/cache`, adds
|
|
221
|
+
`gem "solid_objects", "= <version>"` to the `Gemfile`, and runs
|
|
222
|
+
`bundle install --local`. The application cannot get the gem from the
|
|
223
|
+
repository or from rubygems.org.
|
|
224
|
+
5. It confirms which gem the application loads:
|
|
225
|
+
- the `Gemfile` has no `path:` option for `solid_objects`;
|
|
226
|
+
- the `Gemfile.lock` checksum is the checksum of the built gem;
|
|
227
|
+
- the loaded gem files are in the temporary bundle path, not in the
|
|
228
|
+
repository;
|
|
229
|
+
- the installed gem file has the checksum of the built gem.
|
|
230
|
+
6. It runs the install generator, the migrations, and the doctor. Then it
|
|
231
|
+
writes the actor and the two demo grants. It confirms that the other
|
|
232
|
+
policies stay denied.
|
|
233
|
+
7. It starts `bundle exec solid_objects start`. Eight separate `bin/rails runner`
|
|
234
|
+
processes wait at a barrier and then hold the same event at the same time.
|
|
235
|
+
The check confirms that exactly one buyer holds the only ticket and that the
|
|
236
|
+
durable state agrees.
|
|
237
|
+
8. It stops the runtime. One more process places a hold for a second event. That
|
|
238
|
+
process shifts its own clock back with `travel_to`, so the ten-minute
|
|
239
|
+
reminder is due eight seconds later. The reminder scheduler compares due
|
|
240
|
+
times with the database clock, so the runtime does not need a shifted
|
|
241
|
+
clock.
|
|
242
|
+
9. It waits until the reminder is past due and confirms that the hold is still
|
|
243
|
+
there while no runtime runs. Then it starts the runtime again and confirms
|
|
244
|
+
that the reminder released the hold exactly once.
|
|
245
|
+
10. It stops every child process and removes the temporary directory, also
|
|
246
|
+
when a step fails.
|
|
247
|
+
|
|
248
|
+
The check needs network access for `rails new` and `bundle install`. It takes
|
|
249
|
+
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
|