patient_http-solid_queue 1.2.0 → 1.3.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/ARCHITECTURE.md +10 -8
- data/CHANGELOG.md +30 -0
- data/README.md +303 -234
- data/VERSION +1 -1
- data/lib/generators/patient_http/solid_queue/install_generator.rb +248 -0
- data/lib/generators/patient_http/solid_queue/templates/initializer.rb +102 -0
- data/lib/patient_http/solid_queue/callback_job.rb +14 -7
- data/lib/patient_http/solid_queue/configuration.rb +178 -76
- data/lib/patient_http/solid_queue/context.rb +10 -9
- data/lib/patient_http/solid_queue/engine.rb +3 -1
- data/lib/patient_http/solid_queue/gc_lock.rb +4 -4
- data/lib/patient_http/solid_queue/inflight_request.rb +4 -4
- data/lib/patient_http/solid_queue/lifecycle_hooks.rb +11 -1
- data/lib/patient_http/solid_queue/process_registration.rb +4 -4
- data/lib/patient_http/solid_queue/processor_observer.rb +43 -12
- data/lib/patient_http/solid_queue/record.rb +2 -1
- data/lib/patient_http/solid_queue/request_executor.rb +37 -20
- data/lib/patient_http/solid_queue/request_job.rb +21 -12
- data/lib/patient_http/solid_queue/task_handler.rb +44 -12
- data/lib/patient_http/solid_queue/task_monitor.rb +91 -73
- data/lib/patient_http/solid_queue/task_monitor_thread.rb +15 -13
- data/lib/patient_http/solid_queue.rb +274 -121
- data/patient_http-solid_queue.gemspec +2 -2
- metadata +7 -5
- /data/db/migrate/{20260216000000_create_solid_queue_async_http_tables.rb → 20260216000000_create_patient_http_solid_queue_tables.rb} +0 -0
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.0
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
|
|
7
|
+
module PatientHttp
|
|
8
|
+
module SolidQueue
|
|
9
|
+
# Installs the crash recovery migration and a commented initializer.
|
|
10
|
+
#
|
|
11
|
+
# The migration must run on the database that Solid Queue uses. The
|
|
12
|
+
# generator finds that database in `config/database.yml` and copies the
|
|
13
|
+
# migration to its migrations path, so a multi-database application needs
|
|
14
|
+
# no extra arguments. To name the database explicitly, pass `--database`.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# bin/rails generate patient_http:solid_queue:install
|
|
18
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
19
|
+
include ::Rails::Generators::Migration
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
|
|
23
|
+
# The gem's migration. The generator copies this file so that the
|
|
24
|
+
# generator and the engine's `install:migrations` task use one schema.
|
|
25
|
+
MIGRATION_SOURCE = File.expand_path(
|
|
26
|
+
"../../../../db/migrate/20260216000000_create_patient_http_solid_queue_tables.rb",
|
|
27
|
+
__dir__
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
desc "Copies the patient_http-solid_queue migration and creates a commented initializer."
|
|
31
|
+
|
|
32
|
+
class_option :database,
|
|
33
|
+
type: :string,
|
|
34
|
+
default: nil,
|
|
35
|
+
desc: "Name of the database Solid Queue uses (detected from config/database.yml when omitted)"
|
|
36
|
+
|
|
37
|
+
class_option :skip_initializer,
|
|
38
|
+
type: :boolean,
|
|
39
|
+
default: false,
|
|
40
|
+
desc: "Skip creating config/initializers/patient_http.rb"
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Returns the timestamp prefix for the new migration.
|
|
44
|
+
#
|
|
45
|
+
# @param dirname [String] The directory that the migration is copied to.
|
|
46
|
+
# @return [String] The timestamp prefix.
|
|
47
|
+
def next_migration_number(dirname)
|
|
48
|
+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Copies the migration to the Solid Queue database's migrations path.
|
|
53
|
+
# Skips the copy if any migrations path already has a migration for the
|
|
54
|
+
# tables.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def copy_migration
|
|
58
|
+
existing = existing_migrations.first
|
|
59
|
+
if existing
|
|
60
|
+
say_status(:skip, "#{relative_to_original_destination_root(existing)} already creates the tables", :yellow)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
migration_template(
|
|
65
|
+
MIGRATION_SOURCE,
|
|
66
|
+
File.join(migration_directory, "create_patient_http_solid_queue_tables.rb")
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Creates `config/initializers/patient_http.rb`, unless
|
|
71
|
+
# `--skip-initializer` is set.
|
|
72
|
+
#
|
|
73
|
+
# @return [void]
|
|
74
|
+
def create_initializer
|
|
75
|
+
return if options[:skip_initializer]
|
|
76
|
+
|
|
77
|
+
template("initializer.rb", "config/initializers/patient_http.rb")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Prints the migrate command and a usage example.
|
|
81
|
+
#
|
|
82
|
+
# @return [void]
|
|
83
|
+
def show_next_steps
|
|
84
|
+
say("")
|
|
85
|
+
say("patient_http-solid_queue is installed.", :green)
|
|
86
|
+
say("")
|
|
87
|
+
say("Run the migration to create the crash-recovery tables:")
|
|
88
|
+
say("")
|
|
89
|
+
say(" bin/rails #{migrate_task}")
|
|
90
|
+
say("")
|
|
91
|
+
if other_database_env
|
|
92
|
+
say("The #{database_config.name} database is configured only for #{other_database_env}.", :yellow)
|
|
93
|
+
say("Run the migration where #{other_database_env} is deployed, for example as part of a deploy.", :yellow)
|
|
94
|
+
say("")
|
|
95
|
+
end
|
|
96
|
+
say("Nothing else is required: the request handler is registered when the gem")
|
|
97
|
+
say("loads and the processor starts and stops with your Solid Queue workers.")
|
|
98
|
+
say("")
|
|
99
|
+
say("Make a request from anywhere in your application:")
|
|
100
|
+
say("")
|
|
101
|
+
say(" PatientHttp.get(url, callback: MyCallback, callback_args: {id: 1})")
|
|
102
|
+
say("")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Returns the migrations path of the database that Solid Queue uses. For
|
|
108
|
+
# single-database applications, falls back to `db/migrate`.
|
|
109
|
+
#
|
|
110
|
+
# @return [String] The migrations path.
|
|
111
|
+
def migration_directory
|
|
112
|
+
@migration_directory ||= begin
|
|
113
|
+
path = Array(database_config&.migrations_paths).first
|
|
114
|
+
path || "db/migrate"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns the configuration of the database that Solid Queue uses.
|
|
119
|
+
#
|
|
120
|
+
# The generator checks the following, in order:
|
|
121
|
+
#
|
|
122
|
+
# 1. The database named by `--database`.
|
|
123
|
+
# 2. The database that Solid Queue connects to in the current environment
|
|
124
|
+
# (`config.solid_queue.connects_to`).
|
|
125
|
+
# 3. The primary database, if the current environment runs Solid Queue
|
|
126
|
+
# without `connects_to`.
|
|
127
|
+
# 4. A database named `queue`, which is the Rails default for Solid Queue,
|
|
128
|
+
# then any database whose name contains `queue`.
|
|
129
|
+
# 5. The primary database.
|
|
130
|
+
#
|
|
131
|
+
# Databases in the current environment are checked first, then databases
|
|
132
|
+
# in the other environments. Solid Queue often has its own database only
|
|
133
|
+
# in production, so running the generator in development still finds it.
|
|
134
|
+
#
|
|
135
|
+
# @return [ActiveRecord::DatabaseConfigurations::DatabaseConfig, nil] The
|
|
136
|
+
# database configuration, or `nil` if `config/database.yml` can't be
|
|
137
|
+
# read.
|
|
138
|
+
# @raise [Rails::Generators::Error] If `--database` names a database that
|
|
139
|
+
# isn't configured, or `config/database.yml` can't be read to find it.
|
|
140
|
+
def database_config
|
|
141
|
+
return @database_config if defined?(@database_config)
|
|
142
|
+
|
|
143
|
+
configs = database_configs
|
|
144
|
+
@database_config = if options[:database]
|
|
145
|
+
named = configs&.find { |config| config.name == options[:database] }
|
|
146
|
+
unless named
|
|
147
|
+
raise ::Rails::Generators::Error.new(
|
|
148
|
+
"No #{options[:database].inspect} database is configured in config/database.yml."
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
named
|
|
152
|
+
elsif configs
|
|
153
|
+
primary = configs.find { |config| config.env_name == ::Rails.env && config.name == "primary" }
|
|
154
|
+
connected_name = solid_queue_database_name
|
|
155
|
+
connected = configs.find { |config| config.env_name == ::Rails.env && config.name == connected_name } if connected_name
|
|
156
|
+
|
|
157
|
+
if connected
|
|
158
|
+
connected
|
|
159
|
+
elsif connected_name.nil? && solid_queue_adapter?
|
|
160
|
+
primary
|
|
161
|
+
else
|
|
162
|
+
configs.find { |config| config.name == "queue" } ||
|
|
163
|
+
configs.find { |config| config.name.to_s.include?("queue") } ||
|
|
164
|
+
primary
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns the name of the database that Solid Queue connects to in the
|
|
170
|
+
# current environment.
|
|
171
|
+
#
|
|
172
|
+
# @return [String, nil] The database name, or `nil` if Solid Queue doesn't
|
|
173
|
+
# set `connects_to` with a writing database.
|
|
174
|
+
def solid_queue_database_name
|
|
175
|
+
return nil unless defined?(::SolidQueue) && ::SolidQueue.respond_to?(:connects_to)
|
|
176
|
+
|
|
177
|
+
connects_to = ::SolidQueue.connects_to
|
|
178
|
+
return nil unless connects_to.is_a?(Hash)
|
|
179
|
+
|
|
180
|
+
connects_to.dig(:database, :writing)&.to_s
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Returns whether Active Job uses Solid Queue in the current environment.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean] `true` if the Active Job queue adapter is Solid Queue.
|
|
186
|
+
def solid_queue_adapter?
|
|
187
|
+
::ActiveJob::Base.queue_adapter_name.to_s == "solid_queue"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns the database configurations from `config/database.yml`, with
|
|
191
|
+
# the current environment's configurations first.
|
|
192
|
+
#
|
|
193
|
+
# @return [Array<ActiveRecord::DatabaseConfigurations::DatabaseConfig>, nil]
|
|
194
|
+
# The database configurations, or `nil` if `config/database.yml` can't
|
|
195
|
+
# be read.
|
|
196
|
+
def database_configs
|
|
197
|
+
return @database_configs if defined?(@database_configs)
|
|
198
|
+
|
|
199
|
+
@database_configs = begin
|
|
200
|
+
all_configs = ::ActiveRecord::Base.configurations.configs_for
|
|
201
|
+
current_env_configs = all_configs.select { |config| config.env_name == ::Rails.env }
|
|
202
|
+
current_env_configs + (all_configs - current_env_configs)
|
|
203
|
+
rescue => e
|
|
204
|
+
# Without a readable database configuration, fall back to db/migrate
|
|
205
|
+
# and let the developer move the file if it landed in the wrong place.
|
|
206
|
+
say("Could not read config/database.yml (#{e.class}); using db/migrate.", :yellow)
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Returns the Rake task that runs the migration on the detected database.
|
|
212
|
+
#
|
|
213
|
+
# @return [String] The task name.
|
|
214
|
+
def migrate_task
|
|
215
|
+
name = database_config&.name
|
|
216
|
+
return "db:migrate" if name.nil? || name == "primary"
|
|
217
|
+
|
|
218
|
+
"db:migrate:#{name}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Returns the environment of the detected database if it isn't the
|
|
222
|
+
# current environment.
|
|
223
|
+
#
|
|
224
|
+
# @return [String, nil] The environment name, or `nil` if the database is
|
|
225
|
+
# in the current environment or wasn't detected.
|
|
226
|
+
def other_database_env
|
|
227
|
+
env_name = database_config&.env_name
|
|
228
|
+
env_name unless env_name.nil? || env_name == ::Rails.env
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Returns the paths of migrations that already create this gem's tables.
|
|
232
|
+
# Checks the migrations paths of every configured database and
|
|
233
|
+
# `db/migrate`, and matches copies made by the engine's
|
|
234
|
+
# `install:migrations` task and copies under the gem's earlier migration
|
|
235
|
+
# name.
|
|
236
|
+
#
|
|
237
|
+
# @return [Array<String>] The migration paths.
|
|
238
|
+
def existing_migrations
|
|
239
|
+
directories = [migration_directory, "db/migrate"]
|
|
240
|
+
directories += Array(database_configs).flat_map { |config| Array(config.migrations_paths) }
|
|
241
|
+
pattern = "[0-9]*_create_{patient_http_solid_queue,solid_queue_async_http}_tables*.rb"
|
|
242
|
+
directories.uniq.flat_map do |directory|
|
|
243
|
+
Dir.glob(File.join(destination_root, directory, pattern))
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Configuration for patient_http running on Solid Queue.
|
|
4
|
+
#
|
|
5
|
+
# Every option below is optional. Each is shown with its default value or with
|
|
6
|
+
# an example value. The gem works with no configuration at all: requiring it
|
|
7
|
+
# registers the request handler, and the async processor starts and stops with
|
|
8
|
+
# your Solid Queue workers.
|
|
9
|
+
#
|
|
10
|
+
# Make requests from anywhere in your application:
|
|
11
|
+
#
|
|
12
|
+
# PatientHttp.get("https://api.example.com/users/1",
|
|
13
|
+
# callback: FetchUserCallback,
|
|
14
|
+
# callback_args: {user_id: 1})
|
|
15
|
+
#
|
|
16
|
+
# Full reference: https://github.com/bdurand/patient_http-solid_queue#configuration
|
|
17
|
+
|
|
18
|
+
PatientHttp.configure do |config|
|
|
19
|
+
# --- HTTP behavior -------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# Maximum concurrent HTTP requests per processor. Each named processor below
|
|
22
|
+
# has its own limit and uses this value unless it sets max_connections.
|
|
23
|
+
# config.max_connections = 256
|
|
24
|
+
|
|
25
|
+
# Default request timeout in seconds. Raise it for slow APIs; LLM APIs can
|
|
26
|
+
# take minutes.
|
|
27
|
+
# config.request_timeout = 60
|
|
28
|
+
|
|
29
|
+
# Cap sockets opened against any single host so one host cannot consume every
|
|
30
|
+
# file descriptor. Unlimited by default.
|
|
31
|
+
# config.max_connections_per_host = 32
|
|
32
|
+
|
|
33
|
+
# Maximum response body size in bytes. Larger responses raise
|
|
34
|
+
# PatientHttp::ResponseTooLargeError.
|
|
35
|
+
# config.max_response_size = 1024 * 1024
|
|
36
|
+
|
|
37
|
+
# Treat 4xx and 5xx responses as errors (routing them to the callback's
|
|
38
|
+
# on_error) instead of delivering them to on_complete.
|
|
39
|
+
# config.raise_error_responses = false
|
|
40
|
+
|
|
41
|
+
# User-Agent sent with every request.
|
|
42
|
+
# config.user_agent = "MyApp/1.0"
|
|
43
|
+
|
|
44
|
+
# --- Sending sensitive values -------------------------------------------
|
|
45
|
+
|
|
46
|
+
# Requests are serialized into the queue before they run. Register secrets by
|
|
47
|
+
# name and reference them with PatientHttp.secret(:api_token) when building a
|
|
48
|
+
# request; only the name is written to the queue, and the value is resolved
|
|
49
|
+
# in the processor at send time.
|
|
50
|
+
#
|
|
51
|
+
# config.register_secret(:api_token) { ENV["API_TOKEN"] }
|
|
52
|
+
|
|
53
|
+
# Encrypt request and response payloads in the queue. Pass an array of keys
|
|
54
|
+
# to rotate: the first encrypts, all of them decrypt.
|
|
55
|
+
#
|
|
56
|
+
# config.encryption_key = Rails.application.credentials.patient_http_key
|
|
57
|
+
|
|
58
|
+
# --- Large payloads ------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# Payloads over the threshold are written to a payload store instead of being
|
|
61
|
+
# passed through the queue. Register a store to turn this on.
|
|
62
|
+
#
|
|
63
|
+
# config.register_payload_store(:database, adapter: :active_record)
|
|
64
|
+
# config.payload_store_threshold = 64 * 1024
|
|
65
|
+
|
|
66
|
+
# --- Workload isolation --------------------------------------------------
|
|
67
|
+
|
|
68
|
+
# Named processors run independently, each with its own capacity and
|
|
69
|
+
# timeouts, so a burst of one kind of work cannot starve another. Route a
|
|
70
|
+
# request with PatientHttp.get(url, callback: Cb, processor: :llm).
|
|
71
|
+
#
|
|
72
|
+
# config.processor(:llm, max_connections: 200, request_timeout: 120)
|
|
73
|
+
# config.processor(:webhooks, max_connections: 64, request_timeout: 10)
|
|
74
|
+
|
|
75
|
+
# --- Solid Queue specifics -----------------------------------------------
|
|
76
|
+
|
|
77
|
+
# Queue used for this gem's request and callback jobs.
|
|
78
|
+
# config.queue_name = "patient_http"
|
|
79
|
+
|
|
80
|
+
# Graceful shutdown budget in seconds. Defaults to Solid Queue's own shutdown
|
|
81
|
+
# timeout minus two seconds, and must stay below it.
|
|
82
|
+
# config.shutdown_timeout = 23
|
|
83
|
+
|
|
84
|
+
# Called when Active Job discards a callback job.
|
|
85
|
+
# config.on_retries_exhausted { |error| Sentry.capture_message(error.message) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Callback jobs are not retried by default. Uncomment to retry them before they
|
|
89
|
+
# are discarded.
|
|
90
|
+
#
|
|
91
|
+
# PatientHttp::SolidQueue::CallbackJob.retry_on StandardError,
|
|
92
|
+
# wait: :polynomially_longer, attempts: 5
|
|
93
|
+
|
|
94
|
+
# Hooks for metrics. Both can be registered more than once.
|
|
95
|
+
#
|
|
96
|
+
# PatientHttp::SolidQueue.after_completion do |response|
|
|
97
|
+
# StatsD.timing("patient_http.duration", response.duration * 1000)
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# PatientHttp::SolidQueue.after_error do |error|
|
|
101
|
+
# StatsD.increment("patient_http.error")
|
|
102
|
+
# end
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
4
|
module SolidQueue
|
|
5
|
-
# Active Job that
|
|
5
|
+
# Active Job that passes HTTP request results to callback services.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
# callback service
|
|
7
|
+
# The job receives serialized response or error data and calls the
|
|
8
|
+
# callback service's `on_complete` or `on_error` method.
|
|
9
9
|
#
|
|
10
10
|
# @api private
|
|
11
11
|
class CallbackJob < ActiveJob::Base
|
|
12
|
-
#
|
|
12
|
+
# Calls the `on_retries_exhausted` handler and deletes any externally
|
|
13
|
+
# stored payload when Active Job discards the job.
|
|
13
14
|
after_discard do |job, _exception|
|
|
14
15
|
data = job.arguments[0]
|
|
15
16
|
result_type = job.arguments[1]
|
|
@@ -41,9 +42,15 @@ module PatientHttp
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# @param
|
|
45
|
+
# Calls the callback service with the result.
|
|
46
|
+
#
|
|
47
|
+
# @param data [Hash] The serialized Response or Error, or a reference to
|
|
48
|
+
# it in external storage. The data can be encrypted.
|
|
49
|
+
# @param result_type [String] The result type: `"response"` or `"error"`.
|
|
50
|
+
# @param callback_service_name [String] The fully qualified callback
|
|
51
|
+
# service class name.
|
|
52
|
+
# @return [void]
|
|
53
|
+
# @raise [ArgumentError] If `result_type` isn't valid.
|
|
47
54
|
def perform(data, result_type, callback_service_name)
|
|
48
55
|
callback_service_class = PatientHttp::ClassHelper.resolve_class_name(callback_service_name)
|
|
49
56
|
callback_service = callback_service_class.new
|