jetstream_bridge 3.0.2 → 4.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 +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +1147 -82
- data/lib/jetstream_bridge/consumer/consumer.rb +174 -6
- data/lib/jetstream_bridge/consumer/inbox/inbox_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/message_processor.rb +41 -7
- data/lib/jetstream_bridge/consumer/middleware.rb +154 -0
- data/lib/jetstream_bridge/core/config.rb +150 -9
- data/lib/jetstream_bridge/core/config_preset.rb +99 -0
- data/lib/jetstream_bridge/core/connection.rb +5 -2
- data/lib/jetstream_bridge/core/connection_factory.rb +1 -1
- data/lib/jetstream_bridge/core/duration.rb +19 -35
- data/lib/jetstream_bridge/errors.rb +60 -8
- data/lib/jetstream_bridge/models/event.rb +202 -0
- data/lib/jetstream_bridge/{inbox_event.rb → models/inbox_event.rb} +61 -3
- data/lib/jetstream_bridge/{outbox_event.rb → models/outbox_event.rb} +64 -15
- data/lib/jetstream_bridge/models/publish_result.rb +64 -0
- data/lib/jetstream_bridge/models/subject.rb +53 -2
- data/lib/jetstream_bridge/publisher/batch_publisher.rb +163 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +238 -19
- data/lib/jetstream_bridge/test_helpers.rb +275 -0
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +178 -3
- data/lib/tasks/yard.rake +18 -0
- metadata +11 -4
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JetstreamBridge
|
|
4
|
+
# Configuration presets for common scenarios
|
|
5
|
+
module ConfigPreset
|
|
6
|
+
# Development preset: minimal features for fast local development
|
|
7
|
+
#
|
|
8
|
+
# @param config [Config] Configuration object to apply preset to
|
|
9
|
+
def self.development(config)
|
|
10
|
+
config.use_outbox = false
|
|
11
|
+
config.use_inbox = false
|
|
12
|
+
config.use_dlq = false
|
|
13
|
+
config.max_deliver = 3
|
|
14
|
+
config.ack_wait = '10s'
|
|
15
|
+
config.backoff = %w[1s 2s 5s]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Test preset: similar to development but with synchronous behavior
|
|
19
|
+
#
|
|
20
|
+
# @param config [Config] Configuration object to apply preset to
|
|
21
|
+
def self.test(config)
|
|
22
|
+
config.use_outbox = false
|
|
23
|
+
config.use_inbox = false
|
|
24
|
+
config.use_dlq = false
|
|
25
|
+
config.max_deliver = 2
|
|
26
|
+
config.ack_wait = '5s'
|
|
27
|
+
config.backoff = %w[0.1s 0.5s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Production preset: all reliability features enabled
|
|
31
|
+
#
|
|
32
|
+
# @param config [Config] Configuration object to apply preset to
|
|
33
|
+
def self.production(config)
|
|
34
|
+
config.use_outbox = true
|
|
35
|
+
config.use_inbox = true
|
|
36
|
+
config.use_dlq = true
|
|
37
|
+
config.max_deliver = 5
|
|
38
|
+
config.ack_wait = '30s'
|
|
39
|
+
config.backoff = %w[1s 5s 15s 30s 60s]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Staging preset: production-like but with faster retries
|
|
43
|
+
#
|
|
44
|
+
# @param config [Config] Configuration object to apply preset to
|
|
45
|
+
def self.staging(config)
|
|
46
|
+
config.use_outbox = true
|
|
47
|
+
config.use_inbox = true
|
|
48
|
+
config.use_dlq = true
|
|
49
|
+
config.max_deliver = 3
|
|
50
|
+
config.ack_wait = '15s'
|
|
51
|
+
config.backoff = %w[1s 5s 15s]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# High throughput preset: optimized for volume over reliability
|
|
55
|
+
#
|
|
56
|
+
# @param config [Config] Configuration object to apply preset to
|
|
57
|
+
def self.high_throughput(config)
|
|
58
|
+
config.use_outbox = false # Skip DB writes
|
|
59
|
+
config.use_inbox = false # Skip deduplication
|
|
60
|
+
config.use_dlq = true # But keep DLQ for visibility
|
|
61
|
+
config.max_deliver = 3
|
|
62
|
+
config.ack_wait = '10s'
|
|
63
|
+
config.backoff = %w[1s 2s 5s]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Maximum reliability preset: every safety feature enabled
|
|
67
|
+
#
|
|
68
|
+
# @param config [Config] Configuration object to apply preset to
|
|
69
|
+
def self.maximum_reliability(config)
|
|
70
|
+
config.use_outbox = true
|
|
71
|
+
config.use_inbox = true
|
|
72
|
+
config.use_dlq = true
|
|
73
|
+
config.max_deliver = 10
|
|
74
|
+
config.ack_wait = '60s'
|
|
75
|
+
config.backoff = %w[1s 5s 15s 30s 60s 120s 300s 600s 1200s 1800s]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get available preset names
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Symbol>] List of available preset names
|
|
81
|
+
def self.available_presets
|
|
82
|
+
[:development, :test, :production, :staging, :high_throughput, :maximum_reliability]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Apply a preset by name
|
|
86
|
+
#
|
|
87
|
+
# @param config [Config] Configuration object
|
|
88
|
+
# @param preset_name [Symbol, String] Name of preset to apply
|
|
89
|
+
# @raise [ArgumentError] If preset name is unknown
|
|
90
|
+
def self.apply(config, preset_name)
|
|
91
|
+
preset_method = preset_name.to_sym
|
|
92
|
+
unless available_presets.include?(preset_method)
|
|
93
|
+
raise ArgumentError, "Unknown preset: #{preset_name}. Available: #{available_presets.join(', ')}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
public_send(preset_method, config)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -81,7 +81,10 @@ module JetstreamBridge
|
|
|
81
81
|
# Public API for checking connection status
|
|
82
82
|
# @return [Boolean] true if NATS client is connected and JetStream is healthy
|
|
83
83
|
def connected?
|
|
84
|
-
@nc&.connected?
|
|
84
|
+
return false unless @nc&.connected?
|
|
85
|
+
return false unless @jts
|
|
86
|
+
|
|
87
|
+
jetstream_healthy?
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
# Public API for getting connection timestamp
|
|
@@ -141,7 +144,7 @@ module JetstreamBridge
|
|
|
141
144
|
# Create JetStream context
|
|
142
145
|
@jts = @nc.jetstream
|
|
143
146
|
|
|
144
|
-
#
|
|
147
|
+
# Ensure JetStream responds to #nc
|
|
145
148
|
return if @jts.respond_to?(:nc)
|
|
146
149
|
|
|
147
150
|
nc_ref = @nc
|
|
@@ -89,7 +89,7 @@ module JetstreamBridge
|
|
|
89
89
|
def create_jetstream(client)
|
|
90
90
|
jts = client.jetstream
|
|
91
91
|
|
|
92
|
-
# Ensure JetStream responds to #nc
|
|
92
|
+
# Ensure JetStream responds to #nc
|
|
93
93
|
jts.define_singleton_method(:nc) { client } unless jts.respond_to?(:nc)
|
|
94
94
|
|
|
95
95
|
jts
|
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
module JetstreamBridge
|
|
6
6
|
# Utility for parsing human-friendly durations into milliseconds.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# - Integers >= 1000 are treated as milliseconds (e.g., 1500 -> 1500ms)
|
|
12
|
-
# Prefer setting `default_unit:` to :s or :ms for unambiguous behavior.
|
|
8
|
+
# Uses auto-detection heuristic by default: integers <1000 are treated as
|
|
9
|
+
# seconds, >=1000 as milliseconds. Strings with unit suffixes are supported
|
|
10
|
+
# (e.g., "30s", "500ms", "1h").
|
|
13
11
|
#
|
|
14
12
|
# Examples:
|
|
15
|
-
# Duration.to_millis(
|
|
16
|
-
# Duration.to_millis(1500) #=> 1500
|
|
17
|
-
# Duration.to_millis(
|
|
18
|
-
# Duration.to_millis(1500, default_unit: :s) #=> 1_500_000
|
|
13
|
+
# Duration.to_millis(2) #=> 2000 (auto: seconds)
|
|
14
|
+
# Duration.to_millis(1500) #=> 1500 (auto: milliseconds)
|
|
15
|
+
# Duration.to_millis(30, default_unit: :s) #=> 30000
|
|
19
16
|
# Duration.to_millis("30s") #=> 30000
|
|
20
17
|
# Duration.to_millis("500ms") #=> 500
|
|
21
18
|
# Duration.to_millis("250us") #=> 0
|
|
@@ -43,8 +40,9 @@ module JetstreamBridge
|
|
|
43
40
|
module_function
|
|
44
41
|
|
|
45
42
|
# default_unit:
|
|
46
|
-
# :auto (
|
|
47
|
-
# :
|
|
43
|
+
# :auto (default: heuristic - <1000 => seconds, >=1000 => milliseconds)
|
|
44
|
+
# :ms (explicit milliseconds)
|
|
45
|
+
# :ns, :us, :s, :m, :h, :d (explicit units)
|
|
48
46
|
def to_millis(val, default_unit: :auto)
|
|
49
47
|
case val
|
|
50
48
|
when Integer then int_to_ms(val, default_unit: default_unit)
|
|
@@ -69,20 +67,7 @@ module JetstreamBridge
|
|
|
69
67
|
# --- internal helpers ---
|
|
70
68
|
|
|
71
69
|
def int_to_ms(num, default_unit:)
|
|
72
|
-
|
|
73
|
-
when :auto
|
|
74
|
-
# Preserve existing heuristic for compatibility but log deprecation warning
|
|
75
|
-
if defined?(Logging) && num.positive? && num < 1_000
|
|
76
|
-
Logging.debug(
|
|
77
|
-
"Duration :auto heuristic treating #{num} as seconds. " \
|
|
78
|
-
"Consider specifying default_unit: :s or :ms for clarity.",
|
|
79
|
-
tag: 'JetstreamBridge::Duration'
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
|
-
num >= 1_000 ? num : num * 1_000
|
|
83
|
-
else
|
|
84
|
-
coerce_numeric_to_ms(num.to_f, default_unit)
|
|
85
|
-
end
|
|
70
|
+
coerce_numeric_to_ms(num.to_f, default_unit)
|
|
86
71
|
end
|
|
87
72
|
|
|
88
73
|
def float_to_ms(flt, default_unit:)
|
|
@@ -104,17 +89,16 @@ module JetstreamBridge
|
|
|
104
89
|
end
|
|
105
90
|
|
|
106
91
|
def coerce_numeric_to_ms(num, unit)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
(num * 1_000).round
|
|
111
|
-
else
|
|
112
|
-
u = unit.to_s
|
|
113
|
-
mult = MULTIPLIER_MS[u]
|
|
114
|
-
raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
|
|
115
|
-
|
|
116
|
-
(num * mult).round
|
|
92
|
+
# Handle :auto unit with heuristic: <1000 => seconds, >=1000 => milliseconds
|
|
93
|
+
if unit == :auto
|
|
94
|
+
return (num < 1000 ? num * 1_000 : num).round
|
|
117
95
|
end
|
|
96
|
+
|
|
97
|
+
u = unit.to_s
|
|
98
|
+
mult = MULTIPLIER_MS[u]
|
|
99
|
+
raise ArgumentError, "invalid unit for default_unit: #{unit.inspect}" unless mult
|
|
100
|
+
|
|
101
|
+
(num * mult).round
|
|
118
102
|
end
|
|
119
103
|
end
|
|
120
104
|
end
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module JetstreamBridge
|
|
4
|
-
# Base error for all JetStream Bridge errors
|
|
5
|
-
class Error < StandardError
|
|
4
|
+
# Base error for all JetStream Bridge errors with context support
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :context
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, context: {})
|
|
9
|
+
super(message)
|
|
10
|
+
@context = context.freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
6
13
|
|
|
7
14
|
# Configuration errors
|
|
8
15
|
class ConfigurationError < Error; end
|
|
@@ -14,13 +21,47 @@ module JetstreamBridge
|
|
|
14
21
|
class ConnectionNotEstablishedError < ConnectionError; end
|
|
15
22
|
class HealthCheckFailedError < ConnectionError; end
|
|
16
23
|
|
|
17
|
-
# Publisher errors
|
|
18
|
-
class PublishError < Error
|
|
24
|
+
# Publisher errors with enriched context
|
|
25
|
+
class PublishError < Error
|
|
26
|
+
attr_reader :event_id, :subject
|
|
27
|
+
|
|
28
|
+
def initialize(message = nil, event_id: nil, subject: nil, context: {})
|
|
29
|
+
@event_id = event_id
|
|
30
|
+
@subject = subject
|
|
31
|
+
super(message, context: context.merge(event_id: event_id, subject: subject).compact)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
19
35
|
class PublishFailedError < PublishError; end
|
|
20
36
|
class OutboxError < PublishError; end
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
class BatchPublishError < PublishError
|
|
39
|
+
attr_reader :failed_events, :successful_count
|
|
40
|
+
|
|
41
|
+
def initialize(message = nil, failed_events: [], successful_count: 0, context: {})
|
|
42
|
+
@failed_events = failed_events
|
|
43
|
+
@successful_count = successful_count
|
|
44
|
+
super(
|
|
45
|
+
message,
|
|
46
|
+
context: context.merge(
|
|
47
|
+
failed_count: failed_events.size,
|
|
48
|
+
successful_count: successful_count
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Consumer errors with delivery context
|
|
55
|
+
class ConsumerError < Error
|
|
56
|
+
attr_reader :event_id, :deliveries
|
|
57
|
+
|
|
58
|
+
def initialize(message = nil, event_id: nil, deliveries: nil, context: {})
|
|
59
|
+
@event_id = event_id
|
|
60
|
+
@deliveries = deliveries
|
|
61
|
+
super(message, context: context.merge(event_id: event_id, deliveries: deliveries).compact)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
24
65
|
class HandlerError < ConsumerError; end
|
|
25
66
|
class InboxError < ConsumerError; end
|
|
26
67
|
|
|
@@ -34,6 +75,17 @@ module JetstreamBridge
|
|
|
34
75
|
class DlqError < Error; end
|
|
35
76
|
class DlqPublishFailedError < DlqError; end
|
|
36
77
|
|
|
37
|
-
# Retry errors
|
|
38
|
-
|
|
78
|
+
# Retry errors
|
|
79
|
+
class RetryExhausted < Error
|
|
80
|
+
attr_reader :attempts, :original_error
|
|
81
|
+
|
|
82
|
+
def initialize(message = nil, attempts: 0, original_error: nil, context: {})
|
|
83
|
+
@attempts = attempts
|
|
84
|
+
@original_error = original_error
|
|
85
|
+
super(
|
|
86
|
+
message || "Failed after #{attempts} attempts: #{original_error&.message}",
|
|
87
|
+
context: context.merge(attempts: attempts).compact
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
39
91
|
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module JetstreamBridge
|
|
6
|
+
module Models
|
|
7
|
+
# Structured event object provided to consumers
|
|
8
|
+
#
|
|
9
|
+
# @example Accessing event data in consumer
|
|
10
|
+
# JetstreamBridge.subscribe do |event|
|
|
11
|
+
# puts event.type # "user.created"
|
|
12
|
+
# puts event.payload.id # 123
|
|
13
|
+
# puts event.resource_type # "user"
|
|
14
|
+
# puts event.deliveries # 1
|
|
15
|
+
# puts event.metadata.trace_id # "abc123"
|
|
16
|
+
# end
|
|
17
|
+
class Event
|
|
18
|
+
# Metadata associated with message delivery
|
|
19
|
+
Metadata = Struct.new(
|
|
20
|
+
:subject,
|
|
21
|
+
:deliveries,
|
|
22
|
+
:stream,
|
|
23
|
+
:sequence,
|
|
24
|
+
:consumer,
|
|
25
|
+
:timestamp,
|
|
26
|
+
keyword_init: true
|
|
27
|
+
) do
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
subject: subject,
|
|
31
|
+
deliveries: deliveries,
|
|
32
|
+
stream: stream,
|
|
33
|
+
sequence: sequence,
|
|
34
|
+
consumer: consumer,
|
|
35
|
+
timestamp: timestamp
|
|
36
|
+
}.compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Payload accessor with method-style access
|
|
41
|
+
class PayloadAccessor
|
|
42
|
+
def initialize(payload)
|
|
43
|
+
@payload = payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def method_missing(method_name, *args)
|
|
47
|
+
return @payload[method_name.to_s] if args.empty? && @payload.key?(method_name.to_s)
|
|
48
|
+
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def respond_to_missing?(method_name, _include_private = false)
|
|
53
|
+
@payload.key?(method_name.to_s) || super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def [](key)
|
|
57
|
+
@payload[key.to_s]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def dig(*keys)
|
|
61
|
+
@payload.dig(*keys.map(&:to_s))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
@payload
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
alias to_hash to_h
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
attr_reader :event_id, :type, :resource_type, :resource_id,
|
|
72
|
+
:producer, :occurred_at, :trace_id, :schema_version,
|
|
73
|
+
:metadata
|
|
74
|
+
|
|
75
|
+
# @param envelope [Hash] The raw event envelope
|
|
76
|
+
# @param metadata [Hash] Message delivery metadata
|
|
77
|
+
def initialize(envelope, metadata: {})
|
|
78
|
+
envelope = envelope.transform_keys(&:to_s) if envelope.respond_to?(:transform_keys)
|
|
79
|
+
|
|
80
|
+
@event_id = envelope['event_id']
|
|
81
|
+
@type = envelope['event_type']
|
|
82
|
+
@resource_type = envelope['resource_type']
|
|
83
|
+
@resource_id = envelope['resource_id']
|
|
84
|
+
@producer = envelope['producer']
|
|
85
|
+
@schema_version = envelope['schema_version'] || 1
|
|
86
|
+
@trace_id = envelope['trace_id']
|
|
87
|
+
|
|
88
|
+
@occurred_at = parse_time(envelope['occurred_at'])
|
|
89
|
+
@payload = PayloadAccessor.new(envelope['payload'] || {})
|
|
90
|
+
@metadata = build_metadata(metadata)
|
|
91
|
+
@raw_envelope = envelope
|
|
92
|
+
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Access payload with method-style syntax
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# event.payload.user_id # Same as event.payload["user_id"]
|
|
100
|
+
# event.payload.to_h # Get raw payload hash
|
|
101
|
+
attr_reader :payload
|
|
102
|
+
|
|
103
|
+
# Get raw envelope hash
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] The original envelope
|
|
106
|
+
def to_envelope
|
|
107
|
+
@raw_envelope
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get hash representation
|
|
111
|
+
#
|
|
112
|
+
# @return [Hash] Event as hash
|
|
113
|
+
def to_h
|
|
114
|
+
{
|
|
115
|
+
event_id: @event_id,
|
|
116
|
+
type: @type,
|
|
117
|
+
resource_type: @resource_type,
|
|
118
|
+
resource_id: @resource_id,
|
|
119
|
+
producer: @producer,
|
|
120
|
+
occurred_at: @occurred_at&.iso8601,
|
|
121
|
+
trace_id: @trace_id,
|
|
122
|
+
schema_version: @schema_version,
|
|
123
|
+
payload: @payload.to_h,
|
|
124
|
+
metadata: @metadata.to_h
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
alias to_hash to_h
|
|
129
|
+
|
|
130
|
+
# Number of times this message has been delivered
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] Delivery count
|
|
133
|
+
def deliveries
|
|
134
|
+
@metadata.deliveries || 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Subject this message was received on
|
|
138
|
+
#
|
|
139
|
+
# @return [String] NATS subject
|
|
140
|
+
def subject
|
|
141
|
+
@metadata.subject
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Stream this message came from
|
|
145
|
+
#
|
|
146
|
+
# @return [String, nil] Stream name
|
|
147
|
+
def stream
|
|
148
|
+
@metadata.stream
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Message sequence number in the stream
|
|
152
|
+
#
|
|
153
|
+
# @return [Integer, nil] Sequence number
|
|
154
|
+
def sequence
|
|
155
|
+
@metadata.sequence
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def inspect
|
|
159
|
+
"#<#{self.class.name} id=#{@event_id} type=#{@type} deliveries=#{deliveries}>"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Support hash-like access for backwards compatibility
|
|
163
|
+
def [](key)
|
|
164
|
+
case key.to_s
|
|
165
|
+
when 'event_id' then @event_id
|
|
166
|
+
when 'event_type' then @type
|
|
167
|
+
when 'resource_type' then @resource_type
|
|
168
|
+
when 'resource_id' then @resource_id
|
|
169
|
+
when 'producer' then @producer
|
|
170
|
+
when 'occurred_at' then @occurred_at&.iso8601
|
|
171
|
+
when 'trace_id' then @trace_id
|
|
172
|
+
when 'schema_version' then @schema_version
|
|
173
|
+
when 'payload' then @payload.to_h
|
|
174
|
+
else
|
|
175
|
+
@raw_envelope[key.to_s]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def build_metadata(meta)
|
|
182
|
+
Metadata.new(
|
|
183
|
+
subject: meta[:subject] || meta['subject'],
|
|
184
|
+
deliveries: meta[:deliveries] || meta['deliveries'] || 1,
|
|
185
|
+
stream: meta[:stream] || meta['stream'],
|
|
186
|
+
sequence: meta[:sequence] || meta['sequence'],
|
|
187
|
+
consumer: meta[:consumer] || meta['consumer'],
|
|
188
|
+
timestamp: Time.now.utc
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_time(value)
|
|
193
|
+
return nil if value.nil?
|
|
194
|
+
return value if value.is_a?(Time)
|
|
195
|
+
|
|
196
|
+
Time.parse(value.to_s)
|
|
197
|
+
rescue ArgumentError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -67,21 +67,79 @@ module JetstreamBridge
|
|
|
67
67
|
|
|
68
68
|
# ---- Defaults that do not require schema at load time ----
|
|
69
69
|
before_validation do
|
|
70
|
-
self.status ||=
|
|
70
|
+
self.status ||= JetstreamBridge::Config::Status::RECEIVED if self.class.has_column?(:status) && status.blank?
|
|
71
71
|
self.received_at ||= Time.now.utc if self.class.has_column?(:received_at) && received_at.blank?
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# ----
|
|
74
|
+
# ---- Query Scopes ----
|
|
75
|
+
scope :received, -> { where(status: JetstreamBridge::Config::Status::RECEIVED) if has_column?(:status) }
|
|
76
|
+
scope :processing, -> { where(status: JetstreamBridge::Config::Status::PROCESSING) if has_column?(:status) }
|
|
77
|
+
scope :processed, -> { where(status: JetstreamBridge::Config::Status::PROCESSED) if has_column?(:status) }
|
|
78
|
+
scope :failed, -> { where(status: JetstreamBridge::Config::Status::FAILED) if has_column?(:status) }
|
|
79
|
+
scope :by_subject, lambda { |subject|
|
|
80
|
+
where(subject: subject) if has_column?(:subject)
|
|
81
|
+
}
|
|
82
|
+
scope :by_stream, lambda { |stream|
|
|
83
|
+
where(stream: stream) if has_column?(:stream)
|
|
84
|
+
}
|
|
85
|
+
scope :recent, lambda { |limit = 100|
|
|
86
|
+
order(received_at: :desc).limit(limit) if has_column?(:received_at)
|
|
87
|
+
}
|
|
88
|
+
scope :unprocessed, lambda {
|
|
89
|
+
where.not(status: JetstreamBridge::Config::Status::PROCESSED) if has_column?(:status)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ---- Class Methods ----
|
|
93
|
+
class << self
|
|
94
|
+
# Clean up old processed events
|
|
95
|
+
#
|
|
96
|
+
# @param older_than [ActiveSupport::Duration] Age threshold
|
|
97
|
+
# @return [Integer] Number of records deleted
|
|
98
|
+
def cleanup_processed(older_than: 30.days)
|
|
99
|
+
return 0 unless has_column?(:status) && has_column?(:processed_at)
|
|
100
|
+
|
|
101
|
+
processed.where('processed_at < ?', older_than.ago).delete_all
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get processing statistics
|
|
105
|
+
#
|
|
106
|
+
# @return [Hash] Statistics hash
|
|
107
|
+
def processing_stats
|
|
108
|
+
return {} unless has_column?(:status)
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
total: count,
|
|
112
|
+
processed: processed.count,
|
|
113
|
+
failed: failed.count,
|
|
114
|
+
pending: unprocessed.count
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ---- Instance Methods ----
|
|
75
120
|
def processed?
|
|
76
121
|
if self.class.has_column?(:processed_at)
|
|
77
122
|
processed_at.present?
|
|
78
123
|
elsif self.class.has_column?(:status)
|
|
79
|
-
status ==
|
|
124
|
+
status == JetstreamBridge::Config::Status::PROCESSED
|
|
80
125
|
else
|
|
81
126
|
false
|
|
82
127
|
end
|
|
83
128
|
end
|
|
84
129
|
|
|
130
|
+
def mark_processed!
|
|
131
|
+
now = Time.now.utc
|
|
132
|
+
self.status = JetstreamBridge::Config::Status::PROCESSED if self.class.has_column?(:status)
|
|
133
|
+
self.processed_at = now if self.class.has_column?(:processed_at)
|
|
134
|
+
save!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def mark_failed!(err_msg)
|
|
138
|
+
self.status = JetstreamBridge::Config::Status::FAILED if self.class.has_column?(:status)
|
|
139
|
+
self.last_error = err_msg if self.class.has_column?(:last_error)
|
|
140
|
+
save!
|
|
141
|
+
end
|
|
142
|
+
|
|
85
143
|
def payload_hash
|
|
86
144
|
v = self[:payload]
|
|
87
145
|
case v
|