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,45 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class PollingBackoff
5
+ # @rbs @minimum_interval: Float
6
+ # @rbs @maximum_interval: Float
7
+ # @rbs @current_interval: Float
8
+ # @rbs @on_change: Proc?
9
+
10
+ attr_reader :current_interval
11
+
12
+ # @rbs (minimum_interval: Numeric, maximum_interval: Numeric, ?on_change: Proc?) -> void
13
+ def initialize(minimum_interval:, maximum_interval:, on_change: nil)
14
+ @minimum_interval = minimum_interval.to_f
15
+ @maximum_interval = [ @minimum_interval, maximum_interval.to_f ].max
16
+ @current_interval = @minimum_interval
17
+ @on_change = on_change
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def record_idle
22
+ change([ current_interval * 2, @maximum_interval ].min, :idle)
23
+ end
24
+
25
+ # @rbs (:work | :wake_up) -> void
26
+ def reset(reason)
27
+ change(@minimum_interval, reason)
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (Float, :idle | :work | :wake_up) -> void
33
+ def change(interval, reason)
34
+ return if interval == current_interval
35
+
36
+ previous_interval = current_interval
37
+ @current_interval = interval
38
+ @on_change&.call(
39
+ previous_interval:,
40
+ current_interval: interval,
41
+ reason:
42
+ )
43
+ end
44
+ end
45
+ end
@@ -4,6 +4,9 @@ require "socket"
4
4
 
5
5
  module SolidObjects
6
6
  class ProcessRegistry
7
+ @polling_warning_mutex = Mutex.new
8
+ @polling_warning_emitted = false
9
+
7
10
  class << self
8
11
  # @rbs (?now: Time) -> Integer
9
12
  def cleanup_dead(now: SolidObjects.database_adapter.database_now)
@@ -53,8 +56,56 @@ module SolidObjects
53
56
  true
54
57
  end
55
58
 
59
+ # @rbs () -> void
60
+ def warn_if_polling_is_only_cross_process_wake_up
61
+ return if SolidObjects.configuration.wake_up_adapter
62
+
63
+ polling_warning_mutex.synchronize do
64
+ return if polling_warning_emitted?
65
+ return unless another_live_process?
66
+
67
+ payload = {
68
+ event: "solid_objects.polling_only_cross_process_wake_up",
69
+ polling_interval: SolidObjects.configuration.polling_interval,
70
+ idle_polling_interval: SolidObjects.configuration.idle_polling_interval
71
+ }
72
+ SolidObjects.configuration.logger.warn(payload)
73
+ SolidObjects.instrument(
74
+ :"polling.only_cross_process_wake_up",
75
+ **payload.except(:event)
76
+ )
77
+ @polling_warning_emitted = true
78
+ end
79
+ end
80
+
81
+ # @rbs () -> void
82
+ def reset_polling_warning!
83
+ polling_warning_mutex.synchronize { @polling_warning_emitted = false }
84
+ end
85
+
56
86
  private
57
87
 
88
+ # @rbs () -> bool
89
+ def another_live_process?
90
+ alive_after = SolidObjects.database_adapter.database_now -
91
+ SolidObjects.configuration.process_alive_threshold
92
+ Process
93
+ .where.not(shutdown_state: "stopped")
94
+ .where(last_heartbeat_at: alive_after..)
95
+ .where("hostname <> ? OR pid <> ?", Socket.gethostname, ::Process.pid)
96
+ .exists?
97
+ end
98
+
99
+ # @rbs () -> bool
100
+ def polling_warning_emitted?
101
+ @polling_warning_emitted
102
+ end
103
+
104
+ # @rbs () -> Mutex
105
+ def polling_warning_mutex
106
+ @polling_warning_mutex ||= Mutex.new
107
+ end
108
+
58
109
  # @rbs (Process, Time) -> void
59
110
  def cleanup_process(process_record, now)
60
111
  deregister(process_record, now:)
@@ -1,11 +1,14 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  class ReminderScheduler
5
7
  # @rbs @process_registry: ProcessRegistry
6
8
  # @rbs @database_adapter: DatabaseAdapter
7
9
  # @rbs @stopped: bool
8
10
  # @rbs @shutdown_requested: bool
