dash 4.1.3 → 4.2.0

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/autoscale/connections/builder.rb +51 -0
  3. data/lib/dash/autoscale/connections.rb +102 -0
  4. data/lib/dash/autoscale/controller.rb +296 -0
  5. data/lib/dash/autoscale/cron.rb +75 -0
  6. data/lib/dash/autoscale/decision.rb +89 -0
  7. data/lib/dash/autoscale/duration.rb +19 -0
  8. data/lib/dash/autoscale/evaluation.rb +52 -0
  9. data/lib/dash/autoscale/guard.rb +48 -0
  10. data/lib/dash/autoscale/lease.rb +55 -0
  11. data/lib/dash/autoscale/lease_held.rb +2 -0
  12. data/lib/dash/autoscale/lease_lost.rb +2 -0
  13. data/lib/dash/autoscale/member.rb +28 -0
  14. data/lib/dash/autoscale/metrics.rb +70 -0
  15. data/lib/dash/autoscale/metrics_server.rb +71 -0
  16. data/lib/dash/autoscale/observation.rb +41 -0
  17. data/lib/dash/autoscale/pause.rb +34 -0
  18. data/lib/dash/autoscale/policy.rb +198 -0
  19. data/lib/dash/autoscale/pool.rb +103 -0
  20. data/lib/dash/autoscale/prometheus_client.rb +76 -0
  21. data/lib/dash/autoscale/provider/base.rb +43 -0
  22. data/lib/dash/autoscale/provider/exec.rb +94 -0
  23. data/lib/dash/autoscale/provider/upcloud.rb +140 -0
  24. data/lib/dash/autoscale/provider.rb +9 -0
  25. data/lib/dash/autoscale/provider_error.rb +4 -0
  26. data/lib/dash/autoscale/read_only_query.rb +38 -0
  27. data/lib/dash/autoscale/reader.rb +17 -0
  28. data/lib/dash/autoscale/recorder.rb +13 -0
  29. data/lib/dash/autoscale/role_state.rb +110 -0
  30. data/lib/dash/autoscale/sample.rb +13 -0
  31. data/lib/dash/autoscale/signal/pgbus.rb +19 -0
  32. data/lib/dash/autoscale/signal/prometheus.rb +8 -0
  33. data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
  34. data/lib/dash/autoscale/signal.rb +31 -0
  35. data/lib/dash/autoscale/simulation.rb +200 -0
  36. data/lib/dash/autoscale/state_store.rb +132 -0
  37. data/lib/dash/autoscale/timestamp.rb +15 -0
  38. data/lib/dash/autoscale/tunnel.rb +48 -0
  39. data/lib/dash/cli/accessory.rb +16 -0
  40. data/lib/dash/cli/app/boot.rb +69 -44
  41. data/lib/dash/cli/app/boot_state.rb +70 -0
  42. data/lib/dash/cli/app/rollout_boot.rb +28 -9
  43. data/lib/dash/cli/app/running_targets.rb +39 -0
  44. data/lib/dash/cli/app/slot_version.rb +29 -0
  45. data/lib/dash/cli/app.rb +89 -32
  46. data/lib/dash/cli/autoscale.rb +312 -0
  47. data/lib/dash/cli/base.rb +15 -3
  48. data/lib/dash/cli/lock.rb +6 -1
  49. data/lib/dash/cli/main.rb +77 -8
  50. data/lib/dash/cli/proxy/inspection.rb +52 -0
  51. data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
  52. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
  53. data/lib/dash/cli/proxy/reboot.rb +4 -9
  54. data/lib/dash/cli/proxy.rb +12 -7
  55. data/lib/dash/cli/prune.rb +5 -3
  56. data/lib/dash/cli/report.rb +4 -1
  57. data/lib/dash/cli/scale/host_plan.rb +63 -0
  58. data/lib/dash/cli/scale/join.rb +128 -0
  59. data/lib/dash/cli/scale/leave.rb +58 -0
  60. data/lib/dash/cli/scale/placement.rb +54 -0
  61. data/lib/dash/cli/scale/power_off.rb +26 -0
  62. data/lib/dash/cli/scale/replica_join.rb +74 -0
  63. data/lib/dash/cli/scale/replica_leave.rb +121 -0
  64. data/lib/dash/cli/scale.rb +276 -0
  65. data/lib/dash/cli/server.rb +41 -0
  66. data/lib/dash/cli/stats_table.rb +34 -0
  67. data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
  68. data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
  69. data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
  70. data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
  71. data/lib/dash/commander.rb +17 -3
  72. data/lib/dash/commands/accessory/proxy.rb +1 -1
  73. data/lib/dash/commands/accessory.rb +21 -2
  74. data/lib/dash/commands/app/containers.rb +28 -3
  75. data/lib/dash/commands/app/logging.rb +13 -2
  76. data/lib/dash/commands/app/proxy.rb +4 -4
  77. data/lib/dash/commands/app.rb +90 -10
  78. data/lib/dash/commands/auditor.rb +2 -2
  79. data/lib/dash/commands/autoscale.rb +133 -0
  80. data/lib/dash/commands/base.rb +4 -0
  81. data/lib/dash/commands/proxy/services.rb +10 -0
  82. data/lib/dash/commands/prune.rb +10 -2
  83. data/lib/dash/commands/server.rb +16 -0
  84. data/lib/dash/configuration/accessory.rb +2 -2
  85. data/lib/dash/configuration/autoscale.rb +123 -0
  86. data/lib/dash/configuration/docs/autoscale.yml +141 -0
  87. data/lib/dash/configuration/docs/configuration.yml +8 -2
  88. data/lib/dash/configuration/docs/proxy.yml +1 -1
  89. data/lib/dash/configuration/docs/role.yml +203 -0
  90. data/lib/dash/configuration/loadbalancer.rb +3 -2
  91. data/lib/dash/configuration/proxy/run.rb +1 -1
  92. data/lib/dash/configuration/proxy.rb +19 -7
  93. data/lib/dash/configuration/role/drain.rb +49 -0
  94. data/lib/dash/configuration/role/replicas.rb +72 -0
  95. data/lib/dash/configuration/role/scale/guard.rb +40 -0
  96. data/lib/dash/configuration/role/scale/signal.rb +93 -0
  97. data/lib/dash/configuration/role/scale/window.rb +37 -0
  98. data/lib/dash/configuration/role/scale.rb +199 -0
  99. data/lib/dash/configuration/role.rb +123 -6
  100. data/lib/dash/configuration/validator/autoscale.rb +126 -0
  101. data/lib/dash/configuration/validator/proxy.rb +13 -0
  102. data/lib/dash/configuration/validator/role.rb +26 -5
  103. data/lib/dash/configuration.rb +126 -15
  104. data/lib/dash/diagnostics/audit.rb +28 -0
  105. data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
  106. data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
  107. data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
  108. data/lib/dash/diagnostics/base.rb +53 -0
  109. data/lib/dash/diagnostics/config.rb +32 -0
  110. data/lib/dash/diagnostics/container_stats.rb +67 -0
  111. data/lib/dash/diagnostics/containers.rb +20 -0
  112. data/lib/dash/diagnostics/controller_status.rb +44 -0
  113. data/lib/dash/diagnostics/docker_ps.rb +40 -0
  114. data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
  115. data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
  116. data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
  117. data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
  118. data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
  119. data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
  120. data/lib/dash/diagnostics/doctor.rb +182 -0
  121. data/lib/dash/diagnostics/drift.rb +168 -0
  122. data/lib/dash/diagnostics/host_stats.rb +59 -0
  123. data/lib/dash/diagnostics/lines.rb +14 -0
  124. data/lib/dash/diagnostics/lock.rb +27 -0
  125. data/lib/dash/diagnostics/logs.rb +76 -0
  126. data/lib/dash/diagnostics/pool.rb +41 -0
  127. data/lib/dash/diagnostics/proxy_services.rb +36 -0
  128. data/lib/dash/diagnostics/redactor.rb +48 -0
  129. data/lib/dash/diagnostics/reports.rb +17 -0
  130. data/lib/dash/diagnostics/scale.rb +51 -0
  131. data/lib/dash/diagnostics/units.rb +31 -0
  132. data/lib/dash/mcp/base_tool.rb +43 -0
  133. data/lib/dash/mcp/runner.rb +28 -0
  134. data/lib/dash/mcp/server.rb +40 -0
  135. data/lib/dash/mcp/session.rb +51 -0
  136. data/lib/dash/mcp/tools/audit_tool.rb +11 -0
  137. data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
  138. data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
  139. data/lib/dash/mcp/tools/config_tool.rb +10 -0
  140. data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
  141. data/lib/dash/mcp/tools/containers_tool.rb +10 -0
  142. data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
  143. data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
  144. data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
  145. data/lib/dash/mcp/tools/drift_tool.rb +16 -0
  146. data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
  147. data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
  148. data/lib/dash/mcp/tools/logs_tool.rb +41 -0
  149. data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
  150. data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
  151. data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
  152. data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
  153. data/lib/dash/mcp/transport.rb +14 -0
  154. data/lib/dash/mcp.rb +56 -0
  155. data/lib/dash/sshkit_with_ext.rb +4 -4
  156. data/lib/dash/utils.rb +7 -0
  157. data/lib/dash/version.rb +1 -1
  158. data/lib/dash.rb +2 -0
  159. metadata +119 -5
  160. data/lib/dash/cli/doctor.rb +0 -113
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 17c99f41669956c79ffd4272e4fd524171c4ff9a37f26d885bd4b4cdc084be85
4
- data.tar.gz: 8fa42e665988fd8d343b0d0141438096635e6059566767bb3d63375fe975e324
3
+ metadata.gz: e8fe407ebe24b10effd6d4236b4e9035f6a01362098e6b23a97d5fc120c6e9b5
4
+ data.tar.gz: f10f2b259bd849dde14c9bdf2982571f20cbafe201141a4a4a797eac14ce25ee
5
5
  SHA512:
