relaton 3.0.0.pre.alpha.1 → 3.0.0.pre.alpha.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.
Files changed (270) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +57 -1
  3. data/bin/console +0 -1
  4. data/lib/relaton/3gpp/bibliography.rb +82 -7
  5. data/lib/relaton/3gpp/data_fetcher.rb +51 -3
  6. data/lib/relaton/3gpp/docidentifier.rb +114 -0
  7. data/lib/relaton/3gpp/item.rb +6 -0
  8. data/lib/relaton/3gpp/parser.rb +1 -1
  9. data/lib/relaton/3gpp/processor.rb +4 -1
  10. data/lib/relaton/3gpp.rb +5 -1
  11. data/lib/relaton/adobe/bibdata.rb +8 -0
  12. data/lib/relaton/adobe/bibitem.rb +8 -0
  13. data/lib/relaton/adobe/bibliography.rb +92 -0
  14. data/lib/relaton/adobe/docidentifier.rb +49 -0
  15. data/lib/relaton/adobe/doctype.rb +14 -0
  16. data/lib/relaton/adobe/ext.rb +32 -0
  17. data/lib/relaton/adobe/item.rb +15 -0
  18. data/lib/relaton/adobe/item_base.rb +18 -0
  19. data/lib/relaton/adobe/item_data.rb +6 -0
  20. data/lib/relaton/adobe/processor.rb +45 -0
  21. data/lib/relaton/adobe/util.rb +8 -0
  22. data/lib/relaton/adobe.rb +37 -0
  23. data/lib/relaton/bib/model/address.rb +2 -2
  24. data/lib/relaton/bib/model/docidentifier.rb +24 -9
  25. data/lib/relaton/bib/model/localized_string.rb +1 -1
  26. data/lib/relaton/bib/model/structured_identifier.rb +10 -9
  27. data/lib/relaton/bib/sanitizer.rb +202 -6
  28. data/lib/relaton/bib.rb +0 -2
  29. data/lib/relaton/bipm/bibliography.rb +159 -10
  30. data/lib/relaton/bipm/data_fetcher.rb +26 -2
  31. data/lib/relaton/bipm/data_outcomes_parser.rb +3 -4
  32. data/lib/relaton/bipm/id_parser.rb +5 -4
  33. data/lib/relaton/bipm/model/structured_identifier.rb +21 -0
  34. data/lib/relaton/bipm/processor.rb +2 -2
  35. data/lib/relaton/bipm/rawdata_bipm_metrologia/fetcher.rb +2 -4
  36. data/lib/relaton/bipm/si_brochure_parser.rb +5 -3
  37. data/lib/relaton/bipm.rb +7 -1
  38. data/lib/relaton/bsi/bibliography.rb +115 -43
  39. data/lib/relaton/bsi/hit.rb +14 -0
  40. data/lib/relaton/bsi/hit_collection.rb +15 -16
  41. data/lib/relaton/bsi/model/docidentifier.rb +99 -1
  42. data/lib/relaton/bsi/processor.rb +1 -0
  43. data/lib/relaton/calconnect/bibliography.rb +12 -14
  44. data/lib/relaton/calconnect/data_fetcher.rb +77 -9
  45. data/lib/relaton/calconnect/docidentifier.rb +80 -0
  46. data/lib/relaton/calconnect/hit_collection.rb +65 -57
  47. data/lib/relaton/calconnect/model/item.rb +7 -0
  48. data/lib/relaton/calconnect/processor.rb +7 -1
  49. data/lib/relaton/calconnect.rb +11 -1
  50. data/lib/relaton/ccsds/data/fetcher.rb +17 -12
  51. data/lib/relaton/ccsds/data/parser.rb +1 -1
  52. data/lib/relaton/ccsds/hit_collection.rb +6 -1
  53. data/lib/relaton/ccsds/model/docidentifier.rb +121 -0
  54. data/lib/relaton/ccsds/model/item.rb +2 -0
  55. data/lib/relaton/cen/bibliography.rb +75 -47
  56. data/lib/relaton/cen/hit.rb +16 -1
  57. data/lib/relaton/cen/hit_collection.rb +59 -11
  58. data/lib/relaton/cen/model/docidentifier.rb +92 -1
  59. data/lib/relaton/cen/processor.rb +13 -8
  60. data/lib/relaton/cen/scraper.rb +13 -5
  61. data/lib/relaton/cen.rb +1 -0
  62. data/lib/relaton/cie/data_fetcher.rb +215 -30
  63. data/lib/relaton/cie/processor.rb +3 -1
  64. data/lib/relaton/cie/scrapper.rb +15 -2
  65. data/lib/relaton/cie.rb +2 -1
  66. data/lib/relaton/core/data_fetcher.rb +150 -3
  67. data/lib/relaton/core/governor.rb +320 -0
  68. data/lib/relaton/core/pacer.rb +134 -0
  69. data/lib/relaton/core/processor.rb +19 -0
  70. data/lib/relaton/core/request_error.rb +14 -0
  71. data/lib/relaton/core.rb +3 -0
  72. data/lib/relaton/db/registry.rb +41 -1
  73. data/lib/relaton/doi/crossref.rb +19 -2
  74. data/lib/relaton/doi/parser.rb +109 -15
  75. data/lib/relaton/easc/bibdata.rb +8 -0
  76. data/lib/relaton/easc/bibitem.rb +8 -0
  77. data/lib/relaton/easc/bibliography.rb +95 -0
  78. data/lib/relaton/easc/docidentifier.rb +100 -0
  79. data/lib/relaton/easc/doctype.rb +14 -0
  80. data/lib/relaton/easc/ext.rb +44 -0
  81. data/lib/relaton/easc/item.rb +13 -0
  82. data/lib/relaton/easc/item_base.rb +18 -0
  83. data/lib/relaton/easc/item_data.rb +6 -0
  84. data/lib/relaton/easc/processor.rb +46 -0
  85. data/lib/relaton/easc/util.rb +8 -0
  86. data/lib/relaton/easc.rb +35 -0
  87. data/lib/relaton/ecma/bibliography.rb +93 -25
  88. data/lib/relaton/ecma/data_fetcher.rb +71 -12
  89. data/lib/relaton/ecma/docidentifier.rb +124 -0
  90. data/lib/relaton/ecma/item.rb +2 -0
  91. data/lib/relaton/ecma/memento_parser.rb +1 -1
  92. data/lib/relaton/ecma/page_fetcher.rb +15 -3
  93. data/lib/relaton/ecma/parser_common.rb +2 -2
  94. data/lib/relaton/ecma/processor.rb +4 -1
  95. data/lib/relaton/ecma/standard_parser.rb +2 -2
  96. data/lib/relaton/ecma.rb +10 -1
  97. data/lib/relaton/etsi/bibliography.rb +67 -2
  98. data/lib/relaton/etsi/data_fetcher.rb +43 -4
  99. data/lib/relaton/etsi/processor.rb +3 -1
  100. data/lib/relaton/etsi.rb +2 -1
  101. data/lib/relaton/gb/bibliography.rb +55 -29
  102. data/lib/relaton/gb/docidentifier.rb +58 -9
  103. data/lib/relaton/gb/processor.rb +3 -0
  104. data/lib/relaton/gb/scraper.rb +27 -10
  105. data/lib/relaton/gost/bibdata.rb +8 -0
  106. data/lib/relaton/gost/bibitem.rb +8 -0
  107. data/lib/relaton/gost/bibliography.rb +107 -0
  108. data/lib/relaton/gost/docidentifier.rb +80 -0
  109. data/lib/relaton/gost/doctype.rb +16 -0
  110. data/lib/relaton/gost/ext.rb +46 -0
  111. data/lib/relaton/gost/item.rb +15 -0
  112. data/lib/relaton/gost/item_base.rb +18 -0
  113. data/lib/relaton/gost/item_data.rb +6 -0
  114. data/lib/relaton/gost/processor.rb +49 -0
  115. data/lib/relaton/gost/util.rb +8 -0
  116. data/lib/relaton/gost.rb +36 -0
  117. data/lib/relaton/iala/bibdata.rb +8 -0
  118. data/lib/relaton/iala/bibitem.rb +8 -0
  119. data/lib/relaton/iala/bibliography.rb +146 -0
  120. data/lib/relaton/iala/docidentifier.rb +89 -0
  121. data/lib/relaton/iala/doctype.rb +18 -0
  122. data/lib/relaton/iala/ext.rb +32 -0
  123. data/lib/relaton/iala/item.rb +21 -0
  124. data/lib/relaton/iala/item_base.rb +18 -0
  125. data/lib/relaton/iala/item_data.rb +6 -0
  126. data/lib/relaton/iala/processor.rb +43 -0
  127. data/lib/relaton/iala/relation.rb +7 -0
  128. data/lib/relaton/iala/util.rb +8 -0
  129. data/lib/relaton/iala.rb +35 -0
  130. data/lib/relaton/iana/bibliography.rb +67 -14
  131. data/lib/relaton/iana/data_fetcher.rb +35 -5
  132. data/lib/relaton/iana/processor.rb +3 -1
  133. data/lib/relaton/iana.rb +12 -1
  134. data/lib/relaton/iec/data_fetcher.rb +7 -1
  135. data/lib/relaton/iec/hit_collection.rb +1 -1
  136. data/lib/relaton/iec/model/docidentifier.rb +9 -5
  137. data/lib/relaton/iec/model/ext.rb +2 -2
  138. data/lib/relaton/iec/processor.rb +1 -0
  139. data/lib/relaton/ieee/bibliography.rb +25 -3
  140. data/lib/relaton/ieee/data_fetcher.rb +158 -17
  141. data/lib/relaton/ieee/idams_parser.rb +18 -11
  142. data/lib/relaton/ieee/processor.rb +4 -1
  143. data/lib/relaton/ieee/rawbib_id_parser.rb +291 -86
  144. data/lib/relaton/ieee.rb +2 -1
  145. data/lib/relaton/ietf/data_fetcher.rb +295 -12
  146. data/lib/relaton/ietf/processor.rb +7 -3
  147. data/lib/relaton/ietf/rfc/entry.rb +39 -3
  148. data/lib/relaton/ietf/scraper.rb +69 -36
  149. data/lib/relaton/ietf.rb +4 -1
  150. data/lib/relaton/iho/bibliography.rb +1 -1
  151. data/lib/relaton/iho/docidentifier.rb +1 -1
  152. data/lib/relaton/index/file_io.rb +11 -11
  153. data/lib/relaton/index/file_storage.rb +6 -1
  154. data/lib/relaton/index/pool.rb +6 -1
  155. data/lib/relaton/index/shard_source.rb +201 -0
  156. data/lib/relaton/index/type.rb +63 -12
  157. data/lib/relaton/index.rb +2 -1
  158. data/lib/relaton/iso/bibliography.rb +20 -15
  159. data/lib/relaton/iso/data_fetcher.rb +3 -3
  160. data/lib/relaton/iso/data_parser.rb +17 -3
  161. data/lib/relaton/iso/hit_collection.rb +27 -15
  162. data/lib/relaton/iso/item_data.rb +22 -0
  163. data/lib/relaton/iso/model/docidentifier.rb +24 -12
  164. data/lib/relaton/iso/processor.rb +1 -0
  165. data/lib/relaton/iso/scraper.rb +19 -3
  166. data/lib/relaton/itu/bibliography.rb +9 -4
  167. data/lib/relaton/itu/data_crawler_r.rb +664 -0
  168. data/lib/relaton/itu/data_fetcher.rb +496 -50
  169. data/lib/relaton/itu/data_merge_r.rb +149 -0
  170. data/lib/relaton/itu/data_parser_r.rb +163 -89
  171. data/lib/relaton/itu/data_parser_t.rb +228 -0
  172. data/lib/relaton/itu/family_cache.rb +177 -0
  173. data/lib/relaton/itu/governor.rb +56 -0
  174. data/lib/relaton/itu/hit.rb +9 -3
  175. data/lib/relaton/itu/hit_collection.rb +258 -86
  176. data/lib/relaton/itu/model/docidentifier.rb +67 -1
  177. data/lib/relaton/itu/model/structured_identifier.rb +19 -0
  178. data/lib/relaton/itu/processor.rb +10 -4
  179. data/lib/relaton/itu/pubid.rb +27 -5
  180. data/lib/relaton/itu/recommendation_fields.rb +334 -0
  181. data/lib/relaton/itu/recommendation_parser.rb +18 -149
  182. data/lib/relaton/itu/scraper.rb +13 -3
  183. data/lib/relaton/itu.rb +2 -1
  184. data/lib/relaton/jcgm/bibdata.rb +8 -0
  185. data/lib/relaton/jcgm/bibitem.rb +8 -0
  186. data/lib/relaton/jcgm/bibliography.rb +97 -0
  187. data/lib/relaton/jcgm/data_fetcher.rb +81 -0
  188. data/lib/relaton/jcgm/docidentifier.rb +102 -0
  189. data/lib/relaton/jcgm/doctype.rb +12 -0
  190. data/lib/relaton/jcgm/ext.rb +23 -0
  191. data/lib/relaton/jcgm/item.rb +20 -0
  192. data/lib/relaton/jcgm/item_base.rb +18 -0
  193. data/lib/relaton/jcgm/item_data.rb +6 -0
  194. data/lib/relaton/jcgm/meetings_parser.rb +175 -0
  195. data/lib/relaton/jcgm/processor.rb +71 -0
  196. data/lib/relaton/jcgm/relation.rb +9 -0
  197. data/lib/relaton/jcgm/structured_identifier.rb +40 -0
  198. data/lib/relaton/jcgm/util.rb +8 -0
  199. data/lib/relaton/jcgm.rb +24 -0
  200. data/lib/relaton/jis/bibliography.rb +8 -10
  201. data/lib/relaton/jis/data_fetcher.rb +21 -19
  202. data/lib/relaton/jis/docidentifier.rb +104 -5
  203. data/lib/relaton/jis/hit.rb +18 -23
  204. data/lib/relaton/jis/hit_collection.rb +19 -18
  205. data/lib/relaton/jis/processor.rb +1 -1
  206. data/lib/relaton/jis.rb +2 -3
  207. data/lib/relaton/logger/channels/gh_issue.rb +78 -13
  208. data/lib/relaton/nist/data_fetcher.rb +63 -13
  209. data/lib/relaton/nist/docidentifier.rb +165 -0
  210. data/lib/relaton/nist/item.rb +2 -0
  211. data/lib/relaton/nist/item_base.rb +16 -0
  212. data/lib/relaton/nist/mods_parser.rb +38 -12
  213. data/lib/relaton/nist/processor.rb +2 -1
  214. data/lib/relaton/nist/relation.rb +3 -0
  215. data/lib/relaton/nist/scraper.rb +6 -3
  216. data/lib/relaton/oasis/bibliography.rb +147 -6
  217. data/lib/relaton/oasis/data_fetcher.rb +41 -5
  218. data/lib/relaton/oasis/data_parser_utils.rb +37 -3
  219. data/lib/relaton/oasis/docidentifier.rb +54 -0
  220. data/lib/relaton/oasis/item.rb +3 -0
  221. data/lib/relaton/oasis/processor.rb +7 -1
  222. data/lib/relaton/oasis.rb +14 -1
  223. data/lib/relaton/ogc/data_fetcher.rb +23 -2
  224. data/lib/relaton/ogc/docidentifier.rb +105 -0
  225. data/lib/relaton/ogc/hit_collection.rb +78 -3
  226. data/lib/relaton/ogc/processor.rb +2 -1
  227. data/lib/relaton/ogc.rb +5 -1
  228. data/lib/relaton/oiml/bibliography.rb +90 -15
  229. data/lib/relaton/oiml/docidentifier.rb +18 -3
  230. data/lib/relaton/omg/docidentifier.rb +67 -0
  231. data/lib/relaton/omg/item.rb +1 -0
  232. data/lib/relaton/omg/processor.rb +1 -0
  233. data/lib/relaton/omg/scraper.rb +61 -16
  234. data/lib/relaton/omg.rb +1 -0
  235. data/lib/relaton/plateau/bibliography.rb +10 -3
  236. data/lib/relaton/plateau/data_fetcher.rb +25 -2
  237. data/lib/relaton/plateau/handbook_parser.rb +8 -1
  238. data/lib/relaton/plateau/hit.rb +10 -2
  239. data/lib/relaton/plateau/hit_collection.rb +31 -11
  240. data/lib/relaton/plateau/processor.rb +3 -1
  241. data/lib/relaton/plateau/technical_report_parser.rb +8 -1
  242. data/lib/relaton/plateau.rb +2 -1
  243. data/lib/relaton/sdo/config.rb +34 -0
  244. data/lib/relaton/sdo/fetcher.rb +52 -0
  245. data/lib/relaton/sdo/logo.rb +95 -0
  246. data/lib/relaton/sdo/name.rb +26 -0
  247. data/lib/relaton/sdo/organization.rb +71 -0
  248. data/lib/relaton/sdo/store.rb +49 -0
  249. data/lib/relaton/sdo.rb +29 -0
  250. data/lib/relaton/version.rb +1 -1
  251. data/lib/relaton/w3c/bibliography.rb +132 -12
  252. data/lib/relaton/w3c/data_fetcher.rb +194 -16
  253. data/lib/relaton/w3c/data_parser.rb +3 -3
  254. data/lib/relaton/w3c/docidentifier.rb +48 -0
  255. data/lib/relaton/w3c/governor.rb +32 -0
  256. data/lib/relaton/w3c/item.rb +3 -0
  257. data/lib/relaton/w3c/pubid.rb +12 -0
  258. data/lib/relaton/w3c/safe_realize.rb +110 -21
  259. data/lib/relaton/w3c.rb +12 -1
  260. data/lib/relaton/xsf/bibliography.rb +61 -1
  261. data/lib/relaton/xsf/data_fetcher.rb +55 -5
  262. data/lib/relaton/xsf/docidentifier.rb +46 -0
  263. data/lib/relaton/xsf/hit_collection.rb +31 -3
  264. data/lib/relaton/xsf/item.rb +6 -0
  265. data/lib/relaton/xsf/processor.rb +1 -0
  266. data/lib/relaton/xsf.rb +5 -1
  267. data/lib/relaton.rb +42 -0
  268. metadata +135 -24
  269. data/lib/relaton/ieee/pub_id.rb +0 -161
  270. data/lib/relaton/index/id_number.rb +0 -30
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time" # Time.parse, for an HTTP-date Retry-After
4
+
5
+ module Relaton
6
+ module Core
7
+ #
8
+ # Process-wide, thread-safe rate-limit back-pressure for a crawl.
9
+ #
10
+ # Promoted here from `Relaton::W3c::Governor`, which is now a thin binding
11
+ # of it, when the ITU-R crawler became the second flavor to need
12
+ # cross-thread throttling. What stays with a flavor is only which errors
13
+ # mean "throttled" (THROTTLE_ERRORS / .throttle?) and which env vars tune it
14
+ # (ENV_PREFIX); the ladder, the jitter, the give-up latch and the
15
+ # Retry-After parsing are shared.
16
+ #
17
+ # The problem it solves, as first met on api.w3.org: that host sits behind
18
+ # Cloudflare, whose rate limiting is bimodal — a run either never trips it
19
+ # or trips it and stays banned for hours. Before this
20
+ # existed, each of the crawl's workers reacted to its own 429s in isolation
21
+ # with a backoff capped at ~31s — far too short to outlast the ban, and the
22
+ # retries themselves kept the limiter engaged. The Aug-2026 crawl spent 4h56m
23
+ # doing nothing but re-tripping the limiter, permanently discarding 1,412
24
+ # documents, before it aborted.
25
+ #
26
+ # The governor gives the whole worker pool one shared cooldown. When any
27
+ # thread sees a 429 it opens (or extends) that cooldown; every other thread
28
+ # observes it in #wait. Cooldowns escalate per *round* — 60s, 120s, ... to a
29
+ # 15-minute cap — and after GIVE_UP_AFTER consecutive rounds without a single
30
+ # success the crawl is declared rate-limited and gives up (~15 min) so CI
31
+ # fails fast instead of grinding for five hours.
32
+ #
33
+ # `clock:` and `sleeper:` are injectable so specs exercise the real
34
+ # arithmetic without wall-clock waits.
35
+ #
36
+ class Governor
37
+ # First cooldown, in seconds. Deliberately minute-scale: a Cloudflare ban
38
+ # window outlasts any second-scale backoff.
39
+ BASE_COOLDOWN = 60
40
+
41
+ # Ceiling for a single cooldown, including a server-supplied Retry-After
42
+ # (which must not be able to park a worker for an hour).
43
+ MAX_COOLDOWN = 900
44
+
45
+ # Consecutive throttle rounds with no intervening success before the crawl
46
+ # is considered banned. With the ladder above that is ~15 min of cooldown
47
+ # (60+120+240+480 s; the fifth round latches before its own wait).
48
+ GIVE_UP_AFTER = 5
49
+
50
+ # Fraction of the cooldown added as random jitter, so the workers do not
51
+ # all resume on the same instant and immediately re-trip the limiter.
52
+ JITTER_FRACTION = 0.1
53
+
54
+ # Which errors mean "you are being rate-limited", as opposed to "this
55
+ # resource is broken". Core ships none: the distinction is the one thing
56
+ # only the flavor knows, and getting it wrong in either direction is
57
+ # expensive — a broken resource routed here opens a pool-wide cooldown
58
+ # for one bad record, and a throttle routed the other way gets recorded
59
+ # as permanent data loss (which is exactly what the W3C crawl did before
60
+ # this class existed). Subclasses override the constant, or #throttle?
61
+ # when the signal is a status code rather than an exception class.
62
+ THROTTLE_ERRORS = [].freeze
63
+
64
+ # Prefix for the three duration knobs, so two flavors crawling in one
65
+ # process cannot share a cooldown ladder by accident. A subclass sets its
66
+ # own ("RELATON_W3C", "RELATON_ITU"); the suffixes are fixed.
67
+ ENV_PREFIX = "RELATON"
68
+
69
+ # Whether this error means the server is rate-limiting us.
70
+ #
71
+ # Class membership is the common case. A flavor whose server signals
72
+ # throttling with a *status code* on a shared exception class (ITU: 503 on
73
+ # /rec) overrides this instead of the constant.
74
+ #
75
+ # @param error [StandardError]
76
+ # @return [Boolean]
77
+ def self.throttle?(error)
78
+ self::THROTTLE_ERRORS.any? { |klass| error.is_a? klass }
79
+ end
80
+
81
+ attr_reader :base, :max, :give_up_after, :env_prefix
82
+
83
+ def initialize(base: nil, max: nil, give_up_after: nil,
84
+ clock: nil, sleeper: nil, jitter: nil, env_prefix: nil)
85
+ @env_prefix = env_prefix || self.class::ENV_PREFIX
86
+ # Remembered so #reset! can re-read the env knobs for the next crawl
87
+ # without discarding values a caller passed explicitly.
88
+ @overrides = { base: base, max: max, give_up_after: give_up_after }
89
+ load_limits
90
+ @clock = clock || -> { Time.now }
91
+ @sleeper = sleeper || ->(secs) { sleep secs }
92
+ @jitter = jitter || ->(secs) { Kernel.rand * secs * self.class::JITTER_FRACTION }
93
+ @mutex = Mutex.new
94
+ @cooldown_until = nil
95
+ @penalty = nil
96
+ @rounds = 0
97
+ @events = 0
98
+ @abandoned = false
99
+ @abandoned_rounds = nil
100
+ end
101
+
102
+ #
103
+ # Block until the shared cooldown expires. Called before every request.
104
+ #
105
+ # The mutex is never held across the sleep — the deadline is read under
106
+ # the lock and the sleeping happens outside it, so a cooling-down worker
107
+ # cannot block another thread from recording its own throttle.
108
+ #
109
+ # @return [Numeric] seconds actually slept (0.0 when there was nothing to
110
+ # wait for, including once the crawl has given up)
111
+ #
112
+ def wait
113
+ slept = 0.0
114
+ waited_for = nil
115
+ loop do
116
+ # Once we have given up there is nothing left to wait for, and stalling
117
+ # here would delay the shutdown by up to a full cooldown.
118
+ deadline, remaining = @mutex.synchronize do
119
+ gave_up? ? [nil, 0.0] : [@cooldown_until, cooldown_remaining]
120
+ end
121
+ break unless remaining.positive?
122
+ # Re-check after sleeping so a cooldown *extended* mid-round (a later
123
+ # Retry-After asking for longer) also holds back the workers already
124
+ # sleeping — they would otherwise wake on the stale deadline and
125
+ # re-trip the limiter. Looping only on a later deadline keeps a stalled
126
+ # clock from spinning here.
127
+ break if waited_for && deadline && deadline <= waited_for
128
+
129
+ waited_for = deadline
130
+ delay = remaining + @jitter.call(remaining)
131
+ @sleeper.call(delay)
132
+ slept += delay
133
+ end
134
+ slept
135
+ end
136
+
137
+ #
138
+ # Record a 429 and open/extend the shared cooldown.
139
+ #
140
+ # @param retry_after [Integer, nil] seconds from the response's Retry-After
141
+ #
142
+ # @return [Boolean] true when the crawl should give up
143
+ #
144
+ def throttled!(retry_after: nil)
145
+ @mutex.synchronize do
146
+ now = @clock.call
147
+ @events += 1
148
+ if @cooldown_until.nil? || now >= @cooldown_until
149
+ open_round(now, retry_after)
150
+ elsif retry_after
151
+ # Mid-round, the server may still ask for longer than we planned.
152
+ extend_round(now + capped(retry_after))
153
+ end
154
+ # Giving up latches. Otherwise a straggler worker succeeding after the
155
+ # producer already stopped paginating would clear @rounds and let the
156
+ # crawl save the truncated index it just decided to abandon.
157
+ #
158
+ # @rounds is snapshotted at the same moment, because #succeeded!
159
+ # resets it: read afterwards it names whatever has re-accumulated
160
+ # since, not the threshold that was crossed. The abort message is
161
+ # the only place an operator learns why the crawl stopped.
162
+ unless @abandoned
163
+ @abandoned = @rounds >= @give_up_after
164
+ @abandoned_rounds = @rounds if @abandoned
165
+ end
166
+ @abandoned
167
+ end
168
+ end
169
+
170
+ #
171
+ # Record a successful request: the limiter has released us.
172
+ #
173
+ def succeeded!
174
+ @mutex.synchronize do
175
+ @rounds = 0
176
+ @cooldown_until = nil
177
+ # Decay rather than reset, so a limiter that keeps flapping still
178
+ # climbs the ladder instead of restarting at `base` every time.
179
+ @penalty = @penalty && @penalty > @base ? [@penalty / 2.0, @base].max : nil
180
+ end
181
+ end
182
+
183
+ # @return [Boolean] give_up_after consecutive rounds passed with no success
184
+ def exhausted?
185
+ @mutex.synchronize { gave_up? }
186
+ end
187
+
188
+ # Clear all state for a fresh crawl. Resets in place rather than handing
189
+ # back a new object so callers (and specs) holding this instance keep
190
+ # observing the live governor.
191
+ def reset!
192
+ @mutex.synchronize do
193
+ load_limits
194
+ @cooldown_until = nil
195
+ @penalty = nil
196
+ @rounds = 0
197
+ @events = 0
198
+ @abandoned = false
199
+ @abandoned_rounds = nil
200
+ end
201
+ end
202
+
203
+ # @return [Integer] consecutive throttle rounds since the last success
204
+ def throttle_rounds
205
+ @mutex.synchronize { @rounds }
206
+ end
207
+
208
+ # The consecutive-round count as it stood when the give-up latched, for
209
+ # the abort message. Distinct from #throttle_rounds, which stays LIVE
210
+ # because a crawler logs it per retry as the current round (see
211
+ # Relaton::Itu::DataCrawlerR) and #succeeded! resets it.
212
+ #
213
+ # @return [Integer, nil] nil while the crawl is still running
214
+ def give_up_rounds
215
+ @mutex.synchronize { @abandoned_rounds }
216
+ end
217
+
218
+ # @return [Integer] every 429 observed, for reporting
219
+ def throttle_count
220
+ @mutex.synchronize { @events }
221
+ end
222
+
223
+ #
224
+ # Seconds requested by a rate-limited response's Retry-After header.
225
+ #
226
+ # lutaml-hal >= 0.2.5 carries a `{ status:, headers: }` hash on every error
227
+ # mapped from a response (nil for locally raised ones), so anything without
228
+ # one yields nil.
229
+ #
230
+ # @param error [StandardError]
231
+ #
232
+ # @return [Integer, nil]
233
+ #
234
+ def self.retry_after(error)
235
+ return nil unless error.respond_to?(:response)
236
+
237
+ response = error.response
238
+ headers = response.is_a?(Hash) ? (response[:headers] || response["headers"]) : nil
239
+ return nil unless headers.respond_to?(:[])
240
+
241
+ seconds_from headers["retry-after"] || headers["Retry-After"]
242
+ end
243
+
244
+ #
245
+ # Retry-After is either delta-seconds or an HTTP-date (RFC 9110). The date
246
+ # form is parsed properly rather than by scanning digits, which would pick
247
+ # up the day-of-month and back off for a wildly wrong duration; a date
248
+ # already in the past yields 0, i.e. "no extra floor".
249
+ #
250
+ def self.seconds_from(value)
251
+ str = value.to_s.strip
252
+ return nil if str.empty?
253
+ return str.to_i if str.match?(/\A\d+\z/)
254
+
255
+ begin
256
+ [(Time.parse(str) - Time.now).ceil, 0].max
257
+ rescue ArgumentError
258
+ nil
259
+ end
260
+ end
261
+
262
+ # A blank or non-positive override is treated as unset.
263
+ def self.env_int(name, default)
264
+ value = ENV[name].to_s.strip
265
+ return default if value.empty?
266
+
267
+ parsed = value.to_i
268
+ parsed.positive? ? parsed : default
269
+ end
270
+
271
+ private
272
+
273
+ # "<PREFIX>_THROTTLE_<SUFFIX>", the one thing ENV_PREFIX exists for.
274
+ def env_name(suffix)
275
+ "#{@env_prefix}_THROTTLE_#{suffix}"
276
+ end
277
+
278
+ # Read the duration knobs, letting explicit constructor args win over the
279
+ # environment. Re-read on #reset! so the env vars behave like a flavor's
280
+ # sibling knobs, which are class methods evaluated per crawl rather than
281
+ # frozen when the flavor was autoloaded.
282
+ def load_limits
283
+ @base = @overrides[:base] || self.class.env_int(env_name("BASE"), self.class::BASE_COOLDOWN)
284
+ @max = @overrides[:max] || self.class.env_int(env_name("MAX"), self.class::MAX_COOLDOWN)
285
+ @give_up_after = @overrides[:give_up_after] ||
286
+ self.class.env_int(env_name("GIVEUP"), self.class::GIVE_UP_AFTER)
287
+ end
288
+
289
+ # Start a new escalation step. Retry-After raises the floor but never
290
+ # lowers our own ladder — the server's hint is a minimum, not a discount.
291
+ def open_round(now, retry_after)
292
+ @rounds += 1
293
+ ladder = @penalty ? [@penalty * 2, @max].min : @base
294
+ hint = retry_after ? capped(retry_after) : 0
295
+ @penalty = [ladder, hint].max
296
+ @cooldown_until = now + @penalty
297
+ end
298
+
299
+ def extend_round(deadline)
300
+ @cooldown_until = deadline if deadline > @cooldown_until
301
+ end
302
+
303
+ def cooldown_remaining
304
+ return 0.0 unless @cooldown_until
305
+
306
+ [@cooldown_until - @clock.call, 0.0].max
307
+ end
308
+
309
+ def capped(seconds)
310
+ [seconds, @max].min
311
+ end
312
+
313
+ # Callers must already hold the mutex. Latched by #throttled! — never
314
+ # recomputed from @rounds, which #succeeded! resets.
315
+ def gave_up?
316
+ @abandoned
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relaton
4
+ module Core
5
+ #
6
+ # A process-wide, thread-safe request pacer: the whole worker pool between
7
+ # them starts **at most one request per `gap` seconds**.
8
+ #
9
+ # It exists because the two natural alternatives are both wrong for a
10
+ # crawler that has to be polite to one host:
11
+ #
12
+ # * `sleep delay` before each request (what `Relaton::Itu::DataCrawlerR`
13
+ # did) **adds** the delay to the server's own latency. At ITU's measured
14
+ # ~1.1 s response time a 1 s delay yields one request every ~2.1 s, so a
15
+ # ~7k-request crawl spends ~2 h of a ~4 h run doing nothing at all — and
16
+ # the crawl is pinned to one thread, because the pacing *is* the loop.
17
+ # * a per-worker gap (`Relaton::Cie::DataFetcher::Pacing`) lets N workers
18
+ # issue N requests per gap. That is right for CIE, where the point is to
19
+ # let one struggling worker slow itself without dragging the others down,
20
+ # and wrong here, where the budget belongs to the host.
21
+ #
22
+ # This reserves a *slot* instead: each caller takes the next free instant on
23
+ # a shared timeline and sleeps only until it arrives. The server's latency
24
+ # therefore counts toward the gap rather than being added on top, so the
25
+ # rate the host sees is exactly `1 / gap` no matter how many workers there
26
+ # are — and workers exist only to hide latency, never to raise the rate.
27
+ #
28
+ # The mutex is held for the reservation and **never across the sleep**, so a
29
+ # waiting worker cannot stop another from reserving.
30
+ #
31
+ # Idle time does not bank up slots: a reservation never starts earlier than
32
+ # "now", so a stall elsewhere in the crawl (a governor cooldown, a slow
33
+ # merge) cannot be repaid afterwards as a burst — which is exactly the burst
34
+ # that would re-trip the limiter that caused the stall.
35
+ #
36
+ # `clock:` and `sleeper:` are injectable so specs exercise the arithmetic
37
+ # without wall-clock waits (mirroring `Relaton::Core::Governor`).
38
+ #
39
+ class Pacer
40
+ # How the gap is spent.
41
+ #
42
+ # :slot (default) the gap is the minimum interval between request
43
+ # *starts*, shared by the pool, so the host's own latency counts
44
+ # toward it. Peak rate 1/gap regardless of worker count.
45
+ # :fixed sleep the whole gap before every request, whatever else is
46
+ # happening. This is what a plain `sleep delay` loop did, kept
47
+ # as a rollback knob: with one worker it reproduces the old
48
+ # request pattern exactly — one request per (gap + latency).
49
+ DEFAULT_MODE = :slot
50
+
51
+ # @param gap [Numeric] minimum seconds between request *starts*. Zero
52
+ # disables pacing entirely (used by specs and by cassette replay).
53
+ # @param mode [Symbol] :slot or :fixed, see DEFAULT_MODE
54
+ # @param clock [#call, nil] returns monotonic seconds
55
+ # @param sleeper [#call, nil] receives seconds to sleep
56
+ def initialize(gap:, mode: DEFAULT_MODE, clock: nil, sleeper: nil)
57
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
58
+ @sleeper = sleeper || ->(secs) { sleep secs }
59
+ @mutex = Mutex.new
60
+ @gap = coerce_gap(gap)
61
+ @mode = mode == :fixed ? :fixed : DEFAULT_MODE
62
+ @next = nil
63
+ @requests = 0
64
+ @slept = 0.0
65
+ end
66
+
67
+ attr_reader :gap, :mode
68
+
69
+ # @return [Integer] requests paced so far, for the run summary
70
+ def requests
71
+ @mutex.synchronize { @requests }
72
+ end
73
+
74
+ # @return [Float] total seconds spent waiting, for the run summary
75
+ def slept
76
+ @mutex.synchronize { @slept }
77
+ end
78
+
79
+ # @param secs [Numeric] the new minimum gap; takes effect on the next
80
+ # reservation. A negative value is clamped to zero.
81
+ def gap=(secs)
82
+ @mutex.synchronize { @gap = coerce_gap(secs) }
83
+ end
84
+
85
+ # Clear the timeline and the counters, so the next caller goes out
86
+ # immediately. For specs, and for a crawl starting a fresh phase.
87
+ def reset!
88
+ @mutex.synchronize do
89
+ @next = nil
90
+ @requests = 0
91
+ @slept = 0.0
92
+ end
93
+ end
94
+
95
+ #
96
+ # Reserve the next slot and block until it arrives.
97
+ #
98
+ # @return [Float] seconds actually slept (0.0 when the slot was already
99
+ # due, which is the common case once the host is slower than the gap)
100
+ #
101
+ def wait
102
+ delay = @mode == :fixed ? @mutex.synchronize { @gap } : (reserve - @clock.call)
103
+ @mutex.synchronize { @requests += 1 }
104
+ return 0.0 unless delay.positive?
105
+
106
+ @sleeper.call delay
107
+ @mutex.synchronize { @slept += delay }
108
+ delay
109
+ end
110
+
111
+ private
112
+
113
+ # Claim the next instant on the shared timeline and advance it. Held under
114
+ # the lock; the waiting happens outside.
115
+ #
116
+ # @return [Float] the instant this caller may start its request
117
+ def reserve
118
+ @mutex.synchronize do
119
+ now = @clock.call
120
+ # `max(now)` is what stops an idle stretch from banking up slots: the
121
+ # timeline catches up to the present rather than paying out a burst.
122
+ @next = @next.nil? || @next < now ? now : @next
123
+ reserved = @next
124
+ @next += @gap
125
+ reserved
126
+ end
127
+ end
128
+
129
+ def coerce_gap(secs)
130
+ [secs.to_f, 0.0].max
131
+ end
132
+ end
133
+ end
134
+ end
@@ -13,6 +13,25 @@ module Relaton
13
13
  # @return [Array<String>]
