smart_message 0.0.9 → 0.0.10

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,71 @@
1
+ # lib/smart_message/ddq/base.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ module SmartMessage
6
+ module DDQ
7
+ # Base class for Deduplication Queue implementations
8
+ #
9
+ # Defines the interface that all DDQ storage backends must implement.
10
+ # Provides circular queue semantics with O(1) lookup performance.
11
+ class Base
12
+ attr_reader :size, :logger
13
+
14
+ def initialize(size)
15
+ @size = size
16
+ @logger = SmartMessage::Logger.default
17
+ validate_size!
18
+ end
19
+
20
+ # Check if a UUID exists in the queue
21
+ # @param uuid [String] The UUID to check
22
+ # @return [Boolean] true if UUID exists, false otherwise
23
+ def contains?(uuid)
24
+ raise NotImplementedError, "Subclasses must implement #contains?"
25
+ end
26
+
27
+ # Add a UUID to the queue (removes oldest if full)
28
+ # @param uuid [String] The UUID to add
29
+ # @return [void]
30
+ def add(uuid)
31
+ raise NotImplementedError, "Subclasses must implement #add"
32
+ end
33
+
34
+ # Get current queue statistics
35
+ # @return [Hash] Statistics about the queue
36
+ def stats
37
+ {
38
+ size: @size,
39
+ storage_type: storage_type,
40
+ implementation: self.class.name
41
+ }
42
+ end
43
+
44
+ # Clear all entries from the queue
45
+ # @return [void]
46
+ def clear
47
+ raise NotImplementedError, "Subclasses must implement #clear"
48
+ end
49
+
50
+ # Get the storage type identifier
51
+ # @return [Symbol] Storage type (:memory, :redis, etc.)
52
+ def storage_type
53
+ raise NotImplementedError, "Subclasses must implement #storage_type"
54
+ end
55
+
56
+ private
57
+
58
+ def validate_size!
59
+ unless @size.is_a?(Integer) && @size > 0
60
+ raise ArgumentError, "DDQ size must be a positive integer, got: #{@size.inspect}"
61
+ end
62
+ end
63
+
64
+ def validate_uuid!(uuid)
65
+ unless uuid.is_a?(String) && !uuid.empty?
66
+ raise ArgumentError, "UUID must be a non-empty string, got: #{uuid.inspect}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,109 @@
1
+ # lib/smart_message/ddq/memory.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require 'set'
6
+ require_relative 'base'
7
+
8
+ module SmartMessage
9
+ module DDQ
10
+ # Memory-based Deduplication Queue implementation
11
+ #
12
+ # Uses a hybrid approach with Array for circular queue behavior
13
+ # and Set for O(1) lookup performance. Thread-safe with Mutex protection.
14
+ class Memory < Base
15
+ def initialize(size)
16
+ super(size)
17
+ @circular_array = Array.new(@size)
18
+ @lookup_set = Set.new
19
+ @index = 0
20
+ @mutex = Mutex.new
21
+ @count = 0
22
+
23
+ logger.debug { "[SmartMessage::DDQ::Memory] Initialized with size: #{@size}" }
24
+ end
25
+
26
+ # Check if a UUID exists in the queue (O(1) operation)
27
+ # @param uuid [String] The UUID to check
28
+ # @return [Boolean] true if UUID exists, false otherwise
29
+ def contains?(uuid)
30
+ validate_uuid!(uuid)
31
+
32
+ @mutex.synchronize do
33
+ @lookup_set.include?(uuid)
34
+ end
35
+ end
36
+
37
+ # Add a UUID to the queue, removing oldest if full (O(1) operation)
38
+ # @param uuid [String] The UUID to add
39
+ # @return [void]
40
+ def add(uuid)
41
+ validate_uuid!(uuid)
42
+
43
+ @mutex.synchronize do
44
+ # Don't add if already exists
45
+ return if @lookup_set.include?(uuid)
46
+
47
+ # Remove old entry if slot is occupied
48
+ old_uuid = @circular_array[@index]
49
+ if old_uuid
50
+ @lookup_set.delete(old_uuid)
51
+ logger.debug { "[SmartMessage::DDQ::Memory] Evicted UUID: #{old_uuid}" }
52
+ end
53
+
54
+ # Add new entry
55
+ @circular_array[@index] = uuid
56
+ @lookup_set.add(uuid)
57
+ @index = (@index + 1) % @size
58
+ @count = [@count + 1, @size].min
59
+
60
+ logger.debug { "[SmartMessage::DDQ::Memory] Added UUID: #{uuid}, count: #{@count}" }
61
+ end
62
+ end
63
+
64
+ # Get current queue statistics
65
+ # @return [Hash] Statistics about the queue
66
+ def stats
67
+ @mutex.synchronize do
68
+ super.merge(
69
+ current_count: @count,
70
+ utilization: (@count.to_f / @size * 100).round(2),
71
+ next_index: @index
72
+ )
73
+ end
74
+ end
75
+
76
+ # Clear all entries from the queue
77
+ # @return [void]
78
+ def clear
79
+ @mutex.synchronize do
80
+ @circular_array = Array.new(@size)
81
+ @lookup_set.clear
82
+ @index = 0
83
+ @count = 0
84
+
85
+ logger.debug { "[SmartMessage::DDQ::Memory] Cleared all entries" }
86
+ end
87
+ end
88
+
89
+ # Get the storage type identifier
90
+ # @return [Symbol] Storage type
91
+ def storage_type
92
+ :memory
93
+ end
94
+
95
+ # Get current entries (for debugging/testing)
96
+ # @return [Array<String>] Current UUIDs in insertion order
97
+ def entries
98
+ @mutex.synchronize do
99
+ result = []
100
+ @count.times do |i|
101
+ idx = (@index - @count + i) % @size
102
+ result << @circular_array[idx] if @circular_array[idx]
103
+ end
104
+ result
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,168 @@
1
+ # lib/smart_message/ddq/redis.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'base'
6
+
7
+ module SmartMessage
8
+ module DDQ
9
+ # Redis-based Deduplication Queue implementation
10
+ #
11
+ # Uses Redis SET for O(1) lookup and LIST for circular queue behavior.
12
+ # Supports distributed deduplication across multiple processes/servers.
13
+ class Redis < Base
14
+ attr_reader :redis, :key_prefix, :ttl
15
+
16
+ def initialize(size, options = {})
17
+ super(size)
18
+ @redis = options[:redis] || default_redis_connection
19
+ @key_prefix = options[:key_prefix] || 'smart_message:ddq'
20
+ @ttl = options[:ttl] || 3600 # 1 hour default TTL
21
+
22
+ validate_redis_connection!
23
+
24
+ logger.debug { "[SmartMessage::DDQ::Redis] Initialized with size: #{@size}, TTL: #{@ttl}s" }
25
+ end
26
+
27
+ # Check if a UUID exists in the queue (O(1) operation)
28
+ # @param uuid [String] The UUID to check
29
+ # @return [Boolean] true if UUID exists, false otherwise
30
+ def contains?(uuid)
31
+ validate_uuid!(uuid)
32
+
33
+ result = @redis.sismember(set_key, uuid)
34
+ logger.debug { "[SmartMessage::DDQ::Redis] UUID #{uuid} exists: #{result}" }
35
+ result
36
+ rescue ::Redis::BaseError => e
37
+ logger.error { "[SmartMessage::DDQ::Redis] Error checking UUID #{uuid}: #{e.message}" }
38
+ # Fail open - allow processing if Redis is down
39
+ false
40
+ end
41
+
42
+ # Add a UUID to the queue, removing oldest if full (O(1) amortized)
43
+ # @param uuid [String] The UUID to add
44
+ # @return [void]
45
+ def add(uuid)
46
+ validate_uuid!(uuid)
47
+
48
+ # Check if UUID already exists first (avoid unnecessary work)
49
+ return if @redis.sismember(set_key, uuid)
50
+
51
+ # Use Redis transaction for atomicity
52
+ @redis.multi do |pipeline|
53
+ # Add to set for O(1) lookup
54
+ pipeline.sadd(set_key, uuid)
55
+
56
+ # Add to list for ordering/eviction
57
+ pipeline.lpush(list_key, uuid)
58
+
59
+ # Trim list to maintain size (removes oldest)
60
+ pipeline.ltrim(list_key, 0, @size - 1)
61
+
62
+ # Set TTL on both keys
63
+ pipeline.expire(set_key, @ttl)
64
+ pipeline.expire(list_key, @ttl)
65
+ end
66
+
67
+ # Get and remove evicted items from set (outside transaction)
68
+ list_length = @redis.llen(list_key)
69
+ if list_length > @size
70
+ evicted_uuids = @redis.lrange(list_key, @size, -1)
71
+ evicted_uuids.each do |evicted_uuid|
72
+ @redis.srem(set_key, evicted_uuid)
73
+ end
74
+ end
75
+
76
+ logger.debug { "[SmartMessage::DDQ::Redis] Added UUID: #{uuid}" }
77
+ rescue ::Redis::BaseError => e
78
+ logger.error { "[SmartMessage::DDQ::Redis] Error adding UUID #{uuid}: #{e.message}" }
79
+ # Don't raise - deduplication failure shouldn't break message processing
80
+ end
81
+
82
+ # Get current queue statistics
83
+ # @return [Hash] Statistics about the queue
84
+ def stats
85
+ set_size = @redis.scard(set_key)
86
+ list_size = @redis.llen(list_key)
87
+
88
+ super.merge(
89
+ current_count: set_size,
90
+ list_count: list_size,
91
+ utilization: (set_size.to_f / @size * 100).round(2),
92
+ ttl_remaining: @redis.ttl(set_key),
93
+ redis_info: {
94
+ host: redis_host,
95
+ port: redis_port,
96
+ db: redis_db
97
+ }
98
+ )
99
+ rescue ::Redis::BaseError => e
100
+ logger.error { "[SmartMessage::DDQ::Redis] Error getting stats: #{e.message}" }
101
+ super.merge(error: e.message)
102
+ end
103
+
104
+ # Clear all entries from the queue
105
+ # @return [void]
106
+ def clear
107
+ @redis.multi do |pipeline|
108
+ pipeline.del(set_key)
109
+ pipeline.del(list_key)
110
+ end
111
+
112
+ logger.debug { "[SmartMessage::DDQ::Redis] Cleared all entries" }
113
+ rescue ::Redis::BaseError => e
114
+ logger.error { "[SmartMessage::DDQ::Redis] Error clearing queue: #{e.message}" }
115
+ end
116
+
117
+ # Get the storage type identifier
118
+ # @return [Symbol] Storage type
119
+ def storage_type
120
+ :redis
121
+ end
122
+
123
+ # Get current entries (for debugging/testing)
124
+ # @return [Array<String>] Current UUIDs in insertion order (newest first)
125
+ def entries
126
+ @redis.lrange(list_key, 0, -1)
127
+ rescue ::Redis::BaseError => e
128
+ logger.error { "[SmartMessage::DDQ::Redis] Error getting entries: #{e.message}" }
129
+ []
130
+ end
131
+
132
+ private
133
+
134
+ def set_key
135
+ "#{@key_prefix}:set"
136
+ end
137
+
138
+ def list_key
139
+ "#{@key_prefix}:list"
140
+ end
141
+
142
+ def default_redis_connection
143
+ require 'redis'
144
+ ::Redis.new
145
+ rescue LoadError
146
+ raise LoadError, "Redis gem not available. Install with: gem install redis"
147
+ end
148
+
149
+ def validate_redis_connection!
150
+ @redis.ping
151
+ rescue ::Redis::BaseError => e
152
+ raise ConnectionError, "Failed to connect to Redis: #{e.message}"
153
+ end
154
+
155
+ def redis_host
156
+ @redis.connection[:host] rescue 'unknown'
157
+ end
158
+
159
+ def redis_port
160
+ @redis.connection[:port] rescue 'unknown'
161
+ end
162
+
163
+ def redis_db
164
+ @redis.connection[:db] rescue 'unknown'
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,31 @@
1
+ # lib/smart_message/ddq.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'ddq/base'
6
+ require_relative 'ddq/memory'
7
+ require_relative 'ddq/redis'
8
+
9
+ module SmartMessage
10
+ # Deduplication Queue (DDQ) for preventing duplicate message processing
11
+ #
12
+ # Provides a circular queue with O(1) lookup performance for detecting
13
+ # duplicate messages based on UUID. Supports both memory and Redis storage.
14
+ module DDQ
15
+ # Default configuration
16
+ DEFAULT_SIZE = 100
17
+ DEFAULT_STORAGE = :memory
18
+
19
+ # Create a DDQ instance based on storage type
20
+ def self.create(storage_type, size = DEFAULT_SIZE, options = {})
21
+ case storage_type.to_sym
22
+ when :memory
23
+ Memory.new(size)
24
+ when :redis
25
+ Redis.new(size, options)
26
+ else
27
+ raise ArgumentError, "Unknown DDQ storage type: #{storage_type}. Supported types: :memory, :redis"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,174 @@
1
+ # lib/smart_message/deduplication.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'ddq'
6
+
7
+ module SmartMessage
8
+ # Deduplication functionality for message classes
9
+ #
10
+ # Provides class-level configuration and instance-level deduplication
11
+ # checking using a Deduplication Queue (DDQ).
12
+ module Deduplication
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ base.instance_variable_set(:@ddq_size, DDQ::DEFAULT_SIZE)
16
+ base.instance_variable_set(:@ddq_storage, DDQ::DEFAULT_STORAGE)
17
+ base.instance_variable_set(:@ddq_options, {})
18
+ base.instance_variable_set(:@ddq_enabled, false)
19
+ base.instance_variable_set(:@ddq_instance, nil)
20
+ end
21
+
22
+ module ClassMethods
23
+ # Configure DDQ size for this message class
24
+ # @param size [Integer] Maximum number of UUIDs to track
25
+ def ddq_size(size)
26
+ unless size.is_a?(Integer) && size > 0
27
+ raise ArgumentError, "DDQ size must be a positive integer, got: #{size.inspect}"
28
+ end
29
+ @ddq_size = size
30
+ reset_ddq_instance! if ddq_enabled?
31
+ end
32
+
33
+ # Configure DDQ storage type for this message class
34
+ # @param storage [Symbol] Storage type (:memory or :redis)
35
+ # @param options [Hash] Additional options for the storage backend
36
+ def ddq_storage(storage, **options)
37
+ unless [:memory, :redis].include?(storage.to_sym)
38
+ raise ArgumentError, "DDQ storage must be :memory or :redis, got: #{storage.inspect}"
39
+ end
40
+ @ddq_storage = storage.to_sym
41
+ @ddq_options = options
42
+ reset_ddq_instance! if ddq_enabled?
43
+ end
44
+
45
+ # Enable deduplication for this message class
46
+ def enable_deduplication!
47
+ @ddq_enabled = true
48
+ get_ddq_instance # Initialize the DDQ
49
+ end
50
+
51
+ # Disable deduplication for this message class
52
+ def disable_deduplication!
53
+ @ddq_enabled = false
54
+ @ddq_instance = nil
55
+ end
56
+
57
+ # Check if deduplication is enabled
58
+ # @return [Boolean] true if DDQ is enabled
59
+ def ddq_enabled?
60
+ !!@ddq_enabled
61
+ end
62
+
63
+ # Get the current DDQ configuration
64
+ # @return [Hash] Current DDQ configuration
65
+ def ddq_config
66
+ {
67
+ enabled: ddq_enabled?,
68
+ size: @ddq_size,
69
+ storage: @ddq_storage,
70
+ options: @ddq_options
71
+ }
72
+ end
73
+
74
+ # Get DDQ statistics
75
+ # @return [Hash] DDQ statistics
76
+ def ddq_stats
77
+ return { enabled: false } unless ddq_enabled?
78
+
79
+ ddq = get_ddq_instance
80
+ if ddq
81
+ ddq.stats.merge(enabled: true)
82
+ else
83
+ { enabled: true, error: "DDQ instance not available" }
84
+ end
85
+ end
86
+
87
+ # Clear the DDQ
88
+ def clear_ddq!
89
+ return unless ddq_enabled?
90
+
91
+ ddq = get_ddq_instance
92
+ ddq&.clear
93
+ end
94
+
95
+ # Check if a UUID is a duplicate (for external use)
96
+ # @param uuid [String] The UUID to check
97
+ # @return [Boolean] true if UUID is a duplicate
98
+ def duplicate_uuid?(uuid)
99
+ return false unless ddq_enabled?
100
+
101
+ ddq = get_ddq_instance
102
+ ddq ? ddq.contains?(uuid) : false
103
+ end
104
+
105
+ # Get the DDQ instance (exposed for testing)
106
+ def get_ddq_instance
107
+ return nil unless ddq_enabled?
108
+
109
+ # Return cached instance if available and configuration hasn't changed
110
+ if @ddq_instance
111
+ return @ddq_instance
112
+ end
113
+
114
+ # Create new DDQ instance
115
+ size = @ddq_size
116
+ storage = @ddq_storage
117
+ options = @ddq_options
118
+
119
+ ddq = DDQ.create(storage, size, options)
120
+ @ddq_instance = ddq
121
+
122
+ SmartMessage::Logger.default.debug do
123
+ "[SmartMessage::Deduplication] Created DDQ for #{self.name}: " \
124
+ "storage=#{storage}, size=#{size}, options=#{options}"
125
+ end
126
+
127
+ ddq
128
+ rescue => e
129
+ SmartMessage::Logger.default.error do
130
+ "[SmartMessage::Deduplication] Failed to create DDQ for #{self.name}: #{e.message}"
131
+ end
132
+ nil
133
+ end
134
+
135
+ private
136
+
137
+ def reset_ddq_instance!
138
+ @ddq_instance = nil
139
+ end
140
+ end
141
+
142
+ # Instance methods for deduplication checking
143
+
144
+ # Check if this message is a duplicate based on its UUID
145
+ # @return [Boolean] true if this message UUID has been seen before
146
+ def duplicate?
147
+ return false unless self.class.ddq_enabled?
148
+ return false unless uuid
149
+
150
+ self.class.duplicate_uuid?(uuid)
151
+ end
152
+
153
+ # Mark this message as processed (add UUID to DDQ)
154
+ # @return [void]
155
+ def mark_as_processed!
156
+ return unless self.class.ddq_enabled?
157
+ return unless uuid
158
+
159
+ ddq = self.class.send(:get_ddq_instance)
160
+ if ddq
161
+ ddq.add(uuid)
162
+ SmartMessage::Logger.default.debug do
163
+ "[SmartMessage::Deduplication] Marked UUID as processed: #{uuid}"
164
+ end
165
+ end
166
+ end
167
+
168
+ # Get the message UUID
169
+ # @return [String, nil] The message UUID
170
+ def uuid
171
+ _sm_header&.uuid
172
+ end
173
+ end
174
+ end