falqon 0.0.1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/Gemfile +40 -8
- data/README.md +107 -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 +220 -0
- data/lib/falqon/middlewares/logger.rb +32 -0
- data/lib/falqon/queue.rb +626 -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 +95 -0
- data/lib/falqon/sub_set.rb +91 -0
- data/lib/falqon/version.rb +7 -2
- data/lib/falqon.rb +63 -0
- data/lib/generators/falqon/install.rb +37 -0
- metadata +96 -9
@@ -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,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)
|