tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,303 +1,303 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- # Context object passed to each service handler, giving it control
5
- # over its own lifecycle and metadata.
6
- class ServiceContext
7
- attr_accessor :running, :last_run, :name, :error_count
8
-
9
- def initialize(name)
10
- @running = true
11
- @last_run = nil
12
- @name = name
13
- @error_count = 0
14
- end
15
- end
16
-
17
- # In-process service runner using Ruby threads.
18
- # Supports cron schedules, simple intervals, and daemon (self-looping) handlers.
19
- #
20
- # Tina4::ServiceRunner.register("cleanup", timing: "*/5 * * * *") { |ctx| ... }
21
- # Tina4::ServiceRunner.register("poller", interval: 10) { |ctx| ... }
22
- # Tina4::ServiceRunner.register("worker", daemon: true) { |ctx| while ctx.running; ...; end }
23
- # Tina4::ServiceRunner.start
24
- #
25
- class ServiceRunner
26
- @registry = {} # name => { handler:, options: }
27
- @threads = {} # name => Thread
28
- @contexts = {} # name => ServiceContext
29
- @mutex = Mutex.new
30
-
31
- class << self
32
- # ── Registration ──────────────────────────────────────────────────
33
-
34
- # Register a named service with options and a handler block (or callable).
35
- #
36
- # Options:
37
- # timing: cron expression, e.g. "*/5 * * * *"
38
- # interval: run every N seconds
39
- # daemon: boolean — handler manages its own loop
40
- # max_retries: restart limit on crash (default 3)
41
- def register(name, handler = nil, options = {}, &block)
42
- callable = handler || block
43
- raise ArgumentError, "provide a handler or block for service '#{name}'" unless callable
44
-
45
- @mutex.synchronize do
46
- @registry[name.to_s] = { handler: callable, options: options }
47
- end
48
- Tina4::Log.debug("Service registered: #{name}")
49
- end
50
-
51
- # Auto-discover service files from a directory.
52
- # Each file should call Tina4.service or Tina4::ServiceRunner.register.
53
- def discover(service_dir = nil)
54
- service_dir ||= ENV["TINA4_SERVICE_DIR"] || "src/services"
55
- full_dir = File.expand_path(service_dir, Tina4.root_dir || Dir.pwd)
56
- return unless Dir.exist?(full_dir)
57
-
58
- Dir.glob(File.join(full_dir, "**/*.rb")).sort.each do |file|
59
- begin
60
- load file
61
- Tina4::Log.debug("Service discovered: #{file}")
62
- rescue => e
63
- Tina4::Log.error("Failed to load service #{file}: #{e.message}")
64
- end
65
- end
66
- end
67
-
68
- # ── Lifecycle ─────────────────────────────────────────────────────
69
-
70
- # Start all registered services, or a specific one by name.
71
- def start(name = nil)
72
- targets = if name
73
- entry = @registry[name.to_s]
74
- raise KeyError, "service '#{name}' not registered" unless entry
75
- { name.to_s => entry }
76
- else
77
- @registry.dup
78
- end
79
-
80
- targets.each do |svc_name, entry|
81
- next if @threads[svc_name]&.alive?
82
-
83
- ctx = ServiceContext.new(svc_name)
84
- @mutex.synchronize { @contexts[svc_name] = ctx }
85
-
86
- thread = Thread.new { run_loop(svc_name, entry[:handler], entry[:options], ctx) }
87
- thread.name = "tina4-service-#{svc_name}" if thread.respond_to?(:name=)
88
- @mutex.synchronize { @threads[svc_name] = thread }
89
-
90
- Tina4::Log.info("Service started: #{svc_name}")
91
- end
92
- end
93
-
94
- # Stop all running services, or a specific one by name.
95
- def stop(name = nil)
96
- targets = if name
97
- ctx = @contexts[name.to_s]
98
- ctx ? { name.to_s => ctx } : {}
99
- else
100
- @contexts.dup
101
- end
102
-
103
- targets.each do |svc_name, ctx|
104
- ctx.running = false
105
- Tina4::Log.info("Service stopping: #{svc_name}")
106
- end
107
-
108
- # Join threads with a timeout so we don't hang forever
109
- targets.each_key do |svc_name|
110
- thread = @threads[svc_name]
111
- next unless thread
112
-
113
- thread.join(5)
114
- @mutex.synchronize do
115
- @threads.delete(svc_name)
116
- @contexts.delete(svc_name)
117
- end
118
- end
119
- end
120
-
121
- # List all registered services with their status.
122
- def list
123
- @registry.map do |name, entry|
124
- ctx = @contexts[name]
125
- {
126
- name: name,
127
- options: entry[:options],
128
- running: ctx&.running == true && @threads[name]&.alive? == true,
129
- last_run: ctx&.last_run,
130
- error_count: ctx&.error_count || 0
131
- }
132
- end
133
- end
134
-
135
- # Check if a specific service is currently running.
136
- def is_running(name)
137
- ctx = @contexts[name.to_s]
138
- ctx&.running == true && @threads[name.to_s]&.alive? == true
139
- end
140
-
141
- # Remove all registrations and stop all services. Useful for tests.
142
- def clear!
143
- stop
144
- @mutex.synchronize do
145
- @registry.clear
146
- @threads.clear
147
- @contexts.clear
148
- end
149
- end
150
-
151
- # ── Cron matching ─────────────────────────────────────────────────
152
-
153
- # Check whether a 5-field cron pattern matches a given Time.
154
- # Fields: minute hour day_of_month month day_of_week
155
- def match_cron?(pattern, time = Time.now)
156
- fields = pattern.strip.split(/\s+/)
157
- return false unless fields.length == 5
158
-
159
- minute, hour, dom, month, dow = fields
160
-
161
- parse_cron_field(minute, time.min, 59) &&
162
- parse_cron_field(hour, time.hour, 23) &&
163
- parse_cron_field(dom, time.day, 31) &&
164
- parse_cron_field(month, time.month, 12) &&
165
- parse_cron_field(dow, time.wday, 7)
166
- end
167
-
168
- private
169
-
170
- # ── Run loop ──────────────────────────────────────────────────────
171
-
172
- def run_loop(name, handler, options, ctx)
173
- max_retries = options.fetch(:max_retries, 3)
174
- sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 5).to_i.to_f
175
-
176
- if options[:daemon]
177
- run_daemon(name, handler, options, ctx, max_retries)
178
- elsif options[:timing]
179
- run_cron(name, handler, options[:timing], ctx, max_retries, sleep_interval)
180
- elsif options[:interval]
181
- run_interval(name, handler, options[:interval], ctx, max_retries)
182
- else
183
- # One-shot: run handler once
184
- run_handler(name, handler, ctx)
185
- end
186
- rescue => e
187
- Tina4::Log.error("Service '#{name}' loop crashed: #{e.message}")
188
- ensure
189
- ctx.running = false
190
- end
191
-
192
- # Daemon mode: handler manages its own loop, we just call it.
193
- def run_daemon(name, handler, _options, ctx, max_retries)
194
- retries = 0
195
- while ctx.running && retries <= max_retries
196
- begin
197
- run_handler(name, handler, ctx)
198
- break # normal exit
199
- rescue => e
200
- retries += 1
201
- ctx.error_count += 1
202
- Tina4::Log.error("Service '#{name}' daemon crashed (#{retries}/#{max_retries}): #{e.message}")
203
- break if retries > max_retries
204
- sleep(1) if ctx.running
205
- end
206
- end
207
- end
208
-
209
- # Cron mode: check every sleep_interval seconds, fire when pattern matches.
210
- def run_cron(name, handler, pattern, ctx, max_retries, sleep_interval)
211
- last_fired_minute = nil
212
- retries = 0
213
-
214
- while ctx.running
215
- now = Time.now
216
- current_minute = [now.year, now.month, now.day, now.hour, now.min]
217
-
218
- if match_cron?(pattern, now) && current_minute != last_fired_minute
219
- last_fired_minute = current_minute
220
- begin
221
- run_handler(name, handler, ctx)
222
- retries = 0
223
- rescue => e
224
- retries += 1
225
- ctx.error_count += 1
226
- Tina4::Log.error("Service '#{name}' cron failed (#{retries}/#{max_retries}): #{e.message}")
227
- break if retries > max_retries
228
- end
229
- end
230
-
231
- sleep(sleep_interval) if ctx.running
232
- end
233
- end
234
-
235
- # Interval mode: simple sleep(N) between invocations.
236
- def run_interval(name, handler, interval, ctx, max_retries)
237
- retries = 0
238
-
239
- while ctx.running
240
- begin
241
- run_handler(name, handler, ctx)
242
- retries = 0
243
- rescue => e
244
- retries += 1
245
- ctx.error_count += 1
246
- Tina4::Log.error("Service '#{name}' interval failed (#{retries}/#{max_retries}): #{e.message}")
247
- break if retries > max_retries
248
- end
249
-
250
- # Sleep in small increments so stop is responsive
251
- remaining = interval.to_f
252
- while remaining > 0 && ctx.running
253
- nap = [remaining, 0.25].min
254
- sleep(nap)
255
- remaining -= nap
256
- end
257
- end
258
- end
259
-
260
- # Execute the handler and update context.
261
- def run_handler(_name, handler, ctx)
262
- handler.call(ctx)
263
- ctx.last_run = Time.now
264
- end
265
-
266
- # ── Cron field parsing ────────────────────────────────────────────
267
-
268
- # Parse a single cron field and check if `current` matches.
269
- # * — always matches
270
- # */N — every N (step)
271
- # 1,5,10 — list of values
272
- # 1-5 — range
273
- # N — exact value
274
- def parse_cron_field(field, current, max)
275
- return true if field == "*"
276
-
277
- # Step: */N
278
- if field.start_with?("*/")
279
- step = field[2..].to_i
280
- return false if step <= 0
281
- return (current % step).zero?
282
- end
283
-
284
- # List: 1,5,10
285
- if field.include?(",")
286
- values = field.split(",").map(&:to_i)
287
- return values.include?(current)
288
- end
289
-
290
- # Range: 1-5
291
- if field.include?("-")
292
- parts = field.split("-")
293
- low = parts[0].to_i
294
- high = parts[1].to_i
295
- return (low..high).include?(current)
296
- end
297
-
298
- # Exact value
299
- field.to_i == current
300
- end
301
- end
302
- end
303
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Context object passed to each service handler, giving it control
5
+ # over its own lifecycle and metadata.
6
+ class ServiceContext
7
+ attr_accessor :running, :last_run, :name, :error_count
8
+
9
+ def initialize(name)
10
+ @running = true
11
+ @last_run = nil
12
+ @name = name
13
+ @error_count = 0
14
+ end
15
+ end
16
+
17
+ # In-process service runner using Ruby threads.
18
+ # Supports cron schedules, simple intervals, and daemon (self-looping) handlers.
19
+ #
20
+ # Tina4::ServiceRunner.register("cleanup", timing: "*/5 * * * *") { |ctx| ... }
21
+ # Tina4::ServiceRunner.register("poller", interval: 10) { |ctx| ... }
22
+ # Tina4::ServiceRunner.register("worker", daemon: true) { |ctx| while ctx.running; ...; end }
23
+ # Tina4::ServiceRunner.start
24
+ #
25
+ class ServiceRunner
26
+ @registry = {} # name => { handler:, options: }
27
+ @threads = {} # name => Thread
28
+ @contexts = {} # name => ServiceContext
29
+ @mutex = Mutex.new
30
+
31
+ class << self
32
+ # ── Registration ──────────────────────────────────────────────────
33
+
34
+ # Register a named service with options and a handler block (or callable).
35
+ #
36
+ # Options:
37
+ # timing: cron expression, e.g. "*/5 * * * *"
38
+ # interval: run every N seconds
39
+ # daemon: boolean — handler manages its own loop
40
+ # max_retries: restart limit on crash (default 3)
41
+ def register(name, handler = nil, options = {}, &block)
42
+ callable = handler || block
43
+ raise ArgumentError, "provide a handler or block for service '#{name}'" unless callable
44
+
45
+ @mutex.synchronize do
46
+ @registry[name.to_s] = { handler: callable, options: options }
47
+ end
48
+ Tina4::Log.debug("Service registered: #{name}")
49
+ end
50
+
51
+ # Auto-discover service files from a directory.
52
+ # Each file should call Tina4.service or Tina4::ServiceRunner.register.
53
+ def discover(service_dir = nil)
54
+ service_dir ||= ENV["TINA4_SERVICE_DIR"] || "src/services"
55
+ full_dir = File.expand_path(service_dir, Tina4.root_dir || Dir.pwd)
56
+ return unless Dir.exist?(full_dir)
57
+
58
+ Dir.glob(File.join(full_dir, "**/*.rb")).sort.each do |file|
59
+ begin
60
+ load file
61
+ Tina4::Log.debug("Service discovered: #{file}")
62
+ rescue => e
63
+ Tina4::Log.error("Failed to load service #{file}: #{e.message}")
64
+ end
65
+ end
66
+ end
67
+
68
+ # ── Lifecycle ─────────────────────────────────────────────────────
69
+
70
+ # Start all registered services, or a specific one by name.
71
+ def start(name = nil)
72
+ targets = if name
73
+ entry = @registry[name.to_s]
74
+ raise KeyError, "service '#{name}' not registered" unless entry
75
+ { name.to_s => entry }
76
+ else
77
+ @registry.dup
78
+ end
79
+
80
+ targets.each do |svc_name, entry|
81
+ next if @threads[svc_name]&.alive?
82
+
83
+ ctx = ServiceContext.new(svc_name)
84
+ @mutex.synchronize { @contexts[svc_name] = ctx }
85
+
86
+ thread = Thread.new { run_loop(svc_name, entry[:handler], entry[:options], ctx) }
87
+ thread.name = "tina4-service-#{svc_name}" if thread.respond_to?(:name=)
88
+ @mutex.synchronize { @threads[svc_name] = thread }
89
+
90
+ Tina4::Log.info("Service started: #{svc_name}")
91
+ end
92
+ end
93
+
94
+ # Stop all running services, or a specific one by name.
95
+ def stop(name = nil)
96
+ targets = if name
97
+ ctx = @contexts[name.to_s]
98
+ ctx ? { name.to_s => ctx } : {}
99
+ else
100
+ @contexts.dup
101
+ end
102
+
103
+ targets.each do |svc_name, ctx|
104
+ ctx.running = false
105
+ Tina4::Log.info("Service stopping: #{svc_name}")
106
+ end
107
+
108
+ # Join threads with a timeout so we don't hang forever
109
+ targets.each_key do |svc_name|
110
+ thread = @threads[svc_name]
111
+ next unless thread
112
+
113
+ thread.join(5)
114
+ @mutex.synchronize do
115
+ @threads.delete(svc_name)
116
+ @contexts.delete(svc_name)
117
+ end
118
+ end
119
+ end
120
+
121
+ # List all registered services with their status.
122
+ def list
123
+ @registry.map do |name, entry|
124
+ ctx = @contexts[name]
125
+ {
126
+ name: name,
127
+ options: entry[:options],
128
+ running: ctx&.running == true && @threads[name]&.alive? == true,
129
+ last_run: ctx&.last_run,
130
+ error_count: ctx&.error_count || 0
131
+ }
132
+ end
133
+ end
134
+
135
+ # Check if a specific service is currently running.
136
+ def is_running(name)
137
+ ctx = @contexts[name.to_s]
138
+ ctx&.running == true && @threads[name.to_s]&.alive? == true
139
+ end
140
+
141
+ # Remove all registrations and stop all services. Useful for tests.
142
+ def clear!
143
+ stop
144
+ @mutex.synchronize do
145
+ @registry.clear
146
+ @threads.clear
147
+ @contexts.clear
148
+ end
149
+ end
150
+
151
+ # ── Cron matching ─────────────────────────────────────────────────
152
+
153
+ # Check whether a 5-field cron pattern matches a given Time.
154
+ # Fields: minute hour day_of_month month day_of_week
155
+ def match_cron?(pattern, time = Time.now)
156
+ fields = pattern.strip.split(/\s+/)
157
+ return false unless fields.length == 5
158
+
159
+ minute, hour, dom, month, dow = fields
160
+
161
+ parse_cron_field(minute, time.min, 59) &&
162
+ parse_cron_field(hour, time.hour, 23) &&
163
+ parse_cron_field(dom, time.day, 31) &&
164
+ parse_cron_field(month, time.month, 12) &&
165
+ parse_cron_field(dow, time.wday, 7)
166
+ end
167
+
168
+ private
169
+
170
+ # ── Run loop ──────────────────────────────────────────────────────
171
+
172
+ def run_loop(name, handler, options, ctx)
173
+ max_retries = options.fetch(:max_retries, 3)
174
+ sleep_interval = (ENV["TINA4_SERVICE_SLEEP"] || 5).to_i.to_f
175
+
176
+ if options[:daemon]
177
+ run_daemon(name, handler, options, ctx, max_retries)
178
+ elsif options[:timing]
179
+ run_cron(name, handler, options[:timing], ctx, max_retries, sleep_interval)
180
+ elsif options[:interval]
181
+ run_interval(name, handler, options[:interval], ctx, max_retries)
182
+ else
183
+ # One-shot: run handler once
184
+ run_handler(name, handler, ctx)
185
+ end
186
+ rescue => e
187
+ Tina4::Log.error("Service '#{name}' loop crashed: #{e.message}")
188
+ ensure
189
+ ctx.running = false
190
+ end
191
+
192
+ # Daemon mode: handler manages its own loop, we just call it.
193
+ def run_daemon(name, handler, _options, ctx, max_retries)
194
+ retries = 0
195
+ while ctx.running && retries <= max_retries
196
+ begin
197
+ run_handler(name, handler, ctx)
198
+ break # normal exit
199
+ rescue => e
200
+ retries += 1
201
+ ctx.error_count += 1
202
+ Tina4::Log.error("Service '#{name}' daemon crashed (#{retries}/#{max_retries}): #{e.message}")
203
+ break if retries > max_retries
204
+ sleep(1) if ctx.running
205
+ end
206
+ end
207
+ end
208
+
209
+ # Cron mode: check every sleep_interval seconds, fire when pattern matches.
210
+ def run_cron(name, handler, pattern, ctx, max_retries, sleep_interval)
211
+ last_fired_minute = nil
212
+ retries = 0
213
+
214
+ while ctx.running
215
+ now = Time.now
216
+ current_minute = [now.year, now.month, now.day, now.hour, now.min]
217
+
218
+ if match_cron?(pattern, now) && current_minute != last_fired_minute
219
+ last_fired_minute = current_minute
220
+ begin
221
+ run_handler(name, handler, ctx)
222
+ retries = 0
223
+ rescue => e
224
+ retries += 1
225
+ ctx.error_count += 1
226
+ Tina4::Log.error("Service '#{name}' cron failed (#{retries}/#{max_retries}): #{e.message}")
227
+ break if retries > max_retries
228
+ end
229
+ end
230
+
231
+ sleep(sleep_interval) if ctx.running
232
+ end
233
+ end
234
+
235
+ # Interval mode: simple sleep(N) between invocations.
236
+ def run_interval(name, handler, interval, ctx, max_retries)
237
+ retries = 0
238
+
239
+ while ctx.running
240
+ begin
241
+ run_handler(name, handler, ctx)
242
+ retries = 0
243
+ rescue => e
244
+ retries += 1
245
+ ctx.error_count += 1
246
+ Tina4::Log.error("Service '#{name}' interval failed (#{retries}/#{max_retries}): #{e.message}")
247
+ break if retries > max_retries
248
+ end
249
+
250
+ # Sleep in small increments so stop is responsive
251
+ remaining = interval.to_f
252
+ while remaining > 0 && ctx.running
253
+ nap = [remaining, 0.25].min
254
+ sleep(nap)
255
+ remaining -= nap
256
+ end
257
+ end
258
+ end
259
+
260
+ # Execute the handler and update context.
261
+ def run_handler(_name, handler, ctx)
262
+ handler.call(ctx)
263
+ ctx.last_run = Time.now
264
+ end
265
+
266
+ # ── Cron field parsing ────────────────────────────────────────────
267
+
268
+ # Parse a single cron field and check if `current` matches.
269
+ # * — always matches
270
+ # */N — every N (step)
271
+ # 1,5,10 — list of values
272
+ # 1-5 — range
273
+ # N — exact value
274
+ def parse_cron_field(field, current, max)
275
+ return true if field == "*"
276
+
277
+ # Step: */N
278
+ if field.start_with?("*/")
279
+ step = field[2..].to_i
280
+ return false if step <= 0
281
+ return (current % step).zero?
282
+ end
283
+
284
+ # List: 1,5,10
285
+ if field.include?(",")
286
+ values = field.split(",").map(&:to_i)
287
+ return values.include?(current)
288
+ end
289
+
290
+ # Range: 1-5
291
+ if field.include?("-")
292
+ parts = field.split("-")
293
+ low = parts[0].to_i
294
+ high = parts[1].to_i
295
+ return (low..high).include?(current)
296
+ end
297
+
298
+ # Exact value
299
+ field.to_i == current
300
+ end
301
+ end
302
+ end
303
+ end