redis_exchange 0.3.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.
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RedisExchange
6
+ # StreamConsumer
7
+ # --------------
8
+ # Base class for Redis Streams consumer-group operations.
9
+ # Provides common logic for ensure_group, read, ack, pending, and claim.
10
+ class StreamConsumer
11
+ Entry = Struct.new(:stream, :id, :fields)
12
+
13
+ attr_reader :redis, :stream_key, :group, :consumer
14
+
15
+ def initialize(redis:, group:, consumer:, stream_key:)
16
+ @redis = redis
17
+ @group = group
18
+ @consumer = consumer
19
+ @stream_key = stream_key
20
+ end
21
+
22
+ # Ensure the consumer group exists; create starting at `start_id`.
23
+ # Use '0' to consume existing backlog; use '$' to start from the end.
24
+ def ensure_group(start_id: '0')
25
+ begin
26
+ @redis.xgroup(:create, stream_key, group, start_id, mkstream: true)
27
+ rescue StandardError => e
28
+ # Ignore group exists errors across redis-rb versions
29
+ msg = e.message.to_s.downcase
30
+ return if msg.include?('busygroup') || msg.include?('exists')
31
+ raise
32
+ end
33
+ true
34
+ end
35
+
36
+ # Read a batch of entries as Entry objects.
37
+ # Returns [] on timeout or when no entries are available.
38
+ # @param id [String] '>' for new entries, '0' for pending entries
39
+ def read(count: 100, block_ms: 2000, id: '>')
40
+ # Use positional args form to support older redis-rb versions
41
+ raw = @redis.xreadgroup(group, consumer, stream_key, id, count: count, block: block_ms)
42
+ normalize_read_result(raw)
43
+ end
44
+
45
+ # Convenience loop: continuously read and yield entries to the given block.
46
+ # Stops when the block returns :halt. If auto_ack is true, ack after block
47
+ # executes successfully (no exception raised).
48
+ def each(count: 100, block_ms: 2000, auto_ack: false)
49
+ raise ArgumentError, 'block required' unless block_given?
50
+ loop do
51
+ entries = read(count: count, block_ms: block_ms)
52
+ next if entries.empty?
53
+
54
+ entries.each do |e|
55
+ begin
56
+ result = yield e
57
+ ack(e.id) if auto_ack
58
+ return if result == :halt
59
+ rescue StandardError => err
60
+ warn "[#{self.class.name}] error processing #{stream_key} #{e.id}: #{err.message}"
61
+ # Do not ack on error; pending entries can be retried/claimed later
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ # Acknowledge an entry id back to the consumer group.
68
+ def ack(entry_id)
69
+ @redis.xack(stream_key, group, entry_id)
70
+ end
71
+
72
+ # Inspect pending summary (XPENDING without range) for monitoring.
73
+ def pending_summary
74
+ @redis.xpending(stream_key, group)
75
+ end
76
+
77
+ # Get the consumer group lag (number of messages not yet delivered to the group).
78
+ # Returns Integer (lag) or nil if lag cannot be determined.
79
+ def lag
80
+ groups = @redis.xinfo(:groups, stream_key)
81
+ group_info = groups.find { |g| g['name'] == group }
82
+ return nil unless group_info
83
+
84
+ # Redis returns 'lag' field; if nil, it means calculation is pending or not applicable
85
+ group_info['lag']
86
+ end
87
+
88
+ # Reset the consumer group offset to a specific ID (default '0' for beginning).
89
+ # This forces the consumer group to re-process messages starting after the given ID.
90
+ # @param id [String] new last delivered ID (e.g., '0', '$', or specific timestamp)
91
+ def reset_offset(id: '0')
92
+ @redis.xgroup(:setid, stream_key, group, id)
93
+ end
94
+
95
+ # Claim idle pending entries older than min_idle_ms for this consumer.
96
+ # Returns claimed Entry objects.
97
+ def claim_idle(min_idle_ms: 60_000, count: 10)
98
+ # Find pending entries from any consumer that are idle
99
+ summary = @redis.xpending(stream_key, group, '-', '+', count)
100
+ ids = Array(summary).map { |row| row.is_a?(Array) ? row[0] : row['entry_id'] }.compact
101
+ return [] if ids.empty?
102
+
103
+ # Claim them - this moves them to our PEL and returns the message content
104
+ raw = @redis.xclaim(stream_key, group, consumer, min_idle_ms, ids)
105
+
106
+ # xclaim returns entries in a slightly different format than xreadgroup
107
+ # Format: [[id, [k, v, ...]], ...]
108
+ entries = []
109
+ Array(raw).each do |pair|
110
+ id, fields = pair
111
+ entries << Entry.new(stream_key, id, fields_to_hash(fields))
112
+ end
113
+ entries
114
+ end
115
+
116
+ # Manually trim the stream to a maximum length.
117
+ # @param maxlen [Integer] Maximum number of entries to keep
118
+ # @param approximate [Boolean] Whether to use approximate trimming (~), which is more efficient
119
+ # @return [Integer] Number of entries removed
120
+ def trim(maxlen:, approximate: true)
121
+ @redis.xtrim(stream_key, maxlen, approximate: approximate)
122
+ end
123
+
124
+ private
125
+
126
+ # Normalize Redis gem return into [Entry].
127
+ def normalize_read_result(raw)
128
+ return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
129
+
130
+ # Redis >= 5 typically returns a Hash: { stream_key => [[entry_id, [k1, v1, ...]], ...] }
131
+ if raw.is_a?(Hash)
132
+ entries = []
133
+ list = raw[stream_key] || []
134
+ list.each do |pair|
135
+ id, fields = pair
136
+ entries << Entry.new(stream_key, id, fields_to_hash(fields))
137
+ end
138
+ return entries
139
+ end
140
+
141
+ # Fallback for array forms
142
+ # [[stream, [[id, [k, v, ...]], ...]]]
143
+ if raw.is_a?(Array)
144
+ entries = []
145
+ raw.each do |stream_and_entries|
146
+ stream, pairs = stream_and_entries
147
+ Array(pairs).each do |pair|
148
+ id, fields = pair
149
+ entries << Entry.new(stream.to_s, id, fields_to_hash(fields))
150
+ end
151
+ end
152
+ return entries
153
+ end
154
+
155
+ []
156
+ end
157
+
158
+ def fields_to_hash(fields)
159
+ return fields if fields.is_a?(Hash)
160
+ Hash[*Array(fields)]
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'stream_consumer'
4
+
5
+ module RedisExchange
6
+ # TradeStreamConsumer
7
+ # -------------------
8
+ # Encapsulates Redis Streams consumer-group operations for `trades:{symbol}`.
9
+ class TradeStreamConsumer < StreamConsumer
10
+ attr_reader :symbol
11
+
12
+ def initialize(redis:, symbol:, group:, consumer:)
13
+ @symbol = symbol
14
+ super(redis: redis, group: group, consumer: consumer, stream_key: "trades:#{symbol}")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisExchange
4
+ VERSION = '0.3.0'
5
+ end