mxrb 0.1.2 → 0.1.4

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 (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'securerandom'
5
+ require_relative 'shared_store'
6
+
7
+ begin
8
+ require 'tzinfo'
9
+ rescue LoadError
10
+ # UTC, local time and numeric offsets remain available without tzinfo. IANA
11
+ # names fail explicitly when resolved instead of silently becoming UTC.
12
+ end
13
+
14
+ # Schedule normalization and lifecycle state deliberately live together so a
15
+ # Scheduler instance owns all of its threads and overlap bookkeeping.
16
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
17
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
18
+
19
+ module Mxrb
20
+ module Runtime
21
+ ScheduledJob = Data.define(
22
+ :name, :qualified_name, :microflow, :schedule, :overlap, :enabled,
23
+ :start_at, :time_zone, :source
24
+ )
25
+
26
+ # Small stdlib-only scheduled-event runner. Execution, time, and sleeping
27
+ # are injectable so applications can connect it to their Ruby microflow
28
+ # runtime and test it without waiting for wall-clock time.
29
+ class Scheduler
30
+ attr_reader :jobs, :errors
31
+
32
+ def initialize(project, executor: nil, clock: -> { Time.now.utc },
33
+ sleeper: Kernel.method(:sleep), poll_interval: 1.0,
34
+ skip_overlap: true, async: true, on_error: nil, logger: nil,
35
+ coordinator: nil, lease_ttl: 300, owner: nil)
36
+ raise ArgumentError, 'poll_interval must be positive' unless poll_interval.to_f.positive?
37
+ raise ArgumentError, 'lease_ttl must be positive' unless lease_ttl.to_f.positive?
38
+
39
+ @project = project
40
+ @executor = executor || default_executor
41
+ @clock = clock
42
+ @sleeper = sleeper
43
+ @poll_interval = poll_interval.to_f
44
+ @skip_overlap = skip_overlap
45
+ @async = async
46
+ @on_error = on_error
47
+ @logger = logger
48
+ @coordinator = coordinator || MemorySharedStore.new
49
+ @lease_ttl = lease_ttl.to_f
50
+ @owner = owner || "#{Process.pid}-#{SecureRandom.uuid}"
51
+ @time_zones = {}
52
+ @jobs = load_jobs.freeze
53
+ @errors = []
54
+ @last_slots = {}
55
+ @running_jobs = {}
56
+ @workers = []
57
+ @mutex = Mutex.new
58
+ @stopping = false
59
+ @thread = nil
60
+ end
61
+
62
+ def running? = @mutex.synchronize { @thread&.alive? == true }
63
+
64
+ def start
65
+ @mutex.synchronize do
66
+ return self if @thread&.alive?
67
+
68
+ @stopping = false
69
+ @thread = Thread.new { run_loop }
70
+ @thread.name = 'mxrb-scheduler' if @thread.respond_to?(:name=)
71
+ end
72
+ self
73
+ end
74
+
75
+ def shutdown(wait: true)
76
+ thread = @mutex.synchronize do
77
+ @stopping = true
78
+ @thread
79
+ end
80
+ thread&.join if wait && thread != Thread.current
81
+ wait_for_jobs if wait
82
+ @mutex.synchronize { @thread = nil unless @thread&.alive? }
83
+ self
84
+ end
85
+ alias stop shutdown
86
+
87
+ # Evaluates all jobs once and returns the jobs that were dispatched.
88
+ def tick(now = @clock.call, async: @async)
89
+ cleanup_workers
90
+ jobs.filter_map do |job|
91
+ slot = due_slot(job, now)
92
+ next unless slot
93
+ next unless reserve(job, slot, now)
94
+
95
+ dispatch(job, slot, async:)
96
+ job
97
+ end
98
+ end
99
+
100
+ def wait_for_jobs
101
+ loop do
102
+ workers = @mutex.synchronize { @workers.select(&:alive?) }
103
+ break if workers.empty?
104
+
105
+ workers.each { _1.join unless _1 == Thread.current }
106
+ end
107
+ cleanup_workers
108
+ self
109
+ end
110
+
111
+ private
112
+
113
+ def default_executor
114
+ return @project.method(:execute_microflow) if @project.respond_to?(:execute_microflow)
115
+
116
+ raise ArgumentError, 'executor is required unless project responds to execute_microflow'
117
+ end
118
+
119
+ def load_jobs
120
+ @project.modules.flat_map do |mod|
121
+ Array(mod.scheduled_events).map { normalize_job(mod.name, _1) }
122
+ end
123
+ end
124
+
125
+ def normalize_job(module_name, source)
126
+ name = fetch(source, 'Name', :name).to_s
127
+ qualified = qualify(module_name, fetch(source, 'QualifiedName', :qualified_name) || name)
128
+ microflow = qualify(module_name, fetch(source, 'Microflow', :microflow))
129
+ schedule = normalize_schedule(source)
130
+ ScheduledJob.new(
131
+ name:, qualified_name: qualified, microflow:, schedule:,
132
+ overlap: fetch(source, 'OnOverlap', :on_overlap, :overlap) || 'SkipNext',
133
+ enabled: fetch(source, 'Enabled', :enabled) != false,
134
+ start_at: parse_time(fetch(source, 'StartDateTime', :start_at)),
135
+ time_zone: fetch(source, 'TimeZone', :time_zone).to_s,
136
+ source:
137
+ )
138
+ end
139
+
140
+ def normalize_schedule(source)
141
+ schedule = fetch(source, 'Schedule', :schedule)
142
+ type = fetch(schedule, '$Type', :type).to_s
143
+ case type
144
+ when /MinuteSchedule\z/
145
+ { type: :minute, every: positive(fetch(schedule, 'Multiplier', :multiplier)) }.freeze
146
+ when /HourSchedule\z/
147
+ {
148
+ type: :hour, every: positive(fetch(schedule, 'Multiplier', :multiplier)),
149
+ minute: integer(fetch(schedule, 'MinuteOffset', :minute_offset), 0) % 60
150
+ }.freeze
151
+ when /DaySchedule\z/
152
+ {
153
+ type: :day, every: 1,
154
+ hour: integer(fetch(schedule, 'HourOfDay', :hour_of_day), 0) % 24,
155
+ minute: integer(fetch(schedule, 'MinuteOfHour', :minute_of_hour), 0) % 60
156
+ }.freeze
157
+ else
158
+ legacy_schedule(source)
159
+ end
160
+ end
161
+
162
+ def legacy_schedule(source)
163
+ type = fetch(source, 'IntervalType', :interval_type).to_s.downcase
164
+ every = positive(fetch(source, 'Interval', :interval))
165
+ case type
166
+ when 'minute' then { type: :minute, every: }.freeze
167
+ when 'hour' then { type: :hour, every:, minute: 0 }.freeze
168
+ when 'day' then { type: :day, every:, hour: 0, minute: 0 }.freeze
169
+ else
170
+ raise ArgumentError, "unsupported scheduled-event interval #{type.inspect}"
171
+ end
172
+ end
173
+
174
+ def due_slot(job, now)
175
+ return unless job.enabled
176
+
177
+ time = event_time(now, job.time_zone)
178
+ start = job.start_at && event_time(job.start_at, job.time_zone)
179
+ return if start && time < start
180
+
181
+ schedule = job.schedule
182
+ case schedule.fetch(:type)
183
+ when :minute
184
+ minute = time.to_i / 60
185
+ anchor = start ? start.to_i / 60 : 0
186
+ minute if ((minute - anchor) % schedule.fetch(:every)).zero?
187
+ when :hour
188
+ return unless time.min == schedule.fetch(:minute)
189
+
190
+ hour = time.to_i / 3600
191
+ anchor = start ? start.to_i / 3600 : 0
192
+ hour if ((hour - anchor) % schedule.fetch(:every)).zero?
193
+ when :day
194
+ return unless time.hour == schedule.fetch(:hour) && time.min == schedule.fetch(:minute)
195
+
196
+ day = time.to_date.jd
197
+ anchor = start ? start.to_date.jd : 0
198
+ day if ((day - anchor) % schedule.fetch(:every)).zero?
199
+ end
200
+ end
201
+
202
+ def reserve(job, slot, now)
203
+ @mutex.synchronize do
204
+ return false if @last_slots[job.qualified_name] == slot
205
+ return false if skip_overlap?(job) && @running_jobs[job.qualified_name]
206
+
207
+ slot_key = "#{job.schedule.fetch(:type)}:#{slot}"
208
+ claimed = @coordinator.claim_scheduled_event(
209
+ event: job.qualified_name, slot: slot_key, owner: @owner,
210
+ now:, lease_until: now + @lease_ttl, skip_overlap: skip_overlap?(job)
211
+ )
212
+ return false unless claimed
213
+
214
+ @last_slots[job.qualified_name] = slot
215
+ @running_jobs[job.qualified_name] = slot_key
216
+ true
217
+ end
218
+ end
219
+
220
+ def skip_overlap?(job)
221
+ @skip_overlap || job.overlap.to_s.match?(/skip/i)
222
+ end
223
+
224
+ def dispatch(job, slot = nil, async:)
225
+ slot_key = slot && "#{job.schedule.fetch(:type)}:#{slot}"
226
+ slot_key ||= @running_jobs[job.qualified_name] || "manual:#{SecureRandom.uuid}"
227
+ unless async
228
+ execute(job, slot_key)
229
+ return
230
+ end
231
+
232
+ worker = Thread.new { execute(job, slot_key) }
233
+ worker.name = "mxrb-job-#{job.qualified_name}" if worker.respond_to?(:name=)
234
+ @mutex.synchronize { @workers << worker }
235
+ end
236
+
237
+ def execute(job, slot)
238
+ heartbeat = start_heartbeat(job, slot)
239
+ invoke_executor(job)
240
+ rescue StandardError => e
241
+ @mutex.synchronize { @errors << [job, e].freeze }
242
+ @on_error&.call(job, e)
243
+ @logger&.error("scheduled event #{job.qualified_name} failed: #{e.message}")
244
+ ensure
245
+ stop_heartbeat(heartbeat)
246
+ begin
247
+ @coordinator.complete_scheduled_event(
248
+ event: job.qualified_name, slot:, owner: @owner
249
+ )
250
+ rescue StandardError => e
251
+ @logger&.error("scheduled event #{job.qualified_name} lease completion failed: #{e.message}")
252
+ ensure
253
+ @mutex.synchronize { @running_jobs.delete(job.qualified_name) }
254
+ end
255
+ end
256
+
257
+ def start_heartbeat(job, slot)
258
+ return unless @coordinator.respond_to?(:renew_scheduled_event)
259
+
260
+ state = { mutex: Mutex.new, condition: ConditionVariable.new, stopping: false }
261
+ state[:thread] = Thread.start do
262
+ heartbeat_loop(job, slot, state)
263
+ end
264
+ state[:thread].name = "mxrb-lease-#{job.qualified_name}"
265
+ state
266
+ end
267
+
268
+ def heartbeat_loop(job, slot, state)
269
+ interval = [@lease_ttl / 3.0, 0.01].max
270
+ loop do
271
+ stopping = state.fetch(:mutex).synchronize do
272
+ if state.fetch(:stopping)
273
+ true
274
+ else
275
+ state.fetch(:condition).wait(state.fetch(:mutex), interval)
276
+ state.fetch(:stopping)
277
+ end
278
+ end
279
+ break if stopping
280
+
281
+ now = @clock.call
282
+ renewed = @coordinator.renew_scheduled_event(
283
+ event: job.qualified_name, slot:, owner: @owner,
284
+ lease_until: now + @lease_ttl
285
+ )
286
+ break unless renewed
287
+ end
288
+ rescue StandardError => e
289
+ @logger&.error("scheduled event #{job.qualified_name} lease renewal failed: #{e.message}")
290
+ end
291
+
292
+ def stop_heartbeat(state)
293
+ return unless state
294
+
295
+ state.fetch(:mutex).synchronize do
296
+ state[:stopping] = true
297
+ state.fetch(:condition).broadcast
298
+ end
299
+ state.fetch(:thread).join
300
+ end
301
+
302
+ def invoke_executor(job)
303
+ callable = if @executor.respond_to?(:execute_microflow)
304
+ @executor.method(:execute_microflow)
305
+ elsif @executor.respond_to?(:call)
306
+ @executor
307
+ else
308
+ raise ArgumentError, 'executor must be callable or implement execute_microflow'
309
+ end
310
+ case callable.arity
311
+ when 0 then callable.call
312
+ when 1 then callable.call(job.microflow)
313
+ else callable.call(job.microflow, scheduled_event: job)
314
+ end
315
+ end
316
+
317
+ def run_loop
318
+ until stopping?
319
+ tick
320
+ @sleeper.call(@poll_interval) unless stopping?
321
+ end
322
+ rescue StandardError => e
323
+ @mutex.synchronize { @errors << [nil, e].freeze }
324
+ @on_error&.call(nil, e)
325
+ @logger&.error("scheduler failed: #{e.message}")
326
+ ensure
327
+ @mutex.synchronize { @thread = nil if @thread == Thread.current }
328
+ end
329
+
330
+ def stopping? = @mutex.synchronize { @stopping }
331
+
332
+ def cleanup_workers
333
+ @mutex.synchronize do
334
+ @workers.each { _1.join if !_1.alive? && _1 != Thread.current }
335
+ @workers.select!(&:alive?)
336
+ end
337
+ end
338
+
339
+ def event_time(time, zone)
340
+ return time.getutc if zone.empty? || zone.casecmp('UTC').zero?
341
+ return time.getlocal if zone.casecmp('local').zero?
342
+ return time.getlocal(zone) if zone.match?(/\A[+-]\d{2}:?\d{2}\z/)
343
+
344
+ time_zone(zone).utc_to_local(time.getutc)
345
+ end
346
+
347
+ def time_zone(name)
348
+ unless defined?(TZInfo::Timezone)
349
+ raise ArgumentError,
350
+ "IANA time zone #{name.inspect} requires the tzinfo gem"
351
+ end
352
+
353
+ @time_zones[name] ||= TZInfo::Timezone.get(name)
354
+ rescue StandardError => e
355
+ raise unless defined?(TZInfo::InvalidTimezoneIdentifier) &&
356
+ e.is_a?(TZInfo::InvalidTimezoneIdentifier)
357
+
358
+ raise ArgumentError, "unknown scheduled-event time zone #{name.inspect}"
359
+ end
360
+
361
+ def parse_time(raw)
362
+ return raw if raw.respond_to?(:to_time)
363
+ return if raw.nil? || raw.to_s.empty?
364
+
365
+ Time.iso8601(raw.to_s)
366
+ rescue ArgumentError
367
+ nil
368
+ end
369
+
370
+ def qualify(module_name, raw)
371
+ name = raw.to_s
372
+ return name if name.empty? || name.include?('.')
373
+
374
+ "#{module_name}.#{name}"
375
+ end
376
+
377
+ def fetch(hash, *keys)
378
+ return nil unless hash.respond_to?(:key?)
379
+
380
+ keys.each { return hash[_1] if hash.key?(_1) }
381
+ nil
382
+ end
383
+
384
+ def positive(value)
385
+ number = integer(value, 1)
386
+ raise ArgumentError, 'scheduled-event interval must be positive' unless number.positive?
387
+
388
+ number
389
+ end
390
+
391
+ def integer(value, fallback) = value.nil? ? fallback : Integer(value)
392
+ end
393
+ end
394
+ end
395
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
396
+ # rubocop:enable Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity