Skip to content

Commit 619b71d

Browse files
Add support for container policies.
1 parent 5cda50b commit 619b71d

9 files changed

Lines changed: 231 additions & 38 deletions

File tree

async-service.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ Gem::Specification.new do |spec|
2727
spec.required_ruby_version = ">= 3.2"
2828

2929
spec.add_dependency "async"
30-
spec.add_dependency "async-container", "~> 0.29"
30+
spec.add_dependency "async-container", "~> 0.32"
3131
spec.add_dependency "string-format", "~> 0.2"
3232
end

bake/async/service/controller.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ def initialize(context)
1010
end
1111

1212
def run
13-
# Warm up the Ruby process by preloading gems and running GC.
14-
Async::Service::Controller.warmup
15-
1613
controller.run
1714
end
1815

@@ -21,5 +18,5 @@ def run
2118
def controller
2219
configuration = context.lookup("async:service:configuration").instance.configuration
2320

24-
return Async::Service::Controller.new(configuration.services)
21+
return configuration.make_controller
2522
end

lib/async/service/configuration.rb

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,15 @@ def self.for(*environments)
5252
end
5353

5454
# Initialize an empty configuration.
55-
def initialize(environments = [])
55+
# @parameter environments [Array] Environment instances.
56+
# @parameter container_policy [Proc] Optional proc that returns a policy for container lifecycle management.
57+
def initialize(environments = [], container_policy: nil)
5658
@environments = environments
59+
@container_policy = container_policy
5760
end
5861

5962
attr :environments
63+
attr_accessor :container_policy
6064

6165
# Check if the configuration is empty.
6266
# @returns [Boolean] True if no environments are configured.
@@ -84,11 +88,22 @@ def services(implementing: nil)
8488

8589
# Create a controller for the configured services.
8690
#
91+
# @parameter container_policy [Proc] A proc that returns the policy to use for managing child lifecycle events.
92+
# @parameter options [Hash] Additional options passed to the controller.
8793
# @returns [Controller] A controller that can be used to start/stop services.
88-
def controller(**options)
89-
Controller.new(self.services(**options).to_a)
94+
def make_controller(container_policy: @container_policy, **options)
95+
controller = Controller.new(self.services(**options).to_a)
96+
97+
if container_policy
98+
controller.define_singleton_method(:make_policy, &container_policy)
99+
end
100+
101+
return controller
90102
end
91103

104+
# Alias for backwards compatibility.
105+
alias controller make_controller
106+
92107
# Add the environment to the configuration.
93108
def add(environment)
94109
@environments << environment

lib/async/service/controller.rb

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright, 2024-2025, by Samuel Williams.
55

66
require "async/container/controller"
7+
require_relative "policy"
78

89
module Async
910
module Service
@@ -13,32 +14,11 @@ module Service
1314
# within containers. It extends Async::Container::Controller to provide
1415
# service-specific functionality.
1516
class Controller < Async::Container::Controller
16-
# Warm up the Ruby process by preloading gems and running GC.
17-
def self.warmup
18-
begin
19-
require "bundler"
20-
Bundler.require(:preload)
21-
rescue Bundler::GemfileNotFound, LoadError
22-
# Ignore.
23-
end
24-
25-
if ::Process.respond_to?(:warmup)
26-
::Process.warmup
27-
elsif ::GC.respond_to?(:compact)
28-
3.times{::GC.start}
29-
::GC.compact
30-
end
31-
end
32-
3317
# Run a configuration of services.
3418
# @parameter configuration [Configuration] The service configuration to run.
3519
# @parameter options [Hash] Additional options for the controller.
3620
def self.run(configuration, **options)
37-
controller = Async::Service::Controller.new(configuration.services.to_a, **options)
38-
39-
self.warmup
40-
41-
controller.run
21+
configuration.make_controller(**options).run
4222
end
4323

4424
# Create a controller for the given services.
@@ -58,17 +38,41 @@ def initialize(services, **options)
5838
@services = services
5939
end
6040

