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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +4 -4
- data/lib/monk/auth/errors.rb +5 -0
- data/lib/monk/auth/sweep.rb +23 -0
- data/lib/monk/auth.rb +101 -6
- data/lib/monk/bin.rb +94 -0
- data/lib/monk/generators/auth.rb +1 -0
- data/lib/monk/generators/jobs.rb +3 -1
- data/lib/monk/jobs/adapters/pg.rb +90 -2
- data/lib/monk/jobs/cron.rb +180 -0
- data/lib/monk/jobs/errors.rb +21 -0
- data/lib/monk/jobs/firing.rb +11 -0
- data/lib/monk/jobs/recurring_task.rb +9 -0
- data/lib/monk/jobs/runtime.rb +38 -2
- data/lib/monk/jobs/scheduler.rb +91 -0
- data/lib/monk/jobs.rb +87 -8
- data/lib/monk/live/session.rb +16 -9
- data/lib/monk/templates/auth/agents.md +10 -3
- data/lib/monk/templates/auth/app/routes/auth.rb +4 -8
- data/lib/monk/templates/auth/config/auth.rb +1 -0
- data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.down.sql +2 -0
- data/lib/monk/templates/auth/db/migrate/00000000000004_create_login_requests.up.sql +16 -0
- data/lib/monk/templates/auth/setup.md +16 -3
- data/lib/monk/templates/auth/test/auth_test.rb +6 -0
- data/lib/monk/templates/base/Gemfile +1 -1
- data/lib/monk/templates/base/config/load.rb +5 -0
- data/lib/monk/templates/jobs/agents.md +15 -4
- data/lib/monk/templates/jobs/config/recurring.rb +21 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.down.sql +1 -0
- data/lib/monk/templates/jobs/db/migrate/00000000000003_create_recurring_tables.up.sql +13 -0
- data/lib/monk/templates/jobs/setup.md +20 -2
- data/lib/monk/templates/websocket/bin/websocket_server +14 -0
- data/lib/monk/templates/websocket/setup.md +2 -1
- data/lib/monk/version.rb +1 -1
- data/lib/monk/websocket/handshake.rb +32 -9
- data/lib/monk/websocket/pg_fanout.rb +16 -7
- data/lib/monk/websocket/redis_fanout.rb +16 -7
- data/lib/monk/websocket/registry.rb +40 -5
- data/lib/monk/websocket/server.rb +211 -47
- metadata +11 -1
|
@@ -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
|
|
5
|
-
|
|
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
|
|
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)
|
|
@@ -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`
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
18
|
-
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
79
|
+
token = nil
|
|
80
|
+
headers["cookie"]&.split(";")&.each do |pair|
|
|
60
81
|
name, value = pair.strip.split("=", 2)
|
|
61
|
-
|
|
62
|
-
end
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|