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.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
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 invokes callback services for HTTP request results.
5
+ # Active Job that passes HTTP request results to callback services.
6
6
  #
7
- # Receives serialized Response or Error data and invokes the appropriate
8
- # callback service method (+on_complete+ or +on_error+).
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
- # Clean up externally stored payloads when job exhausts all retries.
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
- # @param data [Hash] Response or Error data (possibly a storage reference)
45
- # @param result_type [String] "response" or "error" indicating the type of result
46
- # @param callback_service_name [String] Fully qualified callback service class name
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