monkrb 0.21.1 → 0.22.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +4 -4
  4. data/lib/monk/auth/errors.rb +5 -0
  5. data/lib/monk/auth/sweep.rb +23 -0
  6. data/lib/monk/auth.rb +101 -6
  7. data/lib/monk/bin.rb +94 -0
  8. data/lib/monk/generators/auth.rb +1 -0
  9. data/lib/monk/generators/jobs.rb +3 -1
  10. data/lib/monk/jobs/adapters/pg.rb +90 -2
  11. data/lib/monk/jobs/cron.rb +180 -0
  12. data/lib/monk/jobs/errors.rb +21 -0
  13. data/lib/monk/jobs/firing.rb +11 -0
  14. data/lib/monk/jobs/recurring_task.rb +9 -0
  15. data/lib/monk/jobs/runtime.rb +38 -2
  16. data/lib/monk/jobs/scheduler.rb +91 -0
  17. data/lib/monk/jobs.rb +87 -8
  18. data/lib/monk/live/session.rb +16 -9
  19. data/lib/monk/templates/auth/agents.md +10 -3
  20. data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
  21. data/lib/monk/templates/auth/config/auth.rb +1 -0
  22. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
  23. data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
  24. data/lib/monk/templates/auth/setup.md +16 -3
  25. data/lib/monk/templates/auth/test/auth_test.rb +6 -0
  26. data/lib/monk/templates/base/Gemfile +1 -1
  27. data/lib/monk/templates/base/config/load.rb +5 -0
  28. data/lib/monk/templates/jobs/agents.md +15 -4
  29. data/lib/monk/templates/jobs/config/recurring.rb +21 -0
  30. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
  31. data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
  32. data/lib/monk/templates/jobs/setup.md +20 -2
  33. data/lib/monk/templates/websocket/bin/websocket_server +14 -0
  34. data/lib/monk/templates/websocket/setup.md +2 -1
  35. data/lib/monk/version.rb +1 -1
  36. data/lib/monk/websocket/handshake.rb +32 -9
  37. data/lib/monk/websocket/pg_fanout.rb +16 -7
  38. data/lib/monk/websocket/redis_fanout.rb +16 -7
  39. data/lib/monk/websocket/registry.rb +40 -5
  40. data/lib/monk/websocket/server.rb +211 -47
  41. metadata +11 -1
@@ -0,0 +1,2 @@
1
+ DROP INDEX sessions_subject;
2
+ DROP TABLE login_requests;
@@ -0,0 +1,16 @@
1
+ -- Monk::Auth housekeeping (Monk 0.22): the login-email rate limit's table
2
+ -- and an index for finding a person's sessions.
3
+
4
+ -- One row per address: when a login link was last requested for it, for
5
+ -- Monk::Auth.allow_login_request?. Checked in the login route, before the
6
+ -- link is sent or enqueued: with jobs the login token only exists once the
7
+ -- job runs, so login_tokens can't tell a request is already queued.
8
+ -- Monk::Auth.sweep! deletes rows older than login_request_interval.
9
+ CREATE TABLE login_requests (
10
+ email TEXT PRIMARY KEY, -- stripped and downcased
11
+ requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
+ );
13
+
14
+ -- A person's sessions by subject: Monk::Auth.revoke_all, or who is online.
15
+ -- An app that already has an index on sessions (subject) can drop this line.
16
+ CREATE INDEX sessions_subject ON sessions (subject);
@@ -1,8 +1,9 @@
1
1
  ## auth
2
2
 
3
3
  Logging in by email link. `config/auth.rb` configures Monk::Auth,
4
- `app/mailers/auth_mailer.rb` sends the link through mail, and a migration
5
- creates the `login_tokens` and `sessions` tables: run `bin/setup_db` and
4
+ `app/mailers/auth_mailer.rb` sends the link through mail, and two
5
+ migrations create the `login_tokens`, `sessions` and `login_requests`
6
+ tables: run `bin/setup_db` and
6
7
  `DB_NAME={{app}}_test bin/setup_db` (postgres, above).
7
8
 
8
9
  ### Before production
@@ -12,6 +13,17 @@ one in production's environment, e.g. from
12
13
  `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'`. `config/auth.rb`
13
14
  reads it with `ENV.fetch`: unset, the app doesn't boot.
14
15
 
