async-service 0.12.0 → 0.14.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.
@@ -1,16 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
- require 'async/container/controller'
6
+ require "async/container/controller"
7
7
 
8
8
  module Async
9
9
  module Service
10
+ # Controls multiple services and their lifecycle.
11
+ #
12
+ # The controller manages starting, stopping, and monitoring multiple services
13
+ # within containers. It extends Async::Container::Controller to provide
14
+ # service-specific functionality.
10
15
  class Controller < Async::Container::Controller
16
+ # Warm up the Ruby process by preloading gems and running GC.
11
17
  def self.warmup
12
18
  begin
13
- require 'bundler'
19
+ require "bundler"
14
20
  Bundler.require(:preload)
15
21
  rescue Bundler::GemfileNotFound, LoadError
16
22
  # Ignore.
@@ -24,6 +30,9 @@ module Async
24
30
  end
25
31
  end
26
32
 
33
+ # Run a configuration of services.
34
+ # @parameter configuration [Configuration] The service configuration to run.
35
+ # @parameter options [Hash] Additional options for the controller.
27
36
  def self.run(configuration, **options)
28
37
  controller = Async::Service::Controller.new(configuration.services.to_a, **options)
29
38
 
@@ -32,6 +41,17 @@ module Async
32
41
  controller.run
33
42
  end
34
43
 
44
+ # Create a controller for the given services.
45
+ # @parameter services [Array(Generic)] The services to control.
46
+ # @parameter options [Hash] Additional options for the controller.
47
+ # @returns [Controller] A new controller instance.
48
+ def self.for(*services, **options)
49
+ self.new(services, **options)
50
+ end
51
+
52
+ # Initialize a new controller with services.
53
+ # @parameter services [Array(Generic)] The services to manage.
54
+ # @parameter options [Hash] Options passed to the parent controller.
35
55
  def initialize(services, **options)
36
56
  super(**options)
37
57
 
@@ -5,8 +5,17 @@
5
5
 
6
6
  module Async
7
7
  module Service
8
+ # Represents a service configuration with lazy evaluation and module composition.
9
+ #
10
+ # Environments store configuration as methods that can be overridden and composed using Ruby modules. They support lazy evaluation through evaluators.
8
11
  class Environment
12
+ # A builder for constructing environments using a DSL.
9
13
  class Builder < BasicObject
14
+ # Create a new environment with facets and values.
15
+ # @parameter facets [Array(Module)] Modules to include in the environment.
16
+ # @parameter values [Hash] Key-value pairs to define as methods.
17
+ # @parameter block [Proc] A block for additional configuration.
18
+ # @returns [Module] The constructed environment module.
10
19
  def self.for(*facets, **values, &block)
11
20
  top = ::Module.new
12
21
 
@@ -46,10 +55,14 @@ module Async
46
55
  return top
47
56
  end
48
57
 
58
+ # Initialize a new builder.
59
+ # @parameter facet [Module] The module to build into, defaults to a new `Module`.
49
60
  def initialize(facet = ::Module.new)
50
61
  @facet = facet
51
62
  end
52
63
 
64
+ # Include a module or other includable object into the environment.
65
+ # @parameter target [Module] The module to include.
53
66
  def include(target)
54
67
  if target.class == ::Module
55
68
  @facet.include(target)
@@ -60,6 +73,10 @@ module Async
60
73
  end
61
74
  end
62
75
 
76
+ # Define methods dynamically on the environment.
77
+ # @parameter name [Symbol] The method name to define.
78
+ # @parameter argument [Object] The value to return from the method.
79
+ # @parameter block [Proc] A block to use as the method implementation.
63
80
  def method_missing(name, argument = nil, &block)
64
81
  if block
65
82
  @facet.define_method(name, &block)
@@ -67,12 +84,26 @@ module Async
67
84
  @facet.define_method(name){argument}
68
85
  end
69
86
  end
