traject-solr_pool 0.1.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.
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'base64'
6
+ require 'http'
7
+ require 'concurrent/atomic/atomic_fixnum'
8
+ require 'traject/util'
9
+ require 'traject/thread_pool'
10
+
11
+ require_relative 'connection'
12
+
13
+ module Traject
14
+ module SolrPool
15
+ # Drop-in replacement for Traject::SolrJsonWriter that sends every Solr HTTP
16
+ # call through a persistent http_connection_pool pool. Reuses the stock
17
+ # writer's settings vocabulary and public surface; all HTTP is delegated to
18
+ # Traject::SolrPool::Connection.
19
+ class SolrJsonWriter
20
+ URI_REGEXP = URI::DEFAULT_PARSER.make_regexp.freeze
21
+ DEFAULT_MAX_SKIPPED = 0
22
+ DEFAULT_BATCH_SIZE = 100
23
+
24
+ class MaxSkippedRecordsExceeded < RuntimeError; end
25
+
26
+ # Raised when Solr returns a non-2xx response; carries the raw response.
27
+ # Its #response is an http.rb HTTP::Response (use #code / #to_s), not an
28
+ # HTTPClient message.
29
+ class BadHttpResponse < RuntimeError
30
+ attr_reader :response
31
+
32
+ def initialize(msg, response = nil)
33
+ solr_error = find_solr_error(response)
34
+ msg = "#{msg}: #{solr_error}" if solr_error
35
+ super(msg)
36
+ @response = response
37
+ end
38
+
39
+ private
40
+
41
+ def find_solr_error(response)
42
+ return nil unless response&.to_s && response.headers['Content-Type'].to_s.start_with?('application/json')
43
+
44
+ JSON.parse(response.to_s).dig('error', 'msg')
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+ end
49
+
50
+ attr_reader :settings, :thread_pool_size, :batched_queue, :solr_update_url,
51
+ :connection, :pool_size
52
+
53
+ def initialize(arg_settings)
54
+ @settings = Traject::Indexer::Settings.new(arg_settings)
55
+ configure_from_settings
56
+ end
57
+
58
+ def solr_update_url_with_query(query_params)
59
+ return @solr_update_url unless query_params
60
+
61
+ "#{@solr_update_url}?#{URI.encode_www_form(query_params)}"
62
+ end
63
+
64
+ def logger
65
+ settings['logger'] ||= Yell.new($stderr, level: 'gt.fatal')
66
+ end
67
+
68
+ def put(context)
69
+ @thread_pool.raise_collected_exception!
70
+ @batched_queue << context
71
+ return if @batched_queue.size < @batch_size
72
+
73
+ batch = Traject::Util.drain_queue(@batched_queue)
74
+ @thread_pool.maybe_in_thread_pool(batch) { |b| send_batch(b) }
75
+ end
76
+
77
+ def flush
78
+ send_batch(Traject::Util.drain_queue(@batched_queue))
79
+ end
80
+
81
+ def send_batch(batch)
82
+ return if batch.empty?
83
+
84
+ json_package = JSON.generate(batch.map(&:output_hash))
85
+ begin
86
+ resp = connection.post(request_path_for(@solr_update_args), body: json_package)
87
+ rescue StandardError => e
88
+ exception = e
89
+ end
90
+
91
+ return if exception.nil? && resp.status == 200
92
+
93
+ log_batch_failure(exception, resp)
94
+ batch.each { |c| send_single(c) }
95
+ end
96
+
97
+ def send_single(context)
98
+ json_package = JSON.generate([context.output_hash])
99
+ resp = connection.post(request_path_for(@solr_update_args), body: json_package)
100
+ unless resp.status == 200
101
+ raise BadHttpResponse.new("Unexpected HTTP response status #{resp.code} from POST", resp)
102
+ end
103
+ rescue *skippable_exceptions => e
104
+ handle_skipped(context, e)
105
+ end
106
+
107
+ def skipped_record_count
108
+ @skipped_record_incrementer.value
109
+ end
110
+
111
+ def close
112
+ @thread_pool.raise_collected_exception!
113
+
114
+ batch = Traject::Util.drain_queue(@batched_queue)
115
+ @thread_pool.maybe_in_thread_pool { send_batch(batch) } unless batch.empty?
116
+
117
+ shutdown_thread_pool if @thread_pool_size&.positive?
118
+
119
+ @thread_pool.raise_collected_exception!
120
+ commit if @commit_on_close
121
+ # Pool is intentionally left warm in the registry for reuse by later
122
+ # writers/readers on this origin; teardown is the host app's concern.
123
+ end
124
+
125
+ def commit(query_params = nil)
126
+ query_params ||= @commit_solr_update_args || { 'commit' => 'true' }
127
+ logger.info("#{self.class.name} sending commit to solr at url #{@solr_update_url}...")
128
+
129
+ resp = connection.get(request_path_for(query_params), timeout: @commit_timeout)
130
+ raise "Could not commit to Solr: #{resp.code} #{resp}" unless resp.status == 200
131
+ end
132
+
133
+ def delete(id)
134
+ json_package = JSON.generate(delete: id)
135
+ resp = connection.post(request_path_for(@solr_update_args), body: json_package)
136
+ raise "Could not delete #{id.inspect}, http response #{resp.code}: #{resp}" unless resp.status == 200
137
+ end
138
+
139
+ def delete_all!
140
+ delete(query: '*:*')
141
+ end
142
+
143
+ private
144
+
145
+ def shutdown_thread_pool
146
+ elapsed = @thread_pool.shutdown_and_wait
147
+ logger.warn("Waited #{elapsed}s for all threads") if elapsed > 60
148
+ return unless skipped_record_count.positive?
149
+
150
+ logger.warn("#{self.class.name}: #{skipped_record_count} skipped records")
151
+ end
152
+
153
+ # Relative path (plus any query) so requests resolve against the pool
154
+ # origin rather than re-encoding the absolute URL.
155
+ def request_path
156
+ uri = URI.parse(@solr_update_url)
157
+ uri.query ? "#{uri.path}?#{uri.query}" : uri.path
158
+ end
159
+
160
+ def request_path_for(query_params)
161
+ base = request_path
162
+ return base unless query_params
163
+
164
+ separator = base.include?('?') ? '&' : '?'
165
+ "#{base}#{separator}#{URI.encode_www_form(query_params)}"
166
+ end
167
+
168
+ def log_batch_failure(exception, resp)
169
+ message = if exception
170
+ Traject::Util.exception_to_log_message(exception)
171
+ else
172
+ "Solr response: #{resp.code}: #{resp}"
173
+ end
174
+ logger.error('Error in Solr batch add. Will retry documents individually ' \
175
+ "at performance penalty: #{message}")
176
+ end
177
+
178
+ def handle_skipped(context, exception)
179
+ logger.error("Could not add record #{context.record_inspect}: #{skip_message(exception)}")
180
+ @skipped_record_incrementer.increment
181
+ return unless @max_skipped && skipped_record_count > @max_skipped
182
+
183
+ raise MaxSkippedRecordsExceeded,
184
+ "#{self.class.name}: Exceeded maximum number of skipped records " \
185
+ "(#{@max_skipped}): aborting: #{exception.message}"
186
+ end
187
+
188
+ def skip_message(exception)
189
+ return Traject::Util.exception_to_log_message(exception) unless exception.is_a?(BadHttpResponse)
190
+
191
+ "Solr error response: #{exception.response.code}: #{exception.response}"
192
+ end
193
+
194
+ def skippable_exceptions
195
+ @skippable_exceptions ||= settings['solr_writer.skippable_exceptions'] || [
196
+ HTTP::TimeoutError,
197
+ HttpConnectionPool::TimeoutError,
198
+ HTTP::ConnectionError,
199
+ SocketError,
200
+ Errno::ECONNREFUSED,
201
+ BadHttpResponse
202
+ ]
203
+ end
204
+
205
+ def configure_from_settings
206
+ configure_skipped
207
+ configure_batching
208
+ configure_pools
209
+ configure_commit
210
+ end
211
+
212
+ def configure_skipped
213
+ @max_skipped = (@settings['solr_writer.max_skipped'] || DEFAULT_MAX_SKIPPED).to_i
214
+ @max_skipped = nil if @max_skipped.negative?
215
+ @skipped_record_incrementer = Concurrent::AtomicFixnum.new(0)
216
+ end
217
+
218
+ def configure_batching
219
+ @batch_size = (@settings['solr_writer.batch_size'] || DEFAULT_BATCH_SIZE).to_i
220
+ @batch_size = 1 if @batch_size < 1
221
+ @batched_queue = Queue.new
222
+ end
223
+
224
+ def configure_pools
225
+ @solr_update_url, user, password = determine_solr_update_url
226
+ @thread_pool_size = (@settings['solr_writer.thread_pool'] || 1).to_i
227
+ @pool_size = (@settings['solr_pool.pool_size'] || (@thread_pool_size + 1)).to_i
228
+ @connection = build_connection(user, password)
229
+ @thread_pool = Traject::ThreadPool.new(@thread_pool_size)
230
+ logger.info(" #{self.class.name} writing to '#{@solr_update_url}' " \
231
+ "#{'(with HTTP basic auth) ' if user || password}" \
232
+ "in batches of #{@batch_size} with #{@thread_pool_size} bg threads")
233
+ end
234
+
235
+ def configure_commit
236
+ @commit_on_close = (@settings['solr_writer.commit_on_close'] ||
237
+ @settings['solrj_writer.commit_on_close']).to_s == 'true'
238
+ @solr_update_args = @settings['solr_writer.solr_update_args']
239
+ @commit_solr_update_args = @settings['solr_writer.commit_solr_update_args']
240
+ @commit_timeout = (@settings['solr_writer.commit_timeout'] || 600).to_i
241
+ end
242
+
243
+ # Splits origin (scheme://host:port) from request path; auth goes to
244
+ # Connection as an Authorization header, never baked into the origin.
245
+ def build_connection(user, password)
246
+ uri = URI.parse(@solr_update_url)
247
+ origin = "#{uri.scheme}://#{uri.host}:#{uri.port}"
248
+ Connection.new(
249
+ origin: origin,
250
+ pool_size: @pool_size,
251
+ pool_timeout: @settings['solr_writer.pool_timeout'],
252
+ auth: basic_auth_header(user, password),
253
+ timeout: @settings['solr_writer.http_timeout']
254
+ )
255
+ end
256
+
257
+ def basic_auth_header(user, password)
258
+ return nil unless user || password
259
+
260
+ "Basic #{Base64.strict_encode64("#{user}:#{password}")}"
261
+ end
262
+
263
+ def determine_solr_update_url
264
+ url = resolve_solr_url
265
+ parsed = URI.parse(url)
266
+ user = parsed.user
267
+ password = parsed.password
268
+ parsed.user = nil
269
+ parsed.password = nil
270
+ [parsed.to_s,
271
+ @settings['solr_writer.basic_auth_user'] || user,
272
+ @settings['solr_writer.basic_auth_password'] || password]
273
+ end
274
+
275
+ def resolve_solr_url
276
+ if settings['solr.update_url']
277
+ validate_url!(settings['solr.update_url'], 'solr.update_url')
278
+ else
279
+ derive_solr_update_url_from_solr_url(settings['solr.url'])
280
+ end
281
+ end
282
+
283
+ def validate_url!(url, setting_name)
284
+ unless /^#{URI_REGEXP}$/o.match?(url)
285
+ raise ArgumentError,
286
+ "#{self.class.name} setting `#{setting_name}` doesn't look like a URL: `#{url}`"
287
+ end
288
+
289
+ url
290
+ end
291
+
292
+ def derive_solr_update_url_from_solr_url(url)
293
+ if url.nil?
294
+ raise ArgumentError,
295
+ "#{self.class.name}: Neither solr.update_url nor solr.url set; need at least one"
296
+ end
297
+
298
+ validate_url!(url, 'solr.url')
299
+ [url.chomp('/'), 'update', 'json'].join('/')
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Traject
4
+ module SolrPool
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'traject'
4
+ require 'http_connection_pool'
5
+
6
+ require_relative 'solr_pool/version'
7
+ require_relative 'solr_pool/connection'
8
+ require_relative 'solr_pool/solr_json_writer'
9
+
10
+ module Traject
11
+ module SolrPool
12
+ class Error < StandardError; end
13
+ # Your code goes here...
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ module Traject
2
+ module SolrPool
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: traject-solr_pool
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - bbarberBPL
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: http_connection_pool
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.1
19
+ - - "~>"
20
+ - !ruby/object:Gem::Version
21
+ version: '0.1'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.1.1
29
+ - - "~>"
30
+ - !ruby/object:Gem::Version
31
+ version: '0.1'
32
+ - !ruby/object:Gem::Dependency
33
+ name: traject
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '3.9'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '3.9'
46
+ description: A traject plugin providing a drop-in Solr JSON writer that routes updates
47
+ through http_connection_pool for persistent, credential-isolated, thread-safe connections.
48
+ email:
49
+ - bbarber@bpl.org
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - ".github/workflows/ci.yml"
55
+ - CLAUDE.md
56
+ - LICENSE.txt
57
+ - README.md
58
+ - Rakefile
59
+ - docs/agents/README.md
60
+ - docs/skills/README.md
61
+ - docs/superpowers/plans/2026-07-01-traject-solr-pool-writer.md
62
+ - docs/superpowers/specs/2026-07-01-traject-solr-pool-writer-design.md
63
+ - docs/usage.md
64
+ - lib/traject/solr_pool.rb
65
+ - lib/traject/solr_pool/connection.rb
66
+ - lib/traject/solr_pool/solr_json_writer.rb
67
+ - lib/traject/solr_pool/version.rb
68
+ - sig/traject/solr_pool.rbs
69
+ homepage: https://github.com/bbarberBPL/traject-solr_pool
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ allowed_push_host: https://rubygems.org
74
+ homepage_uri: https://github.com/bbarberBPL/traject-solr_pool
75
+ source_code_uri: https://github.com/bbarberBPL/traject-solr_pool
76
+ rubygems_mfa_required: 'true'
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.3.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.15
92
+ specification_version: 4
93
+ summary: Traject Solr JSON writer backed by a persistent, pooled, thread-safe HTTP
94
+ client
95
+ test_files: []