14
14
  attr_reader :datasets
15
15
 
16
+ # Global document-ID prefixes this flavor owns, e.g. BSI => %w[BS BSI DD …].
17
+ # The source of truth is **pubid**: a flavor sets @pubid_flavor to its Pubid
18
+ # module name (e.g. :Iso) in #initialize, and the prefixes are read from
19
+ # `Pubid::<Flavor>.prefixes` — the SDO's own leading identifier tokens,
20
+ # including non-obvious ones (BSI `DD`) and joint forms (`ISO/IEC`). Flavors
21
+ # with no pubid backing fall back to the single canonical @prefix. Loaded
22
+ # lazily (pubid is only required on first call) and memoized. Feeds the
23
+ # global prefix register (Relaton.prefix_flavor). (relaton-db#103)
24
+ #
25
+ # @return [Array<String>]
26
+ def prefixes
27
+ @prefixes ||= if @pubid_flavor
28
+ require "pubid"
29
+ ::Pubid.const_get(@pubid_flavor).prefixes
30
+ else
31
+ Array(prefix)
32
+ end
33
+ end
34
+
16
35
  def initialize
17
36
  raise "This is an abstract class!"
18
37
  end
@@ -0,0 +1,14 @@
1
+ module Relaton
2
+ # Transport failure while fetching remote data; `Relaton::Db#net_retry`
3
+ # retries it. It depends on nothing, so `relaton/index` requires this file
4
+ # alone and does not load the rest of `relaton/core`.
5
+ #
6
+ # The class is named `Relaton::RequestError`, not `Relaton::Core::...`: an
7
+ # error raised without a message uses the class name as its message, and
8
+ # relaton-cli prints that message.
9
+ class RequestError < StandardError; end
10
+
11
+ module Core
12
+ RequestError = Relaton::RequestError
13
+ end
14
+ end
data/lib/relaton/core.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "relaton/logger"
2
2
  require_relative "version"