87
+
88
+ # Always respond to missing methods for dynamic method definition.
89
+ # @parameter name [Symbol] The method name.
90
+ # @parameter include_private [Boolean] Whether to include private methods.
91
+ # @returns [Boolean] Always true to enable dynamic method definition.
92
+ def respond_to_missing?(name, include_private = false)
93
+ true
94
+ end
70
95
  end
71
96
 
97
+ # Build a new environment using the builder DSL.
98
+ # @parameter arguments [Array] Arguments passed to Builder.for
99
+ # @returns [Environment] A new environment instance.
72
100
  def self.build(...)
73
101
  Environment.new(Builder.for(...))
74
102
  end
75
103
 
104
+ # Initialize a new environment.
105
+ # @parameter facet [Module] The facet module containing the configuration methods.
106
+ # @parameter parent [Environment | Nil] The parent environment for inheritance.
76
107
  def initialize(facet = ::Module.new, parent = nil)
77
108
  unless facet.class == ::Module
78
109
  raise ArgumentError, "Facet must be a module!"
@@ -88,21 +119,32 @@ module Async
88
119
  # @attribute [Environment | Nil] The parent environment, if any.
89
120
  attr :parent
90
121
 
122
+ # Include this environment's facet into a target module.
123
+ # @parameter target [Module] The target module to include into.
91
124
  def included(target)
92
125
  @parent&.included(target)
93
126
  target.include(@facet)
94
127
  end
95
128
 
129
+ # Create a new environment with additional configuration.
130
+ # @parameter arguments [Array] Arguments passed to Environment.build.
131
+ # @returns [Environment] A new environment with this as parent.
96
132
  def with(...)
97
133
  return self.class.new(Builder.for(...), self)
98
134
  end
99
135
 
136
+ # Check if this environment implements a given interface.
137
+ # @parameter interface [Module] The interface to check.
138
+ # @returns [Boolean] True if this environment implements the interface.
100
139
  def implements?(interface)
101
140
  @facet <= interface
102
141
  end
103
142
 
104
143
  # An evaluator is lazy read-only view of an environment. It memoizes all method calls.
105
144
  class Evaluator
145
+ # Create an evaluator wrapper for an environment.
146
+ # @parameter environment [Environment] The environment to wrap.
147
+ # @returns [Evaluator] A new evaluator instance.
106
148
  def self.wrap(environment)
107
149
  evaluator = ::Class.new(self)
108
150
 
@@ -133,14 +175,19 @@ module Async
133
175
  return evaluator.new
134
176
  end
135
177
 
178
+ # Initialize a new evaluator.
136
179
  def initialize
137
180
  @cache = {}
138
181
  end
139
182
 
183
+ # Inspect representation of the evaluator.
184
+ # @returns [String] A string representation of the evaluator with its keys.
140
185
  def inspect
141
186
  "#<#{Evaluator} #{self.keys}>"
142
187
  end
143
188
 
189
+ # Convert the evaluator to a hash.
190
+ # @returns [Hash] A hash with all evaluated keys and values.
144
191
  def to_h
145
192
  # Ensure all keys are evaluated:
146
193
  self.keys.each do |name|
@@ -150,23 +197,38 @@ module Async
150
197
  return @cache
151
198
  end
152
199
 
200
+ # Convert the evaluator to JSON.
201
+ # @parameter arguments [Array] Arguments passed to to_json.
202
+ # @returns [String] A JSON representation of the evaluator.
153
203
  def to_json(...)
154
204
  self.to_h.to_json(...)
155
205
  end
156
206
 
207
+ # Get value for a given key.
208
+ # @parameter key [Symbol] The key to look up.
209
+ # @returns [Object, nil] The value for the key, or nil if not found.
157
210
  def [](key)
158
- self.__send__(key)
211
+ if self.key?(key)
212
+ self.__send__(key)
213
+ end
159
214
  end
160
215
 
