strongman 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 329a7cf887ef3a484d31e6c1f943d86857d045cf33daa6862e8c4720366d2273
4
+ data.tar.gz: 1f4f60aebb814692098f07d5f1b20d520ebd7d6ad897dfec1b541f60b6f4efbc
5
+ SHA512:
6
+ metadata.gz: 1ca56b74b4bec3f25cfcbd51b5aea8d7eb75725d03ee51adb3e43dd00c01925eeb219b4071c204a61fc7ecad08882410bc466fc80a26bc8de90d70445f8993f2
7
+ data.tar.gz: 2a986b767a76db4e1a5a087a0e05d9d9f8b632de1c6fd0288e622d2ebd25d926aa897d55ac510e45bf918649e9214611a38cbe895641eea270b1d5dcfcc49372
data/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2017-2019, Adam Stankiewicz
2
+ Copyright (c) 2019-present, Caleb Land
3
+
4
+ MIT License
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,243 @@
1
+ # ![](http://i.imgur.com/ZdJKtj1.png) Strongman
2
+
3
+ [![Build Status](https://travis-ci.org/sheerun/dataloader.svg?branch=master)](https://travis-ci.org/sheerun/dataloader) [![codecov](https://codecov.io/gh/sheerun/dataloader/branch/master/graph/badge.svg)](https://codecov.io/gh/sheerun/dataloader)
4
+
5
+ Strongman is a generic utility based on [Dataloader](https://github.com/sheerun/dataloader), to be used as part of your application's data fetching layer to provide a simplified and consistent API to perform batching and caching within a request. It is heavily inspired by [Facebook's dataloader](https://github.com/facebook/dataloader).
6
+
7
+ ## Getting started
8
+
9
+ First, install Strongman using bundler:
10
+
11
+ ```ruby
12
+ gem "strongman"
13
+ ```
14
+
15
+ To get started, instantiate `Strongman`. Each `Strongman` instance represents a unique cache. Typically instances are created per request when used within a web-server. To see how to use with GraphQL server, see section below.
16
+
17
+ Strongman is dependent on [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby) which you can use freely for batch-ready code (e.g. loader can return a `Future` that returns a `Future` that returns a `Future`). Strongman will try to batch most of them.
18
+
19
+ ## Basic usage
20
+
21
+ ```ruby
22
+ # It will be called only once with ids = [0, 1, 2]
23
+ loader = Strongman.new do |ids|
24
+ User.find(*ids)
25
+ end
26
+
27
+ # Schedule data to load
28
+ promise_one = loader.load(0)
29
+ promise_two = loader.load_many([1, 2])
30
+
31
+ # Get promises results
32
+ user0 = promise_one.value!
33
+ user1, user2 = promise_two.value!
34
+ ```
35
+
36
+ ## Using with GraphQL
37
+
38
+ You can pass loaders passed inside [`context`](https://rmosolgo.github.io/graphql-ruby/queries/executing_queries).
39
+
40
+ ```ruby
41
+ UserType = GraphQL::ObjectType.define do
42
+ field :name, types.String
43
+ end
44
+
45
+ QueryType = GraphQL::ObjectType.define do
46
+ name "Query"
47
+ description "The query root of this schema"
48
+
49
+ field :user do
50
+ type UserType
51
+ argument :id, !types.ID
52
+ resolve ->(obj, args, ctx) {
53
+ ctx[:user_loader].load(args["id"])
54
+ }
55
+ end
56
+ end
57
+
58
+ Schema = GraphQL::Schema.define do
59
+ lazy_resolve(Concurrent::Promises::Future, :'value!')
60
+
61
+ query QueryType
62
+ end
63
+
64
+ context = {
65
+ user_loader: Strongman.new do |ids|
66
+ User.find(*ids)
67
+ end
68
+ }
69
+
70
+ Schema.execute("{ user(id: 12) { name } }", context: context)
71
+ ```
72
+
73
+ ## Batching
74
+
75
+ You can create loaders by providing a batch loading function.
76
+
77
+ ```ruby
78
+ user_loader = Strongman.new { |ids| User.find(*ids) }
79
+ ```
80
+
81
+ A batch loading block accepts an Array of keys, and returns a Promise which resolves to an Array or Hash of values.
82
+
83
+ `Strongman` will coalesce all individual loads which occur until first `#value!` (or `#value`, `#wait`, `#touch` or other `Concurrent::Promises::Future` method that blocks waiting for a result) is called on any promise returned by `#load` or `#load_many`, and then call your batch function with all requested keys.
84
+
85
+ ```ruby
86
+ user_loader.load(1)
87
+ .then { |user| user_loader.load(user.invited_by_id) }
88
+ .then { |invited_by| "User 1 was invited by ${invited_by[:name]}" }
89
+
90
+ # Elsewhere in your backend
91
+ user_loader.load(2)
92
+ .then { |user| user_loader.load(user.invited_by_id) }
93
+ .then { |invited_by| "User 2 was invited by ${invited_by[:name]}" }
94
+ ```
95
+
96
+ A naive solution is to issue four SQL queries to get required information, but with `Strongman` this application will make at most two queries (one to load users, and second one to load invites).
97
+
98
+ `Strongman` allows you to decouple unrelated parts of your application without sacrificing the performance of batch data-loading. While the loader presents an API that loads individual values, all concurrent requests will be coalesced and presented to your batch loading function. This allows your application to safely distribute data fetching requirements throughout your application and maintain minimal outgoing data requests.
99
+
100
+ ### Batch function
101
+
102
+ A batch loading function accepts an Array of keys, and returns Array of values or Hash that maps from keys to values (or a [Concurrent::Promises::Future](https://github.com/ruby-concurrency/concurrent-ruby) that returns such Array or Hash). There are a few constraints that must be upheld:
103
+
104
+ * The Array of values must be the same length as the Array of keys.
105
+ * Each index in the Array of values must correspond to the same index in the Array of keys.
106
+ * If Hash is returned, it must include all keys passed to batch loading function
107
+
108
+ For example, if your batch function was provided the Array of keys: `[ 2, 9, 6 ]`, you could return one of following:
109
+
110
+ ```ruby
111
+ [
112
+ { id: 2, name: "foo" },
113
+ { id: 9, name: "bar" },
114
+ { id: 6, name: "baz" }
115
+ ]
116
+ ```
117
+
118
+ ```ruby
119
+ {
120
+ 2 => { id: 2, name: "foo" },
121
+ 9 => { id: 9, name: "bar" },
122
+ 6 => { id: 6, name: "baz" }
123
+ }
124
+ ```
125
+
126
+ ## Caching
127
+
128
+ `Strongman` provides a memoization cache for all loads which occur withing single instance of it. After `#load` is called once with a given key, the resulting Promise is cached to eliminate redundant loads.
129
+
130
+ In addition to relieving pressure on your data storage, caching results per-request also creates fewer objects which may relieve memory pressure on your application:
131
+
132
+ ```
133
+ promise1 = user_loader.load(1)
134
+ promise2 = user_loader.load(1)
135
+ promise1 == promise2 # => true
136
+ ```
137
+
138
+ ### Caching per-request
139
+
140
+ `Strongman` caching does not replace Redis, Memcache, or any other shared application-level cache. `Strongman` is first and foremost a data loading mechanism, and its cache only serves the purpose of not repeatedly loading the same data in the context of a single request to your Application. To do this, it maintains a simple in-memory memoization cache (more accurately: `#load` is a memoized function).
141
+
142
+ Avoid multiple requests from different users using the same `Strongman` instance, which could result in cached data incorrectly appearing in each request. Typically, `Strongman` instances are created when a request begins, and are not used once the request ends.
143
+
144
+ See [Using with GraphQL](https://github.com/sheerun/dataloader#using-with-graphql) section to see how you can pass dataloader instances using context.
145
+
146
+ ### Caching errors
147
+
148
+ If a batch load fails (that is, a batch function throws or returns a rejected Promise), then the requested values will not be cached. However if a batch function returns an Error instance for an individual value, that Error will be cached to avoid frequently loading the same Error.
149
+
150
+ In some circumstances you may wish to clear the cache for these individual Errors:
151
+
152
+ ```ruby
153
+ user_loader.load(1).rescue do |error|
154
+ user_loader.cache.delete(1)
155
+ raise error
156
+ end
157
+ ```
158
+
159
+ ### Disabling cache
160
+
161
+ In certain uncommon cases, a `Strongman` which does not cache may be desirable. Calling `Strongman.new({ cache: nil }) { ... }` will ensure that every call to `#load` will produce a new Promise, and requested keys will not be saved in memory.
162
+
163
+ However, when the memoization cache is disabled, your batch function will receive an array of keys which may contain duplicates! Each key will be associated with each call to `#load`. Your batch loader should provide a value for each instance of the requested key.
164
+
165
+ ```ruby
166
+ loader = Strongman.new(cache: nil) do |keys|
167
+ puts keys
168
+ some_loading_function(keys)
169
+ end
170
+
171
+ loader.load('A')
172
+ loader.load('B')
173
+ loader.load('A')
174
+
175
+ // > [ 'A', 'B', 'A' ]
176
+ ```
177
+
178
+ ## API
179
+
180
+ ### `Strongman`
181
+
182
+ `Strongman` is a class for fetching data given unique keys such as the id column (or any other key).
183
+
184
+ Each `Strongman` instance contains a unique memoized cache. Because of it, it is recommended to use one `Strongman` instance **per web request**. You can use more long-lived instances, but then you need to take care of manually cleaning the cache.
185
+
186
+ You shouldn't share the same dataloader instance across different threads. This behavior is currently undefined.
187
+
188
+ ### `Strongman.new(**options = {}, &batch_load)`
189
+
190
+ Create a new `Strongman` given a batch loading function and options.
191
+
192
+ * `batch_load`: A block which accepts an Array of keys, and returns Array of values or Hash that maps from keys to values (or a [Promise](https://github.com/lgierth/promise.rb) that returns such value).
193
+ * `options`: An optional hash of options:
194
+ * `:key` **(not implemented yet)** A function to produce a cache key for a given load key. Defaults to function { |key| key }. Useful to provide when objects are keys and two similarly shaped objects should be considered equivalent.
195
+ * `:cache` An instance of cache used for caching of promies. Defaults to `Concurrent::Map.new`.
196
+ - The only required API is `#compute_if_absent(key)`).
197
+ - You can pass `nil` if you want to disable the cache.
198
+ - You can pass pre-populated cache as well. The values can be Promises.
199
+ * `:max_batch_size` Limits the number of items that get passed in to the batchLoadFn. Defaults to `INFINITY`. You can pass `1` to disable batching.
200
+
201
+ ### `#load(key)`
202
+
203
+ **key** [Object] a key to load using `batch_load`
204
+
205
+ Returns a [Future](https://github.com/ruby-concurrency/concurrent-ruby) of computed value.
206
+
207
+ You can resolve this promise when you actually need the value with `promise.value!`.
208
+
209
+ All calls to `#load` are batched until the first `#value!` is encountered. Then is starts batching again, et cetera.
210
+
211
+ ### `#load_many(keys)`
212
+
213
+ **keys** [Array<Object>] list of keys to load using `batch_load`
214
+
215
+ Returns a [Future](https://github.com/ruby-concurrency/concurrent-ruby) of array of computed values.
216
+
217
+ To give an example, to multiple keys:
218
+
219
+ ```ruby
220
+ promise = loader.load_many(['a', 'b'])
221
+ object_a, object_b = promise.value!
222
+ ```
223
+
224
+ This is equivalent to the more verbose:
225
+
226
+ ```ruby
227
+ promise = Concurrent::Promises.zip_futures(loader.load('a'), loader.load('b')).then {|*results| results}
228
+ object_a, object_b = promise.value!
229
+ ```
230
+
231
+ ### `#cache`
232
+
233
+ Returns the internal cache that can be overridden with `:cache` option (see constructor)
234
+
235
+ This field is writable, so you can reset the cache with something like:
236
+
237
+ ```ruby
238
+ loader.cache = Concurrent::Map.new
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT
@@ -0,0 +1,274 @@
1
+ require 'concurrent'
2
+
3
+ class Strongman
4
+ class NoCache
5
+ def compute_if_absent(_key)
6
+ yield
7
+ end
8
+ end
9
+
10
+ class Batch
11
+ attr_accessor :parent
12
+ attr_accessor :name
13
+ attr_accessor :lock
14
+ attr_accessor :fulfilled
15
+ attr_accessor :fulfilling
16
+
17
+ def initialize(loader_block, name: nil, parent: nil, max_batch_size: Float::INFINITY)
18
+ @name = name
19
+ @queue = Concurrent::Array.new
20
+ @promise = Concurrent::Promises.resolvable_future
21
+ @loader_block = loader_block
22
+ @lock = Concurrent::ReadWriteLock.new
23
+ @parent = parent
24
+ @children = Concurrent::Array.new
25
+ @fulfilling = Concurrent::AtomicBoolean.new(false)
26
+ @fulfilled = Concurrent::AtomicBoolean.new(false)
27
+ @max_batch_size = max_batch_size
28
+
29
+ @parent.children << self if @parent
30
+
31
+ @root = nil
32
+ @batch_chain = nil
33
+ end
34
+
35
+ def fulfilled?
36
+ root.fulfilled.true?
37
+ end
38
+
39
+ def fulfilling?
40
+ root.fulfilling.true?
41
+ end
42
+
43
+ def needs_fulfilling?
44
+ !fulfilled? && !fulfilling?
45
+ end
46
+
47
+ def queue(key)
48
+ @queue << key
49
+
50
+ future = @promise.then do |results|
51
+ unless results.key?(key)
52
+ raise StandardError, "Batch loader didn't resolve a key: #{key}. Resolved keys: #{results.keys}"
53
+ end
54
+
55
+ result = results[key]
56
+
57
+ if result.is_a?(Concurrent::Promises::Future)
58
+ result
59
+ else
60
+ Concurrent::Promises.resolvable_future.fulfill(result)
61
+ end
62
+ end.flat
63
+
64
+ #
65
+ # If our queue is full, fulfill immediately and return the bare future
66
+ #
67
+ if @queue.size >= @max_batch_size
68
+ root.fulfill_hierarchy
69
+
70
+ future
71
+ else
72
+ #
73
+ # If the queue is not full, create a delayed future that fulfills when the value is requested and chains
74
+ # to the inner future
75
+ #
76
+ Concurrent::Promises.delay do
77
+ # with_lock do
78
+ root.fulfill_hierarchy if root.needs_fulfilling?
79
+ # end
80
+
81
+ future
82
+ end.flat
83
+ end
84
+ end
85
+
86
+ def mark_fulfilled!
87
+ root.fulfilled.make_true
88
+ self
89
+ end
90
+
91
+ def mark_fulfilling!
92
+ root.fulfilling.make_true
93
+ self
94
+ end
95
+
96
+ def mark_not_fulfilling!
97
+ root.fulfilling.make_false
98
+ self
99
+ end
100
+
101
+ def with_lock
102
+ root.lock.with_write_lock do
103
+ yield
104
+ end
105
+ end
106
+
107
+ def root
108
+ if @root
109
+ @root
110
+ else
111
+ find_top = -> (batch) {
112
+ if batch.parent
113
+ find_top.(batch.parent)
114
+ else
115
+ batch
116
+ end
117
+ }
118
+
119
+ @root = find_top.(self)
120
+ end
121
+ end
122
+
123
+ def batch_chain
124
+ if @batch_chain
125
+ @batch_chain
126
+ else
127
+ @batch_chain = Concurrent::Array.new
128
+
129
+ add_children = -> (batch) {
130
+ @batch_chain << batch
131
+ if batch.children.size > 0
132
+ batch.children.flat_map(&add_children)
133
+ end
134
+ }
135
+ add_children.(root)
136
+
137
+ @batch_chain
138
+ end
139
+ end
140
+
141
+ def fulfill_hierarchy
142
+ raise Error.new("Only run #fulfill_hierarchy on root batches") if @parent
143
+
144
+ with_lock do
145
+ return if fulfilled?
146
+
147
+ mark_fulfilling!
148
+ batch_chain.reverse.each(&:fulfill!)
149
+ ensure
150
+ mark_fulfilled!
151
+ mark_not_fulfilling!
152
+ end
153
+ end
154
+
155
+ def fulfill!
156
+ results = @loader_block.call(@queue)
157
+
158
+ if results.is_a?(Concurrent::Promises::Future)
159
+ # if the strongman loader block returns a promise (e.g. if the block uses another loader),
160
+ # make sure to touch it to kick off any delayed effects before chaining
161
+ results.touch.then do |inner_results|
162
+ @promise.fulfill(normalize_results(inner_results))
163
+ end.flat
164
+ else
165
+ @promise.fulfill(normalize_results(results))
166
+ end
167
+
168
+ self
169
+ end
170
+
171
+ def children
172
+ @children ||= Concurrent::Array.new
173
+ end
174
+
175
+ private
176
+
177
+ def normalize_results(results)
178
+ unless results.is_a?(Array) || results.is_a?(Hash)
179
+ raise TypeError, "Batch loader must return an Array or Hash, but returned: #{results.class.name}"
180
+ end
181
+
182
+ if @queue.size != results.size
183
+ raise StandardError, "Batch loader must be instantiated with function that returns Array or Hash " \
184
+ "of the same size as provided to it Array of keys" \
185
+ "\n\nProvided keys:\n#{@queue}" \
186
+ "\n\nReturned values:\n#{results}"
187
+ end
188
+
189
+ if results.is_a?(Array)
190
+ Hash[@queue.zip(results)]
191
+ else
192
+ results.is_a?(Hash)
193
+ results
194
+ end
195
+ end
196
+ end
197
+
198
+ attr_accessor :cache
199
+
200
+ def initialize(**options, &block)
201
+ unless block_given?
202
+ raise TypeError, "Dataloader must be constructed with a block which accepts " \
203
+ "Array and returns either Array or Hash of the same size (or Promise)"
204
+ end
205
+
206
+ @name = options.delete(:name)
207
+ @parent = options.delete(:parent)
208
+ @cache = if options.has_key?(:cache)
209
+ options.delete(:cache) || NoCache.new
210
+ else
211
+ Concurrent::Map.new
212
+ end
213
+ @max_batch_size = options.fetch(:max_batch_size, Float::INFINITY)
214
+
215
+ @interceptor = options.delete(:interceptor) || -> (n) {
216
+ -> (ids) {
217
+ n.call(ids)
218
+ }
219
+ }
220
+
221
+ if @parent
222
+ @interceptor = @interceptor.call(-> (n) {
223
+ -> (ids) {
224
+ n.call(@parent, ids)
225
+ }
226
+ })
227
+ end
228
+
229
+ @loader_block = @interceptor.call(block)
230
+ end
231
+
232
+ def depends_on(**options, &block)
233
+ Strongman.new(**options, parent: self, &block)
234
+ end
235
+
236
+ def load(key)
237
+ if key.nil?
238
+ raise TypeError, "#load must be called with a key, but got: nil"
239
+ end
240
+
241
+ result = retrieve_from_cache(key) do
242
+ batch.queue(key)
243
+ end
244
+
245
+ if result.is_a?(Concurrent::Promises::Future)
246
+ result
247
+ else
248
+ Concurrent::Promises.future {result}
249
+ end
250
+ end
251
+
252
+ def load_many(keys)
253
+ unless keys.is_a?(Array)
254
+ raise TypeError, "#load_many must be called with an Array, but got: #{keys.class.name}"
255
+ end
256
+
257
+ promises = keys.map(&method(:load))
258
+ Concurrent::Promises.zip_futures(*promises).then {|*results| results}
259
+ end
260
+
261
+ def batch
262
+ if @batch.nil? || @batch.fulfilled?
263
+ @batch = Batch.new(@loader_block, name: @name, parent: @parent&.batch, max_batch_size: @max_batch_size)
264
+ else
265
+ @batch
266
+ end
267
+ end
268
+
269
+ def retrieve_from_cache(key)
270
+ @cache.compute_if_absent(key) do
271
+ yield
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,4 @@
1
+ class Strongman
2
+ # @!visibility private
3
+ VERSION = "1.0.0".freeze
4
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: strongman
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Caleb Land
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-07-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ description: A data loading utility to batch loading of promises. It can be used with
28
+ graphql gem.
29
+ email:
30
+ - caleb@land.fm
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - README.md
37
+ - lib/strongman.rb
38
+ - lib/strongman/version.rb
39
+ homepage: https://github.com/caleb/strongman
40
+ licenses:
41
+ - MIT
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 3.1.2
59
+ signing_key:
60
+ specification_version: 4
61
+ summary: Batch data loading, works great with graphql
62
+ test_files: []