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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +34 -0
- data/CLAUDE.md +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +167 -0
- data/Rakefile +21 -0
- data/docs/agents/README.md +41 -0
- data/docs/skills/README.md +42 -0
- data/docs/superpowers/plans/2026-07-01-traject-solr-pool-writer.md +1387 -0
- data/docs/superpowers/specs/2026-07-01-traject-solr-pool-writer-design.md +265 -0
- data/docs/usage.md +122 -0
- data/lib/traject/solr_pool/connection.rb +80 -0
- data/lib/traject/solr_pool/solr_json_writer.rb +303 -0
- data/lib/traject/solr_pool/version.rb +7 -0
- data/lib/traject/solr_pool.rb +15 -0
- data/sig/traject/solr_pool.rbs +6 -0
- metadata +95 -0
|
@@ -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,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
|
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: []
|