Class: Tina4::ServiceRunner

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/service_runner.rb

Overview

In-process service runner using Ruby threads. Supports cron schedules, simple intervals, and daemon (self-looping) handlers.

Tina4::ServiceRunner.register("cleanup", timing: "*/5 * * * *") { |ctx| ... }
Tina4::ServiceRunner.register("poller", interval: 10) { |ctx| ... }
Tina4::ServiceRunner.register("worker", daemon: true) { |ctx| while ctx.running; ...; end }
Tina4::ServiceRunner.start

Class Method Summary collapse

Class Method Details

.clear!Object

Remove all registrations and stop all services. Useful for tests.



203
204
205
206
207
208
209
210
# File 'lib/tina4/service_runner.rb', line 203

def clear!
  stop
  @mutex.synchronize do
    @registry.clear
    @threads.clear
    @contexts.clear
  end
end

.discover(service_dir = nil) ⇒ Object

Auto-discover service files from a directory. Each file should call Tina4.service or Tina4::ServiceRunner.register.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/tina4/service_runner.rb', line 88

def discover(service_dir = nil)
  service_dir ||= ENV["TINA4_SERVICE_DIR"] || "src/services"
  full_dir = File.expand_path(service_dir, Tina4.root_dir || Dir.pwd)
  return unless Dir.exist?(full_dir)

  Dir.glob(File.join(full_dir, "**/*.rb")).sort.each do |file|
    begin
      load file
      Tina4::Log.debug("Service discovered: #{file}")
    rescue => e
      Tina4::Log.error("Failed to load service #{file}: #{e.message}")
    end
  end
end

.is_running(name) ⇒ Object

Check if a specific service is currently running.



197
198
199
200
# File 'lib/tina4/service_runner.rb', line 197

def is_running(name)
  ctx = @contexts[name.to_s]
  ctx&.running == true && @threads[name.to_s]&.alive? == true
end

.listObject

List all registered services with their status.



183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/tina4/service_runner.rb', line 183

def list
  @registry.map do |name, entry|
    ctx = @contexts[name]
    {
      name: name,
      options: entry[:options],
      running: ctx&.running == true && @threads[name]&.alive? == true,
      last_run: ctx&.last_run,
      error_count: ctx&.error_count || 0
    }
  end
end

.match_cron?(pattern, time = Time.now) ⇒ Boolean

Check whether a 5-field cron pattern matches a given Time. Fields: minute hour day_of_month month day_of_week

Returns:

  • (Boolean)


216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/tina4/service_runner.rb', line 216

def match_cron?(pattern, time = Time.now)
  fields = pattern.strip.split(/\s+/)
  return false unless fields.length == 5

  minute, hour, dom, month, dow = fields

  parse_cron_field(minute, time.min, 59) &&
    parse_cron_field(hour, time.hour, 23) &&
    parse_cron_field(dom, time.day, 31) &&
    parse_cron_field(month, time.month, 12) &&
    parse_cron_field(dow, time.wday, 7)
end

.register(name, handler = nil, options = {}, &block) ⇒ Object

Register a named service with options and a handler block (or callable).

Options:

timing:      cron expression, e.g. "*/5 * * * *"
interval:    run every N seconds
daemon:      boolean — handler manages its own loop
max_retries: restart limit on crash (default 3)

Raises:

  • (ArgumentError)


41
42
43
44
45
46
47
48
49
# File 'lib/tina4/service_runner.rb', line 41

def register(name, handler = nil, options = {}, &block)
  callable = handler || block
  raise ArgumentError, "provide a handler or block for service '#{name}'" unless callable

  @mutex.synchronize do
    @registry[name.to_s] = { handler: callable, options: options }
  end
  Tina4::Log.debug("Service registered: #{name}")
end

.register_service(name, service, options = {}) ⇒ Object

Register a class-based service (subclass of Tina4::Service) by name.

Wraps the Service's #run method as the handler callable that ServiceRunner.start invokes. The service's #stop is also wired up so ServiceRunner.stop(name) shuts it down cleanly.

class EmailQueueWorker < Tina4::Service
def run
  until should_stop?
    # process work
  end
end
end

Tina4::ServiceRunner.register_service("emails", EmailQueueWorker.new)
Tina4::ServiceRunner.start

Default options set daemon: true because Service subclasses manage their own loop inside #run. Override via options.

Raises:

  • (ArgumentError)


70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/tina4/service_runner.rb', line 70

def register_service(name, service, options = {})
  raise ArgumentError, "service must be a Tina4::Service instance" unless service.is_a?(Tina4::Service)

  options = { daemon: true }.merge(options)
  callable = service.method(:run)

  @mutex.synchronize do
    @registry[name.to_s] = {
      handler:  callable,
      options:  options,
      instance: service,
    }
  end
  Tina4::Log.debug("Service registered (class-based): #{name}")
end

.start(name = nil) ⇒ Object

Start all registered services, or a specific one by name.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/tina4/service_runner.rb', line 106

def start(name = nil)
  targets = if name
              entry = @registry[name.to_s]
              raise KeyError, "service '#{name}' not registered" unless entry
              { name.to_s => entry }
            else
              @registry.dup
            end

  targets.each do |svc_name, entry|
    next if @threads[svc_name]&.alive?

    ctx = ServiceContext.new(svc_name)
    @mutex.synchronize { @contexts[svc_name] = ctx }

    thread = Thread.new { run_loop(svc_name, entry[:handler], entry[:options], ctx) }
    thread.name = "tina4-service-#{svc_name}" if thread.respond_to?(:name=)
    @mutex.synchronize { @threads[svc_name] = thread }

    Tina4::Log.info("Service started: #{svc_name}")
  end
end

.stop(name = nil) ⇒ Object

Stop all running services, or a specific one by name.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/tina4/service_runner.rb', line 130

def stop(name = nil)
  targets = if name
              ctx = @contexts[name.to_s]
              ctx ? { name.to_s => ctx } : {}
            else
              @contexts.dup
            end

  targets.each do |svc_name, ctx|
    ctx.running = false
    Tina4::Log.info("Service stopping: #{svc_name}")
  end

  # Cooperative shutdown of class-based Service instances — parity with
  # tina4-python #118 and tina4-nodejs #58 (ead390c76). Must happen
  # BEFORE thread.join(5) so a subclass's `until should_stop?` loop
  # observes the flip and returns before the join times out. When
  # invoked with a name, also fires for a registered-but-not-yet-
  # started service — `targets` above is @contexts-derived and empty
  # in that case, so without this the docstring's promise
  # (register_service wires up #stop) would be honoured only for
  # started services. Wrapped so one bad user-override #stop cannot
  # strand siblings.
  cooperative_names = if name
                        @registry.key?(name.to_s) ? [name.to_s] : []
                      else
                        targets.keys
                      end
  cooperative_names.each do |svc_name|
    entry = @registry[svc_name]
    next unless entry && entry[:instance].respond_to?(:stop)

    begin
      entry[:instance].stop
    rescue StandardError => e
      Tina4::Log.error("ServiceRunner: #{svc_name}#stop raised: #{e.message}")
    end
  end

  # Join threads with a timeout so we don't hang forever
  targets.each_key do |svc_name|
    thread = @threads[svc_name]
    next unless thread

    thread.join(5)
    @mutex.synchronize do
      @threads.delete(svc_name)
      @contexts.delete(svc_name)
    end
  end
end