swiss-netex 1.0.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,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "stringio"
5
+ require "tempfile"
6
+
7
+ module SwissNetex
8
+ class Filter
9
+ # Stream one TIMETABLE shard in a single pass:
10
+ # - keep matching ServiceJourneys (collect refs)
11
+ # - keep TrainNumber definitions referenced by those journeys
12
+ #
13
+ # Dropped journeys never materialize their (large) calls list. Kept journeys
14
+ # write the head buffer then stream the body so peak strings stay short.
15
+ # Empty shards return io: nil so the caller omits them from the output zip.
16
+ #
17
+ # TrainNumber defs may appear before or after vehicleJourneys. When the
18
+ # national export puts them after journeys, matching defs write in place.
19
+ # When defs come first (or no journey refs are known yet), the block is
20
+ # buffered and flushed after </vehicleJourneys> (or TimetableFrame end).
21
+ # That can reorder trainNumbers after journeys — intentional and small.
22
+ #
23
+ # Pure whitespace before dropped journeys/trains is discarded so output
24
+ # does not keep blank holes between kept siblings (same idea as XmlSieve).
25
+ class Timetable
26
+ JOURNEY_NAMES = %w[ServiceJourney TemplateServiceJourney].freeze
27
+ JOURNEY_SET = JOURNEY_NAMES.to_h { |name| [name, true] }.freeze
28
+
29
+ TYPE_ELEMENT = Nokogiri::XML::Reader::TYPE_ELEMENT
30
+ TYPE_END = Nokogiri::XML::Reader::TYPE_END_ELEMENT
31
+ TYPE_TEXT = Nokogiri::XML::Reader::TYPE_TEXT
32
+ TYPE_CDATA = Nokogiri::XML::Reader::TYPE_CDATA
33
+ TYPE_WS = Nokogiri::XML::Reader::TYPE_WHITESPACE
34
+ TYPE_SWS = Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE
35
+ TYPE_COMMENT = Nokogiri::XML::Reader::TYPE_COMMENT
36
+
37
+ Result = Data.define(:io, :journeys_kept, :train_numbers_kept)
38
+
39
+ # line_filter: true when the user passed explicit --line aliases. In that
40
+ # mode journeys without a matching LineRef are dropped (no operator-only
41
+ # fallback). When false, selected operator journeys without LineRef stay.
42
+ def initialize(operator_ids:, line_ids:, refs:, line_filter: false)
43
+ @operator_ids = operator_ids.to_h { |id| [id.to_s, true] }
44
+ @line_ids = line_ids.to_h { |id| [id.to_s, true] }
45
+ @line_filter = line_filter
46
+ @refs = refs
47
+ @deferred_trains = []
48
+ end
49
+
50
+ def call(source)
51
+ out = new_tempfile
52
+ out << "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
53
+ journeys_kept = 0
54
+ train_numbers_kept = 0
55
+ @deferred_trains = []
56
+ @journeys_seen = false
57
+ @pending_ws = +""
58
+ reader = Nokogiri::XML::Reader(to_io(source))
59
+
60
+ while reader.read
61
+ case reader.node_type
62
+ when TYPE_ELEMENT
63
+ start = handle_start(reader, out)
64
+ case start
65
+ when :kept_journey
66
+ journeys_kept += 1
67
+ when Array
68
+ train_numbers_kept += start[1] if start[0] == :kept_trains
69
+ end
70
+ when TYPE_END
71
+ name = reader.local_name
72
+ if name == "TimetableFrame"
73
+ train_numbers_kept += flush_deferred_trains(out)
74
+ flush_pending_ws(out)
75
+ out << "</#{reader.name}>"
76
+ else
77
+ flush_pending_ws(out)
78
+ out << "</#{reader.name}>"
79
+ train_numbers_kept += flush_deferred_trains(out) if name == "vehicleJourneys"
80
+ end
81
+ when TYPE_WS, TYPE_SWS
82
+ buffer_ws(reader.value.to_s)
83
+ when TYPE_TEXT
84
+ handle_text(reader, out)
85
+ when TYPE_CDATA
86
+ flush_pending_ws(out)
87
+ out << reader.value.to_s
88
+ when TYPE_COMMENT
89
+ flush_pending_ws(out)
90
+ out << "<!--#{reader.value}-->"
91
+ end
92
+ end
93
+
94
+ train_numbers_kept += flush_deferred_trains(out)
95
+ flush_pending_ws(out)
96
+
97
+ if journeys_kept.zero?
98
+ close_io(out)
99
+ return Result.new(io: nil, journeys_kept: 0, train_numbers_kept: 0)
100
+ end
101
+
102
+ out.flush
103
+ out.rewind
104
+ Result.new(io: out, journeys_kept: journeys_kept, train_numbers_kept: train_numbers_kept)
105
+ end
106
+
107
+ private
108
+
109
+ def handle_start(reader, out)
110
+ name = reader.local_name
111
+ if JOURNEY_SET[name]
112
+ result = write_or_skip_journey(reader, out)
113
+ @journeys_seen = true if %i[kept_journey dropped].include?(result)
114
+ clear_pending_ws if result == :dropped
115
+ result
116
+ elsif name == "trainNumbers"
117
+ result = collect_train_numbers(reader, out)
118
+ clear_pending_ws if %i[dropped deferred].include?(result)
119
+ result
120
+ elsif name == "TrainNumber"
121
+ result = buffer_or_write_train(reader, out)
122
+ clear_pending_ws if %i[dropped deferred].include?(result)
123
+ result
124
+ else
125
+ flush_pending_ws(out)
126
+ write_start_to(reader, out)
127
+ :structure
128
+ end
129
+ end
130
+
131
+ # Walk direct children until LineRef/OperatorRef decide keep/drop.
132
+ # On keep: flush head, stream remaining children to out while indexing refs.
133
+ def write_or_skip_journey(reader, out)
134
+ depth = reader.depth
135
+ head = +""
136
+ journey_id = reader.attribute("id")
137
+ resp = reader.attribute("responsibilitySetRef")
138
+ write_start_to(reader, head)
139
+ return :dropped if reader.empty_element?
140
+
141
+ line_ref = nil
142
+ op_ref = nil
143
+ keep = nil
144
+
145
+ while reader.read
146
+ if reader.node_type == TYPE_END && reader.depth == depth
147
+ head << "</#{reader.name}>"
148
+ break
149
+ end
150
+
151
+ if reader.node_type == TYPE_ELEMENT && reader.depth == depth + 1
152
+ child_name = reader.local_name
153
+
154
+ if child_name == "LineRef"
155
+ line_ref = reader.attribute("ref")
156
+ keep = line_ref && @line_ids.key?(line_ref)
157
+ unless keep
158
+ skip_to_depth(reader, depth)
159
+ return :dropped
160
+ end
161
+
162
+ child_xml = reader.outer_xml
163
+ skip_element(reader)
164
+ head << child_xml
165
+ flush_kept_journey(out, head, journey_id, resp)
166
+ stream_journey_body(reader, depth, out)
167
+ return :kept_journey
168
+ end
169
+
170
+ op_ref = reader.attribute("ref") if child_name == "OperatorRef"
171
+
172
+ child_xml = reader.outer_xml
173
+ skip_element(reader)
174
+ head << child_xml
175
+ else
176
+ append_leaf(reader, head)
177
+ end
178
+ end
179
+
180
+ keep = operator_only_keep?(op_ref) if keep.nil?
181
+ return :dropped unless keep
182
+
183
+ flush_kept_journey(out, head, journey_id, resp)
184
+ :kept_journey
185
+ end
186
+
187
+ def flush_kept_journey(out, head, journey_id, resp)
188
+ flush_pending_ws(out)
189
+ out << head
190
+ @refs.add(:journeys, journey_id) if journey_id
191
+ @refs.add(:responsibility_sets, resp) if resp
192
+ @refs.merge_from_xml(head)
193
+ end
194
+
195
+ def stream_journey_body(reader, depth, out)
196
+ while reader.read
197
+ if reader.node_type == TYPE_END && reader.depth == depth
198
+ out << "</#{reader.name}>"
199
+ break
200
+ end
201
+
202
+ if reader.node_type == TYPE_ELEMENT && reader.depth == depth + 1
203
+ child_xml = reader.outer_xml
204
+ skip_element(reader)
205
+ out << child_xml
206
+ @refs.merge_from_xml(child_xml)
207
+ else
208
+ append_leaf(reader, out)
209
+ end
210
+ end
211
+ end
212
+
213
+ # Buffer every TrainNumber in the container. Write filtered container now
214
+ # when journey refs already exist; otherwise defer until after journeys.
215
+ def collect_train_numbers(reader, out)
216
+ trains = read_train_number_children(reader)
217
+ write_or_defer_trains(trains, out)
218
+ end
219
+
220
+ def buffer_or_write_train(reader, out)
221
+ id = reader.attribute("id")
222
+ xml = reader.outer_xml
223
+ skip_element(reader)
224
+ return :dropped unless id
225
+
226
+ write_or_defer_trains([[id, xml]], out)
227
+ end
228
+
229
+ def write_or_defer_trains(trains, out)
230
+ return :dropped if trains.empty?
231
+
232
+ kept = matching_trains(trains)
233
+ if kept.any?
234
+ flush_pending_ws(out)
235
+ write_train_numbers(out, kept)
236
+ return [:kept_trains, kept.size]
237
+ end
238
+
239
+ # After journeys, unmatched defs are dead; only buffer when we may still
240
+ # learn TrainNumberRefs from later vehicleJourneys.
241
+ return :dropped if @journeys_seen
242
+
243
+ @deferred_trains.concat(trains)
244
+ :deferred
245
+ end
246
+
247
+ def flush_deferred_trains(out)
248
+ return 0 if @deferred_trains.empty?
249
+
250
+ trains = @deferred_trains
251
+ @deferred_trains = []
252
+ kept = matching_trains(trains)
253
+ return 0 if kept.empty?
254
+
255
+ flush_pending_ws(out)
256
+ write_train_numbers(out, kept)
257
+ kept.size
258
+ end
259
+
260
+ def matching_trains(trains)
261
+ trains.select { |id, _xml| id && @refs.include?(:train_numbers, id) }
262
+ end
263
+
264
+ def write_train_numbers(out, kept)
265
+ out << "<trainNumbers>"
266
+ kept.each { |pair| out << pair[1] }
267
+ out << "</trainNumbers>"
268
+ end
269
+
270
+ def read_train_number_children(reader)
271
+ depth = reader.depth
272
+ trains = []
273
+ return trains if reader.empty_element?
274
+
275
+ while reader.read
276
+ break if reader.node_type == TYPE_END && reader.depth == depth
277
+
278
+ if reader.node_type == TYPE_ELEMENT && reader.depth == depth + 1
279
+ if reader.local_name == "TrainNumber"
280
+ id = reader.attribute("id")
281
+ xml = reader.outer_xml
282
+ skip_element(reader)
283
+ trains << [id, xml] if id
284
+ else
285
+ skip_element(reader)
286
+ end
287
+ end
288
+ end
289
+
290
+ trains
291
+ end
292
+
293
+ def operator_only_keep?(op_ref)
294
+ return false if @line_filter
295
+
296
+ op_ref && @operator_ids.key?(op_ref)
297
+ end
298
+
299
+ def handle_text(reader, out)
300
+ value = reader.value.to_s
301
+ if pure_ws?(value)
302
+ buffer_ws(value)
303
+ else
304
+ flush_pending_ws(out)
305
+ out << value
306
+ end
307
+ end
308
+
309
+ def buffer_ws(value)
310
+ @pending_ws << value
311
+ end
312
+
313
+ def flush_pending_ws(out)
314
+ return if @pending_ws.empty?
315
+
316
+ out << @pending_ws
317
+ @pending_ws.clear
318
+ end
319
+
320
+ def clear_pending_ws
321
+ @pending_ws.clear
322
+ end
323
+
324
+ def pure_ws?(value)
325
+ value.match?(/\A\s*\z/)
326
+ end
327
+
328
+ def skip_to_depth(reader, depth)
329
+ while reader.read
330
+ break if reader.node_type == TYPE_END && reader.depth == depth
331
+ end
332
+ end
333
+
334
+ def append_leaf(reader, buf)
335
+ case reader.node_type
336
+ when TYPE_TEXT, TYPE_CDATA, TYPE_WS, TYPE_SWS
337
+ buf << reader.value.to_s
338
+ when TYPE_COMMENT
339
+ buf << "<!--#{reader.value}-->"
340
+ end
341
+ end
342
+
343
+ def write_start_to(reader, buf)
344
+ buf << "<#{reader.name}"
345
+ reader.attributes.each { |key, value| buf << " #{key}=\"#{esc_attr(value)}\"" }
346
+ buf << (reader.empty_element? ? "/>" : ">")
347
+ end
348
+
349
+ def skip_element(reader)
350
+ return if reader.empty_element?
351
+
352
+ depth = reader.depth
353
+ while reader.read
354
+ break if reader.node_type == TYPE_END && reader.depth == depth
355
+ end
356
+ end
357
+
358
+ def new_tempfile
359
+ file = Tempfile.new(["swiss-netex-tt", ".xml"])
360
+ file.binmode
361
+ file
362
+ end
363
+
364
+ def to_io(source)
365
+ return StringIO.new(source) if source.is_a?(String)
366
+ return source if source.respond_to?(:read)
367
+
368
+ raise Error, "Timetable expects a String or IO-like object"
369
+ end
370
+
371
+ def esc_attr(value)
372
+ value.to_s
373
+ .gsub("&", "&amp;")
374
+ .gsub('"', "&quot;")
375
+ .gsub("<", "&lt;")
376
+ end
377
+
378
+ def close_io(io)
379
+ return unless io
380
+
381
+ io.close!
382
+ rescue StandardError
383
+ io.close if io.respond_to?(:close) && !io.closed?
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "json"
5
+
6
+ module SwissNetex
7
+ class Filter
8
+ # Process TIMETABLE shards in short-lived child processes so libxml2/Nokogiri
9
+ # Reader heap is released at exit. Falls back to in-process when fork is
10
+ # unavailable (e.g. some Windows builds).
11
+ #
12
+ # Before forking, the parent byte-scans each shard for selected line /
13
+ # operator ids. Shards with no hits never parse as XML (big win on national
14
+ # drop-all shards up to ~600 MB uncompressed). Matching shards run in a
15
+ # process pool (SWISS_NETEX_TT_WORKERS, default: CPU count).
16
+ class TimetableBatch
17
+ # One shard per child keeps peak RSS ~size of the largest kept shard, not a
18
+ # multi-shard sum. Override with SWISS_NETEX_TT_BATCH.
19
+ DEFAULT_BATCH = 1
20
+ SCAN_CHUNK = 1 << 20 # 1 MiB
21
+ ShardResult = Data.define(:name, :basename, :journeys_kept)
22
+
23
+ def initialize(package_path:, tmp:, operator_ids:, line_ids:,
24
+ line_filter: false, progress_io: nil, verbose: false)
25
+ @package_path = package_path.to_s
26
+ @tmp = tmp.to_s
27
+ @operator_ids = Array(operator_ids).map(&:to_s)
28
+ @line_ids = Array(line_ids).map(&:to_s)
29
+ @line_filter = line_filter
30
+ needles = (@line_ids + @operator_ids).reject(&:empty?).uniq
31
+ @max_needle = needles.map(&:bytesize).max || 0
32
+ @needle_re = needles.empty? ? nil : Regexp.union(needles)
33
+ @progress_io = progress_io
34
+ @verbose = verbose
35
+ @batch_size = resolve_batch_size
36
+ @workers = resolve_workers
37
+ end
38
+
39
+ def call(names, refs)
40
+ total = names.size
41
+ indexes = names.each_with_index.to_h { |name, i| [name, i + 1] }
42
+ candidates = select_candidates(names, indexes, total)
43
+
44
+ journey_count = 0
45
+ kept_by_index = {}
46
+
47
+ each_batch_result(candidates) do |results, delta|
48
+ refs.merge_export(delta)
49
+ results.each do |result|
50
+ index = indexes.fetch(result.name)
51
+ if result.journeys_kept.zero?
52
+ log " [#{index}/#{total}] #{result.basename}: 0 journey(s)" if @verbose
53
+ next
54
+ end
55
+
56
+ journey_count += result.journeys_kept
57
+ kept_by_index[index] = result.basename
58
+ log " [#{index}/#{total}] #{result.basename}: #{result.journeys_kept} journey(s)"
59
+ end
60
+ end
61
+
62
+ kept_names = kept_by_index.sort.map(&:last)
63
+ log " journeys kept: #{journey_count} across #{kept_names.size} shard(s)"
64
+ [journey_count, kept_names]
65
+ end
66
+
67
+ private
68
+
69
+ def select_candidates(names, indexes, total)
70
+ return names if @needle_re.nil?
71
+
72
+ candidates = []
73
+ Package.open(@package_path) do |pkg|
74
+ names.each do |name|
75
+ pkg.open_member(name) do |io|
76
+ if may_match?(io)
77
+ candidates << name
78
+ elsif @verbose
79
+ index = indexes.fetch(name)
80
+ log " [#{index}/#{total}] #{File.basename(name)}: skip (pre-scan)"
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ log " pre-scan: #{candidates.size}/#{total} shard(s) may match " \
87
+ "(#{@workers} worker(s))"
88
+ candidates
89
+ end
90
+
91
+ def each_batch_result(names, &)
92
+ return if names.empty?
93
+
94
+ jobs = names.each_slice(@batch_size).to_a
95
+ if !fork_supported? || @workers <= 1 || jobs.size <= 1
96
+ jobs.each do |batch|
97
+ results, delta = run_batch(batch)
98
+ yield results, delta
99
+ end
100
+ else
101
+ run_pool(jobs, &)
102
+ end
103
+ end
104
+
105
+ def run_pool(jobs)
106
+ queue = jobs.dup
107
+ inflight = {}
108
+
109
+ while queue.any? || inflight.any?
110
+ while inflight.size < @workers && queue.any?
111
+ batch = queue.shift
112
+ spawn_job(batch, inflight)
113
+ end
114
+
115
+ pid, status = Process.wait2(-1)
116
+ meta = inflight.delete(pid)
117
+ unless meta
118
+ # Reap unexpected child rather than leave a zombie.
119
+ next
120
+ end
121
+
122
+ raw = meta[:reader].value
123
+ meta[:read_io].close
124
+ unless status.success?
125
+ reap_inflight(inflight)
126
+ raise Error, "TIMETABLE worker failed (exit #{status.exitstatus})"
127
+ end
128
+
129
+ results, delta = decode_payload(JSON.parse(raw))
130
+ yield results, delta
131
+ end
132
+ rescue JSON::ParserError => e
133
+ reap_inflight(inflight || {})
134
+ raise Error, "TIMETABLE worker returned invalid payload: #{e.message}"
135
+ rescue StandardError
136
+ reap_inflight(inflight || {})
137
+ raise
138
+ end
139
+
140
+ def spawn_job(batch, inflight)
141
+ read_io, write_io = IO.pipe
142
+ pid = fork do
143
+ read_io.close
144
+ payload = process_batch(batch, pre_scanned: true)
145
+ write_io.write(JSON.generate(encode_payload(payload)))
146
+ write_io.close
147
+ exit! 0
148
+ end
149
+ write_io.close
150
+ reader = Thread.new { read_io.read }
151
+ inflight[pid] = { read_io: read_io, reader: reader }
152
+ end
153
+
154
+ def reap_inflight(inflight)
155
+ inflight.each_key do |pid|
156
+ Process.kill("TERM", pid)
157
+ rescue Errno::ESRCH
158
+ # already gone
159
+ end
160
+ inflight.each do |pid, meta|
161
+ Process.wait2(pid)
162
+ rescue Errno::ECHILD
163
+ # already reaped
164
+ ensure
165
+ meta[:reader]&.kill
166
+ meta[:read_io]&.close unless meta[:read_io]&.closed?
167
+ end
168
+ inflight.clear
169
+ end
170
+
171
+ def run_batch(names)
172
+ return process_batch(names, pre_scanned: true) unless fork_supported?
173
+
174
+ read_io, write_io = IO.pipe
175
+ pid = fork do
176
+ read_io.close
177
+ payload = process_batch(names, pre_scanned: true)
178
+ write_io.write(JSON.generate(encode_payload(payload)))
179
+ write_io.close
180
+ exit! 0
181
+ end
182
+ write_io.close
183
+
184
+ raw = nil
185
+ status = nil
186
+ begin
187
+ raw = read_io.read
188
+ ensure
189
+ read_io.close
190
+ _pid, status = Process.wait2(pid)
191
+ end
192
+ raise Error, "TIMETABLE worker failed (exit #{status.exitstatus})" unless status.success?
193
+
194
+ decode_payload(JSON.parse(raw))
195
+ rescue JSON::ParserError => e
196
+ raise Error, "TIMETABLE worker returned invalid payload: #{e.message}"
197
+ end
198
+
199
+ def process_batch(names, pre_scanned: false)
200
+ local_refs = Refs.new
201
+ results = []
202
+
203
+ Package.open(@package_path) do |pkg|
204
+ names.each do |name|
205
+ results << process_shard(pkg, name, local_refs, pre_scanned: pre_scanned)
206
+ end
207
+ end
208
+
209
+ [results, local_refs.export]
210
+ end
211
+
212
+ def process_shard(pkg, name, local_refs, pre_scanned: false)
213
+ basename = File.basename(name)
214
+ journeys = 0
215
+
216
+ pkg.open_member(name) do |io|
217
+ unless pre_scanned || may_match?(io)
218
+ return ShardResult.new(name: name, basename: basename, journeys_kept: 0)
219
+ end
220
+
221
+ io.rewind if io.respond_to?(:rewind) && !pre_scanned
222
+ result = Timetable.new(
223
+ operator_ids: @operator_ids,
224
+ line_ids: @line_ids,
225
+ line_filter: @line_filter,
226
+ refs: local_refs
227
+ ).call(io)
228
+ begin
229
+ journeys = result.journeys_kept
230
+ copy_io_to_file(result.io, File.join(@tmp, basename)) if result.io
231
+ ensure
232
+ close_temp(result.io)
233
+ end
234
+ end
235
+
236
+ ShardResult.new(name: name, basename: basename, journeys_kept: journeys)
237
+ end
238
+
239
+ # Byte scan for selected ids. False positives only force a full Reader pass.
240
+ def may_match?(io)
241
+ return true unless @needle_re
242
+
243
+ tail = +""
244
+ while (chunk = io.read(SCAN_CHUNK))
245
+ haystack = tail.empty? ? chunk : (tail + chunk)
246
+ return true if haystack.match?(@needle_re)
247
+
248
+ tail = @max_needle.positive? ? haystack[-@max_needle, @max_needle].to_s : +""
249
+ end
250
+ false
251
+ end
252
+
253
+ def encode_payload(payload)
254
+ results, delta = payload
255
+ {
256
+ "results" => results.map do |r|
257
+ { "name" => r.name, "basename" => r.basename, "journeys_kept" => r.journeys_kept }
258
+ end,
259
+ "delta" => stringify_keys(delta)
260
+ }
261
+ end
262
+
263
+ def decode_payload(data)
264
+ results = data.fetch("results").map do |row|
265
+ ShardResult.new(
266
+ name: row.fetch("name"),
267
+ basename: row.fetch("basename"),
268
+ journeys_kept: row.fetch("journeys_kept")
269
+ )
270
+ end
271
+ delta = symbolize_keys(data.fetch("delta"))
272
+ [results, delta]
273
+ end
274
+
275
+ def stringify_keys(hash)
276
+ hash.transform_keys(&:to_s).transform_values { |ids| Array(ids).map(&:to_s) }
277
+ end
278
+
279
+ def symbolize_keys(hash)
280
+ hash.transform_keys(&:to_sym)
281
+ end
282
+
283
+ def fork_supported?
284
+ return false if ENV["SWISS_NETEX_NO_FORK"]
285
+
286
+ Process.respond_to?(:fork)
287
+ end
288
+
289
+ def resolve_batch_size
290
+ raw = ENV["SWISS_NETEX_TT_BATCH"].to_s.strip
291
+ return DEFAULT_BATCH if raw.empty?
292
+
293
+ size = Integer(raw, 10)
294
+ size.positive? ? size : DEFAULT_BATCH
295
+ rescue ArgumentError
296
+ DEFAULT_BATCH
297
+ end
298
+
299
+ def resolve_workers
300
+ raw = ENV["SWISS_NETEX_TT_WORKERS"].to_s.strip
301
+ if raw.empty?
302
+ n = Etc.nprocessors
303
+ return n.positive? ? n : 1
304
+ end
305
+
306
+ size = Integer(raw, 10)
307
+ size.positive? ? size : 1
308
+ rescue ArgumentError
309
+ 1
310
+ end
311
+
312
+ def copy_io_to_file(io, destination)
313
+ io.rewind if io.respond_to?(:rewind)
314
+ File.open(destination, "wb") { |out| IO.copy_stream(io, out) }
315
+ end
316
+
317
+ def close_temp(io)
318
+ return unless io
319
+
320
+ io.close!
321
+ rescue StandardError
322
+ io.close if io.respond_to?(:close) && !io.closed?
323
+ end
324
+
325
+ def log(message)
326
+ @progress_io&.puts(message)
327
+ end
328
+ end
329
+ end
330
+ end