async-service 0.13.0 → 0.14.1

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.
@@ -0,0 +1,499 @@
1
+ # Service Architecture
2
+
3
+ This guide explains the key architectural components of `async-service` and how they work together to provide a clean separation of concerns.
4
+
5
+ ## Core Components
6
+
7
+ `Async::Service` is built around four main concepts:
8
+
9
+ - **Environment**: Represents a service configuration and provides lazy evaluation of settings.
10
+ - **Service**: Represents a specific service implementation, defined by an environment.
11
+ - **Configuration**: Represents one or more configured services, defined by environments.
12
+ - **Container**: Used to run the actual service logic in child processes or threads.
13
+
14
+ ## Environment
15
+
16
+ The {ruby Async::Service::Environment} represents a lazy-evaluated chain of key-value pairs. It handles configuration storage, lazy evaluation of computed values, and composition through module inclusion to create flexible, reusable service configurations.
17
+
18
+ ### Configuration Storage
19
+
20
+ Each service definition creates an environment:
21
+
22
+ ```ruby
23
+ service "web" do
24
+ port 3000
25
+ host "localhost"
26
+ end
27
+ ```
28
+
29
+ ### Lazy Evaluation
30
+
31
+ Environments use lazy evaluation through evaluators:
32
+
33
+ ```ruby
34
+ service "web" do
35
+ root "/app"
36
+
37
+ # Computed when accessed:
38
+ log_path {File.join(root, "logs", "app.log")}
39
+ end
40
+ ```
41
+
42
+ The `log_path` is calculated when the service accesses it, not when defined.
43
+
44
+ ### Composition and Inheritance
45
+
46
+ Environments support modular configuration:
47
+
48
+ ```ruby
49
+ module DatabaseEnvironment
50
+ def database_url
51
+ "postgresql://localhost/app"
52
+ end
53
+ end
54
+
55
+ service "web" do
56
+ # Environment includes the module:
57
+ include DatabaseEnvironment
58
+
59
+ database {Database.new(database_url)}
60
+ end
61
+ ```
62
+
63
+ You can also explicitly create environments:
64
+
65
+ ```ruby
66
+ SharedEnvironment = environment do
67
+ port 3000
68
+ host "localhost"
69
+ url {"http://#{host}:#{port}"}
70
+ end
71
+
72
+ service "web1" do
73
+ include SharedEnvironment
74
+ port 3001
75
+ end
76
+
77
+ service "web2" do
78
+ include SharedEnvironment
79
+ port 3002
80
+ end
81
+ ```
82
+
83
+ ## Service
84
+
85
+ The {ruby Async::Service::Generic} represents the service implementation layer. It handles the actual business logic of your services, provides access to configuration through environment evaluators, and manages the service lifecycle including startup, execution, and shutdown phases.
86
+
87
+ ### Business Logic
88
+
89
+ Services contain the actual implementation of what your service does:
90
+
91
+ ```ruby
92
+ class WebService < Async::Service::Generic
93
+ def setup(container)
94
+ super
95
+
96
+ # Define how the service runs:
97
+ container.run(count: 1, restart: true) do |instance|
98
+ # Access configuration through evaluator:
99
+ evaluator = self.environment.evaluator
100
+
101
+ # Indicate that the service instance is healthy thus far:
102
+ instance.ready!
103
+
104
+ # Your service implementation:
105
+ start_web_server(evaluator.host, evaluator.port)
106
+ end
107
+ end
108
+ end
109
+ ```
110
+
111
+ ### Configuration Access
112
+
113
+ Services access their configuration through their environment's evaluator:
114
+
115
+ ```ruby
116
+ def setup(container)
117
+ super
118
+
119
+ container.run do |instance|
120
+ # Create a new evaluator as we are in a different execution context:
121
+ evaluator = self.environment.evaluator
122
+
123
+ # Use evaluator in service configuration:
124
+ database_url = evaluator.database_url
125
+ max_connections = evaluator.max_connections
126
+
127
+ # Your service implementation.
128
+ end
129
+ end
130
+ ```
131
+
132
+ Within the service class itself, you can use `@evaluator`. However, evaluators are not thread safe, so you should not use them across threads, and instead create distinct evaluators for each child process, thread or ractor, etc.
133
+
134
+ #### Lazy Evaluation
135
+
136
+ Environment evaluators are lazy-evaluated and memoized, meaning that any configuration defined within them is not computed until it is accessed. By doing this in the scope of the child process, you can ensure that each instance has its own configuration values.
137
+
138
+ ```ruby
139
+ service do
140
+ log_path {File.join(root, "logs", "app-#{Process.pid}.log")}
141
+ end
142
+ ```
143
+
144
+ By evaluating the log_path in the child process, you ensure that each instance has its own log file with the correct process ID.
145
+
146
+ ### Lifecycle Management
147
+
148
+ Services define their startup, running, and shutdown behavior:
149
+
150
+ ```ruby
151
+ class MyService < Async::Service::Generic
152
+ def start
153
+ super
154
+ # Service-specific startup logic including pre-loading libraries and binding to network interfaces before forking.
155
+ end
156
+
157
+ def setup(container)
158
+ super
159
+
160
+ # Define container execution:
161
+ container.run do |instance|
162
+ # Your service implementation.
163
+ end
164
+ end
165
+
166
+ def stop(graceful = true)
167
+ # Service-specific cleanup including releasing any resources acquired during startup.
168
+ super
169
+ end
170
+ end
171
+ ```
172
+
173
+ ## Configuration
174
+
175
+ The {ruby Async::Service::Configuration} represents the top-level orchestration layer. It handles service definition and registration, provides service discovery and management capabilities, supports loading configurations from files, and enables introspection of defined services and their settings.
176
+
177
+ ### Service Definitions
178
+
179
+ ```ruby
180
+ configuration = Async::Service::Configuration.build do
181
+ service "web" do
182
+ service_class WebService
183
+ port 3000
184
+ host "localhost"
185
+ end
186
+
187
+ service "worker" do
188
+ service_class WorkerService
189
+ worker_count 4
190
+ end
191
+ end
192
+ ```
193
+
194
+ ### Service Discovery and Management
195
+
196
+ ```ruby
197
+ # Access all services in the configuration:
198
+ configuration.services.each do |service|
199
+ puts "Service: #{service.name}"
200
+ end
201
+
202
+ # Create a controller to manage all services:
203
+ controller = configuration.controller
204
+
205
+ # Start all services:
206
+ controller.start
207
+ ```
208
+
209
+ ### Loading from Files
210
+
211
+ ```ruby
212
+ # Load services from configuration files:
213
+ configuration = Async::Service::Configuration.load(["web.rb", "worker.rb"])
214
+
215
+ # Or load from command line arguments:
216
+ configuration = Async::Service::Configuration.load(ARGV)
217
+ ```
218
+
219
+ ### Introspection
220
+
221
+ Given a `service.rb` file, you can list the defined services and their configurations.
222
+
223
+ ```bash
224
+ $ bundle exec bake async:service:configuration:load service.rb async:service:configuration:list o
225
+ utput --format json
226
+ [
227
+ {
228
+ "port": 3000,
229
+ "name": "web",
230
+ "host": "localhost",
231
+ "service_class": "WebService",
232
+ "root": "/Users/samuel/Developer/socketry/async-service"
233
+ },
234
+ {
235
+ "name": "worker",
236
+ "worker_count": 4,
237
+ "service_class": "WorkerService",
238
+ "root": "/Users/samuel/Developer/socketry/async-service"
239
+ }
240
+ ]
241
+ ```
242
+
243
+ Note that only `service do ... end` definitions are included in the configuration listing.
244
+
245
+ ## Container
246
+
247
+ The container layer (provided by the `async-container` gem) handles process management. Services don't interact with containers directly, but configure how containers should run them.
248
+
249
+ ### Process Management
250
+
251
+ ```ruby
252
+ def setup(container)
253
+ # Configure how many instances, restart behavior, etc.
254
+ container.run(count: 4, restart: true) do |instance|
255
+ # This block runs in a separate process/fiber
256
+ instance.ready!
257
+
258
+ # Your service implementation.
259
+ end
260
+ end
261
+ ```
262
+
263
+ ### Health Checking
264
+
265
+ ```ruby
266
+ def setup(container)
267
+ container_options = @evaluator.container_options
268
+ health_check_timeout = container_options[:health_check_timeout]
269
+
270
+ container.run(**container_options) do |instance|
271
+ # Prepare your service.
272
+
273
+ Sync do
274
+ # Start your service.
275
+
276
+ # Set up health checking, if a timeout was specified:
277
+ health_checker(instance, health_check_timeout) do
278
+ instance.name = "#{self.name}: #{current_status}"
279
+ end
280
+ end
281
+ end
282
+ end
283
+ ```
284
+
285
+ ## How They Work Together
286
+
287
+ The four layers interact in a specific pattern:
288
+
289
+ ### 1. Environment Creation
290
+
291
+ ```ruby
292
+ # Environments define individual service configuration:
293
+ module DatabaseEnvironment
294
+ def database_url
295
+ "postgresql://localhost/app"
296
+ end
297
+ end
298
+ ```
299
+
300
+ ### 2. Service Definition
301
+
302
+ ```ruby
303
+ # Services are defined using environments:
304
+ class WebService < Async::Service::Generic
305
+ def setup(container)
306
+ super
307
+
308
+ # Access configuration through evaluator
309
+ evaluator = @environment.evaluator
310
+ port = evaluator.port
311
+
312
+ # Define how the service runs
313
+ container.run(count: 1, restart: true) do |instance|
314
+ instance.ready!
315
+
316
+ # Your service implementation:
317
+ start_web_server(port)
318
+ end
319
+ end
320
+ end
321
+ ```
322
+
323
+ ### 3. Configuration Assembly
324
+
325
+ ```ruby
326
+ #!/usr/bin/env async-service
327
+
328
+ require "database_environment"
329
+ require "web_service"
330
+
331
+ service "web" do
332
+ include DatabaseEnvironment # Use the environment
333
+ service_class WebService
334
+ port 3000
335
+ end
336
+ ```
337
+
338
+ ### 4. Container Execution
339
+
340
+ ```ruby
341
+ # Load the service configurations:
342
+ configuration = Async::Service::Configuration.load("service.rb")
343
+
344
+ # Controller manages the actual execution using containers:
345
+ Async::Service::Controller.run(configuration)
346
+ ```
347
+
348
+ ## Benefits of This Architecture
349
+
350
+ ### Separation of Concerns
351
+
352
+ - **Environment**: "How should individual service configuration be stored and evaluated?"
353
+ - **Service**: "What should each service do when it runs?"
354
+ - **Configuration**: "What services should run and how should they be combined?"
355
+ - **Container**: "How should services be executed and monitored in processes?"
356
+
357
+ ### Testability
358
+
359
+ ```ruby
360
+ # Test configuration
361
+ configuration = Async::Service::Configuration.build do
362
+ service "test-service" do
363
+ service_class MyService
364
+ port 3001
365
+ database_url "sqlite://memory"
366
+ end
367
+ end
368
+
369
+ # Test individual service
370
+ service = configuration.services.first
371
+ mock_container = double("container")
372
+ service.setup(mock_container)
373
+ ```
374
+
375
+ ### Flexibility
376
+
377
+ - Define multiple services in one configuration
378
+ - Same service class with different configurations
379
+ - Load configurations from files or build programmatically
380
+ - Different container strategies per service
381
+
382
+ ### Reusability
383
+
384
+ ```ruby
385
+ # Reusable environment modules:
386
+ module DatabaseEnvironment
387
+ def database_url
388
+ "postgresql://localhost/app"
389
+ end
390
+ end
391
+
392
+ module RedisEnvironment
393
+ def redis_url
394
+ "redis://localhost:6379"
395
+ end
396
+ end
397
+
398
+ # Compose in service definitions:
399
+ configuration = Async::Service::Configuration.build do
400
+ service "worker" do
401
+ include DatabaseEnvironment
402
+ include RedisEnvironment
403
+
404
+ service_class WorkerService
405
+ end
406
+ end
407
+ ```
408
+
409
+ ## Common Patterns
410
+
411
+ ### Configuration Files
412
+
413
+ Create reusable configuration files:
414
+
415
+ ```ruby
416
+ # config/web.rb
417
+ service "web" do
418
+ service_class WebService
419
+ port 3000
420
+ host "0.0.0.0"
421
+ end
422
+
423
+ # config/worker.rb
424
+ service "worker" do
425
+ service_class WorkerService
426
+ concurrency 4
427
+ end
428
+
429
+ # Load both:
430
+ configuration = Async::Service::Configuration.load(["config/web.rb", "config/worker.rb"])
431
+ ```
432
+
433
+ ### Environment Modules
434
+
435
+ Create reusable configuration modules:
436
+
437
+ ```ruby
438
+ module ContainerEnvironment
439
+ def count
440
+ 4
441
+ end
442
+
443
+ def restart
444
+ true
445
+ end
446
+
447
+ def health_check_timeout
448
+ 30
449
+ end
450
+ end
451
+
452
+ configuration = Async::Service::Configuration.build do
453
+ service "my-service" do
454
+ include ContainerEnvironment
455
+ service_class MyService
456
+ end
457
+ end
458
+ ```
459
+
460
+ ### Service Inheritance
461
+
462
+ Build service hierarchies:
463
+
464
+ ```ruby
465
+ class BaseWebService < Async::Service::Generic
466
+ def setup(container)
467
+ super
468
+ # Common web service setup
469
+ end
470
+ end
471
+
472
+ class APIService < BaseWebService
473
+ def setup(container)
474
+ super
475
+ # API-specific setup
476
+ end
477
+ end
478
+ ```
479
+
480
+ ### Programmatic Configuration
481
+
482
+ Build configurations dynamically:
483
+
484
+ ```ruby
485
+ configuration = Async::Service::Configuration.new
486
+
487
+ # Add services programmatically
488
+ ["web", "api", "worker"].each do |name|
489
+ environment = Async::Service::Environment.build do
490
+ service_class MyService
491
+ service_name name
492
+ port 3000 + name.hash % 1000
493
+ end
494
+
495
+ configuration.add(environment)
496
+ end
497
+ ```
498
+
499
+ This architecture provides a clean, testable, and flexible foundation for building services while maintaining clear boundaries between configuration, implementation, and execution concerns.
@@ -13,6 +13,10 @@ module Async
13
13
  #
