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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ module Strategies
7
+ ##
8
+ # Retry strategy that retries a fixed number of times
9
+ #
10
+ # When a message fails to process, it is moved to the scheduled queue, and retried after a fixed delay (configured by {Falqon::Queue#retry_delay}).
11
+ # If a messages fails to process after the maximum number of retries (configured by {Falqon::Queue#max_retries}), it is marked as dead, and moved to the dead subqueue.
12
+ #
13
+ # When using the linear strategy and the retry delay is set to a non-zero value, a scheduled needs to be started to retry the messages after the configured delay.
14
+ #
15
+ # queue = Falqon::Queue.new("my_queue")
16
+ #
17
+ # # Start the watcher in a separate thread
18
+ # Thread.new { loop { queue.schedule; sleep 1 } }
19
+ #
20
+ # # Or start the watcher in a separate fiber
21
+ # Fiber
22
+ # .new { loop { queue.schedule; sleep 1 } }
23
+ # .resume
24
+ #
25
+ # @example
26
+ # queue = Falqon::Queue.new("my_queue", retry_strategy: :linear, retry_delay: 60, max_retries: 3)
27
+ # queue.push("Hello, World!")
28
+ # queue.pop { raise Falqon::Error }
29
+ # queue.inspect # => #<Falqon::Queue name="my_queue" pending=0 processing=0 scheduled=1 dead=0>
30
+ # sleep 60
31
+ # queue.pop # => "Hello, World!"
32
+ #
33
+ class Linear < Strategy
34
+ # @!visibility private
35
+ sig { params(message: Message, error: Error).void }
36
+ def retry(message, error)
37
+ queue.redis.with do |r|
38
+ # Increment retry count
39
+ retries = r.hincrby("#{queue.id}:metadata:#{message.id}", :retries, 1)
40
+
41
+ r.multi do |t|
42
+ # Set error metadata
43
+ t.hset(
44
+ "#{queue.id}:metadata:#{message.id}",
45
+ :retried_at, Time.now.to_i,
46
+ :retry_error, error.message,
47
+ )
48
+
49
+ if retries < queue.max_retries || queue.max_retries == -1
50
+ if queue.retry_delay.positive?
51
+ queue.logger.debug "Scheduling message #{message.id} on queue #{queue.name} in #{queue.retry_delay} seconds (attempt #{retries})"
52
+
53
+ # Add identifier to scheduled queue
54
+ queue.scheduled.add(message.id, Time.now.to_i + queue.retry_delay)
55
+
56
+ # Set message status
57
+ t.hset("#{queue.id}:metadata:#{message.id}", :status, "scheduled")
58
+ else
59
+ queue.logger.debug "Requeuing message #{message.id} on queue #{queue.name} (attempt #{retries})"
60
+
61
+ # Add identifier back to pending queue
62
+ queue.pending.add(message.id)
63
+
64
+ # Set message status
65
+ t.hset("#{queue.id}:metadata:#{message.id}", :status, "pending")
66
+ end
67
+ else
68
+ # Kill message after max retries
69
+ message.kill
70
+
71
+ # Set message status
72
+ t.hset("#{queue.id}:metadata:#{message.id}", :status, "dead")
73
+ end
74
+
75
+ # Remove identifier from processing queue
76
+ queue.processing.remove(message.id)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ module Strategies
7
+ ##
8
+ # Retry strategy that does not retry
9
+ #
10
+ # When a message fails to process, it is immediately marked as dead and moved to the dead subqueue.
11
+ #
12
+ # @example
13
+ # queue = Falqon::Queue.new("my_queue", retry_strategy: :none)
14
+ # queue.push("Hello, World!")
15
+ # queue.pop { raise Falqon::Error }
16
+ # queue.inspect # => #<Falqon::Queue name="my_queue" pending=0 processing=0 scheduled=0 dead=1>
17
+ #
18
+ class None < Strategy
19
+ # @!visibility private
20
+ sig { params(message: Message, error: Error).void }
21
+ def retry(message, error)
22
+ queue.redis.with do |r|
23
+ r.multi do |t|
24
+ # Set error metadata
25
+ t.hset(
26
+ "#{queue.id}:metadata:#{message.id}",
27
+ :retried_at, Time.now.to_i,
28
+ :retry_error, error.message,
29
+ )
30
+
31
+ # Kill message immediately
32
+ message.kill
33
+
34
+ # Remove identifier from processing queue
35
+ queue.processing.remove(message.id)
36
+
37
+ # Set message status
38
+ t.hset("#{queue.id}:metadata:#{message.id}", :status, "dead")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ ##
7
+ # Base class for retry strategies
8
+ # @!visibility private
9
+ #
10
+ class Strategy
11
+ extend T::Sig
12
+
13
+ sig { returns(Queue) }
14
+ attr_reader :queue
15
+
16
+ sig { params(queue: Queue).void }
17
+ def initialize(queue)
18
+ @queue = queue
19
+ end
20
+
21
+ sig { params(message: Message, error: Error).void }
22
+ def retry(message, error)
23
+ raise NotImplementedError
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ ##
7
+ # Simple queue abstraction on top of Redis
8
+ # @!visibility private
9
+ #
10
+ class SubQueue
11
+ extend T::Sig
12
+
13
+ sig { returns(String) }
14
+ attr_reader :type
15
+
16
+ sig { returns(String) }
17
+ attr_reader :id
18
+
19
+ sig { returns(Queue) }
20
+ attr_reader :queue
21
+
22
+ sig { params(queue: Queue, type: T.nilable(String)).void }
23
+ def initialize(queue, type = nil)
24
+ @type = type || "pending"
25
+ @id = [queue.id, type].compact.join(":")
26
+ @queue = queue
27
+ end
28
+
29
+ sig { params(message_id: Identifier, head: T.nilable(T::Boolean)).void }
30
+ def add(message_id, head: false)
31
+ queue.redis.with do |r|
32
+ if head
33
+ r.lpush(id, message_id)
34
+ else
35
+ r.rpush(id, message_id)
36
+ end
37
+ end
38
+ end
39
+
40
+ sig { params(message_id: Identifier).void }
41
+ def remove(message_id)
42
+ queue.redis.with do |r|
43
+ r.lrem(id, 0, message_id)
44
+ end
45
+ end
46
+
47
+ sig { params(index: Integer).returns(T.nilable(Identifier)) }
48
+ def peek(index: 0)
49
+ queue.redis.with do |r|
50
+ r.lindex(id, index)&.to_i
51
+ end
52
+ end
53
+
54
+ sig { params(start: Integer, stop: Integer).returns(T::Array[Identifier]) }
55
+ def range(start: 0, stop: -1)
56
+ queue.redis.with do |r|
57
+ r.lrange(id, start, stop).map(&:to_i)
58
+ end
59
+ end
60
+
61
+ sig { returns(T::Array[Identifier]) }
62
+ def clear
63
+ queue.redis.with do |r|
64
+ # Get all identifiers from queue
65
+ message_ids = r.lrange(id, 0, -1)
66
+
67
+ # Delete all data and clear queue
68
+ # TODO: clear data in batches
69
+ r.del(*message_ids.flat_map { |message_id| ["#{queue.id}:data:#{message_id}", "#{queue.id}:metadata:#{message_id}"] }, id, "#{queue.id}:id")
70
+
71
+ # Return identifiers
72
+ message_ids.map(&:to_i)
73
+ end
74
+ end
75
+
76
+ sig { returns(Integer) }
77
+ def size
78
+ queue.redis.with { |r| r.llen(id) }
79
+ end
80
+
81
+ sig { returns(T::Boolean) }
82
+ def empty?
83
+ size.zero?
84
+ end
85
+
86
+ sig { returns(T::Array[Identifier]) }
87
+ def to_a
88
+ queue.redis.with { |r| r.lrange(id, 0, -1).map(&:to_i) }
89
+ end
90
+
91
+ sig { returns(String) }
92
+ def inspect
93
+ "#<#{self.class} name=#{type} size=#{size}>"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ module Falqon
6
+ ##
7
+ # Simple sorted set abstraction on top of Redis
8
+ # @!visibility private
9
+ #
10
+ class SubSet
11
+ extend T::Sig
12
+
13
+ sig { returns(String) }
14
+ attr_reader :type
15
+
16
+ sig { returns(String) }
17
+ attr_reader :id
18
+
19
+ sig { returns(Queue) }
20
+ attr_reader :queue
21
+
22
+ sig { params(queue: Queue, type: T.nilable(String)).void }
23
+ def initialize(queue, type = nil)
24
+ @type = type || "pending"
25
+ @id = [queue.id, type].compact.join(":")
26
+ @queue = queue
27
+ end
28
+
29
+ sig { params(message_id: Identifier, score: Integer).void }
30
+ def add(message_id, score)
31
+ queue.redis.with do |r|
32
+ r.zadd(id, score, message_id)
33
+ end
34
+ end
35
+
36
+ sig { params(message_id: Identifier).void }
37
+ def remove(message_id)
38
+ queue.redis.with do |r|
39
+ r.zrem(id, message_id)
40
+ end
41
+ end
42
+
43
+ sig { params(index: Integer).returns(T.nilable(Identifier)) }
44
+ def peek(index: 0)
45
+ queue.redis.with do |r|
46
+ r.zrange(id, index, index).first&.to_i
47
+ end
48
+ end
49
+
50
+ sig { params(start: Integer, stop: Integer).returns(T::Array[Identifier]) }
51
+ def range(start: 0, stop: -1)
52
+ queue.redis.with do |r|
53
+ r.zrange(id, start, stop).map(&:to_i)
54
+ end
55
+ end
56
+
57
+ sig { returns(T::Array[Identifier]) }
58
+ def clear
59
+ queue.redis.with do |r|
60
+ # Get all identifiers from queue
61
+ # TODO: work in batches
62
+ message_ids = r.zrange(id, 0, -1)
63
+
64
+ # Delete all data and clear queue
65
+ r.del(*message_ids.flat_map { |message_id| ["#{queue.id}:data:#{message_id}", "#{queue.id}:metadata:#{message_id}"] }, id, "#{queue.id}:id")
66
+
67
+ # Return identifiers
68
+ message_ids.map(&:to_i)
69
+ end
70
+ end
71
+
72
+ sig { returns(Integer) }
73
+ def size
74
+ queue.redis.with { |r| r.zcount(id, "-inf", "+inf") }
75
+ end
76
+
77
+ sig { returns(T::Boolean) }
78
+ def empty?
79
+ size.zero?
80
+ end
81
+
82
+ sig { returns(T::Array[Identifier]) }
83
+ def to_a
84
+ queue.redis.with { |r| r.zrange(id, 0, -1).map(&:to_i) }
85
+ end
86
+
87
+ sig { returns(String) }
88
+ def inspect
89
+ "#<#{self.class} name=#{type} size=#{size}>"
90
+ end
91
+ end
92
+ end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Falqon
4
+ # @!visibility private
4
5
  module Version
