diff --git a/.editorconfig b/.editorconfig index 269d98a..a6e7d26 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,6 @@ root = true indent_style = tab indent_size = 2 +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml deleted file mode 100644 index 9e7a772..0000000 --- a/.github/workflows/development.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Development - -on: [push] - -jobs: - test: - strategy: - matrix: - os: - - ubuntu - - macos - - ruby: - - 2.4 - - 2.5 - - 2.6 - - 2.7 - - include: - - os: 'ubuntu' - ruby: '2.6' - env: COVERAGE=PartialSummary,Coveralls - - runs-on: ${{matrix.os}}-latest - - steps: - - uses: actions/checkout@v1 - - uses: actions/setup-ruby@v1 - with: - ruby-version: ${{matrix.ruby}} - - name: Install dependencies - run: | - command -v bundler || gem install bundler - bundle install - - name: Run tests - run: ${{matrix.env}} bundle exec rspec diff --git a/.github/workflows/documentation-coverage.yaml b/.github/workflows/documentation-coverage.yaml new file mode 100644 index 0000000..8d801c5 --- /dev/null +++ b/.github/workflows/documentation-coverage.yaml @@ -0,0 +1,25 @@ +name: Documentation Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake decode:index:coverage lib diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml new file mode 100644 index 0000000..e47c6b3 --- /dev/null +++ b/.github/workflows/documentation.yaml @@ -0,0 +1,58 @@ +name: Documentation + +on: + push: + branches: + - main + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment: +concurrency: + group: "pages" + cancel-in-progress: true + +env: + CONSOLE_OUTPUT: XTerm + BUNDLE_WITH: maintenance + +jobs: + generate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Installing packages + run: sudo apt-get install wget + + - name: Generate documentation + timeout-minutes: 5 + run: bundle exec bake utopia:project:static --force no + + - name: Upload documentation artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + deploy: + runs-on: ubuntu-latest + + environment: + name: github-pages + url: ${{steps.deployment.outputs.page_url}} + + needs: generate + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/rubocop.yaml b/.github/workflows/rubocop.yaml new file mode 100644 index 0000000..287c06d --- /dev/null +++ b/.github/workflows/rubocop.yaml @@ -0,0 +1,24 @@ +name: RuboCop + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler-cache: true + + - name: Run RuboCop + timeout-minutes: 10 + run: bundle exec rubocop diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..e6dc5c3 --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,59 @@ +name: Test Coverage + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + COVERAGE: PartialSummary + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.4" + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 5 + run: bundle exec bake test + + - uses: actions/upload-artifact@v4 + with: + include-hidden-files: true + if-no-files-found: error + name: coverage-${{matrix.os}}-${{matrix.ruby}} + path: .covered.db + + validate: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - uses: actions/download-artifact@v4 + + - name: Validate coverage + timeout-minutes: 5 + run: bundle exec bake covered:validate --paths */.covered.db \; diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml new file mode 100644 index 0000000..c9cc200 --- /dev/null +++ b/.github/workflows/test-external.yaml @@ -0,0 +1,37 @@ +name: Test External + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.1" + - "3.2" + - "3.3" + - "3.4" + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 10 + run: bundle exec bake test:external diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..5d597fa --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,51 @@ +name: Test + +on: [push, pull_request] + +permissions: + contents: read + +env: + CONSOLE_OUTPUT: XTerm + +jobs: + test: + name: ${{matrix.ruby}} on ${{matrix.os}} + runs-on: ${{matrix.os}}-latest + continue-on-error: ${{matrix.experimental}} + + strategy: + matrix: + os: + - ubuntu + - macos + + ruby: + - "3.1" + - "3.2" + - "3.3" + - "3.4" + + experimental: [false] + + include: + - os: ubuntu + ruby: truffleruby + experimental: true + - os: ubuntu + ruby: jruby + experimental: true + - os: ubuntu + ruby: head + experimental: true + + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{matrix.ruby}} + bundler-cache: true + + - name: Run tests + timeout-minutes: 10 + run: bundle exec bake test diff --git a/.gitignore b/.gitignore index 2250076..09a72e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,5 @@ -*.gem -*.rbc -.bundle -.config -.yardoc -Gemfile.lock -InstalledFiles -_yardoc -coverage -doc/ -lib/bundler/man -pkg -rdoc -spec/reports -test/tmp -test/version_tmp -tmp -.tags* -documentation/run/* -documentation/public/code/* -.rspec_status \ No newline at end of file +/.bundle/ +/pkg/ +/gems.locked +/.covered.db +/external diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..6f2eb8e --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Yuji Yaginuma +Juan Antonio Martín Lucas diff --git a/.rspec b/.rspec deleted file mode 100644 index 8fbe32d..0000000 --- a/.rspec +++ /dev/null @@ -1,3 +0,0 @@ ---format documentation ---warnings ---require spec_helper \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..3b8d476 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,53 @@ +AllCops: + DisabledByDefault: true + +Layout/IndentationStyle: + Enabled: true + EnforcedStyle: tabs + +Layout/InitialIndentation: + Enabled: true + +Layout/IndentationWidth: + Enabled: true + Width: 1 + +Layout/IndentationConsistency: + Enabled: true + EnforcedStyle: normal + +Layout/BlockAlignment: + Enabled: true + +Layout/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + +Layout/BeginEndAlignment: + Enabled: true + EnforcedStyleAlignWith: start_of_line + +Layout/ElseAlignment: + Enabled: true + +Layout/DefEndAlignment: + Enabled: true + +Layout/CaseIndentation: + Enabled: true + +Layout/CommentIndentation: + Enabled: true + +Layout/EmptyLinesAroundClassBody: + Enabled: true + +Layout/EmptyLinesAroundModuleBody: + Enabled: true + +Style/FrozenStringLiteralComment: + Enabled: true + +Style/StringLiterals: + Enabled: true + EnforcedStyle: double_quotes diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2a3ba38..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: ruby -dist: xenial -cache: bundler - -matrix: - include: - - rvm: 2.4 - - rvm: 2.5 - - rvm: 2.6 - # - rvm: 2.6 - # os: osx - - rvm: 2.6 - env: COVERAGE=BriefSummary,Coveralls - - rvm: 2.7 - - rvm: truffleruby - - rvm: jruby-head - env: JRUBY_OPTS="--debug -X+O" - - rvm: ruby-head - allow_failures: - - rvm: ruby-head - - rvm: jruby-head diff --git a/.yardopts b/.yardopts deleted file mode 100644 index d742dcb..0000000 --- a/.yardopts +++ /dev/null @@ -1 +0,0 @@ ---markup markdown \ No newline at end of file diff --git a/Gemfile b/Gemfile deleted file mode 100644 index d9bf345..0000000 --- a/Gemfile +++ /dev/null @@ -1,20 +0,0 @@ -source 'https://rubygems.org' - -# Specify your gem's dependencies in utopia.gemspec -gemspec - -group :development do - gem 'pry' - gem 'guard-rspec' - gem 'guard-yard' - - gem 'yard' -end - -group :test do - gem 'benchmark-ips' - gem 'ruby-prof', platforms: :mri - - gem 'simplecov' - gem 'coveralls', require: false -end diff --git a/Guardfile b/Guardfile deleted file mode 100644 index b4f9196..0000000 --- a/Guardfile +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -directories %w(lib spec) -clearing :on - -guard :rspec, cmd: "bundle exec rspec" do - watch(%r{^spec/.+_spec\.rb$}) - watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } - watch("spec/spec_helper.rb") { "spec" } -end - -guard 'yard', :port => '8808' do - watch(%r{^lib/(.+)\.rb$}) -end diff --git a/README.md b/README.md deleted file mode 100644 index d6e7fd3..0000000 --- a/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# Async::Container - -Provides containers which implement concurrency policy for high-level servers (and potentially clients). - -[![Actions Status](https://github.com/socketry/async-container/workflows/Development/badge.svg)](https://github.com/socketry/async-container/actions?workflow=Development) -[![Code Climate](https://codeclimate.com/github/socketry/async-container.svg)](https://codeclimate.com/github/socketry/async-container) -[![Coverage Status](https://coveralls.io/repos/socketry/async-container/badge.svg)](https://coveralls.io/r/socketry/async-container) - -## Installation - -Add this line to your application's Gemfile: - -```ruby -gem "async-container" -``` - -And then execute: - - $ bundle - -Or install it yourself as: - - $ gem install async - -## Usage - -### Container - -A container represents a set of child processes (or threads) which are doing work for you. - -```ruby -require 'async/container' - -Async.logger.debug! - -container = Async::Container.new - -container.async do |task| - task.logger.debug "Sleeping..." - task.sleep(1) - task.logger.debug "Waking up!" -end - -Async.logger.debug "Waiting for container..." -container.wait -Async.logger.debug "Finished." -``` - -### Controller - -The controller provides the life-cycle management for one or more containers of processes. It provides behaviour like starting, restarting, reloading and stopping. You can see some [example implementations in Falcon](https://github.com/socketry/falcon/blob/master/lib/falcon/controller/). If the process running the controller receives `SIGHUP` it will recreate the container gracefully. - -```ruby -require 'async/container' - -Async.logger.debug! - -class Controller < Async::Container::Controller - def setup(container) - container.async do |task| - while true - Async.logger.debug("Sleeping...") - task.sleep(1) - end - end - end -end - -controller = Controller.new - -controller.run - -# If you send SIGHUP to this process, it will recreate the container. -``` - -### Signal Handling - -`SIGINT` is the interrupt signal. The terminal sends it to the foreground process when the user presses **ctrl-c**. The default behavior is to terminate the process, but it can be caught or ignored. The intention is to provide a mechanism for an orderly, graceful shutdown. - -`SIGQUIT` is the dump core signal. The terminal sends it to the foreground process when the user presses **ctrl-\**. The default behavior is to terminate the process and dump core, but it can be caught or ignored. The intention is to provide a mechanism for the user to abort the process. You can look at `SIGINT` as "user-initiated happy termination" and `SIGQUIT` as "user-initiated unhappy termination." - -`SIGTERM` is the termination signal. The default behavior is to terminate the process, but it also can be caught or ignored. The intention is to kill the process, gracefully or not, but to first allow it a chance to cleanup. - -`SIGKILL` is the kill signal. The only behavior is to kill the process, immediately. As the process cannot catch the signal, it cannot cleanup, and thus this is a signal of last resort. - -`SIGSTOP` is the pause signal. The only behavior is to pause the process; the signal cannot be caught or ignored. The shell uses pausing (and its counterpart, resuming via `SIGCONT`) to implement job control. - -### Integration - -#### systemd - -Install a template file into `/etc/systemd/system/`: - -``` -# my-daemon.service -[Unit] -Description=My Daemon -AssertPathExists=/srv/ - -[Service] -Type=notify -WorkingDirectory=/srv/my-daemon -ExecStart=bundle exec my-daemon -Nice=5 - -[Install] -WantedBy=multi-user.target -``` - -## Contributing - -1. Fork it -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create new Pull Request - -## License - -Released under the MIT license. - -Copyright, 2017, by [Samuel G. D. Williams](http://www.codeotaku.com/samuel-williams). - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/Rakefile b/Rakefile deleted file mode 100644 index f5cbbfd..0000000 --- a/Rakefile +++ /dev/null @@ -1,6 +0,0 @@ -require "bundler/gem_tasks" -require "rspec/core/rake_task" - -RSpec::Core::RakeTask.new(:test) - -task :default => :test diff --git a/async-container.gemspec b/async-container.gemspec index e23b1dd..06368b8 100644 --- a/async-container.gemspec +++ b/async-container.gemspec @@ -1,34 +1,28 @@ +# frozen_string_literal: true -require_relative 'lib/async/container/version' +require_relative "lib/async/container/version" Gem::Specification.new do |spec| - spec.name = "async-container" - spec.version = Async::Container::VERSION - spec.authors = ["Samuel Williams"] - spec.email = ["samuel.williams@oriontransfer.co.nz"] - spec.description = <<-EOF - Provides containers for servers which provide concurrency policies, e.g. threads, processes. - EOF - spec.summary = "Async is an asynchronous I/O framework based on nio4r." - spec.homepage = "https://github.com/socketry/async-container" - spec.license = "MIT" - - spec.files = `git ls-files`.split($/) - spec.executables = spec.files.grep(%r{^bin/}).map{ |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) - spec.require_paths = ["lib"] + spec.name = "async-container" + spec.version = Async::Container::VERSION + + spec.summary = "Abstract container-based parallelism using threads and processes where appropriate." + spec.authors = ["Samuel Williams", "Olle Jonsson", "Anton Sozontov", "Juan Antonio Martín Lucas", "Yuji Yaginuma"] + spec.license = "MIT" + + spec.cert_chain = ["release.cert"] + spec.signing_key = File.expand_path("~/.gem/release.pem") - spec.required_ruby_version = "~> 2.0" + spec.homepage = "https://github.com/socketry/async-container" - spec.add_runtime_dependency "process-group" + spec.metadata = { + "documentation_uri" => "https://socketry.github.io/async-container/", + "source_code_uri" => "https://github.com/socketry/async-container.git", + } - spec.add_runtime_dependency "async", "~> 1.0" - spec.add_runtime_dependency "async-io", "~> 1.26" + spec.files = Dir.glob(["{lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) - spec.add_development_dependency "async-rspec", "~> 1.1" + spec.required_ruby_version = ">= 3.1" - spec.add_development_dependency "covered" - spec.add_development_dependency "bundler" - spec.add_development_dependency "rspec", "~> 3.6" - spec.add_development_dependency "rake" + spec.add_dependency "async", "~> 2.22" end diff --git a/bake.rb b/bake.rb new file mode 100644 index 0000000..d3bc6c1 --- /dev/null +++ b/bake.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +# Update the project documentation with the new version number. +# +# @parameter version [String] The new version number. +def after_gem_release_version_increment(version) + context["releases:update"].call(version) + context["utopia:project:readme:update"].call +end diff --git a/config/external.yaml b/config/external.yaml new file mode 100644 index 0000000..ddb05f5 --- /dev/null +++ b/config/external.yaml @@ -0,0 +1,6 @@ +falcon: + url: https://github.com/socketry/falcon.git + command: bundle exec bake test +async-service: + url: https://github.com/socketry/async-service.git + command: bundle exec bake test diff --git a/config/metrics.rb b/config/metrics.rb new file mode 100644 index 0000000..d5670e0 --- /dev/null +++ b/config/metrics.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +def prepare + require "metrics/provider/async/container" +end diff --git a/config/sus.rb b/config/sus.rb new file mode 100644 index 0000000..e5fc866 --- /dev/null +++ b/config/sus.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2024, by Samuel Williams. + +require "covered/sus" +include Covered::Sus + +ENV["CONSOLE_LEVEL"] ||= "fatal" +ENV["METRICS_BACKEND"] ||= "metrics/backend/test" + +def prepare_instrumentation! + require "metrics" +end + +def before_tests(...) + prepare_instrumentation! + + super +end diff --git a/examples/async.rb b/examples/async.rb deleted file mode 100644 index d8abb43..0000000 --- a/examples/async.rb +++ /dev/null @@ -1,21 +0,0 @@ - -require 'kernel/sync' - -class Worker - def initialize(&block) - - end -end - -Sync do - count.times do - worker = Worker.new(&block) - - status = worker.wait do |message| - - end - - status.success? - status.failed? - end -end diff --git a/examples/benchmark/scalability.rb b/examples/benchmark/scalability.rb new file mode 100644 index 0000000..f83a6a9 --- /dev/null +++ b/examples/benchmark/scalability.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2024, by Samuel Williams. + +# gem install async-container +gem "async-container" + +require "async/clock" +require_relative "../../lib/async/container" + +def fibonacci(n) + if n < 2 + return n + else + return fibonacci(n-1) + fibonacci(n-2) + end +end + +require "sqlite3" + +def work(*) + 512.times do + File.read("/dev/zero", 1024*1024).bytesize + end +end + +def measure_work(container, **options, &block) + duration = Async::Clock.measure do + container.run(**options, &block) + container.wait + end + + puts "Duration for #{container.class}: #{duration}" +end + +threaded = Async::Container::Threaded.new +measure_work(threaded, count: 32, &self.method(:work)) + +forked = Async::Container::Forked.new +measure_work(forked, count: 32, &self.method(:work)) + +hybrid = Async::Container::Hybrid.new +measure_work(hybrid, count: 32, &self.method(:work)) diff --git a/examples/channel.rb b/examples/channel.rb index 1510c58..8847fd7 100644 --- a/examples/channel.rb +++ b/examples/channel.rb @@ -1,5 +1,10 @@ +# frozen_string_literal: true -require 'json' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. + +require "json" class Channel def initialize @@ -12,7 +17,7 @@ def after_fork def receive if data = @in.gets - return JSON.parse(data, symbolize_names: true) + JSON.parse(data, symbolize_names: true) end end diff --git a/examples/channels/client.rb b/examples/channels/client.rb deleted file mode 100644 index b1c78b3..0000000 --- a/examples/channels/client.rb +++ /dev/null @@ -1,103 +0,0 @@ - -require 'msgpack' -require 'async/io' -require 'async/io/stream' -require 'async/container' - -# class Bus -# def initialize -# -# end -# -# def << object -# return :object -# end -# -# def [] key -# return -# end -# -# class Proxy < BasicObject -# def initialize(bus, name) -# @bus = bus -# @name = name -# end -# -# def inspect -# "[Proxy #{method_missing(:inspect)}]" -# end -# -# def method_missing(*args, &block) -# @bus.invoke(@name, args, &block) -# end -# -# def respond_to?(*args) -# @bus.invoke(@name, ["respond_to?", *args]) -# end -# end -# -# class Wrapper < MessagePack::Factory -# def initialize(bus) -# super() -# -# self.register_type(0x00, Object, -# packer: @bus.method(:<<), -# unpacker: @bus.method(:[]) -# ) -# -# self.register_type(0x01, Symbol) -# self.register_type(0x02, Exception, -# packer: ->(exception){Marshal.dump(exception)}, -# unpacker: ->(data){Marshal.load(data)}, -# ) -# -# self.register_type(0x03, Class, -# packer: ->(klass){Marshal.dump(klass)}, -# unpacker: ->(data){Marshal.load(data)}, -# ) -# end -# end -# -# class Channel -# def self.pipe -# input, output = Async::IO.pipe -# -# -# end -# -# def initialize(input, output) -# @input = input -# @output = output -# end -# -# def read -# @input.read -# end -# -# def write -# end -# end - -container = Async::Container.new -input, output = Async::IO.pipe - -container.async do |instance| - stream = Async::IO::Stream.new(input) - output.close - - while message = stream.gets - puts "Hello World from #{instance}: #{message}" - end - - puts "exiting" -end - -stream = Async::IO::Stream.new(output) - -5.times do |i| - stream.puts "#{i}" -end - -stream.close - -container.wait diff --git a/examples/container.rb b/examples/container.rb index 49c67e5..55e9217 100755 --- a/examples/container.rb +++ b/examples/container.rb @@ -1,32 +1,27 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -require '../lib/async/container/controller' -require '../lib/async/container/forked' +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. +# Copyright, 2019, by Yuji Yaginuma. +# Copyright, 2022, by Anton Sozontov. -Async.logger.debug! +require "../lib/async/container" -Async.logger.debug(self, "Starting up...") +Console.logger.debug! -controller = Async::Container::Controller.new do |container| - Async.logger.debug(self, "Setting up container...") - - container.run(count: 1, restart: true) do - Async.logger.debug(self, "Child process started.") - - while true - sleep 1 - - if rand < 0.1 - exit(1) - end - end - ensure - Async.logger.debug(self, "Child process exiting:", $!) +container = Async::Container.new + +Console.debug "Spawning 2 containers..." + +2.times do + container.spawn do |task| + Console.debug task, "Sleeping..." + sleep(2) + Console.debug task, "Waking up!" end end -begin - controller.run -ensure - Async.logger.debug(controller, "Parent process exiting:", $!) -end +Console.debug "Waiting for container..." +container.wait +Console.debug "Finished." diff --git a/examples/controller.rb b/examples/controller.rb new file mode 100755 index 0000000..427b3c7 --- /dev/null +++ b/examples/controller.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022, by Anton Sozontov. +# Copyright, 2024, by Samuel Williams. + +require "../lib/async/container/controller" + +class Controller < Async::Container::Controller + def setup(container) + container.run(count: 1, restart: true) do |instance| + if container.statistics.failed? + Console.debug(self, "Child process restarted #{container.statistics.restarts} times.") + else + Console.debug(self, "Child process started.") + end + + instance.ready! + + while true + sleep 1 + + Console.debug(self, "Work") + + if rand < 0.5 + Console.debug(self, "Should exit...") + sleep 0.5 + exit(1) + end + end + end + end +end + +Console.logger.debug! + +Console.debug(self, "Starting up...") + +controller = Controller.new + +controller.run diff --git a/examples/exec-child/jobs b/examples/exec-child/jobs new file mode 100755 index 0000000..ca988b6 --- /dev/null +++ b/examples/exec-child/jobs @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "console" +require "async/container/notify" + +# Console.logger.debug! + +class Jobs + def self.start = self.new.start + + def start + Console.info(self, "Starting jobs...") + + if notify = Async::Container::Notify.open! + Console.info(self, "Notifying container ready...") + notify.ready! + end + + loop do + Console.info(self, "Jobs running...") + + sleep 10 + end + rescue Interrupt + Console.info(self, "Exiting jobs...") + end +end + +Jobs.start diff --git a/examples/exec-child/readme.md b/examples/exec-child/readme.md new file mode 100644 index 0000000..0916b6c --- /dev/null +++ b/examples/exec-child/readme.md @@ -0,0 +1,69 @@ +# Exec Child Example + +This example demonstrates how to execute a child process using the `exec` function in a container. + +## Usage + +Start the main controller: + +``` +> bundle exec ./start + 0.0s info: AppController [oid=0x938] [ec=0x94c] [pid=96758] [2024-12-12 14:33:45 +1300] + | Controller starting... + 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] + | Starting jobs... + 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] + | Notifying container ready... + 0.65s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:45 +1300] + | Jobs running... + 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] + | Starting web... + 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] + | Notifying container ready... + 0.65s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:45 +1300] + | Web running... + 0.09s info: AppController [oid=0x938] [ec=0x94c] [pid=96758] [2024-12-12 14:33:45 +1300] + | Controller started... +``` + +In another terminal: `kill -HUP 96758` to cause a blue-green restart, which causes a new container to be started with new jobs and web processes: + +``` + 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] + | Starting jobs... + 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] + | Starting web... + 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] + | Notifying container ready... + 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] + | Notifying container ready... + 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:33:54 +1300] + | Jobs running... + 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:33:54 +1300] + | Web running... +``` + +Once the new container is running and the child processes have notified they are ready, the controller will stop the old container: + +``` + 9.01s info: Async::Container::Group [oid=0xa00] [ec=0x94c] [pid=96758] [2024-12-12 14:33:54 +1300] + | Stopping all processes... + | { + | "timeout": true + | } + 9.01s info: Async::Container::Group [oid=0xa00] [ec=0x94c] [pid=96758] [2024-12-12 14:33:54 +1300] + | Sending interrupt to 2 running processes... + 9.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96760] [2024-12-12 14:33:54 +1300] + | Exiting web... + 9.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96763] [2024-12-12 14:33:54 +1300] + | Exiting jobs... +``` + +The new container continues to run as expected: + +``` +19.57s info: Web [oid=0x8e8] [ec=0x8fc] [pid=96833] [2024-12-12 14:34:04 +1300] + | Web running... +19.57s info: Jobs [oid=0x8e8] [ec=0x8fc] [pid=96836] [2024-12-12 14:34:04 +1300] + | Jobs running... +``` diff --git a/examples/exec-child/start b/examples/exec-child/start new file mode 100755 index 0000000..2ff73fa --- /dev/null +++ b/examples/exec-child/start @@ -0,0 +1,24 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "async/container" +require "console" + +# Console.logger.debug! + +class AppController < Async::Container::Controller + def setup(container) + container.spawn(name: "Web") do |instance| + # Specify ready: false here as the child process is expected to take care of the readiness notification: + instance.exec("bundle", "exec", "web", ready: false) + end + + container.spawn(name: "Jobs") do |instance| + instance.exec("bundle", "exec", "jobs", ready: false) + end + end +end + +controller = AppController.new + +controller.run diff --git a/examples/exec-child/web b/examples/exec-child/web new file mode 100755 index 0000000..2cdf9e9 --- /dev/null +++ b/examples/exec-child/web @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "console" +require "async/container/notify" + +# Console.logger.debug! + +class Web + def self.start = self.new.start + + def start + Console.info(self, "Starting web...") + + if notify = Async::Container::Notify.open! + Console.info(self, "Notifying container ready...") + notify.ready! + end + + loop do + Console.info(self, "Web running...") + + sleep 10 + end + rescue Interrupt + Console.info(self, "Exiting web...") + end +end + +Web.start diff --git a/examples/fan-out/pipe.rb b/examples/fan-out/pipe.rb new file mode 100755 index 0000000..9fb3537 --- /dev/null +++ b/examples/fan-out/pipe.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require "async/container" + +container = Async::Container.new +input, output = IO.pipe + +container.async do |instance| + output.close + + while message = input.gets + puts "Hello World from #{instance}: #{message}" + end + + puts "exiting" +end + +5.times do |i| + output.puts "#{i}" +end + +output.close + +container.wait diff --git a/examples/grace/server.rb b/examples/grace/server.rb new file mode 100755 index 0000000..4a9469e --- /dev/null +++ b/examples/grace/server.rb @@ -0,0 +1,72 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require "../../lib/async/container" +require "io/endpoint/host_endpoint" + +Console.logger.debug! + +module SignalWrapper + def self.trap(signal, &block) + signal = signal + + original = Signal.trap(signal) do + ::Signal.trap(signal, original) + block.call + end + end +end + +class Controller < Async::Container::Controller + def initialize(...) + super + + @endpoint = ::IO::Endpoint.tcp("localhost", 8080) + @bound_endpoint = nil + end + + def start + Console.debug(self) {"Binding to #{@endpoint}"} + @bound_endpoint = Sync{@endpoint.bound} + + super + end + + def setup(container) + container.run count: 2, restart: true do |instance| + SignalWrapper.trap(:INT) do + Console.debug(self) {"Closing bound instance..."} + @bound_endpoint.close + end + + Sync do |task| + Console.info(self) {"Starting bound instance..."} + + instance.ready! + + @bound_endpoint.accept do |peer| + while true + peer.write("#{Time.now.to_s.rjust(32)}: Hello World\n") + sleep 1 + end + end + end + end + end + + def stop(graceful = true) + super + + if @bound_endpoint + @bound_endpoint.close + @bound_endpoint = nil + end + end +end + +controller = Controller.new + +controller.run diff --git a/examples/health_check/test.rb b/examples/health_check/test.rb new file mode 100755 index 0000000..70c6bbe --- /dev/null +++ b/examples/health_check/test.rb @@ -0,0 +1,40 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022, by Anton Sozontov. +# Copyright, 2024, by Samuel Williams. + +require "metrics" +require_relative "../../lib/async/container/controller" + +NAMES = [ + "Cupcake", "Donut", "Eclair", "Froyo", "Gingerbread", "Honeycomb", "Ice Cream Sandwich", "Jelly Bean", "KitKat", "Lollipop", "Marshmallow", "Nougat", "Oreo", "Pie", "Apple Tart" +] + +class Controller < Async::Container::Controller + def setup(container) + container.run(count: 10, restart: true, health_check_timeout: 1) do |instance| + if container.statistics.failed? + Console.debug(self, "Child process restarted #{container.statistics.restarts} times.") + else + Console.debug(self, "Child process started.") + end + + instance.name = NAMES.sample + + instance.ready! + + while true + # Must update status more frequently than health check timeout... + sleep(rand*1.2) + + instance.ready! + end + end + end +end + +controller = Controller.new # (container_class: Async::Container::Threaded) + +controller.run diff --git a/examples/http/client.rb b/examples/http/client.rb new file mode 100755 index 0000000..07511a5 --- /dev/null +++ b/examples/http/client.rb @@ -0,0 +1,20 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2024, by Samuel Williams. + +require "async" +require "async/http/endpoint" +require "async/http/client" + +endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292") + +Async do + client = Async::HTTP::Client.new(endpoint) + + response = client.get("/") + puts response.read +ensure + client&.close +end diff --git a/examples/http/server.rb b/examples/http/server.rb new file mode 100755 index 0000000..07c785d --- /dev/null +++ b/examples/http/server.rb @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2022-2024, by Samuel Williams. + +require "async/container" + +require "async/http/endpoint" +require "async/http/server" + +container = Async::Container::Forked.new + +endpoint = Async::HTTP::Endpoint.parse("http://localhost:9292") +bound_endpoint = Sync{endpoint.bound} + +Console.info(endpoint) {"Bound to #{bound_endpoint.inspect}"} + +GC.start +GC.compact if GC.respond_to?(:compact) + +container.run(count: 16, restart: true) do + Async do |task| + server = Async::HTTP::Server.for(bound_endpoint, protocol: endpoint.protocol, scheme: endpoint.scheme) do |request| + Protocol::HTTP::Response[200, {}, ["Hello World"]] + end + + Console.info(server) {"Starting server..."} + + server.run + + task.children.each(&:wait) + end +end + +container.wait diff --git a/examples/isolate.rb b/examples/isolate.rb deleted file mode 100644 index 084e459..0000000 --- a/examples/isolate.rb +++ /dev/null @@ -1,35 +0,0 @@ - -# We define end of life-cycle in terms of "Interrupt" (SIGINT), "Terminate" (SIGTERM) and "Kill" (SIGKILL, does not invoke user code). -class Terminate < Interrupt -end - -class Isolate - def initialize(&block) - - end -end - - -parent = Isolate.new do |parent| - preload_user_code - server = bind_socket - children = 4.times.map do - Isolate.new do |worker| - app = load_user_application - worker.ready! - server.accept do |peer| - app.handle_request(peer) - end - end - end - while status = parent.wait - # Status is not just exit status of process but also can be `:ready` or something else. - end -end - -# Similar to Process.wait(pid) -status = parent.wait -# Life cycle controls -parent.interrupt! -parent.terminate! -parent.kill! diff --git a/examples/minimal.rb b/examples/minimal.rb index 28b4f5e..b4d921f 100644 --- a/examples/minimal.rb +++ b/examples/minimal.rb @@ -1,3 +1,8 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. class Threaded def initialize(&block) @@ -38,7 +43,7 @@ def wait @waiter = nil end - return @status + @status end protected @@ -55,8 +60,8 @@ def initialize(&block) @status = nil @pid = Process.fork do - Signal.trap(:INT) {raise Interrupt} - Signal.trap(:INT) {raise Terminate} + Signal.trap(:INT) {::Thread.current.raise(Interrupt)} + Signal.trap(:TERM) {::Thread.current.raise(Terminate)} @channel.in.close @@ -85,9 +90,9 @@ def terminate! def wait unless @status - pid, @status = ::Process.wait(@pid) + _pid, @status = ::Process.wait(@pid) end - return @status + @status end end diff --git a/examples/puma/application.rb b/examples/puma/application.rb new file mode 100755 index 0000000..3c6e30f --- /dev/null +++ b/examples/puma/application.rb @@ -0,0 +1,46 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require "async/container" +require "console" + +require "io/endpoint/host_endpoint" +require "io/endpoint/bound_endpoint" + +# Console.logger.debug! + +class Application < Async::Container::Controller + def endpoint + IO::Endpoint.tcp("0.0.0.0", 9292) + end + + def bound_socket + bound = endpoint.bound + + bound.sockets.each do |socket| + socket.listen(Socket::SOMAXCONN) + end + + return bound + end + + def setup(container) + @bound = bound_socket + + container.spawn(name: "Web", restart: true) do |instance| + env = ENV.to_h + + @bound.sockets.each_with_index do |socket, index| + env["PUMA_INHERIT_#{index}"] = "#{socket.fileno}:tcp://0.0.0.0:9292" + end + + instance.exec(env, "bundle", "exec", "puma", "-C", "puma.rb", ready: false) + end + end +end + +application = Application.new +application.run diff --git a/examples/puma/config.ru b/examples/puma/config.ru new file mode 100644 index 0000000..acb0a83 --- /dev/null +++ b/examples/puma/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +run do |env| + [200, {"content-type" => "text/plain"}, ["Hello World #{Time.now}"]] +end diff --git a/examples/puma/gems.rb b/examples/puma/gems.rb new file mode 100644 index 0000000..604ae72 --- /dev/null +++ b/examples/puma/gems.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +source "https://rubygems.org" + +gem "async-container", path: "../.." +gem "io-endpoint" + +gem "puma" +gem "rack", "~> 3" diff --git a/examples/puma/puma.rb b/examples/puma/puma.rb new file mode 100644 index 0000000..cf79085 --- /dev/null +++ b/examples/puma/puma.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +on_booted do + require "async/container/notify" + + notify = Async::Container::Notify.open! + notify&.ready! +end diff --git a/examples/puma/readme.md b/examples/puma/readme.md new file mode 100644 index 0000000..c9e1648 --- /dev/null +++ b/examples/puma/readme.md @@ -0,0 +1,36 @@ +# Puma Example + +This example shows how to start Puma in a container, using `on_boot` for process readiness. + +## Usage + +``` +> bundle exec ./application.rb + 0.0s info: Async::Container::Notify::Console [oid=0x474] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] + | {:status=>"Initializing..."} + 0.0s info: Application [oid=0x4b0] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] + | Controller starting... +Puma starting in single mode... +* Puma version: 6.5.0 ("Sky's Version") +* Ruby version: ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [x86_64-linux] +* Min threads: 0 +* Max threads: 5 +* Environment: development +* PID: 196252 +* Listening on http://0.0.0.0:9292 +Use Ctrl-C to stop + 0.12s info: Async::Container::Notify::Console [oid=0x474] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] + | {:ready=>true} + 0.12s info: Application [oid=0x4b0] [ec=0x488] [pid=196250] [2024-12-22 16:53:08 +1300] + | Controller started... +^C21.62s info: Async::Container::Group [oid=0x4ec] [ec=0x488] [pid=196250] [2024-12-22 16:53:30 +1300] + | Stopping all processes... + | { + | "timeout": true + | } +21.62s info: Async::Container::Group [oid=0x4ec] [ec=0x488] [pid=196250] [2024-12-22 16:53:30 +1300] + | Sending interrupt to 1 running processes... +- Gracefully stopping, waiting for requests to finish +=== puma shutdown: 2024-12-22 16:53:30 +1300 === +- Goodbye! +``` diff --git a/examples/queue/server.rb b/examples/queue/server.rb new file mode 100755 index 0000000..1074da1 --- /dev/null +++ b/examples/queue/server.rb @@ -0,0 +1,104 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require "async" +require "async/container" +require "io/endpoint" +require "io/endpoint/unix_endpoint" +require "msgpack" + +class Wrapper < MessagePack::Factory + def initialize + super() + + # self.register_type(0x00, Object, packer: @bus.method(:temporary), unpacker: @bus.method(:[])) + + self.register_type(0x01, Symbol) + self.register_type(0x02, Exception, + packer: ->(exception){Marshal.dump(exception)}, + unpacker: ->(data){Marshal.load(data)}, + ) + + self.register_type(0x03, Class, + packer: ->(klass){Marshal.dump(klass)}, + unpacker: ->(data){Marshal.load(data)}, + ) + end +end + +endpoint = IO::Endpoint.unix("test.ipc") +bound_endpoint = endpoint.bound + +wrapper = Wrapper.new + +container = Async::Container.new + +container.spawn do |instance| + Async do + queue = 500_000.times.to_a + Console.info(self) {"Hosting the queue..."} + + instance.ready! + + bound_endpoint.accept do |peer| + Console.info(self) {"Incoming connection from #{peer}..."} + + packer = wrapper.packer(peer) + unpacker = wrapper.unpacker(peer) + + unpacker.each do |message| + command, *arguments = message + + case command + when :ready + if job = queue.pop + packer.write([:job, job]) + packer.flush + else + peer.close_write + break + end + when :status + Console.info("Job Status") {arguments} + else + Console.warn(self) {"Unhandled command: #{command}#{arguments.inspect}"} + end + end + end + end +end + +container.run do |instance| + Async do |task| + endpoint.connect do |peer| + instance.ready! + + packer = wrapper.packer(peer) + unpacker = wrapper.unpacker(peer) + + packer.write([:ready]) + packer.flush + + unpacker.each do |message| + command, *arguments = message + + case command + when :job + # task.sleep(*arguments) + packer.write([:status, *arguments]) + packer.write([:ready]) + packer.flush + else + Console.warn(self) {"Unhandled command: #{command}#{arguments.inspect}"} + end + end + end + end +end + +container.wait + +Console.info(self) {"Done!"} diff --git a/examples/test.rb b/examples/test.rb deleted file mode 100644 index d72686c..0000000 --- a/examples/test.rb +++ /dev/null @@ -1,50 +0,0 @@ - -require_relative 'group' -require_relative 'thread' -require_relative 'process' - -group = Async::Container::Group.new - -thread_monitor = Fiber.new do - while true - thread = Async::Container::Thread.fork do |instance| - if rand < 0.2 - raise "Random Failure!" - end - - instance.send(ready: true, status: "Started Thread") - - sleep(1) - end - - status = group.wait_for(thread) do |message| - puts "Thread message: #{message}" - end - - puts "Thread status: #{status}" - end -end.resume - -process_monitor = Fiber.new do - while true - # process = Async::Container::Process.fork do |instance| - # if rand < 0.2 - # raise "Random Failure!" - # end - # - # instance.send(ready: true, status: "Started Process") - # - # sleep(1) - # end - - process = Async::Container::Process.spawn('bash -c "sleep 1; echo foobar; sleep 1; exit -1"') - - status = group.wait_for(process) do |message| - puts "Process message: #{message}" - end - - puts "Process status: #{status}" - end -end.resume - -group.wait diff --git a/examples/threads.rb b/examples/threads.rb deleted file mode 100755 index a3b13e1..0000000 --- a/examples/threads.rb +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env ruby - -puts "Process pid: #{Process.pid}" - -threads = 10.times.collect do - Thread.new do - begin - sleep - rescue Exception - puts "Thread: #{$!}" - end - end -end - -while true - begin - threads.each(&:join) - exit(0) - rescue Exception - puts "Join: #{$!}" - end -end - -puts "Done" diff --git a/examples/title.rb b/examples/title.rb deleted file mode 100755 index 47875a0..0000000 --- a/examples/title.rb +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env ruby - -Process.setproctitle "Preparing for sleep..." - -10.times do |i| - puts "Counting sheep #{i}" - Process.setproctitle "Counting sheep #{i}" - - sleep 10 -end - -puts "Zzzzzzz" diff --git a/examples/udppipe.rb b/examples/udppipe.rb deleted file mode 100644 index 3d096b9..0000000 --- a/examples/udppipe.rb +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env ruby - -require 'async/io' -require 'async/io/endpoint' -require 'async/io/unix_endpoint' - -@endpoint = Async::IO::Endpoint.unix("/tmp/notify-test.sock", Socket::SOCK_DGRAM) -# address = Async::IO::Address.udp("127.0.0.1", 6778) -# @endpoint = Async::IO::AddressEndpoint.new(address) - -def server - @endpoint.bind do |server| - puts "Receiving..." - packet, address = server.recvfrom(512) - - puts "Received: #{packet} from #{address}" - end -end - -def client(data = "Hello World!") - @endpoint.connect do |peer| - puts "Sending: #{data}" - peer.send(data) - puts "Sent!" - end -end - -Async do |task| - server_task = task.async do - server - end - - client -end diff --git a/fixtures/async/container/a_container.rb b/fixtures/async/container/a_container.rb new file mode 100644 index 0000000..461ab8e --- /dev/null +++ b/fixtures/async/container/a_container.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. + +require "async" + +module Async + module Container + AContainer = Sus::Shared("a container") do + let(:container) {subject.new} + + with "#run" do + it "can run several instances concurrently" do + container.run do + sleep(1) + end + + expect(container).to be(:running?) + + container.stop(true) + + expect(container).not.to be(:running?) + end + + it "can stop an uncooperative child process" do + container.run do + while true + begin + sleep(1) + rescue Interrupt + # Ignore. + end + end + end + + expect(container).to be(:running?) + + # TODO Investigate why without this, the interrupt can occur before the process is sleeping... + sleep 0.001 + + container.stop(true) + + expect(container).not.to be(:running?) + end + end + + with "#async" do + it "can run concurrently" do + input, output = IO.pipe + + container.async do + output.write "Hello World" + end + + container.wait + + output.close + expect(input.read).to be == "Hello World" + end + + it "can run concurrently" do + container.async(name: "Sleepy Jerry") do |task, instance| + 3.times do |i| + instance.name = "Counting Sheep #{i}" + + sleep 0.01 + end + end + + container.wait + end + end + + it "should be blocking" do + skip "Fiber.blocking? is not supported!" unless Fiber.respond_to?(:blocking?) + + input, output = IO.pipe + + container.spawn do + output.write(Fiber.blocking? != false) + end + + container.wait + + output.close + expect(input.read).to be == "true" + end + + with "instance" do + it "can generate JSON representation" do + IO.pipe do |input, output| + container.spawn do |instance| + output.write(instance.to_json) + end + + container.wait + + expect(container.statistics).to have_attributes(failures: be == 0) + + output.close + instance = JSON.parse(input.read, symbolize_names: true) + expect(instance).to have_keys( + process_id: be_a(Integer), + name: be_a(String), + ) + end + end + end + + with "#sleep" do + it "can sleep for a short time" do + container.spawn do + sleep(0.01) + raise "Boom" + end + + expect(container.statistics).to have_attributes(failures: be == 0) + + container.wait + + expect(container.statistics).to have_attributes(failures: be == 1) + end + end + + with "#stop" do + it "can gracefully stop the child process" do + container.spawn do + sleep(1) + rescue Interrupt + # Ignore. + end + + expect(container).to be(:running?) + + # See above. + sleep 0.001 + + container.stop(true) + + expect(container).not.to be(:running?) + end + + it "can forcefully stop the child process" do + container.spawn do + sleep(1) + rescue Interrupt + # Ignore. + end + + expect(container).to be(:running?) + + # See above. + sleep 0.001 + + container.stop(false) + + expect(container).not.to be(:running?) + end + + it "can stop an uncooperative child process" do + container.spawn do + while true + begin + sleep(1) + rescue Interrupt + # Ignore. + end + end + end + + expect(container).to be(:running?) + + # See above. + sleep 0.001 + + container.stop(true) + + expect(container).not.to be(:running?) + end + end + + with "#ready" do + it "can notify the ready pipe in an asynchronous context" do + container.run do |instance| + Async do + instance.ready! + end + end + + expect(container).to be(:running?) + + container.wait + + container.stop + + expect(container).not.to be(:running?) + end + end + + with "health_check_timeout:" do + let(:container) {subject.new(health_check_interval: 1.0)} + + it "should not terminate a child process if it updates its state within the specified time" do + # We use #run here to hit the Hybrid container code path: + container.run(count: 1, health_check_timeout: 1.0) do |instance| + instance.ready! + + 10.times do + instance.ready! + sleep(0.5) + end + end + + container.wait + + expect(container.statistics).to have_attributes(failures: be == 0) + end + + it "can terminate a child process if it does not update its state within the specified time" do + container.spawn(health_check_timeout: 1.0) do |instance| + instance.ready! + + # This should trigger the health check - since restart is false, the process will be terminated: + sleep + end + + container.wait + + expect(container.statistics).to have_attributes(failures: be > 0) + end + + it "can kill a child process even if it ignores exceptions/signals" do + container.spawn(health_check_timeout: 1.0) do |instance| + while true + begin + sleep 1 + rescue Exception => error + # Ignore. + end + end + end + + container.wait + + expect(container.statistics).to have_attributes(failures: be > 0) + end + end + end + end +end diff --git a/fixtures/async/container/controllers.rb b/fixtures/async/container/controllers.rb new file mode 100644 index 0000000..170ff66 --- /dev/null +++ b/fixtures/async/container/controllers.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +module Async + module Container + module Controllers + ROOT = File.join(__dir__, "controllers") + + def self.path_for(controller) + File.join(ROOT, "#{controller}.rb") + end + end + end +end diff --git a/fixtures/async/container/controllers/bad.rb b/fixtures/async/container/controllers/bad.rb new file mode 100755 index 0000000..60f1673 --- /dev/null +++ b/fixtures/async/container/controllers/bad.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "../../../../lib/async/container/controller" + +$stdout.sync = true + +class Bad < Async::Container::Controller + def setup(container) + container.run(name: "bad", count: 1, restart: true) do |instance| + # Deliberately missing call to `instance.ready!`: + # instance.ready! + + $stdout.puts "Ready..." + + sleep + ensure + $stdout.puts "Exiting..." + end + end +end + +controller = Bad.new + +controller.run diff --git a/fixtures/async/container/controllers/dots.rb b/fixtures/async/container/controllers/dots.rb new file mode 100755 index 0000000..bc01c6d --- /dev/null +++ b/fixtures/async/container/controllers/dots.rb @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. + +require_relative "../../../../lib/async/container/controller" + +$stdout.sync = true + +class Dots < Async::Container::Controller + def setup(container) + container.run(name: "dots", count: 1, restart: true) do |instance| + instance.ready! + + # This is to avoid race conditions in the controller in test conditions. + sleep 0.001 + + $stdout.write "." + + sleep + rescue Async::Container::Interrupt + $stdout.write("I") + rescue Async::Container::Terminate + $stdout.write("T") + end + end +end + +controller = Dots.new + +controller.run diff --git a/fixtures/async/container/controllers/graceful.rb b/fixtures/async/container/controllers/graceful.rb new file mode 100755 index 0000000..cc1ea9e --- /dev/null +++ b/fixtures/async/container/controllers/graceful.rb @@ -0,0 +1,45 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024-2025, by Samuel Williams. + +require_relative "../../../../lib/async/container/controller" + +$stdout.sync = true + +class Graceful < Async::Container::Controller + def setup(container) + container.run(name: "graceful", count: 1, restart: true) do |instance| + instance.ready! + + # This is to avoid race conditions in the controller in test conditions. + sleep 0.001 + + clock = Async::Clock.start + + original_action = Signal.trap(:INT) do + # We ignore the int, but in practical applications you would want start a graceful shutdown. + $stdout.puts "Graceful shutdown...", clock.total + + Signal.trap(:INT, original_action) + end + + $stdout.puts "Ready...", clock.total + + sleep + ensure + $stdout.puts "Exiting...", clock.total + end + end +end + +controller = Graceful.new(graceful_stop: 0.01) + +begin + controller.run +rescue Async::Container::Terminate + $stdout.puts "Terminated..." +rescue Interrupt + $stdout.puts "Interrupted..." +end diff --git a/spec/async/container/notify/notify.rb b/fixtures/async/container/controllers/notify.rb similarity index 54% rename from spec/async/container/notify/notify.rb rename to fixtures/async/container/controllers/notify.rb index 5eb9ea1..4a692aa 100755 --- a/spec/async/container/notify/notify.rb +++ b/fixtures/async/container/controllers/notify.rb @@ -1,15 +1,19 @@ #!/usr/bin/env ruby +# frozen_string_literal: true -require_relative '../../../../lib/async/container' +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. + +require_relative "../../../../lib/async/container" class MyController < Async::Container::Controller def setup(container) container.run(restart: false) do |instance| - sleep(rand(1..8)) + sleep(0.001) instance.ready! - sleep(1) + sleep(0.001) end end end diff --git a/fixtures/async/container/controllers/working_directory.rb b/fixtures/async/container/controllers/working_directory.rb new file mode 100755 index 0000000..72fcb43 --- /dev/null +++ b/fixtures/async/container/controllers/working_directory.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2024, by Samuel Williams. + +require_relative "../../../../lib/async/container/controller" + +$stdout.sync = true + +class Pwd < Async::Container::Controller + def setup(container) + container.spawn do |instance| + instance.ready! + + instance.exec("pwd", chdir: "/") + end + end +end + +controller = Pwd.new + +controller.run diff --git a/gems.rb b/gems.rb new file mode 100644 index 0000000..086b282 --- /dev/null +++ b/gems.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2024, by Samuel Williams. + +source "https://rubygems.org" + +gemspec + +group :maintenance, optional: true do + gem "bake-gem" + gem "bake-modernize" + gem "bake-releases" + + gem "utopia-project" +end + +group :test do + gem "sus" + gem "covered" + gem "decode" + gem "rubocop" + + gem "metrics" + + gem "bake-test" + gem "bake-test-external" +end diff --git a/gems/async-head.rb b/gems/async-head.rb new file mode 100644 index 0000000..164cd80 --- /dev/null +++ b/gems/async-head.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2021-2024, by Samuel Williams. + +source "https://rubygems.org" + +eval_gemfile "../gems.rb" + +gem "async", git: "https://github.com/socketry/async" diff --git a/gems/async-v1.rb b/gems/async-v1.rb new file mode 100644 index 0000000..ad2b8d8 --- /dev/null +++ b/gems/async-v1.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2021-2024, by Samuel Williams. + +source "https://rubygems.org" + +eval_gemfile "../gems.rb" + +gem "async", "~> 1.0" diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md new file mode 100644 index 0000000..7154ceb --- /dev/null +++ b/guides/getting-started/readme.md @@ -0,0 +1,109 @@ +# Getting Started + +This guide explains how to use `async-container` to build basic scalable systems. + +## Installation + +Add the gem to your project: + +~~~ bash +$ bundle add async-container +~~~ + +## Core Concepts + +`async-container` has several core concepts: + +- {ruby Async::Container::Forked} and {ruby Async::Container::Threaded} are used to manage one or more child processes and threads respectively for parallel execution. While threads share the address space which can reduce overall memory usage, processes have better isolation and fault tolerance. +- {ruby Async::Container::Controller} manages one or more containers and handles graceful restarts. Containers should be implemented in such a way that multiple containers can be running at the same time. + +## Containers + +A container represents a set of child processes (or threads) which are doing work for you. + +``` ruby +require 'async/container' + +Console.logger.debug! + +container = Async::Container.new + +container.spawn do |task| + Console.debug task, "Sleeping..." + sleep(1) + Console.debug task, "Waking up!" +end + +Console.debug "Waiting for container..." +container.wait +Console.debug "Finished." +``` + +## Controllers + +The controller provides the life-cycle management for one or more containers of processes. It provides behaviour like starting, restarting, reloading and stopping. You can see some [example implementations in Falcon](https://github.com/socketry/falcon/blob/master/lib/falcon/controller/). If the process running the controller receives `SIGHUP` it will recreate the container gracefully. + +``` ruby +require 'async/container' + +Console.logger.debug! + +class Controller < Async::Container::Controller + def create_container + Async::Container::Forked.new + # or Async::Container::Threaded.new + # or Async::Container::Hybrid.new + end + + def setup(container) + container.run count: 2, restart: true do |instance| + while true + Console.debug(instance, "Sleeping...") + sleep(1) + end + end + end +end + +controller = Controller.new + +controller.run + +# If you send SIGHUP to this process, it will recreate the container. +``` + +## Signal Handling + +`SIGINT` is the reload signal. You may send this to a program to request that it reload its configuration. The default behavior is to gracefully reload the container. + +`SIGINT` is the interrupt signal. The terminal sends it to the foreground process when the user presses **ctrl-c**. The default behavior is to terminate the process, but it can be caught or ignored. The intention is to provide a mechanism for an orderly, graceful shutdown. + +`SIGQUIT` is the dump core signal. The terminal sends it to the foreground process when the user presses **ctrl-\\**. The default behavior is to terminate the process and dump core, but it can be caught or ignored. The intention is to provide a mechanism for the user to abort the process. You can look at `SIGINT` as "user-initiated happy termination" and `SIGQUIT` as "user-initiated unhappy termination." + +`SIGTERM` is the termination signal. The default behavior is to terminate the process, but it also can be caught or ignored. The intention is to kill the process, gracefully or not, but to first allow it a chance to cleanup. + +`SIGKILL` is the kill signal. The only behavior is to kill the process, immediately. As the process cannot catch the signal, it cannot cleanup, and thus this is a signal of last resort. + +`SIGSTOP` is the pause signal. The only behavior is to pause the process; the signal cannot be caught or ignored. The shell uses pausing (and its counterpart, resuming via `SIGCONT`) to implement job control. + +## Integration + +### systemd + +Install a template file into `/etc/systemd/system/`: + +``` +# my-daemon.service +[Unit] +Description=My Daemon +AssertPathExists=/srv/ + +[Service] +Type=notify +WorkingDirectory=/srv/my-daemon +ExecStart=bundle exec my-daemon +Nice=5 + +[Install] +WantedBy=multi-user.target +``` diff --git a/lib/async/container.rb b/lib/async/container.rb index e3e1228..d95c64d 100644 --- a/lib/async/container.rb +++ b/lib/async/container.rb @@ -1,27 +1,13 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'container/controller' +# Released under the MIT License. +# Copyright, 2017-2024, by Samuel Williams. +require_relative "container/controller" + +# @namespace module Async - # Containers execute one or more "instances" which typically contain a reactor. A container spawns "instances" using threads and/or processes. Because these are resources that must be cleaned up some how (either by `join` or `waitpid`), their creation is deferred until the user invokes `Container#wait`. When executed this way, the container guarantees that all "instances" will be complete once `Container#wait` returns. Containers are constructs for achieving parallelism, and are not designed to be used directly for concurrency. Typically, you'd create one or more container, add some tasks to it, and then wait for it to complete. + # @namespace module Container end end diff --git a/lib/async/container/best.rb b/lib/async/container/best.rb index 4af94f0..31b059f 100644 --- a/lib/async/container/best.rb +++ b/lib/async/container/best.rb @@ -1,34 +1,23 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'forked' -require_relative 'threaded' -require_relative 'hybrid' +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. + +require_relative "forked" +require_relative "threaded" +require_relative "hybrid" module Async - # Containers execute one or more "instances" which typically contain a reactor. A container spawns "instances" using threads and/or processes. Because these are resources that must be cleaned up some how (either by `join` or `waitpid`), their creation is deferred until the user invokes `Container#wait`. When executed this way, the container guarantees that all "instances" will be complete once `Container#wait` returns. Containers are constructs for achieving parallelism, and are not designed to be used directly for concurrency. Typically, you'd create one or more container, add some tasks to it, and then wait for it to complete. module Container + # Whether the underlying process supports fork. + # @returns [Boolean] def self.fork? ::Process.respond_to?(:fork) && ::Process.respond_to?(:setpgid) end + # Determins the best container class based on the underlying Ruby implementation. + # Some platforms, including JRuby, don't support fork. Applications which just want a reasonable default can use this method. + # @returns [Class] def self.best_container_class if fork? return Forked @@ -37,8 +26,10 @@ def self.best_container_class end end - def self.new(*arguments) - best_container_class.new(*arguments) + # Create an instance of the best container class. + # @returns [Generic] Typically an instance of either {Forked} or {Threaded} containers. + def self.new(*arguments, **options) + best_container_class.new(*arguments, **options) end end end diff --git a/lib/async/container/channel.rb b/lib/async/container/channel.rb index 1428f43..2313110 100644 --- a/lib/async/container/channel.rb +++ b/lib/async/container/channel.rb @@ -1,48 +1,46 @@ -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require 'json' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require "json" module Async module Container + # Provides a basic multi-thread/multi-process uni-directional communication channel. class Channel + # Initialize the channel using a pipe. def initialize @in, @out = ::IO.pipe end + # The input end of the pipe. + # @attribute [IO] attr :in + + # The output end of the pipe. + # @attribute [IO] attr :out + # Close the input end of the pipe. def close_read @in.close end + # Close the output end of the pipe. def close_write @out.close end + # Close both ends of the pipe. def close close_read close_write end + # Receive an object from the pipe. + # Internally, prefers to receive newline formatted JSON, otherwise returns a hash table with a single key `:line` which contains the line of data that could not be parsed as JSON. + # @returns [Hash] def receive if data = @in.gets begin diff --git a/lib/async/container/controller.rb b/lib/async/container/controller.rb index d114571..c95f9f8 100644 --- a/lib/async/container/controller.rb +++ b/lib/async/container/controller.rb @@ -1,41 +1,18 @@ -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'error' -require_relative 'best' +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. -require_relative 'statistics' -require_relative 'notify' +require_relative "error" +require_relative "best" + +require_relative "statistics" +require_relative "notify" module Async module Container - class ContainerError < Error - def initialize(container) - super("Could not create container!") - @container = container - end - - attr :container - end - - # Manages the life-cycle of a container. + # Manages the life-cycle of one or more containers in order to support a persistent system. + # e.g. a web server, job server or some other long running system. class Controller SIGHUP = Signal.list["HUP"] SIGINT = Signal.list["INT"] @@ -43,18 +20,24 @@ class Controller SIGUSR1 = Signal.list["USR1"] SIGUSR2 = Signal.list["USR2"] - def initialize(notify: Notify.open!) + # Initialize the controller. + # @parameter notify [Notify::Client] A client used for process readiness notifications. + def initialize(notify: Notify.open!, container_class: Container, graceful_stop: true) @container = nil + @container_class = container_class - if @notify = notify - @notify.status!("Initializing...") - end - + @notify = notify @signals = {} - trap(SIGHUP, &self.method(:restart)) + self.trap(SIGHUP) do + self.restart + end + + @graceful_stop = graceful_stop end + # The state of the controller. + # @returns [String] def state_string if running? "running" @@ -63,147 +46,201 @@ def state_string end end + # A human readable representation of the controller. + # @returns [String] def to_s "#{self.class} #{state_string}" end + # Trap the specified signal. + # @parameters signal [Symbol] The signal to trap, e.g. `:INT`. + # @parameters block [Proc] The signal handler to invoke. def trap(signal, &block) @signals[signal] = block end + # The current container being managed by the controller. attr :container + # Create a container for the controller. + # Can be overridden by a sub-class. + # @returns [Generic] A specific container instance to use. def create_container - Container.new + @container_class.new end + # Whether the controller has a running container. + # @returns [Boolean] def running? !!@container end + # Wait for the underlying container to start. def wait @container&.wait end + # Spawn container instances into the given container. + # Should be overridden by a sub-class. + # @parameter container [Generic] The container, generally from {#create_container}. def setup(container) # Don't do this, otherwise calling super is risky for sub-classes: # raise NotImplementedError, "Container setup is must be implemented in derived class!" end + # Start the container unless it's already running. def start - self.restart unless @container + unless @container + Console.info(self) {"Controller starting..."} + self.restart + end + + Console.info(self) {"Controller started..."} end - def stop(graceful = true) + # Stop the container if it's running. + # @parameter graceful [Boolean] Whether to give the children instances time to shut down or to kill them immediately. + def stop(graceful = @graceful_stop) @container&.stop(graceful) @container = nil end + # Restart the container. A new container is created, and if successful, any old container is terminated gracefully. + # This is equivalent to a blue-green deployment. def restart if @container @notify&.restarting! - Async.logger.debug(self) {"Restarting container..."} + Console.debug(self) {"Restarting container..."} else - Async.logger.debug(self) {"Starting container..."} + Console.debug(self) {"Starting container..."} end container = self.create_container begin self.setup(container) - rescue - @notify&.error!($!.to_s) + rescue => error + @notify&.error!(error.to_s) - raise ContainerError, container + raise SetupError, container end # Wait for all child processes to enter the ready state. - Async.logger.debug(self, "Waiting for startup...") + Console.debug(self, "Waiting for startup...") container.wait_until_ready - Async.logger.debug(self, "Finished startup.") + Console.debug(self, "Finished startup.") if container.failed? - @notify&.error!($!.to_s) + @notify&.error!("Container failed to start!") - container.stop + container.stop(false) - raise ContainerError, container + raise SetupError, container end - # Make this swap as atomic as possible: + # The following swap should be atomic: old_container = @container @container = container + container = nil + + if old_container + Console.debug(self, "Stopping old container...") + old_container&.stop(@graceful_stop) + end - old_container&.stop - @notify&.ready! - rescue + @notify&.ready!(size: @container.size) + ensure # If we are leaving this function with an exception, try to kill the container: container&.stop(false) end + # Reload the existing container. Children instances will be reloaded using `SIGHUP`. def reload @notify&.reloading! - Async.logger.info(self) {"Reloading container: #{@container}..."} + Console.info(self) {"Reloading container: #{@container}..."} begin self.setup(@container) rescue - raise ContainerError, container + raise SetupError, container end # Wait for all child processes to enter the ready state. - Async.logger.debug(self, "Waiting for startup...") + Console.debug(self, "Waiting for startup...") + @container.wait_until_ready - Async.logger.debug(self, "Finished startup.") + + Console.debug(self, "Finished startup.") if @container.failed? - @notify.error!("Container failed!") + @notify.error!("Container failed to reload!") - raise ContainerError, @container + raise SetupError, @container else @notify&.ready! end end + # Enter the controller run loop, trapping `SIGINT` and `SIGTERM`. def run - # I thought this was the default... but it doesn't always raise an exception unless you do this explicitly. - interrupt_action = Signal.trap(:INT) do - raise Interrupt - end + @notify&.status!("Initializing controller...") - terminate_action = Signal.trap(:TERM) do - raise Terminate - end - - self.start - - while @container&.running? - begin - @container.wait - rescue SignalException => exception - if handler = @signals[exception.signo] - begin - handler.call - rescue ContainerError => failure - Async.logger.error(self) {failure} + with_signal_handlers do + self.start + + while @container&.running? + begin + @container.wait + rescue SignalException => exception + if handler = @signals[exception.signo] + begin + handler.call + rescue SetupError => error + Console.error(self, error) + end + else + raise end - else - raise end end end rescue Interrupt - self.stop(true) + self.stop rescue Terminate self.stop(false) - else - self.stop(true) + ensure + self.stop(false) + end + + private def with_signal_handlers + # I thought this was the default... but it doesn't always raise an exception unless you do this explicitly. + + interrupt_action = Signal.trap(:INT) do + # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. + # $stderr.puts "Received INT signal, interrupting...", caller + ::Thread.current.raise(Interrupt) + end + + terminate_action = Signal.trap(:TERM) do + # $stderr.puts "Received TERM signal, terminating...", caller + ::Thread.current.raise(Terminate) + end + + hangup_action = Signal.trap(:HUP) do + # $stderr.puts "Received HUP signal, restarting...", caller + ::Thread.current.raise(Restart) + end + + ::Thread.handle_interrupt(SignalException => :never) do + yield + end ensure # Restore the interrupt handler: Signal.trap(:INT, interrupt_action) Signal.trap(:TERM, terminate_action) + Signal.trap(:HUP, hangup_action) end end end diff --git a/lib/async/container/error.rb b/lib/async/container/error.rb index 65bd58a..0758cae 100644 --- a/lib/async/container/error.rb +++ b/lib/async/container/error.rb @@ -1,36 +1,49 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2025, by Samuel Williams. module Async module Container + # Represents an error that occured during container execution. class Error < StandardError end Interrupt = ::Interrupt + # Similar to {Interrupt}, but represents `SIGTERM`. class Terminate < SignalException - SIGTERM = Signal.list['TERM'] - + SIGTERM = Signal.list["TERM"] + + # Create a new terminate error. def initialize super(SIGTERM) end end + + # Similar to {Interrupt}, but represents `SIGHUP`. + class Restart < SignalException + SIGHUP = Signal.list["HUP"] + + # Create a new restart error. + def initialize + super(SIGHUP) + end + end + + # Represents the error which occured when a container failed to start up correctly. + class SetupError < Error + # Create a new setup error. + # + # @parameter container [Generic] The container that failed. + def initialize(container) + super("Could not create container!") + + @container = container + end + + # @attribute [Generic] The container that failed. + attr :container + end end end diff --git a/lib/async/container/forked.rb b/lib/async/container/forked.rb index 61be0ec..bc21551 100644 --- a/lib/async/container/forked.rb +++ b/lib/async/container/forked.rb @@ -1,36 +1,265 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'generic' -require_relative 'process' +# Released under the MIT License. +# Copyright, 2017-2024, by Samuel Williams. + +require_relative "error" + +require_relative "generic" +require_relative "channel" +require_relative "notify/pipe" module Async - # Manages a reactor within one or more threads. module Container + # A multi-process container which uses {Process.fork}. class Forked < Generic + # Indicates that this is a multi-process container. def self.multiprocess? true end + # Represents a running child process from the point of view of the parent container. + class Child < Channel + # Represents a running child process from the point of view of the child process. + class Instance < Notify::Pipe + # Wrap an instance around the {Process} instance from within the forked child. + # @parameter process [Process] The process intance to wrap. + def self.for(process) + instance = self.new(process.out) + + # The child process won't be reading from the channel: + process.close_read + + instance.name = process.name + + return instance + end + + # Initialize the child process instance. + # + # @parameter io [IO] The IO object to use for communication. + def initialize(io) + super + + @name = nil + end + + # Generate a hash representation of the process. + # + # @returns [Hash] The process as a hash, including `process_id` and `name`. + def as_json(...) + { + process_id: ::Process.pid, + name: @name, + } + end + + # Generate a JSON representation of the process. + # + # @returns [String] The process as JSON. + def to_json(...) + as_json.to_json(...) + end + + # Set the process title to the specified value. + # + # @parameter value [String] The name of the process. + def name= value + @name = value + + # This sets the process title to an empty string if the name is nil: + ::Process.setproctitle(@name.to_s) + end + + # @returns [String] The name of the process. + def name + @name + end + + # Replace the current child process with a different one. Forwards arguments and options to {::Process.exec}. + # This method replaces the child process with the new executable, thus this method never returns. + # + # @parameter arguments [Array] The arguments to pass to the new process. + # @parameter ready [Boolean] If true, informs the parent process that the child is ready. Otherwise, the child process will need to use a notification protocol to inform the parent process that it is ready. + # @parameter options [Hash] Additional options to pass to {::Process.exec}. + def exec(*arguments, ready: true, **options) + if ready + self.ready!(status: "(exec)") + else + self.before_spawn(arguments, options) + end + + ::Process.exec(*arguments, **options) + end + end + + # Fork a child process appropriate for a container. + # + # @returns [Process] + def self.fork(**options) + # $stderr.puts fork: caller + self.new(**options) do |process| + ::Process.fork do + # We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly. + Signal.trap(:INT) {::Thread.current.raise(Interrupt)} + Signal.trap(:TERM) {::Thread.current.raise(Terminate)} + Signal.trap(:HUP) {::Thread.current.raise(Restart)} + + # This could be a configuration option: + ::Thread.handle_interrupt(SignalException => :immediate) do + yield Instance.for(process) + rescue Interrupt + # Graceful exit. + rescue Exception => error + Console.error(self, error) + + exit!(1) + end + end + end + end + + # Spawn a child process using {::Process.spawn}. + # + # The child process will need to inform the parent process that it is ready using a notification protocol. + # + # @parameter arguments [Array] The arguments to pass to the new process. + # @parameter name [String] The name of the process. + # @parameter options [Hash] Additional options to pass to {::Process.spawn}. + def self.spawn(*arguments, name: nil, **options) + self.new(name: name) do |process| + Notify::Pipe.new(process.out).before_spawn(arguments, options) + + ::Process.spawn(*arguments, **options) + end + end + + # Initialize the process. + # @parameter name [String] The name to use for the child process. + def initialize(name: nil) + super() + + @name = name + @status = nil + @pid = nil + + @pid = yield(self) + + # The parent process won't be writing to the channel: + self.close_write + end + + # Convert the child process to a hash, suitable for serialization. + # + # @returns [Hash] The request as a hash. + def as_json(...) + { + name: @name, + pid: @pid, + status: @status&.to_i, + } + end + + # Convert the request to JSON. + # + # @returns [String] The request as JSON. + def to_json(...) + as_json.to_json(...) + end + + # Set the name of the process. + # Invokes {::Process.setproctitle} if invoked in the child process. + def name= value + @name = value + + # If we are the child process: + ::Process.setproctitle(@name) if @pid.nil? + end + + # The name of the process. + # @attribute [String] + attr :name + + # @attribute [Integer] The process identifier. + attr :pid + + # A human readable representation of the process. + # @returns [String] + def inspect + "\#<#{self.class} name=#{@name.inspect} status=#{@status.inspect} pid=#{@pid.inspect}>" + end + + alias to_s inspect + + # Invoke {#terminate!} and then {#wait} for the child process to exit. + def close + self.terminate! + self.wait + ensure + super + end + + # Send `SIGINT` to the child process. + def interrupt! + unless @status + ::Process.kill(:INT, @pid) + end + end + + # Send `SIGTERM` to the child process. + def terminate! + unless @status + ::Process.kill(:TERM, @pid) + end + end + + # Send `SIGKILL` to the child process. + def kill! + unless @status + ::Process.kill(:KILL, @pid) + end + end + + # Send `SIGHUP` to the child process. + def restart! + unless @status + ::Process.kill(:HUP, @pid) + end + end + + # Wait for the child process to exit. + # @asynchronous This method may block. + # + # @returns [::Process::Status] The process exit status. + def wait + if @pid && @status.nil? + Console.debug(self, "Waiting for process to exit...", pid: @pid) + + _, @status = ::Process.wait2(@pid, ::Process::WNOHANG) + + while @status.nil? + sleep(0.1) + + _, @status = ::Process.wait2(@pid, ::Process::WNOHANG) + + if @status.nil? + Console.warn(self) {"Process #{@pid} is blocking, has it exited?"} + end + end + end + + Console.debug(self, "Process exited.", pid: @pid, status: @status) + + return @status + end + end + + + # Start a named child process and execute the provided block in it. + # @parameter name [String] The name (title) of the child process. + # @parameter block [Proc] The block to execute in the child process. def start(name, &block) - Process.fork(name: name, &block) + Child.fork(name: name, &block) end end end diff --git a/lib/async/container/generic.rb b/lib/async/container/generic.rb index 3f6dafb..312dfce 100644 --- a/lib/async/container/generic.rb +++ b/lib/async/container/generic.rb @@ -1,49 +1,49 @@ -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require 'async' +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. -require 'etc' +require "etc" +require "async/clock" -require_relative 'group' -require_relative 'keyed' -require_relative 'statistics' +require_relative "group" +require_relative "keyed" +require_relative "statistics" module Async module Container - # @return [Integer] the number of hardware processors which can run threads/processes simultaneously. - def self.processor_count - Etc.nprocessors - rescue - 2 + # An environment variable key to override {.processor_count}. + ASYNC_CONTAINER_PROCESSOR_COUNT = "ASYNC_CONTAINER_PROCESSOR_COUNT" + + # The processor count which may be used for the default number of container threads/processes. You can override the value provided by the system by specifying the `ASYNC_CONTAINER_PROCESSOR_COUNT` environment variable. + # @returns [Integer] The number of hardware processors which can run threads/processes simultaneously. + # @raises [RuntimeError] If the process count is invalid. + def self.processor_count(env = ENV) + count = env.fetch(ASYNC_CONTAINER_PROCESSOR_COUNT) do + Etc.nprocessors rescue 1 + end.to_i + + if count < 1 + raise RuntimeError, "Invalid processor count #{count}!" + end + + return count end + # A base class for implementing containers. class Generic - def self.run(*arguments, **options, &block) - self.new.run(*arguments, **options, &block) + # Run a new container. + def self.run(...) + self.new.run(...) end UNNAMED = "Unnamed" + # Initialize the container. + # + # @parameter options [Hash] Options passed to the {Group} instance. def initialize(**options) - @group = Group.new + @group = Group.new(**options) @running = true @state = {} @@ -52,27 +52,46 @@ def initialize(**options) @keyed = {} end + # @attribute [Group] The group of running children instances. + attr :group + + # @returns [Integer] The number of running children instances. + def size + @group.size + end + + # @attribute [Hash(Child, Hash)] The state of each child instance. + attr :state + + # A human readable representation of the container. + # @returns [String] def to_s "#{self.class} with #{@statistics.spawns} spawns and #{@statistics.failures} failures." end + # Look up a child process by key. + # A key could be a symbol, a file path, or something else which the child instance represents. def [] key @keyed[key]&.value end + # Statistics relating to the behavior of children instances. + # @attribute [Statistics] attr :statistics + # Whether any failures have occurred within the container. + # @returns [Boolean] def failed? @statistics.failed? end - # Whether there are running tasks. + # Whether the container has running children instances. def running? @group.running? end # Sleep until some state change occurs. - # @param duration [Integer] the maximum amount of time to sleep for. + # @parameter duration [Numeric] the maximum amount of time to sleep for. def sleep(duration = nil) @group.sleep(duration) end @@ -82,68 +101,108 @@ def wait @group.wait end + # Returns true if all children instances have the specified status flag set. + # e.g. `:ready`. + # This state is updated by the process readiness protocol mechanism. See {Notify::Client} for more details. + # @returns [Boolean] def status?(flag) # This also returns true if all processes have exited/failed: @state.all?{|_, state| state[flag]} end + # Wait until all the children instances have indicated that they are ready. + # @returns [Boolean] The children all became ready. def wait_until_ready while true - Async.logger.debug(self) do |buffer| + Console.debug(self) do |buffer| buffer.puts "Waiting for ready:" @state.each do |child, state| - buffer.puts "\t#{child.class}: #{state.inspect}" + buffer.puts "\t#{child.inspect}: #{state}" end end self.sleep if self.status?(:ready) + Console.logger.debug(self) do |buffer| + buffer.puts "All ready:" + @state.each do |child, state| + buffer.puts "\t#{child.inspect}: #{state}" + end + end + return true end end end + # Stop the children instances. + # @parameter timeout [Boolean | Numeric] Whether to stop gracefully, or a specific timeout. def stop(timeout = true) @running = false @group.stop(timeout) if @group.running? - Async.logger.warn(self) {"Group is still running after stopping it!"} + Console.warn(self) {"Group is still running after stopping it!"} end ensure @running = true end - def spawn(name: nil, restart: false, key: nil, &block) + protected def health_check_failed!(child, age_clock, health_check_timeout) + Console.warn(self, "Child failed health check!", child: child, age: age_clock.total, health_check_timeout: health_check_timeout) + + # If the child has failed the health check, we assume the worst and kill it immediately: + child.kill! + end + + # Spawn a child instance into the container. + # @parameter name [String] The name of the child instance. + # @parameter restart [Boolean] Whether to restart the child instance if it fails. + # @parameter key [Symbol] A key used for reloading child instances. + # @parameter health_check_timeout [Numeric | Nil] The maximum time a child instance can run without updating its state, before it is terminated as unhealthy. + def spawn(name: nil, restart: false, key: nil, health_check_timeout: nil, &block) name ||= UNNAMED if mark?(key) - Async.logger.debug(self) {"Reusing existing child for #{key}: #{name}"} + Console.debug(self) {"Reusing existing child for #{key}: #{name}"} return false end @statistics.spawn! - Fiber.new do + fiber do while @running child = self.start(name, &block) state = insert(key, child) + # If a health check is specified, we will monitor the child process and terminate it if it does not update its state within the specified time. + if health_check_timeout + age_clock = state[:age] = Clock.start + end + begin status = @group.wait_for(child) do |message| - state.update(message) + case message + when :health_check! + if health_check_timeout&.<(age_clock.total) + health_check_failed!(child, age_clock, health_check_timeout) + end + else + state.update(message) + age_clock&.reset! + end end ensure delete(key, child) end if status.success? - Async.logger.info(self) {"#{child} #{status}"} + Console.debug(self) {"#{child} exited with #{status}"} else @statistics.failure! - Async.logger.error(self) {status} + Console.error(self, status: status) end if restart @@ -152,19 +211,13 @@ def spawn(name: nil, restart: false, key: nil, &block) break end end - # ensure - # Async.logger.error(self) {$!} if $! end.resume return true end - def async(**options, &block) - spawn(**options) do |instance| - Async::Reactor.run(instance, &block) - end - end - + # Run multiple instances of the same block in the container. + # @parameter count [Integer] The number of instances to start. def run(count: Container.processor_count, **options, &block) count.times do spawn(**options, &block) @@ -173,6 +226,18 @@ def run(count: Container.processor_count, **options, &block) return self end + # @deprecated Please use {spawn} or {run} instead. + def async(**options, &block) + # warn "#{self.class}##{__method__} is deprecated, please use `spawn` or `run` instead.", uplevel: 1 + + require "async" + + spawn(**options) do |instance| + Async(instance, &block) + end + end + + # Reload the container's keyed instances. def reload @keyed.each_value(&:clear!) @@ -187,6 +252,7 @@ def reload return dirty end + # Mark the container's keyed instance which ensures that it won't be discarded. def mark?(key) if key if value = @keyed[key] @@ -199,6 +265,7 @@ def mark?(key) return false end + # Whether a child instance exists for the given key. def key?(key) if key @keyed.key?(key) @@ -228,6 +295,18 @@ def delete(key, child) @state.delete(child) end + + private + + if Fiber.respond_to?(:blocking?) + def fiber(&block) + Fiber.new(blocking: true, &block) + end + else + def fiber(&block) + Fiber.new(&block) + end + end end end end diff --git a/lib/async/container/group.rb b/lib/async/container/group.rb index 67b305f..f1f761c 100644 --- a/lib/async/container/group.rb +++ b/lib/async/container/group.rb @@ -1,56 +1,62 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require 'fiber' +# Released under the MIT License. +# Copyright, 2018-2024, by Samuel Williams. -require 'async/clock' +require "fiber" +require "async/clock" -require_relative 'error' +require_relative "error" module Async module Container # Manages a group of running processes. class Group - def initialize + # Initialize an empty group. + # + # @parameter health_check_interval [Numeric | Nil] The (biggest) interval at which health checks are performed. + def initialize(health_check_interval: 1.0) + @health_check_interval = health_check_interval + + # The running fibers, indexed by IO: @running = {} # This queue allows us to wait for processes to complete, without spawning new processes as a result. @queue = nil end - # @attribute [Hash] the running tasks, indexed by IO. + # @returns [String] A human-readable representation of the group. + def inspect + "#<#{self.class} running=#{@running.size}>" + end + + # @attribute [Hash(IO, Fiber)] the running tasks, indexed by IO. attr :running + # @returns [Integer] The number of running processes. + def size + @running.size + end + + # Whether the group contains any running processes. + # @returns [Boolean] def running? @running.any? end + # Whether the group contains any running processes. + # @returns [Boolean] def any? @running.any? end + # Whether the group is empty. + # @returns [Boolean] def empty? @running.empty? end - # This method sleeps for at most the specified duration. + # Sleep for at most the specified duration until some state change occurs. def sleep(duration) self.resume self.suspend @@ -58,34 +64,71 @@ def sleep(duration) self.wait_for_children(duration) end + # Begin any outstanding queued processes and wait for them indefinitely. def wait self.resume - while self.running? - self.wait_for_children + with_health_checks do |duration| + self.wait_for_children(duration) end end + private def with_health_checks + if @health_check_interval + health_check_clock = Clock.start + + while self.running? + duration = [@health_check_interval - health_check_clock.total, 0].max + + yield duration + + if health_check_clock.total > @health_check_interval + self.health_check! + health_check_clock.reset! + end + end + else + while self.running? + yield nil + end + end + end + + # Perform a health check on all running processes. + def health_check! + @running.each_value do |fiber| + fiber.resume(:health_check!) + end + end + + # Interrupt all running processes. + # This resumes the controlling fiber with an instance of {Interrupt}. def interrupt + Console.info(self, "Sending interrupt to #{@running.size} running processes...") @running.each_value do |fiber| fiber.resume(Interrupt) end end + # Terminate all running processes. + # This resumes the controlling fiber with an instance of {Terminate}. def terminate + Console.info(self, "Sending terminate to #{@running.size} running processes...") @running.each_value do |fiber| fiber.resume(Terminate) end end + # Stop all child processes using {#terminate}. + # @parameter timeout [Boolean | Numeric | Nil] If specified, invoke a graceful shutdown using {#interrupt} first. def stop(timeout = 1) - # Handle legacy `graceful = true` argument: + Console.debug(self, "Stopping all processes...", timeout: timeout) + # Use a default timeout if not specified: + timeout = 1 if timeout == true + if timeout start_time = Async::Clock.now - # Use a default timeout if not specified: - timeout = 1 if timeout == true - self.interrupt while self.any? @@ -101,36 +144,33 @@ def stop(timeout = 1) end end - # Timeout can also be `graceful = false`: - if timeout - self.interrupt - self.sleep(timeout) - end - - self.wait_for_children(duration) - # Terminate all children: - self.terminate + self.terminate if any? # Wait for all children to exit: self.wait end + # Wait for a message in the specified {Channel}. def wait_for(channel) io = channel.in @running[io] = Fiber.current while @running.key?(io) + # Wait for some event on the channel: result = Fiber.yield if result == Interrupt channel.interrupt! elsif result == Terminate channel.terminate! + elsif result + yield result elsif message = channel.receive yield message else + # Wait for the channel to exit: return channel.wait end end @@ -141,12 +181,25 @@ def wait_for(channel) protected def wait_for_children(duration = nil) + # This log is a big noisy and doesn't really provide a lot of useful information. + # Console.debug(self, "Waiting for children...", duration: duration, running: @running) + if !@running.empty? + # Maybe consider using a proper event loop here: + if ready = self.select(duration) + ready.each do |io| + @running[io].resume + end + end + end + end + + # Wait for a child process to exit OR a signal to be received. + def select(duration) + ::Thread.handle_interrupt(SignalException => :immediate) do readable, _, _ = ::IO.select(@running.keys, nil, nil, duration) - readable&.each do |io| - @running[io].resume - end + return readable end end diff --git a/lib/async/container/hybrid.rb b/lib/async/container/hybrid.rb index c310af0..537eaf4 100644 --- a/lib/async/container/hybrid.rb +++ b/lib/async/container/hybrid.rb @@ -1,42 +1,44 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'forked' -require_relative 'threaded' +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. +# Copyright, 2022, by Anton Sozontov. + +require_relative "forked" +require_relative "threaded" module Async module Container + # Provides a hybrid multi-process multi-thread container. class Hybrid < Forked - def run(count: nil, forks: nil, threads: nil, **options, &block) + # Run multiple instances of the same block in the container. + # @parameter count [Integer] The number of instances to start. + # @parameter forks [Integer] The number of processes to fork. + # @parameter threads [Integer] the number of threads to start. + # @parameter health_check_timeout [Numeric] The timeout for health checks, in seconds. Passed into the child {Threaded} containers. + def run(count: nil, forks: nil, threads: nil, health_check_timeout: nil, **options, &block) processor_count = Container.processor_count count ||= processor_count ** 2 forks ||= [processor_count, count].min - threads = (count / forks).ceil + threads ||= (count / forks).ceil forks.times do - self.spawn(**options) do - container = Threaded::Container.new + self.spawn(**options) do |instance| + container = Threaded.new + + container.run(count: threads, health_check_timeout: health_check_timeout, **options, &block) - container.run(count: threads, **options, &block) + container.wait_until_ready + instance.ready! container.wait + rescue Async::Container::Terminate + # Stop it immediately: + container.stop(false) + raise + ensure + # Stop it gracefully (also code path for Interrupt): + container.stop end end diff --git a/lib/async/container/keyed.rb b/lib/async/container/keyed.rb index cfb1ba3..f8d286e 100644 --- a/lib/async/container/keyed.rb +++ b/lib/async/container/keyed.rb @@ -1,47 +1,47 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2022, by Samuel Williams. module Async module Container + # Tracks a key/value pair such that unmarked keys can be identified and cleaned up. + # This helps implement persistent processes that start up child processes per directory or configuration file. If those directories and/or configuration files are removed, the child process can then be cleaned up automatically, because those key/value pairs will not be marked when reloading the container. class Keyed + # Initialize the keyed instance + # + # @parameter key [Object] The key. + # @parameter value [Object] The value. def initialize(key, value) @key = key @value = value @marked = true end + # @attribute [Object] The key value, normally a symbol or a file-system path. attr :key + + # @attribute [Object] The value, normally a child instance. attr :value + # @returns [Boolean] True if the instance has been marked, during reloading the container. def marked? @marked end + # Mark the instance. This will indiciate that the value is still in use/active. def mark! @marked = true end + # Clear the instance. This is normally done before reloading a container. def clear! @marked = false end + # Stop the instance if it was not marked. + # + # @returns [Boolean] True if the instance was stopped. def stop? unless @marked @value.stop diff --git a/lib/async/container/notify.rb b/lib/async/container/notify.rb index 71515c2..5dfb155 100644 --- a/lib/async/container/notify.rb +++ b/lib/async/container/notify.rb @@ -1,39 +1,26 @@ # frozen_string_literal: true -# -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -require_relative 'notify/pipe' -require_relative 'notify/socket' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require_relative "notify/pipe" +require_relative "notify/socket" +require_relative "notify/console" +require_relative "notify/log" module Async module Container module Notify - # We cache the client on a per-process basis. Because that's the relevant scope for process readiness protocols. - @@client = nil + @client = nil + # Select the best available notification client. + # We cache the client on a per-process basis. Because that's the relevant scope for process readiness protocols. def self.open! - # Select the best available client: - @@client ||= ( + @client ||= ( Pipe.open! || - Socket.open! + Socket.open! || + Log.open! || + Console.open! ) end end diff --git a/lib/async/container/notify/client.rb b/lib/async/container/notify/client.rb index 34be9e1..dbd3ac6 100644 --- a/lib/async/container/notify/client.rb +++ b/lib/async/container/notify/client.rb @@ -1,33 +1,24 @@ # frozen_string_literal: true -# -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. + +# Released under the MIT License. +# Copyright, 2020-2022, by Samuel Williams. module Async module Container + # Handles the details of several process readiness protocols. module Notify + # Represents a client that can send messages to the parent controller in order to notify it of readiness, status changes, etc. + # + # A process readiness protocol (e.g. `sd_notify`) is a simple protocol for a child process to notify the parent process that it is ready (e.g. to accept connections, to process requests, etc). This can help dependency-based startup systems to start services in the correct order, and to handle failures gracefully. class Client + # Notify the parent controller that the child has become ready, with a brief status message. + # @parameters message [Hash] Additional details to send with the message. def ready!(**message) send(ready: true, **message) end + # Notify the parent controller that the child is reloading. + # @parameters message [Hash] Additional details to send with the message. def reloading!(**message) message[:ready] = false message[:reloading] = true @@ -36,6 +27,8 @@ def reloading!(**message) send(**message) end + # Notify the parent controller that the child is restarting. + # @parameters message [Hash] Additional details to send with the message. def restarting!(**message) message[:ready] = false message[:reloading] = true @@ -44,14 +37,23 @@ def restarting!(**message) send(**message) end + # Notify the parent controller that the child is stopping. + # @parameters message [Hash] Additional details to send with the message. def stopping!(**message) message[:stopping] = true + + send(**message) end + # Notify the parent controller of a status change. + # @parameters text [String] The details of the status change. def status!(text) send(status: text) end + # Notify the parent controller of an error condition. + # @parameters text [String] The details of the error condition. + # @parameters message [Hash] Additional details to send with the message. def error!(text, **message) send(status: text, **message) end diff --git a/lib/async/container/notify/console.rb b/lib/async/container/notify/console.rb new file mode 100644 index 0000000..a777f1f --- /dev/null +++ b/lib/async/container/notify/console.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require_relative "client" + +require "console" + +module Async + module Container + module Notify + # Implements a general process readiness protocol with output to the local console. + class Console < Client + # Open a notification client attached to the current console. + def self.open!(logger = ::Console) + self.new(logger) + end + + # Initialize the notification client. + # @parameter logger [Console::Logger] The console logger instance to send messages to. + def initialize(logger) + @logger = logger + end + + # Send a message to the console. + def send(level: :info, **message) + @logger.public_send(level, self) {message} + end + + # Send an error message to the console. + # @parameters text [String] The details of the error condition. + # @parameters message [Hash] Additional details to send with the message. + def error!(text, **message) + send(status: text, level: :error, **message) + end + end + end + end +end diff --git a/lib/async/container/notify/log.rb b/lib/async/container/notify/log.rb new file mode 100644 index 0000000..010211d --- /dev/null +++ b/lib/async/container/notify/log.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require_relative "client" +require "socket" + +module Async + module Container + module Notify + # Represents a client that uses a local log file to communicate readiness, status changes, etc. + class Log < Client + # The name of the environment variable which contains the path to the notification socket. + NOTIFY_LOG = "NOTIFY_LOG" + + # Open a notification client attached to the current {NOTIFY_LOG} if possible. + def self.open!(environment = ENV) + if path = environment.delete(NOTIFY_LOG) + self.new(path) + end + end + + # Initialize the notification client. + # @parameter path [String] The path to the UNIX socket used for sending messages to the process manager. + def initialize(path) + @path = path + end + + # @attribute [String] The path to the UNIX socket used for sending messages to the controller. + attr :path + + # Send the given message. + # @parameter message [Hash] + def send(**message) + data = JSON.dump(message) + + File.open(@path, "a") do |file| + file.puts(data) + end + end + + # Send the specified error. + # `sd_notify` requires an `errno` key, which defaults to `-1` to indicate a generic error. + def error!(text, **message) + message[:errno] ||= -1 + + super + end + end + end + end +end diff --git a/lib/async/container/notify/pipe.rb b/lib/async/container/notify/pipe.rb index f316763..597bcf5 100644 --- a/lib/async/container/notify/pipe.rb +++ b/lib/async/container/notify/pipe.rb @@ -1,68 +1,67 @@ # frozen_string_literal: true -# -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -require_relative 'client' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. +# Copyright, 2020, by Juan Antonio Martín Lucas. -require 'json' +require_relative "client" + +require "json" module Async module Container module Notify + # Implements a process readiness protocol using an inherited pipe file descriptor. class Pipe < Client - NOTIFY_PIPE = 'NOTIFY_PIPE' + # The environment variable key which contains the pipe file descriptor. + NOTIFY_PIPE = "NOTIFY_PIPE" + # Open a notification client attached to the current {NOTIFY_PIPE} if possible. def self.open!(environment = ENV) if descriptor = environment.delete(NOTIFY_PIPE) self.new(::IO.for_fd(descriptor.to_i)) end rescue Errno::EBADF => error - Async.logger.error(self) {error} + Console.error(self) {error} return nil end + # Initialize the notification client. + # @parameter io [IO] An IO instance used for sending messages. def initialize(io) @io = io end - def before_exec(arguments) - @io.close_on_exec = false - - # Insert or duplicate the environment hash which is the first argument: - environment = environment_for(arguments) - - environment[NOTIFY_PIPE] = @io.fileno.to_s - end - # Inserts or duplicates the environment given an argument array. # Sets or clears it in a way that is suitable for {::Process.spawn}. def before_spawn(arguments, options) environment = environment_for(arguments) - options[3] = @io + # Use `notify_pipe` option if specified: + if notify_pipe = options.delete(:notify_pipe) + options[notify_pipe] = @io + environment[NOTIFY_PIPE] = notify_pipe.to_s + + # Use stdout if it's not redirected: + # This can cause issues if the user expects stdout to be connected to a terminal. + # elsif !options.key?(:out) + # options[:out] = @io + # environment[NOTIFY_PIPE] = "1" - environment[NOTIFY_PIPE] = "3" + # Use fileno 3 if it's available: + elsif !options.key?(3) + options[3] = @io + environment[NOTIFY_PIPE] = "3" + + # Otherwise, give up! + else + raise ArgumentError, "Please specify valid file descriptor for notify_pipe!" + end end + # Formats the message using JSON and sends it to the parent controller. + # This is suitable for use with {Channel}. def send(**message) data = ::JSON.dump(message) @@ -70,26 +69,6 @@ def send(**message) @io.flush end - def ready!(**message) - send(ready: true, **message) - end - - def reloading!(**message) - message[:ready] = false - message[:reloading] = true - message[:status] ||= "Reloading..." - - send(**message) - end - - def reloading!(**message) - message[:ready] = false - message[:reloading] = true - message[:status] ||= "Reloading..." - - send(**message) - end - private def environment_for(arguments) diff --git a/lib/async/container/notify/server.rb b/lib/async/container/notify/server.rb index 76e04ca..a9d74fc 100644 --- a/lib/async/container/notify/server.rb +++ b/lib/async/container/notify/server.rb @@ -1,39 +1,24 @@ # frozen_string_literal: true -# -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -require 'async/io' -require 'async/io/unix_endpoint' -require 'kernel/sync' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. -require 'tmpdir' -require 'securerandom' +require "tmpdir" +require "socket" +require "securerandom" module Async module Container module Notify + # A simple UDP server that can be used to receive messages from a child process, tracking readiness, status changes, etc. class Server - NOTIFY_SOCKET = 'NOTIFY_SOCKET' MAXIMUM_MESSAGE_SIZE = 4096 + # Parse a message, according to the `sd_notify` protocol. + # + # @parameter message [String] The message to parse. + # @returns [Hash] The parsed message. def self.load(message) lines = message.split("\n") @@ -42,18 +27,25 @@ def self.load(message) pairs = lines.map do |line| key, value = line.split("=", 2) - if value == '0' + key = key.downcase.to_sym + + if value == "0" value = false - elsif value == '1' + elsif value == "1" value = true + elsif key == :errno and value =~ /\A\-?\d+\z/ + value = Integer(value) end - next [key.downcase.to_sym, value] + next [key, value] end return Hash[pairs] end + # Generate a new unique path for the UNIX socket. + # + # @returns [String] The path for the UNIX socket. def self.generate_path File.expand_path( "async-container-#{::Process.pid}-#{SecureRandom.hex(8)}.ipc", @@ -61,47 +53,61 @@ def self.generate_path ) end + # Open a new server instance with a temporary and unique path. def self.open(path = self.generate_path) self.new(path) end + # Initialize the server with the given path. + # + # @parameter path [String] The path to the UNIX socket. def initialize(path) @path = path end + # @attribute [String] The path to the UNIX socket. attr :path + # Generate a bound context for receiving messages. + # + # @returns [Context] The bound context. def bind Context.new(@path) end + # A bound context for receiving messages. class Context + # Initialize the context with the given path. + # + # @parameter path [String] The path to the UNIX socket. def initialize(path) @path = path - @endpoint = IO::Endpoint.unix(@path, ::Socket::SOCK_DGRAM) - - Sync do - @bound = @endpoint.bind - end + @bound = Addrinfo.unix(@path, ::Socket::SOCK_DGRAM).bind @state = {} end + # Close the bound context. def close - Sync do - @bound.close - end + @bound.close File.unlink(@path) end + # Receive a message from the bound context. + # + # @returns [Hash] The parsed message. def receive while true - data, address, flags, *controls = @bound.recvmsg(MAXIMUM_MESSAGE_SIZE) + data, _address, _flags, *_controls = @bound.recvmsg(MAXIMUM_MESSAGE_SIZE) message = Server.load(data) - yield message + if block_given? + yield message + else + return message + end end end end diff --git a/lib/async/container/notify/socket.rb b/lib/async/container/notify/socket.rb index 34d7323..597f342 100644 --- a/lib/async/container/notify/socket.rb +++ b/lib/async/container/notify/socket.rb @@ -1,49 +1,42 @@ # frozen_string_literal: true -# -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -require_relative 'client' +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. -require 'async/io' -require 'async/io/unix_endpoint' -require 'kernel/sync' +require_relative "client" +require "socket" module Async module Container module Notify + # Implements the systemd NOTIFY_SOCKET process readiness protocol. + # See for more details of the underlying protocol. class Socket < Client - NOTIFY_SOCKET = 'NOTIFY_SOCKET' + # The name of the environment variable which contains the path to the notification socket. + NOTIFY_SOCKET = "NOTIFY_SOCKET" + + # The maximum allowed size of the UDP message. MAXIMUM_MESSAGE_SIZE = 4096 + # Open a notification client attached to the current {NOTIFY_SOCKET} if possible. def self.open!(environment = ENV) if path = environment.delete(NOTIFY_SOCKET) self.new(path) end end + # Initialize the notification client. + # @parameter path [String] The path to the UNIX socket used for sending messages to the process manager. def initialize(path) @path = path - @endpoint = IO::Endpoint.unix(path, ::Socket::SOCK_DGRAM) + @address = Addrinfo.unix(path, ::Socket::SOCK_DGRAM) end + # @attribute [String] The path to the UNIX socket used for sending messages to the controller. + attr :path + + # Dump a message in the format requied by `sd_notify`. + # @parameter message [Hash] Keys and values should be string convertible objects. Values which are `true`/`false` are converted to `1`/`0` respectively. def dump(message) buffer = String.new @@ -61,24 +54,26 @@ def dump(message) return buffer end + # Send the given message. + # @parameter message [Hash] def send(**message) data = dump(message) if data.bytesize > MAXIMUM_MESSAGE_SIZE - raise ArgumentError, "Message length #{message.bytesize} exceeds #{MAXIMUM_MESSAGE_SIZE}: #{message.inspect}" + raise ArgumentError, "Message length #{data.bytesize} exceeds #{MAXIMUM_MESSAGE_SIZE}: #{message.inspect}" end - Sync do - @endpoint.connect do |peer| - peer.send(data) - end + @address.connect do |peer| + peer.sendmsg(data) end end + # Send the specified error. + # `sd_notify` requires an `errno` key, which defaults to `-1` to indicate a generic error. def error!(text, **message) message[:errno] ||= -1 - send(status: text, **message) + super end end end diff --git a/lib/async/container/process.rb b/lib/async/container/process.rb deleted file mode 100644 index 3f51a8c..0000000 --- a/lib/async/container/process.rb +++ /dev/null @@ -1,163 +0,0 @@ -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require_relative 'channel' -require_relative 'error' - -require_relative 'notify/pipe' - -module Async - module Container - class Process < Channel - class Instance < Notify::Pipe - def self.for(process) - instance = self.new(process.out) - - # The child process won't be reading from the channel: - process.close_read - - instance.name = process.name - - return instance - end - - def initialize(io) - super - - @name = nil - end - - def name= value - if @name = value - ::Process.setproctitle(@name) - end - end - - def name - @name - end - - def exec(*arguments, ready: true, **options) - if ready - self.ready!(status: "(exec)") if ready - else - self.before_exec(arguments) - end - - ::Process.exec(*arguments, **options) - end - end - - def self.fork(**options) - self.new(**options) do |process| - ::Process.fork do - Signal.trap(:INT) {raise Interrupt} - Signal.trap(:TERM) {raise Terminate} - - begin - yield Instance.for(process) - rescue Interrupt - # Graceful exit. - rescue Exception => error - Async.logger.error(self) {error} - - exit!(1) - end - end - end - end - - # def self.spawn(*arguments, name: nil, **options) - # self.new(name: name) do |process| - # unless options.key?(:out) - # options[:out] = process.out - # end - # - # ::Process.spawn(*arguments, **options) - # end - # end - - def initialize(name: nil) - super() - - @name = name - @status = nil - @pid = nil - - @pid = yield self - - # The parent process won't be writing to the channel: - self.close_write - end - - def name= value - @name = value - - # If we are the child process: - ::Process.setproctitle(@name) if @pid.nil? - end - - attr :name - - def to_s - if @status - "\#<#{self.class} #{@name} -> #{@status}>" - elsif @pid - "\#<#{self.class} #{@name} -> #{@pid}>" - else - "\#<#{self.class} #{@name}>" - end - end - - def close - self.terminate! - self.wait - ensure - super - end - - def interrupt! - unless @status - ::Process.kill(:INT, @pid) - end - end - - def terminate! - unless @status - ::Process.kill(:TERM, @pid) - end - end - - def wait - unless @status - sleep(0.1) - pid, @status = ::Process.wait2(@pid, ::Process::WNOHANG) - - if @status.nil? - Async.logger.warn(self) {"Process #{@pid} is blocking, has it exited?"} - pid, @status = ::Process.wait2(@pid) - end - end - - return @status - end - end - end -end diff --git a/lib/async/container/statistics.rb b/lib/async/container/statistics.rb index 47ef852..ea38b0e 100644 --- a/lib/async/container/statistics.rb +++ b/lib/async/container/statistics.rb @@ -1,54 +1,56 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require 'async/reactor' +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. + +require "async/reactor" module Async module Container + # Tracks various statistics relating to child instances in a container. class Statistics + # Initialize the statistics all to 0. def initialize @spawns = 0 @restarts = 0 @failures = 0 end + # How many child instances have been spawned. + # @attribute [Integer] attr :spawns + + # How many child instances have been restarted. + # @attribute [Integer] attr :restarts + + # How many child instances have failed. + # @attribute [Integer] attr :failures + # Increment the number of spawns by 1. def spawn! @spawns += 1 end + # Increment the number of restarts by 1. def restart! @restarts += 1 end + # Increment the number of failures by 1. def failure! @failures += 1 end + # Whether there have been any failures. + # @returns [Boolean] If the failure count is greater than 0. def failed? @failures > 0 end + # Append another statistics instance into this one. + # @parameter other [Statistics] The statistics to append. def << other @spawns += other.spawns @restarts += other.restarts diff --git a/lib/async/container/thread.rb b/lib/async/container/thread.rb deleted file mode 100644 index 0d0651b..0000000 --- a/lib/async/container/thread.rb +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require_relative 'channel' -require_relative 'notify/pipe' - -require 'async/logger' - -module Async - module Container - class Thread < Channel - class Exit < Exception - def initialize(status) - @status = status - end - - attr :status - - def error - unless status.success? - status - end - end - end - - class Instance < Notify::Pipe - def self.for(thread) - instance = self.new(thread.out) - - return instance - end - - def initialize(io) - @name = nil - @thread = ::Thread.current - - super - end - - def name= value - @thread.name = value - end - - def name - @thread.name - end - - def exec(*arguments, ready: true, **options) - if ready - self.ready!(status: "(spawn)") if ready - else - self.before_spawn(arguments, options) - end - - begin - pid = ::Process.spawn(*arguments, **options) - ensure - _, status = ::Process.wait2(@pid) - - raise Exit, status - end - end - end - - def self.fork(**options) - self.new(**options) do |thread| - ::Thread.new do - yield Instance.for(thread) - end - end - end - - def initialize(name: nil) - super() - - @status = nil - - @thread = yield(self) - @thread.report_on_exception = false - @thread.name = name - - @waiter = ::Thread.new do - begin - @thread.join - rescue Exit => exit - finished(exit.result) - rescue Interrupt - # Graceful shutdown. - finished - rescue Exception => error - finished(error) - else - finished - end - end - end - - def name= value - @thread.name = name - end - - def name - @thread.name - end - - def to_s - if @status - "\#<#{self.class} #{@thread.name} -> #{@status}>" - else - "\#<#{self.class} #{@thread.name}>" - end - end - - def close - self.terminate! - self.wait - ensure - super - end - - def interrupt! - @thread.raise(Interrupt) - end - - def terminate! - @thread.raise(Terminate) - end - - def wait - if @waiter - @waiter.join - @waiter = nil - end - - return @status - end - - class Status - def initialize(result = nil) - @result = result - end - - def success? - @result.nil? - end - - def to_s - "\#<#{self.class} #{success? ? "success" : "failure"}>" - end - end - - protected - - def finished(error = nil) - if error - Async.logger.error(self) {error} - end - - @status = Status.new(error) - self.close_write - end - end - end -end diff --git a/lib/async/container/threaded.rb b/lib/async/container/threaded.rb index 57f0171..3b9595b 100644 --- a/lib/async/container/threaded.rb +++ b/lib/async/container/threaded.rb @@ -1,36 +1,280 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true -require_relative 'generic' -require_relative 'thread' +# Released under the MIT License. +# Copyright, 2017-2025, by Samuel Williams. + +require_relative "generic" +require_relative "channel" +require_relative "notify/pipe" module Async - # Manages a reactor within one or more threads. module Container + # A multi-thread container which uses {Thread.fork}. class Threaded < Generic + # Indicates that this is not a multi-process container. def self.multiprocess? false end + # Represents a running child thread from the point of view of the parent container. + class Child < Channel + # Used to propagate the exit status of a child process invoked by {Instance#exec}. + class Exit < Exception + # Initialize the exit status. + # @parameter status [::Process::Status] The process exit status. + def initialize(status) + @status = status + end + + # The process exit status. + # @attribute [::Process::Status] + attr :status + + # The process exit status if it was an error. + # @returns [::Process::Status | Nil] + def error + unless status.success? + status + end + end + end + + # Represents a running child thread from the point of view of the child thread. + class Instance < Notify::Pipe + # Wrap an instance around the {Thread} instance from within the threaded child. + # @parameter thread [Thread] The thread intance to wrap. + def self.for(thread) + instance = self.new(thread.out) + + return instance + end + + # Initialize the child thread instance. + # + # @parameter io [IO] The IO object to use for communication with the parent. + def initialize(io) + @thread = ::Thread.current + + super + end + + # Generate a hash representation of the thread. + # + # @returns [Hash] The thread as a hash, including `process_id`, `thread_id`, and `name`. + def as_json(...) + { + process_id: ::Process.pid, + thread_id: @thread.object_id, + name: @thread.name, + } + end + + # Generate a JSON representation of the thread. + # + # @returns [String] The thread as JSON. + def to_json(...) + as_json.to_json(...) + end + + # Set the name of the thread. + # @parameter value [String] The name to set. + def name= value + @thread.name = value + end + + # Get the name of the thread. + # @returns [String] + def name + @thread.name + end + + # Execute a child process using {::Process.spawn}. In order to simulate {::Process.exec}, an {Exit} instance is raised to propagage exit status. + # This creates the illusion that this method does not return (normally). + def exec(*arguments, ready: true, **options) + if ready + self.ready!(status: "(spawn)") + else + self.before_spawn(arguments, options) + end + + begin + pid = ::Process.spawn(*arguments, **options) + ensure + _, status = ::Process.wait2(pid) + + raise Exit, status + end + end + end + + # Start a new child thread and execute the provided block in it. + # + # @parameter options [Hash] Additional options to to the new child instance. + def self.fork(**options) + self.new(**options) do |thread| + ::Thread.new do + # This could be a configuration option (see forked implementation too): + ::Thread.handle_interrupt(SignalException => :immediate) do + yield Instance.for(thread) + end + end + end + end + + # Initialize the thread. + # + # @parameter name [String] The name to use for the child thread. + def initialize(name: nil) + super() + + @status = nil + + @thread = yield(self) + @thread.report_on_exception = false + @thread.name = name + + @waiter = ::Thread.new do + begin + @thread.join + rescue Exit => exit + finished(exit.error) + rescue Interrupt + # Graceful shutdown. + finished + rescue Exception => error + finished(error) + else + finished + end + end + end + + # Convert the child process to a hash, suitable for serialization. + # + # @returns [Hash] The request as a hash. + def as_json(...) + { + name: @thread.name, + status: @status&.as_json, + } + end + + # Convert the request to JSON. + # + # @returns [String] The request as JSON. + def to_json(...) + as_json.to_json(...) + end + + # Set the name of the thread. + # @parameter value [String] The name to set. + def name= value + @thread.name = value + end + + # Get the name of the thread. + # @returns [String] + def name + @thread.name + end + + # A human readable representation of the thread. + # @returns [String] + def to_s + "\#<#{self.class} #{@thread.name}>" + end + + # Invoke {#terminate!} and then {#wait} for the child thread to exit. + def close + self.terminate! + self.wait + ensure + super + end + + # Raise {Interrupt} in the child thread. + def interrupt! + @thread.raise(Interrupt) + end + + # Raise {Terminate} in the child thread. + def terminate! + @thread.raise(Terminate) + end + + # Invoke {Thread#kill} on the child thread. + def kill! + # Killing a thread does not raise an exception in the thread, so we need to handle the status here: + @status = Status.new(:killed) + + @thread.kill + end + + # Raise {Restart} in the child thread. + def restart! + @thread.raise(Restart) + end + + # Wait for the thread to exit and return he exit status. + # @returns [Status] + def wait + if @waiter + @waiter.join + @waiter = nil + end + + return @status + end + + # A pseudo exit-status wrapper. + class Status + # Initialise the status. + # @parameter error [::Process::Status] The exit status of the child thread. + def initialize(error = nil) + @error = error + end + + # Whether the status represents a successful outcome. + # @returns [Boolean] + def success? + @error.nil? + end + + # Convert the status to a hash, suitable for serialization. + # + # @returns [Boolean | String] If the status is an error, the error message is returned, otherwise `true`. + def as_json(...) + if @error + @error.inspect + else + true + end + end + + # A human readable representation of the status. + def to_s + "\#<#{self.class} #{success? ? "success" : "failure"}>" + end + end + + protected + + # Invoked by the @waiter thread to indicate the outcome of the child thread. + def finished(error = nil) + if error + Console.error(self) {error} + end + + @status ||= Status.new(error) + self.close_write + end + end + + # Start a named child thread and execute the provided block in it. + # @parameter name [String] The name (title) of the child process. + # @parameter block [Proc] The block to execute in the child process. def start(name, &block) - Thread.fork(name: name, &block) + Child.fork(name: name, &block) end end end diff --git a/lib/async/container/version.rb b/lib/async/container/version.rb index 81d0be6..d2dd80e 100644 --- a/lib/async/container/version.rb +++ b/lib/async/container/version.rb @@ -1,25 +1,10 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2024, by Samuel Williams. module Async module Container - VERSION = "0.16.0" + VERSION = "0.24.0" end end diff --git a/lib/metrics/provider/async/container.rb b/lib/metrics/provider/async/container.rb new file mode 100644 index 0000000..80d07c2 --- /dev/null +++ b/lib/metrics/provider/async/container.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "container/generic" diff --git a/lib/metrics/provider/async/container/generic.rb b/lib/metrics/provider/async/container/generic.rb new file mode 100644 index 0000000..aba27dd --- /dev/null +++ b/lib/metrics/provider/async/container/generic.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "../../../../async/container/generic" +require "metrics/provider" + +Metrics::Provider(Async::Container::Generic) do + ASYNC_CONTAINER_GENERIC_HEALTH_CHECK_FAILED = Metrics.metric("async.container.generic.health_check_failed", :counter, description: "The number of health checks that failed.") + + protected def health_check_failed!(child, age_clock, health_check_timeout) + ASYNC_CONTAINER_GENERIC_HEALTH_CHECK_FAILED.emit(1) + + super + end +end diff --git a/license.md b/license.md new file mode 100644 index 0000000..7867ed1 --- /dev/null +++ b/license.md @@ -0,0 +1,25 @@ +# MIT License + +Copyright, 2017-2025, by Samuel Williams. +Copyright, 2019, by Yuji Yaginuma. +Copyright, 2020, by Olle Jonsson. +Copyright, 2020, by Juan Antonio Martín Lucas. +Copyright, 2022, by Anton Sozontov. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b7d147a --- /dev/null +++ b/readme.md @@ -0,0 +1,64 @@ +# Async::Container + +Provides containers which implement parallelism for clients and servers. + +[![Development Status](https://github.com/socketry/async-container/workflows/Test/badge.svg)](https://github.com/socketry/async-container/actions?workflow=Test) + +## Features + + - Supports multi-process, multi-thread and hybrid containers. + - Automatic scalability based on physical hardware. + - Direct integration with [systemd](https://www.freedesktop.org/software/systemd/man/sd_notify.html) using `$NOTIFY_SOCKET`. + - Internal process readiness protocol for handling state changes. + - Automatic restart of failed processes. + +## Usage + +Please see the [project documentation](https://socketry.github.io/async-container/) for more details. + + - [Getting Started](https://socketry.github.io/async-container/guides/getting-started/index) - This guide explains how to use `async-container` to build basic scalable systems. + +## Releases + +Please see the [project releases](https://socketry.github.io/async-container/releases/index) for all releases. + +### v0.24.0 + + - Add support for health check failure metrics. + +### v0.23.0 + + - [Add support for `NOTIFY_LOG` for Kubernetes readiness probes.](https://socketry.github.io/async-container/releases/index#add-support-for-notify_log-for-kubernetes-readiness-probes.) + +### v0.21.0 + + - Use `SIGKILL`/`Thread#kill` when the health check fails. In some cases, `SIGTERM` may not be sufficient to terminate a process because the signal can be ignored or the process may be in an uninterruptible state. + +### v0.20.1 + + - Fix compatibility between Async::Container::Hybrid and the health check. + - Async::Container::Generic\#initialize passes unused arguments through to Async::Container::Group. + +### v0.20.0 + + - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points. + - Improved logging when child process fails and container startup. + - [Add `health_check_timeout` for detecting hung processes.](https://socketry.github.io/async-container/releases/index#add-health_check_timeout-for-detecting-hung-processes.) + +## Contributing + +We welcome contributions to this project. + +1. Fork it. +2. Create your feature branch (`git checkout -b my-new-feature`). +3. Commit your changes (`git commit -am 'Add some feature'`). +4. Push to the branch (`git push origin my-new-feature`). +5. Create new Pull Request. + +### Developer Certificate of Origin + +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. + +### Community Guidelines + +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. diff --git a/release.cert b/release.cert new file mode 100644 index 0000000..d98e595 --- /dev/null +++ b/release.cert @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 +ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK +CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz +MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd +MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj +bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB +igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 +9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW +sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE +e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN +XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss +RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn +tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM +zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW +xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O +BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs +aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE +cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl +xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ +c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp +8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws +JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP +eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt +Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 +voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= +-----END CERTIFICATE----- diff --git a/releases.md b/releases.md new file mode 100644 index 0000000..ff8995e --- /dev/null +++ b/releases.md @@ -0,0 +1,76 @@ +# Releases + +## v0.24.0 + + - Add support for health check failure metrics. + +## v0.23.0 + +### Add support for `NOTIFY_LOG` for Kubernetes readiness probes. + +You may specify a `NOTIFY_LOG` environment variable to enable readiness logging to a log file. This can be used for Kubernetes readiness probes, e.g. + +``` yaml +containers: + - name: falcon + env: + - name: NOTIFY_LOG + value: "/tmp/notify.log" + command: ["falcon", "host"] + readinessProbe: + exec: + command: ["sh", "-c", "grep -q '\"ready\":true' /tmp/notify.log"] + initialDelaySeconds: 5 + periodSeconds: 5 + failureThreshold: 12 +``` + +## v0.21.0 + + - Use `SIGKILL`/`Thread#kill` when the health check fails. In some cases, `SIGTERM` may not be sufficient to terminate a process because the signal can be ignored or the process may be in an uninterruptible state. + +## v0.20.1 + + - Fix compatibility between {ruby Async::Container::Hybrid} and the health check. + - {ruby Async::Container::Generic\#initialize} passes unused arguments through to {ruby Async::Container::Group}. + +## v0.20.0 + + - Improve container signal handling reliability by using `Thread.handle_interrupt` except at known safe points. + - Improved logging when child process fails and container startup. + +### Add `health_check_timeout` for detecting hung processes. + +In order to detect hung processes, a `health_check_timeout` can be specified when spawning children workers. If the health check does not complete within the specified timeout, the child process is killed. + +``` ruby +require "async/container" + +container = Async::Container.new + +container.run(count: 1, restart: true, health_check_timeout: 1) do |instance| + while true + # This example will fail sometimes: + sleep(0.5 + rand) + instance.ready! + end +end + +container.wait +``` + +If the health check does not complete within the specified timeout, the child process is killed: + +``` + 3.01s warn: Async::Container::Forked [oid=0x1340] [ec=0x1348] [pid=27100] [2025-02-20 13:24:55 +1300] + | Child failed health check! + | { + | "child": { + | "name": "Unnamed", + | "pid": 27101, + | "status": null + | }, + | "age": 1.0612829999881797, + | "health_check_timeout": 1 + | } +``` diff --git a/spec/async/container/controller_spec.rb b/spec/async/container/controller_spec.rb deleted file mode 100644 index 2cdfc78..0000000 --- a/spec/async/container/controller_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container/controller" - -RSpec.describe Async::Container::Controller do - describe '#reload' do - it "can reuse keyed child" do - input, output = IO.pipe - - subject.instance_variable_set(:@output, output) - - def subject.setup(container) - container.spawn(key: "test") do |instance| - instance.ready! - - sleep(0.1) - - @output.write(".") - @output.flush - - sleep(0.2) - end - - container.spawn do |instance| - instance.ready! - - sleep(0.2) - - @output.write(",") - @output.flush - end - end - - subject.start - expect(input.read(2)).to be == ".," - - subject.reload - - expect(input.read(1)).to be == "," - subject.wait - end - end - - describe '#start' do - it "can start up a container" do - expect(subject).to receive(:setup) - - subject.start - - expect(subject).to be_running - expect(subject.container).to_not be_nil - - subject.stop - - expect(subject).to_not be_running - expect(subject.container).to be_nil - end - - it "can spawn a reactor" do - def subject.setup(container) - container.async do |task| - task.sleep 1 - end - end - - subject.start - - statistics = subject.container.statistics - - expect(statistics.spawns).to be == 1 - - subject.stop - end - end -end diff --git a/spec/async/container/forked_spec.rb b/spec/async/container/forked_spec.rb deleted file mode 100644 index b3f09d2..0000000 --- a/spec/async/container/forked_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container" -require "async/container/forked" - -require_relative 'shared_examples' - -RSpec.describe Async::Container::Forked, if: Async::Container.fork? do - subject {described_class.new} - - it_behaves_like Async::Container - - it "can restart child" do - trigger = IO.pipe - pids = IO.pipe - - thread = Thread.new do - subject.async(restart: true) do - trigger.first.gets - pids.last.puts Process.pid.to_s - end - - subject.wait - end - - 3.times do - trigger.last.puts "die" - child_pid = pids.first.gets - end - - thread.kill - thread.join - - expect(subject.statistics.spawns).to be == 1 - expect(subject.statistics.restarts).to be == 2 - end - - it "should be multiprocess" do - expect(described_class).to be_multiprocess - end -end diff --git a/spec/async/container/hybrid_spec.rb b/spec/async/container/hybrid_spec.rb deleted file mode 100644 index 5db1e52..0000000 --- a/spec/async/container/hybrid_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright, 2019, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'async/container/hybrid' -require 'async/container/best' - -require_relative 'shared_examples' - -RSpec.describe Async::Container::Hybrid, if: Async::Container.fork? do - subject {described_class.new} - - it_behaves_like Async::Container - - it "should be multiprocess" do - expect(described_class).to be_multiprocess - end -end diff --git a/spec/async/container/notify/pipe_spec.rb b/spec/async/container/notify/pipe_spec.rb deleted file mode 100644 index eb66a80..0000000 --- a/spec/async/container/notify/pipe_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container/controller" - -RSpec.describe Async::Container::Notify::Pipe do - let(:notify_script) {File.expand_path("notify.rb", __dir__)} - - it "receives notification of child status" do - container = Async::Container.new - - container.spawn(restart: false) do |instance| - instance.exec("bundle", "exec", notify_script, ready: false) - end - - container.wait - - expect(container.statistics).to have_attributes(failures: 0) - end -end diff --git a/spec/async/container/notify_spec.rb b/spec/async/container/notify_spec.rb deleted file mode 100644 index fc70655..0000000 --- a/spec/async/container/notify_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright, 2020, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container/controller" -require "async/container/notify/server" - -RSpec.describe Async::Container::Notify, if: Async::Container.fork? do - let(:server) {described_class::Server.open} - let(:notify_socket) {server.path} - let(:client) {described_class::Socket.new(notify_socket)} - - describe '#ready!' do - it "should send message" do - begin - context = server.bind - - pid = fork do - client.ready! - end - - messages = [] - - Sync do - context.receive do |message, address| - messages << message - break - end - end - - expect(messages.last).to include(ready: true) - ensure - context&.close - Process.wait(pid) if pid - end - end - end -end diff --git a/spec/async/container/shared_examples.rb b/spec/async/container/shared_examples.rb deleted file mode 100644 index 85eb1a0..0000000 --- a/spec/async/container/shared_examples.rb +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require 'async/rspec/reactor' - -RSpec.shared_examples_for Async::Container do - it "can run concurrently" do - input, output = IO.pipe - - subject.async do - output.write "Hello World" - end - - subject.wait - - output.close - expect(input.read).to be == "Hello World" - end - - it "can run concurrently" do - subject.async(name: "Sleepy Jerry") do |task, instance| - 3.times do |i| - instance.name = "Counting Sheep #{i}" - - sleep 0.01 - end - end - - subject.wait - end - - describe '#sleep' do - it "can sleep for a short time" do - subject.spawn do - sleep(0.2) - raise "Boom" - end - - subject.sleep(0.1) - expect(subject.statistics).to have_attributes(failures: 0) - - subject.wait - - expect(subject.statistics).to have_attributes(failures: 1) - end - end - - describe '#stop' do - it 'can stop the child process' do - subject.spawn do - sleep(1) - end - - is_expected.to be_running - - subject.stop - - is_expected.to_not be_running - end - end -end diff --git a/spec/async/container/threaded_spec.rb b/spec/async/container/threaded_spec.rb deleted file mode 100644 index 440a469..0000000 --- a/spec/async/container/threaded_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright, 2018, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container/threaded" - -require_relative 'shared_examples' - -RSpec.describe Async::Container::Threaded do - subject {described_class.new} - - it_behaves_like Async::Container - - it "should not be multiprocess" do - expect(described_class).to_not be_multiprocess - end -end diff --git a/spec/async/container_spec.rb b/spec/async/container_spec.rb deleted file mode 100644 index a9df396..0000000 --- a/spec/async/container_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright, 2017, by Samuel G. D. Williams. -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. - -require "async/container" - -RSpec.describe Async::Container do - it "can get processor count" do - expect(Async::Container.processor_count).to be >= 1 - end - - it "can get best container class" do - expect(Async::Container.best_container_class).to_not be_nil - end - - subject {Async::Container.new} - - it "can get best container class" do - expect(subject).to_not be_nil - - subject.stop - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb deleted file mode 100644 index d6bf0fb..0000000 --- a/spec/spec_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ - -require 'covered/rspec' - -# Shared rspec helpers: -require "async/rspec" - -RSpec.configure do |config| - # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" - - config.expect_with :rspec do |c| - c.syntax = :expect - end -end diff --git a/test/async/container.rb b/test/async/container.rb new file mode 100644 index 0000000..bdefa8c --- /dev/null +++ b/test/async/container.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2017-2024, by Samuel Williams. + +require "async/container" + +describe Async::Container do + with ".processor_count" do + it "can get processor count" do + expect(Async::Container.processor_count).to be >= 1 + end + + it "can override the processor count" do + env = {"ASYNC_CONTAINER_PROCESSOR_COUNT" => "8"} + + expect(Async::Container.processor_count(env)).to be == 8 + end + + it "fails on invalid processor count" do + env = {"ASYNC_CONTAINER_PROCESSOR_COUNT" => "-1"} + + expect do + Async::Container.processor_count(env) + end.to raise_exception(RuntimeError, message: be =~ /Invalid processor count/) + end + end + + with ".new" do + let(:container) {Async::Container.new} + + it "can get best container class" do + expect(container).not.to be_nil + container.stop + end + end + + with ".best" do + it "can get the best container class" do + expect(Async::Container.best_container_class).not.to be_nil + end + + it "can get the best container class if fork is not available" do + expect(subject).to receive(:fork?).and_return(false) + + expect(Async::Container.best_container_class).to be == Async::Container::Threaded + end + end +end diff --git a/test/async/container/channel.rb b/test/async/container/channel.rb new file mode 100644 index 0000000..9af3c4b --- /dev/null +++ b/test/async/container/channel.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "async/container/channel" + +describe Async::Container::Channel do + let(:channel) {subject.new} + + after do + @channel&.close + end + + it "can send and receive" do + channel.out.puts "Hello, World!" + + expect(channel.in.gets).to be == "Hello, World!\n" + end + + it "can send and receive JSON" do + channel.out.puts JSON.dump({hello: "world"}) + + expect(channel.receive).to be == {hello: "world"} + end + + it "can receive invalid JSON" do + channel.out.puts "Hello, World!" + + expect(channel.receive).to be == {line: "Hello, World!\n"} + end +end diff --git a/test/async/container/controller.rb b/test/async/container/controller.rb new file mode 100644 index 0000000..f57aa8f --- /dev/null +++ b/test/async/container/controller.rb @@ -0,0 +1,233 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2025, by Samuel Williams. + +require "async/container/controller" +require "async/container/controllers" + +describe Async::Container::Controller do + let(:controller) {subject.new} + + with "#to_s" do + it "can generate string representation" do + expect(controller.to_s).to be == "Async::Container::Controller stopped" + end + end + + with "#reload" do + it "can reuse keyed child" do + input, output = IO.pipe + + controller.instance_variable_set(:@output, output) + + def controller.setup(container) + container.spawn(key: "test") do |instance| + instance.ready! + + @output.write(".") + @output.flush + + sleep(0.2) + end + + container.spawn do |instance| + instance.ready! + + sleep(0.1) + + @output.write(",") + @output.flush + end + end + + controller.start + + expect(controller.state_string).to be == "running" + + expect(input.read(2)).to be == ".," + + controller.reload + + expect(input.read(1)).to be == "," + + controller.wait + end + end + + with "#start" do + it "can start up a container" do + expect(controller).to receive(:setup) + + controller.start + + expect(controller).to be(:running?) + expect(controller.container).not.to be_nil + + controller.stop + + expect(controller).not.to be(:running?) + expect(controller.container).to be_nil + end + + it "can spawn a reactor" do + def controller.setup(container) + container.async do |task| + task.sleep 0.001 + end + end + + controller.start + + statistics = controller.container.statistics + + expect(statistics.spawns).to be == 1 + + controller.stop + end + + it "propagates exceptions" do + def controller.setup(container) + raise "Boom!" + end + + expect do + controller.run + end.to raise_exception(Async::Container::SetupError) + end + end + + with "graceful controller" do + let(:controller_path) {Async::Container::Controllers.path_for("graceful")} + + let(:pipe) {IO.pipe} + let(:input) {pipe.first} + let(:output) {pipe.last} + + let(:pid) {@pid} + + def before + @pid = Process.spawn("bundle", "exec", controller_path, out: output) + output.close + + super + end + + def after(error = nil) + Process.kill(:TERM, @pid) + Process.wait(@pid) + + super + end + + it "has graceful shutdown" do + expect(input.gets).to be == "Ready...\n" + start_time = input.gets.to_f + + Process.kill(:INT, @pid) + + expect(input.gets).to be == "Graceful shutdown...\n" + graceful_shutdown_time = input.gets.to_f + + expect(input.gets).to be == "Exiting...\n" + exit_time = input.gets.to_f + + expect(exit_time - graceful_shutdown_time).to be >= 0.01 + end + end + + with "bad controller" do + let(:controller_path) {Async::Container::Controllers.path_for("bad")} + + let(:pipe) {IO.pipe} + let(:input) {pipe.first} + let(:output) {pipe.last} + + let(:pid) {@pid} + + def before + @pid = Process.spawn("bundle", "exec", controller_path, out: output) + output.close + + super + end + + def after(error = nil) + Process.kill(:TERM, @pid) + Process.wait(@pid) + + super + end + + it "fails to start" do + expect(input.gets).to be == "Ready...\n" + + Process.kill(:INT, @pid) + + expect(input.gets).to be == "Exiting...\n" + end + end + + with "signals" do + let(:controller_path) {Async::Container::Controllers.path_for("dots")} + + let(:pipe) {IO.pipe} + let(:input) {pipe.first} + let(:output) {pipe.last} + + let(:pid) {@pid} + + def before + @pid = Process.spawn("bundle", "exec", controller_path, out: output) + output.close + + super + end + + def after(error = nil) + Process.kill(:TERM, @pid) + Process.wait(@pid) + + super + end + + it "restarts children when receiving SIGHUP" do + expect(input.read(1)).to be == "." + + Process.kill(:HUP, pid) + + expect(input.read(2)).to be == "I." + end + + it "exits gracefully when receiving SIGINT" do + expect(input.read(1)).to be == "." + + Process.kill(:INT, pid) + + expect(input.read).to be == "I" + end + + it "exits gracefully when receiving SIGTERM" do + expect(input.read(1)).to be == "." + + Process.kill(:TERM, pid) + + expect(input.read).to be == "T" + end + end + + with "working directory" do + let(:controller_path) {Async::Container::Controllers.path_for("working_directory")} + + it "can change working directory" do + pipe = IO.pipe + + pid = Process.spawn("bundle", "exec", controller_path, out: pipe.last) + pipe.last.close + + expect(pipe.first.gets(chomp: true)).to be == "/" + ensure + Process.kill(:INT, pid) if pid + end + end +end diff --git a/test/async/container/forked.rb b/test/async/container/forked.rb new file mode 100644 index 0000000..b1ad306 --- /dev/null +++ b/test/async/container/forked.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2024, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. + +require "async/container/best" +require "async/container/forked" +require "async/container/a_container" + +describe Async::Container::Forked do + let(:container) {subject.new} + + it_behaves_like Async::Container::AContainer + + it "can restart child" do + trigger = IO.pipe + pids = IO.pipe + + thread = Thread.new do + container.async(restart: true) do + trigger.first.gets + pids.last.puts Process.pid.to_s + end + + container.wait + end + + 3.times do + trigger.last.puts "die" + _child_pid = pids.first.gets + end + + thread.kill + thread.join + + expect(container.statistics.spawns).to be == 1 + expect(container.statistics.restarts).to be == 2 + end + + it "can handle interrupts" do + finished = IO.pipe + interrupted = IO.pipe + + container.spawn(restart: true) do |instance| + Thread.handle_interrupt(Interrupt => :never) do + instance.ready! + + finished.first.gets + rescue ::Interrupt + interrupted.last.puts "incorrectly interrupted" + end + rescue ::Interrupt + interrupted.last.puts "correctly interrupted" + end + + container.wait_until_ready + + container.group.interrupt + sleep(0.001) + finished.last.puts "finished" + + expect(interrupted.first.gets).to be == "correctly interrupted\n" + + container.stop + end + + it "should be multiprocess" do + expect(subject).to be(:multiprocess?) + end +end if Async::Container.fork? diff --git a/test/async/container/hybrid.rb b/test/async/container/hybrid.rb new file mode 100644 index 0000000..48f50a1 --- /dev/null +++ b/test/async/container/hybrid.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2019-2024, by Samuel Williams. + +require "async/container/hybrid" +require "async/container/best" +require "async/container/a_container" + +describe Async::Container::Hybrid do + it_behaves_like Async::Container::AContainer + + it "should be multiprocess" do + expect(subject).to be(:multiprocess?) + end +end if Async::Container.fork? diff --git a/test/async/container/notify.rb b/test/async/container/notify.rb new file mode 100644 index 0000000..5323726 --- /dev/null +++ b/test/async/container/notify.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2024, by Samuel Williams. + +require "async/container/controller" +require "async/container/notify/server" + +require "async" + +describe Async::Container::Notify do + let(:server) {subject::Server.open} + let(:notify_socket) {server.path} + let(:client) {subject::Socket.new(notify_socket)} + + it "can send and receive messages" do + context = server.bind + + client.send(true: true, false: false, hello: "world") + + message = context.receive + + expect(message).to be == {true: true, false: false, hello: "world"} + end + + with "#ready!" do + it "should send message" do + begin + context = server.bind + + pid = fork do + client.ready! + end + + messages = [] + + Sync do + context.receive do |message, address| + messages << message + break + end + end + + expect(messages.last).to have_keys( + ready: be == true + ) + ensure + context&.close + Process.wait(pid) if pid + end + end + end + + with "#send" do + it "sends message" do + context = server.bind + + client.send(hello: "world") + + message = context.receive + + expect(message).to be == {hello: "world"} + end + + it "fails if the message is too big" do + context = server.bind + + expect do + client.send(test: "x" * (subject::Socket::MAXIMUM_MESSAGE_SIZE+1)) + end.to raise_exception(ArgumentError, message: be =~ /Message length \d+ exceeds \d+/) + end + end + + with "#stopping!" do + it "sends stopping message" do + context = server.bind + + client.stopping! + + message = context.receive + + expect(message).to have_keys( + stopping: be == true + ) + end + end + + with "#error!" do + it "sends error message" do + context = server.bind + + client.error!("Boom!") + + message = context.receive + + expect(message).to have_keys( + status: be == "Boom!", + errno: be == -1, + ) + end + end +end if Async::Container.fork? diff --git a/test/async/container/notify/log.rb b/test/async/container/notify/log.rb new file mode 100644 index 0000000..033cf4e --- /dev/null +++ b/test/async/container/notify/log.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. + +require "async/container/controller" +require "async/container/controllers" + +require "tmpdir" + +describe Async::Container::Notify::Pipe do + let(:notify_script) {Async::Container::Controllers.path_for("notify")} + let(:notify_log) {File.expand_path("notify-#{::Process.pid}-#{SecureRandom.hex(8)}.log", Dir.tmpdir)} + + it "receives notification of child status" do + system({"NOTIFY_LOG" => notify_log}, "bundle", "exec", notify_script) + + lines = File.readlines(notify_log).map{|line| JSON.parse(line)} + + expect(lines.last).to have_keys( + "ready" => be == true, + "size" => be > 0, + ) + end +end diff --git a/test/async/container/notify/pipe.rb b/test/async/container/notify/pipe.rb new file mode 100644 index 0000000..035c424 --- /dev/null +++ b/test/async/container/notify/pipe.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. + +require "async/container/controller" +require "async/container/controllers" + +describe Async::Container::Notify::Pipe do + let(:notify_script) {Async::Container::Controllers.path_for("notify")} + + it "receives notification of child status" do + container = Async::Container.new + + container.spawn(restart: false) do |instance| + instance.exec( + "bundle", "exec", + notify_script, ready: false + ) + end + + # Wait for the state to be updated by the child process: + container.sleep + + _child, state = container.state.first + expect(state).to be == {status: "Initializing controller..."} + + container.wait + + expect(container.statistics).to have_attributes(failures: be == 0) + end +end diff --git a/test/async/container/notify/socket.rb b/test/async/container/notify/socket.rb new file mode 100644 index 0000000..dc54168 --- /dev/null +++ b/test/async/container/notify/socket.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2020-2025, by Samuel Williams. +# Copyright, 2020, by Olle Jonsson. + +require "async/container/notify/socket" + +describe Async::Container::Notify::Socket do + with ".open!" do + it "can open a socket" do + socket = subject.open!({subject::NOTIFY_SOCKET => "test"}) + + expect(socket).to have_attributes(path: be == "test") + end + end +end diff --git a/test/async/container/statistics.rb b/test/async/container/statistics.rb new file mode 100644 index 0000000..ccf54ef --- /dev/null +++ b/test/async/container/statistics.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "async/container/statistics" + +describe Async::Container::Statistics do + let(:statistics) {subject.new} + + with "#spawn!" do + it "can count spawns" do + expect(statistics.spawns).to be == 0 + + statistics.spawn! + + expect(statistics.spawns).to be == 1 + end + end + + with "#restart!" do + it "can count restarts" do + expect(statistics.restarts).to be == 0 + + statistics.restart! + + expect(statistics.restarts).to be == 1 + end + end + + with "#failure!" do + it "can count failures" do + expect(statistics.failures).to be == 0 + + statistics.failure! + + expect(statistics.failures).to be == 1 + end + end + + with "#failed?" do + it "can check for failures" do + expect(statistics).not.to be(:failed?) + + statistics.failure! + + expect(statistics).to be(:failed?) + end + end + + with "#<<" do + it "can append statistics" do + other = subject.new + + other.spawn! + other.restart! + other.failure! + + statistics << other + + expect(statistics.spawns).to be == 1 + expect(statistics.restarts).to be == 1 + expect(statistics.failures).to be == 1 + end + end +end \ No newline at end of file diff --git a/test/async/container/threaded.rb b/test/async/container/threaded.rb new file mode 100644 index 0000000..f6c078e --- /dev/null +++ b/test/async/container/threaded.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2018-2024, by Samuel Williams. + +require "async/container/threaded" +require "async/container/a_container" + +describe Async::Container::Threaded do + it_behaves_like Async::Container::AContainer + + it "should not be multiprocess" do + expect(subject).not.to be(:multiprocess?) + end +end