Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,11 @@ RSpec.describe Streams::Autoscaler do
|
||||
redis.del(Streams::DnsProviders::Lab::REDIS_KEY)
|
||||
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||
StreamNode.where.not(slug: Streams::NodeRegistry::HOME_SLUG).delete_all
|
||||
ENV["STREAM_AUTOSCALE_QUIET_HOURS"] = "off"
|
||||
end
|
||||
|
||||
after do
|
||||
ENV.delete("STREAM_AUTOSCALE_QUIET_HOURS")
|
||||
end
|
||||
|
||||
it "is a no-op when disabled" do
|
||||
@@ -261,4 +266,34 @@ RSpec.describe Streams::Autoscaler do
|
||||
expect(node.reload.status).to eq("ready")
|
||||
end
|
||||
end
|
||||
|
||||
it "blocks scale-out and warm spare during quiet hours" do
|
||||
with_env(
|
||||
"STREAM_AUTOSCALE_ENABLED" => "1",
|
||||
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
|
||||
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "2",
|
||||
"STREAM_AUTOSCALE_WARM_SPARE" => "1",
|
||||
"STREAM_AUTOSCALE_KIND" => "lab",
|
||||
"STREAM_AUTOSCALE_MAX_NODES" => "5",
|
||||
"STREAM_NODE_HOME_MAX_PUBLISHERS" => "1",
|
||||
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
|
||||
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
|
||||
"HLS_PUBLIC_URL" => "https://home.example/hls"
|
||||
) do
|
||||
allow(Streams::QuietHours).to receive(:active?).and_return(true)
|
||||
home = Streams::NodeRegistry.ensure_home_from_env!
|
||||
user = User.create!(email: "qh@example.com", name: "Q", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "QH", sport: "volleyball")
|
||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "qh-t")
|
||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home)
|
||||
|
||||
provisioner = instance_double(Streams::NodeProvisioner)
|
||||
expect(provisioner).not_to receive(:provision_lab!)
|
||||
|
||||
result = described_class.reconcile!(provisioner: provisioner)
|
||||
expect(result.actions).to include(:blocked_capacity)
|
||||
expect(result.metrics[:quiet_hours]).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::NightCloudSweeper do
|
||||
def with_env(vars)
|
||||
previous = vars.keys.index_with { |k| ENV[k] }
|
||||
vars.each { |k, v| ENV[k] = v }
|
||||
yield
|
||||
ensure
|
||||
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||
end
|
||||
|
||||
def cloud_node!(slug: "ingest-cloud-01")
|
||||
StreamNode.create!(
|
||||
slug: slug,
|
||||
hostname: "#{slug}.mltv-stream.net",
|
||||
role: "cloud",
|
||||
status: "ready",
|
||||
provider: "hetzner",
|
||||
provider_instance_id: "42",
|
||||
rtmp_base_url: "rtmp://#{slug}:1935",
|
||||
hls_base_url: "https://#{slug}/hls",
|
||||
api_base_url: "http://10.0.0.9:9997",
|
||||
max_publishers: 6,
|
||||
max_relays: 6,
|
||||
metadata: { "public_ip" => "1.2.3.4" }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
StreamSession.where.not(stream_node_id: nil).update_all(stream_node_id: nil, status: "ended", ended_at: Time.current)
|
||||
StreamNode.where(role: "cloud").delete_all
|
||||
end
|
||||
|
||||
it "skips outside quiet hours" do
|
||||
with_env(
|
||||
"STREAM_NIGHT_SWEEP_ENABLED" => "1",
|
||||
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
|
||||
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
|
||||
) do
|
||||
allow(Streams::QuietHours).to receive(:active?).and_return(false)
|
||||
cloud_node!
|
||||
result = described_class.sweep!
|
||||
expect(result.skipped).to eq(true)
|
||||
expect(StreamNode.where(role: "cloud").count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it "decommissions idle cloud nodes during quiet hours" do
|
||||
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
|
||||
allow(Streams::QuietHours).to receive(:active?).and_return(true)
|
||||
node = cloud_node!
|
||||
provisioner = instance_double(Streams::NodeProvisioner)
|
||||
allow(provisioner).to receive(:drain!) do |n|
|
||||
n.update!(status: "draining")
|
||||
n
|
||||
end
|
||||
allow(provisioner).to receive(:decommission!) do |n|
|
||||
n.destroy!
|
||||
true
|
||||
end
|
||||
|
||||
result = described_class.sweep!(provisioner: provisioner)
|
||||
expect(result.skipped).to eq(false)
|
||||
expect(result.actions).to include(:"decommission_#{node.slug}")
|
||||
expect(StreamNode.find_by(id: node.id)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it "alerts and keeps nodes with active sessions" do
|
||||
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
|
||||
allow(Streams::QuietHours).to receive(:active?).and_return(true)
|
||||
node = cloud_node!
|
||||
user = User.create!(email: "night@example.com", name: "N", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "Night", sport: "volleyball")
|
||||
team = club.teams.create!(name: "T", sport: "volleyball", slug: "night-t")
|
||||
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
|
||||
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: node)
|
||||
|
||||
allow(Ops::IncidentRecorder).to receive(:record)
|
||||
provisioner = instance_double(Streams::NodeProvisioner)
|
||||
expect(provisioner).not_to receive(:decommission!)
|
||||
|
||||
result = described_class.sweep!(provisioner: provisioner)
|
||||
expect(result.actions).to include(:"alert_active_#{node.slug}")
|
||||
expect(Ops::IncidentRecorder).to have_received(:record).with(hash_including(
|
||||
fingerprint: "stream_overflow:night_active:#{node.slug}"
|
||||
))
|
||||
expect(node.reload.status).to eq("ready")
|
||||
end
|
||||
end
|
||||
|
||||
it "does not touch lab nodes" do
|
||||
with_env("STREAM_NIGHT_SWEEP_ENABLED" => "1") do
|
||||
allow(Streams::QuietHours).to receive(:active?).and_return(true)
|
||||
StreamNode.create!(
|
||||
slug: "ingest-lab-night-01",
|
||||
hostname: "lab-night.local",
|
||||
role: "lab",
|
||||
status: "ready",
|
||||
provider: "local",
|
||||
rtmp_base_url: "rtmp://lab:1935",
|
||||
hls_base_url: "https://lab/hls",
|
||||
api_base_url: "http://lab:9997",
|
||||
max_publishers: 2,
|
||||
max_relays: 2
|
||||
)
|
||||
result = described_class.sweep!
|
||||
expect(result.actions).to eq([])
|
||||
expect(StreamNode.find_by(slug: "ingest-lab-night-01")).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::QuietHours do
|
||||
def with_env(vars)
|
||||
previous = vars.keys.index_with { |k| ENV[k] }
|
||||
vars.each { |k, v| ENV[k] = v }
|
||||
yield
|
||||
ensure
|
||||
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
||||
end
|
||||
|
||||
it "is active inside the Rome window" do
|
||||
with_env(
|
||||
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
|
||||
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
|
||||
) do
|
||||
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 3, 30)
|
||||
expect(described_class.active?(now: now)).to eq(true)
|
||||
end
|
||||
end
|
||||
|
||||
it "is inactive outside the window" do
|
||||
with_env(
|
||||
"STREAM_AUTOSCALE_QUIET_HOURS" => "02:00-07:00",
|
||||
"STREAM_AUTOSCALE_QUIET_TZ" => "Europe/Rome"
|
||||
) do
|
||||
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 10, 0)
|
||||
expect(described_class.active?(now: now)).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
it "can be disabled with off" do
|
||||
with_env("STREAM_AUTOSCALE_QUIET_HOURS" => "off") do
|
||||
now = Time.find_zone!("Europe/Rome").local(2026, 9, 5, 3, 0)
|
||||
expect(described_class.active?(now: now)).to eq(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user