falqon 0.0.1 → 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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ # Hooks can be registered on a custom queue to execute code before and after certain events.
7
+ # The following hooks are available:
8
+ # - +after :initialize+: executed after the queue has been initialized
9
+ # - +before :push+: executed before a message is pushed to the queue
10
+ # - +after :push+: executed after a message has been pushed to the queue
11
+ # - +before :pop+: executed before a message is popped from the queue
12
+ # - +after :pop+: executed after a message has been popped from the queue (but before deleting it)
13
+ # - +before :peek+: executed before peeking to a message in the queue
14
+ # - +after :peek+: executed after peeking to a message in the queue
15
+ # - +before :range+: executed before peeking to a message range in the queue
16
+ # - +after :range+: executed after peeking to a message range in the queue
17
+ # - +before :clear+: executed before clearing the queue
18
+ # - +after :clear+: executed after clearing the queue
19
+ # - +before :delete+: executed before deleting the queue
20
+ # - +after :delete+: executed after deleting the queue
21
+ # - +before :refill+: executed before refilling the queue
22
+ # - +after :refill+: executed after refilling the queue
23
+ # - +before :revive+: executed before reviving a message from the dead queue
24
+ # - +after :revive+: executed after reviving a message from the dead queue
25
+ # - +before :schedule+: executed before scheduling messages for retry
26
+ # - +after :schedule+: executed after scheduling messages for retry
27
+ #
28
+ # @example
29
+ # class MyQueue < Falqon::Queue
30
+ # before :push, :my_custom_method
31
+ #
32
+ # after :delete do
33
+ # ...
34
+ # end
35
+ #
36
+ # private
37
+ #
38
+ # def my_custom_method
39
+ # ...
40
+ # end
41
+ # end
42
+ #
43
+ module Hooks
44
+ extend T::Sig
45
+
46
+ # @!visibility private
47
+ sig { params(base: T.class_of(Hooks)).void }
48
+ def self.included(base)
49
+ base.extend(ClassMethods)
50
+ end
51
+
52
+ # @!visibility private
53
+ sig { params(event: Symbol, type: T.nilable(Symbol), block: T.nilable(T.proc.void)).void }
54
+ def run_hook(event, type = nil, &block)
55
+ T.unsafe(self).class.hooks[event][:before].each { |hook| instance_eval(&hook) } if type.nil? || type == :before
56
+
57
+ block&.call
58
+
59
+ T.unsafe(self).class.hooks[event][:after].each { |hook| instance_eval(&hook) } if type.nil? || type == :after
60
+ end
61
+
62
+ module ClassMethods
63
+ include Kernel
64
+ extend T::Sig
65
+
66
+ # @!visibility private
67
+ sig { returns(T::Hash[Symbol, T::Hash[Symbol, T::Array[T.proc.void]]]) }
68
+ def hooks
69
+ @hooks ||= Hash.new { |h, k| h[k] = { before: [], after: [] } }
70
+ end
71
+
72
+ # Add hook to before list (either the given block or a wrapped method call)
73
+ #
74
+ # @param event The event to hook into
75
+ # @param method_sym The method to call
76
+ # @param block The block to execute
77
+ # @return [void]
78
+ #
79
+ sig { params(event: Symbol, method_sym: T.nilable(Symbol), block: T.nilable(T.proc.void)).void }
80
+ def before(event, method_sym = nil, &block)
81
+ block ||= proc { send(method_sym) } if method_sym
82
+
83
+ T.must(T.must(hooks[event])[:before]) << block if block
84
+ end
85
+
86
+ # Add hook to after list (either the given block or a wrapped method call)
87
+ #
88
+ # @param event The event to hook into
89
+ # @param method_sym The method to call
90
+ # @param block The block to execute
91
+ # @return [void]
92
+ #
93
+ sig { params(event: Symbol, method_sym: T.nilable(Symbol), block: T.nilable(T.proc.void)).void }
94
+ def after(event, method_sym = nil, &block)
95
+ block ||= proc { send(method_sym) } if method_sym
96
+
97
+ T.must(T.must(hooks[event])[:after]) << block if block
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ require "logger"
6
+
7
+ require "redis"
8
+ require "connection_pool"
9
+
10
+ module Falqon
11
+ # Falqon configuration
12
+ #
13
+ # Falqon can be configured before use, by leveraging the +Falqon.configure+ method.
14
+ # It's recommended to configure Falqon in an initializer file, such as +config/initializers/falqon.rb+.
15
+ # In a Rails application, the generator can be used to create the initializer file:
16
+ #
17
+ # rails generate falqon:install
18
+ #
19
+ # Otherwise, the file can be created manually:
20
+ #
21
+ # Falqon.configure do |config|
22
+ # # Configure global queue name prefix
23
+ # # config.prefix = ENV.fetch("FALQON_PREFIX", "falqon")
24
+ #
25
+ # # Retry strategy (none or linear)
26
+ # # config.retry_strategy = :linear
27
+ #
28
+ # # Maximum number of retries before a message is discarded (-1 for infinite retries)
29
+ # # config.max_retries = 3
30
+ #
31
+ # # Retry delay (in seconds) for linear retry strategy (defaults to 0)
32
+ # # config.retry_delay = 60
33
+ #
34
+ # # Configure the Redis client options
35
+ # # config.redis_options = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
36
+ #
37
+ # # Or, configure the Redis client directly
38
+ # # config.redis = ConnectionPool.new(size: 5, timeout: 5) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) }
39
+ #
40
+ # # Configure logger
41
+ # # config.logger = Logger.new(STDOUT)
42
+ # end
43
+ #
44
+ # The values above are the default values.
45
+ #
46
+ # In addition, it is recommended to configure Redis to be persistent in production environments, in order not to lose data.
47
+ # Refer to the {https://redis.io/docs/management/persistence Redis documentation} for more information.
48
+ #
49
+ class Configuration
50
+ extend T::Sig
51
+
52
+ # Queue name prefix
53
+ sig { params(prefix: String).returns(String) }
54
+ attr_writer :prefix
55
+
56
+ # Maximum number of retries before a message is discarded
57
+ sig { params(max_retries: Integer).returns(Integer) }
58
+ attr_writer :max_retries
59
+
60
+ # Delay between retries (in seconds)
61
+ sig { params(retry_delay: Integer).returns(Integer) }
62
+ attr_writer :retry_delay
63
+
64
+ # Redis connection pool
65
+ sig { params(redis: ConnectionPool).returns(ConnectionPool) }
66
+ attr_writer :redis
67
+
68
+ # Redis connection options
69
+ sig { params(redis_options: Hash).returns(Hash) }
70
+ attr_writer :redis_options
71
+
72
+ # Logger instance
73
+ sig { params(logger: Logger).returns(Logger) }
74
+ attr_writer :logger
75
+
76
+ # Queue name prefix, defaults to "falqon"
77
+ sig { returns(String) }
78
+ def prefix
79
+ @prefix ||= "falqon"
80
+ end
81
+
82
+ # Failed message retry strategy
83
+ #
84
+ # @see Falqon::Strategies
85
+ sig { returns(Symbol) }
86
+ def retry_strategy
87
+ @retry_strategy ||= :linear
88
+ end
89
+
90
+ # Failed message retry strategy
91
+ #
92
+ # @see Falqon::Strategies
93
+ sig { params(retry_strategy: Symbol).returns(Symbol) }
94
+ def retry_strategy=(retry_strategy)
95
+ raise ArgumentError, "Invalid retry strategy #{retry_strategy.inspect}" unless [:none, :linear].include? retry_strategy
96
+
97
+ @retry_strategy = retry_strategy
98
+ end
99
+
100
+ # Maximum number of retries before a message is discarded
101
+ #
102
+ # Only applicable when using the +:linear+ retry strategy
103
+ #
104
+ # @see Falqon::Strategies::Linear
105
+ sig { returns(Integer) }
106
+ def max_retries
107
+ @max_retries ||= 3
108
+ end
109
+
110
+ # Delay between retries (in seconds)
111
+ #
112
+ # Only applicable when using the +:linear+ retry strategy
113
+ #
114
+ # @see Falqon::Strategies::Linear
115
+ sig { returns(Integer) }
116
+ def retry_delay
117
+ @retry_delay ||= 0
118
+ end
119
+
120
+ # Redis connection pool
121
+ sig { returns(ConnectionPool) }
122
+ def redis
123
+ @redis ||= ConnectionPool.new(size: 5, timeout: 5) { Redis.new(**redis_options) }
124
+ end
125
+
126
+ # Redis connection options passed to +Redis.new+
127
+ sig { returns(Hash) }
128
+ def redis_options
129
+ @redis_options ||= {
130
+ url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"),
131
+ middlewares: [Middlewares::Logger],
132
+ }
133
+ end
134
+
135
+ # Logger instance
136
+ sig { returns(Logger) }
137
+ def logger
138
+ @logger ||= Logger.new(File::NULL)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Falqon
4
+ ##
5
+ # Connection pool that logs method calls
6
+ # @!visibility private
7
+ #
8
+ class ConnectionPoolSnooper < ConnectionPool
9
+ def with(...)
10
+ puts "#{caller(1..1).first[/.*:in/][0..-4]} #{caller(1..1).first[/`.*'/][1..-2]}"
11
+
12
+ super
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ extend T::Sig
7
+
8
+ # Base class for queue data
9
+ #
10
+ Data = T.type_alias { String }
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Falqon
4
+ # Base error class for Falqon
5
+ #
6
+ class Error < StandardError
7
+ end
8
+
9
+ # Error raised when a version mismatch is detected
10
+ #
11
+ class VersionMismatchError < Error
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ extend T::Sig
7
+
8
+ # Base class for queue identifiers
9
+ #
10
+ Identifier = T.type_alias { Integer }
11
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ ##
7
+ # A message in a queue
8
+ #
9
+ # This class should typically not be instantiated directly, but rather be created by a queue instance.
10
+ #
11
+ class Message
12
+ extend Forwardable
13
+ extend T::Sig
14
+
15
+ # The queue instance the message belongs to
16
+ sig { returns(Queue) }
17
+ attr_reader :queue
18
+
19
+ # Create a new message
20
+ #
21
+ # @param queue [Queue] The queue instance the message belongs to
22
+ # @param id [Integer] The message identifier (optional if creating a new message)
23
+ # @param data [String] The message data (optional if fetching an existing message)
24
+ # @return The message instance
25
+ #
26
+ # @example Instantiate an existing message
27
+ # queue = Falqon::Queue.new("my_queue")
28
+ # id = queue.push("Hello, World!")
29
+ # message = Falqon::Message.new(queue, id:)
30
+ # message.data # => "Hello, World!"
31
+ #
32
+ # @example Create a new message
33
+ # queue = Falqon::Queue.new("my_queue")
34
+ # message = Falqon::Message.new(queue, data: "Hello, World!")
35
+ # message.create
36
+ # message.id # => 1
37
+ #
38
+ sig { params(queue: Queue, id: T.nilable(Identifier), data: T.nilable(Data)).void }
39
+ def initialize(queue, id: nil, data: nil)
40
+ @queue = queue
41
+ @id = id
42
+ @data = data
43
+ end
44
+
45
+ # The message identifier
46
+ sig { returns(Identifier) }
47
+ def id
48
+ @id ||= redis.with { |r| r.incr("#{queue.id}:id") }
49
+ end
50
+
51
+ # The message data
52
+ sig { returns(String) }
53
+ def data
54
+ @data ||= redis.with { |r| r.get("#{queue.id}:data:#{id}") }
55
+ end
56
+
57
+ # Whether the message status is unknown
58
+ sig { returns(T::Boolean) }
59
+ def unknown?
60
+ metadata.status == "unknown"
61
+ end
62
+
63
+ # Whether the message status is pending
64
+ sig { returns(T::Boolean) }
65
+ def pending?
66
+ metadata.status == "pending"
67
+ end
68
+
69
+ # Whether the message status is processing
70
+ sig { returns(T::Boolean) }
71
+ def processing?
72
+ metadata.status == "processing"
73
+ end
74
+
75
+ # Whether the message status is scheduled
76
+ sig { returns(T::Boolean) }
77
+ def scheduled?
78
+ metadata.status == "scheduled"
79
+ end
80
+
81
+ # Whether the message status is dead
82
+ sig { returns(T::Boolean) }
83
+ def dead?
84
+ metadata.status == "dead"
85
+ end
86
+
87
+ # Whether the message exists (i.e. has been created)
88
+ sig { returns(T::Boolean) }
89
+ def exists?
90
+ redis.with do |r|
91
+ r.exists("#{queue.id}:data:#{id}") == 1
92
+ end
93
+ end
94
+
95
+ # Create the message in the queue
96
+ #
97
+ # This method will overwrite any existing message with the same identifier.
98
+ #
99
+ # @return The message instance
100
+ #
101
+ sig { returns(Message) }
102
+ def create
103
+ redis.with do |r|
104
+ message_id = id
105
+
106
+ r.multi do |t|
107
+ # Store data
108
+ t.set("#{queue.id}:data:#{message_id}", data)
109
+
110
+ # Set metadata
111
+ t.hset("#{queue.id}:metadata:#{message_id}",
112
+ :created_at, Time.now.to_i,
113
+ :updated_at, Time.now.to_i,)
114
+ end
115
+ end
116
+
117
+ self
118
+ end
119
+
120
+ # Kill the message
121
+ #
122
+ # This method moves the message to the dead queue, and resets the retry count.
123
+ #
124
+ sig { void }
125
+ def kill
126
+ logger.debug "Killing message #{id} on queue #{queue.name}"
127
+
128
+ redis.with do |r|
129
+ # Add identifier to dead queue
130
+ queue.dead.add(id)
131
+
132
+ # Reset retry count and set status to dead
133
+ r.hdel("#{queue.id}:metadata:#{id}", :retries)
134
+ r.hset("#{queue.id}:metadata:#{id}", :status, "dead")
135
+
136
+ # Remove identifier from queues
137
+ queue.pending.remove(id)
138
+ end
139
+ end
140
+
141
+ # Delete the message, removing it from the queue
142
+ #
143
+ # This method deletes the message, metadata, and data from the queue.
144
+ #
145
+ sig { void }
146
+ def delete
147
+ redis.with do |r|
148
+ r.multi do |t|
149
+ # Delete message from queue
150
+ queue.pending.remove(id)
151
+ queue.dead.remove(id)
152
+
153
+ # Delete data and metadata
154
+ t.del("#{queue.id}:data:#{id}", "#{queue.id}:metadata:#{id}")
155
+ end
156
+ end
157
+ end
158
+
159
+ # Message length
160
+ #
161
+ # @return The string length of the message (in bytes)
162
+ #
163
+ sig { returns Integer }
164
+ def size
165
+ redis.with { |r| r.strlen("#{queue.id}:data:#{id}") }
166
+ end
167
+
168
+ # Metadata of the message
169
+ #
170
+ # @return The metadata of the message
171
+ # @see Falqon::Message::Metadata
172
+ #
173
+ sig { returns Metadata }
174
+ def metadata
175
+ queue.redis.with do |r|
176
+ Metadata
177
+ .parse(r.hgetall("#{queue.id}:metadata:#{id}"))
178
+ end
179
+ end
180
+
181
+ sig { returns(String) }
182
+ def inspect
183
+ "#<#{self.class} id=#{id.inspect} size=#{size.inspect}>"
184
+ end
185
+
186
+ def_delegator :queue, :redis
187
+ def_delegator :queue, :logger
188
+
189
+ ##
190
+ # Metadata for a message
191
+ #
192
+ class Metadata < T::Struct
193
+ # Status (unknown, pending, processing, scheduled, dead)
194
+ prop :status, String, default: "unknown"
195
+
196
+ # Number of times the message has been retried
197
+ prop :retries, Integer, default: 0
198
+
199
+ # Timestamp of last retry
200
+ prop :retried_at, T.nilable(Integer)
201
+
202
+ # Last error message
203
+ prop :retry_error, T.nilable(String)
204
+
205
+ # Timestamp of creation
206
+ prop :created_at, Integer
207
+
208
+ # Timestamp of last update
209
+ prop :updated_at, Integer
210
+
211
+ # Parse metadata from Redis hash
212
+ #
213
+ # @!visibility private
214
+ #
215
+ def self.parse(data)
216
+ # Transform keys to symbols, and values to integers
217
+ new(data.to_h { |k, v| [k.to_sym, (send(props.dig(k.to_sym, :type).name.to_sym, v) if v)] })
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Falqon
4
+ # @!visibility private
5
+ module Middlewares
6
+ ##
7
+ # Redis client logger middleware
8
+ # @!visibility private
9
+ #
10
+ module Logger
11
+ def connect(redis_config)
12
+ Falqon.logger.warn { "[redis] #{redis_config.inspect}" }
13
+
14
+ super
15
+ end
16
+
17
+ def call(command, redis_config)
18
+ Falqon.logger.warn { "[redis] #{command.join(' ')}" }
19
+
20
+ super
21
+ end
22
+
23
+ def call_pipelined(commands, redis_config)
24
+ Falqon.logger.warn { "[redis] #{commands.join(' ')}" }
25
+
26
+ super
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ RedisClient.register(Falqon::Middlewares::Logger)