Aggiunge l'autoscaler streaming con warm spare e kill-switch.
Scala i nodi overflow quando gli slot calano, mantiene una spare a caldo sotto carico e spegne gli idle, disattivabile con STREAM_AUTOSCALE_ENABLED. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ module Admin
|
||||
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||
@lab_hosts = lab_dns_snippet
|
||||
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Streams
|
||||
class AutoscalerJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options retry: 1, queue: "default"
|
||||
|
||||
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
||||
|
||||
def self.ensure_chain
|
||||
return unless redis
|
||||
return if redis.get(REDIS_CHAIN_KEY)
|
||||
|
||||
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||
perform_in(INTERVAL_SECS)
|
||||
end
|
||||
|
||||
def self.redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
rescue Redis::CannotConnectError
|
||||
nil
|
||||
end
|
||||
|
||||
def perform
|
||||
Streams::Autoscaler.reconcile!
|
||||
ensure
|
||||
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
|
||||
self.class.perform_in(INTERVAL_SECS)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Streams
|
||||
# Scale-out / warm spare / scale-in dei nodi overflow (lab o Hetzner).
|
||||
# Kill-switch: STREAM_AUTOSCALE_ENABLED!=1 → no-op.
|
||||
class Autoscaler
|
||||
Result = Struct.new(:actions, :metrics, :skipped, :error, keyword_init: true)
|
||||
|
||||
LOCK_KEY = "streams:autoscaler:lock"
|
||||
|
||||
class << self
|
||||
def enabled?
|
||||
ENV["STREAM_AUTOSCALE_ENABLED"] == "1"
|
||||
end
|
||||
|
||||
def soft_free_slots
|
||||
ENV.fetch("STREAM_AUTOSCALE_SOFT_FREE_SLOTS", "2").to_i
|
||||
end
|
||||
|
||||
def warm_spare_min
|
||||
ENV.fetch("STREAM_AUTOSCALE_WARM_SPARE", "1").to_i
|
||||
end
|
||||
|
||||
def idle_minutes
|
||||
ENV.fetch("STREAM_AUTOSCALE_IDLE_MINUTES", "30").to_i
|
||||
end
|
||||
|
||||
def max_overflow_nodes
|
||||
ENV.fetch("STREAM_AUTOSCALE_MAX_NODES", "5").to_i
|
||||
end
|
||||
|
||||
def kind
|
||||
ENV.fetch("STREAM_AUTOSCALE_KIND", "lab") # lab|cloud
|
||||
end
|
||||
|
||||
def reconcile!(provisioner: nil)
|
||||
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
|
||||
|
||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
unless redis.set(LOCK_KEY, worker_id, nx: true, ex: 55)
|
||||
return Result.new(skipped: true, actions: [], metrics: metrics, error: "locked")
|
||||
end
|
||||
|
||||
begin
|
||||
new(provisioner: provisioner).reconcile!
|
||||
ensure
|
||||
redis.del(LOCK_KEY)
|
||||
end
|
||||
end
|
||||
|
||||
def metrics
|
||||
NodeRegistry.ensure_home_from_env!
|
||||
nodes = StreamNode.ready.to_a
|
||||
overflow = StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
|
||||
.where(status: %w[ready draining provisioning]).to_a
|
||||
{
|
||||
free_slots: nodes.sum(&:free_slots),
|
||||
ready_nodes: nodes.size,
|
||||
spare_ready: nodes.count { |n| n.slug != NodeRegistry::HOME_SLUG && n.active_publishers.zero? },
|
||||
overflow_nodes: overflow.size,
|
||||
soft_free_slots: soft_free_slots,
|
||||
warm_spare_min: warm_spare_min,
|
||||
max_overflow_nodes: max_overflow_nodes,
|
||||
enabled: enabled?,
|
||||
kind: kind
|
||||
}
|
||||
end
|
||||
|
||||
def worker_id
|
||||
ENV.fetch("HOSTNAME", "autoscaler")
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(provisioner: nil)
|
||||
@provisioner = provisioner || NodeProvisioner.new
|
||||
end
|
||||
|
||||
def reconcile!
|
||||
actions = []
|
||||
m = self.class.metrics
|
||||
|
||||
if need_capacity?(m) && can_provision?(m)
|
||||
provision_overflow!
|
||||
actions << :scale_out
|
||||
m = self.class.metrics
|
||||
end
|
||||
|
||||
if warm_spare_desired?(m) && m[:spare_ready] < self.class.warm_spare_min && can_provision?(m)
|
||||
provision_overflow!
|
||||
actions << :warm_spare
|
||||
m = self.class.metrics
|
||||
end
|
||||
|
||||
scale_in_candidates.each do |node|
|
||||
next if keep_as_warm_spare?(node)
|
||||
|
||||
@provisioner.decommission!(node)
|
||||
actions << :"scale_in_#{node.slug}"
|
||||
m = self.class.metrics
|
||||
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
|
||||
Rails.logger.warn("[Streams::Autoscaler] scale-in #{node.slug}: #{e.message}")
|
||||
end
|
||||
|
||||
Rails.logger.info("[Streams::Autoscaler] actions=#{actions.inspect} metrics=#{m.inspect}")
|
||||
Result.new(actions: actions, metrics: m, skipped: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def need_capacity?(m)
|
||||
m[:free_slots] <= self.class.soft_free_slots
|
||||
end
|
||||
|
||||
def warm_spare_desired?(m)
|
||||
return false if self.class.warm_spare_min <= 0
|
||||
|
||||
need_capacity?(m) || overflow_in_use?
|
||||
end
|
||||
|
||||
def overflow_in_use?
|
||||
StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).any? { |n| n.active_publishers.positive? }
|
||||
end
|
||||
|
||||
def can_provision?(m)
|
||||
m[:overflow_nodes] < self.class.max_overflow_nodes
|
||||
end
|
||||
|
||||
def provision_overflow!
|
||||
case self.class.kind
|
||||
when "cloud" then @provisioner.provision_cloud!
|
||||
else @provisioner.provision_lab!
|
||||
end
|
||||
end
|
||||
|
||||
def scale_in_candidates
|
||||
StreamNode.where.not(slug: NodeRegistry::HOME_SLUG)
|
||||
.where(status: %w[ready draining])
|
||||
.order(:created_at)
|
||||
.select { |n| n.active_publishers.zero? && idle_long_enough?(n) }
|
||||
end
|
||||
|
||||
def idle_long_enough?(node)
|
||||
idle_since(node) <= self.class.idle_minutes.minutes.ago
|
||||
end
|
||||
|
||||
def idle_since(node)
|
||||
last_end = node.stream_sessions.where(status: %w[ended error]).maximum(:ended_at)
|
||||
last_end || node.created_at
|
||||
end
|
||||
|
||||
def keep_as_warm_spare?(node)
|
||||
return false unless warm_spare_desired?(self.class.metrics)
|
||||
|
||||
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
|
||||
spares.size <= self.class.warm_spare_min && spares.map(&:id).include?(node.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,20 @@
|
||||
<%= t("admin.stream_nodes.providers", cloud: @cloud_provider, dns: @dns_provider) %>
|
||||
</p>
|
||||
|
||||
<p class="admin-muted">
|
||||
<%= t(
|
||||
"admin.stream_nodes.autoscale",
|
||||
enabled: (@autoscale_metrics[:enabled] ? "ON" : "OFF"),
|
||||
free: @autoscale_metrics[:free_slots],
|
||||
soft: @autoscale_metrics[:soft_free_slots],
|
||||
spare: @autoscale_metrics[:spare_ready],
|
||||
warm: @autoscale_metrics[:warm_spare_min],
|
||||
overflow: @autoscale_metrics[:overflow_nodes],
|
||||
max: @autoscale_metrics[:max_overflow_nodes],
|
||||
kind: @autoscale_metrics[:kind]
|
||||
) %>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<%= button_to t("admin.stream_nodes.provision_lab"), admin_stream_nodes_path, method: :post, params: { kind: "lab" }, class: "admin-btn" %>
|
||||
<% if @hetzner_configured %>
|
||||
|
||||
Reference in New Issue
Block a user