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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +237 -0
- data/exe/swiss-netex +6 -0
- data/lib/swiss_netex/catalog.rb +41 -0
- data/lib/swiss_netex/cli.rb +546 -0
- data/lib/swiss_netex/dataset_page.rb +90 -0
- data/lib/swiss_netex/download.rb +239 -0
- data/lib/swiss_netex/error.rb +5 -0
- data/lib/swiss_netex/extract.rb +56 -0
- data/lib/swiss_netex/filter/common_frame.rb +194 -0
- data/lib/swiss_netex/filter/frame_worker.rb +188 -0
- data/lib/swiss_netex/filter/frames.rb +126 -0
- data/lib/swiss_netex/filter/refs.rb +106 -0
- data/lib/swiss_netex/filter/service_lines.rb +139 -0
- data/lib/swiss_netex/filter/timetable.rb +387 -0
- data/lib/swiss_netex/filter/timetable_batch.rb +330 -0
- data/lib/swiss_netex/filter/xml_sieve.rb +189 -0
- data/lib/swiss_netex/filter.rb +346 -0
- data/lib/swiss_netex/http.rb +130 -0
- data/lib/swiss_netex/lines.rb +161 -0
- data/lib/swiss_netex/operators.rb +254 -0
- data/lib/swiss_netex/package.rb +145 -0
- data/lib/swiss_netex/package_source.rb +30 -0
- data/lib/swiss_netex/resource_frame.rb +73 -0
- data/lib/swiss_netex/tsv.rb +53 -0
- data/lib/swiss_netex/version.rb +5 -0
- data/lib/swiss_netex.rb +20 -0
- data/swiss-netex.gemspec +40 -0
- metadata +104 -0
|
@@ -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("&", "&")
|
|
374
|
+
.gsub('"', """)
|
|
375
|
+
.gsub("<", "<")
|
|
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
|