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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 34b95584309b550018488327792c81595cc1c5a2483098001dc2daae636fad0e
4
+ data.tar.gz: 58607818cac88add344df5edd697bcc157bf187f6c49cd4b30ec1e6e29d1d89b
5
+ SHA512:
6
+ metadata.gz: 5d5224d0a1cff03380b0d4913eadc547db7f7a1f58e758b7decfa3a4820348aaef50c8323b4a580a7f94016adbe63ec8ec5d3a03ab6e23410cf175200720c1c7
7
+ data.tar.gz: e52faff83deea12ff2a7a084b6b9fa2346779f4cbc859aacfd96f6a5ddc2b0d208a12bc36fa8add31cf9a1fe2209e3a63685e89b47d5ae730fd11501681f9cae
data/CHANGELOG.md ADDED
File without changes
data/LICENSE ADDED
File without changes
data/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # NatsWork::Client
2
+
3
+ Job dispatching client for the NatsWork distributed job processing system.
4
+
5
+ ## Installation
6
+
7
+ Add to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'natswork-client'
11
+ ```
12
+
13
+ And execute:
14
+
15
+ $ bundle install
16
+
17
+ ## Configuration
18
+
19
+ ```ruby
20
+ NatsWork::Client.configure do |config|
21
+ config.nats_url = ENV['NATS_URL'] || 'nats://localhost:4222'
22
+ config.default_timeout = 30.0
23
+ config.logger = Rails.logger
24
+ end
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ### Defining Jobs
30
+
31
+ Jobs are defined as classes that include `NatsWork::Job`:
32
+
33
+ ```ruby
34
+ class ProcessPaymentJob < NatsWork::Job
35
+ queue 'payments'
36
+ retries 3
37
+ timeout 30
38
+
39
+ def perform(order_id, amount)
40
+ order = Order.find(order_id)
41
+ PaymentService.charge(order, amount)
42
+ end
43
+ end
44
+ ```
45
+
46
+ ### Dispatching Jobs
47
+
48
+ #### Asynchronous Execution
49
+
50
+ Fire-and-forget job execution:
51
+
52
+ ```ruby
53
+ # Basic async dispatch
54
+ ProcessPaymentJob.perform_async(order.id, 99.99)
55
+
56
+ # With options
57
+ ProcessPaymentJob.perform_in(5.minutes, order.id, 99.99)
58
+ ProcessPaymentJob.perform_at(Time.now + 1.hour, order.id, 99.99)
59
+ ```
60
+
61
+ #### Synchronous Execution
62
+
63
+ Wait for job completion and get result:
64
+
65
+ ```ruby
66
+ # Wait for result with timeout
67
+ result = ProcessPaymentJob.perform_sync(order.id, 99.99, timeout: 10.0)
68
+
69
+ # Handle timeout
70
+ begin
71
+ result = ProcessPaymentJob.perform_sync(order.id, 99.99, timeout: 5.0)
72
+ rescue NatsWork::TimeoutError => e
73
+ Rails.logger.error "Job timed out: #{e.message}"
74
+ end
75
+ ```
76
+
77
+ ### Advanced Features
78
+
79
+ #### Job Options
80
+
81
+ ```ruby
82
+ class CustomJob < NatsWork::Job
83
+ # Routing
84
+ queue 'critical'
85
+
86
+ # Retries
87
+ retries 5
88
+ retry_backoff :exponential
89
+
90
+ # Timeout
91
+ timeout 60
92
+
93
+ # Unique jobs
94
+ unique_for 1.hour
95
+
96
+ def perform(*)
97
+ # Job logic
98
+ end
99
+ end
100
+ ```
101
+
102
+ #### Middleware
103
+
104
+ Add custom middleware for cross-cutting concerns:
105
+
106
+ ```ruby
107
+ class LoggingMiddleware
108
+ def call(job, message)
109
+ Rails.logger.info "Dispatching #{job.class.name}"
110
+ yield
111
+ ensure
112
+ Rails.logger.info "Dispatched #{job.class.name}"
113
+ end
114
+ end
115
+
116
+ NatsWork::Client.configure do |config|
117
+ config.client_middleware do |chain|
118
+ chain.add LoggingMiddleware
119
+ end
120
+ end
121
+ ```
122
+
123
+ #### Callbacks
124
+
125
+ ```ruby
126
+ class NotificationJob < NatsWork::Job
127
+ before_enqueue :validate_recipient
128
+ after_enqueue :log_dispatch
129
+
130
+ def perform(recipient_id, message)
131
+ # Send notification
132
+ end
133
+
134
+ private
135
+
136
+ def validate_recipient
137
+ # Validation logic
138
+ end
139
+
140
+ def log_dispatch
141
+ Rails.logger.info "Notification queued"
142
+ end
143
+ end
144
+ ```
145
+
146
+ ## Rails Integration
147
+
148
+ ### Generators
149
+
150
+ ```bash
151
+ # Generate a new job
152
+ rails generate natswork:job ProcessOrder
153
+
154
+ # Generate configuration
155
+ rails generate natswork:install
156
+ ```
157
+
158
+ ### ActiveJob Adapter
159
+
160
+ Use NatsWork as an ActiveJob backend:
161
+
162
+ ```ruby
163
+ # config/application.rb
164
+ config.active_job.queue_adapter = :natswork
165
+ ```
166
+
167
+ ### Auto-loading
168
+
169
+ Jobs in `app/jobs` are automatically loaded in Rails.
170
+
171
+ ## Testing
172
+
173
+ ```ruby
174
+ # In your test helper
175
+ require 'natswork/testing'
176
+
177
+ # Inline mode - jobs run immediately
178
+ NatsWork::Testing.inline! do
179
+ ProcessPaymentJob.perform_async(order.id, 99.99)
180
+ # Job has already completed
181
+ end
182
+
183
+ # Fake mode - jobs are stored for assertions
184
+ NatsWork::Testing.fake! do
185
+ ProcessPaymentJob.perform_async(order.id, 99.99)
186
+
187
+ expect(ProcessPaymentJob).to have_enqueued_job(order.id, 99.99)
188
+ end
189
+ ```
190
+
191
+ ## API Reference
192
+
193
+ See the [API documentation](https://rubydoc.info/gems/natswork-client) for detailed class and method documentation.
194
+
195
+ ## Contributing
196
+
197
+ Bug reports and pull requests are welcome at https://github.com/yourusername/natswork.
198
+
199
+ ## License
200
+
201
+ MIT License
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'natswork/client'
5
+
6
+ module ActiveJob
7
+ module QueueAdapters
8
+ class NatsworkAdapter
9
+ def enqueue(job)
10
+ NatsWork::Client.push(
11
+ job_class: job.class.name,
12
+ queue: job.queue_name || 'default',
13
+ arguments: job.serialize,
14
+ job_id: job.job_id,
15
+ metadata: {
16
+ 'active_job' => true,
17
+ 'priority' => job.priority,
18
+ 'enqueued_at' => Time.now.iso8601
19
+ }
20
+ )
21
+ end
22
+
23
+ def enqueue_at(job, timestamp)
24
+ NatsWork::Client.perform_at(
25
+ Time.at(timestamp),
26
+ job_class: job.class.name,
27
+ queue: job.queue_name || 'default',
28
+ arguments: job.serialize,
29
+ job_id: job.job_id,
30
+ metadata: {
31
+ 'active_job' => true,
32
+ 'priority' => job.priority,
33
+ 'scheduled_at' => Time.at(timestamp).iso8601
34
+ }
35
+ )
36
+ end
37
+
38
+ class << self
39
+ def enqueue(job)
40
+ new.enqueue(job)
41
+ end
42
+
43
+ def enqueue_at(job, timestamp)
44
+ new.enqueue_at(job, timestamp)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module Natswork
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ desc 'Installs NatsWork and creates configuration files'
9
+
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ def create_initializer_file
13
+ template 'natswork.rb.erb', 'config/initializers/natswork.rb'
14
+ end
15
+
16
+ def create_config_file
17
+ template 'natswork.yml.erb', 'config/natswork.yml'
18
+ end
19
+
20
+ def add_natswork_to_application
21
+ environment do
22
+ <<~RUBY
23
+ # Configure ActiveJob to use NatsWork
24
+ config.active_job.queue_adapter = :natswork
25
+ RUBY
26
+ end
27
+ end
28
+
29
+ def create_jobs_directory
30
+ empty_directory 'app/jobs'
31
+ end
32
+
33
+ def display_post_install_message
34
+ readme 'POST_INSTALL' if behavior == :invoke
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+ require 'rails/generators/active_job/job_generator'
5
+
6
+ module Natswork
7
+ module Generators
8
+ class JobGenerator < Rails::Generators::NamedBase
9
+ desc 'Creates a new NatsWork job class'
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ argument :arguments, type: :array, default: [], banner: 'argument:type argument:type'
14
+
15
+ class_option :queue, type: :string, default: 'default', desc: 'Queue name for the job'
16
+ class_option :retries, type: :numeric, default: 3, desc: 'Number of retries'
17
+ class_option :timeout, type: :numeric, default: 30, desc: 'Job timeout in seconds'
18
+
19
+ def create_job_file
20
+ template 'job.rb.erb', File.join('app/jobs', class_path, "#{file_name}_job.rb")
21
+ end
22
+
23
+ def create_test_file
24
+ if Rails.application.config.generators.test_framework == :rspec
25
+ template 'job_spec.rb.erb', File.join('spec/jobs', class_path, "#{file_name}_job_spec.rb")
26
+ else
27
+ template 'job_test.rb.erb', File.join('test/jobs', class_path, "#{file_name}_job_test.rb")
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def file_name
34
+ @file_name ||= name.underscore
35
+ end
36
+
37
+ def class_name
38
+ @class_name ||= name.camelize
39
+ end
40
+
41
+ def job_class_name
42
+ "#{class_name}Job"
43
+ end
44
+
45
+ def arguments_signature
46
+ arguments.map { |arg| arg.split(':').first }.join(', ')
47
+ end
48
+
49
+ def arguments_with_types
50
+ arguments.map do |arg|
51
+ name, type = arg.split(':')
52
+ type ? "#{name} # #{type}" : name
53
+ end.join(', ')
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,21 @@
1
+ class <%= job_class_name %> < NatsWork::Job
2
+ queue '<%= options[:queue] %>'
3
+ retries <%= options[:retries] %>
4
+ timeout <%= options[:timeout] %>
5
+
6
+ def perform(<%= arguments_with_types %>)
7
+ # TODO: Implement your job logic here
8
+ <% if arguments.any? -%>
9
+ # Arguments available: <%= arguments_signature %>
10
+ <% end -%>
11
+
12
+ # Example:
13
+ # process_data(<%= arguments_signature %>)
14
+ # send_notification
15
+ # update_records
16
+ end
17
+
18
+ private
19
+
20
+ # Add your helper methods here
21
+ end
@@ -0,0 +1,49 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe <%= job_class_name %>, type: :job do
4
+ describe '#perform' do
5
+ <% if arguments.any? -%>
6
+ let(:job) { described_class.new }
7
+ <% arguments.each do |arg| -%>
8
+ <% name, type = arg.split(':') -%>
9
+ let(:<%= name %>) { <%= type == 'integer' ? '123' : type == 'string' ? "'test'" : 'nil' %> }
10
+ <% end -%>
11
+
12
+ it 'performs the job successfully' do
13
+ expect {
14
+ job.perform(<%= arguments.map { |a| a.split(':').first }.join(', ') %>)
15
+ }.not_to raise_error
16
+ end
17
+ <% else -%>
18
+ it 'performs the job successfully' do
19
+ job = described_class.new
20
+ expect { job.perform }.not_to raise_error
21
+ end
22
+ <% end -%>
23
+ end
24
+
25
+ describe 'job configuration' do
26
+ it 'uses the correct queue' do
27
+ expect(described_class.get_queue).to eq('<%= options[:queue] %>')
28
+ end
29
+
30
+ it 'has the correct retry count' do
31
+ expect(described_class.get_retries).to eq(<%= options[:retries] %>)
32
+ end
33
+
34
+ it 'has the correct timeout' do
35
+ expect(described_class.get_timeout).to eq(<%= options[:timeout] %>)
36
+ end
37
+ end
38
+
39
+ describe '.perform_async' do
40
+ it 'enqueues the job' do
41
+ <% if arguments.any? -%>
42
+ job_id = described_class.perform_async(<%= arguments.map { |a| a.split(':').first }.join(', ') %>)
43
+ <% else -%>
44
+ job_id = described_class.perform_async
45
+ <% end -%>
46
+ expect(job_id).to be_present
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # NatsWork Configuration
2
+ require 'natswork'
3
+
4
+ NatsWork::Client.instance.configure do |config|
5
+ # NATS server connection
6
+ config.servers = ENV.fetch('NATSWORK_SERVERS', 'nats://localhost:4222').split(',')
7
+
8
+ # Connection pool settings
9
+ config.pool_size = ENV.fetch('NATSWORK_POOL_SIZE', 5).to_i
10
+ config.pool_timeout = ENV.fetch('NATSWORK_POOL_TIMEOUT', 5).to_i
11
+
12
+ # Job settings
13
+ config.max_retries = ENV.fetch('NATSWORK_MAX_RETRIES', 3).to_i
14
+ config.job_timeout = ENV.fetch('NATSWORK_JOB_TIMEOUT', 30).to_i
15
+ config.sync_timeout = ENV.fetch('NATSWORK_SYNC_TIMEOUT', 30).to_i
16
+
17
+ # Namespace for NATS subjects
18
+ config.namespace = ENV.fetch('NATSWORK_NAMESPACE', Rails.env)
19
+
20
+ # JetStream settings
21
+ config.use_jetstream = ENV.fetch('NATSWORK_USE_JETSTREAM', 'true') == 'true'
22
+
23
+ # Logging
24
+ config.logger = Rails.logger
25
+
26
+ # Authentication (optional)
27
+ # config.user = ENV['NATSWORK_USER']
28
+ # config.password = ENV['NATSWORK_PASSWORD']
29
+ # config.token = ENV['NATSWORK_TOKEN']
30
+ end
31
+
32
+ # Auto-load jobs from app/jobs in development
33
+ if Rails.env.development?
34
+ Rails.application.config.to_prepare do
35
+ Dir[Rails.root.join('app/jobs/**/*_job.rb')].each { |f| require f }
36
+ end
37
+ end
38
+
39
+ # Register jobs with the registry
40
+ Rails.application.config.after_initialize do
41
+ # Auto-discover and register all job classes
42
+ Dir[Rails.root.join('app/jobs/**/*_job.rb')].each do |file|
43
+ require file
44
+ class_name = File.basename(file, '.rb').camelize
45
+ job_class = class_name.safe_constantize
46
+
47
+ if job_class && job_class < NatsWork::Job
48
+ Rails.logger.info "[NatsWork] Registered job: #{job_class.name}"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsWork
4
+ class CircuitBreakerError < Error; end
5
+ class CircuitOpenError < CircuitBreakerError; end
6
+
7
+ class CircuitBreaker
8
+ STATES = %i[closed open half_open].freeze
9
+
10
+ attr_reader :state, :failure_count, :last_failure_time
11
+
12
+ def initialize(options = {})
13
+ @failure_threshold = options[:failure_threshold] || 5
14
+ @success_threshold = options[:success_threshold] || 2
15
+ @timeout = options[:timeout] || 60
16
+ @reset_timeout = options[:reset_timeout] || 60
17
+
18
+ @state = :closed
19
+ @failure_count = 0
20
+ @success_count = 0
21
+ @last_failure_time = nil
22
+ @mutex = Mutex.new
23
+ @on_open = options[:on_open]
24
+ @on_close = options[:on_close]
25
+ @on_half_open = options[:on_half_open]
26
+ end
27
+
28
+ def call
29
+ @mutex.synchronize do
30
+ case @state
31
+ when :open
32
+ raise CircuitOpenError, 'Circuit breaker is open' unless can_attempt_reset?
33
+
34
+ transition_to(:half_open)
35
+
36
+ when :half_open
37
+ # Allow the attempt in half-open state
38
+ when :closed
39
+ # Allow the attempt in closed state
40
+ end
41
+ end
42
+
43
+ begin
44
+ result = yield
45
+ on_success
46
+ result
47
+ rescue StandardError => e
48
+ on_failure(e)
49
+ raise
50
+ end
51
+ end
52
+
53
+ def closed?
54
+ @state == :closed
55
+ end
56
+
57
+ def open?
58
+ @state == :open
59
+ end
60
+
61
+ def half_open?
62
+ @state == :half_open
63
+ end
64
+
65
+ def reset
66
+ @mutex.synchronize do
67
+ @failure_count = 0
68
+ @success_count = 0
69
+ @last_failure_time = nil
70
+ transition_to(:closed)
71
+ end
72
+ end
73
+
74
+ def trip
75
+ @mutex.synchronize do
76
+ transition_to(:open)
77
+ end
78
+ end
79
+
80
+ def stats
81
+ @mutex.synchronize do
82
+ {
83
+ state: @state,
84
+ failure_count: @failure_count,
85
+ success_count: @success_count,
86
+ last_failure_time: @last_failure_time,
87
+ time_until_retry: time_until_retry
88
+ }
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def on_success
95
+ @mutex.synchronize do
96
+ case @state
97
+ when :half_open
98
+ @success_count += 1
99
+ if @success_count >= @success_threshold
100
+ @failure_count = 0
101
+ @success_count = 0
102
+ transition_to(:closed)
103
+ end
104
+ when :closed
105
+ @failure_count = 0 if @failure_count.positive?
106
+ end
107
+ end
108
+ end
109
+
110
+ def on_failure(_error)
111
+ @mutex.synchronize do
112
+ @last_failure_time = Time.now
113
+
114
+ case @state
115
+ when :half_open
116
+ @failure_count += 1
117
+ transition_to(:open)
118
+ when :closed
119
+ @failure_count += 1
120
+ transition_to(:open) if @failure_count >= @failure_threshold
121
+ end
122
+ end
123
+ end
124
+
125
+ def can_attempt_reset?
126
+ return false unless @last_failure_time
127
+
128
+ Time.now - @last_failure_time >= @reset_timeout
129
+ end
130
+
131
+ def time_until_retry
132
+ return 0 unless @last_failure_time && @state == :open
133
+
134
+ elapsed = Time.now - @last_failure_time
135
+ remaining = @reset_timeout - elapsed
136
+ remaining.positive? ? remaining : 0
137
+ end
138
+
139
+ def transition_to(new_state)
140
+ return if @state == new_state
141
+
142
+ @state = new_state
143
+ @success_count = 0 if new_state == :half_open
144
+
145
+ case new_state
146
+ when :open
147
+ @on_open&.call
148
+ when :closed
149
+ @on_close&.call
150
+ when :half_open
151
+ @on_half_open&.call
152
+ end
153
+ end
154
+ end
155
+
156
+ class CircuitBreakedConnection
157
+ attr_reader :connection, :circuit_breaker
158
+
159
+ def initialize(connection, circuit_breaker_options = {})
160
+ @connection = connection
161
+ @circuit_breaker = CircuitBreaker.new(circuit_breaker_options)
162
+ end
163
+
164
+ def connect
165
+ @circuit_breaker.call { @connection.connect }
166
+ end
167
+
168
+ def disconnect
169
+ @connection.disconnect
170
+ end
171
+
172
+ def connected?
173
+ @connection.connected?
174
+ end
175
+
176
+ def publish(subject, payload)
177
+ @circuit_breaker.call { @connection.publish(subject, payload) }
178
+ end
179
+
180
+ def subscribe(subject, opts = {}, &block)
181
+ @circuit_breaker.call { @connection.subscribe(subject, opts, &block) }
182
+ end
183
+
184
+ def request(subject, payload, opts = {})
185
+ @circuit_breaker.call { @connection.request(subject, payload, opts) }
186
+ end
187
+
188
+ def unsubscribe(sid)
189
+ @circuit_breaker.call { @connection.unsubscribe(sid) }
190
+ end
191
+
192
+ def with_connection(&block)
193
+ @circuit_breaker.call { @connection.with_connection(&block) }
194
+ end
195
+
196
+ def jetstream
197
+ @circuit_breaker.call { @connection.jetstream }
198
+ end
199
+
200
+ def stats
201
+ connection_stats = @connection.stats
202
+ connection_stats[:circuit_breaker] = @circuit_breaker.stats
203
+ connection_stats
204
+ end
205
+
206
+ def healthy?
207
+ @circuit_breaker.closed? && @connection.healthy?
208
+ end
209
+
210
+ def ping
211
+ @circuit_breaker.call { @connection.ping }
212
+ rescue CircuitOpenError
213
+ false
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
+ end
229
+ end