solid_objects 0.12.1 → 0.13.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +69 -13
  4. data/benchmark/idle_polling.rb +98 -0
  5. data/benchmark/support.rb +2 -0
  6. data/docs/adr/0009-realtime-updates.md +5 -1
  7. data/docs/adr/0011-wake-up-strategy.md +6 -0
  8. data/docs/architecture.md +5 -4
  9. data/docs/authorization.md +11 -4
  10. data/docs/benchmarks.md +38 -0
  11. data/docs/correctness.md +3 -3
  12. data/docs/dashboard.md +200 -0
  13. data/docs/database-schema.md +5 -4
  14. data/docs/development.md +1 -0
  15. data/docs/operations.md +24 -0
  16. data/docs/realtime.md +22 -14
  17. data/docs/roadmap.md +32 -11
  18. data/docs/security.md +7 -7
  19. data/examples/application/README.md +5 -5
  20. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  21. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  22. data/lib/solid_objects/actor.rb +1 -1
  23. data/lib/solid_objects/broadcast_executor.rb +35 -4
  24. data/lib/solid_objects/configuration.rb +4 -0
  25. data/lib/solid_objects/effect_executor.rb +34 -3
  26. data/lib/solid_objects/polling_backoff.rb +45 -0
  27. data/lib/solid_objects/process_registry.rb +51 -0
  28. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  29. data/lib/solid_objects/version.rb +1 -1
  30. data/lib/solid_objects/wake_up.rb +36 -4
  31. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  32. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  33. data/lib/solid_objects/web/action.rb +109 -0
  34. data/lib/solid_objects/web/application.rb +239 -0
  35. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  36. data/lib/solid_objects/web/helpers.rb +227 -0
  37. data/lib/solid_objects/web/paginator.rb +64 -0
  38. data/lib/solid_objects/web/route.rb +55 -0
  39. data/lib/solid_objects/web/router.rb +46 -0
  40. data/lib/solid_objects/web/statistics.rb +78 -0
  41. data/lib/solid_objects/web.rb +222 -0
  42. data/lib/solid_objects/worker.rb +39 -3
  43. data/lib/solid_objects.rb +2 -0
  44. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  45. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  46. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  47. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  48. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  49. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  50. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  51. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  52. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  53. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  54. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  55. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  56. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  57. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  58. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  59. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  60. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  61. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  62. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  63. data/web/assets/javascripts/application.js +118 -0
  64. data/web/assets/javascripts/charts.js +190 -0
  65. data/web/assets/stylesheets/application.css +527 -0
  66. data/web/views/_messages.erb +29 -0
  67. data/web/views/_navigation.erb +15 -0
  68. data/web/views/_paging.erb +17 -0
  69. data/web/views/_status_filter.erb +8 -0
  70. data/web/views/_summary.erb +39 -0
  71. data/web/views/broadcasts.erb +35 -0
  72. data/web/views/dashboard.erb +128 -0
  73. data/web/views/dead_letter.erb +40 -0
  74. data/web/views/dead_letters.erb +42 -0
  75. data/web/views/effects.erb +35 -0
  76. data/web/views/instance.erb +139 -0
  77. data/web/views/instances.erb +49 -0
  78. data/web/views/layout.erb +22 -0
  79. data/web/views/mailbox.erb +35 -0
  80. data/web/views/message.erb +34 -0
  81. data/web/views/processes.erb +37 -0
  82. data/web/views/reminders.erb +37 -0
  83. metadata +58 -2
