Skip to content

Commit d46bf32

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

File tree

9 files changed

+283
-40
lines changed

9 files changed

+283
-40
lines changed

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: 30 additions & 24 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.
@@ -55,20 +35,46 @@ def self.for(*services, **options)
5535
def initialize(services, **options)
5636
super(**options)
5737

58-
@services = services
59-
end
38+
@services = services
39+
end
40+
41+
# Warm up the Ruby process by preloading gems, running GC, and compacting memory.
42+
# This reduces startup latency and improves copy-on-write efficiency.
43+
def warmup
44+
begin
45+
require "bundler"
46+
Bundler.require(:preload)
47+
rescue Bundler::GemfileNotFound, LoadError
48+
# Ignore.
49+
end
50+
51+
if ::Process.respond_to?(:warmup)
52+
::Process.warmup
53+
elsif ::GC.respond_to?(:compact)
54+
3.times{::GC.start}
55+
::GC.compact
56+
end
57+
end
6058

6159
# All the services associated with this controller.
6260
# @attribute [Array(Async::Service::Generic)]
6361
attr :services
6462

63+
# Create a policy for managing child lifecycle events.
64+
# @returns [Policy] The service-level policy with failure rate monitoring.
65+
def make_policy
66+
Policy::DEFAULT
67+
end
68+
6569
# Start all named services.
6670
def start
6771
@services.each do |service|
6872
service.start
6973
end
7074

7175
super
76+
77+
self.warmup
7278
end
7379

7480
# 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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
# The failure rate threshold in failures per second.
29+
# @attribute [Float]
30+
attr :failure_rate_threshold
31+
32+
# Called when a child exits. Monitors failure rate and stops the container if threshold is exceeded.
33+
# @parameter container [Async::Container::Generic] The container.
34+
# @parameter child [Child] The child process.
35+
# @parameter status [Process::Status] The exit status.
36+
# @parameter name [String] The name of the child.
37+
# @parameter key [Symbol] An optional key for the child.
38+
# @parameter options [Hash] Additional options for future extensibility.
39+
def child_exit(container, child, status, name:, key:, **options)
40+
unless success?(status)
41+
# Check failure rate after this failure is recorded
42+
rate = container.statistics.failure_rate.per_second
43+
44+
if rate > @failure_rate_threshold
45+
# Only stop if container is still running (avoid redundant stop calls during shutdown)
46+
if container.running?
47+
Console.error(self, "Failure rate exceeded threshold, stopping container!",
48+
rate: rate,
49+
threshold: @failure_rate_threshold
50+
)
51+
container.stop(true)
52+
end
53+
end
54+
end
55+
end
56+
57+
# The default service policy instance.
58+
DEFAULT = self.for.freeze
59+
end
60+
end
61+
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

0 commit comments

Comments
 (0)