216
+ # Check if a key is available.
217
+ # @parameter key [Symbol] The key to check.
218
+ # @returns [Boolean] True if the key exists.
161
219
  def key?(key)
162
220
  self.keys.include?(key)
163
221
  end
164
222
  end
165
223
 
224
+ # Create an evaluator for this environment.
225
+ # @returns [Evaluator] A lazy evaluator for this environment.
166
226
  def evaluator
167
227
  return Evaluator.wrap(self)
168
228
  end
169
229
 
230
+ # Convert the environment to a hash.
231
+ # @returns [Hash] A hash representation of the environment.
170
232
  def to_h
171
233
  evaluator.to_h
172
234
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Async
7
+ module Service
8
+ # Formatting utilities for service titles.
9
+ #
10
+ # Services need meaningful process/thread names for monitoring and debugging. This module provides consistent formatting for common service metrics like connection counts, request ratios, and load values in process titles.
11
+ #
12
+ # It is expected you will include these into your service class and use them to update the `instance.name` in the health check.
13
+ module Formatting
14
+ UNITS = [nil, "K", "M", "B", "T", "P", "E", "Z", "Y"]
15
+
16
+ # Format a count into a human-readable string.
17
+ # @parameter value [Numeric] The count to format.
18
+ # @parameter units [Array] The units to use for formatting (default: UNITS).
19
+ # @returns [String] A formatted string representing the count.
20
+ def format_count(value, units = UNITS)
21
+ value = value.to_f
22
+ index = 0
23
+ limit = units.size - 1
24
+
25
+ # Handle negative numbers by working with absolute value:
26
+ negative = value < 0
27
+ value = value.abs
28
+
29
+ while value >= 1000 and index < limit
30
+ value = value / 1000
31
+ index += 1
32
+ end
33
+
34
+ result = String.new
35
+ result << "-" if negative
36
+ result << value.round(2).to_s
37
+ result << units[index].to_s if units[index]
38
+
39
+ return result
40
+ end
41
+
42
+ module_function :format_count
43
+
44
+ # Format a ratio as "current/total" with human-readable counts.
45
+ # @parameter current [Numeric] The current value.
46
+ # @parameter total [Numeric] The total value.
47
+ # @returns [String] A formatted ratio string.
48
+ def format_ratio(current, total)
49
+ "#{format_count(current)}/#{format_count(total)}"
50
+ end
51
+
52
+ module_function :format_ratio
53
+
54
+ # Format a load value as a decimal with specified precision.
55
+ # @parameter load [Numeric] The load value (typically 0.0 to 1.0+).
56
+ # @returns [String] A formatted load string.
57
+ def format_load(load)
58
+ load.round(2).to_s
59
+ end
60
+
61
+ module_function :format_load
62
+
63
+ # Format multiple statistics into a compact string.
64
+ # @parameter stats [Hash] Hash of statistic names to values or [current, total] arrays.
65
+ # @returns [String] A formatted statistics string.
66
+ def format_statistics(**pairs)
67
+ pairs.map do |key, value|
68
+ case value
69
+ when Array
70
+ if value.length == 2
71
+ "#{key.to_s.upcase}=#{format_ratio(value[0], value[1])}"
72
+ else
73
+ "#{key.to_s.upcase}=#{value.join('/')}"
74
+ end
75
+ else
76
+ "#{key.to_s.upcase}=#{format_count(value)}"
77
+ end
78
+ end.join(" ")
79
+ end
80
+
81
+ module_function :format_statistics
82
+ end
83
+ end
84
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module Service
@@ -11,22 +11,30 @@ module Async
11
11
  # Designed to be invoked within an {Async::Controller::Container}.
12
12
  class Generic
13
13
  # Convert the given environment into a service if possible.
14
- # @parameter environment [Build::Environment] The environment to use to construct the service.
14
+ # @parameter environment [Environment] The environment to use to construct the service.
15
+ # @returns [Generic | Nil] The constructed service if the environment specifies a service class.
15
16
  def self.wrap(environment)