6
- metadata.gz: c12f97e422182fba7128f2c27094dc824b0c76825273b89f70d10563c4acc6a59a1d22fe10c22c47b7e0456a6a50c30de0343a6c42de1e0ebd677e5a602c19f7
7
- data.tar.gz: 897652d67bb727025f490d7ee8ca1b719c77c13a2fb896826742c6fc34f5ff3968384925dccd01e00d6493a07c98e75212e76933c97d36323e03c20fb3436d46
6
+ metadata.gz: dcf7d8731f2526798b9e601a9c75c81c728fd7a203a5191fad0eb61473ac2cf474c53de4c0efb83e640610ba3674969d58bc8f8033f7e46f5595bfdc43a83373
7
+ data.tar.gz: 00a5d2988465fdaf6b5df1e0beca380b92ab20ccc61a00cda1fdab9030c44b1c50e9e763a4736d1e649dfc99d3c2020a173ceff58df25118a0522f03b1778771
@@ -0,0 +1,51 @@
1
+ # How each source's client is made. redis-client and pg are not dash dependencies: they are
2
+ # required here, on first use, and a missing one names the gem to add to the Gemfile the
3
+ # controller runs with.
4
+ module Dash::Autoscale::Connections::Builder
5
+ GEMS = { redis: "redis-client", postgres: "pg" }.freeze
6
+ CONNECT_TIMEOUT = 2
7
+ READ_TIMEOUT = 5
8
+ POSTGRES_CONNECT_TIMEOUT = 5
9
+
10
+ def self.build(source, url, tunnel, config:)
11
+ case source
12
+ when :redis
13
+ load_gem(source)
14
+ RedisClient.new(**redis_options(url, tunnel))
15
+ when :postgres
16
+ load_gem(source)
17
+ PG.connect(postgres_options(conninfo(url), tunnel))
18
+ when :prometheus
19
+ Dash::Autoscale::PrometheusClient.new(url: url, token: config.autoscale.prometheus_token, tunnel: tunnel)
20
+ end
21
+ end
22
+
23
+ # redis-client takes host and port over the URL's, and keeps the rest (password, db).
24
+ def self.redis_options(url, tunnel)
25
+ { url: url, **({ host: tunnel.address, port: tunnel.port } if tunnel).to_h,
26
+ connect_timeout: CONNECT_TIMEOUT, read_timeout: READ_TIMEOUT, write_timeout: READ_TIMEOUT }
27
+ end
28
+
29
+ # libpq connects to `hostaddr` and verifies TLS against `host`, so a tunnel only sets
30
+ # hostaddr and port.
31
+ def self.postgres_options(conninfo, tunnel)
32
+ conninfo.merge(**({ hostaddr: tunnel.address, port: tunnel.port } if tunnel).to_h, connect_timeout: POSTGRES_CONNECT_TIMEOUT)
33
+ end
34
+
35
+ # The URL as libpq reads it, so nothing in it is lost when a tunnel changes where it goes.
36
+ def self.conninfo(url)
37
+ PG::Connection.conninfo_parse(url).each_with_object({}) { |option, hash| hash[option[:keyword].to_sym] = option[:val] if option[:val] }
38
+ end
39
+
40
+ # Only the gem itself missing means "add it"; an installed one that fails to load (a
41
+ # native library, a dependency) says why. Both are configuration errors: a LoadError is
42
+ # not a StandardError, and would end the controller.
43
+ def self.load_gem(source)
44
+ require GEMS.fetch(source)
45
+ rescue LoadError => e
46
+ gem = GEMS.fetch(source)
47
+ raise Dash::ConfigurationError, "autoscale/#{source}: the #{gem} gem failed to load: #{e.message}" unless e.path == gem
48
+
49
+ raise Dash::ConfigurationError, %(autoscale/#{source}: needs the #{gem} gem - add gem "#{gem}" to the Gemfile dash autoscale runs with)
50
+ end
51
+ end
@@ -0,0 +1,102 @@
1
+ require "net/ssh"
2
+ require "timeout"
3
+
4
+ # The controller's clients for its signals and guards - Redis, Postgres, Prometheus - one per
5
+ # source for the whole process, so they outlive `reconfigure!` and every tick reuses them.
6
+ # Each is built on first use, through a Dash::Autoscale::Tunnel when the source has `via`,
7
+ # and rebuilt after an error, when its URL, via or Prometheus token changes, or when its tunnel died.
8
+ #
9
+ # One read at a time per source: neither redis-client nor pg connections are thread-safe,
10
+ # and the drain's busy polls (inside SSHKit's `on` threads) share them with the tick.
11
+ class Dash::Autoscale::Connections
12
+ DEFAULT_PORTS = { redis: 6379, postgres: 5432 }.freeze
13
+
14
+ # Errors that leave a client (or the tunnel under it) unusable. Anything else - a query
15
+ # that failed, a malformed job, an HTTP error status - keeps it: the next tick reuses it.
16
+ # The drivers' classes are named, since their gems may not be loaded.
17
+ CONNECTION_ERRORS = [ IOError, SystemCallError, SocketError, Timeout::Error, Net::SSH::Exception ].freeze
18
+ DRIVER_CONNECTION_ERRORS = %w[ RedisClient::ConnectionError RedisClient::ProtocolError PG::ConnectionBad PG::UnableToSend ].freeze
19
+
20
+ Entry = Struct.new(:key, :client, :tunnel)
21
+
22
+ # The process's connections: the controller's, and the drain's when `dash scale set` runs
23
+ # inside it. Closed by the controller when it stops; a later read opens them again.
24
+ def self.shared
25
+ @shared ||= new
26
+ end
27
+
28
+ def initialize(config: -> { DASH.config }, builder: Builder.method(:build), tunnel: Dash::Autoscale::Tunnel.method(:open))
29
+ @config, @builder, @tunnel = config, builder, tunnel
30
+ @entries = {}
31
+ @locks = Hash.new { |locks, source| locks[source] = Mutex.new }
32
+ @locks_lock = Mutex.new
33
+ end
34
+
35
+ def with(source)
36
+ source = source.to_sym
37
+
38
+ lock(source).synchronize do
39
+ entry = entry_for(source)
40
+
41
+ begin
42
+ yield entry.client
43
+ rescue StandardError => e
44
+ discard(source) if connection_error?(e)
45
+ raise
46
+ end
47
+ end
48
+ end
49
+
50
+ def close
51
+ @entries.keys.each { |source| lock(source).synchronize { discard(source) } }
52
+ end
53
+
54
+ private
55
+ def lock(source)
56
+ @locks_lock.synchronize { @locks[source] }
57
+ end
58
+
59
+ # A client is kept while what it was built from - the URL, via and, for Prometheus, the
60
+ # token it sends - stays the same.
61
+ def entry_for(source)
62
+ autoscale = @config.call.autoscale
63
+ url, via = autoscale.source_url(source), autoscale.source_via(source)
64
+ key = [ url, via, (autoscale.prometheus_token if source == :prometheus) ]
65
+ entry = @entries[source]
66
+ return entry if entry && entry.key == key && (entry.tunnel.nil? || entry.tunnel.alive?)
67
+
68
+ discard(source)
69
+ @entries[source] = build(source, url, via, key)
70
+ end
71
+
72
+ def build(source, url, via, key)
73
+ uri = URI(url)
74
+ raise Dash::ConfigurationError, "autoscale/#{source}: via needs a URL that names its host" if via && uri.host.blank?
75
+
76
+ tunnel = @tunnel.call(via, uri.host, uri.port || DEFAULT_PORTS.fetch(source)) if via
77
+ Entry.new(key, @builder.call(source, url, tunnel, config: @config.call), tunnel)
78
+ rescue StandardError
79
+ tunnel&.close
80
+ raise
81
+ end
82
+
83
+ def connection_error?(error)
84
+ CONNECTION_ERRORS.any? { |type| error.is_a?(type) } || error.class.ancestors.any? { |type| DRIVER_CONNECTION_ERRORS.include?(type.name) }
85
+ end
86
+
87
+ def discard(source)
88
+ entry = @entries.delete(source)
89
+ return unless entry
90
+
91
+ close_quietly(entry.client)
92
+ close_quietly(entry.tunnel)
93
+ end
94
+
95
+ # A client or tunnel being thrown away after an error may fail to close; the error
96
+ # that matters has been raised already.
97
+ def close_quietly(closable)
98
+ closable&.close
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ end
@@ -0,0 +1,296 @@
1
+ require "securerandom"
2
+ require "socket"
3
+
4
+ # `dash autoscale run`: every `autoscale.interval` seconds, for each scaled role with a
5
+ # `scale.schedule` or `scale.signal`, observe it, ask Dash::Autoscale::Policy, and carry the decision out
6
+ # through `dash scale set` (or replace an unreachable member) under the deploy lock -
7
+ # never by building a join itself.
8
+ #
9
+ # Its state lives on the state host (Dash::Autoscale::StateStore) and is read at the
10
+ # start of every tick, so a restart or a `--once` run from cron behaves like the long-lived
11
+ # loop. The heartbeat is the lease: a second controller refuses to start while it is
12
+ # alive, and this one stops when it names another controller.
13
+ #
14
+ # A failure in one role becomes that role's `action_failed` (or `lock_busy`) hold and the
15
+ # tick moves on. A tick that fails as a whole (the state host down) is reported and the
16
+ # loop goes on; only a lost lease or a deploy.yml that no longer loads stops it.
17
+ class Dash::Autoscale::Controller
18
+ include SSHKit::DSL
19
+
20
+ DECISION_LOG_LIMIT = 10_000
21
+ TRIM_CHECK_INTERVAL = 3600
22
+
23
+ # One scaled role on one tick: the state it is carried in, how it was evaluated, and
24
+ # what was decided (or what became of the decision once carried out).
25
+ Entry = Struct.new(:role, :state, :evaluation, :decision, keyword_init: true)
26
+
27
+ attr_reader :id, :metrics, :cli
28
+
29
+ # `cli` reconfigures DASH, runs `dash scale set`, replaces members and prints - see
30
+ # Dash::Cli::Autoscale.
31
+ # `reader` reads the signals and guards, over connections the controller closes when it
32
+ # stops - see Dash::Autoscale::Reader.
33
+ def initialize(cli:, clock: -> { Time.now.utc }, sleeper: ->(seconds) { sleep seconds }, dry_run: false, takeover: false,
34
+ interval: nil, mode: "loop", recorder: nil, metrics: Dash::Autoscale::Metrics.new, reader: Dash::Autoscale::Reader.new)
35
+ @cli, @clock, @sleeper = cli, clock, sleeper
36
+ @dry_run, @takeover, @interval, @mode = dry_run, takeover, interval, mode
37
+ @recorder, @metrics, @reader = recorder, metrics, reader
38
+ @id = SecureRandom.hex(8)
39
+ @stopping = false
40
+ end
41
+
42
+ def dry_run?
43
+ @dry_run
44
+ end
45
+
46
+ def interval
47
+ @interval || DASH.config.autoscale.interval
48
+ end
49
+
50
+ # The roles the controller owns the count of: scaled, with a schedule or a signal, within --roles.
51
+ def roles
52
+ (DASH.specific_roles || DASH.config.roles).select(&:controlled?)
53
+ end
54
+
55
+ # Claims the lease, unless another controller's is alive.
56
+ def start
57
+ cli.reconfigure!
58
+ raise ArgumentError, "No scaled role in scope has a scale/schedule or a scale/signal, so there is nothing for the controller to do" if roles.empty?
59
+
60
+ @started_at = @clock.call
61
+ return if dry_run?
62
+
63
+ # Raised out here: SSHKit would wrap anything raised inside `on`.
64
+ lease = with_store { |store| store.ensure_directory; Dash::Autoscale::Lease.new(store.heartbeat, now: @started_at) }
65
+ if lease.held_by_another?(id) && !@takeover
66
+ raise Dash::Autoscale::LeaseHeld, "Another autoscale controller is running: #{lease.describe}. Stop it, or start this one with --takeover"
67
+ end
68
+
69
+ @role_names = roles.map(&:name)
70
+ with_store { |store| store.write_heartbeat(heartbeat(@started_at)) }
71
+ end
72
+
73
+ def run
74
+ start
75
+
76
+ until @stopping
77
+ tick_started = @clock.call
78
+ tick_reporting_failure
79
+ wait_until(tick_started + interval)
80
+ end
81
+ ensure
82
+ release
83
+ end
84
+
85
+ # From a signal handler: only a flag, the current tick (and a join in flight) finishes.
86
+ def stop!
87
+ @stopping = true
88
+ end
89
+
90
+ def stopping?
91
+ @stopping
92
+ end
93
+
94
+ def tick
95
+ started = monotonic
96
+ now = @clock.call
97
+ cli.reconfigure!
98
+
99
+ heartbeat, state, pauses = read_state
100
+ ensure_lease(heartbeat)
101
+
102
+ entries = roles.map { |role| evaluate(role, now, state, pauses) }
103
+ unless dry_run?
104
+ acting = entries.any? { |entry| !entry.decision.hold? }
105
+ keeping_heartbeat { execute(entries) } if acting
106
+ persist(entries, state, now)
107
+ end
108
+
109
+ decisions = entries.map(&:decision)
110
+ @metrics.record_tick(decisions: decisions, at: @clock.call, duration: monotonic - started,
111
+ members: entries.to_h { |entry| [ entry.role.name, entry.evaluation&.observation&.pool.to_a.map(&:state) ] })
112
+ @recorder&.record(now, decisions)
113
+ decisions.each { |decision| cli.report decision.summary, (:red if decision.error) }
114
+ decisions
115
+ end
116
+
117
+ # Closes the signal connections and marks the heartbeat stopped, so the next controller
118
+ # does not wait out a stale lease - unless another controller holds it by now.
119
+ def release
120
+ @reader.close
121
+ return if dry_run? || @started_at.nil?
122
+
123
+ with_store { |store| store.write_heartbeat_if_held(id, heartbeat(@last_tick_at || @started_at).merge(stopped_at: @clock.call)) }
124
+ rescue StandardError => e
125
+ cli.report "Could not mark the autoscale controller stopped: #{e.class}: #{e.message}", :yellow
126
+ end
127
+
128
+ private
129
+ def tick_reporting_failure
130
+ tick
131
+ rescue Dash::Autoscale::LeaseLost, Dash::ConfigurationError
132
+ raise
133
+ rescue StandardError => e
134
+ cli.report "Autoscale tick failed: #{e.class}: #{e.message}", :red
135
+ end
136
+
137
+ def wait_until(deadline)
138
+ while !@stopping && (remaining = deadline - @clock.call) > 0
139
+ @sleeper.call([ remaining, 1 ].min)
140
+ end
141
+ end
142
+
143
+ def read_state
144
+ quietly do
145
+ with_store { |store| [ store.heartbeat, store.state, store.pauses ] }
146
+ end
147
+ end
148
+
149
+ def ensure_lease(heartbeat)
150
+ return if dry_run? || heartbeat["controller_id"].blank? || heartbeat["controller_id"] == id
151
+
152
+ raise Dash::Autoscale::LeaseLost, "Another autoscale controller took over: #{Dash::Autoscale::Lease.new(heartbeat, now: @clock.call).describe}"
153
+ end
154
+
155
+ def evaluate(role, now, state, pauses)
156
+ entry = Entry.new(role: role, state: Dash::Autoscale::RoleState.from(state.dig("roles", role.name)))
157
+ entry.evaluation = Dash::Autoscale::Evaluation.new(role: role, now: now, state: entry.state, pause: Dash::Autoscale::Pause.from(pauses[role.name]),
158
+ observation: quietly { Dash::Autoscale::Observation.take(role) }, reader: @reader)
159
+ entry.decision = entry.evaluation.decide
160
+ entry
161
+ rescue StandardError => e
162
+ entry.evaluation = nil
163
+ entry.decision = Dash::Autoscale::Decision.new(role: role.name, action: "hold", reasons: [ "action_failed" ], at: now, error: "#{e.class}: #{e.message}")
164
+ entry
165
+ end
166
+
167
+ # Taking the deploy lock first ensures the run directory on every host of the app, and
168
+ # a host that did not answer this tick would fail that for every role. The hosts this
169
+ # tick saw down are skipped for it (the trick Dash::Cli::Scale#replace_unreachable uses
170
+ # for its own member).
171
+ def execute(entries)
172
+ skipped = entries.flat_map { |entry| entry.evaluation&.observation&.unread_hosts.to_a }.uniq - DASH.run_directory_ensured_on
173
+ DASH.run_directory_ensured_on.concat(skipped)
174
+
175
+ entries.each { |entry| entry.decision = carry_out(entry) unless entry.decision.hold? }
176
+ ensure
177
+ skipped&.each { |host| DASH.run_directory_ensured_on.delete(host) }
178
+ end
179
+
180
+ # A join can take minutes (lock wait, boot_timeout, the app's boot). The heartbeat keeps
181
+ # beating meanwhile, so the lease stays alive and status does not read the controller as
182
+ # stuck exactly while it scales. A beat that finds another controller's id stops beating.
183
+ def keeping_heartbeat
184
+ done = false
185
+ beat = Thread.new do
186
+ until done
187
+ Kernel.sleep(interval)
188
+ break if done || !beat_heartbeat
189
+ end
190
+ end
191
+
192
+ yield
193
+ ensure
194
+ done = true
195
+ beat&.wakeup if beat&.alive?
196
+ # A beat still writing should land before anything after it (the tick's own heartbeat,
197
+ # or release's stopped_at), but a write that hangs must not hold the tick: past two
198
+ # intervals the beat is killed.
199
+ beat.kill unless beat.nil? || beat.join(2 * interval)
200
+ end
201
+
202
+ def beat_heartbeat
203
+ with_store { |store| store.write_heartbeat_if_held(id, heartbeat(@clock.call)) }
204
+ rescue StandardError
205
+ true # a missed beat is retried on the next one
206
+ end
207
+
208
+ def carry_out(entry)
209
+ role, decision, observation = entry.role, entry.decision, entry.evaluation.observation
210
+
211
+ case decision.action
212
+ when "scale_out", "scale_in"
213
+ cli.invoke_scale_set(role, decision.to)
214
+ entry.state.scaled(decision.action, now: @clock.call)
215
+ record_joins(entry, observation.member_hosts) if decision.action == "scale_out"
216
+ when "replace_member"
217
+ member = observation.members.find { |candidate| candidate.host == decision.member_host }
218
+ cli.replace_member(role, member, count: decision.from)
219
+ entry.state.forget(member.host)
220
+ end
221
+
222
+ decision
223
+ rescue Dash::Cli::LockError => e
224
+ failed(decision, "lock_busy", e.message)
225
+ rescue StandardError => e
226
+ failed(decision, "action_failed", "#{e.class}: #{e.message}")
227
+ end
228
+
229
+ # The members a scale-out powered on warm up from now. Members someone else joined are
230
+ # not known to be warming, so they never hold a scale-in.
231
+ #
232
+ # The scale-out already happened, so a pool that cannot be read now is only reported:
233
+ # the members it joined go without a warmup rather than the action being logged failed.
234
+ def record_joins(entry, before)
235
+ DASH.config.pool.refresh!
236
+ entry.state.joined_at(entry.role.active_members.map(&:host) - before, now: @clock.call)
237
+ rescue StandardError => e
238
+ cli.report "#{entry.role}: scaled out, but the members it joined could not be read for their warmup (#{e.class}: #{e.message})", :yellow
239
+ end
240
+
241
+ def failed(decision, reason, message)
242
+ Dash::Autoscale::Decision.new(role: decision.role, action: "hold", from: decision.from, to: decision.from, reasons: [ reason ],
243
+ inputs: decision.inputs.merge(attempted: { action: decision.action, to: decision.to, replace: decision.member_host }.compact),
244
+ eligible_at: decision.eligible_at, at: decision.at, error: message)
245
+ end
246
+
247
+ def persist(entries, state, now)
248
+ logged = entries.select { |entry| entry.state.log?(entry.decision) }.each { |entry| entry.state.logged(entry.decision) }.map(&:decision)
249
+ roles_state = state.fetch("roles", {}).merge(entries.to_h { |entry| [ entry.role.name, entry.state.to_h ] })
250
+ checked_at = Dash::Autoscale::Timestamp.parse(state["decisions_checked_at"])
251
+ check = checked_at.nil? || now - checked_at >= TRIM_CHECK_INTERVAL
252
+
253
+ # The heartbeat first, and only while it still names this controller: one command on
254
+ # the host checks and writes, so a takeover since the tick started (or while it acted)
255
+ # is never overwritten, and its state is left to the controller that took over.
256
+ beat_at = @clock.call
257
+ held = quietly { with_store { |store| store.write_heartbeat_if_held(id, heartbeat(beat_at)) } }
258
+ # Only a heartbeat naming another controller is a lost lease; a write that failed
259
+ # raised above and is this tick's failure, which the loop survives.
260
+ raise Dash::Autoscale::LeaseLost, "Another autoscale controller took over while this one ticked" unless held
261
+
262
+ @last_tick_at = beat_at
263
+ quietly do
264
+ with_store do |store|
265
+ store.append_decisions(logged)
266
+ trim(store) if check
267
+ updated = state.merge("roles" => roles_state, "decisions_checked_at" => Dash::Autoscale::Timestamp.dump(check ? now : checked_at))
268
+ store.write_state(updated) unless updated == state
269
+ end
270
+ end
271
+ end
272
+
273
+ def trim(store)
274
+ store.trim_decisions(keep: DECISION_LOG_LIMIT) if store.decision_count > DECISION_LOG_LIMIT
275
+ end
276
+
277
+ def heartbeat(last_tick_at)
278
+ { controller_id: id, hostname: Socket.gethostname, pid: Process.pid, version: Dash::VERSION, mode: @mode,
279
+ started_at: @started_at, last_tick_at: last_tick_at, interval: interval, roles: @role_names }
280
+ end
281
+
282
+ def with_store(&block)
283
+ result = nil
284
+ on(Dash::Autoscale::StateStore.host) { result = block.call(Dash::Autoscale::StateStore.new(self)) }
285
+ result
286
+ end
287
+
288
+ # The controller reads every host every tick; those reads print only with --verbose.
289
+ def quietly(&block)
290
+ DASH.verbosity == :debug ? block.call : DASH.with_verbosity(:error, &block)
291
+ end
292
+
293
+ def monotonic
294
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
295
+ end
296
+ end
@@ -0,0 +1,75 @@
1
+ # A five-field cron expression - `minute hour day-of-month month day-of-week` - for the
2
+ # schedule windows of a scaled role. Each field is `*`, a number, a range `a-b`, a list
3
+ # `a,b` or a step `*/n` / `a-b/n`; day-of-week 0 and 7 are both Sunday. Vixie semantics:
4
+ # when both day fields are restricted (neither starts with `*`), a day matches if either
5
+ # does. No names, `L`, `W` or `#`.
6
+ #
7
+ # `match?` takes a time already in the zone the schedule is evaluated in.
8
+ class Dash::Autoscale::Cron
9
+ class Error < ArgumentError; end
10
+
11
+ Field = Struct.new(:name, :min, :max)
12
+
13
+ FIELDS = [
14
+ Field.new("minute", 0, 59),
15
+ Field.new("hour", 0, 23),
16
+ Field.new("day-of-month", 1, 31),
17
+ Field.new("month", 1, 12),
18
+ Field.new("day-of-week", 0, 7)
19
+ ].freeze
20
+
21
+ ITEM = /\A(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?\z/
22
+
23
+ def self.parse(expression)
24
+ new(expression)
25
+ end
26
+
27
+ def initialize(expression)
28
+ @expression = expression.to_s.strip
29
+ parts = @expression.split
30
+
31
+ unless parts.size == FIELDS.size
32
+ raise Error, "#{@expression.inspect} has #{parts.size} fields, a cron has 5 (minute hour day-of-month month day-of-week)"
33
+ end
34
+
35
+ @minutes, @hours, @days, @months, weekdays = FIELDS.zip(parts).map { |field, part| parse_field(field, part) }
36
+ @weekdays = weekdays.map { |day| day % 7 }.to_set
37
+ @days_restricted, @weekdays_restricted = !parts[2].start_with?("*"), !parts[4].start_with?("*")
38
+ end
39
+
40
+ def match?(time)
41
+ @minutes.include?(time.min) && @hours.include?(time.hour) && @months.include?(time.month) && day_match?(time)
42
+ end
43
+
44
+ def to_s
45
+ @expression
46
+ end
47
+
48
+ private
49
+ def day_match?(time)
50
+ day, weekday = @days.include?(time.day), @weekdays.include?(time.wday)
51
+
52
+ @days_restricted && @weekdays_restricted ? day || weekday : day && weekday
53
+ end
54
+
55
+ def parse_field(field, part)
56
+ part.split(",", -1).flat_map { |item| parse_item(field, item) }.to_set
57
+ end
58
+
59
+ def parse_item(field, item)
60
+ match = ITEM.match(item)
61
+ unless match && (match[1] || match[3] || !match[4])
62
+ raise Error, "#{field.name}: #{item} is not supported, use numbers, *, ranges (a-b), lists (a,b) and steps (*/n, a-b/n)"
63
+ end
64
+
65
+ star, first, last, step = match.captures
66
+ from, to = star ? [ field.min, field.max ] : [ Integer(first, 10), Integer(last || first, 10) ]
67
+ step = step ? Integer(step, 10) : 1
68
+
69
+ [ from, to ].each { |value| raise Error, "#{field.name}: #{value} is outside #{field.min}-#{field.max}" unless value.between?(field.min, field.max) }
70
+ raise Error, "#{field.name}: #{item} is a reversed range" if from > to
71
+ raise Error, "#{field.name}: #{item} needs a step of at least 1" if step < 1
72
+
73
+ (from..to).step(step).to_a
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ # What the controller decided for one scaled role on one tick: the action, the container
2
+ # count it goes from and to, the reason codes, and every input the policy used. The reason
3
+ # and action codes are stable - the decision log, the metrics and the MCP tools all use them.
4
+ #
5
+ # On a scale_out or scale_in the reasons name what set the target the role moves toward:
6
+ # `schedule_floor` (a window), `at_min` (scale min × replicas min), plus `at_max` when the
7
+ # target was capped. A scale_in step that stops short of the target still names the target.
8
+ # A signal-driven step names its signal instead: `above_threshold` or `below_threshold`.
9
+ Dash::Autoscale::Decision = Struct.new(:role, :action, :from, :to, :reasons, :inputs, :eligible_at, :at, :error, keyword_init: true) do
10
+ def initialize(reasons: [], inputs: {}, **attributes)
11
+ super
12
+ end
13
+
14
+ # Times as ISO 8601 in UTC, symbols as strings, all the way down - what JSON, the MCP
15
+ # tools and the decision log carry.
16
+ def self.json_safe(value)
17
+ case value
18
+ when Hash then value.to_h { |key, item| [ key, json_safe(item) ] }
19
+ when Array then value.map { |item| json_safe(item) }
20
+ when Time, ActiveSupport::TimeWithZone then value.utc.iso8601
21
+ when Symbol then value.to_s
22
+ when Exception then value.message
23
+ else value
24
+ end
25
+ end
26
+
27
+ def hold?
28
+ action == "hold"
29
+ end
30
+
31
+ # The member a `replace_member` decision takes out.
32
+ def member_host
33
+ inputs[:replace]
34
+ end
35
+
36
+ def to_h
37
+ Dash::Autoscale::Decision.json_safe(super)
38
+ end
39
+
40
+ # One log line: `payments: scale_out 4 -> 10 (schedule_floor: window "0 22 1,15 * *" min 10)`.
41
+ def summary
42
+ counts = case action
43
+ when "hold" then "at #{from.nil? ? "?" : from}"
44
+ when "replace_member" then member_host
45
+ else "#{from} -> #{to}"
46
+ end
47
+
48
+ "#{role}: #{action} #{counts} (#{reasons.map { |reason| note(reason) }.join(", ")})"
49
+ end
50
+
51
+ private
52
+ # `above_threshold: 12.4 > 10 for 45s`, or `... until <time>` while it has not been long enough.
53
+ def threshold(reason)
54
+ above = reason == "above_threshold"
55
+ since = inputs[above ? :above_since : :below_since]
56
+ text = "#{reason}: #{inputs.dig(:signal, :value)} #{above ? ">" : "<"} #{inputs[above ? :up_above : :down_below]}"
57
+
58
+ if hold? && eligible_at
59
+ "#{text} until #{eligible_at.utc.iso8601}"
60
+ elsif since
61
+ "#{text} for #{(at - since).round}s"
62
+ else
63
+ text
64
+ end
65
+ end
66
+
67
+ def note(reason)
68
+ case reason
69
+ when "schedule_floor"
70
+ window = inputs[:windows].to_a.max_by { |candidate| candidate[:min] }
71
+ window ? %(#{reason}: window "#{window[:cron]}" min #{window[:min]}) : reason
72
+ when "cooldown", "warming_up", "member_unreachable", "paused"
73
+ eligible_at ? "#{reason} until #{eligible_at.utc.iso8601}" : reason
74
+ when "above_threshold", "below_threshold"
75
+ threshold(reason)
76
+ when "held_by_guard"
77
+ "#{reason}: #{inputs[:held_by]}"
78
+ else
79
+ error ? "#{reason}: #{error}" : reason
80
+ end
81
+ end
82
+ end
83
+
84
+ Dash::Autoscale::Decision::ACTIONS = %w[ scale_out scale_in replace_member hold ].freeze
85
+ Dash::Autoscale::Decision::REASONS = %w[
86
+ schedule_floor at_min at_max at_target cooldown warming_up paused lock_busy
87
+ member_unreachable host_unreachable pool_unreadable action_failed
88
+ above_threshold below_threshold held_by_guard missing_data
89
+ ].freeze
@@ -0,0 +1,19 @@
1
+ # A length of time as deploy.yml and the CLI write it: integer seconds, or a whole number
2
+ # with a unit - `90s`, `15m`, `30h`, `7d`. Returns seconds.
3
+ module Dash::Autoscale::Duration
4
+ UNITS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
5
+ FORMAT = /\A(\d+)([smhd]?)\z/
6
+
7
+ def self.parse(value)
8
+ case value
9
+ when Integer
10
+ return value unless value.negative?
11
+ when String
12
+ if (match = FORMAT.match(value))
13
+ return Integer(match[1], 10) * UNITS.fetch(match[2].presence || "s")
14
+ end
15
+ end
16
+
17
+ raise ArgumentError, "#{value.inspect} should be seconds, or a number followed by s, m, h or d (30m, 2h, 1d)"
18
+ end
19
+ end