11
+ # @rbs @polling_backoff: PollingBackoff
9
12
 
10
13
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
14
  def initialize(
@@ -17,6 +20,17 @@ module SolidObjects
17
20
  process_registry.register(kind: "reminder")
18
21
  @stopped = false
19
22
  @shutdown_requested = false
23
+ @polling_backoff = PollingBackoff.new(
24
+ minimum_interval: SolidObjects.configuration.polling_interval,
25
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
26
+ on_change: ->(transition) do
27
+ SolidObjects.instrument(
28
+ :"polling.interval_changed",
29
+ role: "reminders",
30
+ **transition
31
+ )
32
+ end
33
+ )
20
34
  end
21
35
 
22
36
  # @rbs (?now: Time?) -> bool
@@ -45,11 +59,23 @@ module SolidObjects
45
59
 
46
60
  # @rbs () -> void
47
61
  def run
62
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
63
+
48
64
  until shutdown_requested?
65
+ wake_up = SolidObjects.wake_up
66
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
49
67
  worked = run_once
50
- next if worked
51
-
52
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
68
+ if worked
69
+ polling_backoff.reset(:work)
70
+ next
71
+ end
72
+
73
+ notified = watch.wait(timeout: current_polling_interval)
74
+ if notified == false
75
+ polling_backoff.record_idle
76
+ else
77
+ polling_backoff.reset(:wake_up)
78
+ end
53
79
  end
54
80
  ensure
55
81
  stop
@@ -71,9 +97,14 @@ module SolidObjects
71
97
  @shutdown_requested
72
98
  end
73
99
 
100
+ # @rbs () -> Float
101
+ def current_polling_interval
102
+ polling_backoff.current_interval
103
+ end
104
+
74
105
  private
75
106
 
76
- attr_reader :process_registry, :database_adapter
107
+ attr_reader :process_registry, :database_adapter, :polling_backoff
77
108
 
78
109
  # @rbs (now: Time?) -> Reminder?
79
110
  def claim_next(now:)
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.12.1"
4
+ VERSION = "0.13.1"
5
5
  end
@@ -2,23 +2,55 @@
2
2
 
3
3
  module SolidObjects
4
4
  class WakeUp
5
+ class Watch
6
+ # @rbs @wake_up: WakeUp
7
+ # @rbs @generation: Integer
8
+
9
+ # @rbs (WakeUp, Integer) -> void
10
+ def initialize(wake_up, generation)
11
+ @wake_up = wake_up
12
+ @generation = generation
13
+ end
14
+
15
+ # @rbs (timeout: Numeric) -> bool
16
+ def wait(timeout:)
17
+ @wake_up.wait(timeout:, generation: @generation)
18
+ end
19
+ end
20
+
5
21
  # @rbs @mutex: Mutex
6
22
  # @rbs @condition: Thread::ConditionVariable
23
+ # @rbs @generation: Integer
7
24
 
8
25
  # @rbs () -> void
9
26
  def initialize
10
27
  @mutex = Mutex.new
11
28
  @condition = Thread::ConditionVariable.new
29
+ @generation = 0
12
30
  end
13
31
 
14
32
  # @rbs () -> void
15
33
  def signal
16
- mutex.synchronize { condition.broadcast }
34
+ mutex.synchronize do
35
+ @generation += 1
36
+ condition.broadcast
37
+ end
17
38
  end
18
39
 
19
- # @rbs (timeout: Numeric) -> void
20
- def wait(timeout:)
21
- mutex.synchronize { condition.wait(mutex, timeout) }
40
+ # @rbs () -> Watch
41
+ def watch
42
+ mutex.synchronize { Watch.new(self, @generation) }
43
+ end
44
+
45
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
46
+ def wait(timeout:, generation: nil)
47
+ mutex.synchronize do
48
+ return true if generation && @generation != generation
49
+
50
+ generation ||= @generation
51
+ condition.wait(mutex, timeout)
52
+ @generation != generation
53
+ end
22
54
  end
23
55
 
24
56
  private
@@ -45,6 +45,12 @@ module SolidObjects
45
45
  false
46
46
  end
47
47
 
48
+ # @rbs () -> self
49
+ def watch
50
+ listen
51
+ self
52
+ end
53
+
48
54
  # Starts listening before a caller blocks, so a notification sent between
49
55
  # startup and the first wait is not missed.
50
56
  # @rbs () -> bool
@@ -12,6 +12,22 @@ module SolidObjects
12
12
  # polling interval remains the upper bound, so a missed or failed
13
13
  # notification costs latency rather than correctness.
14
14
  class Redis
15
+ class Watch
16
+ # @rbs @adapter: Redis
17
+ # @rbs @generation: Integer
18
+
19
+ # @rbs (Redis, Integer) -> void
20
+ def initialize(adapter, generation)
21
+ @adapter = adapter
22
+ @generation = generation
23
+ end
24
+
25
+ # @rbs (timeout: Numeric) -> bool
26
+ def wait(timeout:)
27
+ @adapter.wait(timeout:, generation: @generation)
28
+ end
29
+ end
30
+
15
31
  CHANNEL = "solid_objects_wake_up"
16
32
  FAILED_WAIT_INTERVAL = 0.05
17
33
  SUBSCRIBE_TIMEOUT = 5.0
@@ -52,9 +68,9 @@ module SolidObjects
52
68
  # The counter is snapshotted before subscribing, and re-checked before
53
69
  # blocking, so a signal delivered while this caller was still getting
54
70
  # ready is observed rather than absorbed into the new baseline.
55
- # @rbs (timeout: Numeric) -> bool
56
- def wait(timeout:)
57
- signalled = mutex.synchronize { @signalled }
71
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
72
+ def wait(timeout:, generation: nil)
73
+ signalled = generation || mutex.synchronize { @signalled }
58
74
  return paced_failure(timeout) unless listen
59
75
 
60
76
  mutex.synchronize do
@@ -65,6 +81,12 @@ module SolidObjects
65
81
  end
66
82
  end
67
83
 
84
+ # @rbs () -> Watch
85
+ def watch
86
+ listen
87
+ mutex.synchronize { Watch.new(self, @signalled) }
88
+ end
89
+
68
90
  # Redis delivers to a subscribed connection only, and a subscribed
69
91
  # connection cannot serve other callers, so one background subscription
70
92
  # per process fans out to every waiting role in memory. Subscribing
@@ -0,0 +1,109 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "erb"
4
+ require "rack/request"
5
+ require "rack/utils"
6
+
7
+ module SolidObjects
8
+ class Web
9
+ # One request. A route handler runs inside an instance of this class, so a
10
+ # handler and the template it renders share the same helpers and the same
11
+ # request.
12
+ class Action
13
+ include Helpers
14
+
15
+ # @rbs @env: Hash[String, untyped]
16
+ # @rbs @route: Route
17
+ # @rbs @captures: Hash[Symbol, String?]
18
+ # @rbs @request: untyped
19
+ # @rbs @layout_rendered: bool
20
+ # @rbs @response_status: Integer
21
+
22
+ attr_reader :env, :route, :response_status
23
+
24
+ # @rbs (env: Hash[String, untyped], route: Route) -> void
25
+ def initialize(env:, route:)
26
+ @env = env
27
+ @route = route
28
+ @captures = route.capture(env["PATH_INFO"].to_s)
29
+ @layout_rendered = false
30
+ @response_status = 200
31
+ end
32
+
33
+ # Sets the status a rendered page is served with. `halt` and `redirect`
34
+ # stop the handler, so a page that must render its own body and still
35
+ # report a failure needs this instead.
36
+ # @rbs (Integer) -> void
37
+ def status(code)
38
+ @response_status = code
39
+ end
40
+
41
+ # @rbs () -> untyped
42
+ def request
43
+ @request ||= ::Rack::Request.new(env)
44
+ end
45
+
46
+ # @rbs () -> Hash[untyped, untyped]?
47
+ def session
48
+ env["rack.session"]
49
+ end
50
+
51
+ # @rbs (Symbol) -> String?
52
+ def route_params(key)
53
+ @captures[key]
54
+ end
55
+
56
+ # @rbs (String) -> untyped
57
+ def url_params(key)
58
+ request.params[key]
59
+ end
60
+
61
+ # @rbs () -> untyped
62
+ def call
63
+ instance_exec(&route.handler)
64
+ end
65
+
66
+ # The layout is rendered once per request. The flag is raised before the
67
+ # page body runs so a partial the body renders returns its own fragment
68
+ # rather than a second whole page. The layout reads the page it wraps
69
+ # from `locals.fetch(:content)`.
70
+ # @rbs (Symbol, ?Hash[Symbol, untyped]) -> String
71
+ def erb(name, locals = {})
72
+ return evaluate(Web.template(name), locals) if @layout_rendered
73
+
74
+ @layout_rendered = true
75
+ content = evaluate(Web.template(name), locals)
76
+ evaluate(Web.template(:layout), { content: })
77
+ end
78
+
79
+ # @rbs (Integer, ?String) -> void
80
+ def halt(status, body = ::Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Error"))
81
+ throw :halt, [ status, { "content-type" => "text/plain" }, [ body ] ]
82
+ end
83
+
84
+ # @rbs (String) -> void
85
+ def redirect(path)
86
+ throw :halt, [ 302, { "location" => path_to(path) }, [] ]
87
+ end
88
+
89
+ # @rbs (untyped) -> void
90
+ def json(payload)
91
+ throw :halt, [
92
+ 200,
93
+ { "content-type" => "application/json", "cache-control" => "private, no-store" },
94
+ [ JSON.generate(payload) ]
95
+ ]
96
+ end
97
+
98
+ private
99
+
100
+ # `locals` is a local variable of this method, so a template reads it by
101
+ # name, and every helper is reachable because the template runs against
102
+ # this object.
103
+ # @rbs (untyped, Hash[Symbol, untyped]) -> String
104
+ def evaluate(template, locals)
105
+ template.result(binding)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,239 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Web
5
+ # The pages. Each route states the administration policy it needs, and
6
+ # `call` asks that policy before the handler runs, so a page cannot read
7
+ # the actor tables on behalf of an unauthorized request.
8
+ class Application
9
+ extend Router
10
+
11
+ SCRIPT_PLACEHOLDER = "!script-src!"
12
+ CONTENT_SECURITY_POLICY = [
13
+ "default-src 'self'",
14
+ "base-uri 'self'",
15
+ "form-action 'self'",
16
+ "frame-ancestors 'none'",
17
+ "img-src 'self' data:",
18
+ "style-src 'self'",
19
+ "script-src #{SCRIPT_PLACEHOLDER}",
20
+ "connect-src 'self'",
21
+ "object-src 'none'"
22
+ ].join("; ").freeze
23
+
24
+ MAILBOX_MEMBERSHIPS = %w[ready claimed].freeze
25
+ RECENT_LIMIT = 10
26
+ CHART_TYPE_LIMIT = 12
27
+
28
+ head "/", policy: { action: "index", resource: "dashboard" } do
29
+ # The cheapest liveness check available: it proves the dashboard can
30
+ # reach the database the actors run on, and returns no body.
31
+ ReadyMessage.count
32
+ ""
33
+ end
34
+
35
+ get "/", policy: { action: "index", resource: "dashboard" } do
36
+ @statistics = statistics.to_h
37
+ @processes = Process.order(last_heartbeat_at: :desc).limit(RECENT_LIMIT)
38
+ @dead_letters = DeadLetter.order(last_failed_at: :desc, id: :desc).limit(RECENT_LIMIT)
39
+ # The only chart that costs its own query, and the only one the poller
40
+ # cannot refresh, because the other two read what `/stats` already
41
+ # returns. Bounded so a runtime with many actor types draws a readable
42
+ # chart rather than every type it has ever seen.
43
+ @instances_by_type = Instance
44
+ .group(:actor_type)
45
+ .order(Arel.sql("COUNT(*) DESC"))
46
+ .limit(CHART_TYPE_LIMIT)
47
+ .count
48
+ erb(:dashboard)
49
+ end
50
+
51
+ get "/stats", policy: { action: "index", resource: "dashboard" } do
52
+ json(statistics.to_h)
53
+ end
54
+
55
+ get "/instances", policy: { action: "index", resource: "instances" } do
56
+ @paginator = paginate(filtered_instances)
57
+ # Suggestions come from the registry rather than a DISTINCT over the
58
+ # instances table, which no adapter can answer from an index. The field
59
+ # stays free text, so an actor type that is no longer registered is
60
+ # still reachable.
61
+ @actor_types = SolidObjects.registry.to_h.keys.sort
62
+ erb(:instances)
63
+ end
64
+
65
+ get "/instances/:id", policy: { action: "show", resource: "instances" } do
66
+ @instance = find_instance
67
+ @ready_messages = @instance.messages
68
+ .where(id: ReadyMessage.select(:message_id))
69
+ .order(sequence: :asc)
70
+ .limit(RECENT_LIMIT)
71
+ @claimed_messages = @instance.messages
72
+ .where(id: ClaimedMessage.select(:message_id))
73
+ .order(sequence: :asc)
74
+ .limit(RECENT_LIMIT)
75
+ @recent_messages = @instance.messages.order(sequence: :desc).limit(RECENT_LIMIT)
76
+ @reminders = Reminder.where(instance_id: @instance.id).order(next_run_at: :asc).limit(RECENT_LIMIT)
77
+ @effects = Effect.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
78
+ @broadcasts = Broadcast.where(instance_id: @instance.id).order(id: :desc).limit(RECENT_LIMIT)
79
+ @dead_letters = DeadLetter.where(instance_id: @instance.id).order(last_failed_at: :desc).limit(RECENT_LIMIT)
80
+ erb(:instance)
81
+ end
82
+
83
+ # Pausing stops the activation manager from claiming the instance again.
84
+ # A pass already in flight finishes its turn, and a synchronous caller
85
+ # waiting on this instance times out rather than being answered, so this
86
+ # is an operator brake and not a delivery guarantee.
87
+ post "/instances/:id/pause", policy: { action: "pause", resource: "instances" } do
88
+ instance = find_instance
89
+ instance.update!(paused_at: SolidObjects.database_adapter.database_now)
90
+ redirect("/instances/#{instance.id}")
91
+ end
92
+
93
+ post "/instances/:id/resume", policy: { action: "resume", resource: "instances" } do
94
+ instance = find_instance
95
+ instance.update!(paused_at: nil)
96
+ redirect("/instances/#{instance.id}")
97
+ end
98
+
99
+ get "/mailbox", policy: { action: "index", resource: "messages" } do
100
+ @membership = filter_value(MAILBOX_MEMBERSHIPS, default: "ready")
101
+ @paginator = paginate(mailbox_messages(@membership))
102
+ erb(:mailbox)
103
+ end
104
+
105
+ get "/messages/:id", policy: { action: "show", resource: "messages" } do
106
+ @message = Message.find_by(id: route_params(:id))
107
+ halt(404) unless @message
108
+ erb(:message)
109
+ end
110
+
111
+ get "/reminders", policy: { action: "index", resource: "reminders" } do
112
+ @status = filter_value(Statistics::REMINDER_STATUSES)
113
+ relation = Reminder.order(next_run_at: :asc, id: :asc)
114
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
115
+ erb(:reminders)
116
+ end
117
+
118
+ get "/effects", policy: { action: "index", resource: "effects" } do
119
+ @status = filter_value(Statistics::EFFECT_STATUSES)
120
+ relation = Effect.order(id: :desc)
121
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
122
+ erb(:effects)
123
+ end
124
+
125
+ get "/broadcasts", policy: { action: "index", resource: "broadcasts" } do
126
+ @status = filter_value(Statistics::BROADCAST_STATUSES)
127
+ relation = Broadcast.order(id: :desc)
128
+ @paginator = paginate(@status ? relation.where(status: @status) : relation)
129
+ erb(:broadcasts)
130
+ end
131
+
132
+ get "/dead_letters", policy: { action: "index", resource: "dead_letters" } do
133
+ @paginator = paginate(DeadLetter.order(last_failed_at: :desc, id: :desc))
134
+ erb(:dead_letters)
135
+ end
136
+
137
+ get "/dead_letters/:id", policy: { action: "show", resource: "dead_letters" } do
138
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
139
+ halt(404) unless @dead_letter
140
+ erb(:dead_letter)
141
+ end
142
+
143
+ # A retry re-enters the mailbox, which refuses work the runtime cannot
144
+ # accept: an actor class that no longer exists, a full mailbox, a payload
145
+ # over the cap. The operator who pressed the button is told which,
146
+ # instead of being handed a bare 500 from the Rack handler.
147
+ post "/dead_letters/:id/retry", policy: { action: "retry", resource: "dead_letters" } do
148
+ SolidObjects.dead_letters.retry(route_params(:id).to_i, authorization_context: self)
149
+ redirect("/dead_letters")
150
+ rescue Unauthorized
151
+ raise
152
+ rescue SolidObjects::Error => error
153
+ @dead_letter = DeadLetter.find_by(id: route_params(:id))
154
+ halt(404) unless @dead_letter
155
+ @error = "#{error.class.name.split("::").last}: #{error.message}"
156
+ status(422)
157
+ erb(:dead_letter)
158
+ end
159
+
160
+ get "/processes", policy: { action: "index", resource: "processes" } do
161
+ @status = filter_value(Statistics::PROCESS_STATES)
162
+ relation = Process.order(last_heartbeat_at: :desc)
163
+ @paginator = paginate(@status ? relation.where(shutdown_state: @status) : relation)
164
+ # Counted in one grouped query rather than once per row, because this
165
+ # page is read while the runtime is already under load.
166
+ @activated_counts = Instance
167
+ .where(activation_owner_id: @paginator.records.map(&:id))
168
+ .group(:activation_owner_id)
169
+ .count
170
+ erb(:processes)
171
+ end
172
+
173
+ # @rbs (Hash[String, untyped]) -> Array[untyped]
174
+ def call(env)
175
+ route = self.class.match(env["REQUEST_METHOD"].to_s, env["PATH_INFO"].to_s)
176
+ return not_found unless route
177
+
178
+ action = Action.new(env:, route:)
179
+ return forbidden unless authorized?(action)
180
+
181
+ respond(action, catch(:halt) { action.call })
182
+ rescue Unauthorized
183
+ forbidden
184
+ end
185
+
186
+ private
187
+
188
+ # @rbs (Action, untyped) -> Array[untyped]
189
+ def respond(action, result)
190
+ return result if result.is_a?(Array)
191
+
192
+ [ action.response_status, page_headers(action.env), [ result.to_s ] ]
193
+ end
194
+
195
+ # @rbs (Action) -> bool
196
+ def authorized?(action)
197
+ policy = action.route.policy
198
+ SolidObjects.configuration.authorize_administration.call(
199
+ action: policy.fetch(:action),
200
+ resource: policy.fetch(:resource),
201
+ resource_id: action.route_params(:id),
202
+ authorization_context: action
203
+ )
204
+ end
205
+
206
+ # @rbs (Hash[String, untyped]) -> Hash[String, String]
207
+ def page_headers(env)
208
+ {
209
+ "content-type" => "text/html; charset=utf-8",
210
+ "cache-control" => "private, no-store",
211
+ "content-security-policy" => content_security_policy(env),
212
+ "x-content-type-options" => "nosniff",
213
+ "referrer-policy" => "same-origin"
214
+ }
215
+ end
216
+
217
+ # The chart host is named only when one is configured, so a deployment
218
+ # that vendors the library or turns charts off never advertises a third
219
+ # party origin it does not use.
220
+ # @rbs (Hash[String, untyped]) -> String
221
+ def content_security_policy(env)
222
+ sources = [ "'self'", "'nonce-#{env[Web::NONCE_KEY]}'", Web.chart_library_origin ].compact
223
+ CONTENT_SECURITY_POLICY.sub(SCRIPT_PLACEHOLDER, sources.join(" "))
224
+ end
225
+
226
+ # The cascade header lets a host application serve its own 404 for a path
227
+ # below the mount that the dashboard does not define.
228
+ # @rbs () -> Array[untyped]
229
+ def not_found
230
+ [ 404, { "content-type" => "text/plain", "x-cascade" => "pass" }, [ "Not Found" ] ]
231
+ end
232
+
233
+ # @rbs () -> Array[untyped]
234
+ def forbidden
235
+ [ 403, { "content-type" => "text/plain" }, [ "Forbidden" ] ]
236
+ end
237
+ end
238
+ end
239
+ end