16
17
  evaluator = environment.evaluator
17
18
 
18
- if service_class = evaluator.service_class || self
19
- return service_class.new(environment, evaluator)
19
+ if evaluator.key?(:service_class)
20
+ if service_class = evaluator.service_class
21
+ return service_class.new(environment, evaluator)
22
+ end
20
23
  end
21
24
  end
22
25
 
23
26
  # Initialize the service from the given environment.
24
- # @parameter environment [Build::Environment]
27
+ # @parameter environment [Environment]
25
28
  def initialize(environment, evaluator = environment.evaluator)
26
29
  @environment = environment
27
30
  @evaluator = evaluator
28
31
  end
29
32
 
33
+ # @attribute [Environment] The environment which is used to configure the service.
34
+ attr :environment
35
+
36
+ # Convert the service evaluator to a hash.
37
+ # @returns [Hash] A hash representation of the evaluator.
30
38
  def to_h
31
39
  @evaluator.to_h
32
40
  end
@@ -37,7 +45,7 @@ module Async
37
45
  @evaluator.name
38
46
  end
39
47
 
40
- # Start the service.
48
+ # Start the service. Called before the container setup.
41
49
  def start
42
50
  Console.debug(self) {"Starting service #{self.name}..."}
43
51
  end
@@ -48,10 +56,44 @@ module Async
48
56
  Console.debug(self) {"Setting up service #{self.name}..."}
49
57
  end
50
58
 
51
- # Stop the service.
59
+ # Stop the service. Called after the container is stopped.
52
60
  def stop(graceful = true)
53
61
  Console.debug(self) {"Stopping service #{self.name}..."}
54
62
  end
63
+
64
+ protected
65
+
66
+ # Start the health checker.
67
+ #
68
+ # If a timeout is specified, a transient child task will be scheduled, which will yield the instance if a block is given, then mark the instance as ready, and finally sleep for half the health check duration (so that we guarantee that the health check runs in time).
69
+ #
70
+ # If a timeout is not specified, the health checker will yield the instance immediately and then mark the instance as ready.
71
+ #
72
+ # @parameter instance [Object] The service instance to check.
73
+ # @parameter timeout [Numeric] The timeout duration for the health check.
74
+ # @parameter parent [Async::Task] The parent task to run the health checker in.
75
+ # @yields {|instance| ...} If a block is given, it will be called with the service instance at least once.
76
+ def health_checker(instance, timeout = @evaluator.health_check_timeout, parent: Async::Task.current, &block)
77
+ if timeout
78
+ parent.async(transient: true) do
79
+ while true
80
+ if block_given?
81
+ yield(instance)
82
+ end
83
+
84
+ instance.ready!
85
+
86
+ sleep(timeout / 2)
87
+ end
88
+ end
89
+ else
90
+ if block_given?
91
+ yield(instance)
92
+ end
93
+
94
+ instance.ready!
95
+ end
96
+ end
55
97
  end
56
98
  end
57
99
  end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
- require_relative 'environment'
6
+ require_relative "environment"
7
7
 
8
8
  module Async
9
9
  module Service
10
- # The domain specific language for loading configuration files.
10
+ # The domain specific language for loading configuration files.
11
11
  class Loader
12
12
  # Initialize the loader, attached to a specific configuration instance.
13
13
  # Any environments generated by the loader will be added to the configuration.
@@ -38,6 +38,8 @@ module Async
38
38
  loader.instance_eval(File.read(path), path)
39
39
  end
40
40
 
41
+ # Load a file relative to the loader's root directory.
42
+ # @parameter path [String] The path to the file to load.
41
43
  def load_file(path)
42
44
  Loader.load_file(@configuration, File.expand_path(path, @root))
43
45
  end
@@ -47,11 +49,14 @@ module Async
47
49
  Environment.build(**initial, &block)