5
- MAJOR = 0
6
+ MAJOR = 1
6
7
  MINOR = 0
7
- PATCH = 1
8
+ PATCH = 0
8
9
  PRE = nil
9
10
 
10
11
  VERSION = [MAJOR, MINOR, PATCH].compact.join(".")
@@ -12,5 +13,9 @@ module Falqon
12
13
  STRING = [VERSION, PRE].compact.join("-")
13
14
  end
14
15
 
16
+ # @!visibility private
15
17
  VERSION = Version::STRING
18
+
19
+ # @!visibility private
20
+ PROTOCOL = 1
16
21
  end
data/lib/falqon.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: true
4
+
5
+ require "forwardable"
6
+
7
+ require "sorbet-runtime"
8
+ require "zeitwerk"
9
+
10
+ module Falqon
11
+ class << self
12
+ extend Forwardable
13
+ extend T::Sig
14
+
15
+ # Code loader instance
16
+ # @!visibility private
17
+ attr_reader :loader
18
+
19
+ # Global configuration
20
+ #
21
+ # @see Falqon::Configuration
22
+ sig { returns(Configuration) }
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ # @!visibility private
28
+ def root
29
+ @root ||= Pathname.new(File.expand_path(File.join("..", ".."), __FILE__))
30
+ end
31
+
32
+ # @!visibility private
33
+ def setup
34
+ @loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
35
+
36
+ # Register inflections
37
+ require root.join("config/inflections.rb")
38
+
39
+ # Collapse concerns directory
40
+ loader.collapse(root.join("lib/falqon/concerns"))
41
+
42
+ # Configure Rails generators (if applicable)
43
+ if const_defined?(:Rails)
44
+ loader.collapse(root.join("lib/generators"))
45
+ else
46
+ loader.ignore(root.join("lib/generators"))
47
+ end
48
+
49
+ loader.setup
50
+ loader.eager_load
51
+ end
52
+
53
+ # @!visibility private
54
+ def configure
55
+ yield configuration
56
+ end
57
+
58
+ def_delegator :configuration, :redis
59
+ def_delegator :configuration, :logger
60
+ end
61
+ end
62
+
63
+ Falqon.setup
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Falqon
6
+ # @!visibility private
7
+ class Install < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ def create_initializer_file
11
+ create_file "config/initializers/falqon.rb", <<~RUBY
12
+ Falqon.configure do |config|
13
+ # Configure global queue name prefix
14
+ # config.prefix = ENV.fetch("FALQON_PREFIX", "falqon")
15
+
16
+ # Retry strategy (none or linear)
17
+ # config.retry_strategy = :linear
18
+
19
+ # Maximum number of retries before a message is discarded (-1 for infinite retries)
20
+ # config.max_retries = 3
21
+
22
+ # Retry delay (in seconds) for linear retry strategy (defaults to 0)
23
+ # config.retry_delay = 60
24
+
25
+ # Configure the Redis client options
26
+ # config.redis_options = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
27
+
28
+ # Or, configure the Redis client directly
29
+ # config.redis = ConnectionPool.new(size: 5, timeout: 5) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) }
30
+
31
+ # Configure logger
32
+ # config.logger = Logger.new(STDOUT)
33
+ end
34
+ RUBY
35
+ end
36
+ end
37
+ end
metadata CHANGED
@@ -1,33 +1,90 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: falqon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Dejonckheere
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-06-22 00:00:00.000000000 Z
11
+ date: 2024-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: connection_pool
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: redis
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sorbet-runtime
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.5'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: thor
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.3'
13
69
  - !ruby/object:Gem::Dependency
