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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/service_runner.rb
CHANGED
|
@@ -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
|