tina4ruby 0.5.2 → 3.0.0
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 +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +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 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"] || 1).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
|
data/lib/tina4/session.rb
CHANGED
|
@@ -57,6 +57,56 @@ module Tina4
|
|
|
57
57
|
@data = {}
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Get a session value with optional default
|
|
61
|
+
def get(key, default = nil)
|
|
62
|
+
@data[key.to_s] || default
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Set a session value
|
|
66
|
+
def set(key, value)
|
|
67
|
+
@data[key.to_s] = value
|
|
68
|
+
@modified = true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if a key exists in the session
|
|
72
|
+
def has?(key)
|
|
73
|
+
@data.key?(key.to_s)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Return all session data
|
|
77
|
+
def all
|
|
78
|
+
@data.dup
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Flash data: set a value that is removed after next read.
|
|
82
|
+
# Call with value to set, call without value to get (and remove).
|
|
83
|
+
def flash(key, value = nil)
|
|
84
|
+
flash_key = "_flash_#{key}"
|
|
85
|
+
if value.nil?
|
|
86
|
+
val = @data.delete(flash_key.to_s)
|
|
87
|
+
@modified = true if val
|
|
88
|
+
val
|
|
89
|
+
else
|
|
90
|
+
@data[flash_key.to_s] = value
|
|
91
|
+
@modified = true
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Regenerate the session ID while preserving data
|
|
97
|
+
def regenerate
|
|
98
|
+
old_id = @id
|
|
99
|
+
@id = SecureRandom.hex(32)
|
|
100
|
+
@handler.destroy(old_id)
|
|
101
|
+
@modified = true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Garbage collection: remove expired sessions from the handler
|
|
105
|
+
def gc(max_age = nil)
|
|
106
|
+
max_age ||= @options[:max_age]
|
|
107
|
+
@handler.gc(max_age) if @handler.respond_to?(:gc)
|
|
108
|
+
end
|
|
109
|
+
|
|
60
110
|
def cookie_header
|
|
61
111
|
"#{@options[:cookie_name]}=#{@id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=#{@options[:max_age]}"
|
|
62
112
|
end
|
|
@@ -126,6 +176,41 @@ module Tina4
|
|
|
126
176
|
@session&.destroy
|
|
127
177
|
end
|
|
128
178
|
|
|
179
|
+
def get(key, default = nil)
|
|
180
|
+
ensure_loaded
|
|
181
|
+
@session.get(key, default)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def set(key, value)
|
|
185
|
+
ensure_loaded
|
|
186
|
+
@session.set(key, value)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def has?(key)
|
|
190
|
+
ensure_loaded
|
|
191
|
+
@session.has?(key)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def all
|
|
195
|
+
ensure_loaded
|
|
196
|
+
@session.all
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def flash(key, value = nil)
|
|
200
|
+
ensure_loaded
|
|
201
|
+
@session.flash(key, value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def regenerate
|
|
205
|
+
ensure_loaded
|
|
206
|
+
@session.regenerate
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def gc(max_age = nil)
|
|
210
|
+
ensure_loaded
|
|
211
|
+
@session.gc(max_age)
|
|
212
|
+
end
|
|
213
|
+
|
|
129
214
|
def cookie_header
|
|
130
215
|
ensure_loaded
|
|
131
216
|
@session.cookie_header
|
|
@@ -20,7 +20,7 @@ module Tina4
|
|
|
20
20
|
rescue LoadError
|
|
21
21
|
raise "MongoDB session handler requires the 'mongo' gem. Install with: gem install mongo"
|
|
22
22
|
rescue Mongo::Error => e
|
|
23
|
-
Tina4::
|
|
23
|
+
Tina4::Log.error("MongoDB session setup failed: #{e.message}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def read(session_id)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Tina4
|
|
5
|
+
module SessionHandlers
|
|
6
|
+
class ValkeyHandler
|
|
7
|
+
def initialize(options = {})
|
|
8
|
+
require "redis"
|
|
9
|
+
@prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
|
|
10
|
+
@ttl = options[:ttl] || (ENV["TINA4_SESSION_VALKEY_TTL"] ? ENV["TINA4_SESSION_VALKEY_TTL"].to_i : 86400)
|
|
11
|
+
@redis = Redis.new(
|
|
12
|
+
host: options[:host] || ENV["TINA4_SESSION_VALKEY_HOST"] || "localhost",
|
|
13
|
+
port: options[:port] || (ENV["TINA4_SESSION_VALKEY_PORT"] ? ENV["TINA4_SESSION_VALKEY_PORT"].to_i : 6379),
|
|
14
|
+
db: options[:db] || (ENV["TINA4_SESSION_VALKEY_DB"] ? ENV["TINA4_SESSION_VALKEY_DB"].to_i : 0),
|
|
15
|
+
password: options[:password] || ENV["TINA4_SESSION_VALKEY_PASSWORD"]
|
|
16
|
+
)
|
|
17
|
+
rescue LoadError
|
|
18
|
+
raise "Valkey session handler requires the 'redis' gem (Valkey uses the RESP protocol). Install with: gem install redis"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def read(session_id)
|
|
22
|
+
data = @redis.get("#{@prefix}#{session_id}")
|
|
23
|
+
return nil unless data
|
|
24
|
+
JSON.parse(data)
|
|
25
|
+
rescue JSON::ParserError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(session_id, data)
|
|
30
|
+
key = "#{@prefix}#{session_id}"
|
|
31
|
+
@redis.setex(key, @ttl, JSON.generate(data))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def destroy(session_id)
|
|
35
|
+
@redis.del("#{@prefix}#{session_id}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cleanup
|
|
39
|
+
# Valkey handles TTL automatically (same as Redis)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
module Shutdown
|
|
5
|
+
DEFAULT_TIMEOUT = 30 # seconds
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :in_flight_count
|
|
9
|
+
|
|
10
|
+
def setup(server: nil, timeout: nil)
|
|
11
|
+
@server = server
|
|
12
|
+
@timeout = (timeout || ENV["TINA4_SHUTDOWN_TIMEOUT"] || DEFAULT_TIMEOUT).to_i
|
|
13
|
+
@shutting_down = false
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@in_flight_count = 0
|
|
16
|
+
@in_flight_cv = ConditionVariable.new
|
|
17
|
+
|
|
18
|
+
install_signal_handlers
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def shutting_down?
|
|
22
|
+
@shutting_down
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def track_request
|
|
26
|
+
@mutex.synchronize { @in_flight_count += 1 }
|
|
27
|
+
begin
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@in_flight_count -= 1
|
|
32
|
+
@in_flight_cv.broadcast if @in_flight_count <= 0
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initiate_shutdown
|
|
38
|
+
return if @shutting_down
|
|
39
|
+
|
|
40
|
+
@shutting_down = true
|
|
41
|
+
Tina4::Log.info("Shutdown signal received, stopping gracefully...")
|
|
42
|
+
|
|
43
|
+
# Wait for in-flight requests with timeout
|
|
44
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
while @in_flight_count > 0
|
|
47
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
if remaining <= 0
|
|
49
|
+
Tina4::Log.warning("Shutdown timeout reached with #{@in_flight_count} requests still in flight")
|
|
50
|
+
break
|
|
51
|
+
end
|
|
52
|
+
@in_flight_cv.wait(@mutex, remaining)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Close database connections
|
|
57
|
+
if Tina4.database
|
|
58
|
+
begin
|
|
59
|
+
Tina4.database.close
|
|
60
|
+
Tina4::Log.info("Database connections closed")
|
|
61
|
+
rescue => e
|
|
62
|
+
Tina4::Log.error("Error closing database: #{e.message}")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Tina4::Log.info("Shutdown complete")
|
|
67
|
+
|
|
68
|
+
# Stop the server
|
|
69
|
+
@server&.shutdown if @server.respond_to?(:shutdown)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def install_signal_handlers
|
|
75
|
+
%w[INT TERM].each do |signal|
|
|
76
|
+
Signal.trap(signal) do
|
|
77
|
+
# Signal handlers must be async-signal-safe; use Thread to do real work
|
|
78
|
+
Thread.new { initiate_shutdown }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|