jetstream_bridge 1.5.0 → 1.7.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/.idea/dictionaries/project.xml +2 -0
- data/.idea/jetstream_bridge.iml +6 -1
- data/.rubocop.yml +102 -0
- data/Gemfile.lock +1 -5
- data/README.md +163 -78
- data/jetstream_bridge.gemspec +9 -10
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +16 -0
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +24 -0
- data/lib/generators/jetstream_bridge/install/install_generator.rb +19 -0
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +44 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb +24 -0
- data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb +21 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +103 -0
- data/lib/jetstream_bridge/{consumer_config.rb → consumer/consumer_config.rb} +3 -3
- data/lib/jetstream_bridge/consumer/inbox/inbox_message.rb +50 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +51 -0
- data/lib/jetstream_bridge/consumer/inbox/inbox_repository.rb +102 -0
- data/lib/jetstream_bridge/{message_processor.rb → consumer/message_processor.rb} +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +91 -0
- data/lib/jetstream_bridge/{connection.rb → core/connection.rb} +1 -1
- data/lib/jetstream_bridge/core/model_utils.rb +51 -0
- data/lib/jetstream_bridge/models/inbox_event.rb +98 -0
- data/lib/jetstream_bridge/models/outbox_event.rb +114 -0
- data/lib/jetstream_bridge/publisher/outbox_repository.rb +70 -0
- data/lib/jetstream_bridge/{publisher.rb → publisher/publisher.rb} +41 -4
- data/lib/jetstream_bridge/railtie.rb +12 -0
- data/lib/jetstream_bridge/tasks/install.rake +10 -0
- data/lib/jetstream_bridge/{overlap_guard.rb → topology/overlap_guard.rb} +6 -4
- data/lib/jetstream_bridge/topology/stream.rb +129 -0
- data/lib/jetstream_bridge/{topology.rb → topology/topology.rb} +2 -2
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +35 -23
- metadata +49 -49
- data/lib/jetstream_bridge/consumer.rb +0 -136
- data/lib/jetstream_bridge/dlq.rb +0 -24
- data/lib/jetstream_bridge/inbox_event.rb +0 -46
- data/lib/jetstream_bridge/outbox_event.rb +0 -60
- data/lib/jetstream_bridge/stream.rb +0 -114
- /data/lib/jetstream_bridge/{config.rb → core/config.rb} +0 -0
- /data/lib/jetstream_bridge/{duration.rb → core/duration.rb} +0 -0
- /data/lib/jetstream_bridge/{logging.rb → core/logging.rb} +0 -0
- /data/lib/jetstream_bridge/{subject_matcher.rb → topology/subject_matcher.rb} +0 -0
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
require 'rails/generators/active_record'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
module Generators
|
8
|
+
# Migrations generator.
|
9
|
+
class MigrationsGenerator < Rails::Generators::Base
|
10
|
+
include Rails::Generators::Migration
|
11
|
+
source_root File.expand_path('templates', __dir__)
|
12
|
+
desc 'Creates Inbox/Outbox migrations for JetstreamBridge'
|
13
|
+
|
14
|
+
def create_outbox_migration
|
15
|
+
name = 'create_jetstream_outbox_events'
|
16
|
+
return say_status :skip, "migration #{name} already exists", :yellow if migration_exists?('db/migrate', name)
|
17
|
+
|
18
|
+
migration_template 'create_jetstream_outbox_events.rb.erb', "db/migrate/#{name}.rb"
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_inbox_migration
|
22
|
+
name = 'create_jetstream_inbox_events'
|
23
|
+
return say_status :skip, "migration #{name} already exists", :yellow if migration_exists?('db/migrate', name)
|
24
|
+
|
25
|
+
migration_template 'create_jetstream_inbox_events.rb.erb', "db/migrate/#{name}.rb"
|
26
|
+
end
|
27
|
+
|
28
|
+
# -- Rails::Generators::Migration plumbing --
|
29
|
+
def self.next_migration_number(dirname)
|
30
|
+
if ActiveRecord::Base.timestamped_migrations
|
31
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
32
|
+
else
|
33
|
+
format('%.3d', current_migration_number(dirname) + 1)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def migration_exists?(dirname, file_name)
|
40
|
+
Dir.glob(File.join(dirname, '[0-9]*_*.rb')).grep(/\d+_#{file_name}\.rb$/).any?
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_inbox_events.rb.erb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateJetstreamInboxEvents < ActiveRecord::Migration[7.0]
|
4
|
+
def change
|
5
|
+
create_table :jetstream_inbox_events do |t|
|
6
|
+
t.string :event_id # preferred dedupe key
|
7
|
+
t.string :subject, null: false
|
8
|
+
t.jsonb :payload, null: false, default: {}
|
9
|
+
t.jsonb :headers, null: false, default: {}
|
10
|
+
t.string :stream
|
11
|
+
t.bigint :stream_seq
|
12
|
+
t.integer :deliveries
|
13
|
+
t.string :status, null: false, default: 'received' # received|processing|processed|failed
|
14
|
+
t.text :last_error
|
15
|
+
t.datetime :received_at
|
16
|
+
t.datetime :processed_at
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
|
20
|
+
add_index :jetstream_inbox_events, :event_id, unique: true, where: 'event_id IS NOT NULL'
|
21
|
+
add_index :jetstream_inbox_events, [:stream, :stream_seq], unique: true, where: 'stream IS NOT NULL AND stream_seq IS NOT NULL'
|
22
|
+
add_index :jetstream_inbox_events, :status
|
23
|
+
end
|
24
|
+
end
|
data/lib/generators/jetstream_bridge/migrations/templates/create_jetstream_outbox_events.rb.erb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateJetstreamOutboxEvents < ActiveRecord::Migration[7.0]
|
4
|
+
def change
|
5
|
+
create_table :jetstream_outbox_events do |t|
|
6
|
+
t.string :event_id, null: false
|
7
|
+
t.string :subject, null: false
|
8
|
+
t.jsonb :payload, null: false, default: {}
|
9
|
+
t.jsonb :headers, null: false, default: {}
|
10
|
+
t.string :status, null: false, default: 'pending' # pending|publishing|sent|failed
|
11
|
+
t.integer :attempts, null: false, default: 0
|
12
|
+
t.text :last_error
|
13
|
+
t.datetime :enqueued_at
|
14
|
+
t.datetime :sent_at
|
15
|
+
t.timestamps
|
16
|
+
end
|
17
|
+
|
18
|
+
add_index :jetstream_outbox_events, :event_id, unique: true
|
19
|
+
add_index :jetstream_outbox_events, :status
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
require_relative '../core/connection'
|
6
|
+
require_relative '../core/duration'
|
7
|
+
require_relative '../core/logging'
|
8
|
+
require_relative '../core/config'
|
9
|
+
require_relative '../core/model_utils'
|
10
|
+
require_relative 'consumer_config'
|
11
|
+
require_relative 'message_processor'
|
12
|
+
require_relative 'subscription_manager'
|
13
|
+
require_relative 'inbox/inbox_processor'
|
14
|
+
|
15
|
+
module JetstreamBridge
|
16
|
+
# Subscribes to "{env}.data.sync.{dest}.{app}" and processes messages.
|
17
|
+
class Consumer
|
18
|
+
DEFAULT_BATCH_SIZE = 25
|
19
|
+
FETCH_TIMEOUT_SECS = 5
|
20
|
+
IDLE_SLEEP_SECS = 0.05
|
21
|
+
|
22
|
+
def initialize(durable_name:, batch_size: DEFAULT_BATCH_SIZE, &block)
|
23
|
+
@handler = block
|
24
|
+
@batch_size = batch_size
|
25
|
+
@durable = durable_name
|
26
|
+
@jts = Connection.connect!
|
27
|
+
|
28
|
+
ensure_destination!
|
29
|
+
|
30
|
+
@sub_mgr = SubscriptionManager.new(@jts, @durable, JetstreamBridge.config)
|
31
|
+
@sub_mgr.ensure_consumer!
|
32
|
+
@psub = @sub_mgr.subscribe!
|
33
|
+
|
34
|
+
@processor = MessageProcessor.new(@jts, @handler)
|
35
|
+
@inbox_proc = InboxProcessor.new(@processor) if JetstreamBridge.config.use_inbox
|
36
|
+
end
|
37
|
+
|
38
|
+
def run!
|
39
|
+
Logging.info("Consumer #{@durable} started…", tag: 'JetstreamBridge::Consumer')
|
40
|
+
loop do
|
41
|
+
processed = process_batch
|
42
|
+
sleep(IDLE_SLEEP_SECS) if processed.zero?
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def ensure_destination!
|
49
|
+
return unless JetstreamBridge.config.destination_app.to_s.empty?
|
50
|
+
|
51
|
+
raise ArgumentError, 'destination_app must be configured'
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns number of messages processed; 0 on timeout/idle or after recovery.
|
55
|
+
def process_batch
|
56
|
+
msgs = fetch_messages
|
57
|
+
process_messages(msgs)
|
58
|
+
rescue NATS::Timeout, NATS::IO::Timeout
|
59
|
+
0
|
60
|
+
rescue NATS::JetStream::Error => e
|
61
|
+
handle_js_error(e)
|
62
|
+
end
|
63
|
+
|
64
|
+
# --- helpers ---
|
65
|
+
|
66
|
+
def fetch_messages
|
67
|
+
@psub.fetch(@batch_size, timeout: FETCH_TIMEOUT_SECS)
|
68
|
+
end
|
69
|
+
|
70
|
+
def process_messages(msgs)
|
71
|
+
msgs.sum { |m| process_one(m) }
|
72
|
+
end
|
73
|
+
|
74
|
+
def process_one(m)
|
75
|
+
if @inbox_proc
|
76
|
+
@inbox_proc.process(m) ? 1 : 0
|
77
|
+
else
|
78
|
+
@processor.handle_message(m)
|
79
|
+
1
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def handle_js_error(e)
|
84
|
+
if recoverable_consumer_error?(e)
|
85
|
+
Logging.warn("Recovering subscription after error: #{e.class} #{e.message}",
|
86
|
+
tag: 'JetstreamBridge::Consumer')
|
87
|
+
@sub_mgr.ensure_consumer!
|
88
|
+
@psub = @sub_mgr.subscribe!
|
89
|
+
else
|
90
|
+
Logging.error("Fetch failed: #{e.class} #{e.message}",
|
91
|
+
tag: 'JetstreamBridge::Consumer')
|
92
|
+
end
|
93
|
+
0
|
94
|
+
end
|
95
|
+
|
96
|
+
def recoverable_consumer_error?(error)
|
97
|
+
msg = error.message.to_s
|
98
|
+
msg =~ /consumer.*(not\s+found|deleted)/i ||
|
99
|
+
msg =~ /no\s+responders/i ||
|
100
|
+
msg =~ /stream.*not\s+found/i
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'duration'
|
4
|
-
require_relative '
|
5
|
-
require_relative '
|
3
|
+
require_relative '../core/duration'
|
4
|
+
require_relative '../core/config'
|
5
|
+
require_relative '../core/logging'
|
6
6
|
|
7
7
|
module JetstreamBridge
|
8
8
|
# Consumer configuration helpers.
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module JetstreamBridge
|
6
|
+
# Immutable value object for a single NATS message.
|
7
|
+
class InboxMessage
|
8
|
+
attr_reader :msg, :seq, :deliveries, :stream, :subject, :headers, :body, :raw, :event_id, :now
|
9
|
+
|
10
|
+
def self.from_nats(m)
|
11
|
+
meta = (m.respond_to?(:metadata) && m.metadata) || nil
|
12
|
+
seq = meta.respond_to?(:stream_sequence) ? meta.stream_sequence : nil
|
13
|
+
deliveries = meta.respond_to?(:num_delivered) ? meta.num_delivered : nil
|
14
|
+
stream = meta.respond_to?(:stream) ? meta.stream : nil
|
15
|
+
subject = m.subject.to_s
|
16
|
+
|
17
|
+
headers = {}
|
18
|
+
(m.header || {}).each { |k, v| headers[k.to_s.downcase] = v }
|
19
|
+
|
20
|
+
raw = m.data
|
21
|
+
body = begin
|
22
|
+
JSON.parse(raw)
|
23
|
+
rescue StandardError
|
24
|
+
{}
|
25
|
+
end
|
26
|
+
|
27
|
+
id = (headers['nats-msg-id'] || body['event_id']).to_s.strip
|
28
|
+
id = "seq:#{seq}" if id.empty?
|
29
|
+
|
30
|
+
new(m, seq, deliveries, stream, subject, headers, body, raw, id, Time.now.utc)
|
31
|
+
end
|
32
|
+
|
33
|
+
def initialize(m, seq, deliveries, stream, subject, headers, body, raw, event_id, now)
|
34
|
+
@msg = m
|
35
|
+
@seq = seq
|
36
|
+
@deliveries = deliveries
|
37
|
+
@stream = stream
|
38
|
+
@subject = subject
|
39
|
+
@headers = headers
|
40
|
+
@body = body
|
41
|
+
@raw = raw
|
42
|
+
@event_id = event_id
|
43
|
+
@now = now
|
44
|
+
end
|
45
|
+
|
46
|
+
def body_for_store
|
47
|
+
body.empty? ? raw : body
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../..//core/logging'
|
4
|
+
require_relative '../../core/model_utils'
|
5
|
+
require_relative 'inbox_message'
|
6
|
+
require_relative 'inbox_repository'
|
7
|
+
|
8
|
+
module JetstreamBridge
|
9
|
+
# Orchestrates AR-backed inbox processing.
|
10
|
+
class InboxProcessor
|
11
|
+
def initialize(message_processor)
|
12
|
+
@processor = message_processor
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [true,false] processed?
|
16
|
+
def process(m)
|
17
|
+
klass = ModelUtils.constantize(JetstreamBridge.config.inbox_model)
|
18
|
+
return process_direct(m, klass) unless ModelUtils.ar_class?(klass)
|
19
|
+
|
20
|
+
msg = InboxMessage.from_nats(m)
|
21
|
+
repo = InboxRepository.new(klass)
|
22
|
+
record = repo.find_or_build(msg)
|
23
|
+
|
24
|
+
if repo.already_processed?(record)
|
25
|
+
m.ack
|
26
|
+
return true
|
27
|
+
end
|
28
|
+
|
29
|
+
repo.persist_pre(record, msg)
|
30
|
+
@processor.handle_message(m)
|
31
|
+
repo.persist_post(record)
|
32
|
+
true
|
33
|
+
rescue => e
|
34
|
+
repo.persist_failure(record, e) if defined?(repo) && defined?(record)
|
35
|
+
Logging.error("Inbox processing failed: #{e.class}: #{e.message}",
|
36
|
+
tag: 'JetstreamBridge::Consumer')
|
37
|
+
false
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def process_direct(m, klass)
|
43
|
+
unless ModelUtils.ar_class?(klass)
|
44
|
+
Logging.warn("Inbox model #{klass} is not an ActiveRecord model; processing directly.",
|
45
|
+
tag: 'JetstreamBridge::Consumer')
|
46
|
+
end
|
47
|
+
@processor.handle_message(m)
|
48
|
+
true
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../core/model_utils'
|
4
|
+
require_relative '../../core/logging'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
# AR-facing operations for inbox rows (find/build/persist).
|
8
|
+
class InboxRepository
|
9
|
+
def initialize(klass)
|
10
|
+
@klass = klass
|
11
|
+
end
|
12
|
+
|
13
|
+
def find_or_build(msg)
|
14
|
+
if ModelUtils.has_columns?(@klass, :event_id)
|
15
|
+
@klass.find_or_initialize_by(event_id: msg.event_id)
|
16
|
+
elsif ModelUtils.has_columns?(@klass, :stream_seq)
|
17
|
+
@klass.find_or_initialize_by(stream_seq: msg.seq)
|
18
|
+
else
|
19
|
+
@klass.new
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def already_processed?(record)
|
24
|
+
record.respond_to?(:processed_at) && record.processed_at
|
25
|
+
end
|
26
|
+
|
27
|
+
def persist_pre(record, msg)
|
28
|
+
ModelUtils.assign_known_attrs(record, {
|
29
|
+
event_id: (if ModelUtils.has_columns?(@klass,
|
30
|
+
:event_id)
|
31
|
+
msg.event_id
|
32
|
+
end),
|
33
|
+
subject: msg.subject,
|
34
|
+
payload: ModelUtils.json_dump(msg.body_for_store),
|
35
|
+
headers: ModelUtils.json_dump(msg.headers),
|
36
|
+
stream: (if ModelUtils.has_columns?(@klass,
|
37
|
+
:stream)
|
38
|
+
msg.stream
|
39
|
+
end),
|
40
|
+
stream_seq: (if ModelUtils.has_columns?(@klass,
|
41
|
+
:stream_seq)
|
42
|
+
msg.seq
|
43
|
+
end),
|
44
|
+
deliveries: (if ModelUtils.has_columns?(@klass,
|
45
|
+
:deliveries)
|
46
|
+
msg.deliveries
|
47
|
+
end),
|
48
|
+
status: 'processing',
|
49
|
+
last_error: nil,
|
50
|
+
received_at: (if ModelUtils.has_columns?(@klass,
|
51
|
+
:received_at)
|
52
|
+
record.received_at || msg.now
|
53
|
+
end),
|
54
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
55
|
+
:updated_at)
|
56
|
+
msg.now
|
57
|
+
end)
|
58
|
+
})
|
59
|
+
record.save!
|
60
|
+
end
|
61
|
+
|
62
|
+
def persist_post(record)
|
63
|
+
now = Time.now.utc
|
64
|
+
ModelUtils.assign_known_attrs(record, {
|
65
|
+
status: 'processed',
|
66
|
+
processed_at: (if ModelUtils.has_columns?(@klass,
|
67
|
+
:processed_at)
|
68
|
+
now
|
69
|
+
end),
|
70
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
71
|
+
:updated_at)
|
72
|
+
now
|
73
|
+
end)
|
74
|
+
})
|
75
|
+
record.save!
|
76
|
+
end
|
77
|
+
|
78
|
+
def persist_failure(record, error)
|
79
|
+
return unless record
|
80
|
+
|
81
|
+
now = Time.now.utc
|
82
|
+
ModelUtils.assign_known_attrs(record, {
|
83
|
+
status: (if ModelUtils.has_columns?(@klass,
|
84
|
+
:status)
|
85
|
+
'failed'
|
86
|
+
end),
|
87
|
+
last_error: (if ModelUtils.has_columns?(@klass,
|
88
|
+
:last_error)
|
89
|
+
"#{error.class}: #{error.message}"
|
90
|
+
end),
|
91
|
+
updated_at: (if ModelUtils.has_columns?(@klass,
|
92
|
+
:updated_at)
|
93
|
+
now
|
94
|
+
end)
|
95
|
+
})
|
96
|
+
record.save!
|
97
|
+
rescue StandardError => e
|
98
|
+
Logging.warn("Failed to persist inbox failure: #{e.class}: #{e.message}",
|
99
|
+
tag: 'JetstreamBridge::Consumer')
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../core/logging'
|
4
|
+
require_relative '../consumer/consumer_config'
|
5
|
+
|
6
|
+
module JetstreamBridge
|
7
|
+
# Encapsulates durable ensure + subscribe for a consumer.
|
8
|
+
class SubscriptionManager
|
9
|
+
def initialize(jts, durable, cfg = JetstreamBridge.config)
|
10
|
+
@jts = jts
|
11
|
+
@durable = durable
|
12
|
+
@cfg = cfg
|
13
|
+
end
|
14
|
+
|
15
|
+
def stream_name
|
16
|
+
@cfg.stream_name
|
17
|
+
end
|
18
|
+
|
19
|
+
def filter_subject
|
20
|
+
@cfg.destination_subject
|
21
|
+
end
|
22
|
+
|
23
|
+
def desired_consumer_cfg
|
24
|
+
ConsumerConfig.consumer_config(@durable, filter_subject)
|
25
|
+
end
|
26
|
+
|
27
|
+
def ensure_consumer!
|
28
|
+
info = consumer_info_or_nil
|
29
|
+
return create_consumer! unless info
|
30
|
+
return log_consumer_ok if consumer_matches?(info)
|
31
|
+
|
32
|
+
recreate_consumer!
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def consumer_info_or_nil
|
38
|
+
@jts.consumer_info(stream_name, @durable)
|
39
|
+
rescue NATS::JetStream::Error
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
|
43
|
+
def consumer_matches?(info)
|
44
|
+
cfg = info.config
|
45
|
+
have = (cfg.respond_to?(:filter_subject) ? cfg.filter_subject : cfg[:filter_subject]).to_s
|
46
|
+
want = desired_consumer_cfg[:filter_subject].to_s
|
47
|
+
have == want
|
48
|
+
end
|
49
|
+
|
50
|
+
def recreate_consumer!
|
51
|
+
Logging.warn(
|
52
|
+
"Consumer #{@durable} exists with mismatched config; recreating (filter=#{filter_subject})",
|
53
|
+
tag: 'JetstreamBridge::Consumer'
|
54
|
+
)
|
55
|
+
safe_delete_consumer
|
56
|
+
create_consumer!
|
57
|
+
end
|
58
|
+
|
59
|
+
def create_consumer!
|
60
|
+
@jts.add_consumer(stream_name, **desired_consumer_cfg)
|
61
|
+
Logging.info("Created consumer #{@durable} (filter=#{filter_subject})",
|
62
|
+
tag: 'JetstreamBridge::Consumer')
|
63
|
+
end
|
64
|
+
|
65
|
+
def log_consumer_ok
|
66
|
+
Logging.info("Consumer #{@durable} exists with desired config.",
|
67
|
+
tag: 'JetstreamBridge::Consumer')
|
68
|
+
end
|
69
|
+
|
70
|
+
def safe_delete_consumer
|
71
|
+
@jts.delete_consumer(stream_name, @durable)
|
72
|
+
rescue NATS::JetStream::Error => e
|
73
|
+
Logging.warn("Delete consumer #{@durable} ignored: #{e.class} #{e.message}",
|
74
|
+
tag: 'JetstreamBridge::Consumer')
|
75
|
+
end
|
76
|
+
def subscribe!
|
77
|
+
@jts.pull_subscribe(
|
78
|
+
filter_subject,
|
79
|
+
@durable,
|
80
|
+
stream: stream_name,
|
81
|
+
config: ConsumerConfig.subscribe_config
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
def consumer_mismatch?(info, desired_cfg)
|
86
|
+
cfg = info.config
|
87
|
+
(cfg.respond_to?(:filter_subject) ? cfg.filter_subject.to_s : cfg[:filter_subject].to_s) !=
|
88
|
+
desired_cfg[:filter_subject].to_s
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module JetstreamBridge
|
4
|
+
module ModelUtils
|
5
|
+
module_function
|
6
|
+
|
7
|
+
def constantize(name)
|
8
|
+
name.to_s.split('::').reduce(Object) { |m, c| m.const_get(c) }
|
9
|
+
end
|
10
|
+
|
11
|
+
def ar_class?(klass)
|
12
|
+
defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
|
13
|
+
end
|
14
|
+
|
15
|
+
def has_columns?(klass, *cols)
|
16
|
+
return false unless ar_class?(klass)
|
17
|
+
cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def assign_known_attrs(record, attrs)
|
21
|
+
attrs.each do |k, v|
|
22
|
+
setter = :"#{k}="
|
23
|
+
record.public_send(setter, v) if record.respond_to?(setter)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# find_or_initialize_by on the first keyset whose columns exist; else new
|
28
|
+
def find_or_init_by_best(klass, *keysets)
|
29
|
+
keysets.each do |keys|
|
30
|
+
next if keys.nil? || keys.empty?
|
31
|
+
if has_columns?(klass, keys.keys)
|
32
|
+
return klass.find_or_initialize_by(keys)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
klass.new
|
36
|
+
end
|
37
|
+
|
38
|
+
def json_dump(obj)
|
39
|
+
obj.is_a?(String) ? obj : JSON.generate(obj)
|
40
|
+
rescue
|
41
|
+
obj.to_s
|
42
|
+
end
|
43
|
+
|
44
|
+
def json_load(str)
|
45
|
+
return str if str.is_a?(Hash)
|
46
|
+
JSON.parse(str.to_s)
|
47
|
+
rescue
|
48
|
+
{}
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# No-op; shim below if AR missing.
|
7
|
+
end
|
8
|
+
|
9
|
+
module JetstreamBridge
|
10
|
+
# Default Inbox model when `use_inbox` is enabled.
|
11
|
+
# Prefers event_id, but can fall back to (stream, stream_seq).
|
12
|
+
if defined?(ActiveRecord::Base)
|
13
|
+
class InboxEvent < ActiveRecord::Base
|
14
|
+
self.table_name = 'jetstream_inbox_events'
|
15
|
+
|
16
|
+
class << self
|
17
|
+
def column?(name) = column_names.include?(name.to_s)
|
18
|
+
|
19
|
+
def attribute_json?(name)
|
20
|
+
return false unless respond_to?(:attribute_types) && attribute_types.key?(name.to_s)
|
21
|
+
attribute_types[name.to_s].to_s.downcase.include?('json')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
if column?(:payload)
|
26
|
+
serialize :payload, coder: JSON unless attribute_json?(:payload)
|
27
|
+
end
|
28
|
+
if column?(:headers)
|
29
|
+
serialize :headers, coder: JSON unless attribute_json?(:headers)
|
30
|
+
end
|
31
|
+
|
32
|
+
if column?(:event_id)
|
33
|
+
validates :event_id, presence: true, uniqueness: true
|
34
|
+
elsif column?(:stream_seq)
|
35
|
+
validates :stream_seq, presence: true
|
36
|
+
validates :stream, presence: true, if: -> { self.class.column?(:stream) }
|
37
|
+
if column?(:stream)
|
38
|
+
validates :stream_seq, uniqueness: { scope: :stream }
|
39
|
+
else
|
40
|
+
validates :stream_seq, uniqueness: true
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
validates :subject, presence: true, if: -> { self.class.column?(:subject) }
|
45
|
+
|
46
|
+
if self.class.column?(:status)
|
47
|
+
STATUSES = %w[received processing processed failed].freeze
|
48
|
+
validates :status, inclusion: { in: STATUSES }
|
49
|
+
end
|
50
|
+
|
51
|
+
scope :processed, -> { where(status: 'processed') }, if: -> { column?(:status) }
|
52
|
+
|
53
|
+
before_validation do
|
54
|
+
self.status ||= 'received' if self.class.column?(:status) && status.blank?
|
55
|
+
self.received_at ||= Time.now.utc if self.class.column?(:received_at) && received_at.blank?
|
56
|
+
end
|
57
|
+
|
58
|
+
def processed?
|
59
|
+
if self.class.column?(:processed_at)
|
60
|
+
processed_at.present?
|
61
|
+
elsif self.class.column?(:status)
|
62
|
+
status == 'processed'
|
63
|
+
else
|
64
|
+
false
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def payload_hash
|
69
|
+
v = self[:payload]
|
70
|
+
case v
|
71
|
+
when String then JSON.parse(v) rescue {}
|
72
|
+
when Hash then v
|
73
|
+
else v.respond_to?(:as_json) ? v.as_json : {}
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
else
|
78
|
+
class InboxEvent
|
79
|
+
class << self
|
80
|
+
def method_missing(method_name, *_args, &_block)
|
81
|
+
raise_missing_ar!('Inbox', method_name)
|
82
|
+
end
|
83
|
+
|
84
|
+
def respond_to_missing?(_name, _priv = false) = false
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def raise_missing_ar!(which, method_name)
|
89
|
+
raise(
|
90
|
+
"#{which} requires ActiveRecord (tried to call ##{method_name}). " \
|
91
|
+
'Enable `use_inbox` only in apps with ActiveRecord, or add ' \
|
92
|
+
'`gem "activerecord"` to your Gemfile.'
|
93
|
+
)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|