41+
def warmup
42+
begin
43+
require "bundler"
44+
Bundler.require(:preload)
45+
rescue Bundler::GemfileNotFound, LoadError
46+
# Ignore.
47+
end
48+
49+
if ::Process.respond_to?(:warmup)
50+
::Process.warmup
51+
elsif ::GC.respond_to?(:compact)
52+
3.times{::GC.start}
53+
::GC.compact
54+
end
55+
end
56+
6157
# All the services associated with this controller.
6258
# @attribute [Array(Async::Service::Generic)]
6359
attr :services
6460

61+
# Create a policy for managing child lifecycle events.
62+
# @returns [Policy] The service-level policy with failure rate monitoring.
63+
def make_policy
64+
Policy::DEFAULT
65+
end
66+
6567
# Start all named services.
6668
def start
6769
@services.each do |service|
6870
service.start
6971
end
7072

7173
super
74+
75+
self.warmup
7276
end
7377

7478
# Setup all services into the given container.

lib/async/service/loader.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ def service(name = nil, **options, &block)
5858

5959
@configuration.add(self.environment(**options, &block))
6060
end
61+
62+
# Set the container policy for all services in this configuration.
63+
# Can be called with either an argument or a block.
64+
# @parameter value [Async::Container::Policy] The policy to use for managing child lifecycle events.
65+
# @parameter block [Proc] A block that returns a policy instance.
66+
def container_policy(value = nil, &block)
67+
if @configuration.container_policy
68+
Console.warn(self, "Container policy is already set, overriding previous value!")
69+
end
70+
71+
@configuration.container_policy = block_given? ? block : proc{value}
72+
end
6173
end
6274
end
6375
end

lib/async/service/policy.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "async/container/policy"
7+
8+
module Async
9+
module Service
10+
# A service-level policy that extends the base container policy with failure rate monitoring.
11+
# This policy will stop the container if the failure rate exceeds a threshold.
12+
class Policy < Async::Container::Policy
13+
# Create a policy from maximum failures and time window.
14+
# @parameter maximum_failures [Integer] The maximum number of failures allowed within the window.
15+
# @parameter window [Integer] The time window in seconds for counting failures.
16+
# @returns [Policy] A new policy instance.
17+
def self.for(maximum_failures: 1, window: 10)
18+
failure_rate_threshold = maximum_failures.to_f / window
19+
self.new(failure_rate_threshold)
20+
end
21+
22+
# Initialize the policy.
23+
# @parameter failure_rate_threshold [Float] The maximum failures per second before stopping the container.
24+
def initialize(failure_rate_threshold)
25+
@failure_rate_threshold = failure_rate_threshold
26+
end
27+
28+
def child_exit(container, child, status, name:, key:, **options)
29+
unless success?(status)
30+
# Check failure rate after this failure is recorded
31+
rate = container.statistics.failure_rate.per_second
32+
33+
if rate > @failure_rate_threshold
34+
Console.error(self, "Failure rate exceeded threshold, stopping container!",
35+
rate: rate,
36+
threshold: @failure_rate_threshold
37+
)
38+
container.stop(true)
39+
end
40+
end
41+
end
42+
43+
# The default service policy instance.
44+
DEFAULT = self.for.freeze
45+
end
46+
end
47+
end

test/async/service/configuration.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,19 @@
121121
end
122122

123123
it "can create a controller" do
124-
controller = configuration.controller
124+
controller = configuration.make_controller
125125
expect(controller).to be_a(Async::Service::Controller)
126126

127127
expect(controller.services).to have_attributes(
128128
size: be == 1
129129
)
130130
end
131+
132+
it "make_controller returns controller with policy" do
133+
controller = configuration.make_controller
134+
135+
expect(controller.make_policy).to be_a(Async::Container::Policy)
136+
end
131137
end
132138

133139
with "other configuration file" do

