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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +69 -13
- data/benchmark/idle_polling.rb +98 -0
- data/benchmark/support.rb +2 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/adr/0011-wake-up-strategy.md +6 -0
- data/docs/architecture.md +5 -4
- data/docs/authorization.md +11 -4
- data/docs/benchmarks.md +38 -0
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +1 -0
- data/docs/operations.md +24 -0
- data/docs/realtime.md +22 -14
- data/docs/roadmap.md +32 -11
- data/docs/security.md +7 -7
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- data/lib/solid_objects/broadcast_executor.rb +35 -4
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/effect_executor.rb +34 -3
- data/lib/solid_objects/polling_backoff.rb +45 -0
- data/lib/solid_objects/process_registry.rb +51 -0
- data/lib/solid_objects/reminder_scheduler.rb +35 -4
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +36 -4
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/lib/solid_objects/worker.rb +39 -3
- data/lib/solid_objects.rb +2 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
- data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/lib/solid_objects/worker.rbs +7 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- 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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:)
|
|
@@ -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
|
|
34
|
+
mutex.synchronize do
|
|
35
|
+
@generation += 1
|
|
36
|
+
condition.broadcast
|
|
37
|
+
end
|
|
17
38
|
end
|
|
18
39
|
|
|
19
|
-
# @rbs (
|
|
20
|
-
def
|
|
21
|
-
mutex.synchronize {
|
|
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
|
|
@@ -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
|