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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +0 -0
- data/LICENSE +21 -0
- data/README.md +286 -0
- data/lib/natswork/cli.rb +420 -0
- data/lib/natswork/error_tracker.rb +338 -0
- data/lib/natswork/health_check.rb +252 -0
- data/lib/natswork/instrumentation.rb +141 -0
- data/lib/natswork/job_executor.rb +271 -0
- data/lib/natswork/job_hooks.rb +63 -0
- data/lib/natswork/logger.rb +183 -0
- data/lib/natswork/metrics.rb +241 -0
- data/lib/natswork/middleware.rb +142 -0
- data/lib/natswork/middleware_chain.rb +40 -0
- data/lib/natswork/monitoring.rb +397 -0
- data/lib/natswork/protocol.rb +454 -0
- data/lib/natswork/queue_manager.rb +164 -0
- data/lib/natswork/retry_handler.rb +125 -0
- data/lib/natswork/server/version.rb +7 -0
- data/lib/natswork/server.rb +47 -0
- data/lib/natswork/simple_worker.rb +101 -0
- data/lib/natswork/thread_pool.rb +192 -0
- data/lib/natswork/worker.rb +217 -0
- data/lib/natswork/worker_manager.rb +62 -0
- data/lib/natswork-server.rb +5 -0
- metadata +151 -0
@@ -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
|