test/async/service/controller.rb

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,5 @@
9797
controller.stop
9898
end
9999
end
100-
101-
with ".warmup" do
102-
it "can warmup without errors" do
103-
# Should not raise exception
104-
subject.warmup
105-
end
106-
end
107100
end
108101

test/async/service/policy.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "async/service/policy"
7+
require "async/container/statistics"
8+
9+
describe Async::Service::Policy do
10+
let(:policy) {subject.for(maximum_failures: 5, window: 10)}
11+
12+
with "::DEFAULT" do
13+
it "exists and is frozen" do
14+
expect(Async::Service::Policy::DEFAULT).not.to be_nil
15+
expect(Async::Service::Policy::DEFAULT).to be(:frozen?)
16+
end
17+
18+
it "is a Service::Policy instance" do
19+
expect(Async::Service::Policy::DEFAULT).to be_a(Async::Service::Policy)
20+
end
21+
22+
it "has working helper methods" do
23+
status = Object.new
24+
def status.termsig; Signal.list["SEGV"]; end
25+
26+
expect(Async::Service::Policy::DEFAULT).to be(:segfault?, status)
27+
end
28+
end
29+
30+
with "failure rate monitoring" do
31+
let(:mock_container) do
32+
Class.new do
33+
attr_reader :stopped, :statistics
34+
35+
def initialize
36+
@stopped = false
37+
@statistics = Async::Container::Statistics.new(window: 10)
38+
end
39+
40+
def stop(graceful)
41+
@stopped = true
42+
end
43+
end.new
44+
end
45+
46+
let(:mock_status) do
47+
Class.new do
48+
def success?
49+
false
50+
end
51+
end.new
52+
end
53+
54+
it "stops container when failure rate exceeds threshold" do
55+
# Policy with max 5 failures in 10 second window
56+
policy = subject.for(maximum_failures: 5, window: 10)
57+
58+
# Add 6 failures (exceeds threshold of 5 in 10 seconds)
59+
6.times do
60+
mock_container.statistics.failure!
61+
end
62+
63+
# 6 failures in same second = 0.6/sec which exceeds 5/10sec = 0.5/sec
64+
rate = mock_container.statistics.failure_rate.per_second
65+
expect(rate).to be > 0.5
66+
expect(mock_container.stopped).to be == false
67+
68+
# Trigger policy check
69+
policy.child_exit(mock_container, nil, mock_status, name: "test", key: nil)
70+
71+
expect(mock_container.stopped).to be == true
72+
end
73+
74+
it "does not stop container when failure rate is acceptable" do
75+
# Policy with max 10 failures in 10 second window
76+
policy = subject.for(maximum_failures: 10, window: 10)
77+
78+
# Add only 3 failures (below threshold)
79+
3.times do
80+
mock_container.statistics.failure!
81+
end
82+
83+
# 3 failures = 0.3/sec which is below 10/10sec = 1.0/sec
84+
rate = mock_container.statistics.failure_rate.per_second
85+
expect(rate).to be < 1.0
86+
87+
# Trigger policy check
88+
policy.child_exit(mock_container, nil, mock_status, name: "test", key: nil)
89+
90+
expect(mock_container.stopped).to be == false
91+
end
92+
93+
it "does nothing on successful exit" do
94+
policy = subject.for(maximum_failures: 1, window: 10)
95+
96+
success_status = Object.new
97+
def success_status.success?; true; end
98+
99+
# Even with low threshold, success shouldn't trigger stop
100+
policy.child_exit(mock_container, nil, success_status, name: "test", key: nil)
101+
102+
expect(mock_container.stopped).to be == false
103+
end
104+
end
105+
106+
with "custom parameters" do
107+
it "can be created with custom threshold" do
108+
policy = subject.for(maximum_failures: 20, window: 30)
109+
110+
expect(policy).to be_a(Async::Service::Policy)
111+
end
112+
113+
it "can be initialized with raw threshold" do
114+
policy = subject.new(0.5)
115+
116+
expect(policy).to be_a(Async::Service::Policy)
117+
end
118+
end
119+
end

0 commit comments

Comments
 (0)