artery 1.3.1 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e07fd862261dcd6ea03bcdaeaa8ba1105f4acbdbd19a7be3c1a3a475955964a7
4
- data.tar.gz: bad3ae0d8a661dde23887d06c52ef073f24b43f3774562525094430eb5882a55
3
+ metadata.gz: d38d9b951e14faf5d0a8950f6e1d23dd7d20e9c0ec0599daceaefcbbf5279a86
4
+ data.tar.gz: 624da77476449143e5298b6da48d39a1e7e0502efa574c146dd6ab5f9857c0ea
5
5
  SHA512:
6
- metadata.gz: e6e7f7bd693b96dc421b6673ea818515546415c7a016364aff407de07b18e4b76d6a10dd59a34ea9f29b9f5cfddc39ad81fe1f816fa166c2bc135fd8105bf225
7
- data.tar.gz: 6515ae08facc636e0334b6bddf24d37c4dbf8136d0f90db342771f346c68f302fa114368a707becc2da4e2b868369e05d6db5006971c12e00ad6e34372240b87
6
+ metadata.gz: afbb2b2cd62233852531faa4bc2a45487e1f78a719f939b23aec251592578dd4b451e7520f8491ac697bddd25a1d53537abca7759d6d6320863038b56b572272
7
+ data.tar.gz: 331626ae3d7bc48b488875918e7f7bc6b16f986e9238397a35a8acefd6f54d6347ca32e2f59f263b8562e8d5f07b864e171756f6146599816801fddbd6b2c136
data/README.md CHANGED
@@ -27,6 +27,32 @@ $ rake artery:install:migrations
27
27
  $ rake db:migrate
28
28
  ```
29
29
 
30
+ ## Publishing modes
31
+
32
+ Artery supports two modes for publishing messages to NATS:
33
+
34
+ ### Inline mode (`inline_publish = true`, default)
35
+
36
+ Messages are published directly from the `after_commit` callback. No additional processes required -- convenient for development and testing. However, under high concurrency `after_commit` callbacks can execute out of order across transactions, which may lead to incorrect `_previous_index` values. **Recommended for development only.**
37
+
38
+ ### Publisher mode (`inline_publish = false`)
39
+
40
+ A separate Publisher process polls `artery_messages` and publishes them to NATS in strict `id` order, guaranteeing a correct `_previous_index` chain. Messages are persisted inside the model transaction (`before_commit`) without any locks, so there is no contention overhead. **Recommended for production.**
41
+
42
+ ```ruby
43
+ Artery.configure do |config|
44
+ config.inline_publish = false
45
+ end
46
+ ```
47
+
48
+ Running the publisher:
49
+
50
+ ```bash
51
+ $ bundle exec artery-publisher
52
+ ```
53
+
54
+ The publisher uses a `concurrent-ruby` thread pool. Pool size is controlled by `RAILS_MAX_THREADS` (default 5). Each model gets its own thread that polls for unpublished messages.
55
+
30
56
  ## Admin interface
31
57
 
32
58
  In admin interface you can list your artery endpoints and check their statuses.
@@ -46,6 +72,11 @@ Artery uses `ActiveSupport::Notifications` for instrumentation and `ActiveSuppor
46
72
  Artery.configure do |config|
47
73
  config.service_name = :my_service
48
74
 
75
+ # When true, messages are published inline from after_commit (no publisher needed).
76
+ # Set to false in production when running the publisher process.
77
+ # Default: true
78
+ config.inline_publish = false
79
+
49
80
  # Log every message (publish/request/subscribe/response).
50
81
  # When false, only lifecycle events (errors, sync, connect/disconnect) are logged.
51
82
  # Default: true
