suma 0.5.9 → 0.5.11

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +27 -0
  3. data/CLAUDE.md +17 -5
  4. data/README.adoc +101 -0
  5. data/lib/suma/anchor_validator.rb +85 -0
  6. data/lib/suma/artifact_server.rb +102 -0
  7. data/lib/suma/cli/artifact_server_cli.rb +22 -0
  8. data/lib/suma/cli/build.rb +20 -3
  9. data/lib/suma/cli/core.rb +50 -4
  10. data/lib/suma/cli/export.rb +1 -1
  11. data/lib/suma/cli/extract_terms.rb +1 -1
  12. data/lib/suma/cli/generate_register.rb +1 -1
  13. data/lib/suma/cli/ingest_translations.rb +36 -0
  14. data/lib/suma/cli/package_gcr.rb +51 -0
  15. data/lib/suma/cli/validate.rb +16 -0
  16. data/lib/suma/cli.rb +4 -1
  17. data/lib/suma/closure_validator.rb +141 -37
  18. data/lib/suma/corpus_graph.rb +80 -17
  19. data/lib/suma/gcr_package.rb +230 -0
  20. data/lib/suma/link_validation_result.rb +8 -0
  21. data/lib/suma/link_validator.rb +0 -3
  22. data/lib/suma/output_canonicalizer.rb +107 -0
  23. data/lib/suma/owl_projection.rb +3 -3
  24. data/lib/suma/processor.rb +100 -20
  25. data/lib/suma/provenance.rb +92 -12
  26. data/lib/suma/schema_collection.rb +1 -0
  27. data/lib/suma/schema_compiler.rb +79 -13
  28. data/lib/suma/schema_exporter.rb +12 -0
  29. data/lib/suma/schema_index.rb +5 -5
  30. data/lib/suma/schema_manifest_generator.rb +10 -3
  31. data/lib/suma/schema_profile.rb +12 -0
  32. data/lib/suma/schema_template/anchors.rb +154 -0
  33. data/lib/suma/schema_template/document.rb +2 -93
  34. data/lib/suma/schema_template/plain.rb +10 -6
  35. data/lib/suma/schema_template.rb +1 -0
  36. data/lib/suma/site_build.rb +153 -0
  37. data/lib/suma/staged_collection_builder.rb +146 -10
  38. data/lib/suma/term_extractor.rb +10 -4
  39. data/lib/suma/translation_ingester.rb +169 -0
  40. data/lib/suma/urn_resolver.rb +3 -3
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +5 -1
  43. data/lib/suma.rb +7 -0
  44. metadata +13 -2
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SchemaTemplate
5
+ # The Liquid anchor block shared by the schema document templates: one
6
+ # [[id]] for every element an Expressir cross-reference may target, so
7
+ # every xref the hyperlinked formatter emits resolves in the rendered
8
+ # document. Expressir links schema-qualified element ids
9
+ # (`schema.entity`) but bare `entity.attribute` pairs, hence the two
10
+ # bookmark forms.
11
+ module Anchors
12
+ # Xref targets are always qualified by the schema's *declared* id, so
13
+ # anchors are too — independent of the manifest key's casing.
14
+ SCHEMA_REF = "{{ schema.id }}"
15
+
16
+ # The raw anchor block contains Liquid comments and newlines that are
17
+ # illegal on the section header line; strip them before interpolating.
18
+ def rendered_anchors
19
+ anchor_block
20
+ .gsub(%r{//[^\r\n]+}, "")
21
+ .gsub(/[\n\r]+/, "")
22
+ .gsub(/^[\n\r]/, "")
23
+ end
24
+
25
+ private
26
+
27
+ def anchor_block
28
+ <<~HEREDOC
29
+ // _fund_cons.liquid
30
+ [[#{schema_id}_funds]]
31
+
32
+ #{constants_and_types_anchors}
33
+
34
+ #{entity_anchors}
35
+
36
+ #{declaration_anchors}
37
+ HEREDOC
38
+ end
39
+
40
+ def constants_and_types_anchors
41
+ <<~HEREDOC
42
+ // _constants.liquid
43
+ {% if schema.constants.size > 0 %}
44
+ #{bookmark('constants')}
45
+ {% for thing in schema.constants %}
46
+ #{bookmark('{{thing.id}}')}
47
+ {% endfor %}
48
+ {% endif %}
49
+
50
+ // _types.liquid
51
+ {% if schema.types.size > 0 %}
52
+ #{bookmark('types')}
53
+ // _type.liquid
54
+ {% for thing in schema.types %}
55
+ #{bookmark('{{thing.id}}')}
56
+ {% if thing.items.size > 0 %}
57
+ // _type_items.liquid
58
+ #{bookmark('{{thing.id}}.items')}
59
+ {% for item in thing.items %}
60
+ #{bookmark('{{thing.id}}.items.{{item.id}}')}
61
+ {% endfor %}
62
+ {% endif %}
63
+ {% endfor %}
64
+ {% endif %}
65
+ HEREDOC
66
+ end
67
+
68
+ def entity_anchors
69
+ <<~HEREDOC
70
+ // _entities.liquid
71
+ {% if schema.entities.size > 0 %}
72
+ #{bookmark('entities')}
73
+ // _entity.liquid
74
+ {% for thing in schema.entities %}
75
+ #{bookmark('{{thing.id}}')}
76
+ {% for attr in thing.attributes %}
77
+ #{bookmark_local('{{thing.id}}.{{attr.id}}')}
78
+ {% endfor %}
79
+ {% endfor %}
80
+ {% endif %}
81
+ HEREDOC
82
+ end
83
+
84
+ def declaration_anchors
85
+ <<~HEREDOC
86
+ // _subtype_constraints.liquid
87
+ {% if schema.subtype_constraints.size > 0 %}
88
+ #{bookmark('subtype_constraints')}
89
+ // _subtype_constraint.liquid
90
+ {% for thing in schema.subtype_constraints %}
91
+ #{bookmark('{{thing.id}}')}
92
+ {% endfor %}
93
+ {% endif %}
94
+
95
+ // _functions.liquid
96
+ {% if schema.functions.size > 0 %}
97
+ #{bookmark('functions')}
98
+ // _function.liquid
99
+ {% for thing in schema.functions %}
100
+ #{callable_anchors}
101
+ {% endfor %}
102
+ {% endif %}
103
+
104
+ // _procedures.liquid
105
+ {% if schema.procedures.size > 0 %}
106
+ #{bookmark('procedures')}
107
+ // _procedure.liquid
108
+ {% for thing in schema.procedures %}
109
+ #{callable_anchors}
110
+ {% endfor %}
111
+ {% endif %}
112
+
113
+ // _rules.liquid
114
+ {% if schema.rules.size > 0 %}
115
+ #{bookmark('rules')}
116
+ // _rule.liquid
117
+ {% for thing in schema.rules %}
118
+ #{bookmark('{{thing.id}}')}
119
+ {% for var in thing.variables %}
120
+ #{bookmark_local('{{thing.id}}.{{var.id}}')}
121
+ {% endfor %}
122
+ {% endfor %}
123
+ {% endif %}
124
+ HEREDOC
125
+ end
126
+
127
+ # Functions and procedures carry parameters and local variables, and
128
+ # Expressir links references to them as bare `owner.name` pairs.
129
+ def callable_anchors
130
+ <<~HEREDOC
131
+ #{bookmark('{{thing.id}}')}
132
+ {% for param in thing.parameters %}
133
+ #{bookmark_local('{{thing.id}}.{{param.id}}')}
134
+ {% endfor %}
135
+ {% for var in thing.variables %}
136
+ #{bookmark_local('{{thing.id}}.{{var.id}}')}
137
+ {% endfor %}
138
+ HEREDOC
139
+ end
140
+
141
+ def bookmark(anchor)
142
+ "[[#{mangled(SCHEMA_REF)}.#{mangled(anchor)}]]"
143
+ end
144
+
145
+ def bookmark_local(anchor)
146
+ "[[#{mangled(anchor)}]]"
147
+ end
148
+
149
+ def mangled(anchor)
150
+ anchor.gsub("}}", ' | replace: "\", "-", "-}}')
151
+ end
152
+ end
153
+ end
154
+ end
@@ -4,10 +4,11 @@ module Suma
4
4
  module SchemaTemplate
5
5
  # Emits an AsciiDoc body with cross-reference anchors for every
6
6
  # schema element so other documents can deep-link into the compiled
7
- # HTML. Only XML is produced — the anchors only resolve against the
7
+ # output. Only XML is produced — the anchors only resolve against the
8
8
  # XML output, not the HTML rendering.
9
9
  class Document
10
10
  EXTENSIONS = "xml"
11
+ include Anchors
11
12
 
12
13
  attr_reader :schema_id
13
14
 
@@ -44,98 +45,6 @@ module Suma
44
45
 
45
46
  ADOC
46
47
  end
47
-
48
- private
49
-
50
- # The raw anchor block contains Liquid comments and newlines that
51
- # are illegal on the section header line; strip them before
52
- # interpolating.
53
- def rendered_anchors
54
- schema_anchors.gsub(%r{//[^\r\n]+}, "").gsub(/[\n\r]+/, "").gsub(
55
- /^[\n\r]/, ""
56
- )
57
- end
58
-
59
- def schema_anchors
60
- <<~HEREDOC
61
- // _fund_cons.liquid
62
- [[#{schema_id}_funds]]
63
-
64
- // _constants.liquid
65
- {% if schema.constants.size > 0 %}
66
- #{bookmark('constants')}
67
- {% for thing in schema.constants %}
68
- #{bookmark('{{thing.id}}')}
69
- {% endfor %}
70
- {% endif %}
71
-
72
- // _types.liquid
73
- {% if schema.types.size > 0 %}
74
- #{bookmark('types')}
75
- // _type.liquid
76
- {% for thing in schema.types %}
77
- #{bookmark('{{thing.id}}')}
78
- {% if thing.items.size > 0 %}
79
- // _type_items.liquid
80
- #{bookmark('{{thing.id}}.items')}
81
- {% for item in thing.items %}
82
- #{bookmark('{{thing.id}}.items.{{item.id}}')}
83
- {% endfor %}
84
- {% endif %}
85
- {% endfor %}
86
- {% endif %}
87
-
88
- // _entities.liquid
89
- {% if schema.entities.size > 0 %}
90
- #{bookmark('entities')}
91
- {% for thing in schema.entities %}
92
- // _entity.liquid
93
- #{bookmark('{{thing.id}}')}
94
- {% endfor %}
95
- {% endif %}
96
-
97
- // _subtype_constraints.liquid
98
- {% if schema.subtype_constraints.size > 0 %}
99
- #{bookmark('subtype_constraints')}
100
- // _subtype_constraint.liquid
101
- {% for thing in schema.subtype_constraints %}
102
- #{bookmark('{{thing.id}}')}
103
- {% endfor %}
104
- {% endif %}
105
-
106
- // _functions.liquid
107
- {% if schema.functions.size > 0 %}
108
- #{bookmark('functions')}
109
- // _function.liquid
110
- {% for thing in schema.functions %}
111
- #{bookmark('{{thing.id}}')}
112
- {% endfor %}
113
- {% endif %}
114
-
115
- // _procedures.liquid
116
- {% if schema.procedures.size > 0 %}
117
- #{bookmark('procedures')}
118
- // _procedure.liquid
119
- {% for thing in schema.procedures %}
120
- #{bookmark('{{thing.id}}')}
121
- {% endfor %}
122
- {% endif %}
123
-
124
- // _rules.liquid
125
- {% if schema.rules.size > 0 %}
126
- #{bookmark('rules')}
127
- // _rule.liquid
128
- {% for thing in schema.rules %}
129
- #{bookmark('{{thing.id}}')}
130
- {% endfor %}
131
- {% endif %}
132
- HEREDOC
133
- end
134
-
135
- def bookmark(anchor)
136
- mangled = anchor.gsub("}}", ' | replace: "\", "-", "-}}')
137
- "[[#{schema_id}.#{mangled}]]"
138
- end
139
48
  end
140
49
  end
141
50
  end
@@ -2,10 +2,13 @@
2
2
 
3
3
  module Suma
4
4
  module SchemaTemplate
5
- # Emits a plain AsciiDoc body for a single EXPRESS schema, producing
6
- # both HTML and XML outputs via Metanorma.
5
+ # Emits an anchored, hyperlinked AsciiDoc body for a single EXPRESS
6
+ # schema, producing both HTML and XML outputs via Metanorma. Every
7
+ # element cross-reference the hyperlinked source emits resolves inside
8
+ # the rendered document through the shared anchor block.
7
9
  class Plain
8
10
  EXTENSIONS = "xml,html"
11
+ include Anchors
9
12
 
10
13
  attr_reader :schema_id
11
14
 
@@ -29,12 +32,13 @@ module Suma
29
32
  ----
30
33
  {% for schema in context.schemas %}
31
34
 
32
- [%unnumbered]
33
- == #{schema_id}
35
+ [[#{schema_id}]]
36
+ [%unnumbered,type=express]
37
+ == #{schema_id} #{rendered_anchors}
34
38
 
35
- [source%unnumbered]
39
+ [source%unnumbered,subs="+macros"]
36
40
  --
37
- {{ schema.formatted }}
41
+ {{ schema.formatted_hyperlinked_adoc }}
38
42
  --
39
43
  {% endfor %}
40
44
  ----
@@ -13,6 +13,7 @@ module Suma
13
13
  #
14
14
  # Composition with the compiler lives in SchemaCompiler.
15
15
  module SchemaTemplate
16
+ autoload :Anchors, "suma/schema_template/anchors"
16
17
  autoload :Plain, "suma/schema_template/plain"
17
18
  autoload :Document, "suma/schema_template/document"
18
19
  end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "fileutils"
6
+ require "pathname"
7
+
8
+ module Suma
9
+ # Freshness decision for the rendered collection site.
10
+ #
11
+ # The collection render is expensive: Metanorma compiles every member
12
+ # document. Its output is a pure function of the emitted collection manifest,
13
+ # the source tree it references, and the rendering toolchain, so this class
14
+ # keys those inputs, remembers the key and the produced files in the output
15
+ # directory, and lets the processor skip the render when nothing changed.
16
+ class SiteBuild
17
+ STATE_FILE = ".suma-site-build.json"
18
+
19
+ # Emitted manifests that the build itself regenerates deterministically
20
+ # from the sources above; whether they exist yet must not change the key.
21
+ GENERATED_FILES = %w[collection-output.yaml schemas.yml].freeze
22
+
23
+ def initialize(root:, output_directory:, inputs: [])
24
+ @root = File.expand_path(root)
25
+ @output_directory = File.expand_path(output_directory)
26
+ @extra_inputs = inputs.map { |path| File.expand_path(path) }
27
+ end
28
+
29
+ # @return [Array<String>] source files determining the rendered site
30
+ def source_files
31
+ @source_files ||= begin
32
+ files = @extra_inputs.select { |file| File.file?(file) }
33
+ (files + tree_files).uniq.sort
34
+ end
35
+ end
36
+
37
+ # @return [String] digest of every source file and the render toolchain
38
+ def key
39
+ @key ||= begin
40
+ digest = Digest::SHA256.new
41
+ source_files.each do |file|
42
+ digest << relative(file) << "\0"
43
+ digest << Digest::SHA256.file(file).hexdigest << "\0"
44
+ end
45
+ digest << JSON.generate(Provenance.render_toolchain)
46
+ digest.hexdigest
47
+ end
48
+ end
49
+
50
+ # True when the previous render used the same inputs and every file it
51
+ # produced is still present.
52
+ def current?
53
+ state = read_state
54
+ return false unless state && state["key"] == key
55
+
56
+ outputs = Array(state["outputs"])
57
+ !outputs.empty? &&
58
+ outputs.all? { |file| File.file?(File.join(@output_directory, file)) }
59
+ end
60
+
61
+ # Persist the key and the produced files for the next build.
62
+ def record
63
+ write_state("key" => key, "outputs" => output_files)
64
+ self
65
+ end
66
+
67
+ def state_path
68
+ File.join(@output_directory, STATE_FILE)
69
+ end
70
+
71
+ private
72
+
73
+ def tree_files
74
+ excluded = excluded_paths
75
+ Dir.glob(File.join(@root, "**", "*")).select do |file|
76
+ File.file?(file) &&
77
+ excluded.none? { |path| under?(file, path) } &&
78
+ !generated?(relative(file).tr("\\", "/"))
79
+ end
80
+ end
81
+
82
+ # Files the collection render writes next to its sources. They are
83
+ # outputs of the previous build, never inputs, so including them would
84
+ # change the key on every run.
85
+ def generated?(relative)
86
+ base = File.basename(relative)
87
+ GENERATED_FILES.include?(relative) ||
88
+ relative.start_with?("relaton/") ||
89
+ base.start_with?("tmp_document") ||
90
+ base.end_with?("_index.html", ".err.html", ".log.txt") ||
91
+ document_artifact?(relative, base)
92
+ end
93
+
94
+ def document_artifact?(relative, base)
95
+ return false unless relative.start_with?("documents/")
96
+
97
+ base == "cover.html" || base == "document.xml" ||
98
+ (base.start_with?("document.") && base.include?(".xml"))
99
+ end
100
+
101
+ # Generated trees are derived from the sources above and never feed the
102
+ # site render as inputs of their own. Bundler's install directory is a
103
+ # dependency artifact: CI conventionally places it at vendor/bundle
104
+ # inside the project, where it must never count as site source.
105
+ def excluded_paths
106
+ fixed = %w[schema_docs plain_schemas .git .bundle vendor/bundle]
107
+ .map { |dir| File.join(@root, dir) }
108
+ env = [ENV.fetch("SUMA_SCHEMA_CACHE_DIR", nil),
109
+ ENV.fetch("BUNDLE_PATH", nil)]
110
+ .compact
111
+ .map { |dir| File.expand_path(dir, @root) }
112
+ [@output_directory] + fixed + env
113
+ end
114
+
115
+ def under?(file, path)
116
+ file == path || file.start_with?("#{path}#{File::SEPARATOR}")
117
+ end
118
+
119
+ def read_state
120
+ return unless File.file?(state_path)
121
+
122
+ JSON.parse(File.read(state_path))
123
+ rescue JSON::ParserError
124
+ nil
125
+ end
126
+
127
+ def write_state(state)
128
+ FileUtils.mkdir_p(@output_directory)
129
+ temp = "#{state_path}.#{Process.pid}.tmp"
130
+ File.write(temp, "#{JSON.pretty_generate(state)}\n")
131
+ File.rename(temp, state_path)
132
+ ensure
133
+ File.delete(temp) if temp && File.exist?(temp)
134
+ end
135
+
136
+ def output_files
137
+ Dir.glob(File.join(@output_directory, "**", "*"))
138
+ .select do |file|
139
+ File.file?(file) && File.basename(file) != STATE_FILE
140
+ end
141
+ .map { |file| relative_to(file, @output_directory) }
142
+ .sort
143
+ end
144
+
145
+ def relative(path)
146
+ relative_to(path, @root)
147
+ end
148
+
149
+ def relative_to(path, base)
150
+ Pathname.new(path).relative_path_from(Pathname.new(base)).to_s
151
+ end
152
+ end
153
+ end
@@ -16,10 +16,12 @@ module Suma
16
16
  # stub-bearing Semantic XML + anchors to a durable content-addressed store
17
17
  # (the metanorma engine's +preserve_unresolved:+ / +artifact_store_dir:+
18
18
  # render options, metanorma/metanorma#576);
19
- # 2. runs those per-member processes SEQUENTIALLY (never in parallel --
20
- # parallelism reintroduces the cross-linking / sectionsplit races that are
21
- # deliberately avoided); each process exits and the OS reclaims its memory
22
- # before the next, so peak memory is bounded by a single member; then
19
+ # 2. runs those per-member processes SEQUENTIALLY by default; with
20
+ # +workers+ > 1, batches of members are rendered by that many child
21
+ # processes concurrently (safe: members write disjoint store entries,
22
+ # and sectionsplit happens only in the sequential reinflate pass); each
23
+ # process exit hands its memory back to the OS, so peak memory stays
24
+ # bounded by workers x one member; then
23
25
  # 3. REINFLATES: a final pass assembles a manifest of the stored stubs and
24
26
  # resolves them into real cross-document links (+reinflate:+), producing
25
27
  # the same output an all-present build would.
@@ -34,31 +36,159 @@ module Suma
34
36
  class StagedCollectionBuilder
35
37
  STORE_DIRNAME = ".metanorma-collection-cache"
36
38
 
39
+ # Members rendered per child process: one Metanorma boot amortised over
40
+ # a batch of small members keeps boot cost under a few percent.
41
+ DEFAULT_BATCH_SIZE = 25
42
+
37
43
  # +collection_config_path+ is the emitted collection manifest (the same
38
44
  # +collection-output.yaml+ the normal build renders). Member filerefs are
39
45
  # relative to its directory, so all per-member manifests are written there.
46
+ # +workers+ > 1 runs that many renderer processes concurrently over batches
47
+ # of +batch_size+ members; members write disjoint, identifier-keyed store
48
+ # entries, so children cannot race. (a data-declaring initializer)
40
49
  def initialize(collection_config_path:, output_directory:, coverpage: nil,
41
- store_dir: nil, formats: %i[xml html])
50
+ store_dir: nil, formats: %i[xml html], no_valid: false,
51
+ workers: 1, batch_size: DEFAULT_BATCH_SIZE) # rubocop:disable Metrics/ParameterLists
42
52
  @config_path = collection_config_path
43
53
  @config_dir = File.dirname(File.expand_path(collection_config_path))
44
54
  @output_directory = output_directory
45
55
  @coverpage = coverpage
46
56
  @store_dir = store_dir || File.join(output_directory, STORE_DIRNAME)
47
57
  @formats = formats
58
+ @no_valid = no_valid
59
+ @workers = [workers.to_i, 1].max
60
+ @batch_size = [batch_size.to_i, 1].max
48
61
  end
49
62
 
50
63
  def build
51
64
  FileUtils.mkdir_p(@output_directory)
52
65
  FileUtils.mkdir_p(@store_dir)
53
66
  members = collection_members
54
- Utils.log "[staged] #{members.size} members; one process per member, " \
55
- "sequential; store: #{@store_dir}"
56
- members.each_with_index { |member, i| stage_member(member, i) }
67
+ Utils.log "[staged] #{members.size} members; workers: #{@workers}; " \
68
+ "batch size: #{@batch_size}; store: #{@store_dir}"
69
+ if @workers == 1
70
+ members.each_with_index { |member, i| stage_member(member, i) }
71
+ else
72
+ stage_parallel(members)
73
+ end
57
74
  reinflate(members)
58
75
  end
59
76
 
60
77
  private
61
78
 
79
+ # Batch the members and run +@workers+ renderer child processes
80
+ # concurrently. Each batch file carries fully-rendered single-member
81
+ # manifests; children claim batches through O_EXCL markers, so a slow
82
+ # worker never starves. One Metanorma boot amortises over
83
+ # +@batch_size+ members (the boot dominates small members), and each
84
+ # child exit hands its memory back to the OS. Members write disjoint,
85
+ # identifier-keyed store entries, so concurrent children cannot race;
86
+ # sectionsplit only happens at reinflate (a single sequential pass).
87
+ def stage_parallel(members)
88
+ paths, count = write_batch_files(members)
89
+ run_batch_workers(count)
90
+ ensure
91
+ remove_batch_files(paths, count)
92
+ end
93
+
94
+ def write_batch_files(members)
95
+ batches = batch_manifests(members)
96
+ paths = write_batch_bins(batches)
97
+ Utils.log "[staged] #{batches.size} batches over #{@workers} workers"
98
+ [paths, batches.size]
99
+ end
100
+
101
+ def batch_manifests(members)
102
+ members.each_slice(@batch_size).map do |slice|
103
+ slice.map do |member|
104
+ base_manifest.merge("manifest" => collection_manifest([member]))
105
+ end
106
+ end
107
+ end
108
+
109
+ def write_batch_bins(batches)
110
+ batches.map.with_index do |batch, i|
111
+ path = File.join(@config_dir, "._suma_batch_#{i}.bin")
112
+ File.binwrite(path, Marshal.dump(batch))
113
+ path
114
+ end
115
+ end
116
+
117
+ def run_batch_workers(count)
118
+ children = Array.new([@workers, count].min) do
119
+ Process.spawn(RbConfig.ruby, "-e", child_script, @config_dir,
120
+ count.to_s, @store_dir, @output_directory,
121
+ @no_valid ? "1" : "0", chdir: @config_dir)
122
+ end
123
+ failed = children
124
+ .filter_map { |pid| Process.waitpid2(pid) }
125
+ .map(&:last).reject(&:success?).size
126
+ return if failed.zero?
127
+
128
+ raise "[staged] #{failed} of #{@workers} batch workers failed; " \
129
+ "see the worker output above"
130
+ end
131
+
132
+ def remove_batch_files(paths, count)
133
+ paths&.each { |path| FileUtils.rm_f(path) }
134
+ (0...(count || 0)).each do |i|
135
+ FileUtils.rm_f(File.join(@config_dir, "._suma_batch_#{i}.claim"))
136
+ end
137
+ end
138
+
139
+ # Child renderer: claims and renders batches until none remain. The
140
+ # Metanorma boot is paid once per child, and GC runs between members to
141
+ # bound the heap. Any render failure exits nonzero and fails the build.
142
+ def child_script
143
+ <<~RUBY
144
+ require "yaml"
145
+ require "fileutils"
146
+ require "metanorma"
147
+ config_dir, batch_count, store_dir, out_dir, no_valid = ARGV
148
+ count = Integer(batch_count)
149
+ claimed = 0
150
+ loop do
151
+ idx = (0...count).find do |i|
152
+ begin
153
+ File.open(File.join(config_dir, "._suma_batch_\#{i}.claim"),
154
+ File::RDWR | File::CREAT | File::EXCL) {}
155
+ true
156
+ rescue Errno::EEXIST
157
+ false
158
+ end
159
+ end
160
+ break unless idx
161
+
162
+ manifests = Marshal.load(File.binread(File.join(
163
+ config_dir, "._suma_batch_\#{idx}.bin")))
164
+ manifests.each_with_index do |manifest, j|
165
+ one_path = File.join(config_dir,
166
+ "._suma_staged_b\#{idx}_\#{j}.yml")
167
+ out = File.join(out_dir, "._staged_out_b\#{idx}_\#{j}")
168
+ t0 = Time.now
169
+ begin
170
+ File.write(one_path, YAML.dump(manifest))
171
+ compile = { install_fonts: false }
172
+ compile[:novalid] = true if no_valid == "1"
173
+ Metanorma::Collection.parse(one_path).render(
174
+ format: [:xml], output_folder: out,
175
+ preserve_unresolved: true, artifact_store_dir: store_dir,
176
+ compile: compile,
177
+ )
178
+ warn format("[staged worker %d] batch %d/%d member %d OK (%ds)",
179
+ Process.pid, idx + 1, count, j, Time.now - t0)
180
+ ensure
181
+ File.unlink(one_path) rescue nil
182
+ FileUtils.rm_rf(out) rescue nil
183
+ GC.start
184
+ end
185
+ end
186
+ claimed += 1
187
+ end
188
+ exit 1 if claimed.zero? && count.positive?
189
+ RUBY
190
+ end
191
+
62
192
  def manifest
63
193
  @manifest ||= YAML.load_file(@config_path)
64
194
  end
@@ -97,7 +227,7 @@ module Suma
97
227
  manifest_path: one_path,
98
228
  opts: { format: [:xml], output_folder: out,
99
229
  preserve_unresolved: true, artifact_store_dir: @store_dir,
100
- compile: { install_fonts: false } },
230
+ compile: compile_flags },
101
231
  )
102
232
  ensure
103
233
  FileUtils.rm_f(one_path)
@@ -118,10 +248,16 @@ module Suma
118
248
  manifest_path: reinf_path,
119
249
  opts: { format: @formats, output_folder: @output_directory,
120
250
  reinflate: true, coverpage: @coverpage,
121
- compile: { install_fonts: false } }.compact,
251
+ compile: compile_flags }.compact,
122
252
  )
123
253
  end
124
254
 
255
+ def compile_flags
256
+ { install_fonts: false }.tap do |flags|
257
+ flags[:novalid] = true if @no_valid
258
+ end
259
+ end
260
+
125
261
  # Copy each member's stored stub into +dir+ and return docref entries pointing
126
262
  # at the copies, keyed by real docid (as the store keys them).
127
263
  def stored_docrefs(members, dir)