14
14
  # Environments are key-value maps with lazy value resolution. An environment can inherit from a parent environment, which can provide defaults
15
15
  class Configuration
16
+ # Build a configuration using a block.
17
+ # @parameter root [String] The root directory for loading files.
18
+ # @yields {|loader| ...} A loader instance for configuration.
19
+ # @returns [Configuration] A new configuration instance.
16
20
  def self.build(root: Dir.pwd, &block)
17
21
  configuration = self.new
18
22
 
@@ -22,6 +26,9 @@ module Async
22
26
  return configuration
23
27
  end
24
28
 
29
+ # Load configuration from file paths.
30
+ # @parameter paths [Array(String)] File paths to load, defaults to `ARGV`.
31
+ # @returns [Configuration] A new configuration instance.
25
32
  def self.load(paths = ARGV)
26
33
  configuration = self.new
27
34
 
@@ -32,6 +39,9 @@ module Async
32
39
  return configuration
33
40
  end
34
41
 
42
+ # Create configuration from environments.
43
+ # @parameter environments [Array] Environment instances.
44
+ # @returns [Configuration] A new configuration instance.
35
45
  def self.for(*environments)
36
46
  self.new(environments)
37
47
  end
@@ -43,6 +53,8 @@ module Async
43
53
 
44
54
  attr :environments
