iopromise 0.1.0 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +1 -2
  3. data/Gemfile +4 -10
  4. data/Gemfile.lock +7 -30
  5. data/README.md +23 -3
  6. data/bin/setup +0 -3
  7. data/iopromise.gemspec +1 -0
  8. data/lib/iopromise.rb +47 -18
  9. data/lib/iopromise/cancel_context.rb +51 -0
  10. data/lib/iopromise/data_loader.rb +58 -0
  11. data/lib/iopromise/deferred.rb +2 -2
  12. data/lib/iopromise/deferred/executor_pool.rb +26 -10
  13. data/lib/iopromise/deferred/promise.rb +15 -2
  14. data/lib/iopromise/executor_context.rb +47 -59
  15. data/lib/iopromise/executor_pool/base.rb +26 -7
  16. data/lib/iopromise/executor_pool/batch.rb +5 -3
  17. data/lib/iopromise/executor_pool/sequential.rb +6 -14
  18. data/lib/iopromise/rack/context_middleware.rb +5 -6
  19. data/lib/iopromise/version.rb +1 -1
  20. data/lib/iopromise/view_component/data_loader.rb +3 -44
  21. metadata +18 -18
  22. data/lib/iopromise/dalli.rb +0 -13
  23. data/lib/iopromise/dalli/client.rb +0 -146
  24. data/lib/iopromise/dalli/executor_pool.rb +0 -13
  25. data/lib/iopromise/dalli/patch_dalli.rb +0 -337
  26. data/lib/iopromise/dalli/promise.rb +0 -52
  27. data/lib/iopromise/dalli/response.rb +0 -25
  28. data/lib/iopromise/faraday.rb +0 -17
  29. data/lib/iopromise/faraday/connection.rb +0 -25
  30. data/lib/iopromise/faraday/continuable_hydra.rb +0 -29
  31. data/lib/iopromise/faraday/executor_pool.rb +0 -19
  32. data/lib/iopromise/faraday/multi_socket_action.rb +0 -107
  33. data/lib/iopromise/faraday/promise.rb +0 -42
  34. data/lib/iopromise/memcached.rb +0 -13
  35. data/lib/iopromise/memcached/client.rb +0 -22
  36. data/lib/iopromise/memcached/executor_pool.rb +0 -61
  37. data/lib/iopromise/memcached/promise.rb +0 -32
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb3b131c886899408dfd81fa4d0fdefca9491df9e1a53ddeb6c684ab6043d4fc
4
- data.tar.gz: f0f42864a1860e8e30f01c2994e85bebf8d47828852a8990d973a74ebbe90410
3
+ metadata.gz: 1189b2ad73f76eff39b347922c03e2e40fc188ff95b7a6771da921d193c88ed5
4
+ data.tar.gz: 169d59659740616c94508bdd3fabfd8fd1850c2a593dade86fda7267efa03eb7
5
5
  SHA512:
6
- metadata.gz: 6a68af080d98407fd8b6b1764e589dff42f6597e6c5118ca62c4d61387bd1cd475c01f5fe09ef3702d287da9c440c8811f22ff44379381e6477503400e0cf9b3
7
- data.tar.gz: a07492017391dae8dba06b5b76b1bcb738845fc5e5d87a8a5a44f397bb9d1d2f8ee3586ed31a3fc35b7633bee45616744324dbd0133cd96b08521602a0e61c29
6
+ metadata.gz: 11f75193659f6f37295faf3d0ecea8166aa446da826da462cfc8ea5631eae2f107a65c1d093f1c082ae1e2690166f124244ea2ddf9bcc837ae3c6763e13ae063
7
+ data.tar.gz: d16beca1753c4c31994801787925b25713ac315599cc246dc19a92169fab7afe5948717454e9511be59735e542e8eea4805660c86c6c8de7a906a4e376848dc4
@@ -8,8 +8,7 @@ jobs:
8
8
  steps:
9
9
  - uses: actions/checkout@v2
10
10
  - name: Install dependencies