@@ -0,0 +1,130 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "rack/request"
4
+ require "rack/utils"
5
+ require "securerandom"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # A state changing request must carry the token of the session that asked
10
+ # for the form. The token a form receives is masked with a fresh one-time
11
+ # pad on every request, so the bytes on the wire differ each time and a
12
+ # compression side channel cannot recover the session token.
13
+ class CsrfProtection
14
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
15
+ TOKEN_BYTES = 32
16
+
17
+ MISSING_SESSION = <<~MESSAGE
18
+ SolidObjects::Web needs a Rack session for CSRF protection.
19
+
20
+ Mount it inside the application routes so the Rails session middleware runs first:
21
+
22
+ Rails.application.routes.draw do
23
+ mount SolidObjects::Web => "/solid_objects"
24
+ end
25
+
26
+ In a bare Rack application, run a session middleware before it:
27
+
28
+ use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
29
+ run SolidObjects::Web
30
+ MESSAGE
31
+
32
+ # @rbs (untyped) -> void
33
+ def initialize(app)
34
+ @app = app
35
+ end
36
+
37
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
38
+ def call(env)
39
+ return forbidden unless accept?(env)
40
+
41
+ session = session!(env)
42
+ session[:csrf] ||= SecureRandom.base64(TOKEN_BYTES)
43
+ env[Web::CSRF_TOKEN_KEY] = mask(session[:csrf])
44
+ @app.call(env)
45
+ end
46
+
47
+ private
48
+
49
+ # @rbs (Hash[String, untyped]) -> bool
50
+ def accept?(env)
51
+ return true if SAFE_METHODS.include?(env["REQUEST_METHOD"])
52
+
53
+ valid?(env, ::Rack::Request.new(env).params["authenticity_token"])
54
+ end
55
+
56
+ # @rbs (Hash[String, untyped], String?) -> bool
57
+ def valid?(env, given)
58
+ return false if given.nil? || given.empty?
59
+
60
+ session = session!(env)
61
+ stored = session[:csrf]
62
+ return false if stored.nil?
63
+
64
+ token = decode(given)
65
+ return false unless token
66
+
67
+ # The secret is not rotated here. A page renders one Retry form per
68
+ # dead letter, and a browser keeps pages open in other tabs, so
69
+ # spending the secret on the first submission would answer 403 to
70
+ # every other form already rendered. Single use is not what a CSRF
71
+ # token provides: it proves the request came from a page this session
72
+ # was served, and the per-request mask below is what keeps the value
73
+ # on the wire from repeating.
74
+ matches?(token, stored)
75
+ end
76
+
77
+ # @rbs (String, String) -> bool
78
+ def matches?(token, stored)
79
+ candidate = case token.bytesize
80
+ when TOKEN_BYTES then token
81
+ when TOKEN_BYTES * 2 then unmask(token)
82
+ else return false
83
+ end
84
+
85
+ ::Rack::Utils.secure_compare(candidate, decode(stored).to_s)
86
+ end
87
+
88
+ # @rbs (String) -> String
89
+ def mask(token)
90
+ decoded = decode(token).to_s
91
+ pad = SecureRandom.random_bytes(decoded.bytesize)
92
+ encode(pad + exclusive_or(pad, decoded))
93
+ end
94
+
95
+ # @rbs (String) -> String
96
+ def unmask(masked)
97
+ half = masked.bytesize / 2
98
+ exclusive_or(masked[0, half].to_s, masked[half..].to_s)
99
+ end
100
+
101
+ # @rbs (String, String) -> String
102
+ def exclusive_or(left, right)
103
+ left.bytes.zip(right.bytes).map { |first, second| first ^ second.to_i }.pack("c*")
104
+ end
105
+
106
+ # @rbs (String) -> String
107
+ def encode(token)
108
+ [ token ].pack("m0").tr("+/", "-_")
109
+ end
110
+
111
+ # @rbs (String) -> String?
112
+ def decode(token)
113
+ decoded = token.tr("-_", "+/").unpack1("m0")
114
+ decoded.is_a?(String) ? decoded : nil
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+
119
+ # @rbs (Hash[String, untyped]) -> Hash[untyped, untyped]
120
+ def session!(env)
121
+ env["rack.session"] || raise(MISSING_SESSION)
122
+ end
123
+
124
+ # @rbs () -> Array[untyped]
125
+ def forbidden
126
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,227 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "json"
4
+ require "rack/utils"
5
+
6
+ module SolidObjects
7
+ class Web
8
+ # The methods a view may call. Everything a template prints goes through
9
+ # `h`, because an actor id, an operation name, and an exception message are
10
+ # all application supplied strings that reach this page unchanged.
11
+ module Helpers
12
+ # Only these survive a page link. A filter an operator set stays set when
13
+ # they turn the page; anything else the query string carries does not
14
+ # come back.
15
+ FORWARDED_PARAMS = %w[actor_type actor_id status per_page].freeze
16
+ TRUNCATION_LIMIT = 2_000
17
+
18
+ # @rbs (untyped) -> String
19
+ def h(text)
20
+ ::Rack::Utils.escape_html(text.to_s)
21
+ end
22
+
23
+ # @rbs () -> String
24
+ def root_path
25
+ env["SCRIPT_NAME"].to_s
26
+ end
27
+
28
+ # @rbs (String) -> String
29
+ def path_to(path)
30
+ "#{root_path}#{path}"
31
+ end
32
+
33
+ # @rbs () -> String
34
+ def current_path
35
+ request.path_info
36
+ end
37
+
38
+ # @rbs (String) -> bool
39
+ def current_tab?(path)
40
+ return current_path == "/" if path == "/"
41
+
42
+ current_path.start_with?(path)
43
+ end
44
+
45
+ # @rbs () -> Hash[String, String]
46
+ def tabs
47
+ Web.tabs
48
+ end
49
+
50
+ # @rbs () -> String?
51
+ def csp_nonce
52
+ env[Web::NONCE_KEY]
53
+ end
54
+
55
+ # @rbs () -> String
56
+ def csrf_tag
57
+ %(<input type="hidden" name="authenticity_token" value="#{h(env[Web::CSRF_TOKEN_KEY])}" />)
58
+ end
59
+
60
+ # @rbs (String) -> String
61
+ def form_to(path)
62
+ %(<form method="post" action="#{h(path_to(path))}">#{csrf_tag})
63
+ end
64
+
65
+ # @rbs (untyped) -> String
66
+ def relative_time(time)
67
+ return "&mdash;" unless time
68
+
69
+ stamp = time.getutc.iso8601
70
+ %(<time datetime="#{stamp}" title="#{stamp}">#{h(stamp)}</time>)
71
+ end
72
+
73
+ # @rbs (untyped) -> String
74
+ def number(value)
75
+ h(value.to_i.to_s.reverse.scan(/\d{1,3}/).join(",").reverse)
76
+ end
77
+
78
+ # @rbs (Numeric?) -> String
79
+ def duration(seconds)
80
+ return "&mdash;" unless seconds
81
+
82
+ return "#{h(format("%.3f", seconds))} s" if seconds < 60
83
+
84
+ h("#{(seconds / 60).floor} min #{(seconds % 60).round} s")
85
+ end
86
+
87
+ # @rbs (untyped, ?Integer) -> String
88
+ def json_block(value)
89
+ return "&mdash;" if value.nil?
90
+
91
+ %(<pre class="payload">#{h(truncate(JSON.pretty_generate(value)))}</pre>)
92
+ rescue JSON::GeneratorError, TypeError
93
+ %(<pre class="payload">#{h(truncate(value.inspect))}</pre>)
94
+ end
95
+
96
+ # @rbs (String, ?Integer) -> String
97
+ def truncate(text, limit = TRUNCATION_LIMIT)
98
+ return text if text.length <= limit
99
+
100
+ "#{text[0, limit]}…"
101
+ end
102
+
103
+ # @rbs (String?) -> String
104
+ def status_label(status)
105
+ %(<span class="status status-#{h(status)}">#{h(status)}</span>)
106
+ end
107
+
108
+ # @rbs (untyped) -> String
109
+ def actor_label(record)
110
+ "#{h(record.actor_type)} / #{h(record.actor_id)}"
111
+ end
112
+
113
+ # @rbs (untyped) -> String
114
+ def instance_link(instance)
115
+ %(<a href="#{h(path_to("/instances/#{instance.id}"))}">#{actor_label(instance)}</a>)
116
+ end
117
+
118
+ # @rbs (?Hash[String, untyped]) -> String
119
+ def query_string(overrides = {})
120
+ merged = FORWARDED_PARAMS
121
+ .to_h { |name| [ name, url_params(name) ] }
122
+ .merge(overrides.transform_keys(&:to_s))
123
+ .reject { |_name, value| value.nil? || value.to_s.empty? }
124
+ return "" if merged.empty?
125
+
126
+ "?#{merged.map { |name, value| "#{::Rack::Utils.escape(name)}=#{::Rack::Utils.escape(value.to_s)}" }.join("&")}"
127
+ end
128
+
129
+ # @rbs (?Hash[String, untyped]) -> String
130
+ def page_link(overrides = {})
131
+ h("#{path_to(current_path)}#{query_string(overrides)}")
132
+ end
133
+
134
+ # @rbs (Instance) -> String
135
+ def lease_state(instance)
136
+ return "paused" if instance.paused_at
137
+ return "idle" unless instance.activation_owner_id
138
+ return "idle" unless instance.activation_expires_at
139
+
140
+ (instance.activation_expires_at > statistics.now) ? "activated" : "expired"
141
+ end
142
+
143
+ # @rbs () -> Statistics
144
+ def statistics
145
+ @statistics ||= Statistics.new
146
+ end
147
+
148
+ # @rbs (untyped) -> Paginator
149
+ def paginate(relation)
150
+ Paginator.new(relation:, page: url_params("page"), per_page: url_params("per_page"))
151
+ end
152
+
153
+ # An unrecognized filter falls back to the default rather than returning
154
+ # nothing, so a hand edited query string cannot make a page look empty.
155
+ # @rbs (Array[String], ?default: String?) -> String?
156
+ def filter_value(allowed, default: nil)
157
+ value = url_params("status")
158
+ allowed.include?(value) ? value : default
159
+ end
160
+
161
+ # @rbs () -> Instance
162
+ def find_instance
163
+ instance = Instance.find_by(id: route_params(:id))
164
+ halt(404) unless instance
165
+
166
+ instance
167
+ end
168
+
169
+ # @rbs () -> untyped
170
+ def filtered_instances
171
+ relation = Instance.order(updated_at: :desc, id: :desc)
172
+ actor_type = url_params("actor_type")
173
+ relation = relation.where(actor_type:) unless actor_type.to_s.empty?
174
+ actor_id = url_params("actor_id")
175
+ return relation if actor_id.to_s.empty?
176
+
177
+ relation.where(
178
+ Instance.arel_table[:actor_id].matches("%#{Instance.sanitize_sql_like(actor_id)}%")
179
+ )
180
+ end
181
+
182
+ # @rbs (String) -> untyped
183
+ def mailbox_messages(membership)
184
+ membership_model = (membership == "claimed") ? ClaimedMessage : ReadyMessage
185
+ Message
186
+ .where(id: membership_model.select(:message_id))
187
+ .order(available_at: :asc, id: :asc)
188
+ end
189
+
190
+ # Chart data travels in an attribute rather than an inline script block,
191
+ # so the page needs no script-src exception and an actor type cannot
192
+ # close the attribute and open a tag.
193
+ #
194
+ # The container is not decoration. Chart.js measures a responsive canvas
195
+ # against its parent, so the parent has to have a height of its own; a
196
+ # panel that sizes to its children would grow a little on every redraw.
197
+ # @rbs (String, untyped) -> String
198
+ def chart(name, values)
199
+ return "" unless Web.charts?
200
+
201
+ %(<div class="chart-frame"><canvas data-chart="#{h(name)}" ) +
202
+ %(data-chart-values='#{h(JSON.generate(values))}'></canvas></div>)
203
+ end
204
+
205
+ # A vendored copy is a path below the mount; a CDN copy is an absolute
206
+ # URL and is left alone.
207
+ # @rbs () -> String
208
+ def chart_library_source
209
+ url = Web.chart_library_url.to_s
210
+ url.include?("//") ? url : path_to(url)
211
+ end
212
+
213
+ # @rbs () -> String
214
+ def chart_library_integrity_attributes
215
+ integrity = Web.chart_library_integrity
216
+ return "" unless integrity
217
+
218
+ %( integrity="#{h(integrity)}" crossorigin="anonymous" referrerpolicy="no-referrer")
219
+ end
220
+
221
+ # @rbs () -> String
222
+ def environment_name
223
+ ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,64 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # Counts and slices one relation. The page size is clamped because the page
6
+ # number and the page size both arrive from the query string, and an
7
+ # operator page that accepts an unbounded limit is a denial of service
8
+ # against the database the actors run on.
9
+ class Paginator
10
+ DEFAULT_PER_PAGE = 25
11
+ MAXIMUM_PER_PAGE = 200
12
+
13
+ # @rbs @page: Integer
14
+ # @rbs @per_page: Integer
15
+ # @rbs @total: Integer
16
+ # @rbs @records: Array[untyped]
17
+
18
+ attr_reader :page, :per_page, :total, :records
19
+
20
+ # @rbs (relation: untyped, ?page: String?, ?per_page: String?) -> void
21
+ def initialize(relation:, page: nil, per_page: nil)
22
+ @per_page = bounded(per_page, default: DEFAULT_PER_PAGE, maximum: MAXIMUM_PER_PAGE)
23
+ @total = relation.count
24
+ @page = bounded(page, default: 1, maximum: last_page)
25
+ @records = relation.offset((@page - 1) * @per_page).limit(@per_page).to_a
26
+ end
27
+
28
+ # @rbs () -> Integer
29
+ def last_page
30
+ [ (total.to_f / per_page).ceil, 1 ].max
31
+ end
32
+
33
+ # @rbs () -> Integer?
34
+ def previous_page
35
+ (page > 1) ? page - 1 : nil
36
+ end
37
+
38
+ # @rbs () -> Integer?
39
+ def next_page
40
+ (page < last_page) ? page + 1 : nil
41
+ end
42
+
43
+ # @rbs () -> Integer
44
+ def first_record
45
+ total.zero? ? 0 : ((page - 1) * per_page) + 1
46
+ end
47
+
48
+ # @rbs () -> Integer
49
+ def last_record
50
+ [ page * per_page, total ].min
51
+ end
52
+
53
+ private
54
+
55
+ # @rbs (String?, default: Integer, maximum: Integer) -> Integer
56
+ def bounded(value, default:, maximum:)
57
+ requested = Integer(value.to_s, 10, exception: false)
58
+ return default unless requested&.positive?
59
+
60
+ [ requested, maximum ].min
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ class Route
6
+ # A named segment stops at the next slash, so `/instances/:id` never
7
+ # swallows `/instances/1/pause` and route order cannot hide a page.
8
+ NAMED_SEGMENT = %r{/([^/]*):([^.:$/]+)}
9
+ SEGMENT_CAPTURE = '/\1(?<\2>[^$/]+)'
10
+
11
+ # @rbs @matcher: String | Regexp
12
+ # @rbs @request_method: String
13
+ # @rbs @pattern: String
14
+ # @rbs @policy: Hash[Symbol, String]
15
+ # @rbs @handler: Proc
16
+
17
+ attr_reader :request_method, :pattern, :policy, :handler
18
+
19
+ # @rbs (request_method: String, pattern: String, policy: Hash[Symbol, String], handler: Proc) -> void
20
+ def initialize(request_method:, pattern:, policy:, handler:)
21
+ @request_method = request_method
22
+ @pattern = pattern
23
+ @policy = policy
24
+ @handler = handler
25
+ @matcher = compile(pattern)
26
+ end
27
+
28
+ # @rbs (String) -> bool
29
+ def match?(path)
30
+ return @matcher == path if @matcher.is_a?(String)
31
+
32
+ @matcher.match?(path)
33
+ end
34
+
35
+ # @rbs (String) -> Hash[Symbol, String?]
36
+ def capture(path)
37
+ return {} if @matcher.is_a?(String)
38
+
39
+ match = @matcher.match(path)
40
+ return {} unless match
41
+
42
+ match.named_captures.transform_keys(&:to_sym)
43
+ end
44
+
45
+ private
46
+
47
+ # @rbs (String) -> (String | Regexp)
48
+ def compile(pattern)
49
+ return pattern unless pattern.match?(NAMED_SEGMENT)
50
+
51
+ Regexp.new("\\A#{pattern.gsub(NAMED_SEGMENT, SEGMENT_CAPTURE)}\\z")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,46 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # Declares the pages of the dashboard. Every route carries the
6
+ # administration policy it needs, and a route declared without one raises
7
+ # at load time. A new page therefore cannot reach the database before an
8
+ # application has said who may read it.
9
+ module Router
10
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
11
+ def head(path, policy: nil, &handler)
12
+ route("HEAD", path, policy:, &handler)
13
+ end
14
+
15
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
16
+ def get(path, policy: nil, &handler)
17
+ route("GET", path, policy:, &handler)
18
+ end
19
+
20
+ # @rbs (String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
21
+ def post(path, policy: nil, &handler)
22
+ route("POST", path, policy:, &handler)
23
+ end
24
+
25
+ # @rbs (String, String, policy: Hash[Symbol, String]?) { () -> untyped } -> void
26
+ def route(request_method, path, policy: nil, &handler)
27
+ raise ArgumentError, "route #{path} requires an authorization policy" unless policy
28
+ raise ArgumentError, "route #{path} requires a handler" unless handler
29
+ raise ArgumentError, "route #{path} requires a policy action" unless policy[:action]
30
+ raise ArgumentError, "route #{path} requires a policy resource" unless policy[:resource]
31
+
32
+ routes[request_method] << Route.new(request_method:, pattern: path, policy:, handler:)
33
+ end
34
+
35
+ # @rbs () -> Hash[String, Array[Route]]
36
+ def routes
37
+ @routes ||= Hash.new { |store, request_method| store[request_method] = [] }
38
+ end
39
+
40
+ # @rbs (String, String) -> Route?
41
+ def match(request_method, path)
42
+ routes[request_method].find { |route| route.match?(path) }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,78 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # The counts behind the dashboard and behind `GET /stats`. Both read the
6
+ # same object, so the polled JSON and the rendered page can never disagree
7
+ # about what a number means.
8
+ class Statistics
9
+ EFFECT_STATUSES = %w[pending processing completed dead].freeze
10
+ BROADCAST_STATUSES = %w[pending processing delivered dead].freeze
11
+ REMINDER_STATUSES = %w[scheduled paused completed].freeze
12
+ PROCESS_STATES = %w[running draining stopped].freeze
13
+
14
+ # @rbs @now: Time
15
+
16
+ attr_reader :now
17
+
18
+ # @rbs (?now: Time) -> void
19
+ def initialize(now: SolidObjects.database_adapter.database_now)
20
+ @now = now
21
+ end
22
+
23
+ # @rbs () -> Hash[Symbol, untyped]
24
+ def to_h
25
+ {
26
+ instances:,
27
+ mailbox:,
28
+ effects: grouped(Effect, :status, EFFECT_STATUSES),
29
+ broadcasts: grouped(Broadcast, :status, BROADCAST_STATUSES),
30
+ reminders:,
31
+ dead_letters: { total: DeadLetter.count },
32
+ processes: grouped(Process, :shutdown_state, PROCESS_STATES),
33
+ server_time: now.utc.iso8601
34
+ }
35
+ end
36
+
37
+ # @rbs () -> Hash[Symbol, Integer]
38
+ def instances
39
+ {
40
+ total: Instance.count,
41
+ paused: Instance.where.not(paused_at: nil).count,
42
+ activated: Instance.where(activation_expires_at: now..).count
43
+ }
44
+ end
45
+
46
+ # The oldest ready message that is already due is the queue latency of
47
+ # this runtime: how far behind the workers are, in seconds.
48
+ # @rbs () -> Hash[Symbol, untyped]
49
+ def mailbox
50
+ due = ReadyMessage.where(available_at: ..now)
51
+ oldest = due.minimum(:available_at)
52
+ {
53
+ ready: ReadyMessage.count,
54
+ due: due.count,
55
+ claimed: ClaimedMessage.count,
56
+ latency: oldest ? (now - oldest).round(3) : 0.0
57
+ }
58
+ end
59
+
60
+ # @rbs () -> Hash[Symbol, Integer]
61
+ def reminders
62
+ grouped(Reminder, :status, REMINDER_STATUSES).merge(
63
+ due: Reminder.where(status: "scheduled", next_run_at: ..now).count
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ # A status the schema allows but the table does not currently hold still
70
+ # reports zero, so a row of counts keeps the same shape between polls.
71
+ # @rbs (untyped, Symbol, Array[String]) -> Hash[Symbol, Integer]
72
+ def grouped(model, column, statuses)
73
+ counts = model.group(column).count
74
+ statuses.to_h { |status| [ status.to_sym, counts.fetch(status, 0) ] }
75
+ end
76
+ end
77
+ end
78
+ end