@@ -111,6 +142,10 @@ Available events (each uses a `stage:`, `state:`, or `action:` payload key to di
111
142
  | | | `:subscribing` | `route` | Subscribing to route |
112
143
  | `lock.artery` | `state` | `:waiting` | `latest_index` | Waiting for subscription lock |
113
144
  | | | `:acquired` | `latest_index` | Lock acquired |
145
+ | `publisher.artery` | `action` | `:started` | — | Publisher loop started |
146
+ | | | `:model_started` | `model` | Model polling thread started |
147
+ | | | `:publishing` | `model`, `count` | Batch published (block, has duration) |
148
+ | | | `:error` | `model`, `error` | Publisher error for model |
114
149
 
115
150
  ## Contributing
116
151
  Contribution directions go here.
@@ -44,9 +44,7 @@ module Artery
44
44
  raise NotImplementedError
45
45
  end
46
46
 
47
- protected
48
-
49
- def send_to_artery
47
+ def publish_to_artery(previous_index: self.previous_index)
50
48
  Artery.publish route, to_artery.merge('_previous_index' => previous_index)
51
49
  end
52
50
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddLastPublishedIdToArteryModelInfos < ActiveRecord::Migration[5.2]
4
+ def change
5
+ add_column :artery_model_infos, :last_published_id, :bigint, null: false, default: 0
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require ENV['APP_PATH'] || File.join(File.expand_path('.'), 'config', 'application')
5
+
6
+ Rails.application.initialize!
7
+ Rails.application.eager_load!
8
+
9
+ Artery::Publisher.new.run
@@ -9,10 +9,7 @@ module Artery
9
9
 
10
10
  serialize :data, coder: JSON
11
11
 
12
- after_commit :send_to_artery, on: :create
13
- around_create :lock_on_model
14
-
15
- attr_accessor :cached_previous_index
12
+ after_commit :publish_to_artery, on: :create, if: -> { Artery.inline_publish? }
16
13
 
17
14
  alias index id
18
15
 
@@ -23,7 +20,7 @@ module Artery
23
20
  end
24
21
 
25
22
  def latest_index(model)
26
- Artery.model_info_class.find_by(model: model)&.latest_index.to_i
23
+ where(model: model).maximum(:id).to_i
27
24
  end
28
25
 
29
26
  def delete_old
@@ -33,22 +30,11 @@ module Artery
33
30
  end
34
31
 
35
32
  def previous_index
36
- return cached_previous_index if cached_previous_index
37
-
38
33
  scope = self.class.where(model: model).order(:id)
39
34
  scope = scope.where(self.class.arel_table[:id].lt(index)) if index
40
35
 
41
36
  scope.select(:id).last&.id.to_i
42
37
  end
43
-
44
- private
45
-
46
- def lock_on_model
47
- lock_row = Artery.model_info_class.acquire_lock!(model)
48
- self.cached_previous_index = lock_row.latest_index
49
- yield
50
- lock_row.update!(latest_index: id)
51
- end
52
38
  end
53
39
  end
54
40
  end
@@ -5,6 +5,10 @@ module Artery
5
5
  class ModelInfo < ::ActiveRecord::Base
6
6
  self.table_name = 'artery_model_infos'
7
7
 
8
+ # @deprecated The +latest_index+ column is no longer maintained.
9
+ # Use {Artery::ActiveRecord::Message.latest_index} instead.
10
+
11
+ # @deprecated No longer used. Publishing is handled by {Artery::Publisher}.
8
12
  def self.acquire_lock!(model_name)
9
13
  lock_row = lock('FOR UPDATE').find_by(model: model_name)
10
14
  return lock_row if lock_row
@@ -17,6 +21,16 @@ module Artery
17
21
 
18
22
  lock('FOR UPDATE').find_by!(model: model_name)
19
23
  end
24
+
25
+ def self.ensure_initialized!(model_name)
26
+ row = find_or_create_by!(model: model_name) do |r|
27
+ r.latest_index = Artery.message_class.latest_index(model_name)
28
+ end
29
+
30
+ row.update!(last_published_id: row.latest_index) if row.last_published_id.zero? && row.latest_index.positive?
31
+
32
+ row
33
+ end
20
34
  end
21
35
  end
22
36
  end
data/lib/artery/config.rb CHANGED
@@ -39,7 +39,7 @@ module Artery
39
39
  @request_timeout || ENV.fetch('ARTERY_REQUEST_TIMEOUT', '15').to_i
40
40
  end
41
41
 
42
- attr_writer :log_messages, :message_body_max_size
42
+ attr_writer :log_messages, :message_body_max_size, :inline_publish
43
43
 
44
44
  def log_messages?
45
45
  instance_variable_defined?(:@log_messages) ? @log_messages : true
@@ -49,6 +49,10 @@ module Artery
49
49
  instance_variable_defined?(:@message_body_max_size) ? @message_body_max_size : nil
50
50
  end
51
51
 
52
+ def inline_publish?
53
+ instance_variable_defined?(:@inline_publish) ? @inline_publish : true
54
+ end
55
+
52
56
  def error_handler
53
57
  @error_handler || (defined?(Artery::SentryErrorHandler) ? Artery::SentryErrorHandler : Artery::ErrorHandler)
54
58
  end
@@ -66,6 +66,17 @@ module Artery
66
66
  end
67
67
  end
68
68
 
69
+ def publisher(event)
70
+ p = event.payload
71
+
72
+ case p[:action]
73
+ when :started then info 'started'
74
+ when :model_started then info 'polling started'
75
+ when :publishing then debug "published #{p[:count]} messages (#{event.duration.round(1)}ms)"
76
+ when :error then error p[:error]
77
+ end
78
+ end
79
+
69
80
  private
70
81
 
71
82
  def debug(msg)
@@ -14,57 +14,28 @@ module Artery
14
14
  after_archive :artery_on_archive
15
15
  after_unarchive :artery_on_unarchive
16
16
  end
17
-
18
- if artery[:non_atomic_notification]
19
- after_commit :artery_send_pending_notifications
20
- else
21
- before_commit :artery_send_pending_notifications
22
- end
23
17
  end
24
18
 
19
+ private
20
+
25
21
  def artery_on_create
26
- artery_pending_notifications << [:create]
22
+ artery_notify_message(:create)
27
23
  end
28
24
 
29
25
  def artery_on_update
30
- artery_pending_notifications << [:update]
26
+ artery_notify_message(:update)
31
27
  end
32
28
 
33
29
  def artery_on_archive
34
- artery_pending_notifications << [:archive, { archived_at: archived_at.to_f }]
30
+ artery_notify_message(:archive, archived_at: archived_at.to_f)
35
31
  end
36
32
 
37
33
  def artery_on_unarchive
38
- artery_pending_notifications << [:unarchive]
34
+ artery_notify_message(:unarchive)
39
35
  end
40
36
 
41
37
  def artery_on_destroy
42
- artery_pending_notifications << [:delete]
43
- end
44
-
45
- private
46
-
47
- def artery_pending_notifications
48
- @artery_pending_notifications ||= []
49
- end
50
-
51
- def artery_send_pending_notifications
52
- attempts = 0
53
- begin
54
- artery_pending_notifications.each do |action, extra_data|
55
- artery_notify_message(action, extra_data || {})
56
- end
57
- rescue StandardError => e
58
- attempts += 1
59
- retry if self.class.artery[:non_atomic_notification] && attempts <= 3
60
-
61
- Artery.handle_error Artery::Error.new(
62
- "Failed to send artery notifications after #{attempts} attempts: #{e.message}",
63
- original_exception: e
64
- )
65
- ensure
66
- @artery_pending_notifications = nil
67
- end
38
+ artery_notify_message(:delete)
68
39
  end
69
40
  end
70
41
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module Artery
6
+ class Publisher
7
+ DISCOVERY_INTERVAL = 5
8
+ POLL_INTERVAL = 0.5
9
+ BATCH_SIZE = 100
10
+
11
+ def run
12
+ Artery.handle_signals { shutdown }
13
+ Artery.start { publisher_loop }
14
+ end
15
+
16
+ def shutdown
17
+ @pool&.shutdown
18
+ @pool&.wait_for_termination(10)
19
+ end
20
+
21
+ private
22
+
23
+ def publisher_loop
24
+ @pool = Concurrent::ThreadPoolExecutor.new(
25
+ min_threads: 1,
26
+ max_threads: ENV.fetch('RAILS_MAX_THREADS', 5).to_i,
27
+ max_queue: 0,
28
+ fallback_policy: :caller_runs
29
+ )
30
+ @running_models = Concurrent::Set.new
31
+
32
+ Instrumentation.instrument(:publisher, action: :started)
33
+
34
+ loop do
35
+ models = Artery.model_info_class.pluck(:model)
36
+
37
+ models.each do |model|
38
+ next if @running_models.include?(model)
39
+
40
+ @running_models.add(model)
41
+ @pool.post { model_loop(model) }
42
+ end
43
+
44
+ sleep DISCOVERY_INTERVAL
45
+ end
46
+ end
47
+
48
+ def model_loop(model)
49
+ Artery.logger.tagged('Publisher', model) do
50
+ Artery.model_info_class.ensure_initialized!(model)
51
+ Instrumentation.instrument(:publisher, action: :model_started, model: model)
52
+
53
+ loop do
54
+ published = publish_batch(model)
55
+ sleep POLL_INTERVAL if published < BATCH_SIZE
56
+ end
57
+ rescue StandardError => e
58
+ Instrumentation.instrument(:publisher, action: :error, model: model, error: e.message)
59
+ Artery.handle_error Error.new(
60
+ "Publisher error for #{model}: #{e.message}",
61
+ original_exception: e
62
+ )
63
+ sleep POLL_INTERVAL
64
+ retry
65
+ end
66
+ ensure
67
+ @running_models.delete(model)
68
+ end
69
+
70
+ def publish_batch(model)
71
+ Artery.model_info_class.transaction do
72
+ row = Artery.model_info_class.lock('FOR UPDATE').find_by!(model: model)
73
+
74
+ scope = Artery.message_class.where(model: model)
75
+ messages = scope.where(scope.arel_table[:id].gt(row.last_published_id))
76
+ .order(:id)
77
+ .limit(BATCH_SIZE)
78
+
79
+ return 0 if messages.empty?
80
+
81
+ prev_index = row.last_published_id
82
+ Instrumentation.instrument(:publisher, action: :publishing, model: model, count: messages.size) do
83
+ messages.each do |msg|
84
+ msg.publish_to_artery(previous_index: prev_index)
85
+ prev_index = msg.id
86
+ end
87
+ end
88
+
89
+ row.update!(last_published_id: prev_index)
90
+ messages.size
91
+ end
92
+ end
93
+ end
94
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Artery
4
- VERSION = '1.3.1'
4
+ VERSION = '1.4.0'
5
5
  end
data/lib/artery.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'multiblock_has_block'
12
12
 
13
13
  module Artery
14
14
  autoload :Config, 'artery/config'
15
+ autoload :Publisher, 'artery/publisher'
15
16
  autoload :Worker, 'artery/worker'
16
17
  autoload :Sync, 'artery/sync'
17
18
  autoload :Check, 'artery/check'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: artery
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Gnuskov
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: concurrent-ruby
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: nats-pure
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -82,6 +96,7 @@ email:
82
96
  executables:
83
97
  - artery-check
84
98
  - artery-clean
99
+ - artery-publisher
85
100
  - artery-sync
86
101
  - artery-worker
87
102
  extensions: []
@@ -101,8 +116,10 @@ files:
101
116
  - db/migrate/20200109120305_remove_last_message_at_from_artery_subscription_infos.rb
102
117
  - db/migrate/20240411120304_add_synchronization_heartbeat_to_artery_subscription_infos.rb
103
118
  - db/migrate/20250320120000_create_artery_model_infos.rb
119
+ - db/migrate/20260326132618_add_last_published_id_to_artery_model_infos.rb
104
120
  - exe/artery-check
105
121
  - exe/artery-clean
122
+ - exe/artery-publisher
106
123
  - exe/artery-sync
107
124
  - exe/artery-worker
108
125
  - lib/artery.rb
@@ -127,6 +144,7 @@ files:
127
144
  - lib/artery/no_brainer.rb
128
145
  - lib/artery/no_brainer/message.rb
129
146
  - lib/artery/no_brainer/subscription_info.rb
147
+ - lib/artery/publisher.rb
130
148
  - lib/artery/routing.rb
131
149
  - lib/artery/subscription.rb
132
150
  - lib/artery/subscription/incoming_message.rb