falqon 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,220 @@
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
+ # Store data
105
+ r.set("#{queue.id}:data:#{id}", data)
106
+
107
+ # Set metadata
108
+ r.hset("#{queue.id}:metadata:#{id}",
109
+ :created_at, Time.now.to_i,
110
+ :updated_at, Time.now.to_i,)
111
+ end
112
+
113
+ self
114
+ end
115
+
116
+ # Kill the message
117
+ #
118
+ # This method moves the message to the dead queue, and resets the retry count.
119
+ #
120
+ sig { void }
121
+ def kill
122
+ logger.debug "Killing message #{id} on queue #{queue.name}"
123
+
124
+ redis.with do |r|
125
+ # Add identifier to dead queue
126
+ queue.dead.add(id)
127
+
128
+ # Reset retry count and set status to dead
129
+ r.hdel("#{queue.id}:metadata:#{id}", :retries)
130
+ r.hset("#{queue.id}:metadata:#{id}", :status, "dead")
131
+
132
+ # Remove identifier from queues
133
+ queue.pending.remove(id)
134
+ end
135
+ end
136
+
137
+ # Delete the message, removing it from the queue
138
+ #
139
+ # This method deletes the message, metadata, and data from the queue.
140
+ #
141
+ sig { void }
142
+ def delete
143
+ redis.with do |r|
144
+ # Delete message from queue
145
+ queue.pending.remove(id)
146
+ queue.dead.remove(id)
147
+
148
+ # Delete data and metadata
149
+ r.del("#{queue.id}:data:#{id}", "#{queue.id}:metadata:#{id}")
150
+ end
151
+ end
152
+
153
+ # Message length
154
+ #
155
+ # @return The string length of the message (in bytes)
156
+ #
157
+ sig { returns Integer }
158
+ def size
159
+ redis.with do |r|
160
+ r.strlen("#{queue.id}:data:#{id}")
161
+ end
162
+ end
163
+
164
+ # Metadata of the message
165
+ #
166
+ # @return The metadata of the message
167
+ # @see Falqon::Message::Metadata
168
+ #
169
+ sig { returns Metadata }
170
+ def metadata
171
+ queue.redis.with do |r|
172
+ Metadata
173
+ .parse(r.hgetall("#{queue.id}:metadata:#{id}"))
174
+ end
175
+ end
176
+
177
+ sig { returns(String) }
178
+ def inspect
179
+ "#<#{self.class} id=#{id.inspect} size=#{size.inspect}>"
180
+ end
181
+
182
+ def_delegator :queue, :redis
183
+ def_delegator :queue, :logger
184
+
185
+ ##
186
+ # Metadata for an message
187
+ #
188
+ class Metadata < T::Struct
189
+ # Status (unknown, pending, processing, scheduled, dead)
190
+ prop :status, String, default: "unknown"
191
+
192
+ # Number of times the message has been retried
193
+ prop :retries, Integer, default: 0
194
+
195
+ # Timestamp of last retry
196
+ prop :retried_at, T.nilable(Integer)
197
+
198
+ # Last error message
199
+ prop :retry_error, T.nilable(String)
200
+
201
+ # Timestamp of creation
202
+ prop :created_at, Integer
203
+
204
+ # Timestamp of last update
205
+ prop :updated_at, Integer
206
+
207
+ # Parse metadata from Redis hash
208
+ #
209
+ # @!visibility private
210
+ #
211
+ def self.parse(data)
212
+ # Keys that are not numeric
213
+ keys = ["status", "retry_error"]
214
+
215
+ # Transform keys to symbols, and values to integers
216
+ new(data.to_h { |k, v| [k.to_sym, keys.include?(k) ? v : v.to_i] })
217
+ end
218
+ end
219
+ end
220
+ 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)