nats_pubsub 1.0.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 +7 -0
- data/exe/nats_pubsub +44 -0
- data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
- data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
- data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
- data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
- data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
- data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
- data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
- data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
- data/lib/nats_pubsub/active_record/publishable.rb +192 -0
- data/lib/nats_pubsub/cli.rb +105 -0
- data/lib/nats_pubsub/core/base_repository.rb +73 -0
- data/lib/nats_pubsub/core/config.rb +152 -0
- data/lib/nats_pubsub/core/config_presets.rb +139 -0
- data/lib/nats_pubsub/core/connection.rb +103 -0
- data/lib/nats_pubsub/core/constants.rb +190 -0
- data/lib/nats_pubsub/core/duration.rb +113 -0
- data/lib/nats_pubsub/core/error_action.rb +288 -0
- data/lib/nats_pubsub/core/event.rb +275 -0
- data/lib/nats_pubsub/core/health_check.rb +470 -0
- data/lib/nats_pubsub/core/logging.rb +72 -0
- data/lib/nats_pubsub/core/message_context.rb +193 -0
- data/lib/nats_pubsub/core/presets.rb +222 -0
- data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
- data/lib/nats_pubsub/core/structured_logger.rb +141 -0
- data/lib/nats_pubsub/core/subject.rb +185 -0
- data/lib/nats_pubsub/instrumentation.rb +327 -0
- data/lib/nats_pubsub/middleware/active_record.rb +18 -0
- data/lib/nats_pubsub/middleware/chain.rb +92 -0
- data/lib/nats_pubsub/middleware/logging.rb +48 -0
- data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
- data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
- data/lib/nats_pubsub/models/event_model.rb +73 -0
- data/lib/nats_pubsub/models/inbox_event.rb +109 -0
- data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
- data/lib/nats_pubsub/models/model_utils.rb +57 -0
- data/lib/nats_pubsub/models/outbox_event.rb +113 -0
- data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
- data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
- data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
- data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
- data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
- data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
- data/lib/nats_pubsub/publisher/publisher.rb +156 -0
- data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
- data/lib/nats_pubsub/railtie.rb +52 -0
- data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
- data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
- data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
- data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
- data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
- data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
- data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
- data/lib/nats_pubsub/subscribers/pool.rb +166 -0
- data/lib/nats_pubsub/subscribers/registry.rb +114 -0
- data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
- data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
- data/lib/nats_pubsub/subscribers/worker.rb +152 -0
- data/lib/nats_pubsub/tasks/install.rake +10 -0
- data/lib/nats_pubsub/testing/helpers.rb +199 -0
- data/lib/nats_pubsub/testing/matchers.rb +208 -0
- data/lib/nats_pubsub/testing/test_harness.rb +250 -0
- data/lib/nats_pubsub/testing.rb +157 -0
- data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
- data/lib/nats_pubsub/topology/stream.rb +102 -0
- data/lib/nats_pubsub/topology/stream_support.rb +170 -0
- data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
- data/lib/nats_pubsub/topology/topology.rb +24 -0
- data/lib/nats_pubsub/version.rb +8 -0
- data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
- data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
- data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
- data/lib/nats_pubsub/web/views/layout.erb +68 -0
- data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
- data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
- data/lib/nats_pubsub/web.rb +181 -0
- data/lib/nats_pubsub.rb +290 -0
- metadata +225 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/structured_logger'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
module Middleware
|
|
7
|
+
# Structured logging middleware for message processing
|
|
8
|
+
# Logs with consistent JSON format and correlation IDs
|
|
9
|
+
class StructuredLogging
|
|
10
|
+
def initialize(logger: nil)
|
|
11
|
+
@logger = logger || Core::LoggerFactory.create_from_config(NatsPubsub.config)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(subscriber, payload, metadata)
|
|
15
|
+
start_time = Time.now
|
|
16
|
+
context = build_log_context(subscriber, payload, metadata)
|
|
17
|
+
|
|
18
|
+
logger.with_context(context).info('Processing message started')
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
yield
|
|
22
|
+
|
|
23
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round(2)
|
|
24
|
+
logger.with_context(context).info('Processing message completed', {
|
|
25
|
+
elapsed_ms: elapsed_ms,
|
|
26
|
+
status: 'success'
|
|
27
|
+
})
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
elapsed_ms = ((Time.now - start_time) * 1000).round(2)
|
|
30
|
+
logger.with_context(context).error('Processing message failed', {
|
|
31
|
+
elapsed_ms: elapsed_ms,
|
|
32
|
+
status: 'error',
|
|
33
|
+
error_class: e.class.name,
|
|
34
|
+
error_message: e.message,
|
|
35
|
+
backtrace: e.backtrace&.first(5)
|
|
36
|
+
})
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :logger
|
|
44
|
+
|
|
45
|
+
def build_log_context(subscriber, payload, metadata)
|
|
46
|
+
{
|
|
47
|
+
subscriber: subscriber.class.name,
|
|
48
|
+
event_id: metadata[:event_id],
|
|
49
|
+
trace_id: metadata[:trace_id],
|
|
50
|
+
subject: metadata[:subject],
|
|
51
|
+
topic: metadata[:topic],
|
|
52
|
+
delivery_count: metadata[:deliveries] || 1
|
|
53
|
+
}.compact
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
# Shared behavior for ActiveRecord-based event models (Inbox/Outbox)
|
|
7
|
+
# Provides safe column checking, common validations, and payload handling
|
|
8
|
+
module EventModel
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.extend(ClassMethods)
|
|
11
|
+
base.include(InstanceMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Safe column presence check that never boots a connection during class load.
|
|
16
|
+
# rubocop:disable Naming/PredicateNames
|
|
17
|
+
def has_column?(name)
|
|
18
|
+
return false unless ar_connected?
|
|
19
|
+
|
|
20
|
+
connection.schema_cache.columns_hash(table_name).key?(name.to_s)
|
|
21
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
# rubocop:enable Naming/PredicateNames
|
|
25
|
+
|
|
26
|
+
def ar_connected?
|
|
27
|
+
ActiveRecord::Base.connected? && connection_pool.active_connection?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module InstanceMethods
|
|
34
|
+
# Parse and return payload as a Hash
|
|
35
|
+
# Handles String (JSON), Hash, and objects with as_json
|
|
36
|
+
def payload_hash
|
|
37
|
+
v = self[:payload]
|
|
38
|
+
case v
|
|
39
|
+
when String
|
|
40
|
+
begin
|
|
41
|
+
Oj.load(v, mode: :strict)
|
|
42
|
+
rescue Oj::Error
|
|
43
|
+
{}
|
|
44
|
+
end
|
|
45
|
+
when Hash then v
|
|
46
|
+
else
|
|
47
|
+
v.respond_to?(:as_json) ? v.as_json : {}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Create a shim class when ActiveRecord is not available
|
|
53
|
+
# @param class_name [String] Name of the class (e.g., 'InboxEvent')
|
|
54
|
+
# @param feature_name [String] Feature name for error message (e.g., 'Inbox')
|
|
55
|
+
def self.create_shim(class_name, feature_name)
|
|
56
|
+
Class.new do
|
|
57
|
+
class << self
|
|
58
|
+
define_method(:method_missing) do |method_name, *_args, &_block|
|
|
59
|
+
raise(
|
|
60
|
+
"#{feature_name} requires ActiveRecord (tried to call ##{method_name}). " \
|
|
61
|
+
"Enable `use_#{feature_name.downcase}` only in apps with ActiveRecord, or add " \
|
|
62
|
+
'`gem "activerecord"` to your Gemfile.'
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def respond_to_missing?(_method_name, _include_private = false)
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'event_model'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'active_record'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# No-op; shim defined below.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module NatsPubsub
|
|
12
|
+
if defined?(ActiveRecord::Base)
|
|
13
|
+
class InboxEvent < ActiveRecord::Base
|
|
14
|
+
include EventModel
|
|
15
|
+
|
|
16
|
+
self.table_name = 'nats_pubsub_inbox'
|
|
17
|
+
|
|
18
|
+
# ---- Scopes for common queries ----
|
|
19
|
+
scope :received, -> { where(status: 'received') if has_column?(:status) }
|
|
20
|
+
scope :processing, -> { where(status: 'processing') if has_column?(:status) }
|
|
21
|
+
scope :processed, -> { where(status: 'processed') if has_column?(:status) }
|
|
22
|
+
scope :failed, -> { where(status: 'failed') if has_column?(:status) }
|
|
23
|
+
scope :unprocessed, -> { where(status: %w[received failed]) if has_column?(:status) }
|
|
24
|
+
|
|
25
|
+
scope :ready_to_process, lambda {
|
|
26
|
+
return none unless has_column?(:status)
|
|
27
|
+
|
|
28
|
+
where(status: 'received')
|
|
29
|
+
.order(:received_at)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
scope :with_errors, lambda {
|
|
33
|
+
return none unless has_column?(:last_error)
|
|
34
|
+
|
|
35
|
+
where.not(last_error: nil)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
scope :by_delivery_count, lambda { |count|
|
|
39
|
+
return none unless has_column?(:deliveries)
|
|
40
|
+
|
|
41
|
+
where(deliveries: count)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
scope :for_cleanup, lambda { |retention_period = 30.days.ago|
|
|
45
|
+
return none unless has_column?(:status) && has_column?(:processed_at)
|
|
46
|
+
|
|
47
|
+
where(status: 'processed')
|
|
48
|
+
.where('processed_at < ?', retention_period)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
scope :by_subject, ->(pattern) { where('subject LIKE ?', pattern) if has_column?(:subject) }
|
|
52
|
+
scope :recent, -> { order(received_at: :desc) if has_column?(:received_at) }
|
|
53
|
+
scope :oldest_first, -> { order(received_at: :asc) if has_column?(:received_at) }
|
|
54
|
+
|
|
55
|
+
# ---- Validations (NO with_options; guard everything with procs) ----
|
|
56
|
+
|
|
57
|
+
# Preferred dedupe key
|
|
58
|
+
validates :event_id,
|
|
59
|
+
presence: true,
|
|
60
|
+
uniqueness: true,
|
|
61
|
+
if: -> { self.class.has_column?(:event_id) }
|
|
62
|
+
|
|
63
|
+
# Fallback to (stream, stream_seq) when event_id column not present
|
|
64
|
+
validates :stream_seq,
|
|
65
|
+
presence: true,
|
|
66
|
+
if: -> { !self.class.has_column?(:event_id) && self.class.has_column?(:stream_seq) }
|
|
67
|
+
|
|
68
|
+
validates :stream_seq,
|
|
69
|
+
uniqueness: { scope: :stream },
|
|
70
|
+
if: lambda {
|
|
71
|
+
!self.class.has_column?(:event_id) &&
|
|
72
|
+
self.class.has_column?(:stream_seq) &&
|
|
73
|
+
self.class.has_column?(:stream)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
validates :stream_seq,
|
|
77
|
+
uniqueness: true,
|
|
78
|
+
if: lambda {
|
|
79
|
+
!self.class.has_column?(:event_id) &&
|
|
80
|
+
self.class.has_column?(:stream_seq) &&
|
|
81
|
+
!self.class.has_column?(:stream)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
validates :subject,
|
|
85
|
+
presence: true,
|
|
86
|
+
if: -> { self.class.has_column?(:subject) }
|
|
87
|
+
|
|
88
|
+
# ---- Defaults that do not require schema at load time ----
|
|
89
|
+
before_validation do
|
|
90
|
+
self.status ||= 'received' if self.class.has_column?(:status) && status.blank?
|
|
91
|
+
self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ---- Helpers ----
|
|
95
|
+
def processed?
|
|
96
|
+
if self.class.has_column?(:processed_at)
|
|
97
|
+
processed_at.present?
|
|
98
|
+
elsif self.class.has_column?(:status)
|
|
99
|
+
status == 'processed'
|
|
100
|
+
else
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
# Shim: loud failure if AR isn't present but someone calls the model.
|
|
107
|
+
InboxEvent = EventModel.create_shim('InboxEvent', 'Inbox')
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
module ModelCodecSetup
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def apply!
|
|
10
|
+
return unless ar_connected?
|
|
11
|
+
|
|
12
|
+
[NatsPubsub::OutboxEvent, NatsPubsub::InboxEvent].each { |k| apply_to(k) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def apply_to(klass)
|
|
16
|
+
return unless table_exists_safely?(klass)
|
|
17
|
+
|
|
18
|
+
%w[payload headers].each do |attr|
|
|
19
|
+
next unless column?(klass, attr)
|
|
20
|
+
next if json_column?(klass, attr) || already_serialized?(klass, attr)
|
|
21
|
+
|
|
22
|
+
klass.serialize attr.to_sym, coder: Oj
|
|
23
|
+
end
|
|
24
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError
|
|
25
|
+
# ignore when schema isn’t available yet
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- helpers ---
|
|
29
|
+
|
|
30
|
+
def ar_connected?
|
|
31
|
+
ActiveRecord::Base.connected?
|
|
32
|
+
rescue StandardError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def table_exists_safely?(klass)
|
|
37
|
+
klass.table_exists?
|
|
38
|
+
rescue StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def column?(klass, attr)
|
|
43
|
+
klass.columns_hash.key?(attr)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def json_column?(klass, attr)
|
|
49
|
+
sql = klass.columns_hash.fetch(attr).sql_type.to_s.downcase
|
|
50
|
+
sql.include?('json') # covers json & jsonb
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def already_serialized?(klass, attr)
|
|
56
|
+
klass.attribute_types.fetch(attr, nil).is_a?(ActiveRecord::Type::Serialized)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
|
|
5
|
+
module NatsPubsub
|
|
6
|
+
module ModelUtils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def constantize(name)
|
|
10
|
+
name.to_s.split('::').reduce(Object) { |m, c| m.const_get(c) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ar_class?(klass)
|
|
14
|
+
defined?(ActiveRecord::Base) && klass <= ActiveRecord::Base
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
18
|
+
def has_columns?(klass, *cols)
|
|
19
|
+
return false unless ar_class?(klass)
|
|
20
|
+
|
|
21
|
+
cols.flatten.all? { |c| klass.column_names.include?(c.to_s) }
|
|
22
|
+
end
|
|
23
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
24
|
+
|
|
25
|
+
def assign_known_attrs(record, attrs)
|
|
26
|
+
attrs.each do |k, v|
|
|
27
|
+
setter = :"#{k}="
|
|
28
|
+
record.public_send(setter, v) if record.respond_to?(setter)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# find_or_initialize_by on the first keyset whose columns exist; else new
|
|
33
|
+
def find_or_init_by_best(klass, *keysets)
|
|
34
|
+
keysets.each do |keys|
|
|
35
|
+
next if keys.nil? || keys.empty?
|
|
36
|
+
return klass.find_or_initialize_by(keys) if has_columns?(klass, keys.keys)
|
|
37
|
+
end
|
|
38
|
+
klass.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def json_dump(obj)
|
|
42
|
+
return obj if obj.is_a?(String)
|
|
43
|
+
|
|
44
|
+
Oj.dump(obj, mode: :compat)
|
|
45
|
+
rescue Oj::Error, TypeError
|
|
46
|
+
obj.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def json_load(str)
|
|
50
|
+
return str if str.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
Oj.load(str.to_s, mode: :strict)
|
|
53
|
+
rescue Oj::Error
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'event_model'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'active_record'
|
|
7
|
+
rescue LoadError
|
|
8
|
+
# No-op; shim defined below.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module NatsPubsub
|
|
12
|
+
if defined?(ActiveRecord::Base)
|
|
13
|
+
class OutboxEvent < ActiveRecord::Base
|
|
14
|
+
include EventModel
|
|
15
|
+
|
|
16
|
+
self.table_name = 'nats_pubsub_outbox'
|
|
17
|
+
|
|
18
|
+
# ---- Scopes for common queries ----
|
|
19
|
+
scope :pending, -> { where(status: 'pending') if has_column?(:status) }
|
|
20
|
+
scope :publishing, -> { where(status: 'publishing') if has_column?(:status) }
|
|
21
|
+
scope :sent, -> { where(status: 'sent') if has_column?(:status) }
|
|
22
|
+
scope :failed, -> { where(status: 'failed') if has_column?(:status) }
|
|
23
|
+
scope :unsent, -> { where(status: %w[pending failed]) if has_column?(:status) }
|
|
24
|
+
|
|
25
|
+
scope :ready_to_publish, lambda {
|
|
26
|
+
return none unless has_column?(:status) && has_column?(:enqueued_at)
|
|
27
|
+
|
|
28
|
+
where(status: %w[pending failed])
|
|
29
|
+
.where('enqueued_at <= ?', Time.current)
|
|
30
|
+
.order(:enqueued_at)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
scope :stale_publishing, lambda { |threshold = 5.minutes.ago|
|
|
34
|
+
return none unless has_column?(:status) && has_column?(:updated_at)
|
|
35
|
+
|
|
36
|
+
where(status: 'publishing')
|
|
37
|
+
.where('updated_at < ?', threshold)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
scope :for_cleanup, lambda { |retention_period = 7.days.ago|
|
|
41
|
+
return none unless has_column?(:status) && has_column?(:sent_at)
|
|
42
|
+
|
|
43
|
+
where(status: 'sent')
|
|
44
|
+
.where('sent_at < ?', retention_period)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
scope :by_subject, ->(pattern) { where('subject LIKE ?', pattern) if has_column?(:subject) }
|
|
48
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
49
|
+
scope :oldest_first, -> { order(created_at: :asc) }
|
|
50
|
+
|
|
51
|
+
# ---- Validations guarded by safe schema checks (no with_options) ----
|
|
52
|
+
validates :payload,
|
|
53
|
+
presence: true,
|
|
54
|
+
if: -> { self.class.has_column?(:payload) }
|
|
55
|
+
|
|
56
|
+
# Preferred path when event_id exists
|
|
57
|
+
validates :event_id,
|
|
58
|
+
presence: true,
|
|
59
|
+
uniqueness: true,
|
|
60
|
+
if: -> { self.class.has_column?(:event_id) }
|
|
61
|
+
|
|
62
|
+
# Fallback legacy fields when event_id is absent
|
|
63
|
+
validates :resource_type,
|
|
64
|
+
presence: true,
|
|
65
|
+
if: lambda {
|
|
66
|
+
!self.class.has_column?(:event_id) && self.class.has_column?(:resource_type)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
validates :resource_id,
|
|
70
|
+
presence: true,
|
|
71
|
+
if: lambda {
|
|
72
|
+
!self.class.has_column?(:event_id) && self.class.has_column?(:resource_id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
validates :event_type,
|
|
76
|
+
presence: true,
|
|
77
|
+
if: -> { !self.class.has_column?(:event_id) && self.class.has_column?(:event_type) }
|
|
78
|
+
|
|
79
|
+
validates :subject,
|
|
80
|
+
presence: true,
|
|
81
|
+
if: -> { self.class.has_column?(:subject) }
|
|
82
|
+
|
|
83
|
+
validates :attempts,
|
|
84
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 },
|
|
85
|
+
if: -> { self.class.has_column?(:attempts) }
|
|
86
|
+
|
|
87
|
+
# ---- Defaults that do not require schema at load time ----
|
|
88
|
+
before_validation do
|
|
89
|
+
now = Time.now.utc
|
|
90
|
+
self.status ||= 'pending' if self.class.has_column?(:status) && status.blank?
|
|
91
|
+
self.enqueued_at ||= now if self.class.has_column?(:enqueued_at) && enqueued_at.blank?
|
|
92
|
+
self.attempts = 0 if self.class.has_column?(:attempts) && attempts.nil?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ---- Helpers ----
|
|
96
|
+
def mark_sent!
|
|
97
|
+
now = Time.now.utc
|
|
98
|
+
self.status = 'sent' if self.class.has_column?(:status)
|
|
99
|
+
self.sent_at = now if self.class.has_column?(:sent_at)
|
|
100
|
+
save!
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mark_failed!(err_msg)
|
|
104
|
+
self.status = 'failed' if self.class.has_column?(:status)
|
|
105
|
+
self.last_error = err_msg if self.class.has_column?(:last_error)
|
|
106
|
+
save!
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
# Shim: loud failure if AR isn't present but someone calls the model.
|
|
111
|
+
OutboxEvent = EventModel.create_shim('OutboxEvent', 'Outbox')
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require_relative '../core/config'
|
|
5
|
+
require_relative '../core/subject'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
# Service object responsible for building message envelopes.
|
|
9
|
+
# Extracted from Publisher to follow Single Responsibility Principle.
|
|
10
|
+
#
|
|
11
|
+
# Supports three envelope formats:
|
|
12
|
+
# 1. Event envelope (domain/resource/action pattern)
|
|
13
|
+
# 2. Topic envelope (topic-based messaging)
|
|
14
|
+
# 3. Legacy envelope (backward compatibility)
|
|
15
|
+
class EnvelopeBuilder
|
|
16
|
+
# Build envelope for domain/resource/action events
|
|
17
|
+
# More specific name makes it clear this builds an event envelope
|
|
18
|
+
#
|
|
19
|
+
# @param domain [String] Domain name (e.g., 'users', 'orders')
|
|
20
|
+
# @param resource [String] Resource type (e.g., 'user', 'order')
|
|
21
|
+
# @param action [String] Action performed (e.g., 'created', 'updated')
|
|
22
|
+
# @param payload [Hash] Event payload
|
|
23
|
+
# @param options [Hash] Additional options
|
|
24
|
+
# @return [Hash] Event envelope
|
|
25
|
+
def self.build_event_envelope(domain, resource, action, payload, options = {})
|
|
26
|
+
{
|
|
27
|
+
'event_id' => options[:event_id] || SecureRandom.uuid,
|
|
28
|
+
'schema_version' => 1,
|
|
29
|
+
'domain' => domain.to_s,
|
|
30
|
+
'resource' => resource.to_s,
|
|
31
|
+
'action' => action.to_s,
|
|
32
|
+
'producer' => NatsPubsub.config.app_name,
|
|
33
|
+
'resource_id' => extract_resource_id(payload),
|
|
34
|
+
'occurred_at' => format_timestamp(options[:occurred_at]),
|
|
35
|
+
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
36
|
+
'payload' => payload
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build envelope for topic-based messages
|
|
41
|
+
# More specific name makes it clear this builds a topic envelope
|
|
42
|
+
#
|
|
43
|
+
# @param topic [String] Topic name
|
|
44
|
+
# @param message [Hash] Message payload
|
|
45
|
+
# @param options [Hash] Additional options
|
|
46
|
+
# @return [Hash] Topic envelope
|
|
47
|
+
def self.build_topic_envelope(topic, message, options = {})
|
|
48
|
+
envelope = {
|
|
49
|
+
'event_id' => options[:event_id] || SecureRandom.uuid,
|
|
50
|
+
'schema_version' => 1,
|
|
51
|
+
'topic' => topic.to_s,
|
|
52
|
+
'message_type' => options[:message_type]&.to_s,
|
|
53
|
+
'producer' => NatsPubsub.config.app_name,
|
|
54
|
+
'occurred_at' => format_timestamp(options[:occurred_at]),
|
|
55
|
+
'trace_id' => options[:trace_id] || SecureRandom.hex(8),
|
|
56
|
+
'message' => message
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Add domain/resource/action fields if provided (for backward compatibility)
|
|
60
|
+
envelope['domain'] = options[:domain].to_s if options[:domain]
|
|
61
|
+
envelope['resource'] = options[:resource].to_s if options[:resource]
|
|
62
|
+
envelope['action'] = options[:action].to_s if options[:action]
|
|
63
|
+
envelope['resource_id'] = options[:resource_id].to_s if options[:resource_id]
|
|
64
|
+
|
|
65
|
+
envelope.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build NATS subject for topic
|
|
69
|
+
# Delegates to Subject class for centralized subject building logic
|
|
70
|
+
#
|
|
71
|
+
# @param topic [String] Topic name
|
|
72
|
+
# @return [String] NATS subject
|
|
73
|
+
def self.build_subject(topic)
|
|
74
|
+
Subject.from_topic(
|
|
75
|
+
env: NatsPubsub.config.env,
|
|
76
|
+
app_name: NatsPubsub.config.app_name,
|
|
77
|
+
topic: topic
|
|
78
|
+
).to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Extract resource ID from payload
|
|
82
|
+
#
|
|
83
|
+
# @param payload [Hash] Event payload
|
|
84
|
+
# @return [String] Resource ID
|
|
85
|
+
def self.extract_resource_id(payload)
|
|
86
|
+
(payload['id'] || payload[:id]).to_s
|
|
87
|
+
end
|
|
88
|
+
private_class_method :extract_resource_id
|
|
89
|
+
|
|
90
|
+
# Format timestamp to ISO8601
|
|
91
|
+
#
|
|
92
|
+
# @param timestamp [Time, nil] Timestamp
|
|
93
|
+
# @return [String] ISO8601 formatted timestamp
|
|
94
|
+
def self.format_timestamp(timestamp)
|
|
95
|
+
(timestamp || Time.now.utc).iso8601
|
|
96
|
+
end
|
|
97
|
+
private_class_method :format_timestamp
|
|
98
|
+
end
|
|
99
|
+
end
|