48
50
  end
49
51
 
50
- # Define a host with the specified name.
51
- # Adds `root` and `authority` keys.
52
+ # Define a service with the specified name.
53
+ # Adds `root` and `name` keys.
52
54
  # @parameter name [String] The name of the environment, usually a hostname.
53
- def service(name, &block)
54
- @configuration.add(self.environment(name: name, root: @root, &block))
55
+ def service(name = nil, **options, &block)
56
+ options[:name] = name
57
+ options[:root] ||= @root
58
+
59
+ @configuration.add(self.environment(**options, &block))
55
60
  end
56
61
  end
57
62
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
6
  module Async
7
7
  module Service
8
- VERSION = "0.12.0"
8
+ VERSION = "0.14.0"
9
9
  end
10
10
  end
data/lib/async/service.rb CHANGED
@@ -1,8 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2024, by Samuel Williams.
4
+ # Copyright, 2024-2025, by Samuel Williams.
5
5
 
6
- require_relative 'service/configuration'
7
- require_relative 'service/controller'
8
- require_relative 'service/version'
6
+ require_relative "service/configuration"
7
+ require_relative "service/controller"
8
+ require_relative "service/version"
9
+
10
+ # @namespace
11
+ module Async
12
+ # @namespace
13
+ module Service
14
+ end
15
+ end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2024, by Samuel Williams.
3
+ Copyright, 2024-2025, by Samuel Williams.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/readme.md CHANGED
@@ -1,9 +1,76 @@
1
1
  # Async::Service
2
2
 
3
- Provides a simple service interface for configuring and running services.
3
+ Provides a simple service interface for configuring and running asynchronous services in Ruby.
4
4
 