11
- run: sudo apt-get install libcurl4-openssl-dev libsasl2-dev
12
- - uses: niden/actions-memcached@v7
11
+ run: sudo apt-get update && sudo apt-get install libcurl4-openssl-dev libsasl2-dev
13
12
  - name: Set up Ruby
14
13
  uses: ruby/setup-ruby@v1
15
14
  with:
data/Gemfile CHANGED
@@ -10,17 +10,11 @@ gem "rake", "~> 13.0"
10
10
  gem "rspec", "~> 3.0"
11
11
 
12
12
  group :development, :test do
13
- # faraday adapter
14
- gem 'faraday'
15
- gem 'typhoeus'
16
-
17
- # memcached adapter
18
- gem 'memcached', :git => 'https://github.com/theojulienne/memcached.git', :branch => 'continuable-get'
19
-
20
- # dalli adapter
21
- gem 'dalli', "= 2.7.11"
22
-
23
13
  # view_component extensions
24
14
  gem "rails"
25
15
  gem "view_component", require: "view_component/engine"
16
+
17
+ # benchmarking
18
+ gem "benchmark-ips"
19
+ gem "stackprof"
26
20
  end
data/Gemfile.lock CHANGED
@@ -1,15 +1,9 @@
1
- GIT
2
- remote: https://github.com/theojulienne/memcached.git
3
- revision: 18c1da3708f3e7dca316b2f0143b4f05116f7672
4
- branch: continuable-get
5
- specs:
6
- memcached (2.0.0.alpha)
7
-
8
1
  PATH
9
2
  remote: .
10
3
  specs:
11
- iopromise (0.1.0)
12
- promise.rb
4
+ iopromise (0.1.4)
5
+ nio4r
6
+ promise.rb (~> 0.7.4)
13
7
 
14
8
  GEM
15
9
  remote: https://rubygems.org/
@@ -73,24 +67,12 @@ GEM
73
67
  minitest (>= 5.1)
74
68
  tzinfo (~> 2.0)
75
69
  zeitwerk (~> 2.3)
70
+ benchmark-ips (2.9.1)
76
71
  builder (3.2.4)
77
72
  concurrent-ruby (1.1.8)
78
73
  crass (1.0.6)
79
- dalli (2.7.11)
80
74
  diff-lcs (1.4.4)
81
75
  erubi (1.10.0)
82
- ethon (0.14.0)
83
- ffi (>= 1.15.0)
84
- faraday (1.4.1)
85
- faraday-excon (~> 1.1)
86
- faraday-net_http (~> 1.0)
87
- faraday-net_http_persistent (~> 1.1)
88
- multipart-post (>= 1.2, < 3)
89
- ruby2_keywords (>= 0.0.4)
90
- faraday-excon (1.1.0)
91
- faraday-net_http (1.0.1)
92
- faraday-net_http_persistent (1.1.0)
93
- ffi (1.15.0)
94
76
  globalid (0.4.2)
95
77
  activesupport (>= 4.2.0)
96
78
  i18n (1.8.10)
@@ -104,7 +86,6 @@ GEM
104
86
  method_source (1.0.0)
105
87
  mini_mime (1.0.3)
106
88
  minitest (5.14.4)
107
- multipart-post (2.1.1)
108
89
  nio4r (2.5.7)
109
90
  nokogiri (1.11.3-x86_64-linux)
110
91
  racc (~> 1.4)
@@ -153,7 +134,6 @@ GEM
153
134
  diff-lcs (>= 1.2.0, < 2.0)
154
135
  rspec-support (~> 3.10.0)
155
136
  rspec-support (3.10.2)
156
- ruby2_keywords (0.0.4)
157
137
  sprockets (4.0.2)
158
138
  concurrent-ruby (~> 1.0)
159
139
  rack (> 1, < 3)
@@ -161,9 +141,8 @@ GEM
161
141
  actionpack (>= 4.0)
162
142
  activesupport (>= 4.0)
163
143
  sprockets (>= 3.0.0)
