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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +27 -0
- data/CLAUDE.md +17 -5
- data/README.adoc +101 -0
- data/lib/suma/anchor_validator.rb +85 -0
- data/lib/suma/artifact_server.rb +102 -0
- data/lib/suma/cli/artifact_server_cli.rb +22 -0
- data/lib/suma/cli/build.rb +20 -3
- data/lib/suma/cli/core.rb +50 -4
- data/lib/suma/cli/export.rb +1 -1
- data/lib/suma/cli/extract_terms.rb +1 -1
- data/lib/suma/cli/generate_register.rb +1 -1
- data/lib/suma/cli/ingest_translations.rb +36 -0
- data/lib/suma/cli/package_gcr.rb +51 -0
- data/lib/suma/cli/validate.rb +16 -0
- data/lib/suma/cli.rb +4 -1
- data/lib/suma/closure_validator.rb +141 -37
- data/lib/suma/corpus_graph.rb +80 -17
- data/lib/suma/gcr_package.rb +230 -0
- data/lib/suma/link_validation_result.rb +8 -0
- data/lib/suma/link_validator.rb +0 -3
- data/lib/suma/output_canonicalizer.rb +107 -0
- data/lib/suma/owl_projection.rb +3 -3
- data/lib/suma/processor.rb +100 -20
- data/lib/suma/provenance.rb +92 -12
- data/lib/suma/schema_collection.rb +1 -0
- data/lib/suma/schema_compiler.rb +79 -13
- data/lib/suma/schema_exporter.rb +12 -0
- data/lib/suma/schema_index.rb +5 -5
- data/lib/suma/schema_manifest_generator.rb +10 -3
- data/lib/suma/schema_profile.rb +12 -0
- data/lib/suma/schema_template/anchors.rb +154 -0
- data/lib/suma/schema_template/document.rb +2 -93
- data/lib/suma/schema_template/plain.rb +10 -6
- data/lib/suma/schema_template.rb +1 -0
- data/lib/suma/site_build.rb +153 -0
- data/lib/suma/staged_collection_builder.rb +146 -10
- data/lib/suma/term_extractor.rb +10 -4
- data/lib/suma/translation_ingester.rb +169 -0
- data/lib/suma/urn_resolver.rb +3 -3
- data/lib/suma/version.rb +1 -1
- data/lib/suma/worker_pool.rb +5 -1
- data/lib/suma.rb +7 -0
- 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
|
-
#
|
|
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
|
|
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
|
-
[
|
|
33
|
-
|
|
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.
|
|
41
|
+
{{ schema.formatted_hyperlinked_adoc }}
|
|
38
42
|
--
|
|
39
43
|
{% endfor %}
|
|
40
44
|
----
|
data/lib/suma/schema_template.rb
CHANGED
|
@@ -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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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;
|
|
55
|
-
"
|
|
56
|
-
|
|
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:
|
|
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:
|
|
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)
|