3
+ require_relative "core/request_error"
3
4
  require_relative "core/array_wrapper"
4
5
  require_relative "core/hash_keys_sybolizer"
5
6
  require_relative "core/date_parser"
@@ -7,3 +8,5 @@ require_relative "core/processor"
7
8
  require_relative "core/data_fetcher"
8
9
  require_relative "core/hit_collection"
9
10
  require_relative "core/workers_pool"
11
+ require_relative "core/pacer"
12
+ require_relative "core/governor"
@@ -11,7 +11,8 @@ module Relaton
11
11
  relaton/bsi relaton/cen relaton/iana relaton/3gpp
12
12
  relaton/oasis relaton/doi relaton/jis relaton/xsf
13
13
  relaton/ccsds relaton/etsi relaton/isbn relaton/plateau
14
- relaton/oiml
14
+ relaton/oiml relaton/jcgm relaton/easc relaton/gost relaton/adobe
15
+ relaton/iala
15
16
  ].freeze
16
17
 
17
18
  include Singleton
@@ -107,8 +108,47 @@ module Relaton
107
108
  nil
108
109
  end
109
110
 
111
+ #
112
+ # Global prefix register (relaton-db#103): all processors that own the
113
+ # given global prefix, matched case-insensitively and exactly (unlike
114
+ # #class_by_ref, which matches a prefix at the start of a full reference).
115
+ # Ordered by registration order (SUPPORTED_GEMS), so results are
116
+ # deterministic. Lazy: never dereferences a flavor constant.
117
+ #
118
+ # @param prefix [String]
119
+ # @return [Array<Relaton::Core::Processor>]
120
+ #
121
+ def processors_by_prefix(prefix)
122
+ key = prefix.to_s.strip.upcase
123
+ processors.values.select do |processor|
124
+ processor.prefixes.any? { |pref| pref.upcase == key }
125
+ end
126
+ end
127
+
128
+ #
129
+ # Flavor modules (e.g. Relaton::Iso) that own the given global prefix.
130
+ # NOTE: dereferencing the returned module forces that flavor's lazy load.
131
+ # Callers that must stay lazy should use #processors_by_prefix instead.
132
+ #
133
+ # @param prefix [String]
134
+ # @return [Array<Module>]
135
+ #
136
+ def flavors_by_prefix(prefix)
137
+ processors_by_prefix(prefix).map { |processor| flavor_module(processor) }
138
+ end
139
+
110
140
  private
