expressir 2.4.0 → 2.4.2
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/TODO.bugs/01-stale-transformer-autoload.md +39 -0
- data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
- data/TODO.bugs/03-builder-mutable-state.md +43 -0
- data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
- data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
- data/TODO.bugs/06-collection-registry-single-source.md +53 -0
- data/TODO.bugs/07-require-relative-cleanup.md +42 -0
- data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
- data/TODO.bugs/09-parser-split.md +53 -0
- data/TODO.bugs/10-to-s-override.md +42 -0
- data/TODO.bugs/11-parser-class-variables.md +39 -0
- data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
- data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
- data/TODO.bugs/14-model-formatting-leak.md +30 -0
- data/TODO.bugs/15-expression-children-macro.md +27 -0
- data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
- data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
- data/TODO.bugs/18-const-get-private-constants.md +30 -0
- data/TODO.bugs/19-format-methods-public.md +22 -0
- data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
- data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
- data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
- data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
- data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
- data/TODO.bugs/25-debug-puts-in-production.md +21 -0
- data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
- data/TODO.bugs/27-package-build-god-method.md +19 -0
- data/TODO.bugs/28-package-god-class.md +30 -0
- data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
- data/TODO.bugs/30-unicode-map-extraction.md +19 -0
- data/TODO.bugs/README.md +43 -0
- data/TODO.max-perf/01-restore-ci-green.md +29 -0
- data/TODO.max-perf/02-streaming-parse-path.md +31 -0
- data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
- data/TODO.max-perf/04-benchmark-harness.md +28 -0
- data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
- data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
- data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
- data/TODO.max-perf/08-builder-build-perf.md +45 -0
- data/TODO.max-perf/09-grammar-cold-start.md +25 -0
- data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
- data/TODO.max-perf/11-ci-green-closeout.md +25 -0
- data/TODO.max-perf/12-require-boot-profile.md +25 -0
- data/TODO.max-perf/13-key-conversion-specs.md +26 -0
- data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
- data/benchmark/srl_benchmark.rb +76 -17
- data/docs/_tutorials/formatting-schemas.adoc +89 -0
- data/docs/_tutorials/index.adoc +10 -0
- data/docs/lychee.toml +3 -0
- data/expressir.gemspec +4 -2
- data/lib/expressir/cli.rb +3 -0
- data/lib/expressir/commands/changes_import_eengine.rb +0 -6
- data/lib/expressir/commands/changes_validate.rb +0 -2
- data/lib/expressir/commands/clean.rb +1 -1
- data/lib/expressir/commands/coverage.rb +6 -2
- data/lib/expressir/commands/file_violations.rb +70 -0
- data/lib/expressir/commands/format.rb +1 -1
- data/lib/expressir/commands/non_ascii_character.rb +49 -0
- data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
- data/lib/expressir/commands/package.rb +143 -187
- data/lib/expressir/commands/validate_ascii.rb +0 -510
- data/lib/expressir/commands/version.rb +1 -1
- data/lib/expressir/commands.rb +3 -0
- data/lib/expressir/coverage.rb +49 -117
- data/lib/expressir/express/ast_key_converter.rb +114 -0
- data/lib/expressir/express/builder.rb +71 -194
- data/lib/expressir/express/builder_context.rb +22 -0
- data/lib/expressir/express/builders/expression_builder.rb +16 -16
- data/lib/expressir/express/cache.rb +35 -8
- data/lib/expressir/express/error.rb +24 -0
- data/lib/expressir/express/formatter.rb +33 -18
- data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
- data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
- data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
- data/lib/expressir/express/grammar/parser.rb +705 -0
- data/lib/expressir/express/grammar.rb +11 -0
- data/lib/expressir/express/node_position_index.rb +215 -0
- data/lib/expressir/express/parallel_files.rb +229 -0
- data/lib/expressir/express/parser.rb +138 -950
- data/lib/expressir/express/pretty_formatter.rb +23 -5
- data/lib/expressir/express/remark_attacher.rb +497 -581
- data/lib/expressir/express/remark_scanner.rb +84 -19
- data/lib/expressir/express/schema_block_scanner.rb +137 -0
- data/lib/expressir/express/schema_source_formatter.rb +15 -0
- data/lib/expressir/express/scope_resolver.rb +223 -0
- data/lib/expressir/express/source_formatter.rb +15 -0
- data/lib/expressir/express/streaming_builder.rb +147 -176
- data/lib/expressir/express.rb +10 -6
- data/lib/expressir/model/concerns.rb +3 -0
- data/lib/expressir/model/data_types/generic_entity.rb +6 -6
- data/lib/expressir/model/declarations/entity.rb +5 -0
- data/lib/expressir/model/declarations/function.rb +5 -0
- data/lib/expressir/model/declarations/procedure.rb +5 -0
- data/lib/expressir/model/declarations/rule.rb +6 -0
- data/lib/expressir/model/declarations/schema.rb +21 -8
- data/lib/expressir/model/declarations/type.rb +3 -0
- data/lib/expressir/model/exp_file.rb +2 -0
- data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
- data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
- data/lib/expressir/model/expressions/binary_expression.rb +1 -0
- data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
- data/lib/expressir/model/expressions/function_call.rb +1 -0
- data/lib/expressir/model/expressions/interval.rb +1 -0
- data/lib/expressir/model/expressions/query_expression.rb +2 -0
- data/lib/expressir/model/expressions/unary_expression.rb +1 -0
- data/lib/expressir/model/model_element.rb +133 -14
- data/lib/expressir/model/remark_format.rb +17 -0
- data/lib/expressir/model/remark_info.rb +31 -3
- data/lib/expressir/model/remark_placement.rb +29 -0
- data/lib/expressir/model/repository.rb +18 -5
- data/lib/expressir/model/statements/alias.rb +2 -0
- data/lib/expressir/model/statements/assignment.rb +4 -0
- data/lib/expressir/model/statements/case.rb +21 -0
- data/lib/expressir/model/statements/case_action.rb +1 -0
- data/lib/expressir/model/statements/compound.rb +4 -0
- data/lib/expressir/model/statements/escape.rb +3 -0
- data/lib/expressir/model/statements/if.rb +5 -0
- data/lib/expressir/model/statements/null.rb +3 -0
- data/lib/expressir/model/statements/procedure_call.rb +3 -0
- data/lib/expressir/model/statements/repeat.rb +3 -0
- data/lib/expressir/model/statements/return.rb +3 -0
- data/lib/expressir/model/statements/skip.rb +3 -0
- data/lib/expressir/model.rb +12 -3
- data/lib/expressir/package/builder.rb +2 -2
- data/lib/expressir/version.rb +6 -1
- data/lib/expressir.rb +56 -3
- metadata +81 -7
- data/lib/expressir/express/transformer/remark_handling.rb +0 -194
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Express
|
|
5
|
+
# EXPRESS grammar namespace. Houses the Parsanol grammar definition
|
|
6
|
+
# separate from the I/O orchestration on the outer Parser facade.
|
|
7
|
+
module Grammar
|
|
8
|
+
autoload :Parser, "#{__dir__}/grammar/parser"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Express
|
|
5
|
+
# Builds and queries an index of model nodes by source position.
|
|
6
|
+
#
|
|
7
|
+
# One interface (`nearest_node_to`, `node_for_end_scope_at`, `each_node`),
|
|
8
|
+
# one implementation. Extracted from RemarkAttacher so the tree-walk +
|
|
9
|
+
# byte→line translation + nearest-node heuristics live in one place,
|
|
10
|
+
# tested independently of any remark logic.
|
|
11
|
+
#
|
|
12
|
+
# Reference: ISO 10303-11 — the parser yields source spans on ModelElement
|
|
13
|
+
# instances; this module turns those spans into a line-keyed index that
|
|
14
|
+
# remark attachment can query.
|
|
15
|
+
class NodePositionIndex
|
|
16
|
+
# Single source of truth for "what collections does this node have"
|
|
17
|
+
# lives on the model — each class declares its own via the
|
|
18
|
+
# `collection_attributes` macro. Referenced by both RemarkAttacher
|
|
19
|
+
# and NodePositionIndex via this alias for backward compatibility.
|
|
20
|
+
COLLECTION_REGISTRY = Model::ModelElement.collection_registry
|
|
21
|
+
|
|
22
|
+
attr_reader :nodes
|
|
23
|
+
|
|
24
|
+
def initialize(model, line_map)
|
|
25
|
+
@model = model
|
|
26
|
+
@line_map = line_map
|
|
27
|
+
@nodes = build_sorted_nodes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the most-specific node whose span contains `remark_line`,
|
|
31
|
+
# preferring same-line starts/ends, then smallest containing span.
|
|
32
|
+
# Excludes Repository and Cache (not semantic scopes for remarks).
|
|
33
|
+
def nearest_node_to(remark_line)
|
|
34
|
+
same_start = nodes.select do |n|
|
|
35
|
+
n[:line] == remark_line && semantic?(n[:node])
|
|
36
|
+
end
|
|
37
|
+
return same_start.last[:node] if same_start.any?
|
|
38
|
+
|
|
39
|
+
same_end = nodes.select do |n|
|
|
40
|
+
n[:end_line] == remark_line && semantic?(n[:node])
|
|
41
|
+
end
|
|
42
|
+
return same_end.last[:node] if same_end.any?
|
|
43
|
+
|
|
44
|
+
containing = nodes.select do |n|
|
|
45
|
+
n[:line] && n[:end_line] &&
|
|
46
|
+
n[:line] <= remark_line && n[:end_line] >= remark_line &&
|
|
47
|
+
semantic?(n[:node])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if containing.any?
|
|
51
|
+
exp_file_node = containing.find { |n| n[:node].is_a?(Model::ExpFile) }
|
|
52
|
+
if exp_file_node
|
|
53
|
+
first_schema_offset = exp_file_node[:node].schemas&.first&.source_offset
|
|
54
|
+
if first_schema_offset && remark_line < @line_map.line_number(first_schema_offset)
|
|
55
|
+
return exp_file_node[:node]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
# Prefer non-ExpFile nodes (Schema, Entity, etc.) over ExpFile
|
|
59
|
+
# when both span the same range, since ExpFile is a file-level
|
|
60
|
+
# container and remarks inside a schema belong to the schema.
|
|
61
|
+
candidates = containing.reject { |n| n[:node].is_a?(Model::ExpFile) }
|
|
62
|
+
candidates = containing if candidates.empty?
|
|
63
|
+
candidates.min_by { |n| n[:end_line] - n[:line] }[:node]
|
|
64
|
+
else
|
|
65
|
+
before = nodes.select do |n|
|
|
66
|
+
n[:end_line] && n[:end_line] < remark_line && semantic?(n[:node])
|
|
67
|
+
end
|
|
68
|
+
if before.any?
|
|
69
|
+
before.max_by { |n| n[:end_line] }[:node]
|
|
70
|
+
else
|
|
71
|
+
# Remark is before all nodes (e.g., preamble comment before SCHEMA).
|
|
72
|
+
# Attach to the first semantic node.
|
|
73
|
+
after = nodes.select { |n| n[:line] && semantic?(n[:node]) }
|
|
74
|
+
after.min_by { |n| n[:line] }[:node] if after.any?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns the node whose END_XXX declaration is on `remark_line`, when
|
|
80
|
+
# the line text matches one of the END_XXX keywords. Used to attach
|
|
81
|
+
# remarks that appear immediately after a scope closes.
|
|
82
|
+
def node_for_end_scope_at(remark_line, line_content)
|
|
83
|
+
node_type = case line_content
|
|
84
|
+
when /END_SCHEMA/i then Model::Declarations::Schema
|
|
85
|
+
when /END_ENTITY/i then Model::Declarations::Entity
|
|
86
|
+
when /END_TYPE/i then Model::Declarations::Type
|
|
87
|
+
when /END_FUNCTION/i then Model::Declarations::Function
|
|
88
|
+
when /END_PROCEDURE/i then Model::Declarations::Procedure
|
|
89
|
+
when /END_RULE/i then Model::Declarations::Rule
|
|
90
|
+
end
|
|
91
|
+
return nil unless node_type
|
|
92
|
+
|
|
93
|
+
matching = nodes.select do |n|
|
|
94
|
+
n[:node].is_a?(node_type) &&
|
|
95
|
+
(n[:end_line] == remark_line ||
|
|
96
|
+
(n[:end_line] && n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
matching.first&.dig(:node) || nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def semantic?(node)
|
|
105
|
+
!node.is_a?(Model::Repository) && !node.is_a?(Model::Cache)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_sorted_nodes
|
|
109
|
+
result = []
|
|
110
|
+
collect_nodes(@model, result, Set.new)
|
|
111
|
+
result.sort_by!.with_index { |n, i| [n[:position] || Float::INFINITY, i] }
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def collect_nodes(node, result, visited, owner: nil, collection: nil)
|
|
116
|
+
return unless node
|
|
117
|
+
return if visited.include?(node.object_id)
|
|
118
|
+
|
|
119
|
+
visited.add(node.object_id)
|
|
120
|
+
|
|
121
|
+
if node.is_a?(Model::ModelElement) && node.source && node.source_offset
|
|
122
|
+
record_node_position(node, result, owner, collection)
|
|
123
|
+
else
|
|
124
|
+
result << { node: node, position: nil, line: nil, end_line: nil,
|
|
125
|
+
owner: owner, collection: collection }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
collect_children(node, result, visited)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def record_node_position(node, result, owner, collection)
|
|
132
|
+
pos = node.source_offset
|
|
133
|
+
valid = position_valid?(pos, node)
|
|
134
|
+
unless valid
|
|
135
|
+
result << { node: node, position: nil, line: nil, end_line: nil,
|
|
136
|
+
owner: owner, collection: collection }
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
line = @line_map.line_number(pos)
|
|
141
|
+
source_end_line = @line_map.line_number(pos + node.source.length)
|
|
142
|
+
children_end_line = children_end_line_for(node)
|
|
143
|
+
end_line = [source_end_line, children_end_line].compact.max || source_end_line
|
|
144
|
+
|
|
145
|
+
result << { node: node, position: pos, line: line, end_line: end_line,
|
|
146
|
+
owner: owner, collection: collection }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The parser returns source_offset=0 for leaf nodes (WhereRule) where
|
|
150
|
+
# it cannot determine the actual position. Accept position=0 only when
|
|
151
|
+
# the source is a declaration keyword line — those legitimately start
|
|
152
|
+
# at the beginning of the file.
|
|
153
|
+
def position_valid?(pos, node)
|
|
154
|
+
return true if pos.positive?
|
|
155
|
+
return false unless pos.zero? && node.source
|
|
156
|
+
|
|
157
|
+
node.source.to_s.start_with?(
|
|
158
|
+
"SCHEMA", "ENTITY", "TYPE", "FUNCTION",
|
|
159
|
+
"PROCEDURE", "RULE", "CONSTANT", "VARIABLE",
|
|
160
|
+
"USE", "REFERENCE", "END_SCHEMA", "END_ENTITY",
|
|
161
|
+
"END_TYPE", "END_FUNCTION", "END_PROCEDURE",
|
|
162
|
+
"END_RULE", "END_CONSTANT", "END_VARIABLE"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def children_end_line_for(node)
|
|
167
|
+
end_lines = []
|
|
168
|
+
|
|
169
|
+
if node.is_a?(Model::Declarations::Schema)
|
|
170
|
+
Array(node.children).each do |child|
|
|
171
|
+
next unless child.is_a?(Model::ModelElement) && child.source_offset && child.source
|
|
172
|
+
|
|
173
|
+
end_lines << @line_map.line_number(child.source_offset + child.source.length)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
each_collection_on(node) do |item|
|
|
178
|
+
next unless item.is_a?(Model::ModelElement) && item.source_offset && item.source
|
|
179
|
+
|
|
180
|
+
end_lines << @line_map.line_number(item.source_offset + item.source.length)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
end_lines.max
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def collect_children(node, result, visited)
|
|
187
|
+
if node.is_a?(Model::Declarations::Schema)
|
|
188
|
+
Array(node.children).each do |c|
|
|
189
|
+
collect_nodes(c, result, visited, owner: node, collection: :children)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
each_collection_on(node) do |item, attr|
|
|
194
|
+
collect_nodes(item, result, visited, owner: node, collection: attr)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Yields each child in any declared collection on the node (with the
|
|
199
|
+
# collection attribute name), based on the type-driven
|
|
200
|
+
# COLLECTION_REGISTRY. Returns nothing for nodes whose class is not
|
|
201
|
+
# registered.
|
|
202
|
+
def each_collection_on(node)
|
|
203
|
+
attrs = COLLECTION_REGISTRY[node.class]
|
|
204
|
+
return unless attrs
|
|
205
|
+
|
|
206
|
+
attrs.each do |attr|
|
|
207
|
+
collection = node.public_send(attr)
|
|
208
|
+
next unless collection.is_a?(Array)
|
|
209
|
+
|
|
210
|
+
collection.each { |item| yield(item, attr) }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
require "etc"
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Express
|
|
5
|
+
# Fork-based worker pool for parsing many EXPRESS files in parallel.
|
|
6
|
+
# The native parser holds the GVL for the whole parse, so process-level
|
|
7
|
+
# parallelism is the only way to use multiple cores. Files are
|
|
8
|
+
# independent until reference resolution, which stays in the parent.
|
|
9
|
+
#
|
|
10
|
+
# Unlike sequential parsing, the progress block fires in file order
|
|
11
|
+
# only after all files have been parsed.
|
|
12
|
+
class ParallelFiles
|
|
13
|
+
DEFAULT_MAX_PROCESSES = 4
|
|
14
|
+
FRAME_HEADER_BYTES = 4
|
|
15
|
+
|
|
16
|
+
FORK_SUPPORTED = Process.respond_to?(:fork).freeze
|
|
17
|
+
|
|
18
|
+
# Forking is never the default: a library must not spawn processes on
|
|
19
|
+
# behalf of its host (forked children inherit broken thread and lock
|
|
20
|
+
# state, and fork does not exist on all Rubies). Parallelism requires
|
|
21
|
+
# an explicit max_processes > 1 from the caller and a platform that
|
|
22
|
+
# supports fork (e.g. not Windows); otherwise the request degrades
|
|
23
|
+
# to sequential parsing.
|
|
24
|
+
def self.sequential?(files, max_processes)
|
|
25
|
+
!FORK_SUPPORTED || max_processes.nil? || max_processes <= 1 ||
|
|
26
|
+
files.size < 3
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param files [Array<String>] EXPRESS file paths
|
|
30
|
+
# @param max_processes [Integer, nil] worker cap; nil auto-selects
|
|
31
|
+
# @param parse [Proc] callback taking a file path, returning an ExpFile
|
|
32
|
+
# @param strict [Boolean] re-raise every error, including
|
|
33
|
+
# Error::SchemaParseFailure, instead of skipping the file
|
|
34
|
+
# @yield [file, exp_file, error] called in original file order
|
|
35
|
+
# @return [Array<Expressir::Model::ExpFile, nil>] parsed files in order;
|
|
36
|
+
# nil marks a file that failed with Error::SchemaParseFailure
|
|
37
|
+
def self.run(files, parse:, max_processes: nil, strict: false, &block)
|
|
38
|
+
new(files, max_processes, parse, block, strict).run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(files, max_processes, parse, block, strict)
|
|
42
|
+
@files = files
|
|
43
|
+
@parse = parse
|
|
44
|
+
@block = block
|
|
45
|
+
@strict = strict
|
|
46
|
+
@worker_count = [
|
|
47
|
+
files.size - 1,
|
|
48
|
+
max_processes || [Etc.nprocessors, DEFAULT_MAX_PROCESSES].min,
|
|
49
|
+
].min
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run
|
|
53
|
+
job_pipes = Array.new(@worker_count) { IO.pipe }
|
|
54
|
+
result_pipes = Array.new(@worker_count) { IO.pipe }
|
|
55
|
+
pids = spawn_workers(job_pipes, result_pipes)
|
|
56
|
+
|
|
57
|
+
files_results = schedule_jobs(job_pipes, result_pipes)
|
|
58
|
+
|
|
59
|
+
ordered_pass(files_results)
|
|
60
|
+
ensure
|
|
61
|
+
cleanup(job_pipes, result_pipes, pids)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def spawn_workers(job_pipes, result_pipes)
|
|
67
|
+
job_pipes.each_index.map do |i|
|
|
68
|
+
job_r, job_w = job_pipes[i]
|
|
69
|
+
result_r, result_w = result_pipes[i]
|
|
70
|
+
fork do
|
|
71
|
+
job_w.close
|
|
72
|
+
result_r.close
|
|
73
|
+
other_pipes = (job_pipes + result_pipes).flatten -
|
|
74
|
+
[job_r, result_w]
|
|
75
|
+
other_pipes.each { |io| io.close unless io.closed? }
|
|
76
|
+
worker_loop(job_r, result_w)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Assigns one job at a time to whichever worker reports a result, so
|
|
82
|
+
# each file is parsed exactly once and each worker holds at most one
|
|
83
|
+
# in-flight job (keeping result pipes free of interleaved frames).
|
|
84
|
+
def schedule_jobs(job_pipes, result_pipes)
|
|
85
|
+
writers = job_pipes.map(&:last)
|
|
86
|
+
readers = result_pipes.map(&:first)
|
|
87
|
+
files_results = Array.new(@files.size)
|
|
88
|
+
next_job = 0
|
|
89
|
+
busy = {}
|
|
90
|
+
|
|
91
|
+
writers.each_index do |wi|
|
|
92
|
+
break if next_job == @files.size
|
|
93
|
+
|
|
94
|
+
dispatch(writers[wi], next_job)
|
|
95
|
+
busy[wi] = true
|
|
96
|
+
next_job += 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
until busy.empty?
|
|
100
|
+
ready, = IO.select(readers.values_at(*busy.keys))
|
|
101
|
+
ready.each do |io|
|
|
102
|
+
wi = readers.index(io)
|
|
103
|
+
payload = Marshal.load(read_frame(io)) # rubocop:disable Security/MarshalLoad
|
|
104
|
+
files_results[payload[:index]] = payload
|
|
105
|
+
|
|
106
|
+
if next_job < @files.size
|
|
107
|
+
dispatch(writers[wi], next_job)
|
|
108
|
+
next_job += 1
|
|
109
|
+
else
|
|
110
|
+
writers[wi].close unless writers[wi].closed?
|
|
111
|
+
busy.delete(wi)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
files_results
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def dispatch(writer, job_index)
|
|
120
|
+
write_frame(writer, pack_frame(Marshal.dump([job_index, @files[job_index]])))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def worker_loop(job_r, result_w)
|
|
124
|
+
until job_r.eof?
|
|
125
|
+
data = read_frame(job_r)
|
|
126
|
+
index, file = Marshal.load(data) # rubocop:disable Security/MarshalLoad
|
|
127
|
+
begin
|
|
128
|
+
payload = { index: index, exp_file: @parse.call(file) }
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
payload = { index: index, error: transferable_error(e) }
|
|
131
|
+
end
|
|
132
|
+
write_frame(result_w, pack_frame(Marshal.dump(payload)))
|
|
133
|
+
end
|
|
134
|
+
rescue Errno::EPIPE
|
|
135
|
+
# parent went away; nothing to report to
|
|
136
|
+
exit!(0)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# SIGTERM cannot interrupt a worker blocked in the native parser (the
|
|
140
|
+
# GVL is held), so escalate to SIGKILL after a grace period instead of
|
|
141
|
+
# blocking in waitpid forever.
|
|
142
|
+
def cleanup(job_pipes, result_pipes, pids)
|
|
143
|
+
(job_pipes.to_a + result_pipes.to_a).each do |r, w|
|
|
144
|
+
r.close unless r.closed?
|
|
145
|
+
w.close unless w.closed?
|
|
146
|
+
end
|
|
147
|
+
pids.to_a.each { |pid| Process.kill("TERM", pid) if alive?(pid) }
|
|
148
|
+
|
|
149
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
|
150
|
+
pids.to_a.each do |pid|
|
|
151
|
+
while alive?(pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
152
|
+
Process.waitpid(pid, Process::WNOHANG)
|
|
153
|
+
sleep 0.05
|
|
154
|
+
end
|
|
155
|
+
Process.kill("KILL", pid) if alive?(pid)
|
|
156
|
+
reap(pid)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def ordered_pass(files_results)
|
|
161
|
+
files_results.each_with_index do |payload, index|
|
|
162
|
+
error = payload[:error]
|
|
163
|
+
@block&.call(@files[index], payload[:exp_file], error)
|
|
164
|
+
raise error if error && (@strict ||
|
|
165
|
+
!error.is_a?(Error::SchemaParseFailure))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
files_results.map do |payload|
|
|
169
|
+
payload[:error] ? nil : payload[:exp_file]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Errors carrying native-parser state (e.g. Parsanol::ParseFailed with
|
|
174
|
+
# its cause tree) cannot cross a fork boundary; rebuild them without
|
|
175
|
+
# the untransferable internals, preserving class and message.
|
|
176
|
+
def transferable_error(error)
|
|
177
|
+
Marshal.dump(error)
|
|
178
|
+
error
|
|
179
|
+
rescue StandardError
|
|
180
|
+
if error.is_a?(Error::SchemaParseFailure)
|
|
181
|
+
Error::SchemaParseFailure.new(error.filename,
|
|
182
|
+
StandardError.new(error.message))
|
|
183
|
+
else
|
|
184
|
+
StandardError.new(error.message)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def pack_frame(data)
|
|
189
|
+
[data.bytesize].pack("N") + data
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def write_frame(io, frame)
|
|
193
|
+
io.write(frame)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def read_frame(io)
|
|
197
|
+
header = read_exactly(io, FRAME_HEADER_BYTES)
|
|
198
|
+
read_exactly(io, header.unpack1("N"))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def read_exactly(io, count)
|
|
202
|
+
data = +""
|
|
203
|
+
while data.bytesize < count
|
|
204
|
+
chunk = io.read(count - data.bytesize)
|
|
205
|
+
unless chunk
|
|
206
|
+
raise Error::ParallelParseError,
|
|
207
|
+
"worker exited before sending its result"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
data << chunk
|
|
211
|
+
end
|
|
212
|
+
data
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def alive?(pid)
|
|
216
|
+
Process.kill(0, pid)
|
|
217
|
+
true
|
|
218
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
219
|
+
false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def reap(pid)
|
|
223
|
+
Process.waitpid(pid)
|
|
224
|
+
rescue Errno::ECHILD, Errno::EINVAL
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|