From 058df311f681df3e2770dd1a6319e462bc2bc6d4 Mon Sep 17 00:00:00 2001 From: Daniel Azuma Date: Fri, 2 Jan 2026 15:47:53 -0800 Subject: [PATCH] feat: Update for Ruby 4.0 --- .github/workflows/ci.yml | 32 +- .rubocop.yml | 263 +++++++++++- .toys/.toys.rb | 31 +- .toys/ci.rb | 17 + Gemfile | 16 +- LICENSE.md | 2 +- README.md | 116 +++--- lib/ractor-wrapper.rb | 2 + lib/ractor/wrapper.rb | 728 ++++++++++++++++++++-------------- lib/ractor/wrapper/version.rb | 4 +- ractor-wrapper.gemspec | 8 +- test/data/numbers.db | Bin 0 -> 8192 bytes test/helper.rb | 12 +- test/test_example.rb | 37 ++ test/test_wrapper.rb | 78 +++- 15 files changed, 899 insertions(+), 447 deletions(-) create mode 100644 .toys/ci.rb create mode 100644 test/data/numbers.db create mode 100644 test/test_example.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8d5988..5586374 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,32 +16,28 @@ jobs: matrix: include: - os: ubuntu-latest - ruby: "3.0" - tool: test + ruby: "4.0" + tool: "--test" - os: macos-latest - ruby: "3.0" - tool: test - - os: windows-latest - ruby: "3.0" - tool: test + ruby: "4.0" + tool: "--test" - os: ubuntu-latest - ruby: "3.0" - tool: rubocop - - os: ubuntu-latest - ruby: "3.0" - tool: "build , yardoc" + ruby: "4.0" + tool: "--rubocop --build --yard" fail-fast: false runs-on: ${{ matrix.os }} steps: + - name: Checkout repo + uses: actions/checkout@v4 - name: Install Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - - name: Checkout repo - uses: actions/checkout@v2 - - name: Install dependencies + bundler: default + bundler-cache: true + - name: Install toys shell: bash - run: "bundle install && gem install --no-document toys" - - name: Run ${{ matrix.tool || 'test' }} + run: "gem install --no-document toys" + - name: Run ${{ matrix.tool }} shell: bash - run: toys do ${{ matrix.tool || 'test' }} < /dev/null + run: toys ci --only ${{ matrix.tool }} < /dev/null diff --git a/.rubocop.yml b/.rubocop.yml index 004e2f4..57a8912 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,45 +1,108 @@ AllCops: NewCops: disable SuggestExtensions: false - TargetRubyVersion: 3.0 + TargetRubyVersion: 4.0 -Gemspec/DateAssignment: +Gemspec/AddRuntimeDependency: Enabled: true +Gemspec/AttributeAssignment: + Enabled: true +Gemspec/DeprecatedAttributeAssignment: + Enabled: true +Gemspec/DevelopmentDependencies: + Enabled: true +Gemspec/RequireMFA: + Enabled: false + Layout/EmptyLineAfterGuardClause: Enabled: false +Layout/EmptyLinesAfterModuleInclusion: + Enabled: true +Layout/HashAlignment: + Enabled: false +Layout/LineContinuationLeadingSpace: + Enabled: false +Layout/LineContinuationSpacing: + Enabled: true +Layout/LineEndStringConcatenationIndentation: + Enabled: false Layout/LineLength: - Max: 100 - Exclude: - - "*.gemspec" + Max: 120 Layout/SpaceBeforeBrackets: Enabled: true Layout/SpaceInsideHashLiteralBraces: Enabled: false + Lint/AmbiguousAssignment: Enabled: true +Lint/AmbiguousOperatorPrecedence: + Enabled: true +Lint/AmbiguousRange: + Enabled: true +Lint/ArrayLiteralInRegexp: + Enabled: true +Lint/ConstantOverwrittenInRescue: + Enabled: true +Lint/ConstantReassignment: + Enabled: true +Lint/CopDirectiveSyntax: + Enabled: true Lint/DeprecatedConstants: Enabled: true Lint/DuplicateBranch: Enabled: true +Lint/DuplicateMagicComment: + Enabled: true +Lint/DuplicateMatchPattern: + Enabled: true Lint/DuplicateRegexpCharacterClassElement: Enabled: true +Lint/DuplicateSetElement: + Enabled: true Lint/EmptyBlock: Enabled: true Lint/EmptyClass: + AllowComments: true + Enabled: true +Lint/EmptyInPattern: + Enabled: true +Lint/HashNewWithKeywordArgumentsAsDefault: + Enabled: true +Lint/IncompatibleIoSelectWithFiberScheduler: + Enabled: true +Lint/ItWithoutArgumentsInBlock: Enabled: true Lint/LambdaWithoutLiteralBlock: Enabled: true +Lint/LiteralAssignmentInCondition: + Enabled: true +Lint/MixedCaseRange: + Enabled: true Lint/NoReturnInBeginEndBlocks: Enabled: true +Lint/NonAtomicFileOperation: + Enabled: true Lint/NumberedParameterAssignment: Enabled: true -Lint/OrAssignmentToConstant: +Lint/NumericOperationWithConstantResult: Enabled: true -Lint/RaiseException: +Lint/OrAssignmentToConstant: Enabled: true Lint/RedundantDirGlobSort: Enabled: true -Lint/StructNewOverride: +Lint/RedundantRegexpQuantifiers: + Enabled: true +Lint/RedundantTypeConversion: + Enabled: true +Lint/RefinementImportMethods: + Enabled: true +Lint/RequireRangeParentheses: + Enabled: true +Lint/RequireRelativeSelfPath: + Enabled: true +Lint/SharedMutableDefault: + Enabled: true +Lint/SuppressedExceptionInNumberConversion: Enabled: true Lint/SymbolConversion: Enabled: true @@ -47,12 +110,29 @@ Lint/ToEnumArguments: Enabled: true Lint/TripleQuotes: Enabled: true +Lint/UnescapedBracketInRegexp: + Enabled: true Lint/UnexpectedBlockArity: Enabled: true Lint/UnmodifiedReduceAccumulator: Enabled: true +Lint/UselessConstantScoping: + Enabled: true +Lint/UselessDefaultValueArgument: + Enabled: true +Lint/UselessDefined: + Enabled: true +Lint/UselessNumericOperation: + Enabled: true +Lint/UselessOr: + Enabled: true +Lint/UselessRescue: + Enabled: true +Lint/UselessRuby2Keywords: + Enabled: true + Metrics/AbcSize: - Max: 25 + Max: 30 Metrics/BlockLength: Exclude: - "test/**/*" @@ -62,23 +142,46 @@ Metrics/BlockNesting: - "test/**/*" Metrics/ClassLength: Max: 500 +Metrics/CollectionLiteralLength: + Enabled: true Metrics/CyclomaticComplexity: Max: 10 Metrics/MethodLength: - Max: 25 + Max: 30 Metrics/ModuleLength: Max: 500 Metrics/ParameterLists: Enabled: false Metrics/PerceivedComplexity: Max: 10 + +Naming/BlockForwarding: + Enabled: true Naming/FileName: Exclude: - "lib/ractor-wrapper.rb" +Naming/PredicateMethod: + Enabled: false + +Security/CompoundHash: + Enabled: true +Security/IoMethods: + Enabled: true + Style/AccessorGrouping: - EnforcedStyle: separated + Enabled: false +Style/AmbiguousEndlessMethodDefinition: + Enabled: true Style/ArgumentsForwarding: Enabled: true +Style/ArrayIntersect: + Enabled: true +Style/ArrayIntersectWithSingleElement: + Enabled: true +Style/BisectedAttrAccessor: + Enabled: false +Style/BitwisePredicate: + Enabled: true Style/BlockDelimiters: Exclude: - "test/**/*" @@ -86,6 +189,22 @@ Style/CaseEquality: Enabled: false Style/CollectionCompact: Enabled: true +Style/CollectionQuerying: + Enabled: true +Style/CombinableDefined: + Enabled: true +Style/ComparableBetween: + Enabled: true +Style/ComparableClamp: + Enabled: true +Style/ConcatArrayLiterals: + Enabled: true +Style/DataInheritance: + Enabled: true +Style/DigChain: + Enabled: true +Style/DirEmpty: + Enabled: true Style/DocumentDynamicEvalDefinition: Enabled: true Style/Documentation: @@ -96,40 +215,144 @@ Style/DocumentationMethod: Exclude: - ".toys.rb" - "test/**/*" +Style/EmptyHeredoc: + Enabled: true +Style/EmptyStringInsideInterpolation: + Enabled: true Style/EndlessMethod: Enabled: true -Style/FrozenStringLiteralComment: +Style/EnvHome: + Enabled: true +Style/ExactRegexpMatch: + Enabled: true +Style/FetchEnvVar: Enabled: false +Style/FileEmpty: + Enabled: true +Style/FileNull: + Enabled: true +Style/FileRead: + Enabled: true +Style/FileTouch: + Enabled: true +Style/FileWrite: + Enabled: true Style/GuardClause: Enabled: false Style/HashConversion: Enabled: true -Style/HashEachMethods: - Enabled: true Style/HashExcept: Enabled: true -Style/HashTransformKeys: - Enabled: false -Style/HashTransformValues: - Enabled: false +Style/HashFetchChain: + Enabled: true +Style/HashSlice: + Enabled: true Style/IfUnlessModifier: Enabled: false Style/IfWithBooleanLiteralBranches: Enabled: true +Style/InPatternThen: + Enabled: true +Style/ItAssignment: + Enabled: true +Style/ItBlockParameter: + Enabled: true +Style/KeywordArgumentsMerging: + Enabled: true +Style/MagicCommentFormat: + Enabled: true +Style/MapCompactWithConditionalBlock: + Enabled: true +Style/MapIntoArray: + Enabled: true +Style/MapToHash: + Enabled: true +Style/MapToSet: + Enabled: true +Style/MinMaxComparison: + Enabled: true +Style/ModuleMemberExistenceCheck: + Enabled: true +Style/MultilineInPatternThen: + Enabled: true Style/NegatedIfElseCondition: Enabled: true +Style/NestedFileDirname: + Enabled: true Style/Next: Enabled: false Style/NilLambda: Enabled: true -Style/NumericLiterals: +Style/NumberedParameters: + Enabled: true +Style/NumberedParametersLimit: + Enabled: true +Style/ObjectThen: + Enabled: true +Style/OpenStructUse: + Enabled: true +Style/OperatorMethodCall: + Enabled: true +Style/OptionalBooleanParameter: Enabled: false +Style/QuotedSymbols: + Enabled: true Style/RedundantArgument: Enabled: true +Style/RedundantArrayConstructor: + Enabled: true +Style/RedundantArrayFlatten: + Enabled: true +Style/RedundantConstantBase: + Enabled: false +Style/RedundantCurrentDirectoryInPath: + Enabled: true +Style/RedundantDoubleSplatHashBraces: + Enabled: true +Style/RedundantEach: + Enabled: true +Style/RedundantFilterChain: + Enabled: true +Style/RedundantFormat: + Enabled: true +Style/RedundantHeredocDelimiterQuotes: + Enabled: true +Style/RedundantInitialize: + Enabled: true +Style/RedundantInterpolationUnfreeze: + Enabled: true +Style/RedundantLineContinuation: + Enabled: true +Style/RedundantRegexpArgument: + Enabled: true +Style/RedundantRegexpConstructor: + Enabled: true +Style/RedundantSelfAssignmentBranch: + Enabled: true +Style/RedundantStringEscape: + Enabled: true +Style/ReturnNilInPredicateMethodDefinition: + Enabled: false +Style/SafeNavigationChainLength: + Enabled: true +Style/SelectByRegexp: + Enabled: true +Style/SendWithLiteralMethodName: + Enabled: true +Style/SingleLineDoEndBlock: + Enabled: true +Style/SoleNestedConditional: + Enabled: false Style/StderrPuts: Enabled: false +Style/StringChars: + Enabled: true Style/StringLiterals: EnforcedStyle: double_quotes +Style/SuperArguments: + Enabled: true +Style/SuperWithArgsParentheses: + Enabled: true Style/SwapValues: Enabled: true Style/SymbolArray: @@ -142,3 +365,5 @@ Style/WhileUntilModifier: Enabled: false Style/WordArray: EnforcedStyle: brackets +Style/YAMLFileRead: + Enabled: true diff --git a/.toys/.toys.rb b/.toys/.toys.rb index 3f5701a..be85f24 100644 --- a/.toys/.toys.rb +++ b/.toys/.toys.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + expand :clean, paths: :gitignore expand :minitest, libs: ["lib", "test"], bundler: true @@ -16,32 +18,3 @@ expand :gem_build, name: "release", push_gem: true expand :gem_build, name: "install", install_gem: true - -tool "ci" do - desc "Run all CI checks" - - long_desc "The 'ci' tool runs all CI checks, including unit tests," \ - " rubocop, and documentation checks. It is useful for running" \ - " tests locally during normal development, as well as being" \ - " an entrypoint for CI systems. Any failure will result in a" \ - " nonzero result code." - - include :exec, result_callback: :handle_result - include :terminal - - def handle_result(result) - if result.success? - puts("** #{result.name} passed\n\n", :green, :bold) - else - puts("** CI terminated: #{result.name} failed!", :red, :bold) - exit(1) - end - end - - def run - exec_tool(["test"], name: "Tests") - exec_tool(["rubocop"], name: "Style checker") - exec_tool(["yardoc"], name: "Docs generation") - exec_tool(["build"], name: "Gem build") - end -end diff --git a/.toys/ci.rb b/.toys/ci.rb new file mode 100644 index 0000000..b9fe216 --- /dev/null +++ b/.toys/ci.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +load_git remote: "https://github.com/dazuma/toys.git", + path: "common-tools/ci", + update: 3600 + +desc "Run all CI checks" + +expand("toys-ci") do |toys_ci| + toys_ci.only_flag = true + toys_ci.fail_fast_flag = true + toys_ci.job("Bundle update", flag: :bundle, exec: ["bundle", "update", "--all"]) + toys_ci.job("Rubocop", flag: :rubocop, tool: ["rubocop"]) + toys_ci.job("Tests", flag: :test, tool: ["test"]) + toys_ci.job("Yardoc", flag: :yard, tool: ["yardoc"]) + toys_ci.job("Gem build", flag: :build, tool: ["build"]) +end diff --git a/Gemfile b/Gemfile index 28f26cc..ec945f3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,14 @@ +# frozen_string_literal: true + source "https://rubygems.org" gemspec -gem "minitest", "~> 5.14" -gem "minitest-focus", "~> 1.1" -gem "minitest-rg", "~> 5.2" -gem "redcarpet", "~> 3.5" -gem "rubocop", "~> 1.11" -gem "yard", "~> 0.9", ">= 0.9.26" +gem "faraday", "~> 2.14" +gem "minitest", "~> 6.0", ">= 6.0.1" +gem "minitest-focus", "~> 1.4", ">= 1.4.1" +gem "minitest-rg", "~> 5.4" +gem "redcarpet", "~> 3.6", ">= 3.6.1" +gem "rubocop", "~> 1.82", ">= 1.82.1" +gem "sqlite3", "~> 2.9" +gem "yard", "~> 0.9", ">= 0.9.38" diff --git a/LICENSE.md b/LICENSE.md index 6249eaf..3892847 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # License -Copyright 2021 Daniel Azuma +Copyright 2021-2026 Daniel Azuma Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 16314ea..e3c31d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ `Ractor::Wrapper` is an experimental class that wraps a non-shareable object, allowing multiple Ractors to access it concurrently. This can make it possible -for multiple ractors to share an object such as a database connection. +for Ractors to share a "plain" object such as a database connection. + +**WARNING:** This is a highly experimental library, and currently _not_ +recommended for production use. (As of Ruby 4.0.0, the same can be said of +Ractors in general.) ## Quick start @@ -16,33 +20,29 @@ Require it in your code: You can then create wrappers for objects. See the example below. -`Ractor::Wrapper` requires Ruby 3.0.0 or later. - -WARNING: This is a highly experimental library, and currently _not_ recommended -for production use. (As of Ruby 3.0.0, the same can be said of Ractors in -general.) +`Ractor::Wrapper` requires Ruby 4.0.0 or later. ## About Ractor::Wrapper -Ractors for the most part cannot access objects concurrently with other -Ractors unless the object is _shareable_ (that is, deeply immutable along -with a few other restrictions.) If multiple Ractors need to interact with a -shared resource that is stateful or otherwise not Ractor-shareable, that -resource must itself be implemented and accessed as a Ractor. +Ractors for the most part cannot access objects concurrently with other Ractors +unless the object is _shareable_, which generally means deeply immutable along +with a few other restrictions. If multiple Ractors need to interact with a +shared resource that is stateful or otherwise not shareable that resource must +itself be implemented and accessed as a Ractor. `Ractor::Wrapper` makes it possible for such a shared resource to be -implemented as an ordinary object and accessed using ordinary method calls. It -does this by "wrapping" the object in a Ractor, and mapping method calls to -message passing. This may make it easier to implement such a resource with -a simple class rather than a full-blown Ractor with message passing, and it -may also useful for adapting existing legacy object-based implementations. +implemented as an object and accessed using ordinary method calls. It does this +by "wrapping" the object in a Ractor, and mapping method calls to message +passing. This may make it easier to implement such a resource with a simple +class rather than a full-blown Ractor with message passing, and it may also be +useful for adapting existing object-based resources. Given a shared resource object, `Ractor::Wrapper` starts a new Ractor and -"runs" the object within that Ractor. It provides you with a stub object -on which you can invoke methods. The wrapper responds to these method calls -by sending messages to the internal Ractor, which invokes the shared object -and then sends back the result. If the underlying object is thread-safe, -you can configure the wrapper to run multiple threads that can run methods +"runs" the object within that Ractor. It provides you with a stub object on +which you can invoke methods. The wrapper responds to these method calls by +sending messages to the internal Ractor, which invokes the shared object and +then sends back the result. If the underlying object is thread-safe, you can +configure the wrapper to run multiple threads that can run methods concurrently. Or, if not, the wrapper can serialize requests to the object. ### Example usage @@ -52,37 +52,48 @@ object among multiple Ractors. Because `Faraday::Connection` is not itself thread-safe, this example serializes all calls to it. ```ruby -require "faraday" require "ractor/wrapper" +require "faraday" + +# Create a Faraday connection. Faraday connections are not shareable, +# so normally only one Ractor can access them at a time. +connection = Faraday.new("http://example.com") -# Create a Faraday connection and a wrapper for it. -connection = Faraday.new "http://example.com" +# Create a wrapper around the connection. This starts up an internal +# Ractor and "moves" the connection object to that Ractor. wrapper = Ractor::Wrapper.new(connection) -# At this point, the connection object cannot be accessed directly -# because it has been "moved" to the wrapper's internal Ractor. +# At this point, the connection object can no longer be accessed +# directly because it is now owned by the wrapper's internal Ractor. # connection.get("/whoops") # <= raises an error -# However, any number of Ractors can now access it through the wrapper. -# By default, access to the object is serialized; methods will not be -# invoked concurrently. (To allow concurrent access, set up threads when -# creating the wrapper.) -r1 = Ractor.new(wrapper) do |w| +# However, you can access the connection via the stub object provided +# by the wrapper. This stub proxies the call to the wrapper's internal +# Ractor. And it's shareable, so any number of Ractors can use it. +wrapper.stub.get("/hello") + +# Here, we start two Ractors, and pass the stub to each one. Each +# Ractor can simply call methods on the stub as if it were the original +# connection object. (Internally, of course, the calls are proxied back +# to the wrapper.) By default, all calls are serialized. However, if +# you know that the underlying object is thread-safe, you can configure +# a wrapper to run calls concurrently. +r1 = Ractor.new(wrapper.stub) do |conn| 10.times do - w.stub.get("/hello") + conn.get("/hello") end :ok end -r2 = Ractor.new(wrapper) do |w| +r2 = Ractor.new(wrapper.stub) do |conn| 10.times do - w.stub.get("/ruby") + conn.get("/ruby") end :ok end # Wait for the two above Ractors to finish. -r1.take -r2.take +r1.join +r2.join # After you stop the wrapper, you can retrieve the underlying # connection object and access it directly again. @@ -93,31 +104,30 @@ connection.get("/finally") ### Features -* Provides a method interface to an object running in a different Ractor. +* Provides a method interface to an object running in its own Ractor. * Supports arbitrary method arguments and return values. -* Supports exceptions thrown by the method. -* Can be configured to copy or move arguments, return values, and - exceptions, per method. -* Can serialize method calls for non-concurrency-safe objects, or run - methods concurrently in multiple worker threads for thread-safe objects. +* Can be configured per method whether to copy or move arguments and + return values. +* Blocks can be run in the calling Ractor or in the object Ractor. +* Raises exceptions thrown by the method. +* Can serialize method calls for non-thread-safe objects, or run methods + concurrently in multiple worker threads for thread-safe objects. * Can gracefully shut down the wrapper and retrieve the original object. ### Caveats Ractor::Wrapper is subject to some limitations (and bugs) of Ractors, as of -Ruby 3.0.0. +Ruby 4.0.0. -* You cannot pass blocks to wrapped methods. +* You can run blocks in the object's Ractor only if the block does not + access any data outside the block. Otherwise, the block must be run in + the calling Ractor. * Certain types cannot be used as method arguments or return values because Ractor does not allow them to be moved between Ractors. These - include threads, procs, backtraces, and a few others. -* You can call wrapper methods from multiple Ractors concurrently, but - you cannot call them from multiple Threads within a single Ractor. - (This is due to https://bugs.ruby-lang.org/issues/17624) -* If you close the incoming port on a Ractor, it will no longer be able - to call out via a wrapper. If you close its incoming port while a call - is currently pending, that call may hang. (This is due to - https://bugs.ruby-lang.org/issues/17617) + include threads, backtraces, and a few others. +* Any exceptions raised are always copied back to the calling Ractor, and + the backtrace is cleared out. This is due to + https://bugs.ruby-lang.org/issues/21818 ## Contributing @@ -136,7 +146,7 @@ unit tests, rubocop, and builds independently. ## License -Copyright 2021 Daniel Azuma +Copyright 2021-2026 Daniel Azuma Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/lib/ractor-wrapper.rb b/lib/ractor-wrapper.rb index d03b1bd..6a7b4f3 100644 --- a/lib/ractor-wrapper.rb +++ b/lib/ractor-wrapper.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + require "ractor/wrapper" diff --git a/lib/ractor/wrapper.rb b/lib/ractor/wrapper.rb index 4777382..8aadc06 100644 --- a/lib/ractor/wrapper.rb +++ b/lib/ractor/wrapper.rb @@ -1,29 +1,32 @@ +# frozen_string_literal: true + ## # See ruby-doc.org for info on Ractors. # class Ractor ## # An experimental class that wraps a non-shareable object, allowing multiple - # Ractors to access it concurrently. + # Ractors to access it concurrently. This can make it possible for Ractors to + # share a "plain" object such as a database connection. # # WARNING: This is a highly experimental library, and currently _not_ - # recommended for production use. (As of Ruby 3.0.0, the same can be said of + # recommended for production use. (As of Ruby 4.0.0, the same can be said of # Ractors in general.) # # ## What is Ractor::Wrapper? # # Ractors for the most part cannot access objects concurrently with other - # Ractors unless the object is _shareable_ (that is, deeply immutable along - # with a few other restrictions.) If multiple Ractors need to interact with a - # shared resource that is stateful or otherwise not Ractor-shareable, that - # resource must itself be implemented and accessed as a Ractor. + # Ractors unless the object is _shareable_, which generally means deeply + # immutable along with a few other restrictions. If multiple Ractors need to + # interact with a shared resource that is stateful or otherwise not shareable + # that resource must itself be implemented and accessed as a Ractor. # # `Ractor::Wrapper` makes it possible for such a shared resource to be # implemented as an object and accessed using ordinary method calls. It does # this by "wrapping" the object in a Ractor, and mapping method calls to # message passing. This may make it easier to implement such a resource with # a simple class rather than a full-blown Ractor with message passing, and it - # may also useful for adapting existing legacy object-based implementations. + # may also be useful for adapting existing object-based resources. # # Given a shared resource object, `Ractor::Wrapper` starts a new Ractor and # "runs" the object within that Ractor. It provides you with a stub object @@ -39,35 +42,48 @@ class Ractor # object among multiple Ractors. Because `Faraday::Connection` is not itself # thread-safe, this example serializes all calls to it. # + # require "ractor/wrapper" # require "faraday" # - # # Create a Faraday connection and a wrapper for it. - # connection = Faraday.new "http://example.com" + # # Create a Faraday connection. Faraday connections are not shareable, + # # so normally only one Ractor can access them at a time. + # connection = Faraday.new("http://example.com") + # + # # Create a wrapper around the connection. This starts up an internal + # # Ractor and "moves" the connection object to that Ractor. # wrapper = Ractor::Wrapper.new(connection) # - # # At this point, the connection object cannot be accessed directly - # # because it has been "moved" to the wrapper's internal Ractor. + # # At this point, the connection object can no longer be accessed + # # directly because it is now owned by the wrapper's internal Ractor. # # connection.get("/whoops") # <= raises an error # - # # However, any number of Ractors can now access it through the wrapper. - # # By default, access to the object is serialized; methods will not be - # # invoked concurrently. - # r1 = Ractor.new(wrapper) do |w| + # # However, you can access the connection via the stub object provided + # # by the wrapper. This stub proxies the call to the wrapper's internal + # # Ractor. And it's shareable, so any number of Ractors can use it. + # wrapper.stub.get("/hello") + # + # # Here, we start two Ractors, and pass the stub to each one. Each + # # Ractor can simply call methods on the stub as if it were the original + # # connection object. (Internally, of course, the calls are proxied back + # # to the wrapper.) By default, all calls are serialized. However, if + # # you know that the underlying object is thread-safe, you can configure + # # a wrapper to run calls concurrently. + # r1 = Ractor.new(wrapper.stub) do |conn| # 10.times do - # w.stub.get("/hello") + # conn.get("/hello") # end # :ok # end - # r2 = Ractor.new(wrapper) do |w| + # r2 = Ractor.new(wrapper.stub) do |conn| # 10.times do - # w.stub.get("/ruby") + # conn.get("/ruby") # end # :ok # end # # # Wait for the two above Ractors to finish. - # r1.take - # r2.take + # r1.join + # r2.join # # # After you stop the wrapper, you can retrieve the underlying # # connection object and access it directly again. @@ -77,71 +93,186 @@ class Ractor # # ## Features # - # * Provides a method interface to an object running in a different Ractor. + # * Provides a method interface to an object running in its own Ractor. # * Supports arbitrary method arguments and return values. - # * Supports exceptions thrown by the method. - # * Can be configured to copy or move arguments, return values, and - # exceptions, per method. - # * Can serialize method calls for non-concurrency-safe objects, or run - # methods concurrently in multiple worker threads for thread-safe objects. + # * Can be configured per method whether to copy or move arguments and + # return values. + # * Blocks can be run in the calling Ractor or in the object Ractor. + # * Raises exceptions thrown by the method. + # * Can serialize method calls for non-thread-safe objects, or run methods + # concurrently in multiple worker threads for thread-safe objects. # * Can gracefully shut down the wrapper and retrieve the original object. # # ## Caveats # # Ractor::Wrapper is subject to some limitations (and bugs) of Ractors, as of - # Ruby 3.0.0. + # Ruby 4.0.0. # - # * You cannot pass blocks to wrapped methods. + # * You can run blocks in the object Ractor only if the block does not + # access any data outside the block. Otherwise, the block must be run in + # the calling Ractor. # * Certain types cannot be used as method arguments or return values # because Ractor does not allow them to be moved between Ractors. These - # include threads, procs, backtraces, and a few others. - # * You can call wrapper methods from multiple Ractors concurrently, but - # you cannot call them from multiple Threads within a single Ractor. - # (This is due to https://bugs.ruby-lang.org/issues/17624) - # * If you close the incoming port on a Ractor, it will no longer be able - # to call out via a wrapper. If you close its incoming port while a call - # is currently pending, that call may hang. (This is due to - # https://bugs.ruby-lang.org/issues/17617) + # include threads, backtraces, and a few others. + # * Any exceptions raised are always copied back to the calling Ractor, and + # the backtrace is cleared out. This is due to + # https://bugs.ruby-lang.org/issues/21818 # class Wrapper + ## + # A stub that forwards calls to a wrapper. + # + # This object is shareable and can be passed to any Ractor. + # + class Stub + ## + # Create a stub given a wrapper. + # + # @param wrapper [Ractor::Wrapper] + # + def initialize(wrapper) + @wrapper = wrapper + freeze + end + + ## + # Forward calls to {Ractor::Wrapper#call}. + # @private + # + def method_missing(name, ...) + @wrapper.call(name, ...) + end + + ## + # Forward respond_to queries. + # @private + # + def respond_to_missing?(name, include_all) + @wrapper.call(:respond_to?, name, include_all) + end + end + + ## + # Settings for a method call. Specifies how a method's arguments and + # return value are communicated (i.e. copy or move semantics.) + # + class MethodSettings + # @private + def initialize(move: false, + move_arguments: nil, + move_result: nil, + execute_block_in_ractor: nil, + move_block_arguments: nil, + move_block_result: nil) + @move_arguments = interpret_setting(move_arguments, move) + @move_result = interpret_setting(move_result, move) + @execute_block_in_ractor = interpret_setting(execute_block_in_ractor, false) + @move_block_arguments = interpret_setting(move_block_arguments, move) + @move_block_result = interpret_setting(move_block_result, move) + freeze + end + + ## + # @return [Boolean] Whether to move arguments + # + def move_arguments? + @move_arguments + end + + ## + # @return [Boolean] Whether to move return values + # + def move_result? + @move_result + end + + ## + # @return [Boolean] Whether to call blocks in-ractor + # + def execute_block_in_ractor? + @execute_block_in_ractor + end + + ## + # @return [Boolean] Whether to move arguments to a block + # + def move_block_arguments? + @move_block_arguments + end + + ## + # @return [Boolean] Whether to move block results + # + def move_block_result? + @move_block_result + end + + private + + def interpret_setting(setting, default) + if setting.nil? + default ? true : false + else + setting ? true : false + end + end + end + ## # Create a wrapper around the given object. # - # If you pass an optional block, the wrapper itself will be yielded to it - # at which time you can set additional configuration options. (The - # configuration is frozen once the object is constructed.) + # If you pass an optional block, the wrapper itself will be yielded to it, + # at which time you can set additional configuration options. In + # particular, method-specific configuration must be set in this block. + # The configuration is frozen once the object is constructed. # # @param object [Object] The non-shareable object to wrap. - # @param threads [Integer] The number of worker threads to run. - # Defaults to 1, which causes the worker to serialize calls. + # @param name [String] A name for this wrapper. Used during logging. + # @param logging_enabled [boolean] Set to true to enable logging. Default + # is false. + # @param thread_count [Integer] The number of worker threads to run. + # Defaults to 1, which causes the worker to serialize calls into a + # single thread. + # @param move [boolean] If true, all communication will by default move + # instead of copy arguments and return values. Default is false. + # @param move_arguments [boolean] If true, all arguments will be moved + # instead of copied by default. If not set, uses the `:move` setting. + # @param move_result [boolean] If true, return values will be moved instead + # of copied by default. If not set, uses the `:move` setting. # def initialize(object, - threads: 1, + name: nil, + logging_enabled: false, + thread_count: 1, move: false, move_arguments: nil, - move_return: nil, - logging: false, - name: nil) + move_result: nil, + execute_block_in_ractor: nil, + move_block_arguments: nil, + move_block_result: nil) + raise ::Ractor::MovedError, "can not wrap a moved object" if ::Ractor::MovedObject === object + @method_settings = {} - self.threads = threads - self.logging = logging - self.name = name - configure_method(move: move, move_arguments: move_arguments, move_return: move_return) + self.name = name || object_id.to_s + self.logging_enabled = logging_enabled + self.thread_count = thread_count + configure_method(move: move, + move_arguments: move_arguments, + move_result: move_result, + execute_block_in_ractor: execute_block_in_ractor, + move_block_arguments: move_block_arguments, + move_block_result: move_block_result) yield self if block_given? @method_settings.freeze maybe_log("Starting server") - @ractor = ::Ractor.new(name: name) { Server.new.run } - opts = { - object: object, - threads: @threads, - method_settings: @method_settings, - name: @name, - logging: @logging, - } - @ractor.send(opts, move: true) - - maybe_log("Server ready") + @ractor = ::Ractor.new(self.name, name: "wrapper: #{name}") do |wrapper_name| + Server.new(wrapper_name).run + end + init_message = InitMessage.new(object: object, + logging_enabled: self.logging_enabled, + thread_count: self.thread_count) + @ractor.send(init_message, move: true) @stub = Stub.new(self) freeze end @@ -157,10 +288,10 @@ def initialize(object, # # @param value [Integer] # - def threads=(value) + def thread_count=(value) value = value.to_i value = 1 if value < 1 - @threads = value + @thread_count = value end ## @@ -171,8 +302,8 @@ def threads=(value) # # @param value [Boolean] # - def logging=(value) - @logging = value ? true : false + def logging_enabled=(value) + @logging_enabled = value ? true : false end ## @@ -200,18 +331,26 @@ def name=(value) # @param method_name [Symbol, nil] The name of the method being configured, # or `nil` to set defaults for all methods not configured explicitly. # @param move [Boolean] Whether to move all communication. This value, if - # given, is used if `move_arguments`, `move_return`, or + # given, is used if `move_arguments`, `move_result`, or # `move_exceptions` are not set. # @param move_arguments [Boolean] Whether to move arguments. - # @param move_return [Boolean] Whether to move return values. + # @param move_result [Boolean] Whether to move return values. # def configure_method(method_name = nil, move: false, move_arguments: nil, - move_return: nil) + move_result: nil, + execute_block_in_ractor: nil, + move_block_arguments: nil, + move_block_result: nil) method_name = method_name.to_sym unless method_name.nil? @method_settings[method_name] = - MethodSettings.new(move: move, move_arguments: move_arguments, move_return: move_return) + MethodSettings.new(move: move, + move_arguments: move_arguments, + move_result: move_result, + execute_block_in_ractor: execute_block_in_ractor, + move_block_arguments: move_block_arguments, + move_block_result: move_block_result) end ## @@ -227,19 +366,19 @@ def configure_method(method_name = nil, # # @return [Integer] # - attr_reader :threads + attr_reader :thread_count ## # Return whether logging is enabled for this wrapper. # # @return [Boolean] # - attr_reader :logging + attr_reader :logging_enabled ## # Return the name of this wrapper. # - # @return [String, nil] + # @return [String] # attr_reader :name @@ -265,20 +404,32 @@ def method_settings(method_name) # @param kwargs [keywords] The keyword arguments # @return [Object] The return value # - def call(method_name, *args, **kwargs) - request = Message.new(:call, data: [method_name, args, kwargs]) - transaction = request.transaction - move = method_settings(method_name).move_arguments? - maybe_log("Sending method #{method_name} (move=#{move}, transaction=#{transaction})") - @ractor.send(request, move: move) - reply = ::Ractor.receive_if { |msg| msg.is_a?(Message) && msg.transaction == transaction } - case reply.type - when :result - maybe_log("Received result for method #{method_name} (transaction=#{transaction})") - reply.data - when :error - maybe_log("Received exception for method #{method_name} (transaction=#{transaction})") - raise reply.data + def call(method_name, *args, **kwargs, &) + reply_port = ::Ractor::Port.new + transaction = ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze + settings = method_settings(method_name) + block_arg = settings.execute_block_in_ractor? ? ::Ractor.shareable_proc(&) : :message + message = CallMessage.new(method_name: method_name, + args: args, + kwargs: kwargs, + block_arg: block_arg, + transaction: transaction, + settings: settings, + reply_port: reply_port) + maybe_log("Sending method", method_name: method_name, transaction: transaction) + @ractor.send(message, move: settings.move_arguments?) + loop do + reply_message = reply_port.receive + case reply_message + when YieldMessage + handle_yield(reply_message, transaction, settings, method_name, &) + when ReturnMessage + maybe_log("Received result", method_name: method_name, transaction: transaction) + return reply_message.value + when ExceptionMessage + maybe_log("Received exception", method_name: method_name, transaction: transaction) + raise reply_message.exception + end end end @@ -286,20 +437,30 @@ def call(method_name, *args, **kwargs) # Request that the wrapper stop. All currently running calls will complete # before the wrapper actually terminates. However, any new calls will fail. # - # This metnod is idempotent and can be called multiple times (even from + # This method is idempotent and can be called multiple times (even from # different ractors). # # @return [self] # def async_stop - maybe_log("Stopping #{name}") - @ractor.send(Message.new(:stop)) + maybe_log("Stopping wrapper") + @ractor.send(StopMessage.new.freeze) self rescue ::Ractor::ClosedError # Ignore to allow stops to be idempotent. self end + ## + # Blocks until the wrapper has fully stopped. + # + # @return [self] + # + def join + @ractor.join + self + end + ## # Retrieves the original object that was wrapped. This should be called # only after a stop request has been issued using {#async_stop}, and may @@ -309,129 +470,85 @@ def async_stop # # @return [Object] The original wrapped object # - def recovered_object - @ractor.take + def recover_object + @ractor.value end - private + #### private items below #### - def maybe_log(str) - return unless logging - time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L") - $stderr.puts("[#{time} Ractor::Wrapper/#{name}]: #{str}") - $stderr.flush - end + ## + # @private + # Message sent to initialize a server. + # + InitMessage = ::Data.define(:object, :logging_enabled, :thread_count) ## - # A stub that forwards calls to a wrapper. + # @private + # Message sent to a server to call a method # - class Stub - ## - # Create a stub given a wrapper. - # - # @param wrapper [Ractor::Wrapper] - # - def initialize(wrapper) - @wrapper = wrapper - freeze - end + CallMessage = ::Data.define(:method_name, :args, :kwargs, :block_arg, + :transaction, :settings, :reply_port) - ## - # Forward calls to {Ractor::Wrapper#call}. - # @private - # - def method_missing(name, *args, **kwargs) - @wrapper.call(name, *args, **kwargs) - end + ## + # @private + # Message sent to a server when a worker thread terminates + # + WorkerStoppedMessage = ::Data.define(:worker_num) - ## - # Forward respond_to queries. - # @private - # - def respond_to_missing?(name, include_all) - @wrapper.call(:respond_to?, name, include_all) - end - end + ## + # @private + # Message sent to a server to request it to stop + # + StopMessage = ::Data.define ## - # Settings for a method call. Specifies how a method's arguments and - # return value are communicated (i.e. copy or move semantics.) + # @private + # Message sent to report a return value # - class MethodSettings - # @private - def initialize(move: false, - move_arguments: nil, - move_return: nil) - @move_arguments = interpret_setting(move_arguments, move) - @move_return = interpret_setting(move_return, move) - freeze - end + ReturnMessage = ::Data.define(:value) - ## - # @return [Boolean] Whether to move arguments - # - def move_arguments? - @move_arguments - end + ## + # @private + # Message sent to report an exception result + # + ExceptionMessage = ::Data.define(:exception) - ## - # @return [Boolean] Whether to move return values - # - def move_return? - @move_return - end + ## + # @private + # Message sent from a server to request a yield block run + # + YieldMessage = ::Data.define(:args, :kwargs, :reply_port) - private + private - def interpret_setting(setting, default) - if setting.nil? - default ? true : false - else - setting ? true : false + def handle_yield(message, transaction, settings, method_name) + maybe_log("Yielding to block", method_name: method_name, transaction: transaction) + begin + block_result = yield(*message.args, **message.kwargs) + maybe_log("Sending block result", method_name: method_name, transaction: transaction) + message.reply_port.send(ReturnMessage.new(block_result), move: settings.move_block_result?) + rescue ::Exception => e # rubocop:disable Lint/RescueException + maybe_log("Sending block exception", method_name: method_name, transaction: transaction) + begin + message.reply_port.send(ExceptionMessage.new(e)) + rescue ::StandardError + begin + message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect))) + rescue ::StandardError + maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction) + end end end end - ## - # The class of all messages passed between a client Ractor and a wrapper. - # This helps the wrapper distinguish these messages from any other messages - # that might be received by a client Ractor. - # - # Any Ractor that calls a wrapper may receive messages of this type when - # the call is in progress. If a Ractor interacts with its incoming message - # queue concurrently while a wrapped call is in progress, it must ignore - # these messages (i.e. by by using `receive_if`) in order not to interfere - # with the wrapper. (Similarly, the wrapper will use `receive_if` to - # receive only messages of this type, so it does not interfere with your - # Ractor's functionality.) - # - class Message - # @private - def initialize(type, data: nil, transaction: nil) - @sender = ::Ractor.current - @type = type - @data = data - @transaction = transaction || new_transaction - freeze - end - - # @private - attr_reader :type - - # @private - attr_reader :sender - - # @private - attr_reader :transaction - - # @private - attr_reader :data - - private - - def new_transaction - ::Random.rand(7958661109946400884391936).to_s(36).freeze - end + def maybe_log(str, transaction: nil, method_name: nil) + return unless logging_enabled + metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{name}"] + metadata << "Transaction/#{transaction}" if transaction + metadata << "Method/#{method_name}" if method_name + metadata = metadata.join(" ") + $stderr.puts("[#{metadata}] #{str}") + $stderr.flush end ## @@ -441,21 +558,24 @@ def new_transaction # runs worker threads internally to handle actual method calls. # # See the {#run} method for an overview of the Server implementation and - # lifecycle. + # lifecycle. Server is stateful and not shareable. # # @private # class Server + def initialize(name) + @name = name + end + ## # Handle the server lifecycle, running through the following phases: # # * **init**: Setup and spawning of worker threads. # * **running**: Normal operation, until a stop request is received. # * **stopping**: Waiting for worker threads to terminate. - # * **cleanup**: Clearing out of any lingering meessages. + # * **cleanup**: Clearing out of any lingering messages. # - # The server returns the wrapped object, allowing one client Ractor to - # take it. + # The server returns the wrapped object, so one client can recover it. # def run init_phase @@ -476,56 +596,29 @@ def run # * Receives an initial message providing the object to wrap, and # server configuration such as thread count and communications # settings. - # * Initializes the job queue and the pending request list. + # * Initializes the job queue. # * Spawns worker threads. # def init_phase - opts = ::Ractor.receive - @object = opts[:object] - @logging = opts[:logging] - @name = opts[:name] - @method_settings = opts[:method_settings] - @thread_count = opts[:threads] + maybe_log("Waiting for initialization") + init_message = ::Ractor.receive + @object = init_message.object + @logging_enabled = init_message.logging_enabled + @thread_count = init_message.thread_count @queue = ::Queue.new - @mutex = ::Mutex.new - @current_calls = {} - maybe_log("Spawning #{@thread_count} threads") + maybe_log("Spawning #{@thread_count} worker threads") (1..@thread_count).map do |worker_num| ::Thread.new { worker_thread(worker_num) } end - maybe_log("Server initialized") - end - - ## - # A worker thread repeatedly pulls a method call requests off the job - # queue, handles it, and sends back a response. It also removes the - # request from the pending request list to signal that it has responded. - # If no job is available, the thread blocks while waiting. If the queue - # is closed, the worker will send an acknowledgement message and then - # terminate. - # - def worker_thread(worker_num) - maybe_worker_log(worker_num, "Starting") - loop do - maybe_worker_log(worker_num, "Waiting for job") - request = @queue.deq - break if request.nil? - handle_method(worker_num, request) - unregister_call(request.transaction) - end - ensure - maybe_worker_log(worker_num, "Stopping") - ::Ractor.current.send(Message.new(:thread_stopped, data: worker_num), move: true) end ## # In the **running phase**, the Server listens on the Ractor's inbox and # handles messages for normal operation: # - # * If it receives a `call` request, it adds it to the job queue from - # which a worker thread will pick it up. It also adds the request to - # a list of pending requests. - # * If it receives a `stop` request, we proceed to the stopping phase. + # * If it receives a `call` message, it adds it to the job queue from + # which a worker thread will pick it up. + # * If it receives a `stop` message, we proceed to the stopping phase. # * If it receives a `thread_stopped` message, that indicates one of # the worker threads has unexpectedly stopped. We don't expect this # to happen until the stopping phase, so if we do see it here, we @@ -535,18 +628,16 @@ def worker_thread(worker_num) def running_phase loop do maybe_log("Waiting for message") - request = ::Ractor.receive - next unless request.is_a?(Message) - case request.type - when :call - @queue.enq(request) - register_call(request) - maybe_log("Queued method #{request.data.first} (transaction=#{request.transaction})") - when :thread_stopped - maybe_log("Thread unexpectedly stopped: #{request.data}") + message = ::Ractor.receive + case message + when CallMessage + maybe_log("Received CallMessage", call_message: message) + @queue.enq(message) + when WorkerStoppedMessage + maybe_log("Received unexpected WorkerStoppedMessage") @thread_count -= 1 break - when :stop + when StopMessage maybe_log("Received stop") break end @@ -564,14 +655,20 @@ def running_phase def stopping_phase @queue.close while @thread_count.positive? - maybe_log("Waiting for message while stopping") + maybe_log("Refusing incoming messages while stopping") message = ::Ractor.receive - next unless request.is_a?(Message) - case message.type - when :call - refuse_method(message) - when :thread_stopped + case message + when CallMessage + begin + refuse_method(message) + rescue ::Ractor::ClosedError + maybe_log("Reply port is closed", call_message: message) + end + when WorkerStoppedMessage + maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}") @thread_count -= 1 + when StopMessage + maybe_log("Stop received when already stopping") end end end @@ -579,25 +676,55 @@ def stopping_phase ## # In the **cleanup phase**, The Server closes its inbox, and iterates # through one final time to ensure it has responded to all remaining - # requests with a refusal. It also makes another pass through the pending - # requests; if there are any left, it probably means a worker thread died - # without responding to it preoprly, so we send back an error message. + # requests with a refusal. # def cleanup_phase - ::Ractor.current.close_incoming + ::Ractor.current.close maybe_log("Checking message queue for cleanup") loop do message = ::Ractor.receive - refuse_method(message) if message.is_a?(Message) && message.type == :call - end - maybe_log("Checking current calls for cleanup") - @current_calls.each_value do |request| - refuse_method(request) + case message + when CallMessage + begin + refuse_method(message) + rescue ::Ractor::ClosedError + maybe_log("Reply port is closed", call_message: message) + end + when WorkerStoppedMessage + maybe_log("Unexpected WorkerStoppedMessage when in cleanup") + when StopMessage + maybe_log("Stop received when already stopping") + end end rescue ::Ractor::ClosedError maybe_log("Message queue is empty") end + ## + # A worker thread repeatedly pulls a method call requests off the job + # queue, handles it, and sends back a response. It also removes the + # request from the pending request list to signal that it has responded. + # If no job is available, the thread blocks while waiting. If the queue + # is closed, the worker will send an acknowledgement message and then + # terminate. + # + def worker_thread(worker_num) + maybe_log("Worker starting", worker_num: worker_num) + loop do + maybe_log("Waiting for job", worker_num: worker_num) + message = @queue.deq + break if message.nil? + handle_method(message, worker_num: worker_num) + end + ensure + maybe_log("Worker stopping", worker_num: worker_num) + begin + ::Ractor.current.send(WorkerStoppedMessage.new(worker_num)) + rescue ::Ractor::ClosedError + maybe_log("Orphaned worker thread", worker_num: worker_num) + end + end + ## # This is called within a worker thread to handle a method call request. # It calls the method on the wrapped object, and then sends back a @@ -606,27 +733,39 @@ def cleanup_phase # kind; if an error occurs while constructing or sending a response, it # will catch the exception and try to send a simpler response. # - def handle_method(worker_num, request) - method_name, args, kwargs = request.data - transaction = request.transaction - sender = request.sender - maybe_worker_log(worker_num, "Running method #{method_name} (transaction=#{transaction})") + def handle_method(message, worker_num: nil) + block = message.block_arg + block = make_proxy_block(message.reply_port, message.settings) if block == :message + maybe_log("Running method", worker_num: worker_num, call_message: message) begin - result = @object.send(method_name, *args, **kwargs) - maybe_worker_log(worker_num, "Sending result (transaction=#{transaction})") - sender.send(Message.new(:result, data: result, transaction: transaction), - move: (@method_settings[method_name] || @method_settings[nil]).move_return?) + result = @object.send(message.method_name, *message.args, **message.kwargs, &block) + maybe_log("Sending return value", worker_num: worker_num, call_message: message) + message.reply_port.send(ReturnMessage.new(result), move: message.settings.move_result?) rescue ::Exception => e # rubocop:disable Lint/RescueException - maybe_worker_log(worker_num, "Sending exception (transaction=#{transaction})") + maybe_log("Sending exception", worker_num: worker_num, call_message: message) begin - sender.send(Message.new(:error, data: e, transaction: transaction)) + message.reply_port.send(ExceptionMessage.new(e)) rescue ::StandardError - safe_error = begin - ::StandardError.new(e.inspect) + begin + message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect))) rescue ::StandardError - ::StandardError.new("Unknown error") + maybe_log("Failure to send method response", worker_num: worker_num, call_message: message) end - sender.send(Message.new(:error, data: safe_error, transaction: transaction)) + end + end + end + + def make_proxy_block(port, settings) + proc do |*args, **kwargs| + reply_port = ::Ractor::Port.new + yield_message = YieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port) + port.send(yield_message, move: settings.move_block_arguments?) + reply_message = reply_port.receive + case reply_message + when ExceptionMessage + raise reply_message.exception + when ReturnMessage + reply_message.value end end end @@ -636,35 +775,26 @@ def handle_method(worker_num, request) # the wrapper cannot handle a requested method call, likely because the # wrapper is shutting down. # - def refuse_method(request) - maybe_log("Refusing method call (transaction=#{message.transaction})") - error = ::Ractor::ClosedError.new - request.sender.send(Message.new(:error, data: error, transaction: message.transaction)) - end - - def register_call(request) - @mutex.synchronize do - @current_calls[request.transaction] = request - end - end - - def unregister_call(transaction) - @mutex.synchronize do - @current_calls.delete(transaction) + def refuse_method(message) + maybe_log("Refusing method call", call_message: message) + begin + error = ::Ractor::ClosedError.new("Wrapper is shutting down") + message.reply_port.send(ExceptionMessage.new(error)) + rescue ::StandardError + maybe_log("Failure to send refusal message", call_message: message) end end - def maybe_log(str) - return unless @logging - time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L") - $stderr.puts("[#{time} Ractor::Wrapper/#{@name} Server]: #{str}") - $stderr.flush - end - - def maybe_worker_log(worker_num, str) - return unless @logging - time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L") - $stderr.puts("[#{time} Ractor::Wrapper/#{@name} Worker/#{worker_num}]: #{str}") + def maybe_log(str, call_message: nil, worker_num: nil, transaction: nil, method_name: nil) + return unless @logging_enabled + transaction ||= call_message&.transaction + method_name ||= call_message&.method_name + metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{@name}"] + metadata << "Worker/#{worker_num}" if worker_num + metadata << "Transaction/#{transaction}" if transaction + metadata << "Method/#{method_name}" if method_name + metadata = metadata.join(" ") + $stderr.puts("[#{metadata}] #{str}") $stderr.flush end end diff --git a/lib/ractor/wrapper/version.rb b/lib/ractor/wrapper/version.rb index 3dad466..1da0b1d 100644 --- a/lib/ractor/wrapper/version.rb +++ b/lib/ractor/wrapper/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Ractor class Wrapper ## @@ -5,6 +7,6 @@ class Wrapper # # @return [String] # - VERSION = "0.2.0".freeze + VERSION = "0.2.0" end end diff --git a/ractor-wrapper.gemspec b/ractor-wrapper.gemspec index 0e49c56..bec41fb 100644 --- a/ractor-wrapper.gemspec +++ b/ractor-wrapper.gemspec @@ -1,3 +1,5 @@ +# frozen_string_literal: true + lib = ::File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "ractor/wrapper/version" @@ -9,14 +11,16 @@ require "ractor/wrapper/version" spec.email = ["dazuma@gmail.com"] spec.summary = "A Ractor wrapper for a non-shareable object." - spec.description = "An experimental class that wraps a non-shareable object, allowing multiple Ractors to access it concurrently." + spec.description = + "An experimental class that wraps a non-shareable object in a Ractor," \ + " allowing multiple client Ractors to access it concurrently." spec.license = "MIT" spec.homepage = "https://github.com/dazuma/ractor-wrapper" spec.files = ::Dir.glob("lib/**/*.rb") + ::Dir.glob("*.md") + [".yardopts"] spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = ">= 4.0" spec.metadata["bug_tracker_uri"] = "https://github.com/dazuma/ractor-wrapper/issues" spec.metadata["changelog_uri"] = "https://rubydoc.info/gems/ractor-wrapper/#{::Ractor::Wrapper::VERSION}/file/CHANGELOG.md" diff --git a/test/data/numbers.db b/test/data/numbers.db new file mode 100644 index 0000000000000000000000000000000000000000..090fc5054d7f15de36fdbee132a5a9796e65ec4d GIT binary patch literal 8192 zcmeI#zY4-I5C-r|DvE}#KFW?`#h#+cTAeD%PHiZ}!d@kS1)zJt#>nz_# zE|&|Lep|NdW#KgMYg;MD33W*bnlKYl;7j`YMEFDFGx+|Rza^m2>e}(IM0g{=5C}j3 z0uX=z1Rwwb2tWV=5P$##eiT@UW`8gcl~Z}C&8f<@J-(ZqZ_;U&ayFeUQ+{7^WK^a3 ztnBWfY?O@Sp&v>vjEi4?a~a+#eE1E400bZa0SG_<0uX=z1Rwwb2teQ;1X?oa33sVw KQv|(sZS)hW0499^ literal 0 HcmV?d00001 diff --git a/test/helper.rb b/test/helper.rb index e6ec959..77143c0 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require "minitest/autorun" require "minitest/focus" require "minitest/rg" @@ -8,7 +10,15 @@ def echo_args(*args, **kwargs) "#{args}, #{kwargs}" end - def fail + def object_and_id(arg) + [arg, arg.object_id] + end + + def run_block(*, **) + yield(*, **) + end + + def whoops raise "Whoops" end diff --git a/test/test_example.rb b/test/test_example.rb new file mode 100644 index 0000000..9ca8c27 --- /dev/null +++ b/test/test_example.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "helper" + +describe ::Ractor::Wrapper do + it "runs the README example" do + # Faraday cannot be run outside the main Ractor + skip + readme_content = ::File.read(::File.join(::File.dirname(__dir__), "README.md")) + script = /\n```ruby\n(.*\n)```\n/m.match(readme_content)[1] + eval(script) # rubocop:disable Security/Eval + + require "net/http" + connection = Faraday.new("http://example.com") + wrapper = Ractor::Wrapper.new(connection) + begin + wrapper.stub.get("/hello") + ensure + wrapper.async_stop + end + end + + it "wraps a SQLite3 database" do + # SQLite3::Database is not movable + skip + require "sqlite3" + path = File.join(__dir__, "data", "numbers.db") + db = SQLite3::Database.new(path) + wrapper = Ractor::Wrapper.new(db) + begin + rows = wrapper.stub.execute("select * from numbers") + assert_equal([["one", 1], ["two", 2]], rows) + ensure + wrapper.async_stop + end + end +end diff --git a/test/test_wrapper.rb b/test/test_wrapper.rb index 504d822..08462c8 100644 --- a/test/test_wrapper.rb +++ b/test/test_wrapper.rb @@ -1,53 +1,95 @@ +# frozen_string_literal: true + require "helper" describe ::Ractor::Wrapper do let(:remote) { RemoteObject.new } - describe "method features" do + describe "wrapper features" do let(:wrapper) { Ractor::Wrapper.new(remote) } after { wrapper.async_stop } + it "moves a wrapped object" do + wrapper + assert_raises(Ractor::MovedError) do + remote.to_s + end + end + + it "refuses to wrap a moved object" do + wrapper + assert_raises(Ractor::MovedError) do + Ractor::Wrapper.new(remote) + end + end + end + + describe "method features" do + def wrapper(**) + @wrapper ||= Ractor::Wrapper.new(remote, **) + end + + after { wrapper.async_stop } + it "passes arguments and return values" do result = wrapper.call(:echo_args, 1, 2, a: "b", c: "d") - assert_equal("[1, 2], {:a=>\"b\", :c=>\"d\"}", result) + assert_equal("[1, 2], {a: \"b\", c: \"d\"}", result) end it "gets exceptions" do exception = assert_raises(RuntimeError) do - wrapper.call(:fail) + wrapper.call(:whoops) end assert_equal("Whoops", exception.message) end + + it "yields to a local block" do + local_var = false + result = wrapper.call(:run_block, 1, 2, a: "b", c: "d") do |one, two, a:, c:| + local_var = true + "result #{one} #{two} #{a} #{c}" + end + assert_equal("result 1 2 b d", result) + assert_equal(true, local_var) + end + + it "yields to a remote block" do + wrapper(execute_block_in_ractor: true) + result = wrapper.call(:run_block, 1, 2, a: "b", c: "d") do |one, two, a:, c:| + "result #{one} #{two} #{a} #{c} #{inspect}" + end + assert_equal("result 1 2 b d nil", result) + end end - describe "method configuration" do + describe "object moving and copying" do after { @wrapper&.async_stop } it "copies arguments by default" do @wrapper = Ractor::Wrapper.new(remote) - str = "hello" + str = "hello".dup @wrapper.call(:echo_args, str) - str.to_s + str.to_s # Would fail if str was moved end it "moves arguments when move_arguments is set to true" do @wrapper = Ractor::Wrapper.new(remote, move_arguments: true) - str = "hello" + str = "hello".dup @wrapper.call(:echo_args, str) assert_raises(Ractor::MovedError) { str.to_s } end it "moves arguments when move is set to true" do @wrapper = Ractor::Wrapper.new(remote, move: true) - str = "hello" + str = "hello".dup @wrapper.call(:echo_args, str) assert_raises(Ractor::MovedError) { str.to_s } end it "honors move_arguments over move" do @wrapper = Ractor::Wrapper.new(remote, move: true, move_arguments: false) - str = "hello" + str = "hello".dup @wrapper.call(:echo_args, str) str.to_s end @@ -60,12 +102,12 @@ it "converts method calls with arguments and return values" do result = wrapper.stub.echo_args(1, 2, a: "b", c: "d") - assert_equal("[1, 2], {:a=>\"b\", :c=>\"d\"}", result) + assert_equal("[1, 2], {a: \"b\", c: \"d\"}", result) end it "converts exceptions" do exception = assert_raises(RuntimeError) do - wrapper.stub.fail + wrapper.stub.whoops end assert_equal("Whoops", exception.message) end @@ -86,7 +128,7 @@ wrapper assert_raises(Ractor::MovedError) { remote.echo_args } wrapper.async_stop - recovered = wrapper.recovered_object + recovered = wrapper.recover_object assert_equal("[], {}", recovered.echo_args) end @@ -104,8 +146,8 @@ result = w.call(:slow_echo, "world") [result, Time.now.to_f] end - result1, time1 = r1.take - result2, time2 = r2.take + result1, time1 = r1.value + result2, time2 = r2.value assert_equal("hello", result1) assert_equal("world", result2) assert_operator((time1 - time2).abs, :>, 0.8) @@ -113,7 +155,7 @@ end describe "2-thread lifecycle" do - let(:wrapper) { Ractor::Wrapper.new(remote, threads: 2) } + let(:wrapper) { Ractor::Wrapper.new(remote, thread_count: 2) } after { wrapper.async_stop } @@ -122,7 +164,7 @@ wrapper assert_raises(Ractor::MovedError) { remote.echo_args } wrapper.async_stop - recovered = wrapper.recovered_object + recovered = wrapper.recover_object assert_equal("[], {}", recovered.echo_args) end @@ -141,8 +183,8 @@ result = w.call(:slow_echo, "world") [result, Time.now.to_f] end - result1, time1 = r1.take - result2, time2 = r2.take + result1, time1 = r1.value + result2, time2 = r2.value assert_equal("hello", result1) assert_equal("world", result2) assert_operator((time1 - time2).abs, :<, 0.4)