traject 3.8.3 → 3.9.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/.github/workflows/ruby.yml +3 -2
- data/CHANGES.md +27 -6
- data/README.md +6 -0
- data/lib/tasks/load_maps.rake +3 -3
- data/lib/traject/csv_writer.rb +5 -1
- data/lib/traject/delimited_writer.rb +6 -1
- data/lib/traject/experimental_nokogiri_streaming_reader.rb +3 -1
- data/lib/traject/indexer/context.rb +3 -1
- data/lib/traject/indexer.rb +2 -1
- data/lib/traject/macros/marc21_semantics.rb +2 -1
- data/lib/traject/oai_pmh_nokogiri_reader.rb +2 -3
- data/lib/traject/solr_json_httpx_writer.rb +499 -0
- data/lib/traject/version.rb +1 -1
- data/lib/translation_maps/marc_geographic.yaml +7 -4
- data/lib/translation_maps/marc_languages.yaml +13 -6
- data/test/delimited_writer_test.rb +1 -0
- data/test/solr_json_httpx_writer_test.rb +428 -0
- data/test/test_helper.rb +1 -0
- data/traject.gemspec +5 -3
- metadata +50 -21
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
require 'yell'
|
|
2
|
+
|
|
3
|
+
require 'traject/util'
|
|
4
|
+
require 'traject/qualified_const_get'
|
|
5
|
+
require 'traject/thread_pool'
|
|
6
|
+
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'httpx'
|
|
9
|
+
|
|
10
|
+
require 'uri'
|
|
11
|
+
require 'thread' # for Mutex/Queue
|
|
12
|
+
require 'concurrent' # for atomic_fixnum
|
|
13
|
+
|
|
14
|
+
# Write to Solr using the JSON interface
|
|
15
|
+
#
|
|
16
|
+
# Uses httpx ruby client (unlike the original version which used httpclient package)
|
|
17
|
+
# https://honeyryderchuck.gitlab.io/httpx/
|
|
18
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/home.html
|
|
19
|
+
#
|
|
20
|
+
# This should work under both MRI and JRuby, with JRuby possiby getting
|
|
21
|
+
# better performance due to the threading model.
|
|
22
|
+
#
|
|
23
|
+
# Solr updates are by default sent with no commit params. This will definitely
|
|
24
|
+
# maximize your performance, and *especially* for bulk/batch indexing is recommended --
|
|
25
|
+
# use Solr auto commit in your Solr configuration instead, possibly with `commit_on_close`
|
|
26
|
+
# setting here.
|
|
27
|
+
#
|
|
28
|
+
# However, if you want the writer to send `commitWithin=true`, `commit=true`,
|
|
29
|
+
# `softCommit=true`, or any other URL parameters valid for Solr update handlers,
|
|
30
|
+
# you can configure this with `solr_writer.solr_update_args` setting. See:
|
|
31
|
+
# https://lucene.apache.org/solr/guide/7_0/near-real-time-searching.html#passing-commit-and-commitwithin-parameters-as-part-of-the-url
|
|
32
|
+
# Eg:
|
|
33
|
+
#
|
|
34
|
+
# settings do
|
|
35
|
+
# provide "solr_writer.solr_update_args", { commitWithin: 1000 }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# (That it's a hash makes it infeasible to set/override on command line, if this is
|
|
39
|
+
# annoying for you let us know)
|
|
40
|
+
#
|
|
41
|
+
# `solr_update_args` will apply to batch and individual update requests, but
|
|
42
|
+
# not to commit sent if `commit_on_close`. You can also instead set
|
|
43
|
+
# `solr_writer.solr_commit_args` for that (or pass in an arg to #commit if calling
|
|
44
|
+
# manually)
|
|
45
|
+
#
|
|
46
|
+
# ## Relevant settings
|
|
47
|
+
#
|
|
48
|
+
# * solr.url (optional if solr.update_url is set) The URL to the solr core to index into.
|
|
49
|
+
# (Can include embedded HTTP basic auth as eg `http://user:pass@host/solr`)
|
|
50
|
+
#
|
|
51
|
+
# * solr.update_url: The actual update url. If unset, we'll first see if
|
|
52
|
+
# "#{solr.url}/update/json" exists, and if not use "#{solr.url}/update". (Can include
|
|
53
|
+
# embedded HTTP basic auth as eg `http://user:pass@host/solr)
|
|
54
|
+
#
|
|
55
|
+
# * solr_writer.batch_size: How big a batch to send to solr. Default is 100.
|
|
56
|
+
# My tests indicate that this setting doesn't change overall index speed by a ton.
|
|
57
|
+
#
|
|
58
|
+
# * solr_writer.thread_pool: How many threads to use for the writer. Default is 1.
|
|
59
|
+
# Likely useful even under MRI since thread will be waiting on Solr for some time.
|
|
60
|
+
#
|
|
61
|
+
# * solr_writer.max_skipped: How many records skipped due to errors before we
|
|
62
|
+
# bail out with a fatal error? Set to -1 for unlimited skips. Default 0,
|
|
63
|
+
# raise and abort on a single record that could not be added to Solr.
|
|
64
|
+
#
|
|
65
|
+
# * solr_writer.skippable_exceptions: List of classes that will be rescued internal to
|
|
66
|
+
# SolrJsonWriter, and handled with max_skipped logic. Defaults to
|
|
67
|
+
# `[HTTPX::TimeoutError, SocketError, Errno::ECONNREFUSED, Traject::SolrJsonWriter::BadHttpResponse]`
|
|
68
|
+
#
|
|
69
|
+
# * solr_writer.solr_update_args: A _hash_ of query params to send to solr update url.
|
|
70
|
+
# Will be sent with every update request. Eg `{ softCommit: true }` or `{ commitWithin: 1000 }`.
|
|
71
|
+
# See also `solr_writer.solr_commit_args`
|
|
72
|
+
#
|
|
73
|
+
# * solr_writer.commit_on_close: Set to true (or "true") if you want to commit at the
|
|
74
|
+
# end of the indexing run. (Old "solrj_writer.commit_on_close" supported for backwards
|
|
75
|
+
# compat only.)
|
|
76
|
+
#
|
|
77
|
+
# * solr_writer.commit_solr_update_args: A hash of query params to send when committing.
|
|
78
|
+
# Will be used for automatic `close_on_commit`, as well as any manual calls to #commit.
|
|
79
|
+
# If set, must include {"commit" => "true"} or { "softCommit" => "true" } if you actually
|
|
80
|
+
# want commits to happen when SolrJsonWriter tries to commit! But can be used to switch to softCommits
|
|
81
|
+
# (hard commits default), or specify additional params like optimize etc.
|
|
82
|
+
#
|
|
83
|
+
# * solr_writer.http_timeout: Value in seconds, will be set on the httpx connect and request
|
|
84
|
+
# timeouts. No way to set them individually at present. Default 60
|
|
85
|
+
#
|
|
86
|
+
# * solr_writer.commit_timeout: If commit_on_close, how long to wait for Solr before
|
|
87
|
+
# giving up as a timeout (http client receive_timeout). Default 10 minutes. Solr can be slow at commits. Overrides solr_writer.timeout
|
|
88
|
+
#
|
|
89
|
+
# * solr_json_writer.http_client Set your own httpx session object. Can be used for testing,
|
|
90
|
+
# or to configure the client with your desired plugins or configuration. If you set your
|
|
91
|
+
# own, you are reponsible for setting timeouts, auth, etc. INCLUDING persistent connection
|
|
92
|
+
# plugin you surely want for performance.
|
|
93
|
+
#
|
|
94
|
+
#
|
|
95
|
+
class Traject::SolrJsonHttpxWriter
|
|
96
|
+
include Traject::QualifiedConstGet
|
|
97
|
+
|
|
98
|
+
URI_REGEXP = URI::Parser.new.make_regexp.freeze
|
|
99
|
+
|
|
100
|
+
DEFAULT_MAX_SKIPPED = 0
|
|
101
|
+
DEFAULT_BATCH_SIZE = 100
|
|
102
|
+
|
|
103
|
+
# The passed-in settings
|
|
104
|
+
attr_reader :settings, :thread_pool_size
|
|
105
|
+
|
|
106
|
+
# A queue to hold documents before sending to solr
|
|
107
|
+
attr_reader :batched_queue
|
|
108
|
+
|
|
109
|
+
def initialize(argSettings)
|
|
110
|
+
@settings = Traject::Indexer::Settings.new(argSettings)
|
|
111
|
+
|
|
112
|
+
# Set max errors
|
|
113
|
+
@max_skipped = (@settings['solr_writer.max_skipped'] || DEFAULT_MAX_SKIPPED).to_i
|
|
114
|
+
if @max_skipped < 0
|
|
115
|
+
@max_skipped = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Figure out where to send updates, and if with basic auth
|
|
120
|
+
@solr_update_url, basic_auth_user, basic_auth_password = self.determine_solr_update_url
|
|
121
|
+
|
|
122
|
+
# Note httpx is totally thread-safe so it's okay we're using the client obj
|
|
123
|
+
# in multiple threads in parallel.
|
|
124
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Connection-Pools
|
|
125
|
+
@http_client = if @settings["solr_json_writer.http_client"]
|
|
126
|
+
@settings["solr_json_writer.http_client"]
|
|
127
|
+
else
|
|
128
|
+
# Persistent connections prob important for performance
|
|
129
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Persistent
|
|
130
|
+
#
|
|
131
|
+
# Our local defined-below error-raising plugins
|
|
132
|
+
client = HTTPX.plugin(:persistent).plugin(HttpxRaiseErrorPlugin)
|
|
133
|
+
|
|
134
|
+
if @settings["solr_writer.http_timeout"]
|
|
135
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Timeouts
|
|
136
|
+
client = client.with(timeout: { connect_timeout: @settings["solr_writer.http_timeout"], request_timeout: @settings["solr_writer.http_timeout"]})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if basic_auth_user || basic_auth_password
|
|
140
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Auth
|
|
141
|
+
client = client.plugin(:basic_auth).basic_auth(basic_auth_user, basic_auth_password)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
client
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
@batch_size = (settings["solr_writer.batch_size"] || DEFAULT_BATCH_SIZE).to_i
|
|
148
|
+
@batch_size = 1 if @batch_size < 1
|
|
149
|
+
|
|
150
|
+
# Store error count in an AtomicInteger, so multi threads can increment
|
|
151
|
+
# it safely, if we're threaded.
|
|
152
|
+
@skipped_record_incrementer = Concurrent::AtomicFixnum.new(0)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# How many threads to use for the writer?
|
|
156
|
+
# if our thread pool settings are 0, it'll just create a null threadpool that
|
|
157
|
+
# executes in calling context.
|
|
158
|
+
@thread_pool_size = (@settings["solr_writer.thread_pool"] || 1).to_i
|
|
159
|
+
|
|
160
|
+
@batched_queue = Queue.new
|
|
161
|
+
@thread_pool = Traject::ThreadPool.new(@thread_pool_size)
|
|
162
|
+
|
|
163
|
+
# old setting solrj_writer supported for backwards compat, as we make
|
|
164
|
+
# this the new default writer.
|
|
165
|
+
@commit_on_close = (settings["solr_writer.commit_on_close"] || settings["solrj_writer.commit_on_close"]).to_s == "true"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@solr_update_args = settings["solr_writer.solr_update_args"]
|
|
169
|
+
@commit_solr_update_args = settings["solr_writer.commit_solr_update_args"]
|
|
170
|
+
|
|
171
|
+
logger.info(" #{self.class.name} writing to '#{@solr_update_url}' #{"(with HTTP basic auth)" if basic_auth_user || basic_auth_password}in batches of #{@batch_size} with #{@thread_pool_size} bg threads")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Add a single context to the queue, ready to be sent to solr
|
|
176
|
+
def put(context)
|
|
177
|
+
@thread_pool.raise_collected_exception!
|
|
178
|
+
|
|
179
|
+
@batched_queue << context
|
|
180
|
+
if @batched_queue.size >= @batch_size
|
|
181
|
+
batch = Traject::Util.drain_queue(@batched_queue)
|
|
182
|
+
@thread_pool.maybe_in_thread_pool(batch) {|batch_arg| send_batch(batch_arg) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Not part of standard writer API.
|
|
187
|
+
#
|
|
188
|
+
# If we are batching adds, and have some not-yet-written ones queued up --
|
|
189
|
+
# flush em all to solr.
|
|
190
|
+
#
|
|
191
|
+
# This should be thread-safe to call, but the write does take place in
|
|
192
|
+
# the caller's thread, no threading is done for you here, regardless of setting
|
|
193
|
+
# of solr_writer.thread_pool
|
|
194
|
+
def flush
|
|
195
|
+
send_batch( Traject::Util.drain_queue(@batched_queue) )
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# configured update url, with either settings @solr_update_args or passed in
|
|
199
|
+
# query_params added to it
|
|
200
|
+
def solr_update_url_with_query(query_params)
|
|
201
|
+
if query_params
|
|
202
|
+
@solr_update_url + '?' + URI.encode_www_form(query_params)
|
|
203
|
+
else
|
|
204
|
+
@solr_update_url
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Send the given batch of contexts. If something goes wrong, send
|
|
209
|
+
# them one at a time.
|
|
210
|
+
# @param [Array<Traject::Indexer::Context>] an array of contexts
|
|
211
|
+
def send_batch(batch)
|
|
212
|
+
return if batch.empty?
|
|
213
|
+
|
|
214
|
+
logger.debug("#{self.class.name}: sending batch of #{batch.size} to Solr")
|
|
215
|
+
|
|
216
|
+
json_package = JSON.generate(batch.map { |c| c.output_hash })
|
|
217
|
+
|
|
218
|
+
begin
|
|
219
|
+
resp = @http_client.post(solr_update_url_with_query(@solr_update_args), body: json_package, headers: {"content-type" => "application/json"})
|
|
220
|
+
rescue StandardError => exception
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if exception || resp.status != 200
|
|
224
|
+
error_message = exception ?
|
|
225
|
+
Traject::Util.exception_to_log_message(exception) :
|
|
226
|
+
"Solr response: #{resp.status}: #{resp.body}"
|
|
227
|
+
|
|
228
|
+
logger.error "Error in Solr batch add. Will retry documents individually at performance penalty: #{error_message}"
|
|
229
|
+
|
|
230
|
+
batch.each do |c|
|
|
231
|
+
send_single(c)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Send a single context to Solr, logging an error if need be
|
|
238
|
+
# @param [Traject::Indexer::Context] c The context whose document you want to send
|
|
239
|
+
def send_single(c)
|
|
240
|
+
logger.debug("#{self.class.name}: sending single record to Solr: #{c.output_hash}")
|
|
241
|
+
|
|
242
|
+
json_package = JSON.generate([c.output_hash])
|
|
243
|
+
begin
|
|
244
|
+
post_url = solr_update_url_with_query(@solr_update_args)
|
|
245
|
+
resp = @http_client.post(post_url, body: json_package, headers: {"Content-type" => "application/json"})
|
|
246
|
+
|
|
247
|
+
unless resp.status == 200
|
|
248
|
+
raise BadHttpResponse.new("Unexpected HTTP response status #{resp.status} from POST #{post_url}", resp)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Catch Timeouts and network errors -- as well as non-200 http responses --
|
|
252
|
+
# as skipped records, but otherwise allow unexpected errors to propagate up.
|
|
253
|
+
rescue *skippable_exceptions => exception
|
|
254
|
+
msg = if exception.kind_of?(BadHttpResponse)
|
|
255
|
+
"Solr error response: #{exception.response.status}: #{exception.response.body}"
|
|
256
|
+
else
|
|
257
|
+
Traject::Util.exception_to_log_message(exception)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
logger.error "Could not add record #{c.record_inspect}: #{msg}"
|
|
261
|
+
logger.debug("\t" + exception.backtrace.join("\n\t")) if exception
|
|
262
|
+
logger.debug(c.source_record.to_s) if c.source_record
|
|
263
|
+
|
|
264
|
+
@skipped_record_incrementer.increment
|
|
265
|
+
if @max_skipped and skipped_record_count > @max_skipped
|
|
266
|
+
# re-raising in rescue means the last encountered error will be available as #cause
|
|
267
|
+
# on raised exception, a feature in ruby 2.1+.
|
|
268
|
+
raise MaxSkippedRecordsExceeded.new("#{self.class.name}: Exceeded maximum number of skipped records (#{@max_skipped}): aborting: #{exception.message}")
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Very beginning of a delete implementation. POSTs a delete request to solr
|
|
275
|
+
# for id in arg (value of Solr UniqueID field, usually `id` field).
|
|
276
|
+
#
|
|
277
|
+
# Right now, does it inline and immediately, no use of background threads or batching.
|
|
278
|
+
# This could change.
|
|
279
|
+
#
|
|
280
|
+
# Right now, if unsuccesful for any reason, will raise immediately out of here.
|
|
281
|
+
# Could raise any of the `skippable_exceptions` (timeouts, network errors), an
|
|
282
|
+
# exception will be raised right out of here.
|
|
283
|
+
#
|
|
284
|
+
# Will use `solr_writer.solr_update_args` settings.
|
|
285
|
+
#
|
|
286
|
+
# There is no built-in way to direct a record to be deleted from an indexing config
|
|
287
|
+
# file at the moment, this is just a loose method on the writer.
|
|
288
|
+
def delete(id)
|
|
289
|
+
logger.debug("#{self.class.name}: Sending delete to Solr for #{id}")
|
|
290
|
+
|
|
291
|
+
json_package = {delete: id}
|
|
292
|
+
resp = @http_client.post(solr_update_url_with_query(@solr_update_args), body: JSON.generate(json_package), headers: {"Content-type" => "application/json"})
|
|
293
|
+
if resp.status != 200
|
|
294
|
+
raise RuntimeError.new("Could not delete #{id.inspect}, http response #{resp.status}: #{resp.body}")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Send a delete all query.
|
|
299
|
+
#
|
|
300
|
+
# This method takes no params and will not automatically commit the deletes.
|
|
301
|
+
# @example @writer.delete_all!
|
|
302
|
+
def delete_all!
|
|
303
|
+
delete(query: "*:*")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Get the logger from the settings, or default to an effectively null logger
|
|
307
|
+
def logger
|
|
308
|
+
settings["logger"] ||= Yell.new(STDERR, :level => "gt.fatal") # null logger
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# On close, we need to (a) raise any exceptions we might have, (b) send off
|
|
312
|
+
# the last (possibly empty) batch, and (c) commit if instructed to do so
|
|
313
|
+
# via the solr_writer.commit_on_close setting.
|
|
314
|
+
def close
|
|
315
|
+
@thread_pool.raise_collected_exception!
|
|
316
|
+
|
|
317
|
+
# Finish off whatever's left. Do it in the thread pool for
|
|
318
|
+
# consistency, and to ensure expected order of operations, so
|
|
319
|
+
# it goes to the end of the queue behind any other work.
|
|
320
|
+
batch = Traject::Util.drain_queue(@batched_queue)
|
|
321
|
+
if batch.length > 0
|
|
322
|
+
@thread_pool.maybe_in_thread_pool { send_batch(batch) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
if @thread_pool_size && @thread_pool_size > 0
|
|
326
|
+
# Wait for shutdown, and time it.
|
|
327
|
+
logger.debug "#{self.class.name}: Shutting down thread pool, waiting if needed..."
|
|
328
|
+
elapsed = @thread_pool.shutdown_and_wait
|
|
329
|
+
if elapsed > 60
|
|
330
|
+
logger.warn "Waited #{elapsed} seconds for all threads, you may want to increase solr_writer.thread_pool (currently #{@settings["solr_writer.thread_pool"]})"
|
|
331
|
+
end
|
|
332
|
+
logger.debug "#{self.class.name}: Thread pool shutdown complete"
|
|
333
|
+
logger.warn "#{self.class.name}: #{skipped_record_count} skipped records" if skipped_record_count > 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# check again now that we've waited, there could still be some
|
|
337
|
+
# that didn't show up before.
|
|
338
|
+
@thread_pool.raise_collected_exception!
|
|
339
|
+
|
|
340
|
+
# Commit if we're supposed to
|
|
341
|
+
if @commit_on_close
|
|
342
|
+
commit
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# Send a commit
|
|
348
|
+
#
|
|
349
|
+
# Called automatially by `close_on_commit` setting, but also can be called manually.
|
|
350
|
+
#
|
|
351
|
+
# If settings `solr_writer.commit_solr_update_args` is set, will be used by default.
|
|
352
|
+
# That setting needs `{ commit: true }` or `{softCommit: true}` if you want it to
|
|
353
|
+
# actually do a commit!
|
|
354
|
+
#
|
|
355
|
+
# Optional query_params argument is the actual args to send, you must be sure
|
|
356
|
+
# to make it include "commit: true" or "softCommit: true" for it to actually commit!
|
|
357
|
+
# But you may want to include other params too, like optimize etc. query_param
|
|
358
|
+
# argument replaces setting `solr_writer.commit_solr_update_args`, they are not merged.
|
|
359
|
+
#
|
|
360
|
+
# @param [Hash] query_params optional query params to send to solr update. Default {"commit" => "true"}
|
|
361
|
+
#
|
|
362
|
+
# @example @writer.commit
|
|
363
|
+
# @example @writer.commit(softCommit: true)
|
|
364
|
+
# @example @writer.commit(commit: true, optimize: true, waitFlush: false)
|
|
365
|
+
def commit(query_params = nil)
|
|
366
|
+
query_params ||= @commit_solr_update_args || {"commit" => "true"}
|
|
367
|
+
logger.info "#{self.class.name} sending commit to solr at url #{@solr_update_url}..."
|
|
368
|
+
|
|
369
|
+
resp = @http_client.with(timeout: { request_timeout: settings["commit_timeout"] || (10 * 60).to_i }).
|
|
370
|
+
get(solr_update_url_with_query(query_params))
|
|
371
|
+
unless resp.status == 200
|
|
372
|
+
raise RuntimeError.new("Could not commit to Solr: #{resp.status} #{resp.body}")
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Return count of encountered skipped records. Most accurate to call
|
|
378
|
+
# it after #close, in which case it should include full count, even
|
|
379
|
+
# under async thread_pool.
|
|
380
|
+
def skipped_record_count
|
|
381
|
+
@skipped_record_incrementer.value
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Relatively complex logic to determine if we have a valid URL and what it is,
|
|
386
|
+
# and if we have basic_auth info
|
|
387
|
+
#
|
|
388
|
+
# Empties out user and password embedded in URI returned, to help avoid logging it.
|
|
389
|
+
#
|
|
390
|
+
# @returns [update_url, basic_auth_user, basic_auth_password]
|
|
391
|
+
def determine_solr_update_url
|
|
392
|
+
url = if settings['solr.update_url']
|
|
393
|
+
check_solr_update_url(settings['solr.update_url'])
|
|
394
|
+
else
|
|
395
|
+
derive_solr_update_url_from_solr_url(settings['solr.url'])
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
parsed_uri = URI.parse(url)
|
|
399
|
+
user_from_uri, password_from_uri = parsed_uri.user, parsed_uri.password
|
|
400
|
+
parsed_uri.user, parsed_uri.password = nil, nil
|
|
401
|
+
|
|
402
|
+
basic_auth_user = @settings["solr_writer.basic_auth_user"] || user_from_uri
|
|
403
|
+
basic_auth_password = @settings["solr_writer.basic_auth_password"] || password_from_uri
|
|
404
|
+
|
|
405
|
+
return [parsed_uri.to_s, basic_auth_user, basic_auth_password]
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# If we've got a solr.update_url, make sure it's ok
|
|
410
|
+
def check_solr_update_url(url)
|
|
411
|
+
unless /^#{URI_REGEXP}$/.match(url)
|
|
412
|
+
raise ArgumentError.new("#{self.class.name} setting `solr.update_url` doesn't look like a URL: `#{url}`")
|
|
413
|
+
end
|
|
414
|
+
url
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def derive_solr_update_url_from_solr_url(url)
|
|
418
|
+
# Nil? Then we bail
|
|
419
|
+
if url.nil?
|
|
420
|
+
raise ArgumentError.new("#{self.class.name}: Neither solr.update_url nor solr.url set; need at least one")
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Not a URL? Bail
|
|
424
|
+
unless /^#{URI_REGEXP}$/.match(url)
|
|
425
|
+
raise ArgumentError.new("#{self.class.name} setting `solr.url` doesn't look like a URL: `#{url}`")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Assume the /update/json handler
|
|
429
|
+
return [url.chomp('/'), 'update', 'json'].join('/')
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
class MaxSkippedRecordsExceeded < RuntimeError ; end
|
|
433
|
+
|
|
434
|
+
# Adapted from HTTPClient::BadResponseError.
|
|
435
|
+
# It's got a #response accessor that will give you the HTTPClient
|
|
436
|
+
# Response object that had a bad status, although relying on that
|
|
437
|
+
# would tie you to our HTTPClient implementation that maybe should
|
|
438
|
+
# be considered an implementation detail, so I dunno.
|
|
439
|
+
class BadHttpResponse < RuntimeError
|
|
440
|
+
# HTTP::Message:: a response
|
|
441
|
+
attr_reader :response
|
|
442
|
+
|
|
443
|
+
def initialize(msg, response = nil) # :nodoc:
|
|
444
|
+
solr_error = find_solr_error(response)
|
|
445
|
+
msg += ": #{solr_error}" if solr_error
|
|
446
|
+
|
|
447
|
+
super(msg)
|
|
448
|
+
|
|
449
|
+
@response = response
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
private
|
|
453
|
+
|
|
454
|
+
# If we can get the error out of a JSON response, please do,
|
|
455
|
+
# to include in error message.
|
|
456
|
+
def find_solr_error(response)
|
|
457
|
+
return nil unless response&.content_type&.mime_type&.start_with?("application/json") && response.body && !response.body.empty?
|
|
458
|
+
|
|
459
|
+
parsed = JSON.parse(response.body.to_s)
|
|
460
|
+
|
|
461
|
+
parsed && parsed.dig("error", "msg")
|
|
462
|
+
rescue JSON::ParserError
|
|
463
|
+
return nil
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
private
|
|
468
|
+
|
|
469
|
+
def skippable_exceptions
|
|
470
|
+
@skippable_exceptions ||= (settings["solr_writer.skippable_exceptions"] || [HTTPX::TimeoutError, SocketError, Errno::ECONNREFUSED, Traject::SolrJsonHttpxWriter::BadHttpResponse])
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# HTTPX has really weird annoying error handling.
|
|
474
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Error-Handling
|
|
475
|
+
#
|
|
476
|
+
# We add a custom plugin to make errors raise more ruby way.
|
|
477
|
+
# https://honeyryderchuck.gitlab.io/httpx/wiki/Custom-Plugins
|
|
478
|
+
#
|
|
479
|
+
# Works as long as we're not doing multiple parallel requests in one call,
|
|
480
|
+
# which we aren't, DRY's up our error handling.
|
|
481
|
+
module HttpxRaiseErrorPlugin
|
|
482
|
+
module InstanceMethods
|
|
483
|
+
def fetch_response(...)
|
|
484
|
+
super.tap do |response|
|
|
485
|
+
# we don't want to raise on 4xx and 5xx responses, just actual errors with no response!
|
|
486
|
+
response.raise_for_status if response&.error && !response&.error.kind_of?(HTTPX::HTTPError)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def send_requests(*requests)
|
|
491
|
+
if requests.length > 1
|
|
492
|
+
raise ArgumentError.new("HttpxRaiseErrorPlugin only supports one request arg at a time, got #{requests.length}")
|
|
493
|
+
end
|
|
494
|
+
super
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
end
|
data/lib/traject/version.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Translation map for marc geographic codes constructed by `rake load_maps:marc_geographic` task
|
|
2
|
-
# Scraped from
|
|
2
|
+
# Scraped from https://www.loc.gov/marc/geoareas/gacs_code.html at 2025-03-26 10:36:45 -0400
|
|
3
3
|
# Intentionally includes discontinued codes.
|
|
4
4
|
|
|
5
5
|
'a': 'Asia'
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
'e-an': 'Andorra'
|
|
141
141
|
'e-au': 'Austria'
|
|
142
142
|
'e-be': 'Belgium'
|
|
143
|
-
'e-bn': 'Bosnia and
|
|
143
|
+
'e-bn': 'Bosnia and Herzegovina'
|
|
144
144
|
'e-bu': 'Bulgaria'
|
|
145
145
|
'e-bw': 'Belarus'
|
|
146
146
|
'e-ci': 'Croatia'
|
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
'e-fi': 'Finland'
|
|
151
151
|
'e-fr': 'France'
|
|
152
152
|
'e-ge': 'Germany (East)'
|
|
153
|
+
'e-gg': 'Guernsey'
|
|
153
154
|
'e-gi': 'Gibraltar'
|
|
154
155
|
'e-gr': 'Greece'
|
|
155
156
|
'e-gw': 'Germany (West)'
|
|
@@ -157,7 +158,9 @@
|
|
|
157
158
|
'e-hu': 'Hungary'
|
|
158
159
|
'e-ic': 'Iceland'
|
|
159
160
|
'e-ie': 'Ireland'
|
|
161
|
+
'e-im': 'Isle of Man'
|
|
160
162
|
'e-it': 'Italy'
|
|
163
|
+
'e-je': 'Jersey'
|
|
161
164
|
'e-kv': 'Kosovo'
|
|
162
165
|
'e-lh': 'Liechtenstein'
|
|
163
166
|
'e-li': 'Lithuania'
|
|
@@ -215,7 +218,7 @@
|
|
|
215
218
|
'e-urv': 'Volgo-Viatskii Region, RSFSR'
|
|
216
219
|
'e-urw': 'Siberia, Western (Russia)'
|
|
217
220
|
'e-vc': 'Vatican City'
|
|
218
|
-
'e-xn': 'Macedonia
|
|
221
|
+
'e-xn': 'North Macedonia'
|
|
219
222
|
'e-xo': 'Slovakia'
|
|
220
223
|
'e-xr': 'Czech Republic'
|
|
221
224
|
'e-xv': 'Slovenia'
|
|
@@ -280,7 +283,7 @@
|
|
|
280
283
|
'f-sj': 'Sudan'
|
|
281
284
|
'f-sl': 'Sierra Leone'
|
|
282
285
|
'f-so': 'Somalia'
|
|
283
|
-
'f-sq': '
|
|
286
|
+
'f-sq': 'Eswatini'
|
|
284
287
|
'f-ss': 'Western Sahara'
|
|
285
288
|
'f-sx': 'Namibia'
|
|
286
289
|
'f-tg': 'Togo'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# Map Language Codes (in 008[35-37], 041) to User Friendly Term
|
|
2
|
+
# Scraped from https://www.loc.gov/standards/codelists/languages.xml at 2025-03-26 10:40:03 -0400
|
|
2
3
|
|
|
3
4
|
# MARC language codes (including obsolete codes), from https://www.loc.gov/standards/codelists/languages.xml
|
|
4
5
|
aar: Afar
|
|
@@ -111,7 +112,7 @@ dar: Dargwa
|
|
|
111
112
|
day: Dayak
|
|
112
113
|
del: Delaware
|
|
113
114
|
den: Slavey
|
|
114
|
-
dgr:
|
|
115
|
+
dgr: Tlicho
|
|
115
116
|
din: Dinka
|
|
116
117
|
div: Divehi
|
|
117
118
|
doi: Dogri
|
|
@@ -1380,7 +1381,7 @@ bqh: Baima
|
|
|
1380
1381
|
bqi: Bakhtiari
|
|
1381
1382
|
bqj: Bandial
|
|
1382
1383
|
bqk: Banda-Mbrès
|
|
1383
|
-
bql:
|
|
1384
|
+
bql: Karian
|
|
1384
1385
|
bqm: Wumboko
|
|
1385
1386
|
bqn: Bulgarian Sign Language
|
|
1386
1387
|
bqo: Balo
|
|
@@ -1749,6 +1750,7 @@ clk: Idu-Mishmi
|
|
|
1749
1750
|
cll: Chala
|
|
1750
1751
|
clm: Clallam
|
|
1751
1752
|
clo: Lowland Oaxaca Chontal
|
|
1753
|
+
cls: Classical Sanskrit
|
|
1752
1754
|
clt: Lautu Chin
|
|
1753
1755
|
clu: Caluyanun
|
|
1754
1756
|
clw: Chulym
|
|
@@ -1928,7 +1930,7 @@ dau: Dar Sila Daju
|
|
|
1928
1930
|
dav: Taita
|
|
1929
1931
|
daw: Davawenyo
|
|
1930
1932
|
dax: Dayi
|
|
1931
|
-
daz:
|
|
1933
|
+
daz: Moi-Wadea
|
|
1932
1934
|
dba: Bangime
|
|
1933
1935
|
dbb: Deno
|
|
1934
1936
|
dbd: Dadiya
|
|
@@ -1969,7 +1971,6 @@ def: Dezfuli
|
|
|
1969
1971
|
deg: Degema
|
|
1970
1972
|
deh: Dehwari
|
|
1971
1973
|
dei: Demisa
|
|
1972
|
-
dek: Dek
|
|
1973
1974
|
dem: Dem
|
|
1974
1975
|
dep: Pidgin Delaware
|
|
1975
1976
|
deq: Dendi (Central African Republic)
|
|
@@ -2803,6 +2804,7 @@ hng: Hungu
|
|
|
2803
2804
|
hnh: ǁAni
|
|
2804
2805
|
hni: Hani
|
|
2805
2806
|
hnj: Hmong Njua
|
|
2807
|
+
hnm: Hainanese
|
|
2806
2808
|
hnn: Hanunoo
|
|
2807
2809
|
hno: Northern Hindko
|
|
2808
2810
|
hns: Caribbean Hindustani
|
|
@@ -3011,6 +3013,7 @@ iso: Isoko
|
|
|
3011
3013
|
isr: Israeli Sign Language
|
|
3012
3014
|
ist: Istriot
|
|
3013
3015
|
isu: Isu (Menchum Division)
|
|
3016
|
+
isv: Interslavic
|
|
3014
3017
|
itb: Binongan Itneg
|
|
3015
3018
|
itd: Southern Tidung
|
|
3016
3019
|
ite: Itene
|
|
@@ -4075,6 +4078,7 @@ luc: Aringa
|
|
|
4075
4078
|
lud: Ludian
|
|
4076
4079
|
lue: Luvale
|
|
4077
4080
|
luf: Laua
|
|
4081
|
+
luh: Leizhou Chinese
|
|
4078
4082
|
luj: Luna
|
|
4079
4083
|
luk: Lunanakha
|
|
4080
4084
|
lul: "Olu'bo"
|
|
@@ -4396,7 +4400,7 @@ mme: Mae
|
|
|
4396
4400
|
mmf: Mundat
|
|
4397
4401
|
mmg: North Ambrym
|
|
4398
4402
|
mmh: Mehináku
|
|
4399
|
-
mmi:
|
|
4403
|
+
mmi: Hember Avu
|
|
4400
4404
|
mmj: Majhwar
|
|
4401
4405
|
mmk: Mukha-Dora
|
|
4402
4406
|
mml: Man Met
|
|
@@ -5089,7 +5093,6 @@ nsx: Nsongo
|
|
|
5089
5093
|
nsy: Nasal
|
|
5090
5094
|
nsz: Nisenan
|
|
5091
5095
|
ntd: Northern Tidung
|
|
5092
|
-
nte: Nathembo
|
|
5093
5096
|
ntg: Ngantangarra
|
|
5094
5097
|
nti: Natioro
|
|
5095
5098
|
ntj: Ngaanyatjarra
|
|
@@ -5855,6 +5858,7 @@ row: Dela-Oenale
|
|
|
5855
5858
|
rpn: Repanbitip
|
|
5856
5859
|
rpt: Rapting
|
|
5857
5860
|
rri: Ririo
|
|
5861
|
+
rrm: Moriori
|
|
5858
5862
|
rro: Waima
|
|
5859
5863
|
rrt: Arritinngithigh
|
|
5860
5864
|
rsb: Romano-Serbian
|
|
@@ -6064,6 +6068,7 @@ siy: Sivandi
|
|
|
6064
6068
|
siz: Siwi
|
|
6065
6069
|
sja: Epena
|
|
6066
6070
|
sjb: Sajau Basap
|
|
6071
|
+
sjc: Shaojiang Chinese
|
|
6067
6072
|
sjd: Kildin Sami
|
|
6068
6073
|
sje: Pite Sami
|
|
6069
6074
|
sjg: Assangori
|
|
@@ -7060,6 +7065,7 @@ vrs: Varisi
|
|
|
7060
7065
|
vrt: Burmbar
|
|
7061
7066
|
vsi: Moldova Sign Language
|
|
7062
7067
|
vsl: Venezuelan Sign Language
|
|
7068
|
+
vsn: Vedic Sanskrit
|
|
7063
7069
|
vsv: Valencian Sign Language
|
|
7064
7070
|
vto: Vitou
|
|
7065
7071
|
vum: Vumbu
|
|
@@ -7736,6 +7742,7 @@ yms: Mysian
|
|
|
7736
7742
|
ymx: Northern Muji
|
|
7737
7743
|
ymz: Muzi
|
|
7738
7744
|
yna: Aluo
|
|
7745
|
+
ynb: Yamben
|
|
7739
7746
|
ynd: Yandruwandha
|
|
7740
7747
|
yne: "Lang'e"
|
|
7741
7748
|
yng: Yango
|