natswork-client 0.0.1

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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'natswork/registry'
4
+
5
+ module NatsWork
6
+ class Job
7
+ class << self
8
+ def inherited(subclass)
9
+ super
10
+ return unless subclass.name && !subclass.name.empty?
11
+
12
+ Registry.instance.register(
13
+ subclass.name,
14
+ subclass,
15
+ queue: subclass.instance_variable_get(:@queue) || 'default',
16
+ retries: subclass.instance_variable_get(:@retries),
17
+ timeout: subclass.instance_variable_get(:@timeout)
18
+ )
19
+ end
20
+
21
+ def queue(name = nil)
22
+ if name
23
+ @queue = name
24
+ else
25
+ @queue
26
+ end
27
+ end
28
+
29
+ def retries(count = nil)
30
+ if count
31
+ @retries = count
32
+ else
33
+ @retries
34
+ end
35
+ end
36
+
37
+ def timeout(seconds = nil)
38
+ if seconds
39
+ @timeout = seconds
40
+ else
41
+ @timeout
42
+ end
43
+ end
44
+
45
+ def get_queue
46
+ @queue || (superclass.respond_to?(:get_queue) && superclass.get_queue) || 'default'
47
+ end
48
+
49
+ def get_retries
50
+ @retries || (superclass.respond_to?(:get_retries) && superclass.get_retries) || 3
51
+ end
52
+
53
+ def get_timeout
54
+ @timeout || (superclass.respond_to?(:get_timeout) && superclass.get_timeout) || 30
55
+ end
56
+
57
+ def timeout_value
58
+ get_timeout
59
+ end
60
+
61
+ def perform_async(**args)
62
+ # Accept keyword arguments directly
63
+
64
+ Client.push(
65
+ job_class: name,
66
+ queue: get_queue,
67
+ max_retries: get_retries,
68
+ timeout: get_timeout,
69
+ arguments: args
70
+ )
71
+ end
72
+
73
+ def perform_sync(**args)
74
+ # Accept keyword arguments directly
75
+
76
+ Client.perform_sync(
77
+ job_class: name,
78
+ queue: get_queue,
79
+ max_retries: get_retries,
80
+ timeout: get_timeout,
81
+ arguments: args
82
+ )
83
+ end
84
+ end
85
+
86
+ attr_reader :job_id, :retry_count, :metadata
87
+ attr_accessor :context
88
+
89
+ def initialize
90
+ @job_id = nil
91
+ @retry_count = 0
92
+ @metadata = {}
93
+ @context = {}
94
+ end
95
+
96
+ def perform(*args)
97
+ raise NotImplementedError, "#{self.class} must implement #perform"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'json'
5
+
6
+ module NatsWork
7
+ module Logging
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def logger
14
+ @logger ||= NatsWork.logger
15
+ end
16
+
17
+ def logger=(logger)
18
+ @logger = logger
19
+ end
20
+ end
21
+
22
+ def logger
23
+ self.class.logger
24
+ end
25
+
26
+ def log_debug(message, **context)
27
+ logger.debug(format_log_message(message, context))
28
+ end
29
+
30
+ def log_info(message, **context)
31
+ logger.info(format_log_message(message, context))
32
+ end
33
+
34
+ def log_warn(message, **context)
35
+ logger.warn(format_log_message(message, context))
36
+ end
37
+
38
+ def log_error(message, error = nil, **context)
39
+ if error
40
+ context[:error] = {
41
+ class: error.class.name,
42
+ message: error.message,
43
+ backtrace: error.backtrace&.first(5)
44
+ }
45
+ end
46
+
47
+ logger.error(format_log_message(message, context))
48
+ end
49
+
50
+ private
51
+
52
+ def format_log_message(message, context = {})
53
+ if logger.respond_to?(:formatter) && logger.formatter.is_a?(JSONFormatter)
54
+ context
55
+ else
56
+ msg = message.dup
57
+ msg += " | #{context.to_json}" unless context.empty?
58
+ msg
59
+ end
60
+ end
61
+ end
62
+
63
+ class JSONFormatter < ::Logger::Formatter
64
+ def call(severity, timestamp, progname, msg)
65
+ log_entry = {
66
+ timestamp: timestamp.utc.iso8601,
67
+ severity: severity,
68
+ progname: progname
69
+ }
70
+
71
+ if msg.is_a?(Hash)
72
+ log_entry.merge!(msg)
73
+ else
74
+ log_entry[:message] = msg
75
+ end
76
+
77
+ "#{log_entry.to_json}\n"
78
+ end
79
+ end
80
+
81
+ class << self
82
+ attr_writer :logger
83
+
84
+ def logger
85
+ @logger ||= begin
86
+ logger = ::Logger.new($stdout)
87
+ logger.level = ENV['NATSWORK_LOG_LEVEL'] ? ::Logger.const_get(ENV['NATSWORK_LOG_LEVEL'].upcase) : ::Logger::INFO
88
+ logger.progname = 'NatsWork'
89
+
90
+ logger.formatter = JSONFormatter.new if ENV['NATSWORK_LOG_FORMAT'] == 'json'
91
+
92
+ logger
93
+ end
94
+ end
95
+
96
+ def configure_logging(options = {})
97
+ if options[:logger]
98
+ self.logger = options[:logger]
99
+ else
100
+ output = options[:output] || $stdout
101
+ level = options[:level] || ::Logger::INFO
102
+ format = options[:format] || :text
103
+
104
+ self.logger = ::Logger.new(output)
105
+ logger.level = level
106
+ logger.progname = options[:progname] || 'NatsWork'
107
+
108
+ logger.formatter = JSONFormatter.new if format == :json
109
+ end
110
+ end
111
+ end
112
+
113
+ class LoggedConnection
114
+ include Logging
115
+
116
+ attr_reader :connection
117
+
118
+ def initialize(connection)
119
+ @connection = connection
120
+ setup_logging_callbacks
121
+ end
122
+
123
+ def connect
124
+ log_info 'Connecting to NATS', servers: @connection.servers
125
+ result = @connection.connect
126
+ log_info 'Connected to NATS successfully'
127
+ result
128
+ rescue StandardError => e
129
+ log_error 'Failed to connect to NATS', e, servers: @connection.servers
130
+ raise
131
+ end
132
+
133
+ def disconnect
134
+ log_info 'Disconnecting from NATS'
135
+ @connection.disconnect
136
+ log_info 'Disconnected from NATS'
137
+ end
138
+
139
+ def connected?
140
+ @connection.connected?
141
+ end
142
+
143
+ def publish(subject, payload)
144
+ log_debug 'Publishing message', subject: subject, payload_size: payload.to_s.bytesize
145
+ @connection.publish(subject, payload)
146
+ rescue StandardError => e
147
+ log_error 'Failed to publish message', e, subject: subject
148
+ raise
149
+ end
150
+
151
+ def subscribe(subject, opts = {}, &block)
152
+ log_info 'Subscribing to subject', subject: subject, options: opts
153
+
154
+ wrapped_block = proc do |msg, reply, subject, sid|
155
+ log_debug 'Received message', subject: subject, reply: reply
156
+ block.call(msg, reply, subject, sid)
157
+ rescue StandardError => e
158
+ log_error 'Error processing message', e, subject: subject
159
+ raise
160
+ end
161
+
162
+ sid = @connection.subscribe(subject, opts, &wrapped_block)
163
+ log_info 'Subscribed successfully', subject: subject, sid: sid
164
+ sid
165
+ rescue StandardError => e
166
+ log_error 'Failed to subscribe', e, subject: subject
167
+ raise
168
+ end
169
+
170
+ def request(subject, payload, opts = {})
171
+ log_debug 'Sending request', subject: subject, timeout: opts[:timeout]
172
+ start_time = Time.now
173
+
174
+ response = @connection.request(subject, payload, opts)
175
+
176
+ duration = Time.now - start_time
177
+ log_debug 'Received response', subject: subject, duration: duration
178
+
179
+ response
180
+ rescue NatsWork::TimeoutError
181
+ log_warn 'Request timed out', subject: subject, timeout: opts[:timeout]
182
+ raise
183
+ rescue StandardError => e
184
+ log_error 'Request failed', e, subject: subject
185
+ raise
186
+ end
187
+
188
+ def unsubscribe(sid)
189
+ log_debug 'Unsubscribing', sid: sid
190
+ @connection.unsubscribe(sid)
191
+ log_debug 'Unsubscribed successfully', sid: sid
192
+ end
193
+
194
+ def with_connection(&block)
195
+ @connection.with_connection(&block)
196
+ end
197
+
198
+ def jetstream
199
+ @connection.jetstream
200
+ end
201
+
202
+ def stats
203
+ @connection.stats
204
+ end
205
+
206
+ def healthy?
207
+ @connection.healthy?
208
+ end
209
+
210
+ def ping
211
+ result = @connection.ping
212
+ log_debug 'Ping result', success: result
213
+ result
214
+ end
215
+
216
+ # Delegate callback methods
217
+ def on_reconnect(&block)
218
+ @connection.on_reconnect(&block)
219
+ end
220
+
221
+ def on_disconnect(&block)
222
+ @connection.on_disconnect(&block)
223
+ end
224
+
225
+ def on_error(&block)
226
+ @connection.on_error(&block)
227
+ end
228
+
229
+ private
230
+
231
+ def setup_logging_callbacks
232
+ @connection.on_reconnect do
233
+ log_info 'Reconnected to NATS'
234
+ end
235
+
236
+ @connection.on_disconnect do
237
+ log_warn 'Disconnected from NATS'
238
+ end
239
+
240
+ @connection.on_error do |error|
241
+ log_error 'NATS connection error', error
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'time'
6
+ require 'natswork/compression'
7
+
8
+ module NatsWork
9
+ class Message
10
+ PROTOCOL_VERSION = '1.0.0'
11
+
12
+ TYPE_JOB_DISPATCH = 'job.dispatch'
13
+ TYPE_JOB_RESULT = 'job.result'
14
+ TYPE_JOB_ERROR = 'job.error'
15
+
16
+ VALID_TYPES = [
17
+ TYPE_JOB_DISPATCH,
18
+ TYPE_JOB_RESULT,
19
+ TYPE_JOB_ERROR
20
+ ].freeze
21
+
22
+ attr_accessor :type, :job_id, :job_class, :queue, :arguments,
23
+ :retry_count, :max_retries, :timeout, :reply_to,
24
+ :metadata, :created_at, :enqueued_at, :result, :error
25
+
26
+ def initialize(options = {})
27
+ @type = options[:type] || TYPE_JOB_DISPATCH
28
+ @job_id = options[:job_id] || SecureRandom.uuid
29
+ @job_class = options[:job_class]
30
+ @queue = options[:queue]
31
+ @arguments = options[:arguments] || []
32
+ @retry_count = options[:retry_count] || 0
33
+ @max_retries = options[:max_retries] || 3
34
+ @timeout = options[:timeout] || 30
35
+ @reply_to = options[:reply_to]
36
+ @metadata = options[:metadata] || {}
37
+ @created_at = options[:created_at] || Time.now.iso8601
38
+ @enqueued_at = options[:enqueued_at] || @created_at
39
+ @result = options[:result]
40
+ @error = options[:error]
41
+ end
42
+
43
+ def to_json(*args)
44
+ hash = to_hash
45
+
46
+ # Check if arguments should be compressed
47
+ if Compression.should_compress?(@arguments)
48
+ compression_result = Compression.compress(@arguments)
49
+ if compression_result[:compressed]
50
+ hash[:arguments] = compression_result[:data]
51
+ hash[:metadata] ||= {}
52
+ hash[:metadata]['compressed'] = true
53
+ hash[:metadata]['compression_ratio'] = Compression.compression_ratio(
54
+ compression_result[:original_size],
55
+ compression_result[:compressed_size]
56
+ )
57
+ end
58
+ end
59
+
60
+ hash.to_json(*args)
61
+ end
62
+
63
+ def to_hash
64
+ hash = {
65
+ type: @type,
66
+ job_id: @job_id,
67
+ job_class: @job_class,
68
+ queue: @queue,
69
+ arguments: @arguments,
70
+ retry_count: @retry_count,
71
+ max_retries: @max_retries,
72
+ timeout: @timeout,
73
+ metadata: @metadata,
74
+ created_at: @created_at,
75
+ enqueued_at: @enqueued_at,
76
+ protocol_version: PROTOCOL_VERSION
77
+ }
78
+
79
+ hash[:reply_to] = @reply_to if @reply_to
80
+ hash[:result] = @result if @result
81
+ hash[:error] = @error if @error
82
+
83
+ hash
84
+ end
85
+
86
+ def self.from_json(json_string)
87
+ begin
88
+ data = JSON.parse(json_string, symbolize_names: true)
89
+ rescue JSON::ParserError => e
90
+ raise InvalidMessageError, "Invalid JSON: #{e.message}"
91
+ end
92
+
93
+ if data[:protocol_version] && data[:protocol_version] != PROTOCOL_VERSION
94
+ major_version = data[:protocol_version].split('.').first
95
+ supported_major = PROTOCOL_VERSION.split('.').first
96
+
97
+ if major_version != supported_major
98
+ raise InvalidMessageError, "Unsupported protocol version: #{data[:protocol_version]}"
99
+ end
100
+ end
101
+
102
+ # Decompress arguments if compressed
103
+ if data[:metadata] && data[:metadata]['compressed']
104
+ compressed_payload = {
105
+ compressed: true,
106
+ data: data[:arguments]
107
+ }
108
+ data[:arguments] = Compression.decompress(compressed_payload)
109
+ end
110
+
111
+ new(data)
112
+ end
113
+
114
+ def valid?
115
+ return false unless @job_class && @queue
116
+ return false unless VALID_TYPES.include?(@type)
117
+
118
+ true
119
+ end
120
+
121
+ def validate!
122
+ raise InvalidMessageError, 'job_class is required' unless @job_class
123
+
124
+ raise InvalidMessageError, 'queue is required' unless @queue
125
+
126
+ raise InvalidMessageError, "Invalid message type: #{@type}" unless VALID_TYPES.include?(@type)
127
+
128
+ true
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWork
4
+ module Rails
5
+ module ConsoleHelpers
6
+ module_function
7
+
8
+ # List all registered jobs
9
+ def jobs
10
+ Registry.instance.all_jobs
11
+ end
12
+
13
+ # Show jobs for a specific queue
14
+ def queue_jobs(queue_name)
15
+ Registry.instance.jobs_for_queue(queue_name)
16
+ end
17
+
18
+ # Show all queues
19
+ def queues
20
+ Registry.instance.all_queues
21
+ end
22
+
23
+ # Get client status
24
+ def status
25
+ client = Client.instance
26
+ pool = client.connection_pool
27
+
28
+ {
29
+ connected: pool&.healthy? || false,
30
+ pool_size: pool&.size || 0,
31
+ active_connections: pool&.active_connections || 0,
32
+ available_connections: pool&.available_connections || 0,
33
+ configuration: {
34
+ servers: client.configuration.servers,
35
+ namespace: client.configuration.namespace,
36
+ use_jetstream: client.configuration.use_jetstream
37
+ }
38
+ }
39
+ end
40
+
41
+ # Test job execution
42
+ def test_job(job_class, *args)
43
+ job = job_class.is_a?(Class) ? job_class : job_class.constantize
44
+ job_id = job.perform_async(*args)
45
+ puts "Job #{job_id} enqueued to queue '#{job.get_queue}'"
46
+ job_id
47
+ end
48
+
49
+ # Execute job synchronously (for testing)
50
+ def run_job(job_class, *args)
51
+ job = job_class.is_a?(Class) ? job_class : job_class.constantize
52
+ instance = job.new
53
+ result = instance.perform(*args)
54
+ puts "Job completed with result: #{result.inspect}"
55
+ result
56
+ end
57
+
58
+ # Check job result
59
+ def job_result(job_id)
60
+ Client.instance.job_status(job_id)
61
+ end
62
+
63
+ # Clear all results
64
+ def clear_results
65
+ client = Client.instance
66
+ count = client.instance_variable_get(:@result_store).size
67
+ client.instance_variable_set(:@result_store, {})
68
+ client.instance_variable_set(:@result_expiration_times, {})
69
+ puts "Cleared #{count} job results"
70
+ end
71
+
72
+ # Show scheduled jobs
73
+ def scheduled_jobs
74
+ Client.instance.instance_variable_get(:@scheduled_jobs).map do |job|
75
+ {
76
+ job_id: job[:message].job_id,
77
+ job_class: job[:message].job_class,
78
+ scheduled_for: job[:scheduled_for],
79
+ status: job[:status]
80
+ }
81
+ end
82
+ end
83
+
84
+ # Cancel a job
85
+ def cancel_job(job_id)
86
+ Client.instance.cancel_job(job_id)
87
+ puts "Cancellation message sent for job #{job_id}"
88
+ true
89
+ end
90
+
91
+ # Reconnect to NATS
92
+ def reconnect!
93
+ Client.instance.reset_connection!
94
+ puts 'Reconnected to NATS'
95
+ true
96
+ end
97
+
98
+ # Show connection pool statistics
99
+ def pool_stats
100
+ pool = Client.instance.connection_pool
101
+ pool&.stats || {}
102
+ end
103
+
104
+ # Enable/disable debug logging
105
+ def debug(enabled = true)
106
+ logger = enabled ? Logger.new($stdout) : nil
107
+ Client.instance.configuration.logger = logger
108
+ puts "Debug logging #{enabled ? 'enabled' : 'disabled'}"
109
+ end
110
+
111
+ # Perform a health check
112
+ def health_check
113
+ client = Client.instance
114
+ pool = client.connection_pool
115
+
116
+ if pool.nil?
117
+ puts '❌ Connection pool not initialized'
118
+ return false
119
+ end
120
+
121
+ healthy = pool.healthy?
122
+ puts healthy ? '✅ System healthy' : '❌ System unhealthy'
123
+
124
+ # Check individual connections
125
+ pool.with_connection do |conn|
126
+ if conn.connected?
127
+ puts '✅ NATS connection active'
128
+ else
129
+ puts '❌ NATS connection inactive'
130
+ end
131
+ end
132
+
133
+ healthy
134
+ rescue StandardError => e
135
+ puts "❌ Health check failed: #{e.message}"
136
+ false
137
+ end
138
+
139
+ # Show memory usage
140
+ def memory_usage
141
+ kb = `ps -o rss= -p #{Process.pid}`.to_i
142
+ mb = kb / 1024.0
143
+ puts "Memory usage: #{mb.round(2)} MB"
144
+ mb
145
+ end
146
+
147
+ # Benchmark job execution
148
+ def benchmark_job(job_class, *args, count: 100)
149
+ job = job_class.is_a?(Class) ? job_class : job_class.constantize
150
+
151
+ require 'benchmark'
152
+
153
+ time = Benchmark.realtime do
154
+ count.times do
155
+ job.perform_async(*args)
156
+ end
157
+ end
158
+
159
+ rate = count / time
160
+ puts "Enqueued #{count} jobs in #{time.round(3)} seconds"
161
+ puts "Rate: #{rate.round(2)} jobs/second"
162
+
163
+ { time: time, count: count, rate: rate }
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ # Make helpers available in Rails console
170
+ if defined?(Rails::Console)
171
+ module Rails
172
+ class Console
173
+ include NatsWork::Rails::ConsoleHelpers
174
+ end
175
+ end
176
+
177
+ # Also make them available as top-level methods
178
+ include NatsWork::Rails::ConsoleHelpers
179
+
180
+ puts "NatsWork console helpers loaded. Type 'natswork_help' for available commands."
181
+
182
+ def natswork_help
183
+ puts <<~HELP
184
+ NatsWork Console Helpers:
185
+
186
+ jobs - List all registered jobs
187
+ queue_jobs(name) - Show jobs for a specific queue
188
+ queues - Show all queues
189
+ status - Get client status
190
+
191
+ test_job(class, *args) - Enqueue a job
192
+ run_job(class, *args) - Run a job synchronously
193
+ job_result(id) - Check job result
194
+ cancel_job(id) - Cancel a job
195
+
196
+ scheduled_jobs - Show scheduled jobs
197
+ clear_results - Clear all job results
198
+
199
+ reconnect! - Reconnect to NATS
200
+ pool_stats - Show connection pool stats
201
+ health_check - Perform health check
202
+ memory_usage - Show memory usage
203
+
204
+ debug(enabled) - Enable/disable debug logging
205
+ benchmark_job(class, *args, count: 100) - Benchmark job execution
206
+ HELP
207
+ end
208
+ end