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,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'publisher/envelope_builder'
|
|
4
|
+
require_relative 'publisher/publish_result'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
# Testing utilities for NatsPubsub
|
|
8
|
+
# Provides fake and inline modes for testing event publishing
|
|
9
|
+
module Testing
|
|
10
|
+
class << self
|
|
11
|
+
attr_accessor :mode
|
|
12
|
+
|
|
13
|
+
# Enable fake mode (records published events but doesn't process them)
|
|
14
|
+
def fake!
|
|
15
|
+
self.mode = :fake
|
|
16
|
+
published_events.clear
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Enable inline mode (executes subscribers immediately)
|
|
20
|
+
def inline!
|
|
21
|
+
self.mode = :inline
|
|
22
|
+
published_events.clear
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Disable testing mode (normal operation)
|
|
26
|
+
def disable!
|
|
27
|
+
self.mode = nil
|
|
28
|
+
published_events.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Get all published events
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<Hash>] Array of published events
|
|
34
|
+
def published_events
|
|
35
|
+
@published_events ||= []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Clear all published events
|
|
39
|
+
def clear!
|
|
40
|
+
published_events.clear
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if an event was published
|
|
44
|
+
#
|
|
45
|
+
# @param domain [String] Domain of the event
|
|
46
|
+
# @param resource [String] Resource type
|
|
47
|
+
# @param action [String] Action performed
|
|
48
|
+
# @return [Boolean] true if event was published
|
|
49
|
+
def published?(domain, resource, action)
|
|
50
|
+
published_events.any? do |event|
|
|
51
|
+
event[:domain] == domain &&
|
|
52
|
+
event[:resource] == resource &&
|
|
53
|
+
event[:action] == action
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get published events matching criteria
|
|
58
|
+
#
|
|
59
|
+
# @param domain [String, nil] Optional domain filter
|
|
60
|
+
# @param resource [String, nil] Optional resource filter
|
|
61
|
+
# @param action [String, nil] Optional action filter
|
|
62
|
+
# @return [Array<Hash>] Matching published events
|
|
63
|
+
def find_events(domain: nil, resource: nil, action: nil)
|
|
64
|
+
published_events.select do |event|
|
|
65
|
+
(domain.nil? || event[:domain] == domain) &&
|
|
66
|
+
(resource.nil? || event[:resource] == resource) &&
|
|
67
|
+
(action.nil? || event[:action] == action)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get the last published event
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash, nil] Last published event or nil
|
|
74
|
+
def last_event
|
|
75
|
+
published_events.last
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get count of published events
|
|
79
|
+
#
|
|
80
|
+
# @return [Integer] Number of published events
|
|
81
|
+
def event_count
|
|
82
|
+
published_events.size
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Module to prepend to Publisher for testing support
|
|
87
|
+
module PublisherExtension
|
|
88
|
+
def publish_to_topic(topic, message, **options)
|
|
89
|
+
event = {
|
|
90
|
+
topic: topic,
|
|
91
|
+
message: message,
|
|
92
|
+
options: options,
|
|
93
|
+
subject: EnvelopeBuilder.build_subject(topic)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case Testing.mode
|
|
97
|
+
when :fake
|
|
98
|
+
Testing.published_events << event
|
|
99
|
+
PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
|
|
100
|
+
when :inline
|
|
101
|
+
Testing.published_events << event
|
|
102
|
+
execute_subscribers_inline(event)
|
|
103
|
+
PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
|
|
104
|
+
else
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def publish_event(domain, resource, action, payload, **options)
|
|
110
|
+
topic = "#{domain}.#{resource}.#{action}"
|
|
111
|
+
event = {
|
|
112
|
+
domain: domain,
|
|
113
|
+
resource: resource,
|
|
114
|
+
action: action,
|
|
115
|
+
payload: payload,
|
|
116
|
+
options: options,
|
|
117
|
+
topic: topic,
|
|
118
|
+
subject: EnvelopeBuilder.build_subject(topic)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case Testing.mode
|
|
122
|
+
when :fake
|
|
123
|
+
Testing.published_events << event
|
|
124
|
+
PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
|
|
125
|
+
when :inline
|
|
126
|
+
Testing.published_events << event
|
|
127
|
+
execute_subscribers_inline(event.merge(message: payload))
|
|
128
|
+
PublishResult.success(event_id: 'fake-event-id', subject: event[:subject])
|
|
129
|
+
else
|
|
130
|
+
super
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def execute_subscribers_inline(event)
|
|
137
|
+
require_relative 'subscribers/registry'
|
|
138
|
+
|
|
139
|
+
subject = event[:subject]
|
|
140
|
+
subscribers = Subscribers::Registry.instance.subscribers_for(subject)
|
|
141
|
+
|
|
142
|
+
subscribers.each do |sub_class|
|
|
143
|
+
subscriber = sub_class.new
|
|
144
|
+
subscriber.call(event[:message] || event[:payload], event)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Prepend testing extension to Publisher
|
|
152
|
+
require_relative 'publisher/publisher'
|
|
153
|
+
NatsPubsub::Publisher.prepend(NatsPubsub::Testing::PublisherExtension)
|
|
154
|
+
|
|
155
|
+
# Load matchers and helpers if available
|
|
156
|
+
require_relative 'testing/matchers' if defined?(RSpec)
|
|
157
|
+
require_relative 'testing/helpers'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require_relative 'subject_matcher'
|
|
5
|
+
require_relative '../core/logging'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
# Checks for overlapping subjects.
|
|
9
|
+
class OverlapGuard
|
|
10
|
+
class << self
|
|
11
|
+
# Raise if any desired subjects conflict with other streams.
|
|
12
|
+
def check!(jts, target_name, new_subjects)
|
|
13
|
+
conflicts = overlaps(jts, target_name, new_subjects)
|
|
14
|
+
return if conflicts.empty?
|
|
15
|
+
|
|
16
|
+
raise conflict_message(target_name, conflicts)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Return a list of conflicts against other streams, per subject.
|
|
20
|
+
# [{ name:'OTHER' pairs: [['a.b.*', 'a.b.c'], ...] }, ...]
|
|
21
|
+
def overlaps(jts, target_name, new_subjects)
|
|
22
|
+
desired = StreamSupport.normalize_subjects(new_subjects)
|
|
23
|
+
streams = list_streams_with_subjects(jts)
|
|
24
|
+
others = streams.reject { |stream| stream[:name] == target_name }
|
|
25
|
+
|
|
26
|
+
others.filter_map do |stream|
|
|
27
|
+
pairs = desired.flat_map do |desired_subject|
|
|
28
|
+
stream_subjects = StreamSupport.normalize_subjects(stream[:subjects])
|
|
29
|
+
stream_subjects.select { |existing_subject| SubjectMatcher.overlap?(desired_subject, existing_subject) }
|
|
30
|
+
.map { |existing_subject| [desired_subject, existing_subject] }
|
|
31
|
+
end
|
|
32
|
+
{ name: stream[:name], pairs: pairs } unless pairs.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns [allowed, blocked] given desired subjects.
|
|
37
|
+
def partition_allowed(jts, target_name, desired_subjects)
|
|
38
|
+
desired = StreamSupport.normalize_subjects(desired_subjects)
|
|
39
|
+
conflicts = overlaps(jts, target_name, desired)
|
|
40
|
+
blocked = conflicts.flat_map { |c| c[:pairs].map(&:first) }.uniq
|
|
41
|
+
allowed = desired - blocked
|
|
42
|
+
[allowed, blocked]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def allowed_subjects(jts, target_name, desired_subjects)
|
|
46
|
+
partition_allowed(jts, target_name, desired_subjects).first
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def list_streams_with_subjects(jts)
|
|
52
|
+
list_stream_names(jts).map do |name|
|
|
53
|
+
info = jts.stream_info(name)
|
|
54
|
+
{ name: name, subjects: Array(info.config.subjects || []) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def list_stream_names(jts)
|
|
59
|
+
names = []
|
|
60
|
+
offset = 0
|
|
61
|
+
loop do
|
|
62
|
+
resp = js_api_request(jts, '$JS.API.STREAM.NAMES', { offset: offset })
|
|
63
|
+
batch = Array(resp['streams']).filter_map { |h| h['name'] }
|
|
64
|
+
names.concat(batch)
|
|
65
|
+
break if names.size >= resp['total'].to_i || batch.empty?
|
|
66
|
+
|
|
67
|
+
offset = names.size
|
|
68
|
+
end
|
|
69
|
+
names
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def js_api_request(jts, subject, payload = {})
|
|
73
|
+
# JetStream client should expose the underlying NATS client as `nc`
|
|
74
|
+
msg = jts.nc.request(subject, Oj.dump(payload, mode: :compat))
|
|
75
|
+
Oj.load(msg.data, mode: :strict)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def conflict_message(target, conflicts)
|
|
79
|
+
msg = "Overlapping subjects for stream #{target}:\n"
|
|
80
|
+
conflicts.each do |c|
|
|
81
|
+
msg << "- Conflicts with '#{c[:name]}' on:\n"
|
|
82
|
+
c[:pairs].each { |(a, b)| msg << " • #{a} × #{b}\n" }
|
|
83
|
+
end
|
|
84
|
+
msg
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
require_relative 'overlap_guard'
|
|
5
|
+
require_relative 'stream_support'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
# Ensures a stream exists and updates only uncovered subjects, using work-queue semantics.
|
|
9
|
+
class Stream
|
|
10
|
+
RETENTION = 'workqueue'
|
|
11
|
+
STORAGE = 'file'
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def ensure!(jts, name, subjects)
|
|
15
|
+
desired = StreamSupport.normalize_subjects(subjects)
|
|
16
|
+
raise ArgumentError, 'subjects must not be empty' if desired.empty?
|
|
17
|
+
|
|
18
|
+
attempts = 0
|
|
19
|
+
begin
|
|
20
|
+
info = safe_stream_info(jts, name)
|
|
21
|
+
info ? ensure_update(jts, name, info, desired) : ensure_create(jts, name, desired)
|
|
22
|
+
rescue NATS::JetStream::Error => e
|
|
23
|
+
if StreamSupport.overlap_error?(e) && (attempts += 1) <= 1
|
|
24
|
+
Logging.warn("Overlap race while ensuring #{name}; retrying once...", tag: 'NatsPubsub::Stream')
|
|
25
|
+
sleep(0.05)
|
|
26
|
+
retry
|
|
27
|
+
elsif StreamSupport.overlap_error?(e)
|
|
28
|
+
Logging.warn("Overlap persists ensuring #{name}; leaving unchanged. err=#{e.message.inspect}",
|
|
29
|
+
tag: 'NatsPubsub::Stream')
|
|
30
|
+
nil
|
|
31
|
+
else
|
|
32
|
+
raise
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def ensure_update(jts, name, info, desired_subjects)
|
|
40
|
+
existing = StreamSupport.normalize_subjects(info.config.subjects || [])
|
|
41
|
+
to_add = StreamSupport.missing_subjects(existing, desired_subjects)
|
|
42
|
+
add_subjects(jts, name, existing, to_add) if to_add.any?
|
|
43
|
+
|
|
44
|
+
# Retention is immutable; warn if different and do not include on update.
|
|
45
|
+
have_ret = info.config.retention.to_s.downcase
|
|
46
|
+
StreamSupport.log_retention_mismatch(name, have: have_ret, want: RETENTION) if have_ret != RETENTION
|
|
47
|
+
|
|
48
|
+
# Storage can be updated; do it without passing retention.
|
|
49
|
+
have_storage = info.config.storage.to_s.downcase
|
|
50
|
+
if have_storage != STORAGE
|
|
51
|
+
apply_update(jts, name, existing, storage: STORAGE)
|
|
52
|
+
StreamSupport.log_config_updated(name, storage: STORAGE)
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return if to_add.any?
|
|
57
|
+
|
|
58
|
+
StreamSupport.log_already_covered(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ---- tiny helpers extracted to reduce ABC ----
|
|
62
|
+
def add_subjects(jts, name, existing, to_add)
|
|
63
|
+
allowed, blocked = OverlapGuard.partition_allowed(jts, name, to_add)
|
|
64
|
+
return StreamSupport.log_all_blocked(name, blocked) if allowed.empty?
|
|
65
|
+
|
|
66
|
+
target = (existing + allowed).uniq
|
|
67
|
+
OverlapGuard.check!(jts, name, target)
|
|
68
|
+
# Do not pass retention on update to avoid 10052.
|
|
69
|
+
apply_update(jts, name, target)
|
|
70
|
+
StreamSupport.log_updated(name, allowed, blocked)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Only include mutable fields on update (subjects, storage). Never retention.
|
|
74
|
+
def apply_update(jts, name, subjects, storage: nil)
|
|
75
|
+
params = { name: name, subjects: subjects }
|
|
76
|
+
params[:storage] = storage if storage
|
|
77
|
+
jts.update_stream(**params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ensure_create(jts, name, desired_subjects)
|
|
81
|
+
allowed, blocked = OverlapGuard.partition_allowed(jts, name, desired_subjects)
|
|
82
|
+
return StreamSupport.log_not_created(name, blocked) if allowed.empty?
|
|
83
|
+
|
|
84
|
+
jts.add_stream(
|
|
85
|
+
name: name,
|
|
86
|
+
subjects: allowed,
|
|
87
|
+
retention: RETENTION,
|
|
88
|
+
storage: STORAGE
|
|
89
|
+
)
|
|
90
|
+
StreamSupport.log_created(name, allowed, blocked, RETENTION, STORAGE)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def safe_stream_info(jts, name)
|
|
94
|
+
jts.stream_info(name)
|
|
95
|
+
rescue NATS::JetStream::Error => e
|
|
96
|
+
return nil if StreamSupport.stream_not_found?(e)
|
|
97
|
+
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
require_relative 'subject_matcher'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
# Utility module providing helper methods for stream management.
|
|
8
|
+
# Extracted from Stream class to follow Single Responsibility Principle.
|
|
9
|
+
#
|
|
10
|
+
# This module provides:
|
|
11
|
+
# - Subject normalization and filtering utilities
|
|
12
|
+
# - NATS error detection helpers
|
|
13
|
+
# - Structured logging for stream operations
|
|
14
|
+
#
|
|
15
|
+
# @example Normalizing subjects
|
|
16
|
+
# StreamSupport.normalize_subjects(['foo.bar', nil, '', 'baz'])
|
|
17
|
+
# # => ['foo.bar', 'baz']
|
|
18
|
+
#
|
|
19
|
+
# @example Checking for missing subjects
|
|
20
|
+
# existing = ['foo.*', 'bar.>']
|
|
21
|
+
# desired = ['foo.bar', 'baz.qux']
|
|
22
|
+
# StreamSupport.missing_subjects(existing, desired)
|
|
23
|
+
# # => ['baz.qux'] (foo.bar is covered by foo.*)
|
|
24
|
+
module StreamSupport
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Normalize a list of subjects
|
|
28
|
+
#
|
|
29
|
+
# Flattens nested arrays, removes nils, empty strings, converts to strings,
|
|
30
|
+
# and returns unique values.
|
|
31
|
+
#
|
|
32
|
+
# @param list [Array, Object] List of subjects (can be nested)
|
|
33
|
+
# @return [Array<String>] Normalized unique subject list
|
|
34
|
+
def normalize_subjects(list)
|
|
35
|
+
Array(list).flatten.compact.map!(&:to_s).reject(&:empty?).uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find subjects from desired list not covered by existing patterns
|
|
39
|
+
#
|
|
40
|
+
# Uses SubjectMatcher to determine if each desired subject is covered
|
|
41
|
+
# by any of the existing subject patterns (including wildcards).
|
|
42
|
+
#
|
|
43
|
+
# @param existing [Array<String>] Existing subject patterns
|
|
44
|
+
# @param desired [Array<String>] Desired subjects to check
|
|
45
|
+
# @return [Array<String>] Subjects not covered by existing patterns
|
|
46
|
+
def missing_subjects(existing, desired)
|
|
47
|
+
desired.reject { |d| SubjectMatcher.covered?(existing, d) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if error indicates stream not found
|
|
51
|
+
#
|
|
52
|
+
# Detects NATS JetStream "stream not found" errors by examining
|
|
53
|
+
# the error message for known patterns.
|
|
54
|
+
#
|
|
55
|
+
# @param error [Exception] Error object
|
|
56
|
+
# @return [Boolean] True if error indicates stream not found
|
|
57
|
+
def stream_not_found?(error)
|
|
58
|
+
msg = error.message.to_s
|
|
59
|
+
msg =~ /stream\s+not\s+found/i || msg =~ /\b404\b/
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if error indicates subject overlap
|
|
63
|
+
#
|
|
64
|
+
# Detects NATS JetStream subject overlap errors, which occur when
|
|
65
|
+
# attempting to add subjects that conflict with existing streams.
|
|
66
|
+
#
|
|
67
|
+
# @param error [Exception] Error object
|
|
68
|
+
# @return [Boolean] True if error indicates subject overlap
|
|
69
|
+
def overlap_error?(error)
|
|
70
|
+
msg = error.message.to_s
|
|
71
|
+
msg =~ /subjects?\s+overlap/i || msg =~ /\berr_code=10065\b/ || msg =~ /\b400\b/
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Log when stream subjects are already covered
|
|
75
|
+
#
|
|
76
|
+
# @param name [String] Stream name
|
|
77
|
+
# @return [void]
|
|
78
|
+
def log_already_covered(name)
|
|
79
|
+
Logging.info(
|
|
80
|
+
"Stream #{name} exists; subjects and config already covered.",
|
|
81
|
+
tag: 'NatsPubsub::Stream'
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log when all subjects are blocked by overlap
|
|
86
|
+
#
|
|
87
|
+
# @param name [String] Stream name
|
|
88
|
+
# @param blocked [Array<String>] Blocked subjects
|
|
89
|
+
# @return [void]
|
|
90
|
+
def log_all_blocked(name, blocked)
|
|
91
|
+
if blocked.any?
|
|
92
|
+
Logging.warn(
|
|
93
|
+
"Stream #{name}: all missing subjects belong to other streams; unchanged. blocked=#{blocked.inspect}",
|
|
94
|
+
tag: 'NatsPubsub::Stream'
|
|
95
|
+
)
|
|
96
|
+
else
|
|
97
|
+
Logging.info("Stream #{name} exists; nothing to add.", tag: 'NatsPubsub::Stream')
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Log when stream is updated with new subjects
|
|
102
|
+
#
|
|
103
|
+
# @param name [String] Stream name
|
|
104
|
+
# @param added [Array<String>] Successfully added subjects
|
|
105
|
+
# @param blocked [Array<String>] Blocked subjects
|
|
106
|
+
# @return [void]
|
|
107
|
+
def log_updated(name, added, blocked)
|
|
108
|
+
msg = "Updated stream #{name}; added subjects=#{added.inspect}"
|
|
109
|
+
msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
|
|
110
|
+
Logging.info(msg, tag: 'NatsPubsub::Stream')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Log when stream creation is skipped due to overlaps
|
|
114
|
+
#
|
|
115
|
+
# @param name [String] Stream name
|
|
116
|
+
# @param blocked [Array<String>] Blocked subjects
|
|
117
|
+
# @return [void]
|
|
118
|
+
def log_not_created(name, blocked)
|
|
119
|
+
Logging.warn(
|
|
120
|
+
"Not creating stream #{name}: all desired subjects belong to other streams. blocked=#{blocked.inspect}",
|
|
121
|
+
tag: 'NatsPubsub::Stream'
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Log when stream is successfully created
|
|
126
|
+
#
|
|
127
|
+
# @param name [String] Stream name
|
|
128
|
+
# @param allowed [Array<String>] Allowed subjects
|
|
129
|
+
# @param blocked [Array<String>] Blocked subjects
|
|
130
|
+
# @param retention [String] Retention policy
|
|
131
|
+
# @param storage [String] Storage type
|
|
132
|
+
# @return [void]
|
|
133
|
+
def log_created(name, allowed, blocked, retention, storage)
|
|
134
|
+
msg = [
|
|
135
|
+
"Created stream #{name}",
|
|
136
|
+
"subjects=#{allowed.inspect}",
|
|
137
|
+
"retention=#{retention.inspect}",
|
|
138
|
+
"storage=#{storage.inspect}"
|
|
139
|
+
].join(' ')
|
|
140
|
+
msg += " (skipped overlapped=#{blocked.inspect})" if blocked.any?
|
|
141
|
+
Logging.info(msg, tag: 'NatsPubsub::Stream')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Log when stream config is updated
|
|
145
|
+
#
|
|
146
|
+
# @param name [String] Stream name
|
|
147
|
+
# @param storage [String] Storage type
|
|
148
|
+
# @return [void]
|
|
149
|
+
def log_config_updated(name, storage:)
|
|
150
|
+
Logging.info(
|
|
151
|
+
"Updated stream #{name} config; storage=#{storage.inspect}",
|
|
152
|
+
tag: 'NatsPubsub::Stream'
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Log retention policy mismatch warning
|
|
157
|
+
#
|
|
158
|
+
# @param name [String] Stream name
|
|
159
|
+
# @param have [String] Current retention policy
|
|
160
|
+
# @param want [String] Desired retention policy
|
|
161
|
+
# @return [void]
|
|
162
|
+
def log_retention_mismatch(name, have:, want:)
|
|
163
|
+
Logging.warn(
|
|
164
|
+
"Stream #{name} retention mismatch (have=#{have.inspect}, want=#{want.inspect}). " \
|
|
165
|
+
"Retention is immutable; skipping retention change.",
|
|
166
|
+
tag: 'NatsPubsub::Stream'
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NatsPubsub
|
|
4
|
+
# Subject matching helpers.
|
|
5
|
+
module SubjectMatcher
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def covered?(patterns, subject)
|
|
9
|
+
Array(patterns).any? { |pat| match?(pat.to_s, subject.to_s) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Proper NATS semantics:
|
|
13
|
+
# - '*' matches exactly one token
|
|
14
|
+
# - '>' matches the rest (zero or more tokens)
|
|
15
|
+
def match?(pattern, subject)
|
|
16
|
+
pattern_tokens = pattern.split('.')
|
|
17
|
+
subject_tokens = subject.split('.')
|
|
18
|
+
|
|
19
|
+
index = 0
|
|
20
|
+
while index < pattern_tokens.length && index < subject_tokens.length
|
|
21
|
+
pattern_token = pattern_tokens[index]
|
|
22
|
+
case pattern_token
|
|
23
|
+
when '>'
|
|
24
|
+
return true # tail wildcard absorbs the rest
|
|
25
|
+
when '*'
|
|
26
|
+
# matches this token; continue
|
|
27
|
+
else
|
|
28
|
+
return false unless pattern_token == subject_tokens[index]
|
|
29
|
+
end
|
|
30
|
+
index += 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Exact match
|
|
34
|
+
return true if index == pattern_tokens.length && index == subject_tokens.length
|
|
35
|
+
|
|
36
|
+
# If pattern has remaining '>' it can absorb remainder
|
|
37
|
+
pattern_tokens[index] == '>' || pattern_tokens[index..]&.include?('>')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Do two wildcard patterns admit at least one same subject?
|
|
41
|
+
def overlap?(sub_a, sub_b)
|
|
42
|
+
overlap_parts?(sub_a.split('.'), sub_b.split('.'))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def overlap_parts?(a_parts, b_parts)
|
|
46
|
+
a_index = 0
|
|
47
|
+
b_index = 0
|
|
48
|
+
while a_index < a_parts.length && b_index < b_parts.length
|
|
49
|
+
a_token = a_parts[a_index]
|
|
50
|
+
b_token = b_parts[b_index]
|
|
51
|
+
return true if has_tail_wildcard?(a_token, b_token)
|
|
52
|
+
return false unless token_match?(a_token, b_token)
|
|
53
|
+
|
|
54
|
+
a_index += 1
|
|
55
|
+
b_index += 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
tail_overlap?(a_parts[a_index..], b_parts[b_index..])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def has_tail_wildcard?(a_token, b_token)
|
|
62
|
+
a_token == '>' || b_token == '>'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def token_match?(a_token, b_token)
|
|
66
|
+
a_token == b_token || a_token == '*' || b_token == '*'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def tail_overlap?(a_tail, b_tail)
|
|
70
|
+
a_tail ||= []
|
|
71
|
+
b_tail ||= []
|
|
72
|
+
return true if a_tail.include?('>') || b_tail.include?('>')
|
|
73
|
+
|
|
74
|
+
a_tail.empty? && b_tail.empty?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/config'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative 'stream'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
class Topology
|
|
9
|
+
def self.ensure!(jts)
|
|
10
|
+
cfg = NatsPubsub.config
|
|
11
|
+
|
|
12
|
+
# Create stream for all PubSub events
|
|
13
|
+
subjects = ["#{cfg.env}.events.>"]
|
|
14
|
+
subjects << cfg.dlq_subject if cfg.use_dlq
|
|
15
|
+
|
|
16
|
+
Stream.ensure!(jts, cfg.stream_name, subjects)
|
|
17
|
+
|
|
18
|
+
Logging.info(
|
|
19
|
+
"PubSub stream ready: #{cfg.stream_name} with subjects=#{subjects.inspect}",
|
|
20
|
+
tag: 'NatsPubsub::Topology'
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<h2>Dashboard</h2>
|
|
2
|
+
|
|
3
|
+
<h3 style="margin-top: 30px; margin-bottom: 15px;">Outbox Statistics</h3>
|
|
4
|
+
<div class="stats">
|
|
5
|
+
<div class="stat-card">
|
|
6
|
+
<h3>Total</h3>
|
|
7
|
+
<div class="value"><%= @outbox_stats[:total] || 0 %></div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="stat-card warning">
|
|
10
|
+
<h3>Pending</h3>
|
|
11
|
+
<div class="value"><%= @outbox_stats[:pending] || 0 %></div>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="stat-card warning">
|
|
14
|
+
<h3>Publishing</h3>
|
|
15
|
+
<div class="value"><%= @outbox_stats[:publishing] || 0 %></div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="stat-card success">
|
|
18
|
+
<h3>Sent</h3>
|
|
19
|
+
<div class="value"><%= @outbox_stats[:sent] || 0 %></div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="stat-card danger">
|
|
22
|
+
<h3>Failed</h3>
|
|
23
|
+
<div class="value"><%= @outbox_stats[:failed] || 0 %></div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<h3 style="margin-top: 30px; margin-bottom: 15px;">Inbox Statistics</h3>
|
|
28
|
+
<div class="stats">
|
|
29
|
+
<div class="stat-card">
|
|
30
|
+
<h3>Total</h3>
|
|
31
|
+
<div class="value"><%= @inbox_stats[:total] || 0 %></div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="stat-card warning">
|
|
34
|
+
<h3>Received</h3>
|
|
35
|
+
<div class="value"><%= @inbox_stats[:received] || 0 %></div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="stat-card warning">
|
|
38
|
+
<h3>Processing</h3>
|
|
39
|
+
<div class="value"><%= @inbox_stats[:processing] || 0 %></div>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="stat-card success">
|
|
42
|
+
<h3>Processed</h3>
|
|
43
|
+
<div class="value"><%= @inbox_stats[:processed] || 0 %></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="stat-card danger">
|
|
46
|
+
<h3>Failed</h3>
|
|
47
|
+
<div class="value"><%= @inbox_stats[:failed] || 0 %></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="card" style="margin-top: 30px;">
|
|
52
|
+
<h2>Quick Links</h2>
|
|
53
|
+
<p><a href="/outbox" class="btn btn-primary">View Outbox Events</a></p>
|
|
54
|
+
<p style="margin-top: 10px;"><a href="/inbox" class="btn btn-primary">View Inbox Events</a></p>
|
|
55
|
+
</div>
|