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 +4 -4
- data/README.md +35 -0
- data/app/models/concerns/artery/message_model.rb +1 -3
- data/db/migrate/20260326132618_add_last_published_id_to_artery_model_infos.rb +7 -0
- data/exe/artery-publisher +9 -0
- data/lib/artery/active_record/message.rb +2 -16
- data/lib/artery/active_record/model_info.rb +14 -0
- data/lib/artery/config.rb +5 -1
- data/lib/artery/log_subscriber.rb +11 -0
- data/lib/artery/model/callbacks.rb +7 -36
- data/lib/artery/publisher.rb +94 -0
- data/lib/artery/version.rb +1 -1
- data/lib/artery.rb +1 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d38d9b951e14faf5d0a8950f6e1d23dd7d20e9c0ec0599daceaefcbbf5279a86
|
|
4
|
+
data.tar.gz: 624da77476449143e5298b6da48d39a1e7e0502efa574c146dd6ab5f9857c0ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -9,10 +9,7 @@ module Artery
|
|
|
9
9
|
|
|
10
10
|
serialize :data, coder: JSON
|
|
11
11
|
|
|
12
|
-
after_commit :
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
artery_notify_message(:create)
|
|
27
23
|
end
|
|
28
24
|
|
|
29
25
|
def artery_on_update
|
|
30
|
-
|
|
26
|
+
artery_notify_message(:update)
|
|
31
27
|
end
|
|
32
28
|
|
|
33
29
|
def artery_on_archive
|
|
34
|
-
|
|
30
|
+
artery_notify_message(:archive, archived_at: archived_at.to_f)
|
|
35
31
|
end
|
|
36
32
|
|
|
37
33
|
def artery_on_unarchive
|
|
38
|
-
|
|
34
|
+
artery_notify_message(:unarchive)
|
|
39
35
|
end
|
|
40
36
|
|
|
41
37
|
def artery_on_destroy
|
|
42
|
-
|
|
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
|
data/lib/artery/version.rb
CHANGED
data/lib/artery.rb
CHANGED
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.
|
|
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
|