144
+ stackprof (0.2.17)
164
145
  thor (1.1.0)
165
- typhoeus (1.4.0)
166
- ethon (>= 0.9.0)
167
146
  tzinfo (2.0.4)
168
147
  concurrent-ruby (~> 1.0)
169
148
  view_component (2.31.1)
@@ -177,14 +156,12 @@ PLATFORMS
177
156
  x86_64-linux
178
157
 
179
158
  DEPENDENCIES
180
- dalli (= 2.7.11)
181
- faraday
159
+ benchmark-ips
182
160
  iopromise!
183
- memcached!
184
161
  rails
185
162
  rake (~> 13.0)
186
163
  rspec (~> 3.0)
187
- typhoeus
164
+ stackprof
188
165
  view_component
189
166
 
190
167
  BUNDLED WITH
data/README.md CHANGED
@@ -1,6 +1,23 @@
1
- # iopromise
1
+ # IOPromise
2
2
 
3
- This **experimental pre-release** gem extends promise.rb promises to support an extremely simple pattern for \"continuing\" execution of all pending promises in an asynchronous non-blocking way.
3
+ IOPromise is a pattern that allows parallel execution of IO-bound requests (data store and RPCs) behind the abstraction of promises, without needing to introduce the complexity of threading. It uses [promise.rb](https://github.com/lgierth/promise.rb) for promises, and [nio4r](https://github.com/socketry/nio4r) to implement the IO loop.
4
+
5
+ A simple example of this behaviour is using [iopromise-faraday](https://github.com/iopromise-ruby/iopromise-faraday) to perform concurrent HTTP requests:
6
+ ```ruby
7
+ require 'iopromise/faraday'
8
+
9
+ conn = IOPromise::Faraday.new('https://github.com/')
10
+
11
+ promises = (1..3).map do
12
+ conn.get('/status')
13
+ end
14
+
15
+ Promise.all(promises).then do |responses|
16
+ responses.each_with_index do |response, i|
17
+ puts "#{i}: #{response.body.strip} #{response.headers["x-github-request-id"]}"
18
+ end
19
+ end.sync
20
+ ```
4
21
 
5
22
  ## Installation
6
23
 
@@ -20,7 +37,10 @@ Or install it yourself as:
20
37
 
21
38
  ## Usage
22
39
 
23
- TODO: Write usage instructions here
40
+ IOPromise itself is a base library that makes it easy to wrap other IO-based workloads inside a promise-based API that back to an event loop. To use IOPromise, look at the following gems:
41
+
42
+ * [iopromise-faraday](https://github.com/iopromise-ruby/iopromise-faraday) supports [faraday](https://github.com/lostisland/faraday) HTTP requests, backed by libcurl/ethon/typhoeus.
43
+ * [iopromise-dalli](https://github.com/iopromise-ruby/iopromise-dalli) supports [dalli](https://github.com/petergoldstein/dalli) memcached requests.
24
44
 
25
45
  ## Development
26
46
 
data/bin/setup CHANGED
@@ -4,6 +4,3 @@ IFS=$'\n\t'
4
4
  set -vx
5
5
 
6
6
  bundle install
7
-
8
- # bring up a memcached for testing
9
- docker run -d -p 11211:11211 memcached:alpine
data/iopromise.gemspec CHANGED
@@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ["lib"]
28
28
 
29
29
  spec.add_dependency 'promise.rb', '~> 0.7.4'
30
+ spec.add_dependency 'nio4r'
30
31
  end
data/lib/iopromise.rb CHANGED
@@ -4,6 +4,7 @@ require "promise"
4
4
 
5
5
  require_relative "iopromise/version"
6
6
 
7
+ require_relative "iopromise/cancel_context"
7
8
  require_relative "iopromise/executor_context"
8
9
  require_relative "iopromise/executor_pool/base"
9
10
  require_relative "iopromise/executor_pool/batch"
@@ -11,44 +12,72 @@ require_relative "iopromise/executor_pool/sequential"
11
12
 
12
13
  module IOPromise
13
14
  class Error < StandardError; end
15
+ class CancelledError < Error; end
14
16
 
15
17
  class Base < ::Promise
16
- def initialize(*)
17
- @instrument_begin = []
18
- @instrument_end = []
19
- @started_executing = false
20
-
21
- super
22
- end
23
-
24
18
  def instrument(begin_cb = nil, end_cb = nil)
25
- raise ::IOPromise::Error.new("Instrumentation called after promise already started executing") if @started_executing
26
- @instrument_begin << begin_cb unless begin_cb.nil?
27
- @instrument_end << end_cb unless end_cb.nil?
19
+ raise ::IOPromise::Error.new("Instrumentation called after promise already started executing") if started_executing?
20
+ unless begin_cb.nil?
21
+ @instrument_begin ||= []
22
+ @instrument_begin << begin_cb
23
+ end
24
+ unless end_cb.nil?
25
+ @instrument_end ||= []
26
+ @instrument_end << end_cb
27
+ end
28
28
  end
29
29
 
30
30
  def beginning
31
- @instrument_begin.each { |cb| cb.call(self) }
31
+ @instrument_begin&.each { |cb| cb.call(self) }
32
+ @instrument_begin&.clear
32
33
  @started_executing = true
33
34
  end
34
35
 
35
36
  def started_executing?
36
- @started_executing
37
+ !!@started_executing
37
38
  end
38
39
 
39
- def notify_completion
40
- @instrument_end.each { |cb| cb.call(self) }
41
- @instrument_end = []
40
+ def notify_completion(value: nil, reason: nil)
41
+ @instrument_end&.each { |cb| cb.call(self, value: value, reason: reason) }
42
+ @instrument_end&.clear
42
43
  end
43
44
 
44
45
  def fulfill(value)
45
- notify_completion
46
+ return if cancelled?
47
+ notify_completion(value: value)
46
48
  super(value)
47
49
  end
48
50
 
49
51
  def reject(reason)
50
- notify_completion
52
+ return if cancelled?
53
+ notify_completion(reason: reason)
51
54
  super(reason)
52
55
  end
56
+
57
+ def wait
58
+ raise IOPromise::CancelledError if cancelled?
59
+ super
60
+ end
61
+
62
+ # Subclasses are expected to implement 'execute_pool' to return an IOPromise::ExecutorPool
63
+ # that is responsible for completing the given promise.
64
+ def execute_pool
65
+ raise NotImplementedError
66
+ end
67
+
68
+ # makes this promise inert, ensuring that promise chains do not continue
69
+ # propegation once this promise has been cancelled.
70
+ def cancel
71
+ return unless pending?
72
+
73
+ @cancelled = true
74
+ @observers = []
75
+
76
+ execute_pool.promise_cancelled(self)
77
+ end
78
+
79
+ def cancelled?
80
+ !!defined?(@cancelled)
81
+ end
53
82
  end
54
83
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOPromise
4
+ class CancelContext
5
+ class << self
6
+ def context_stack
7
+ Thread.current[:iopromise_context_stack] ||= []
8
+ end
9
+
10
+ def current
11
+ context_stack.last
12
+ end
13
+
14
+ def push
15
+ new_ctx = CancelContext.new(current)
16
+ context_stack.push(new_ctx)
17
+ new_ctx
18
+ end
19
+
20
+ def pop
21
+ ctx = context_stack.pop
22
+ ctx.cancel
23
+ ctx
24
+ end
25
+
26
+ def with_new_context
27
+ ctx = push
28
+ yield ctx
29
+ ensure
30
+ pop
31
+ end
32
+ end
33
+
34
+ def initialize(parent)
35
+ parent.subscribe(self) unless parent.nil?
36
+ end
37
+
38
+ def subscribe(observer)
39
+ @observers ||= []
40
+ @observers.push observer
41
+ end
42
+
43
+ def cancel
44
+ return unless defined?(@observers)
45
+ @observers.each do |o|
46
+ o.cancel
47
+ end
48
+ @observers = []
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IOPromise
4
+ module DataLoader
5
+ module ClassMethods
6
+ def attr_promised_data(*args)
7
+ @promised_data_keys ||= []
8
+ @promised_data_keys.concat(args)
9
+
10
+ args.each do |arg|
11
+ self.class_eval("def #{arg};@#{arg}.sync;end")
12
+ end
13
+ end
14
+
15
+ def promised_data_keys
16
+ @promised_data_keys ||= []
17
+ end
18
+ end
19
+
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+ end
23
+
24
+ def data_promises
25
+ self.class.promised_data_keys.flat_map do |k|
26
+ p = instance_variable_get('@' + k.to_s)
27
+ case p
28
+ when ::IOPromise::DataLoader
29
+ # greedily, recursively preload all nested data that we know about immediately
30
+ p.data_promises
31
+ when ::Promise
32
+ # allow nesting of dataloaders chained behind other promises
33
+ resolved = p.then do |result|
34
+ if result.is_a?(::IOPromise::DataLoader)
35
+ # likewise, if we resolved a promise that we can recurse, load that data too.
36
+ result.data_as_promise
37
+ else
38
+ result
39
+ end
40
+ end
41
+
42
+ [resolved]
43
+ else
44
+ raise TypeError.new("Instance variable #{k.to_s} used with attr_promised_data but was not a promise or a IOPromise::DataLoader.")
45
+ end
46
+ end
47
+ end
48
+
49
+ def data_as_promise
50
+ @data_as_promise ||= Promise.all(data_promises)
51
+ end
52
+
53
+ def sync
54
+ data_as_promise.sync
55
+ self
56
+ end
57
+ end
58
+ end
@@ -5,8 +5,8 @@ require_relative 'deferred/promise'
5
5
  module IOPromise
6
6
  module Deferred
7
7
  class << self
8
- def new(&block)
9
- ::IOPromise::Deferred::DeferredPromise.new(&block)
8
+ def new(*args, **kwargs, &block)
9
+ ::IOPromise::Deferred::DeferredPromise.new(*args, **kwargs, &block)
10
10
  end
11
11
  end
12
12
  end
@@ -3,26 +3,42 @@
3
3
  module IOPromise
4
4
  module Deferred
5
5
  class DeferredExecutorPool < ::IOPromise::ExecutorPool::Batch
6
- def execute_continue(ready_readers, ready_writers, ready_exceptions)
6
+ def initialize(*)
7
+ super
8
+
9
+ # register a dummy reader that never fires, to indicate to the event loop that
10
+ # there is a valid, active ExecutorPool.
11
+ @pipe_rd, @pipe_wr = IO.pipe
12
+ @iop_monitor = ::IOPromise::ExecutorContext.current.register_observer_io(self, @pipe_rd, :r)
13
+ end
14
+
15
+ def execute_continue
7
16
  if @current_batch.empty?
8
17
  next_batch
9
18
  end
10
19
 
11
- until @current_batch.empty?
12
- # we are just running this in the sync cycle, in a blocking way.
13
- @current_batch.each do |promise|
20
+ # we are just running this in the sync cycle, in a blocking way.
21
+ timeouts = []
22
+ @current_batch.each do |promise|
23
+ time_until_execution = promise.time_until_execution
24
+ if time_until_execution <= 0
14
25
  begin_executing(promise)
15
26
  promise.run_deferred
16
- complete(promise)
27
+ else
28
+ timeouts << time_until_execution
17
29
  end
30
+ end
18
31
 
19
- @current_batch = []
20
-
21
- next_batch
32
+ if timeouts.empty?
33
+ @select_timeout = nil
34
+ else
35
+ # ensure we get back to this loop not too long after
36
+ @select_timeout = timeouts.min
22
37
  end
23
38
 
24
- # we always fully complete each cycle
25
- return [[], [], [], nil]
39
+ # we reset the batch - the promises that are not completed will still be
40
+ # pending and will be available next time we are called.
41
+ @current_batch = []
26
42
  end
27
43
  end
28
44
  end