14
70
  name: zeitwerk
15
71
  requirement: !ruby/object:Gem::Requirement
16
72
  requirements:
17
- - - ">="
73
+ - - "~>"
18
74
  - !ruby/object:Gem::Version
19
- version: '0'
75
+ version: '2.6'
20
76
  type: :runtime
21
77
  prerelease: false
22
78
  version_requirements: !ruby/object:Gem::Requirement
23
79
  requirements:
24
- - - ">="
80
+ - - "~>"
25
81
  - !ruby/object:Gem::Version
26
- version: '0'
27
- description: Simple, efficient messaging queue for Ruby
82
+ version: '2.6'
83
+ description: Simple, efficient, and reliable messaging queue for Ruby
28
84
  email:
29
85
  - florian@floriandejonckheere.be
30
- executables: []
86
+ executables:
87
+ - falqon
31
88
  extensions: []
32
89
  extra_rdoc_files: []
33
90
  files:
@@ -35,15 +92,45 @@ files:
35
92
  - Gemfile
36
93
  - LICENSE.md
37
94
  - README.md
95
+ - bin/falqon
38
96
  - config/inflections.rb
97
+ - lib/falqon.rb
98
+ - lib/falqon/cli.rb
99
+ - lib/falqon/cli/base.rb
100
+ - lib/falqon/cli/clear.rb
101
+ - lib/falqon/cli/delete.rb
102
+ - lib/falqon/cli/kill.rb
103
+ - lib/falqon/cli/list.rb
104
+ - lib/falqon/cli/refill.rb
105
+ - lib/falqon/cli/revive.rb
106
+ - lib/falqon/cli/schedule.rb
107
+ - lib/falqon/cli/show.rb
108
+ - lib/falqon/cli/stats.rb
109
+ - lib/falqon/cli/status.rb
110
+ - lib/falqon/cli/version.rb
111
+ - lib/falqon/concerns/hooks.rb
112
+ - lib/falqon/configuration.rb
113
+ - lib/falqon/connection_pool_snooper.rb
114
+ - lib/falqon/data.rb
115
+ - lib/falqon/error.rb
116
+ - lib/falqon/identifier.rb
117
+ - lib/falqon/message.rb
118
+ - lib/falqon/middlewares/logger.rb
119
+ - lib/falqon/queue.rb
120
+ - lib/falqon/strategies/linear.rb
121
+ - lib/falqon/strategies/none.rb
122
+ - lib/falqon/strategy.rb
123
+ - lib/falqon/sub_queue.rb
124
+ - lib/falqon/sub_set.rb
39
125
  - lib/falqon/version.rb
126
+ - lib/generators/falqon/install.rb
40
127
  homepage: https://github.com/floriandejonckheere/falqon
41
128
  licenses:
42
129
  - LGPL-3.0
43
130
  metadata:
44
131
  source_code_uri: https://github.com/floriandejonckheere/falqon.git
45
132
  rubygems_mfa_required: 'true'
46
- post_install_message:
133
+ post_install_message:
47
134
  rdoc_options: []
48
135
  require_paths:
49
136
  - lib
@@ -51,15 +138,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
51
138
  requirements:
52
139
  - - ">="
53
140
  - !ruby/object:Gem::Version
54
- version: '3.0'
141
+ version: '3.1'
55
142
  required_rubygems_version: !ruby/object:Gem::Requirement
56
143
  requirements:
57
144
  - - ">="
58
145
  - !ruby/object:Gem::Version
59
146
  version: '0'
60
147
  requirements: []
61
- rubygems_version: 3.3.5
62
- signing_key:
148
+ rubygems_version: 3.4.20
149
+ signing_key:
63
150
  specification_version: 4
64
151
  summary: Simple messaging queue
65
152
  test_files: []