5
5
  [![Development Status](https://github.com/socketry/async-service/workflows/Test/badge.svg)](https://github.com/socketry/async-service/actions?workflow=Test)
6
6
 
7
+ ## Features
8
+
9
+ - **Service Management**: Define, configure, and run long-running services.
10
+ - **Container Integration**: Built on `async-container` for robust process management.
11
+ - **Multiple Services**: Run and coordinate multiple services together.
12
+ - **Automatic Restart**: Services automatically restart on failure.
13
+ - **Graceful Shutdown**: Clean shutdown handling with proper resource cleanup.
14
+ - **Environment Configuration**: Configure services with environment variables and settings.
15
+
16
+ ## Usage
17
+
18
+ Please see the [project documentation](https://socketry.github.io/async-service/) for more details.
19
+
20
+ - [Getting Started](https://socketry.github.io/async-service/guides/getting-started/index) - This guide explains how to get started with `async-service` to create and run services in Ruby.
21
+
22
+ - [Service Architecture](https://socketry.github.io/async-service/guides/service-architecture/index) - This guide explains the key architectural components of `async-service` and how they work together to provide a clean separation of concerns.
23
+
24
+ - [Best Practices](https://socketry.github.io/async-service/guides/best-practices/index) - This guide outlines recommended patterns and practices for building robust, maintainable services with `async-service`.
25
+
26
+ ## Releases
27
+
28
+ Please see the [project releases](https://socketry.github.io/async-service/releases/index) for all releases.
29
+
30
+ ### v0.14.0
31
+
32
+ - Introduce `ContainerEnvironment` and `ContainerService` for implementing best-practice services.
33
+
34
+ ### v0.13.0
35
+
36
+ - Fix null services handling.
37
+ - Modernize code and improve documentation.
38
+ - Make service name optional and improve code comments.
39
+ - Add `respond_to_missing?` for completeness.
40
+
41
+ ### v0.12.0
42
+
43
+ - Add convenient `Configuration.build{...}` method for constructing inline configurations.
44
+
45
+ ### v0.11.0
46
+
47
+ - Allow builder with argument for more flexible configuration construction.
48
+
49
+ ### v0.10.0
50
+
51
+ - Add `Environment::Evaluator#as_json` for JSON serialization support.
52
+ - Allow constructing a configuration with existing environments.
53
+
54
+ ### v0.9.0
55
+
56
+ - Allow providing a list of modules to include in environments.
57
+
58
+ ### v0.8.0
59
+
60
+ - Introduce `Environment#implements?` and related methods for interface checking.
61
+
62
+ ### v0.7.0
63
+
64
+ - Allow instance methods that take arguments in environments.
65
+
66
+ ### v0.6.1
67
+
68
+ - Fix requirement that facet must be a module.
69
+
70
+ ### v0.6.0
71
+
72
+ - Unify construction of environments for better consistency.
73
+
7
74
  ## Contributing
8
75
 
9
76
  We welcome contributions to this project.
@@ -16,8 +83,8 @@ We welcome contributions to this project.
16
83
 
17
84
  ### Developer Certificate of Origin
18
85
 
19
- This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted.
86
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
20
87
 
21
- ### Contributor Covenant
88
+ ### Community Guidelines
22
89
 
23
- This project is governed by the [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms.
90
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
data/releases.md ADDED
@@ -0,0 +1,85 @@
1
+ # Releases
2
+
3
+ ## v0.14.0
4
+
5
+ - Introduce `ContainerEnvironment` and `ContainerService` for implementing best-practice services.
6
+
7
+ ## v0.13.0
8
+
9
+ - Fix null services handling.
10
+ - Modernize code and improve documentation.
11
+ - Make service name optional and improve code comments.
12
+ - Add `respond_to_missing?` for completeness.
13
+
14
+ ## v0.12.0
15
+
16
+ - Add convenient `Configuration.build{...}` method for constructing inline configurations.
17
+
18
+ ## v0.11.0
19
+
20
+ - Allow builder with argument for more flexible configuration construction.
21
+
22
+ ## v0.10.0
23
+
24
+ - Add `Environment::Evaluator#as_json` for JSON serialization support.
25
+ - Allow constructing a configuration with existing environments.
26
+
27
+ ## v0.9.0
28
+
29
+ - Allow providing a list of modules to include in environments.
30
+
31
+ ## v0.8.0
32
+
33
+ - Introduce `Environment#implements?` and related methods for interface checking.
34
+
35
+ ## v0.7.0
36
+
37
+ - Allow instance methods that take arguments in environments.
38
+
39
+ ## v0.6.1
40
+
41
+ - Fix requirement that facet must be a module.
42
+
43
+ ## v0.6.0
44
+
45
+ - Unify construction of environments for better consistency.
46
+
47
+ ## v0.5.1
48
+
49
+ - Relax dependency on async-container for better compatibility.
50
+
51
+ ## v0.5.0
52
+
53
+ - Add support for passing through options to controllers.
54
+
55
+ ## v0.4.0
56
+
57
+ - Reuse evaluator for service instances for better performance.
58
+ - Expose `Configuration.load` and `Controller.start` for better composition.
59
+ - Add simple service example.
60
+
61
+ ## v0.3.1
62
+
63
+ - Fix usage of `raise` in `BasicObject` context.
64
+
65
+ ## v0.3.0
66
+
67
+ - Use modules for environments instead of basic objects.
68
+ - Allow non-modules to be included in environments.
69
+
70
+ ## v0.2.1
71
+
72
+ - Add missing call to `super` in service implementations.
73
+
74
+ ## v0.2.0
75
+
76
+ - Add support for loading other configuration files.
77
+ - Minor bug fixes and improvements.
78
+
79
+ ## v0.1.0
80
+
81
+ - Initial release with core service framework.
82
+ - Environment abstraction for service configuration.
83
+ - Improved evaluator implementation with comprehensive tests.
84
+ - Controller for handling service execution.
85
+ - Support for explicit `service_class` configuration.
data.tar.gz.sig CHANGED
Binary file