milvaion-worker 1.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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milvaion
4
+ # Batches occurrence logs and sends them from a background thread every flush_interval seconds,
5
+ # or as soon as batch_size entries are waiting. Safe to add to from any thread.
6
+ #
7
+ # A batch that fails to send is kept and sent first next time, so logs keep their order.
8
+ class LogBuffer
9
+ attr_reader :batch_size, :flush_interval, :max_buffered
10
+
11
+ # sender is called with one batch hash ({"logs", "batchTimestamp", "count"}) and raises if it cannot send it.
12
+ def initialize(sender, logger, batch_size: 50, flush_interval: 0.5, max_buffered: 10_000)
13
+ @sender = sender
14
+ @logger = logger
15
+ @batch_size = batch_size
16
+ @flush_interval = flush_interval
17
+ @max_buffered = max_buffered
18
+ @mutex = Mutex.new
19
+ @changed = ConditionVariable.new
20
+ @flush_mutex = Mutex.new
21
+ @buffer = []
22
+ @unsent = nil
23
+ @dropped = 0
24
+ @closed = false
25
+ @thread = nil
26
+ end
27
+
28
+ def start
29
+ @thread ||= Thread.new do
30
+ Thread.current.name = "milvaion-logs"
31
+ run
32
+ end
33
+
34
+ self
35
+ end
36
+
37
+ def add(occurrence_id, instance_id, log)
38
+ @mutex.synchronize do
39
+ if @buffer.size >= max_buffered
40
+ @buffer.shift
41
+ @dropped += 1
42
+ @logger.warn("Log buffer is full; dropped #{@dropped} oldest log(s) so far.") if @dropped % 1000 == 1
43
+ end
44
+
45
+ @buffer << { "occurrenceId" => occurrence_id, "workerId" => instance_id, "log" => log, "messageTimestamp" => Milvaion.utc_now }
46
+ @changed.broadcast
47
+ end
48
+ end
49
+
50
+ def pending
51
+ @mutex.synchronize { @buffer.size + (@unsent&.size || 0) }
52
+ end
53
+
54
+ # Sends everything buffered so far. Raises if a batch cannot be sent; it stays buffered.
55
+ def flush
56
+ @flush_mutex.synchronize do
57
+ per_round = batch_size * 2
58
+ rounds = ((pending + per_round - 1) / per_round) + 1
59
+
60
+ rounds.times do
61
+ batch = @mutex.synchronize do
62
+ if @unsent.nil?
63
+ taken = @buffer.shift(per_round)
64
+ @unsent = taken unless taken.empty?
65
+ end
66
+
67
+ @unsent
68
+ end
69
+
70
+ return if batch.nil?
71
+
72
+ @sender.call({ "logs" => batch, "batchTimestamp" => Milvaion.utc_now, "count" => batch.size })
73
+ @mutex.synchronize { @unsent = nil }
74
+ end
75
+ end
76
+ end
77
+
78
+ def close
79
+ @mutex.synchronize do
80
+ @closed = true
81
+ @changed.broadcast
82
+ end
83
+
84
+ @thread&.join(5)
85
+ @thread = nil
86
+ end
87
+
88
+ private
89
+
90
+ def run
91
+ loop do
92
+ @mutex.synchronize do
93
+ @changed.wait(@mutex) while !@closed && @buffer.empty? && @unsent.nil?
94
+ # Give more entries a moment to arrive, unless a full batch is already waiting.
95
+ deadline = monotonic + flush_interval
96
+
97
+ while !@closed && @buffer.size < batch_size
98
+ remaining = deadline - monotonic
99
+ break if remaining <= 0
100
+
101
+ @changed.wait(@mutex, remaining)
102
+ end
103
+ end
104
+
105
+ break if @mutex.synchronize { @closed }
106
+
107
+ begin
108
+ flush
109
+ rescue StandardError => e
110
+ @logger.debug("Failed to send logs; will retry: #{e.message}")
111
+ Kernel.sleep(flush_interval)
112
+ end
113
+ end
114
+ end
115
+
116
+ def monotonic
117
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "socket"
5
+
6
+ module Milvaion
7
+ # Process resource usage for heartbeats. Inside a Linux container the cgroup figures are used,
8
+ # matching what `docker stats` reports, as the .NET SDK does.
9
+ class ResourceSampler
10
+ def initialize
11
+ @last_cpu = nil
12
+ @last_sample = 0.0
13
+ @cores = Etc.nprocessors
14
+ end
15
+
16
+ def memory_bytes
17
+ cgroup_memory || proc_rss || ps_rss || 0
18
+ end
19
+
20
+ # CPU used since the previous call, summed across cores (one busy core = 100).
21
+ def cpu_usage_percent
22
+ cpu = cgroup_cpu_seconds || Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
23
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
+
25
+ if @last_cpu.nil?
26
+ @last_cpu = cpu
27
+ @last_sample = now
28
+ return 0.0
29
+ end
30
+
31
+ elapsed = now - @last_sample
32
+ used = cpu - @last_cpu
33
+ @last_cpu = cpu
34
+ @last_sample = now
35
+
36
+ return 0.0 if elapsed <= 0
37
+
38
+ (used / elapsed * 100).round(2).clamp(0.0, 100.0 * @cores)
39
+ end
40
+
41
+ # The address used for outbound traffic, or "Unknown".
42
+ def self.local_ip_address
43
+ UDPSocket.open do |socket|
44
+ socket.connect("10.255.255.255", 1) # no packet is sent
45
+ socket.addr.last
46
+ end
47
+ rescue StandardError
48
+ "Unknown"
49
+ end
50
+
51
+ private
52
+
53
+ def read_int(path)
54
+ Integer(File.read(path).strip, 10)
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def read_stat(path, key)
60
+ File.foreach(path) do |line|
61
+ name, value = line.split
62
+ return Integer(value, 10) if name == key && value
63
+ end
64
+
65
+ nil
66
+ rescue StandardError
67
+ nil
68
+ end
69
+
70
+ def cgroup_memory
71
+ current = read_int("/sys/fs/cgroup/memory.current")
72
+ return [0, current - (read_stat("/sys/fs/cgroup/memory.stat", "inactive_file") || 0)].max if current
73
+
74
+ usage = read_int("/sys/fs/cgroup/memory/memory.usage_in_bytes")
75
+ return [0, usage - (read_stat("/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") || 0)].max if usage
76
+
77
+ nil
78
+ end
79
+
80
+ def cgroup_cpu_seconds
81
+ usage = read_stat("/sys/fs/cgroup/cpu.stat", "usage_usec")
82
+ return usage / 1_000_000.0 if usage
83
+
84
+ ["/sys/fs/cgroup/cpu/cpuacct.usage", "/sys/fs/cgroup/cpuacct/cpuacct.usage"].each do |path|
85
+ nanos = read_int(path)
86
+ return nanos / 1_000_000_000.0 if nanos
87
+ end
88
+
89
+ nil
90
+ end
91
+
92
+ def proc_rss
93
+ pages = Integer(File.read("/proc/self/statm").split[1], 10)
94
+ pages * page_size
95
+ rescue StandardError
96
+ nil
97
+ end
98
+
99
+ def page_size
100
+ Etc.sysconf(Etc::SC_PAGESIZE)
101
+ rescue StandardError
102
+ 4096
103
+ end
104
+
105
+ # macOS and other systems without /proc.
106
+ def ps_rss
107
+ return nil if Gem.win_platform?
108
+
109
+ kilobytes = Integer(IO.popen(["ps", "-o", "rss=", "-p", Process.pid.to_s], err: File::NULL, &:read).strip, 10)
110
+ kilobytes * 1024
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "logger"
5
+ require "securerandom"
6
+
7
+ module Milvaion
8
+ # Worker configuration. Only worker_id is required.
9
+ class WorkerOptions
10
+ DEFAULTS = {
11
+ rabbitmq_url: "amqp://guest:guest@localhost:5672/",
12
+ queue_type: "classic",
13
+ redis_url: "redis://localhost:6379",
14
+ redis_key_prefix: Protocol::DEFAULT_KEY_PREFIX,
15
+ max_parallel_jobs: nil,
16
+ execution_timeout: 3600,
17
+ heartbeat_interval: 30,
18
+ job_heartbeat_interval: 60,
19
+ shutdown_timeout: 30,
20
+ startup_timeout: 30,
21
+ version: "1.0.0",
22
+ instance_id: nil,
23
+ logger: nil
24
+ }.freeze
25
+
26
+ # worker_id: identifies the worker application. Use the same value on every replica.
27
+ # rabbitmq_url: AMQP URL of the broker the API uses.
28
+ # queue_type: "classic" or "quorum". Must match the API's MilvaionConfig:RabbitMQ:QueueType.
29
+ # redis_url: Redis only carries cancellation requests. nil disables them.
30
+ # redis_key_prefix: must match the API's MilvaionConfig:Redis:KeyPrefix.
31
+ # max_parallel_jobs: size of the job thread pool, reported as this instance's capacity.
32
+ # execution_timeout: default job timeout in seconds. nil or 0 disables it.
33
+ # heartbeat_interval: seconds between instance heartbeats.
34
+ # job_heartbeat_interval: seconds between Running updates of a long job, so it is not flagged as a zombie. 0 disables it.
35
+ # shutdown_timeout: how long stop lets running jobs finish before returning them to the queue.
36
+ # startup_timeout: how long start waits for RabbitMQ and Redis.
37
+ # version: shown in the dashboard.
38
+ # instance_id: overrides the generated "{worker_id}-{8 hex}" id. Must be unique per process.
39
+ # logger: a Logger. Defaults to one that writes to $stdout at INFO.
40
+ attr_reader :worker_id, :rabbitmq_url, :queue_type, :redis_url, :redis_key_prefix, :max_parallel_jobs,
41
+ :execution_timeout, :heartbeat_interval, :job_heartbeat_interval, :shutdown_timeout,
42
+ :startup_timeout, :version, :instance_id, :logger
43
+
44
+ def initialize(worker_id:, **options)
45
+ unknown = options.keys - DEFAULTS.keys
46
+ raise ArgumentError, "Unknown worker option(s): #{unknown.join(', ')}" unless unknown.empty?
47
+
48
+ values = DEFAULTS.merge(options)
49
+
50
+ @worker_id = worker_id.to_s.strip
51
+ raise ArgumentError, "worker_id is required" if @worker_id.empty?
52
+
53
+ @rabbitmq_url = values[:rabbitmq_url]
54
+ @queue_type = (values[:queue_type] || "classic").to_s.strip.downcase
55
+ raise ArgumentError, %(queue_type must be "classic" or "quorum", got #{values[:queue_type].inspect}) unless %w[classic quorum].include?(@queue_type)
56
+
57
+ @redis_url = values[:redis_url]
58
+ @redis_key_prefix = values[:redis_key_prefix]
59
+ @max_parallel_jobs = Integer(values[:max_parallel_jobs] || Etc.nprocessors * 2)
60
+ raise ArgumentError, "max_parallel_jobs must be positive, got #{@max_parallel_jobs}" unless @max_parallel_jobs.positive?
61
+
62
+ @execution_timeout = values[:execution_timeout]
63
+ @heartbeat_interval = values[:heartbeat_interval]
64
+ @job_heartbeat_interval = values[:job_heartbeat_interval]
65
+ @shutdown_timeout = values[:shutdown_timeout]
66
+ @startup_timeout = values[:startup_timeout]
67
+ @version = values[:version]
68
+ @instance_id = values[:instance_id].to_s.empty? ? "#{@worker_id}-#{SecureRandom.hex(4)}" : values[:instance_id]
69
+ @logger = values[:logger] || Logger.new($stdout, progname: "milvaion", level: Logger::INFO)
70
+ end
71
+
72
+ def cancellation_channel
73
+ "#{redis_key_prefix}cancellation_channel"
74
+ end
75
+
76
+ ENV_MAPPING = {
77
+ "MILVAION_WORKER_ID" => [:worker_id, :string],
78
+ "MILVAION_RABBITMQ_URL" => [:rabbitmq_url, :string],
79
+ "MILVAION_RABBITMQ_QUEUE_TYPE" => [:queue_type, :string],
80
+ "MILVAION_REDIS_KEY_PREFIX" => [:redis_key_prefix, :string],
81
+ "MILVAION_MAX_PARALLEL_JOBS" => [:max_parallel_jobs, :integer],
82
+ "MILVAION_EXECUTION_TIMEOUT_SECONDS" => [:execution_timeout, :float],
83
+ "MILVAION_HEARTBEAT_INTERVAL_SECONDS" => [:heartbeat_interval, :float]
84
+ }.freeze
85
+
86
+ # Reads MILVAION_WORKER_ID, MILVAION_RABBITMQ_URL, MILVAION_RABBITMQ_QUEUE_TYPE, MILVAION_REDIS_URL
87
+ # ("false" disables Redis), MILVAION_REDIS_KEY_PREFIX, MILVAION_MAX_PARALLEL_JOBS,
88
+ # MILVAION_EXECUTION_TIMEOUT_SECONDS and MILVAION_HEARTBEAT_INTERVAL_SECONDS.
89
+ # Keyword arguments win over the environment.
90
+ def self.from_env(env = ENV, **overrides)
91
+ values = {}
92
+
93
+ ENV_MAPPING.each do |key, (name, type)|
94
+ raw = env[key].to_s.strip
95
+ next if raw.empty?
96
+
97
+ values[name] =
98
+ case type
99
+ when :integer then Integer(raw, 10)
100
+ when :float then Float(raw)
101
+ else raw
102
+ end
103
+ rescue ArgumentError
104
+ raise ArgumentError, "#{key} must be a number, got #{raw.inspect}"
105
+ end
106
+
107
+ redis_url = env["MILVAION_REDIS_URL"].to_s.strip
108
+
109
+ if redis_url.casecmp?("false")
110
+ values[:redis_url] = nil
111
+ elsif !redis_url.empty?
112
+ values[:redis_url] = redis_url
113
+ end
114
+
115
+ values.merge!(overrides)
116
+ values[:worker_id] ||= ""
117
+
118
+ new(**values)
119
+ end
120
+ end
121
+
122
+ # Configuration of one job type. Defaults apply to options left out.
123
+ #
124
+ # routing_pattern: default "{worker_id}.{name without Job}.*".
125
+ # consumer_id: default "{name without Job}-consumer".
126
+ # max_parallel_jobs: jobs of this type that may run at once on this instance (prefetch). Default: min(10, worker max).
127
+ # execution_timeout: seconds. A timeout set on the job in the dashboard wins. 0 disables it. Default: the worker default.
128
+ # max_retries: retries after the first failure before the job goes to the dead letter queue. Default 3.
129
+ # base_retry_delay: retry n waits base_retry_delay * 2^(n-1) seconds. Default 5.
130
+ # data_schema: JSON Schema of the job data, as a Hash or a JSON string. The dashboard builds the job data form from it.
131
+ # result_schema: JSON Schema of the result.
132
+ # interrupt_on_abort: raise JobAborted inside the handler thread on cancel or timeout (Thread#raise, like
133
+ # the Timeout module, with the same risks). Default false: the handler must check the context.
134
+ class JobOptions
135
+ KEYS = %i[routing_pattern consumer_id max_parallel_jobs execution_timeout max_retries base_retry_delay data_schema result_schema interrupt_on_abort].freeze
136
+
137
+ attr_reader(*KEYS)
138
+
139
+ def initialize(**options)
140
+ unknown = options.keys - KEYS
141
+ raise ArgumentError, "Unknown job option(s): #{unknown.join(', ')}" unless unknown.empty?
142
+
143
+ @routing_pattern = options[:routing_pattern]
144
+ @consumer_id = options[:consumer_id]
145
+ @max_parallel_jobs = options[:max_parallel_jobs]
146
+ @execution_timeout = options[:execution_timeout]
147
+ @max_retries = options.fetch(:max_retries, 3)
148
+ @base_retry_delay = options.fetch(:base_retry_delay, 5)
149
+ @data_schema = options[:data_schema]
150
+ @result_schema = options[:result_schema]
151
+ @interrupt_on_abort = options.fetch(:interrupt_on_abort, false)
152
+ end
153
+
154
+ def to_h
155
+ KEYS.to_h { |key| [key, public_send(key)] }
156
+ end
157
+ end
158
+
159
+ # A job type with all defaults applied.
160
+ ResolvedJob = Struct.new(:name, :handler, :routing_pattern, :queue, :consumer_id, :max_parallel_jobs,
161
+ :execution_timeout, :max_retries, :base_retry_delay, :data_schema, :result_schema, :interrupt_on_abort,
162
+ keyword_init: true)
163
+
164
+ # The routing pattern a job gets when it does not set one, identical to the .NET SDK:
165
+ # "SendEmailJob" in worker "Mail-Worker" becomes "mail-worker.sendemail.*".
166
+ def self.generate_routing_pattern(job_name, worker_id)
167
+ "#{worker_id.downcase}.#{trim_job_suffix(job_name).downcase}.*"
168
+ end
169
+
170
+ # The queue a job type is consumed from.
171
+ def self.queue_name_for_pattern(pattern)
172
+ "#{Protocol::QUEUE_JOBS}.#{pattern.gsub('*', 'wildcard').gsub('#', 'all')}"
173
+ end
174
+
175
+ def self.trim_job_suffix(name)
176
+ name.sub(/job\z/i, "")
177
+ end
178
+
179
+ def self.resolve_job(name, handler, options, worker)
180
+ max_parallel = Integer(options.max_parallel_jobs || [10, worker.max_parallel_jobs].min)
181
+
182
+ unless max_parallel.between?(1, 65_535)
183
+ raise ArgumentError, "max_parallel_jobs of job #{name.inspect} must be between 1 and 65535, got #{max_parallel}"
184
+ end
185
+
186
+ pattern = options.routing_pattern.to_s.strip
187
+ pattern = generate_routing_pattern(name, worker.worker_id) if pattern.empty?
188
+ timeout = options.execution_timeout.nil? ? worker.execution_timeout : options.execution_timeout
189
+
190
+ ResolvedJob.new(
191
+ name: name,
192
+ handler: handler,
193
+ routing_pattern: pattern,
194
+ queue: queue_name_for_pattern(pattern),
195
+ consumer_id: options.consumer_id || "#{trim_job_suffix(name).downcase}-consumer",
196
+ max_parallel_jobs: max_parallel,
197
+ execution_timeout: timeout || 0,
198
+ max_retries: [0, Integer(options.max_retries)].max,
199
+ base_retry_delay: [0.0, Float(options.base_retry_delay)].max,
200
+ data_schema: options.data_schema,
201
+ result_schema: options.result_schema,
202
+ interrupt_on_abort: options.interrupt_on_abort ? true : false
203
+ )
204
+ end
205
+ end