graphiti 1.7.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e36dfd471013c8ead523d885a475968aeb18947a268e7b7a68e03e10dcc9fd0d
4
- data.tar.gz: a07c6145a4528154dc2592850c434225bfd607038bd80b55ee39efbbac803cb2
3
+ metadata.gz: 5530b650ab32f95fd4ed881afc07d68b5a252fa416959f19e8cced55de5266d2
4
+ data.tar.gz: 3054b7eb3cf13f3e6a744690fbc95ef38b010e6f8e7f4f6b9365696e9395c07a
5
5
  SHA512:
6
- metadata.gz: f6cb999efba286de44b69bfb7a3a56333dbf78c2f51b3dc9359f0fff3da867a8a7bdc98cfa55cbef5f44e3146350d09a729f5d2edc43fb50dd02e264d30f1034
7
- data.tar.gz: faebd5bf5f20ba3a6df4494c1f45aec44c630afd8841b78daad0e5e575bcd0932560e776c38fe38be77a4d6efd7dd1d59b184ae37ec449778f952b32cbd69562
6
+ metadata.gz: 25dea9d82d42029ddb9f7cf5f0770005bf69c265531bcac22e89f843381abc29aa40b533ff00920c84a0ecdde2d889b423b16c1585f349e2d1c81abfdf877bdf
7
+ data.tar.gz: 348f6928dad9f049376b7971924014cf24d458c36159978bdd9996cd7b33da151b49426765361586b46c5a3a410820a57f14854854c349b42e8c79b8a07c4048
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [1.7.9](https://github.com/graphiti-api/graphiti/compare/v1.7.8...v1.7.9) (2025-03-16)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * update version check for clear active connections active record deprecation ([#491](https://github.com/graphiti-api/graphiti/issues/491)) ([4e764f6](https://github.com/graphiti-api/graphiti/commit/4e764f66c3a06b4a83c37afa83ddd64a78ef3b19))
16
+
3
17
  ## [1.7.8](https://github.com/graphiti-api/graphiti/compare/v1.7.7...v1.7.8) (2025-03-16)
4
18
 
5
19
 
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", "~> 1.0"
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"
@@ -304,7 +304,7 @@ module Graphiti
304
304
  end
305
305
 
306
306
  def close
307
- if ::ActiveRecord.version > Gem::Version.new("7.2")
307
+ if ::ActiveRecord.version > Gem::Version.new("7.1")
308
308
  ::ActiveRecord::Base.connection_handler.clear_active_connections!
309
309
  else
310
310
  ::ActiveRecord::Base.clear_active_connections!
@@ -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
- raise Graphiti::Errors::RecordNotFound
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
@@ -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
- if @query.zero_results?
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
- return if results == []
50
+ future_resolve_sideloads(results).value!
51
+ end
36
52
 
37
- concurrent = Graphiti.config.concurrency
38
- promises = []
53
+ def future_resolve
54
+ return Concurrent::Promises.fulfilled_future([], self.class.global_thread_pool_executor) if @query.zero_results?
39
55
 
40
- @query.sideloads.each_pair do |name, q|
41
- sideload = @resource.class.sideload(name)
42
- next if sideload.nil? || sideload.shared_remote?
43
- parent_resource = @resource
44
- graphiti_context = Graphiti.context
45
- resolve_sideload = -> {
46
- Graphiti.config.before_sideload&.call(graphiti_context)
47
- Graphiti.context = graphiti_context
48
- sideload.resolve(results, q, parent_resource)
49
- @resource.adapter.close if concurrent
50
- }
51
- if concurrent
52
- promises << Concurrent::Promise.execute(&resolve_sideload)
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
- if concurrent
59
- # Wait for all promises to finish
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.group_by(&grouper.field_name).each_pair do |group_name, group|
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.resolve(group, duped, graph_parent)
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
@@ -236,7 +236,7 @@ module Graphiti
236
236
  end
237
237
 
238
238
  def load(parents, query, graph_parent)
239
- build_resource_proxy(parents, query, graph_parent).to_a
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 resolve(parents, query, graph_parent)
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.resolve do |sideload_results|
303
+ sideload_scope.future_resolve do |sideload_results|
304
304
  fire_assign(parents, sideload_results)
305
305
  end
306
306
  else
307
- load(parents, query, graph_parent)
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 == [""]
@@ -1,3 +1,3 @@
1
1
  module Graphiti
2
- VERSION = "1.7.8"
2
+ VERSION = "1.8.0"
3
3
  end
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.7.8
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-16 00:00:00.000000000 Z
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.0'
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.0'
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