graphiti 1.7.9 → 1.8.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/graphiti.gemspec +1 -1
- data/lib/graphiti/configuration.rb +15 -0
- data/lib/graphiti/resource_proxy.rb +11 -3
- data/lib/graphiti/scope.rb +117 -44
- data/lib/graphiti/sideload/polymorphic_belongs_to.rb +7 -2
- data/lib/graphiti/sideload.rb +9 -4
- data/lib/graphiti/version.rb +1 -1
- metadata +12 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5530b650ab32f95fd4ed881afc07d68b5a252fa416959f19e8cced55de5266d2
|
4
|
+
data.tar.gz: 3054b7eb3cf13f3e6a744690fbc95ef38b010e6f8e7f4f6b9365696e9395c07a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 25dea9d82d42029ddb9f7cf5f0770005bf69c265531bcac22e89f843381abc29aa40b533ff00920c84a0ecdde2d889b423b16c1585f349e2d1c81abfdf877bdf
|
7
|
+
data.tar.gz: 348f6928dad9f049376b7971924014cf24d458c36159978bdd9996cd7b33da151b49426765361586b46c5a3a410820a57f14854854c349b42e8c79b8a07c4048
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,12 @@
|
|
1
1
|
graphiti changelog
|
2
2
|
|
3
|
+
# [1.8.0](https://github.com/graphiti-api/graphiti/compare/v1.7.9...v1.8.0) (2025-03-17)
|
4
|
+
|
5
|
+
|
6
|
+
### Features
|
7
|
+
|
8
|
+
* add thread pool with promises to limit concurrent sideloading ([#472](https://github.com/graphiti-api/graphiti/issues/472)) ([2998852](https://github.com/graphiti-api/graphiti/commit/2998852cea3e5f366e3748d808e26e83e484e989))
|
9
|
+
|
3
10
|
## [1.7.9](https://github.com/graphiti-api/graphiti/compare/v1.7.8...v1.7.9) (2025-03-16)
|
4
11
|
|
5
12
|
|
data/graphiti.gemspec
CHANGED
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.add_dependency "jsonapi-renderer", "~> 0.2", ">= 0.2.2"
|
23
23
|
spec.add_dependency "dry-types", ">= 0.15.0", "< 2.0"
|
24
24
|
spec.add_dependency "graphiti_errors", "~> 1.1.0"
|
25
|
-
spec.add_dependency "concurrent-ruby", "
|
25
|
+
spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
|
26
26
|
spec.add_dependency "activesupport", ">= 5.2"
|
27
27
|
|
28
28
|
spec.add_development_dependency "faraday", "~> 0.15"
|
@@ -8,6 +8,20 @@ module Graphiti
|
|
8
8
|
# Defaults to false OR if classes are cached (Rails-only)
|
9
9
|
attr_accessor :concurrency
|
10
10
|
|
11
|
+
# This number must be considered in accordance with the database
|
12
|
+
# connection pool size configured in `database.yml`. The connection
|
13
|
+
# pool should be large enough to accommodate both the foreground
|
14
|
+
# threads (ie. web server or job worker threads) and background
|
15
|
+
# threads. For each process, Graphiti will create one global
|
16
|
+
# executor that uses this many threads to sideload resources
|
17
|
+
# asynchronously. Thus, the pool size should be at least
|
18
|
+
# `thread_count + concurrency_max_threads + 1`. For example, if your
|
19
|
+
# web server has a maximum of 3 threads, and
|
20
|
+
# `concurrency_max_threads` is set to 4, then your pool size should
|
21
|
+
# be at least 8.
|
22
|
+
# @return [Integer] Maximum number of threads to use when fetching sideloads concurrently
|
23
|
+
attr_accessor :concurrency_max_threads
|
24
|
+
|
11
25
|
attr_accessor :respond_to
|
12
26
|
attr_accessor :context_for_endpoint
|
13
27
|
attr_accessor :links_on_demand
|
@@ -28,6 +42,7 @@ module Graphiti
|
|
28
42
|
def initialize
|
29
43
|
@raise_on_missing_sideload = true
|
30
44
|
@concurrency = false
|
45
|
+
@concurrency_max_threads = 4
|
31
46
|
@respond_to = [:json, :jsonapi, :xml]
|
32
47
|
@links_on_demand = false
|
33
48
|
@pagination_links_on_demand = false
|
@@ -77,9 +77,8 @@ module Graphiti
|
|
77
77
|
def data
|
78
78
|
@data ||= begin
|
79
79
|
records = @scope.resolve
|
80
|
-
if records.empty? && raise_on_missing?
|
81
|
-
|
82
|
-
end
|
80
|
+
raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
|
81
|
+
|
83
82
|
records = records[0] if single?
|
84
83
|
records
|
85
84
|
end
|
@@ -87,6 +86,15 @@ module Graphiti
|
|
87
86
|
alias_method :to_a, :data
|
88
87
|
alias_method :resolve_data, :data
|
89
88
|
|
89
|
+
def future_resolve_data
|
90
|
+
@scope.future_resolve.then do |records|
|
91
|
+
raise Graphiti::Errors::RecordNotFound if records.empty? && raise_on_missing?
|
92
|
+
|
93
|
+
records = records[0] if single?
|
94
|
+
@data = records
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
90
98
|
def meta
|
91
99
|
@meta ||= data.respond_to?(:meta) ? data.meta : {}
|
92
100
|
end
|
data/lib/graphiti/scope.rb
CHANGED
@@ -2,6 +2,35 @@ module Graphiti
|
|
2
2
|
class Scope
|
3
3
|
attr_accessor :object, :unpaginated_object
|
4
4
|
attr_reader :pagination
|
5
|
+
|
6
|
+
GLOBAL_THREAD_POOL_EXECUTOR_BROADCAST_STATS = %i[
|
7
|
+
length max_length queue_length max_queue completed_task_count largest_length scheduled_task_count synchronous
|
8
|
+
]
|
9
|
+
GLOBAL_THREAD_POOL_EXECUTOR = Concurrent::Promises.delay do
|
10
|
+
if Graphiti.config.concurrency
|
11
|
+
concurrency = Graphiti.config.concurrency_max_threads || 4
|
12
|
+
Concurrent::ThreadPoolExecutor.new(
|
13
|
+
min_threads: 0,
|
14
|
+
max_threads: concurrency,
|
15
|
+
max_queue: concurrency * 4,
|
16
|
+
fallback_policy: :caller_runs
|
17
|
+
)
|
18
|
+
else
|
19
|
+
Concurrent::ThreadPoolExecutor.new(max_threads: 0, synchronous: true, fallback_policy: :caller_runs)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
private_constant :GLOBAL_THREAD_POOL_EXECUTOR, :GLOBAL_THREAD_POOL_EXECUTOR_BROADCAST_STATS
|
23
|
+
|
24
|
+
def self.global_thread_pool_executor
|
25
|
+
GLOBAL_THREAD_POOL_EXECUTOR.value!
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.global_thread_pool_stats
|
29
|
+
GLOBAL_THREAD_POOL_EXECUTOR_BROADCAST_STATS.each_with_object({}) do |key, memo|
|
30
|
+
memo[key] = global_thread_pool_executor.send(key)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
5
34
|
def initialize(object, resource, query, opts = {})
|
6
35
|
@object = object
|
7
36
|
@resource = resource
|
@@ -14,57 +43,33 @@ module Graphiti
|
|
14
43
|
end
|
15
44
|
|
16
45
|
def resolve
|
17
|
-
|
18
|
-
[]
|
19
|
-
else
|
20
|
-
resolved = broadcast_data { |payload|
|
21
|
-
@object = @resource.before_resolve(@object, @query)
|
22
|
-
payload[:results] = @resource.resolve(@object)
|
23
|
-
payload[:results]
|
24
|
-
}
|
25
|
-
resolved.compact!
|
26
|
-
assign_serializer(resolved)
|
27
|
-
yield resolved if block_given?
|
28
|
-
@opts[:after_resolve]&.call(resolved)
|
29
|
-
resolve_sideloads(resolved) unless @query.sideloads.empty?
|
30
|
-
resolved
|
31
|
-
end
|
46
|
+
future_resolve.value!
|
32
47
|
end
|
33
48
|
|
34
49
|
def resolve_sideloads(results)
|
35
|
-
|
50
|
+
future_resolve_sideloads(results).value!
|
51
|
+
end
|
36
52
|
|
37
|
-
|
38
|
-
|
53
|
+
def future_resolve
|
54
|
+
return Concurrent::Promises.fulfilled_future([], self.class.global_thread_pool_executor) if @query.zero_results?
|
39
55
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
else
|
54
|
-
resolve_sideload.call
|
55
|
-
end
|
56
|
+
resolved = broadcast_data { |payload|
|
57
|
+
@object = @resource.before_resolve(@object, @query)
|
58
|
+
payload[:results] = @resource.resolve(@object)
|
59
|
+
payload[:results]
|
60
|
+
}
|
61
|
+
resolved.compact!
|
62
|
+
assign_serializer(resolved)
|
63
|
+
yield resolved if block_given?
|
64
|
+
@opts[:after_resolve]&.call(resolved)
|
65
|
+
sideloaded = @query.parents.any?
|
66
|
+
close_adapter = Graphiti.config.concurrency && sideloaded
|
67
|
+
if close_adapter
|
68
|
+
@resource.adapter.close
|
56
69
|
end
|
57
70
|
|
58
|
-
|
59
|
-
|
60
|
-
sleep 0.01 until promises.all? { |p| p.fulfilled? || p.rejected? }
|
61
|
-
# Re-raise the error with correct stacktrace
|
62
|
-
# OPTION** to avoid failing here?? if so need serializable patch
|
63
|
-
# to avoid loading data when association not loaded
|
64
|
-
if (rejected = promises.find(&:rejected?))
|
65
|
-
raise rejected.reason
|
66
|
-
end
|
67
|
-
end
|
71
|
+
future_resolve_sideloads(resolved)
|
72
|
+
.then_on(self.class.global_thread_pool_executor, resolved) { resolved }
|
68
73
|
end
|
69
74
|
|
70
75
|
def parent_resource
|
@@ -108,6 +113,74 @@ module Graphiti
|
|
108
113
|
|
109
114
|
private
|
110
115
|
|
116
|
+
def future_resolve_sideloads(results)
|
117
|
+
return Concurrent::Promises.fulfilled_future(nil, self.class.global_thread_pool_executor) if results == []
|
118
|
+
|
119
|
+
sideload_promises = @query.sideloads.filter_map do |name, q|
|
120
|
+
sideload = @resource.class.sideload(name)
|
121
|
+
next if sideload.nil? || sideload.shared_remote?
|
122
|
+
|
123
|
+
p = future_with_context(results, q, @resource) do |parent_results, sideload_query, parent_resource|
|
124
|
+
Graphiti.config.before_sideload&.call(Graphiti.context)
|
125
|
+
sideload.future_resolve(parent_results, sideload_query, parent_resource)
|
126
|
+
end
|
127
|
+
p.flat
|
128
|
+
end
|
129
|
+
|
130
|
+
Concurrent::Promises.zip_futures_on(self.class.global_thread_pool_executor, *sideload_promises)
|
131
|
+
.rescue_on(self.class.global_thread_pool_executor) do |*reasons|
|
132
|
+
first_error = reasons.find { |r| r.is_a?(Exception) }
|
133
|
+
raise first_error
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def future_with_context(*args)
|
138
|
+
thread_storage = Thread.current.keys.each_with_object({}) do |key, memo|
|
139
|
+
memo[key] = Thread.current[key]
|
140
|
+
end
|
141
|
+
fiber_storage =
|
142
|
+
if Fiber.current.respond_to?(:storage)
|
143
|
+
Fiber.current&.storage&.keys&.each_with_object({}) do |key, memo|
|
144
|
+
memo[key] = Fiber[key]
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
Concurrent::Promises.future_on(
|
149
|
+
self.class.global_thread_pool_executor, Thread.current.object_id, thread_storage, fiber_storage, *args
|
150
|
+
) do |thread_id, thread_storage, fiber_storage, *args|
|
151
|
+
wrap_in_rails_executor do
|
152
|
+
execution_context_changed = thread_id != Thread.current.object_id
|
153
|
+
if execution_context_changed
|
154
|
+
thread_storage&.keys&.each_with_object(Thread.current) do |key, thread_current|
|
155
|
+
thread_current[key] = thread_storage[key]
|
156
|
+
end
|
157
|
+
fiber_storage&.keys&.each_with_object(Fiber) do |key, fiber_current|
|
158
|
+
fiber_current[key] = fiber_storage[key]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
result = Graphiti.broadcast(:global_thread_pool_task_run, self.class.global_thread_pool_stats) do
|
163
|
+
yield(*args)
|
164
|
+
end
|
165
|
+
|
166
|
+
if execution_context_changed
|
167
|
+
thread_storage&.keys&.each { |key| Thread.current[key] = nil }
|
168
|
+
fiber_storage&.keys&.each { |key| Fiber[key] = nil }
|
169
|
+
end
|
170
|
+
|
171
|
+
result
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def wrap_in_rails_executor(&block)
|
177
|
+
if defined?(::Rails.application.executor)
|
178
|
+
::Rails.application.executor.wrap(&block)
|
179
|
+
else
|
180
|
+
yield
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
111
184
|
def sideload_resource_proxies
|
112
185
|
@sideload_resource_proxies ||= begin
|
113
186
|
@object = @resource.before_resolve(@object, @query)
|
@@ -108,18 +108,23 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
|
|
108
108
|
end
|
109
109
|
|
110
110
|
def resolve(parents, query, graph_parent)
|
111
|
-
parents
|
111
|
+
future_resolve(parents, query, graph_parent).value!
|
112
|
+
end
|
113
|
+
|
114
|
+
def future_resolve(parents, query, graph_parent)
|
115
|
+
promises = parents.group_by(&grouper.field_name).filter_map do |(group_name, group)|
|
112
116
|
next if group_name.nil? || grouper.ignore?(group_name)
|
113
117
|
|
114
118
|
match = ->(c) { c.group_name == group_name.to_sym }
|
115
119
|
if (sideload = children.values.find(&match))
|
116
120
|
duped = remove_invalid_sideloads(sideload.resource, query)
|
117
|
-
sideload.
|
121
|
+
sideload.future_resolve(group, duped, graph_parent)
|
118
122
|
else
|
119
123
|
err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
|
120
124
|
raise err.new(self, group_name)
|
121
125
|
end
|
122
126
|
end
|
127
|
+
Concurrent::Promises.zip(*promises)
|
123
128
|
end
|
124
129
|
|
125
130
|
private
|
data/lib/graphiti/sideload.rb
CHANGED
@@ -236,7 +236,7 @@ module Graphiti
|
|
236
236
|
end
|
237
237
|
|
238
238
|
def load(parents, query, graph_parent)
|
239
|
-
|
239
|
+
future_load(parents, query, graph_parent).value!
|
240
240
|
end
|
241
241
|
|
242
242
|
# Override in subclass
|
@@ -286,7 +286,7 @@ module Graphiti
|
|
286
286
|
children.replace(associated) if track_associated
|
287
287
|
end
|
288
288
|
|
289
|
-
def
|
289
|
+
def future_resolve(parents, query, graph_parent)
|
290
290
|
if single? && parents.length > 1
|
291
291
|
raise Errors::SingularSideload.new(self, parents.length)
|
292
292
|
end
|
@@ -300,11 +300,11 @@ module Graphiti
|
|
300
300
|
sideload: self,
|
301
301
|
sideload_parent_length: parents.length,
|
302
302
|
default_paginate: false
|
303
|
-
sideload_scope.
|
303
|
+
sideload_scope.future_resolve do |sideload_results|
|
304
304
|
fire_assign(parents, sideload_results)
|
305
305
|
end
|
306
306
|
else
|
307
|
-
|
307
|
+
future_load(parents, query, graph_parent)
|
308
308
|
end
|
309
309
|
end
|
310
310
|
|
@@ -368,6 +368,11 @@ module Graphiti
|
|
368
368
|
|
369
369
|
private
|
370
370
|
|
371
|
+
def future_load(parents, query, graph_parent)
|
372
|
+
proxy = build_resource_proxy(parents, query, graph_parent)
|
373
|
+
proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
|
374
|
+
end
|
375
|
+
|
371
376
|
def blank_query?(params)
|
372
377
|
if (filter = params[:filter])
|
373
378
|
if filter.values == [""]
|
data/lib/graphiti/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: graphiti
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Lee Richmond
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-03-
|
11
|
+
date: 2025-03-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jsonapi-serializable
|
@@ -82,16 +82,22 @@ dependencies:
|
|
82
82
|
name: concurrent-ruby
|
83
83
|
requirement: !ruby/object:Gem::Requirement
|
84
84
|
requirements:
|
85
|
-
- - "
|
85
|
+
- - ">="
|
86
86
|
- !ruby/object:Gem::Version
|
87
|
-
version: '1.
|
87
|
+
version: '1.2'
|
88
|
+
- - "<"
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '2.0'
|
88
91
|
type: :runtime
|
89
92
|
prerelease: false
|
90
93
|
version_requirements: !ruby/object:Gem::Requirement
|
91
94
|
requirements:
|
92
|
-
- - "
|
95
|
+
- - ">="
|
93
96
|
- !ruby/object:Gem::Version
|
94
|
-
version: '1.
|
97
|
+
version: '1.2'
|
98
|
+
- - "<"
|
99
|
+
- !ruby/object:Gem::Version
|
100
|
+
version: '2.0'
|
95
101
|
- !ruby/object:Gem::Dependency
|
96
102
|
name: activesupport
|
97
103
|
requirement: !ruby/object:Gem::Requirement
|