16
+ ### Housekeeping
17
+
18
+ - **Rate limit.** The login route allows one email per address per
19
+ minute (`login_request_interval:` in `config/auth.rb`), counted in
20
+ `login_requests`, so across every `bin/server` process. A limit across
21
+ addresses (per IP, say) is up to the app.
22
+ - **Cleanup.** With jobs (`monk add jobs`), `bin/jobs` deletes expired
23
+ sessions and login tokens every night (`monk_auth_sweep` in
24
+ `config/recurring.rb`). Without jobs, run `Monk::Auth.sweep!` on a
25
+ schedule of your own.
26
+
15
27
  ### Check it works
16
28
 
17
29
  1. `bundle exec rake test` runs `test/auth_test.rb`: a link is sent (to
@@ -33,5 +45,6 @@ reads it with `ENV.fetch`: unset, the app doesn't boot.
33
45
  ```
34
46
 
35
47
  and `/me` answers `401` again.
36
- 3. Before going live: keep the rate limit, and list in `config/auth.rb`
48
+ 3. Before going live: keep the rate limit (the `allow_login_request?`
49
+ line in `app/routes/auth.rb`), and list in `config/auth.rb`
37
50
  (`redirect_allowlist`) the paths a login may send the user back to.
@@ -4,6 +4,12 @@ require_relative "test_helper"
4
4
  # sent by config/auth.rb's deliver: (AuthMailer, to log/test.log here), and
5
5
  # redeemed into a session. SETUP.md, auth.
6
6
  class AuthTest < Minitest::Test
7
+ # Lifts the login-email rate limit, so the next test can ask for a link
8
+ # for the same address at once.
9
+ def teardown
10
+ Monk::Auth.clear_login_requests!
11
+ end
12
+
7
13
  def test_a_login_link_is_sent_and_redeems_into_a_session
8
14
  email = "login-check-#{rand(1_000_000)}@example.test"
9
15
  token = Monk::Auth.request_login(email)
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "monkrb", "~> 0.21", require: "monk"
3
+ gem "monkrb", "~> 0.22", require: "monk"
4
4
  gem "rackup"
5
5
  gem "webrick"
6
6
  gem "kino"
@@ -25,6 +25,11 @@ end
25
25
  Dir[File.expand_path("../app/#{role}/**/*.rb", __dir__)].sort.each { |file| require file }
26
26
  end
27
27
 
28
+ # With jobs (monk add jobs), the schedules of recurring jobs. Last, because
29
+ # each one names a job class from app/jobs/.
30
+ recurring = File.expand_path("recurring.rb", __dir__)
31
+ require recurring if File.exist?(recurring)
32
+
28
33
  # With websocket (monk add websocket), every process that loads this file
29
34
  # can broadcast (bin/server, bin/jobs, bin/console, the tests), so its
30
35
  # transport is started here, once everything above has declared its
@@ -1,18 +1,25 @@
1
1
  ## jobs
2
2
 
3
3
  **Where:** `config/jobs.rb` (the queue lives in the `:primary` database),
4
- job classes in `app/jobs/`, `bin/jobs` runs them.
4
+ job classes in `app/jobs/`, schedules in `config/recurring.rb`, `bin/jobs`
5
+ runs them.
5
6
 
6
7
  **Main calls:** `class SendInvoice < Monk::Job` with
7
8
  `def self.perform(id)`; `SendInvoice.enqueue(id)`, `wait: 300`, `at: time`,
8
9
  or `conn: conn` to enqueue inside your own transaction. Class settings:
9
10
  `queue "mailers"`, `max_attempts 5`, `never_retry SomeError`. With mail:
10
11
  `Monk::Mail.deliver_later(...)`; with auth:
11
- `Monk::Auth::SendLoginLink.enqueue(email)`.
12
+ `Monk::Auth::SendLoginLink.enqueue(email)`. Recurring, in
13
+ `config/recurring.rb` only:
14
+ `Monk::Jobs.recurring "key", JobClass, cron: "0 3 * * *", args: [...]`
15
+ (`skip_if_pending: true` to skip while the last run is pending). With
16
+ auth, `config/recurring.rb` already schedules `monk_auth_sweep`.
12
17
 
13
18
  **Test:** `test/jobs_test.rb`. In tests, `Monk::Jobs.drain!` runs every
14
19
  enqueued job right away, each in a Ractor; `Monk::Jobs.clear!` in
15
- `teardown` empties the queue.
20
+ `teardown` empties the queue. `Monk::Jobs.run_scheduler!(at: time)` runs
21
+ the schedules as if the clock read `time` (call it once to set the
22
+ checkpoint, then at the next occurrence).
16
23
 
17
24
  **Pitfalls:**
18
25
  - A job's arguments are stored as JSON: pass ids and plain values, never
@@ -20,6 +27,10 @@ enqueued job right away, each in a Ractor; `Monk::Jobs.clear!` in
20
27
  - A job may run more than once (its process was killed mid-job): make it
21
28
  safe to repeat.
22
29
  - Jobs run in Ractors: the same shareability rules as routes.
30
+ - Cron is UTC. A new recurring task first fires at its next occurrence,
31
+ not on deploy, and a missed run fires once, not once per occurrence
32
+ missed. Keep a task's key when renaming its class.
23
33
 
24
- **Examples:** `jobs-enqueue` in `app/routes/jobs.rb`; `app/jobs/hello_job.rb`
34
+ **Examples:** `jobs-enqueue` in `app/routes/jobs.rb`, `jobs-recurring` in
35
+ `config/recurring.rb`; `app/jobs/hello_job.rb`
25
36
  is a real, deletable demo job.
@@ -0,0 +1,21 @@
1
+ # Recurring jobs: bin/jobs enqueues each one on its schedule, once per
2
+ # occurrence however many job processes are running. Loaded after app/,
3
+ # since each names a job class.
4
+ #
5
+ # Monk::Jobs.recurring "<key>", JobClass, cron: "<expression>", args: [...]
6
+ #
7
+ # The key is the task's identity in the database: keep it when renaming
8
+ # the class. cron: is a five-field cron expression (minute hour
9
+ # day-of-month month day-of-week, or @hourly, @daily, ...), always in UTC.
10
+ # A new task first runs at its next occurrence, not on deploy; after
11
+ # downtime only the latest missed occurrence runs.
12
+ # skip_if_pending: true skips an occurrence while the previous run hasn't
13
+ # finished. Monk's docs/guides/jobs.md, "Recurring jobs".
14
+
15
+ # With auth (monk add auth): deletes expired sessions and login tokens, and
16
+ # old login requests, every night at 03:17 UTC.
17
+ Monk::Jobs.recurring "monk_auth_sweep", Monk::Auth::Sweep, cron: "17 3 * * *" if defined?(Monk::Auth::Sweep)
18
+
19
+ # monk:example jobs-recurring -- the demo job (app/jobs/hello_job.rb) at the top of every hour.
20
+ # Monk::Jobs.recurring "hello_hourly", HelloJob, cron: "@hourly", args: ["cron"]
21
+ # monk:end
@@ -0,0 +1 @@
1
+ DROP TABLE monk_recurring_tasks;
@@ -0,0 +1,13 @@
1
+ -- Monk::Jobs.recurring: one checkpoint row per declared task, the latest
2
+ -- occurrence it has been handled for. Why this shape:
3
+ -- docs/adr/0016-recurring-jobs-on-the-job-process-tick-with-a-checkpoint-row.md.
4
+ CREATE TABLE monk_recurring_tasks (
5
+ key TEXT PRIMARY KEY, -- the key given to Monk::Jobs.recurring
6
+ cron TEXT NOT NULL, -- the expression the checkpoint was set under
7
+ last_occurrence_at TIMESTAMPTZ NOT NULL, -- the checkpoint
8
+ -- The job enqueued for the checkpoint, for skip_if_pending. No foreign
9
+ -- key: ON DELETE SET NULL would add a write to every job's finish, and
10
+ -- a dangling id just means that job is done.
11
+ last_job_id BIGINT,
12
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
13
+ );
@@ -1,7 +1,7 @@
1
1
  ## jobs
2
2
 
3
- Background jobs on the app's own Postgres. A migration creates the queue's
4
- tables: run `bin/setup_db` and `DB_NAME={{app}}_test bin/setup_db`
3
+ Background jobs on the app's own Postgres. Two migrations create the
4
+ queue's tables and the recurring jobs' one: run `bin/setup_db` and `DB_NAME={{app}}_test bin/setup_db`
5
5
  (postgres, above).
6
6
 
7
7
  ### Run them
@@ -18,6 +18,19 @@ first. `TERM` or Ctrl-C lets the jobs in hand finish before it exits. In
18
18
  production it's one more process from the same image, with `bin/jobs` as
19
19
  its command.
20
20
 
21
+ ### Recurring jobs
22
+
23
+ Declare schedules in `config/recurring.rb`:
24
+
25
+ ```ruby
26
+ Monk::Jobs.recurring "purge_sessions", PurgeSessions, cron: "0 3 * * *"
27
+ ```
28
+
29
+ Cron times are UTC. A running `bin/jobs` enqueues each occurrence once,
30
+ however many are running; with none running, nothing fires, and on
31
+ restart only the latest missed occurrence runs. A new task first runs at
32
+ its next occurrence, not on deploy.
33
+
21
34
  ### Check it works
22
35
 
23
36
  1. `bundle exec rake test` runs `test/jobs_test.rb`, which enqueues the
@@ -28,3 +41,8 @@ its command.
28
41
  3. The example: uncomment the `jobs-enqueue` block in `app/routes/jobs.rb`,
29
42
  restart `bin/server`, and
30
43
  `curl -X POST "http://localhost:9292/jobs/hello?name=Bob"`.
44
+ 4. A recurring job, in a test: `Monk::Jobs.run_scheduler!(at: time)` runs
45
+ the schedule as if the clock read `time`. The first call only records
46
+ where the task is; a second call at the next occurrence enqueues it,
47
+ and `Monk::Jobs.drain!` runs it. The `jobs-recurring` example in
48
+ `config/recurring.rb` schedules the demo job every hour.
@@ -4,6 +4,19 @@ require "bundler/setup"
4
4
  # with what to fix (SETUP.md's commands), not a backtrace.
5
5
  require "monk/bin"
6
6
 
7
+ # Fewer GC pauses while thousands of sockets are open, set the only way
8
+ # Ruby takes them: at boot. Any already in the environment win.
9
+ Monk::Bin.boot_with_env!(__FILE__, Monk::Bin::WEBSOCKET_GC_ENV)
10
+
11
+ # WS_PROCESSES (1): this many server processes on WS_PORT, each with its own
12
+ # heap and its own GC pauses over a share of the sockets
13
+ # (docs/guides/websocket.md, "Several processes").
14
+ processes = Integer(ENV.fetch("WS_PROCESSES", "1"), exception: false)
15
+ unless processes&.positive?
16
+ abort "bin/websocket_server: WS_PROCESSES must be a whole number, 1 or more, got #{ENV["WS_PROCESSES"].inspect}."
17
+ end
18
+ Monk::Bin.spawn_processes!(__FILE__, processes)
19
+
7
20
  Monk::Bin.run(__FILE__) do
8
21
  require_relative "../config/settings"
9
22
  require_relative "../config/websocket"
@@ -55,6 +68,7 @@ Monk::Bin.run(__FILE__) do
55
68
 
56
69
  server = Monk::WebSocket::Server.new(
57
70
  port: port, authenticate: authenticate, allowed_origins: allowed_origins, db_pool: db_pool,
71
+ reuse_port: processes > 1,
58
72
  )
59
73
  puts "Monk::WebSocket::Server listening on ws://0.0.0.0:#{port} " \
60
74
  "(#{live ? "live" : "app/sockets"}, transport: #{AppWebSocket::TRANSPORT}, authenticate: #{authenticate})"
@@ -18,7 +18,8 @@ exits. In production it's one more process from the same image, with
18
18
 
19
19
  `WS_ALLOWED_ORIGINS` (default: `PUBLIC_URL`) must list every origin your
20
20
  pages are served from, or browsers' sockets are refused. Behind TLS, pages
21
- connect with `wss://`.
21
+ connect with `wss://`. Past ~5K open sockets, `WS_PROCESSES` (default 1)
22
+ runs several server processes on the port, on Linux.
22
23
 
23
24
  ### Switching transports
24
25
 
data/lib/monk/version.rb CHANGED
@@ -5,5 +5,5 @@ module Monk
5
5
  # class of bug .freeze! guards against for routes/error handlers/models,
6
6
  # just on a top-level constant that every worker Ractor reads on boot
7
7
  # (found live under kino: GET /hello 500'd until this was frozen).
8
- VERSION = "0.21.1".freeze
8
+ VERSION = "0.22.1".freeze
9
9
  end
@@ -9,16 +9,35 @@ module Monk
9
9
  # read from a connection Ractor. .freeze is not optional.
10
10
  MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".freeze
11
11
 
12
+ # The most a handshake request's head may be (Server.serve answers
13
+ # 431 past it): room for a large cookie, nowhere near a memory risk.
14
+ MAX_REQUEST_SIZE = 16 * 1024
15
+
12
16
  def self.accept_key(client_key)
13
17
  Base64.strict_encode64(Digest::SHA1.digest(client_key + MAGIC))
14
18
  end
15
19
 
20
+ # Two Strings a header line (its name and its value), plus the line:
21
+ # this runs once a connect, and every object it makes is one more
22
+ # towards the next GC (docs/plan-ws-connect-ceiling.md, item 4).
16
23
  def self.parse_request(raw_request)
17
- headers = raw_request.split("\r\n").drop(1).each_with_object({}) do |line, h|
18
- next if line.empty?
24
+ headers = {}
25
+ request_line = true
26
+ raw_request.each_line("\r\n", chomp: true) do |line|
27
+ if request_line
28
+ request_line = false
29
+ next
30
+ end
31
+
32
+ colon = line.index(":")
33
+ next unless colon
19
34
 
20
- name, value = line.split(":", 2)
21
- h[name.strip.downcase] = value.strip if name && value
35
+ name = line.byteslice(0, colon)
36
+ name.strip!
37
+ name.downcase!
38
+ value = line.byteslice(colon + 1, line.bytesize)
39
+ value.strip!
40
+ headers[name] = value
22
41
  end
23
42
 
24
43
  unless headers["upgrade"]&.downcase == "websocket"
@@ -55,17 +74,21 @@ module Monk
55
74
  end
56
75
  private_class_method :bearer_token
57
76
 
77
+ # The last session_token in the header wins, as when it was a Hash.
58
78
  def self.session_cookie_token(headers)
59
- headers["cookie"].to_s.split(";").each_with_object({}) do |pair, cookies|
79
+ token = nil
80
+ headers["cookie"]&.split(";")&.each do |pair|
60
81
  name, value = pair.strip.split("=", 2)
61
- cookies[name] = value if name
62
- end["session_token"]
82
+ token = value if name == "session_token"
83
+ end
84
+ token
63
85
  end
64
86
  private_class_method :session_cookie_token
65
87
 
66
- def self.response_for(raw_request)
67
- headers = parse_request(raw_request)
88
+ def self.response_for(raw_request) = response(parse_request(raw_request))
68
89
 
90
+ # The 101 for headers parse_request already returned.
91
+ def self.response(headers)
69
92
  "HTTP/1.1 101 Switching Protocols\r\n" \
70
93
  "Upgrade: websocket\r\n" \
71
94
  "Connection: Upgrade\r\n" \
@@ -225,17 +225,17 @@ module Monk
225
225
  end
226
226
 
227
227
  def register(key, port)
228
- unless Listeners.listening?(@origin)
229
- raise NotListeningError,
230
- "Monk::WebSocket::PgFanout#register before #listen!: this socket would never get " \
231
- "other processes' broadcasts -- call listen! once at boot in the process that holds " \
232
- "sockets (bin/websocket_server: Monk::Live.listen!)"
233
- end
234
-
228
+ ensure_listening!(:register)
235
229
  @registry.register(key, port)
236
230
  end
237
231
 
232
+ def register_many(keys, port)
233
+ ensure_listening!(:register_many)
234
+ @registry.register_many(keys, port)
235
+ end
236
+
238
237
  def unregister(key, port) = @registry.unregister(key, port)
238
+ def unregister_many(keys, port) = @registry.unregister_many(keys, port)
239
239
  def count(key) = @registry.count(key)
240
240
 
241
241
  def broadcast(key, payload)
@@ -266,6 +266,15 @@ module Monk
266
266
 
267
267
  private
268
268
 
269
+ def ensure_listening!(method)
270
+ return if Listeners.listening?(@origin)
271
+
272
+ raise NotListeningError,
273
+ "Monk::WebSocket::PgFanout##{method} before #listen!: this socket would never get " \
274
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
275
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
276
+ end
277
+
269
278
  # The pool's database's connection options, for #listen!.
270
279
  def pooled_pg_opts(db_pool)
271
280
  config = Monk::Persistence::Pg.pool_config(db_pool)
@@ -161,17 +161,17 @@ module Monk
161
161
  end
162
162
 
163
163
  def register(key, port)
164
- unless Listeners.listening?(@origin)
165
- raise NotListeningError,
166
- "Monk::WebSocket::RedisFanout#register before #listen!: this socket would never get " \
167
- "other processes' broadcasts -- call listen! once at boot in the process that holds " \
168
- "sockets (bin/websocket_server: Monk::Live.listen!)"
169
- end
170
-
164
+ ensure_listening!(:register)
171
165
  @registry.register(key, port)
172
166
  end
173
167
 
168
+ def register_many(keys, port)
169
+ ensure_listening!(:register_many)
170
+ @registry.register_many(keys, port)
171
+ end
172
+
174
173
  def unregister(key, port) = @registry.unregister(key, port)
174
+ def unregister_many(keys, port) = @registry.unregister_many(keys, port)
175
175
  def count(key) = @registry.count(key)
176
176
 
177
177
  def broadcast(key, payload)
@@ -181,6 +181,15 @@ module Monk
181
181
 
182
182
  private
183
183
 
184
+ def ensure_listening!(method)
185
+ return if Listeners.listening?(@origin)
186
+
187
+ raise NotListeningError,
188
+ "Monk::WebSocket::RedisFanout##{method} before #listen!: this socket would never get " \
189
+ "other processes' broadcasts -- call listen! once at boot in the process that holds " \
190
+ "sockets (bin/websocket_server: Monk::Live.listen!)"
191
+ end
192
+
184
193
  # A single shared Redis client called concurrently from every
185
194
  # connection Ractor's #broadcast wouldn't be safe -- redis-rb clients
186
195
  # aren't documented safe for concurrent commands from multiple
@@ -8,8 +8,9 @@ module Monk
8
8
  # A dedicated Ractor holding the live set of connection handles keyed by
9
9
  # an app-assigned channel/subject -- mirrors StateRactor's shape
10
10
  # (docs/design/ractor.md): the mutable Hash stays hidden inside this one
11
- # Ractor, and every call is a synchronous "ask" via a fresh
12
- # Ractor::Port, so registration/broadcast can never race each other.
11
+ # Ractor, and every call is a synchronous "ask" answered on the
12
+ # caller's reply port, so registration/broadcast can never race each
13
+ # other.
13
14
  class Registry
14
15
  def initialize
15
16
  @ractor = Ractor.new do
@@ -25,6 +26,14 @@ module Monk
25
26
  when :unregister
26
27
  keys[key].delete(arg)
27
28
  true
29
+ when :register_many
30
+ # key is the Array of keys: a Live subscribe's topics in
31
+ # one ask, not one each.
32
+ key.each { |k| keys[k] << arg unless keys[k].include?(arg) }
33
+ true
34
+ when :unregister_many
35
+ key.each { |k| keys[k].delete(arg) }
36
+ true
28
37
  when :broadcast
29
38
  # A port can be closed out from under this loop without
30
39
  # ever going through #unregister -- e.g. the owning
@@ -58,7 +67,13 @@ module Monk
58
67
  end
59
68
  ports.size
60
69
  end
61
- reply_port.send(result)
70
+ begin
71
+ reply_port.send(result)
72
+ rescue Ractor::ClosedError
73
+ # The caller gave up on this ask (its thread was killed while
74
+ # it waited) and closed its port: nobody to answer, and no
75
+ # reason to end the registry for every other caller.
76
+ end
62
77
  end
63
78
  end
64
79
  freeze
@@ -72,6 +87,16 @@ module Monk
72
87
  ask(:unregister, key, port)
73
88
  end
74
89
 
90
+ # register for each of keys, in one ask: one message and one reply
91
+ # instead of one each (docs/plan-ws-connect-ceiling.md, item 5).
92
+ def register_many(keys, port)
93
+ ask(:register_many, keys, port)
94
+ end
95
+
96
+ def unregister_many(keys, port)
97
+ ask(:unregister_many, keys, port)
98
+ end
99
+
75
100
  def broadcast(key, payload)
76
101
  ask(:broadcast, key, payload)
77
102
  end
@@ -99,10 +124,20 @@ module Monk
99
124
 
100
125
  private
101
126
 
127
+ # An ask is synchronous, so a fiber has at most one outstanding: its
128
+ # reply port is made once and reused (a Port is an allocation and a
129
+ # message each), taken out of fiber storage while the ask is in
130
+ # flight. An ask cut short (the thread killed while it waits) closes
131
+ # the port instead of putting it back, so its late reply can't be
132
+ # read as the next ask's.
102
133
  def ask(op, key, arg)
103
- reply_port = Ractor::Port.new
134
+ reply_port = Thread.current[:monk_registry_reply_port] || Ractor::Port.new
135
+ Thread.current[:monk_registry_reply_port] = nil
104
136
  @ractor.send([op, key, arg, reply_port])
105
- reply_port.receive
137
+ result = reply_port.receive
138
+ Thread.current[:monk_registry_reply_port] = reply_port
139
+ reply_port = nil
140
+ result
106
141
  rescue Ractor::ClosedError
107
142
  # The registry Ractor itself can be gone by the time this runs --
108
143
  # e.g. process-wide shutdown (Ctrl+C) tearing down Ractors in