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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -1
- data/Gemfile +40 -8
- data/README.md +108 -8
- data/bin/falqon +8 -0
- data/config/inflections.rb +3 -1
- data/lib/falqon/cli/base.rb +35 -0
- data/lib/falqon/cli/clear.rb +86 -0
- data/lib/falqon/cli/delete.rb +152 -0
- data/lib/falqon/cli/kill.rb +143 -0
- data/lib/falqon/cli/list.rb +26 -0
- data/lib/falqon/cli/refill.rb +36 -0
- data/lib/falqon/cli/revive.rb +36 -0
- data/lib/falqon/cli/schedule.rb +40 -0
- data/lib/falqon/cli/show.rb +189 -0
- data/lib/falqon/cli/stats.rb +44 -0
- data/lib/falqon/cli/status.rb +47 -0
- data/lib/falqon/cli/version.rb +14 -0
- data/lib/falqon/cli.rb +168 -0
- data/lib/falqon/concerns/hooks.rb +101 -0
- data/lib/falqon/configuration.rb +141 -0
- data/lib/falqon/connection_pool_snooper.rb +15 -0
- data/lib/falqon/data.rb +11 -0
- data/lib/falqon/error.rb +13 -0
- data/lib/falqon/identifier.rb +11 -0
- data/lib/falqon/message.rb +221 -0
- data/lib/falqon/middlewares/logger.rb +32 -0
- data/lib/falqon/queue.rb +640 -0
- data/lib/falqon/strategies/linear.rb +82 -0
- data/lib/falqon/strategies/none.rb +44 -0
- data/lib/falqon/strategy.rb +26 -0
- data/lib/falqon/sub_queue.rb +96 -0
- data/lib/falqon/sub_set.rb +92 -0
- data/lib/falqon/version.rb +7 -2
- data/lib/falqon.rb +63 -0
- data/lib/generators/falqon/install.rb +37 -0
- metadata +100 -13
@@ -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
|
data/lib/falqon/data.rb
ADDED
data/lib/falqon/error.rb
ADDED
@@ -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)
|