natswork-server 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,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module NatsWork
7
+ module Protocol
8
+ VERSION = '1.0.0'
9
+
10
+ # Message types
11
+ module MessageType
12
+ JOB_REQUEST = 'job_request'
13
+ JOB_RESPONSE = 'job_response'
14
+ JOB_ERROR = 'job_error'
15
+ WORKER_HEARTBEAT = 'worker_heartbeat'
16
+ WORKER_REGISTER = 'worker_register'
17
+ WORKER_UNREGISTER = 'worker_unregister'
18
+ QUEUE_STATUS = 'queue_status'
19
+ PROTOCOL_VERSION = 'protocol_version'
20
+ end
21
+
22
+ # Standard error codes
23
+ module ErrorCode
24
+ UNKNOWN = 'UNKNOWN'
25
+ JOB_NOT_FOUND = 'JOB_NOT_FOUND'
26
+ WORKER_NOT_FOUND = 'WORKER_NOT_FOUND'
27
+ QUEUE_NOT_FOUND = 'QUEUE_NOT_FOUND'
28
+ TIMEOUT = 'TIMEOUT'
29
+ INVALID_ARGUMENTS = 'INVALID_ARGUMENTS'
30
+ SERIALIZATION_ERROR = 'SERIALIZATION_ERROR'
31
+ EXECUTION_ERROR = 'EXECUTION_ERROR'
32
+ RETRY_LIMIT_EXCEEDED = 'RETRY_LIMIT_EXCEEDED'
33
+ UNSUPPORTED_VERSION = 'UNSUPPORTED_VERSION'
34
+ end
35
+
36
+ # Message schemas
37
+ class Schema
38
+ # Job request message schema
39
+ def self.job_request
40
+ {
41
+ type: MessageType::JOB_REQUEST,
42
+ version: VERSION,
43
+ job_id: 'string (UUID)',
44
+ job_class: 'string',
45
+ queue: 'string',
46
+ arguments: 'array|hash',
47
+ metadata: {
48
+ created_at: 'ISO8601 timestamp',
49
+ enqueued_at: 'ISO8601 timestamp',
50
+ retry_count: 'integer',
51
+ max_retries: 'integer',
52
+ timeout: 'integer (seconds)',
53
+ language: 'string (optional)',
54
+ language_version: 'string (optional)',
55
+ worker_constraints: 'hash (optional)',
56
+ priority: 'integer (optional)',
57
+ idempotency_key: 'string (optional)',
58
+ correlation_id: 'string (optional)',
59
+ parent_job_id: 'string (optional)'
60
+ }
61
+ }
62
+ end
63
+
64
+ # Job response message schema
65
+ def self.job_response
66
+ {
67
+ type: MessageType::JOB_RESPONSE,
68
+ version: VERSION,
69
+ job_id: 'string (UUID)',
70
+ status: 'success|failure|partial',
71
+ result: 'any',
72
+ metadata: {
73
+ started_at: 'ISO8601 timestamp',
74
+ completed_at: 'ISO8601 timestamp',
75
+ duration_ms: 'float',
76
+ worker_id: 'string',
77
+ worker_language: 'string',
78
+ worker_version: 'string',
79
+ retry_count: 'integer'
80
+ }
81
+ }
82
+ end
83
+
84
+ # Job error message schema
85
+ def self.job_error
86
+ {
87
+ type: MessageType::JOB_ERROR,
88
+ version: VERSION,
89
+ job_id: 'string (UUID)',
90
+ error_code: 'string (ErrorCode)',
91
+ error_message: 'string',
92
+ error_class: 'string',
93
+ backtrace: 'array of strings (optional)',
94
+ metadata: {
95
+ occurred_at: 'ISO8601 timestamp',
96
+ worker_id: 'string',
97
+ worker_language: 'string',
98
+ retry_count: 'integer',
99
+ retryable: 'boolean',
100
+ retry_at: 'ISO8601 timestamp (optional)'
101
+ }
102
+ }
103
+ end
104
+
105
+ # Worker heartbeat schema
106
+ def self.worker_heartbeat
107
+ {
108
+ type: MessageType::WORKER_HEARTBEAT,
109
+ version: VERSION,
110
+ worker_id: 'string',
111
+ timestamp: 'ISO8601 timestamp',
112
+ status: 'running|paused|stopping',
113
+ stats: {
114
+ jobs_processed: 'integer',
115
+ jobs_failed: 'integer',
116
+ active_jobs: 'integer',
117
+ queues: 'array of strings',
118
+ concurrency: 'integer',
119
+ memory_usage: 'integer (bytes)',
120
+ cpu_usage: 'float (percentage)',
121
+ uptime_seconds: 'integer'
122
+ },
123
+ capabilities: {
124
+ language: 'string',
125
+ language_version: 'string',
126
+ protocol_version: 'string',
127
+ supported_job_types: 'array (optional)',
128
+ max_job_size: 'integer (bytes)',
129
+ features: 'array of strings'
130
+ }
131
+ }
132
+ end
133
+ end
134
+
135
+ # Message builder
136
+ class MessageBuilder
137
+ def self.build_job_request(job_class, arguments, options = {})
138
+ {
139
+ type: MessageType::JOB_REQUEST,
140
+ version: VERSION,
141
+ job_id: options[:job_id] || generate_uuid,
142
+ job_class: job_class,
143
+ queue: options[:queue] || 'default',
144
+ arguments: arguments,
145
+ metadata: {
146
+ created_at: Time.now.utc.iso8601,
147
+ enqueued_at: options[:enqueued_at] || Time.now.utc.iso8601,
148
+ retry_count: options[:retry_count] || 0,
149
+ max_retries: options[:max_retries] || 3,
150
+ timeout: options[:timeout] || 30,
151
+ language: options[:language] || 'ruby',
152
+ language_version: options[:language_version] || RUBY_VERSION,
153
+ worker_constraints: options[:worker_constraints],
154
+ priority: options[:priority] || 0,
155
+ idempotency_key: options[:idempotency_key],
156
+ correlation_id: options[:correlation_id],
157
+ parent_job_id: options[:parent_job_id]
158
+ }.compact
159
+ }
160
+ end
161
+
162
+ def self.build_job_response(job_id, result, options = {})
163
+ {
164
+ type: MessageType::JOB_RESPONSE,
165
+ version: VERSION,
166
+ job_id: job_id,
167
+ status: options[:status] || 'success',
168
+ result: result,
169
+ metadata: {
170
+ started_at: options[:started_at],
171
+ completed_at: Time.now.utc.iso8601,
172
+ duration_ms: options[:duration_ms],
173
+ worker_id: options[:worker_id],
174
+ worker_language: 'ruby',
175
+ worker_version: VERSION,
176
+ retry_count: options[:retry_count] || 0
177
+ }.compact
178
+ }
179
+ end
180
+
181
+ def self.build_job_error(job_id, error, options = {})
182
+ {
183
+ type: MessageType::JOB_ERROR,
184
+ version: VERSION,
185
+ job_id: job_id,
186
+ error_code: options[:error_code] || ErrorCode::EXECUTION_ERROR,
187
+ error_message: error.message,
188
+ error_class: error.class.name,
189
+ backtrace: error.backtrace&.first(20),
190
+ metadata: {
191
+ occurred_at: Time.now.utc.iso8601,
192
+ worker_id: options[:worker_id],
193
+ worker_language: 'ruby',
194
+ retry_count: options[:retry_count] || 0,
195
+ retryable: options[:retryable] != false,
196
+ retry_at: options[:retry_at]
197
+ }.compact
198
+ }
199
+ end
200
+
201
+ def self.build_worker_heartbeat(worker_id, stats, capabilities)
202
+ {
203
+ type: MessageType::WORKER_HEARTBEAT,
204
+ version: VERSION,
205
+ worker_id: worker_id,
206
+ timestamp: Time.now.utc.iso8601,
207
+ status: stats[:status] || 'running',
208
+ stats: {
209
+ jobs_processed: stats[:jobs_processed] || 0,
210
+ jobs_failed: stats[:jobs_failed] || 0,
211
+ active_jobs: stats[:active_jobs] || 0,
212
+ queues: stats[:queues] || [],
213
+ concurrency: stats[:concurrency] || 1,
214
+ memory_usage: stats[:memory_usage],
215
+ cpu_usage: stats[:cpu_usage],
216
+ uptime_seconds: stats[:uptime_seconds]
217
+ }.compact,
218
+ capabilities: {
219
+ language: 'ruby',
220
+ language_version: RUBY_VERSION,
221
+ protocol_version: VERSION,
222
+ supported_job_types: capabilities[:supported_job_types],
223
+ max_job_size: capabilities[:max_job_size] || 10_485_760, # 10MB default
224
+ features: capabilities[:features] || []
225
+ }.compact
226
+ }
227
+ end
228
+
229
+ def self.generate_uuid
230
+ require 'securerandom'
231
+ SecureRandom.uuid
232
+ end
233
+ end
234
+
235
+ # Message validator
236
+ class Validator
237
+ def self.valid_job_request?(message)
238
+ return false unless message.is_a?(Hash)
239
+
240
+ required = %w[type version job_id job_class queue arguments metadata]
241
+ required.all? { |key| message.key?(key) } &&
242
+ message['type'] == MessageType::JOB_REQUEST &&
243
+ message['version'] =~ /^\d+\.\d+\.\d+$/ &&
244
+ message['metadata'].is_a?(Hash) &&
245
+ message['metadata']['created_at'].is_a?(String)
246
+ rescue StandardError
247
+ false
248
+ end
249
+
250
+ def self.valid_job_response?(message)
251
+ return false unless message.is_a?(Hash)
252
+
253
+ required = %w[type version job_id status result metadata]
254
+ required.all? { |key| message.key?(key) } &&
255
+ message['type'] == MessageType::JOB_RESPONSE &&
256
+ %w[success failure partial].include?(message['status'])
257
+ rescue StandardError
258
+ false
259
+ end
260
+
261
+ def self.valid_job_error?(message)
262
+ return false unless message.is_a?(Hash)
263
+
264
+ required = %w[type version job_id error_code error_message error_class metadata]
265
+ required.all? { |key| message.key?(key) } &&
266
+ message['type'] == MessageType::JOB_ERROR
267
+ rescue StandardError
268
+ false
269
+ end
270
+
271
+ def self.validate_message(message)
272
+ case message['type']
273
+ when MessageType::JOB_REQUEST
274
+ valid_job_request?(message)
275
+ when MessageType::JOB_RESPONSE
276
+ valid_job_response?(message)
277
+ when MessageType::JOB_ERROR
278
+ valid_job_error?(message)
279
+ else
280
+ false
281
+ end
282
+ end
283
+ end
284
+
285
+ # Type mapper for cross-language support
286
+ class TypeMapper
287
+ # Ruby to Python type mapping
288
+ RUBY_TO_PYTHON = {
289
+ 'NilClass' => 'None',
290
+ 'TrueClass' => 'bool',
291
+ 'FalseClass' => 'bool',
292
+ 'Integer' => 'int',
293
+ 'Float' => 'float',
294
+ 'String' => 'str',
295
+ 'Symbol' => 'str',
296
+ 'Array' => 'list',
297
+ 'Hash' => 'dict',
298
+ 'Time' => 'datetime',
299
+ 'Date' => 'date',
300
+ 'DateTime' => 'datetime'
301
+ }.freeze
302
+
303
+ # Python to Ruby type mapping
304
+ PYTHON_TO_RUBY = {
305
+ 'None' => 'nil',
306
+ 'bool' => 'Boolean',
307
+ 'int' => 'Integer',
308
+ 'float' => 'Float',
309
+ 'str' => 'String',
310
+ 'list' => 'Array',
311
+ 'tuple' => 'Array',
312
+ 'dict' => 'Hash',
313
+ 'datetime' => 'Time',
314
+ 'date' => 'Date',
315
+ 'bytes' => 'String'
316
+ }.freeze
317
+
318
+ def self.serialize_for_language(value, target_language)
319
+ case target_language.downcase
320
+ when 'python'
321
+ serialize_for_python(value)
322
+ when 'javascript', 'node', 'nodejs'
323
+ serialize_for_javascript(value)
324
+ when 'go', 'golang'
325
+ serialize_for_go(value)
326
+ else
327
+ value
328
+ end
329
+ end
330
+
331
+ def self.serialize_for_python(value)
332
+ case value
333
+ when Time, DateTime
334
+ value.utc.iso8601
335
+ when Date
336
+ value.iso8601
337
+ when Symbol
338
+ value.to_s
339
+ when nil
340
+ nil
341
+ else
342
+ value
343
+ end
344
+ end
345
+
346
+ def self.serialize_for_javascript(value)
347
+ case value
348
+ when Time, DateTime
349
+ value.utc.iso8601
350
+ when Date
351
+ value.iso8601
352
+ when Symbol
353
+ value.to_s
354
+ when BigDecimal
355
+ value.to_f
356
+ else
357
+ value
358
+ end
359
+ end
360
+
361
+ def self.serialize_for_go(value)
362
+ case value
363
+ when Time, DateTime
364
+ value.utc.iso8601
365
+ when Date
366
+ value.iso8601
367
+ when Symbol
368
+ value.to_s
369
+ else
370
+ value
371
+ end
372
+ end
373
+
374
+ def self.deserialize_from_language(value, source_language)
375
+ case source_language.downcase
376
+ when 'python'
377
+ deserialize_from_python(value)
378
+ else
379
+ value
380
+ end
381
+ end
382
+
383
+ def self.deserialize_from_python(value)
384
+ if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
385
+ Time.parse(value)
386
+ else
387
+ value
388
+ end
389
+ end
390
+ end
391
+
392
+ # Language router for directing jobs to appropriate workers
393
+ class LanguageRouter
394
+ def self.route_job(job_message, available_workers)
395
+ constraints = job_message.dig('metadata', 'worker_constraints') || {}
396
+ language = constraints['language']
397
+
398
+ if language
399
+ # Filter workers by language
400
+ workers = available_workers.select do |worker|
401
+ worker[:language] == language
402
+ end
403
+
404
+ return workers.first if workers.any?
405
+ end
406
+
407
+ # Fallback to any available worker
408
+ available_workers.first
409
+ end
410
+
411
+ def self.queue_for_language(base_queue, language = nil)
412
+ return base_queue unless language
413
+
414
+ "#{base_queue}.#{language.downcase}"
415
+ end
416
+
417
+ def self.parse_queue_language(queue_name)
418
+ parts = queue_name.split('.')
419
+ return [queue_name, nil] if parts.length == 1
420
+
421
+ language = parts.last
422
+ base_queue = parts[0..-2].join('.')
423
+
424
+ [base_queue, language]
425
+ end
426
+ end
427
+
428
+ # Protocol version negotiation
429
+ class VersionNegotiator
430
+ SUPPORTED_VERSIONS = ['1.0.0'].freeze
431
+
432
+ def self.negotiate(client_version, server_versions = SUPPORTED_VERSIONS)
433
+ return VERSION if client_version.nil?
434
+
435
+ client_major = parse_major_version(client_version)
436
+
437
+ # Find highest compatible version
438
+ compatible = server_versions.select do |version|
439
+ parse_major_version(version) == client_major
440
+ end
441
+
442
+ compatible.max || nil
443
+ end
444
+
445
+ def self.compatible?(version1, version2)
446
+ parse_major_version(version1) == parse_major_version(version2)
447
+ end
448
+
449
+ def self.parse_major_version(version)
450
+ version.to_s.split('.').first.to_i
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module NatsWork
6
+ class QueueManager
7
+ attr_reader :subscriptions, :priorities, :concurrency_limits
8
+
9
+ def initialize(connection)
10
+ @connection = connection
11
+ @subscriptions = Concurrent::Hash.new
12
+ @callbacks = Concurrent::Hash.new
13
+ @priorities = Concurrent::Hash.new
14
+ @concurrency_limits = Concurrent::Hash.new
15
+ @paused_queues = Concurrent::Array.new
16
+ @active_counts = Concurrent::Hash.new { 0 }
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def subscribe(queue, &block)
21
+ subject = queue_subject(queue)
22
+
23
+ sid = @connection.subscribe(subject) do |msg|
24
+ process_message(queue, msg, &block) unless paused?(queue)
25
+ end
26
+
27
+ @subscriptions[queue] = sid
28
+ @callbacks[queue] = block
29
+ sid
30
+ end
31
+
32
+ def unsubscribe(queue)
33
+ @mutex.synchronize do
34
+ sid = @subscriptions.delete(queue)
35
+ @connection.unsubscribe(sid) if sid
36
+ @callbacks.delete(queue)
37
+ end
38
+ end
39
+
40
+ def unsubscribe_all
41
+ @mutex.synchronize do
42
+ @subscriptions.each_value do |sid|
43
+ @connection.unsubscribe(sid)
44
+ end
45
+ @subscriptions.clear
46
+ @callbacks.clear
47
+ end
48
+ end
49
+
50
+ def add_queue(queue, &block)
51
+ subscribe(queue, &block)
52
+ end
53
+
54
+ def remove_queue(queue)
55
+ unsubscribe(queue)
56
+ end
57
+
58
+ def pause(queue)
59
+ @paused_queues << queue unless @paused_queues.include?(queue)
60
+ end
61
+
62
+ def pause_all
63
+ @subscriptions.each_key { |queue| pause(queue) }
64
+ end
65
+
66
+ def resume(queue)
67
+ @paused_queues.delete(queue)
68
+ end
69
+
70
+ def resume_all
71
+ @paused_queues.clear
72
+ end
73
+
74
+ def paused?(queue)
75
+ @paused_queues.include?(queue)
76
+ end
77
+
78
+ def paused_queues
79
+ @paused_queues.to_a
80
+ end
81
+
82
+ def set_priority(queue, priority)
83
+ @priorities[queue] = priority
84
+ end
85
+
86
+ def set_concurrency(queue, limit)
87
+ @concurrency_limits[queue] = limit
88
+ end
89
+
90
+ def drain(queue, timeout: 30)
91
+ deadline = Time.now + timeout
92
+
93
+ # Process any pending messages
94
+ process_pending_messages(queue)
95
+
96
+ # Wait for timeout
97
+ sleep 0.1 while Time.now < deadline
98
+ end
99
+
100
+ def drain_all(timeout: 30)
101
+ threads = @subscriptions.keys.map do |queue|
102
+ Thread.new { drain(queue, timeout: timeout) }
103
+ end
104
+ threads.each(&:join)
105
+ end
106
+
107
+ def stats
108
+ {
109
+ queues: @subscriptions.keys,
110
+ priorities: @priorities.to_h,
111
+ paused: paused_queues,
112
+ total_subscriptions: @subscriptions.size,
113
+ concurrency_limits: @concurrency_limits.to_h,
114
+ active_counts: @active_counts.to_h
115
+ }
116
+ end
117
+
118
+ private
119
+
120
+ def queue_subject(queue)
121
+ "natswork.queue.#{queue}"
122
+ end
123
+
124
+ def process_message(queue, msg, &block)
125
+ # Check concurrency limit
126
+ return if limited?(queue) && at_concurrency_limit?(queue)
127
+
128
+ begin
129
+ increment_active(queue)
130
+ block&.call(msg)
131
+ ensure
132
+ decrement_active(queue)
133
+ end
134
+ end
135
+
136
+ def process_pending_messages(queue)
137
+ # In real implementation, this would process any buffered messages
138
+ # For now, it's a placeholder for draining logic
139
+ end
140
+
141
+ def limited?(queue)
142
+ @concurrency_limits.key?(queue)
143
+ end
144
+
145
+ def at_concurrency_limit?(queue)
146
+ limit = @concurrency_limits[queue]
147
+ return false unless limit
148
+
149
+ @active_counts[queue] >= limit
150
+ end
151
+
152
+ def increment_active(queue)
153
+ @mutex.synchronize do
154
+ @active_counts[queue] = @active_counts[queue] + 1
155
+ end
156
+ end
157
+
158
+ def decrement_active(queue)
159
+ @mutex.synchronize do
160
+ @active_counts[queue] = [@active_counts[queue] - 1, 0].max
161
+ end
162
+ end
163
+ end
164
+ end