├── lib ├── maybe_later │ ├── version.rb │ ├── railtie.rb │ ├── invokes_callback.rb │ ├── config.rb │ ├── store.rb │ ├── queues_callback.rb │ ├── runs_callbacks.rb │ ├── thread_pool.rb │ └── middleware.rb └── maybe_later.rb ├── .standard.yml ├── .gitignore ├── test ├── test_helper.rb └── maybe_later_test.rb ├── sig └── maybe_later.rbs ├── Gemfile ├── bin ├── setup └── console ├── Rakefile ├── CHANGELOG.md ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── maybe_later.gemspec ├── Gemfile.lock └── README.md /lib/maybe_later/version.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | VERSION = "0.0.4" 3 | end 4 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "maybe_later" 3 | 4 | require "minitest/autorun" 5 | -------------------------------------------------------------------------------- /sig/maybe_later.rbs: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "standard" 8 | 9 | gem "rack" 10 | 11 | gem "pry" 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/maybe_later/railtie.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class Railtie < ::Rails::Railtie 3 | initializer "maybe_later.middleware" do 4 | config.app_middleware.use Middleware 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | require "standard/rake" 11 | 12 | task default: %i[test standard] 13 | -------------------------------------------------------------------------------- /lib/maybe_later/invokes_callback.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class InvokesCallback 3 | def call(callback) 4 | config = MaybeLater.config 5 | 6 | callback.callable.call 7 | rescue => e 8 | config.on_error&.call(e) 9 | ensure 10 | config.after_each&.call 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/maybe_later/config.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class Config 3 | attr_accessor :after_each, :on_error, :inline_by_default, :max_threads, 4 | :invoke_even_if_server_is_unsupported 5 | 6 | def initialize 7 | @after_each = nil 8 | @on_error = nil 9 | @inline_by_default = false 10 | @max_threads = 5 11 | @invoke_even_if_server_is_unsupported = false 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/maybe_later/store.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class Store 3 | def self.instance 4 | Thread.current[:maybe_later_store] ||= new 5 | end 6 | 7 | attr_reader :callbacks 8 | def initialize 9 | @callbacks = [] 10 | end 11 | 12 | def add_callback(callable) 13 | @callbacks << callable 14 | end 15 | 16 | def clear_callbacks! 17 | @callbacks = [] 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "maybe_later" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.0.4] - 2022-01-24 2 | 3 | - Add `invoke_even_if_server_is_unsupported` option to ensure tasks are invoked 4 | even if the server doesn't support rack.after_reply 5 | 6 | ## [0.0.3] - 2022-01-21 7 | 8 | - Fix a bug where async tasks' after_each callbacks were called rack.after_reply 9 | 10 | ## [0.0.2] - 2022-01-20 11 | 12 | - Only close HTTP connections when there are >0 inline tasks to be run 13 | 14 | ## [0.0.1] - 2022-01-19 15 | 16 | - Initial release 17 | -------------------------------------------------------------------------------- /lib/maybe_later/queues_callback.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | Callback = Struct.new(:inline, :callable, keyword_init: true) 3 | 4 | class QueuesCallback 5 | def call(callable:, inline:) 6 | raise Error.new("No block was passed to MaybeLater.run") if callable.nil? 7 | 8 | inline = MaybeLater.config.inline_by_default if inline.nil? 9 | Store.instance.add_callback(Callback.new( 10 | inline: inline, 11 | callable: callable 12 | )) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/maybe_later/runs_callbacks.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class RunsCallbacks 3 | def initialize 4 | @invokes_callback = InvokesCallback.new 5 | end 6 | 7 | def call 8 | store = Store.instance 9 | 10 | store.callbacks.each do |callback| 11 | if callback.inline 12 | @invokes_callback.call(callback) 13 | else 14 | ThreadPool.instance.run(callback) 15 | end 16 | end 17 | 18 | store.clear_callbacks! 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.1.0' 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /lib/maybe_later/thread_pool.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class ThreadPool 3 | def self.instance 4 | @instance ||= new 5 | end 6 | 7 | # The only time this is invoked by the gem will be when an Async task runs 8 | # As a result, the max thread config will be locked after responding to the 9 | # first relevant request, since the pool will have been created 10 | def initialize 11 | @pool = Concurrent::FixedThreadPool.new(MaybeLater.config.max_threads) 12 | @invokes_callback = InvokesCallback.new 13 | end 14 | 15 | def run(callback) 16 | @pool.post do 17 | @invokes_callback.call(callback) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/maybe_later.rb: -------------------------------------------------------------------------------- 1 | require_relative "maybe_later/version" 2 | require_relative "maybe_later/config" 3 | require_relative "maybe_later/middleware" 4 | require_relative "maybe_later/queues_callback" 5 | require_relative "maybe_later/runs_callbacks" 6 | require_relative "maybe_later/invokes_callback" 7 | require_relative "maybe_later/store" 8 | require_relative "maybe_later/thread_pool" 9 | require_relative "maybe_later/railtie" if defined?(Rails) 10 | 11 | module MaybeLater 12 | class Error < StandardError; end 13 | 14 | def self.run(inline: nil, &blk) 15 | QueuesCallback.new.call(callable: blk, inline: inline) 16 | end 17 | 18 | def self.config(&blk) 19 | (@config ||= Config.new).tap { |config| 20 | blk&.call(config) 21 | } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Justin Searls 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/maybe_later/middleware.rb: -------------------------------------------------------------------------------- 1 | module MaybeLater 2 | class Middleware 3 | RACK_AFTER_REPLY = "rack.after_reply" 4 | 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | config = MaybeLater.config 11 | 12 | status, headers, body = @app.call(env) 13 | if Store.instance.callbacks.any? 14 | if env.key?(RACK_AFTER_REPLY) 15 | env[RACK_AFTER_REPLY] << -> { 16 | RunsCallbacks.new.call 17 | } 18 | elsif !config.invoke_even_if_server_is_unsupported 19 | warn <<~MSG 20 | This server may not support '#{RACK_AFTER_REPLY}' callbacks. To 21 | ensure that your tasks are executed, consider enabling: 22 | 23 | config.invoke_even_if_server_is_unsupported = true 24 | 25 | Note that this option, when combined with `inline: true` can result 26 | in delayed flushing of HTTP responses by the server (defeating the 27 | purpose of the gem. 28 | MSG 29 | else 30 | RunsCallbacks.new.call 31 | end 32 | 33 | if Store.instance.callbacks.any? { |cb| cb.inline } 34 | headers["Connection"] = "close" 35 | end 36 | end 37 | [status, headers, body] 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /maybe_later.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/maybe_later/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "maybe_later" 5 | spec.version = MaybeLater::VERSION 6 | spec.authors = ["Justin Searls"] 7 | spec.email = ["searls@gmail.com"] 8 | 9 | spec.summary = "Run code after the current Rack response or Rails action completes" 10 | spec.homepage = "https://github.com/testdouble/maybe_later" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.0.0" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = spec.homepage 16 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 17 | 18 | # Specify which files should be added to the gem when it is released. 19 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 20 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 21 | `git ls-files -z`.split("\x0").reject do |f| 22 | (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 23 | end 24 | end 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | # Uncomment to register a new dependency of your gem 30 | spec.add_dependency "railties", ">= 6.0.0" 31 | spec.add_dependency "concurrent-ruby", "~> 1.1.9" 32 | end 33 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | maybe_later (0.0.4) 5 | concurrent-ruby (~> 1.1.9) 6 | railties (>= 6.0.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (7.0.1) 12 | actionview (= 7.0.1) 13 | activesupport (= 7.0.1) 14 | rack (~> 2.0, >= 2.2.0) 15 | rack-test (>= 0.6.3) 16 | rails-dom-testing (~> 2.0) 17 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 18 | actionview (7.0.1) 19 | activesupport (= 7.0.1) 20 | builder (~> 3.1) 21 | erubi (~> 1.4) 22 | rails-dom-testing (~> 2.0) 23 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 24 | activesupport (7.0.1) 25 | concurrent-ruby (~> 1.0, >= 1.0.2) 26 | i18n (>= 1.6, < 2) 27 | minitest (>= 5.1) 28 | tzinfo (~> 2.0) 29 | ast (2.4.2) 30 | builder (3.2.4) 31 | coderay (1.1.3) 32 | concurrent-ruby (1.1.9) 33 | crass (1.0.6) 34 | erubi (1.10.0) 35 | i18n (1.8.11) 36 | concurrent-ruby (~> 1.0) 37 | loofah (2.13.0) 38 | crass (~> 1.0.2) 39 | nokogiri (>= 1.5.9) 40 | method_source (1.0.0) 41 | minitest (5.15.0) 42 | nokogiri (1.13.1-arm64-darwin) 43 | racc (~> 1.4) 44 | nokogiri (1.13.1-x86_64-linux) 45 | racc (~> 1.4) 46 | parallel (1.21.0) 47 | parser (3.1.0.0) 48 | ast (~> 2.4.1) 49 | pry (0.14.1) 50 | coderay (~> 1.1) 51 | method_source (~> 1.0) 52 | racc (1.6.0) 53 | rack (2.2.3) 54 | rack-test (1.1.0) 55 | rack (>= 1.0, < 3) 56 | rails-dom-testing (2.0.3) 57 | activesupport (>= 4.2.0) 58 | nokogiri (>= 1.6) 59 | rails-html-sanitizer (1.4.2) 60 | loofah (~> 2.3) 61 | railties (7.0.1) 62 | actionpack (= 7.0.1) 63 | activesupport (= 7.0.1) 64 | method_source 65 | rake (>= 12.2) 66 | thor (~> 1.0) 67 | zeitwerk (~> 2.5) 68 | rainbow (3.1.1) 69 | rake (13.0.6) 70 | regexp_parser (2.2.0) 71 | rexml (3.2.5) 72 | rubocop (1.25.0) 73 | parallel (~> 1.10) 74 | parser (>= 3.1.0.0) 75 | rainbow (>= 2.2.2, < 4.0) 76 | regexp_parser (>= 1.8, < 3.0) 77 | rexml 78 | rubocop-ast (>= 1.15.1, < 2.0) 79 | ruby-progressbar (~> 1.7) 80 | unicode-display_width (>= 1.4.0, < 3.0) 81 | rubocop-ast (1.15.1) 82 | parser (>= 3.0.1.1) 83 | rubocop-performance (1.13.2) 84 | rubocop (>= 1.7.0, < 2.0) 85 | rubocop-ast (>= 0.4.0) 86 | ruby-progressbar (1.11.0) 87 | standard (1.7.0) 88 | rubocop (= 1.25.0) 89 | rubocop-performance (= 1.13.2) 90 | thor (1.2.1) 91 | tzinfo (2.0.4) 92 | concurrent-ruby (~> 1.0) 93 | unicode-display_width (2.1.0) 94 | zeitwerk (2.5.3) 95 | 96 | PLATFORMS 97 | arm64-darwin-21 98 | x86_64-linux 99 | 100 | DEPENDENCIES 101 | maybe_later! 102 | minitest 103 | pry 104 | rack 105 | rake 106 | standard 107 | 108 | BUNDLED WITH 109 | 2.3.3 110 | -------------------------------------------------------------------------------- /test/maybe_later_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "rack" 3 | 4 | class MaybeLaterTest < Minitest::Test 5 | i_suck_and_my_tests_are_order_dependent! 6 | 7 | def setup 8 | MaybeLater.config do |config| 9 | config.after_each = nil 10 | config.on_error = nil 11 | config.inline_by_default = false 12 | config.max_threads = 5 13 | config.invoke_even_if_server_is_unsupported = false 14 | end 15 | end 16 | 17 | def test_that_it_has_a_version_number 18 | refute_nil ::MaybeLater::VERSION 19 | end 20 | 21 | def test_a_couple_callbacks 22 | chores = [] 23 | call_count = 0 24 | errors_encountered = [] 25 | 26 | MaybeLater.config do |config| 27 | config.after_each = -> { 28 | call_count += 1 29 | } 30 | config.on_error = ->(e) { 31 | errors_encountered << e 32 | } 33 | end 34 | 35 | MaybeLater.run { chores << :laundry } 36 | callback_that_will_error = -> { raise "a stink" } 37 | MaybeLater.run(&callback_that_will_error) 38 | MaybeLater.run { chores << :tidy } 39 | 40 | _, headers, _ = invoke_middleware! 41 | assert_equal 0, call_count # <-- nothing could have happened yet! 42 | sleep 0.01 # <- let threads do stuff 43 | 44 | assert_includes chores, :laundry 45 | assert_includes chores, :tidy 46 | assert_equal 3, call_count 47 | assert_equal 1, errors_encountered.size 48 | error = errors_encountered.first 49 | assert_equal "a stink", error.message 50 | assert_nil headers["Connection"] # No inline runs 51 | end 52 | 53 | def test_inline_runs 54 | called = false 55 | MaybeLater.run(inline: true) { called = true } 56 | 57 | _, headers, _ = invoke_middleware! 58 | 59 | assert called 60 | assert_equal "close", headers["Connection"] 61 | end 62 | 63 | def test_inline_by_default 64 | MaybeLater.config.inline_by_default = true 65 | called = false 66 | MaybeLater.run { called = true } 67 | 68 | invoke_middleware! 69 | 70 | assert called 71 | end 72 | 73 | def test_inline_by_default_still_allows_async 74 | MaybeLater.config.inline_by_default = true 75 | called = false 76 | MaybeLater.run(inline: false) { called = true } 77 | 78 | invoke_middleware! 79 | 80 | refute called # unpossible if async! 81 | end 82 | 83 | def test_only_callsback_once 84 | call_count = 0 85 | MaybeLater.run { call_count += 1 } 86 | 87 | invoke_middleware! 88 | invoke_middleware! 89 | invoke_middleware! 90 | invoke_middleware! 91 | sleep 0.01 # <- let threads do stuff 92 | 93 | assert_equal 1, call_count 94 | end 95 | 96 | def test_that_a_callable_is_required 97 | e = assert_raises { MaybeLater.run } 98 | 99 | assert_kind_of MaybeLater::Error, e 100 | assert_equal "No block was passed to MaybeLater.run", e.message 101 | end 102 | 103 | def test_with_server_that_doesnt_support_rack_after_reply 104 | called = false 105 | MaybeLater.run(inline: true) { called = true } 106 | 107 | stderr = with_fake_stderr do 108 | invoke_middleware!(supports_after_reply: false) 109 | end 110 | 111 | refute called 112 | assert_equal <<~ERR, stderr.read 113 | This server may not support 'rack.after_reply' callbacks. To 114 | ensure that your tasks are executed, consider enabling: 115 | 116 | config.invoke_even_if_server_is_unsupported = true 117 | 118 | Note that this option, when combined with `inline: true` can result 119 | in delayed flushing of HTTP responses by the server (defeating the 120 | purpose of the gem. 121 | ERR 122 | end 123 | 124 | def test_unsupported_server_that_calls_anyway 125 | MaybeLater.config do |config| 126 | config.invoke_even_if_server_is_unsupported = true 127 | end 128 | 129 | called = false 130 | MaybeLater.run(inline: true) { called = true } 131 | 132 | invoke_middleware!(supports_after_reply: false) 133 | 134 | assert called 135 | end 136 | 137 | private 138 | 139 | def invoke_middleware!(supports_after_reply: true) 140 | env = Rack::MockRequest.env_for 141 | if supports_after_reply 142 | env[MaybeLater::Middleware::RACK_AFTER_REPLY] ||= [] 143 | end 144 | subject = MaybeLater::Middleware.new(->(env) { [200, {}, "success"] }) 145 | result = subject.call(env) 146 | 147 | # The server will do this 148 | env[MaybeLater::Middleware::RACK_AFTER_REPLY]&.first&.call 149 | 150 | result 151 | end 152 | 153 | def with_fake_stderr(&blk) 154 | og_stderr = $stderr 155 | fake_stderr = StringIO.new 156 | $stderr = fake_stderr 157 | blk.call 158 | $stderr = og_stderr 159 | fake_stderr.rewind 160 | fake_stderr 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # maybe_later - get slow code out of your users' way 4 | 5 | Maybe you've been in this situation: you want to call some Ruby while responding 6 | to an HTTP request, but it's a time-consuming operation, its outcome won't 7 | impact the response you send, and naively invoking it will result in a slower 8 | page load or API response for your users. 9 | 10 | In almost all cases like this, Rubyists will reach for a [job 11 | queue](https://edgeguides.rubyonrails.org/active_job_basics.html). And that's 12 | usually the right answer! But for relatively trivial tasks—cases where the 13 | _only_ reason you want to defer execution is a faster page load—creating a new 14 | job class and scheduling the work onto a queuing system can feel like overkill. 15 | 16 | If this resonates with you, the `maybe_later` gem might be the best way to run 17 | that code for you (eventually). 18 | 19 | ## Bold Font Disclaimer 20 | 21 | ⚠️ **If the name `maybe_later` didn't make it clear, this gem does nothing to 22 | ensure that your after-action callbacks actually run. If the code you're calling 23 | is very important, use [sidekiq](https://github.com/mperham/sidekiq) or 24 | something!** ⚠️ 25 | 26 | ## Setup 27 | 28 | Add the gem to your Gemfile: 29 | 30 | ```ruby 31 | gem "maybe_later" 32 | ``` 33 | 34 | If you're using Rails, the gem's middleware will be registered automatically. If 35 | you're not using Rails but _are_ using a rack-based server that supports 36 | [env["rack.after_reply"]](https://github.com/rack/rack/issues/1060) (which 37 | includes 38 | [puma](https://github.com/puma/puma/commit/be4a8336c0b4fc911b99d1ffddc4733b6f38d81d) 39 | and 40 | [unicorn](https://github.com/defunkt/unicorn/commit/673c15e3f020bccc0336838617875b26c9a45f4e)), 41 | just add `use MaybeLater::Middleware` to your `config.ru` file. 42 | 43 | ## Usage 44 | 45 | Using the gem is pretty straightforward, just pass the code you want to run to 46 | `MaybeLater.run` as a block: 47 | 48 | ```ruby 49 | MaybeLater.run { 50 | AnalyticsService.send_telemetry! 51 | } 52 | ``` 53 | 54 | Each block passed to `MaybeLater.run` will be run after the HTTP response is 55 | sent. 56 | 57 | If the code you're calling needs to be executed in the same thread that's 58 | handling the HTTP request, you can pass `inline: true`: 59 | 60 | ```ruby 61 | MaybeLater.run(inline: true) { 62 | # Thread un-safe code here 63 | } 64 | ``` 65 | 66 | And your code will be run right after the HTTP response is sent. Additionally, 67 | if there are any inline tasks to be run, the response will include a 68 | `"Connection: close` header so that the browser doesn't sit waiting on its 69 | connection while the web thread executes the deferred code. 70 | 71 | _[**Warning about `inline`:** running 72 | slow inline tasks runs the risk of saturating the server's available threads 73 | listening for connections, effectively shifting the slowness of one request onto 74 | later ones!]_ 75 | 76 | 77 | ## Configuration 78 | 79 | The gem offers a few configuration options: 80 | 81 | ```ruby 82 | MaybeLater.config do |config| 83 | # Will be called if a block passed to MaybeLater.run raises an error 84 | config.on_error = ->(error) { 85 | # e.g. Honeybadger.notify(error) 86 | } 87 | 88 | # Will run after each `MaybeLater.run {}` block, even if it errors 89 | config.after_each = -> {} 90 | 91 | # By default, tasks will run in a fixed thread pool. To run them in the 92 | # thread dispatching the HTTP response, set this to true 93 | config.inline_by_default = false 94 | 95 | # How many threads to allocate to the fixed thread pool (default: 5) 96 | config.max_threads = 5 97 | 98 | # If set to true, will invoke the after_reply tasks even if the server doesn't 99 | # provide a rack.after_reply array. 100 | # One reason to do this is if you are using Rails controller tests 101 | # (with no webserver) rather than system tests. 102 | # config.invoke_even_if_server_is_unsupported = Rails.env.test? 103 | config.invoke_even_if_server_is_unsupported = false 104 | end 105 | ``` 106 | 107 | ## Help! Why isn't my code running? 108 | 109 | If the blocks you pass to `MaybeLater.run` aren't running, possible 110 | explanations include: 111 | 112 | * Because the blocks passed to `MaybeLater.run` are themselves stored in a 113 | thread-local array, if you invoke `MaybeLater.run` from a thread that isn't 114 | handling with a Rack request, the block will never run 115 | * If your Rack server doesn't support `rack.after_reply`, the blocks will never 116 | run 117 | * If the block _is_ running and raising an error, you'll only know about it if 118 | you register a `MaybeLater.config.on_error` handler 119 | 120 | ## Acknowledgement 121 | 122 | The idea for this gem was triggered by [this 123 | tweet](https://twitter.com/julikt/status/1483585327277223939) in reply to [this 124 | question](https://twitter.com/searls/status/1483572597686259714). Also, many 125 | thanks to [Matthew Draper](https://github.com/matthewd) for answering a bunch of 126 | questions I had while implementing this. 127 | 128 | ## Code of Conduct 129 | 130 | This project follows Test Double's [code of 131 | conduct](https://testdouble.com/code-of-conduct) for all community interactions, 132 | including (but not limited to) one-on-one communications, public posts/comments, 133 | code reviews, pull requests, and GitHub issues. If violations occur, Test Double 134 | will take any action they deem appropriate for the infraction, up to and 135 | including blocking a user from the organization's repositories. 136 | --------------------------------------------------------------------------------