45
55
 
56
+ # Check if the configuration is empty.
57
+ # @returns [Boolean] True if no environments are configured.
46
58
  def empty?
47
59
  @environments.empty?
48
60
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Async
4
+ module Service
5
+ # Default configuration for container-based services.
6
+ #
7
+ # This is provided not because it is required, but to offer a sensible default for containerized applications, and to expose a consistent interface for service configuration.
8
+ module ContainerEnvironment
9
+ # Number of instances to start. By default, when `nil`, uses `Etc.nprocessors`.
10
+ #
11
+ # @returns [Integer | nil] The number of instances to start, or `nil` to use the default.
12
+ def count
13
+ nil
14
+ end
15
+
16
+ # The timeout duration for the health check. Set to `nil` to disable the health check.
17
+ #
18
+ # @returns [Numeric | nil] The health check timeout in seconds.
19
+ def health_check_timeout
20
+ 30
21
+ end
22
+
23
+ # Options to use when creating the container, including `restart`, `count`, and `health_check_timeout`.
24
+ #
25
+ # @returns [Hash] The options for the container.
26
+ def container_options
27
+ {
28
+ restart: true,
29
+ count: self.count,
30
+ health_check_timeout: self.health_check_timeout,
31
+ }.compact
32
+ end
33
+
34
+ # Any scripts to preload before starting the server.
35
+ #
36
+ # @returns [Array(String)] The list of scripts to preload.
37
+ def preload
38
+ []
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "generic"
7
+ require_relative "formatting"
8
+
9
+ module Async
10
+ module Service
11
+ # A service that runs in a container with built-in health checking and process title formatting.
12
+ #
13
+ # This is the recommended base class for most services.
14
+ class ContainerService < Async::Service::Generic
15
+ include Async::Service::Formatting
16
+
17
+ private def format_title(evaluator, server)
18
+ "#{evaluator.name} #{server.to_s}"
19
+ end
20
+
21
+ # Run the service logic.
22
+ #
23
+ # Override this method to implement your service. Return an object that represents the running service (e.g., a server, task, or worker pool) for health checking.
24
+ #
25
+ # @parameter instance [Object] The container instance.
26
+ # @parameter evaluator [Environment::Evaluator] The environment evaluator.
27
+ # @returns [Object] The service object (server, task, etc.)
28
+ def run(instance, evaluator)
29
+ Async do
30
+ sleep
31
+ end
32
+ end
33
+
34
+ # Preload any resources specified by the environment.
35
+ def preload!
36
+ if scripts = @evaluator.preload
37
+ root = @evaluator.root
38
+ scripts = Array(scripts)
39
+
40
+ scripts.each do |path|
41
+ Console.info(self) {"Preloading #{path}..."}
42
+ full_path = File.expand_path(path, root)
43
+ require(full_path)
44
+ end
45
+ end
46
+ rescue => error
47
+ Console.warn(self, "Service preload failed!", error)
48
+ end
49
+
50
+ # Start the service, including preloading resources.
51
+ def start
52
+ preload!
53
+
54
+ super
55
+ end
56
+
57
+ # Set up the container with health checking and process title formatting.
58
+ # @parameter container [Async::Container] The container to configure.
59
+ def setup(container)
60
+ super
61
+
62
+ container_options = @evaluator.container_options
63
+ health_check_timeout = container_options[:health_check_timeout]
64
+
65
+ container.run(**container_options) do |instance|
66
+ evaluator = self.environment.evaluator
67
+
68
+ server = run(instance, evaluator)
69
+
70
+ health_checker(instance) do
71
+ instance.name = format_title(evaluator, server)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -7,7 +7,13 @@ 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
19
  require "bundler"
@@ -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