111
141
 
142
+ # The flavor namespace for a processor: the module enclosing its class.
143
+ # Relaton::Iso::Processor -> Relaton::Iso. Derived from the class name so
144
+ # there's no @short string-munging and no 3gpp special case.
145
+ #
146
+ # @param processor [Relaton::Core::Processor]
147
+ # @return [Module]
148
+ def flavor_module(processor)
149
+ Object.const_get processor.class.name.split("::")[0..-2].join("::")
150
+ end
151
+
112
152
  def gem_to_module_path(gem_name)
113
153
  gem_name.split("/").map do |part|
114
154
  part.capitalize.sub("3gpp", "ThreeGpp")
@@ -49,7 +49,7 @@ module Relaton
49
49
  end
50
50
 
51
51
  n += 1
52
- sleep resp.response["x-rate-limit-interval"].to_i * n
52
+ sleep backoff(resp.response, n)
53
53
  rescue Mechanize::ResponseCodeError => e
54
54
  return nil if e.response_code == "404"
55
55
 
@@ -58,10 +58,27 @@ module Relaton
58
58
  end
59
59
 
60
60
  n += 1
61
- sleep e.page.response["x-rate-limit-interval"].to_i * n
61
+ sleep backoff(e.page.response, n)
62
62
  end
63
63
  end
64
64
 
65
+ #
66
+ # Seconds to wait before retry n. Crossref sends X-Rate-Limit-Interval as
67
+ # "1s", but a throttled response can carry no rate-limit headers at all —
68
+ # the 429s seen here had only Date/Content-Length/Connection. Without the
69
+ # floor that degenerates to `sleep 0`, i.e. hammering the endpoint that
70
+ # just asked us to slow down.
71
+ #
72
+ # @param [Hash, nil] headers The response headers.
73
+ # @param [Integer] num The attempt number.
74
+ #
75
+ # @return [Integer] Delay in seconds, at least 1.
76
+ #
77
+ def backoff(headers, num)
78
+ interval = headers.to_h["x-rate-limit-interval"].to_s[/\d+/].to_i
79
+ [interval, 1].max * num
80
+ end
81
+
65
82
  def agent
66
83
  @agent ||= Mechanize.new do |a|
67
84
  a.user_agent = USER_AGENT