multi-background-job 0.1.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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiBackgroundJob
4
+ module Adapters
5
+ # This is a Sidekiq adapter that converts MultiBackgroundJob::Worker object into a sidekiq readable format
6
+ # and then push the jobs into the service.
7
+ class Sidekiq < Adapter
8
+ attr_reader :worker, :queue
9
+
10
+ def initialize(worker)
11
+ @worker = worker
12
+ @queue = worker.options.fetch(:queue, 'default')
13
+
14
+ @payload = worker.payload.merge(
15
+ 'class' => worker.worker_class,
16
+ 'retry' => worker.options.fetch(:retry, true),
17
+ 'queue' => @queue,
18
+ )
19
+ @payload['created_at'] ||= Time.now.to_f
20
+ end
21
+
22
+ # Coerces the raw payload into an instance of Worker
23
+ # @param payload [Hash] The job as json from redis
24
+ # @options options [Hash] list of options that will be passed along to the Worker instance
25
+ # @return [MultiBackgroundJob::Worker] and instance of MultiBackgroundJob::Worker
26
+ def self.coerce_to_worker(payload, **options)
27
+ raise(Error, 'invalid payload') unless payload.is_a?(Hash)
28
+ raise(Error, 'invalid payload') unless payload['class'].is_a?(String)
29
+
30
+ options[:retry] ||= payload['retry'] if payload.key?('retry')
31
+ options[:queue] ||= payload['queue'] if payload.key?('queue')
32
+
33
+ MultiBackgroundJob[payload['class'], **options].tap do |worker|
34
+ worker.with_args(*Array(payload['args'])) if payload.key?('args')
35
+ worker.with_job_jid(payload['jid']) if payload.key?('jid')
36
+ worker.created_at(payload['created_at']) if payload.key?('created_at')
37
+ worker.enqueued_at(payload['enqueued_at']) if payload.key?('enqueued_at')
38
+ worker.at(payload['at']) if payload.key?('at')
39
+ worker.unique(payload['uniq']) if payload.key?('uniq')
40
+ end
41
+ end
42
+
43
+ # Initializes adapter and push job into the sidekiq service
44
+ #
45
+ # @param worker [MultiBackgroundJob::Worker] An instance of MultiBackgroundJob::Worker
46
+ # @return [Hash] Job payload
47
+ # @see push method for more details
48
+ def self.push(worker)
49
+ new(worker).push
50
+ end
51
+
52
+ # Push sidekiq to the Sidekiq(Redis actually).
53
+ # * If job has the 'at' key. Then schedule it
54
+ # * Otherwise enqueue for immediate execution
55
+ #
56
+ # @return [Hash] Payload that was sent to redis
57
+ def push
58
+ @payload['enqueued_at'] = Time.now.to_f
59
+ # Optimization to enqueue something now that is scheduled to go out now or in the past
60
+ if (timestamp = @payload.delete('at')) && (timestamp > Time.now.to_f)
61
+ MultiBackgroundJob.redis_pool.with do |redis|
62
+ redis.zadd(scheduled_queue_name, timestamp.to_f.to_s, to_json(@payload))
63
+ end
64
+ else
65
+ MultiBackgroundJob.redis_pool.with do |redis|
66
+ redis.lpush(immediate_queue_name, to_json(@payload))
67
+ end
68
+ end
69
+ @payload
70
+ end
71
+
72
+ protected
73
+
74
+ def namespace
75
+ MultiBackgroundJob.config.redis_namespace
76
+ end
77
+
78
+ def scheduled_queue_name
79
+ "#{namespace}:schedule"
80
+ end
81
+
82
+ def immediate_queue_name
83
+ "#{namespace}:queue:#{queue}"
84
+ end
85
+
86
+ def to_json(value)
87
+ MultiJson.dump(value, mode: :compat)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,152 @@
1
+ # frizen_string_literal: true
2
+
3
+ require 'redis'
4
+ require 'connection_pool'
5
+
6
+ require_relative './middleware_chain'
7
+
8
+ module MultiBackgroundJob
9
+ class Config
10
+ class << self
11
+ private
12
+
13
+ def attribute_accessor(field, validator: nil, normalizer: nil, default: nil)
14
+ normalizer ||= :"normalize_#{field}"
15
+ validator ||= :"validate_#{field}"
16
+
17
+ define_method(field) do
18
+ unless instance_variable_defined?(:"@#{field}")
19
+ fallback = config_from_yaml[field.to_s] || default
20
+ return if fallback.nil?
21
+
22
+ send(:"#{field}=", fallback.respond_to?(:call) ? fallback.call : fallback)
23
+ end
24
+ instance_variable_get(:"@#{field}")
25
+ end
26
+
27
+ define_method(:"#{field}=") do |value|
28
+ value = send(normalizer, field, value) if respond_to?(normalizer, true)
29
+ send(validator, field, value) if respond_to?(validator, true)
30
+
31
+ instance_variable_set(:"@#{field}", value)
32
+ end
33
+ end
34
+ end
35
+
36
+ # Path to the YAML file with configs
37
+ attr_accessor :config_path
38
+
39
+ # ConnectionPool options for redis
40
+ attribute_accessor :redis_pool_size, default: 5, normalizer: :normalize_to_int, validator: :validate_greater_than_zero
41
+ attribute_accessor :redis_pool_timeout, default: 5, normalizer: :normalize_to_int, validator: :validate_greater_than_zero
42
+
43
+ # Namespace used to manage internal data like unique job verification data.
44
+ attribute_accessor :redis_namespace, default: 'multi-bg'
45
+
46
+ # List of configurations to be passed along to the Redis.new
47
+ attribute_accessor :redis_config, default: {}
48
+
49
+ # A Hash with all workers definitions. The worker class name must be the main hash key
50
+ # Example:
51
+ # "Accounts::ConfirmationEmailWorker":
52
+ # retry: false
53
+ # queue: "mailer"
54
+ # "Elastic::BatchIndex":
55
+ # retry: 5
56
+ # queue: "elasticsearch"
57
+ # adapter: "faktory"
58
+ attribute_accessor :workers, default: {}
59
+
60
+ # Does not validate if it's when set to false
61
+ attribute_accessor :strict, default: true
62
+ alias strict? strict
63
+
64
+ # Global disable the unique_job_active
65
+ attribute_accessor :unique_job_active, default: false
66
+ alias unique_job_active? unique_job_active
67
+
68
+ def worker_options(class_name)
69
+ class_name = class_name.to_s
70
+ if strict? && !workers.key?(class_name)
71
+ raise NotDefinedWorker.new(class_name)
72
+ end
73
+
74
+ workers.fetch(class_name, {})
75
+ end
76
+
77
+ def redis_pool
78
+ {
79
+ size: redis_pool_size,
80
+ timeout: redis_pool_timeout,
81
+ }
82
+ end
83
+
84
+ def middleware
85
+ @middleware ||= MiddlewareChain.new
86
+ yield @middleware if block_given?
87
+ @middleware
88
+ end
89
+
90
+ def config_path=(value)
91
+ @config_from_yaml = nil
92
+ @config_path = value
93
+ end
94
+
95
+ private
96
+
97
+ def normalize_to_int(_attribute, value)
98
+ value.to_i
99
+ end
100
+
101
+ def validate_greater_than_zero(attribute, value)
102
+ return if value > 0
103
+
104
+ raise InvalidConfig, format(
105
+ 'The %<value>p for %<attr>s is not valid. It must be greater than zero',
106
+ value: value,
107
+ attr: attribute,
108
+ )
109
+ end
110
+
111
+ def normalize_redis_config(_attribute, value)
112
+ case value
113
+ when String
114
+ { url: value }
115
+ when Hash
116
+ value.each_with_object({}) { |(k, v), r| r[k.to_sym] = v }
117
+ else
118
+ value
119
+ end
120
+ end
121
+
122
+ def validate_redis_config(attribute, value)
123
+ return if value.is_a?(Hash)
124
+
125
+ raise InvalidConfig, format(
126
+ 'The %<value>p for %<attr>i is not valid. It must be a Hash with the redis initialization options. ' +
127
+ 'See https://github.com/redis/redis-rb for all available options',
128
+ value: value,
129
+ attr: attribute,
130
+ )
131
+ end
132
+
133
+ def normalize_workers(_, value)
134
+ return unless value.is_a?(Hash)
135
+
136
+ hash = {}
137
+ value.each do |class_name, opts|
138
+ hash[class_name.to_s] = MultiJson.load(MultiJson.dump(opts), symbolize_names: true)
139
+ end
140
+ hash
141
+ end
142
+
143
+ def config_from_yaml
144
+ @config_from_yaml ||= begin
145
+ config_path ? YAML.load_file(config_path) : {}
146
+ rescue Errno::ENOENT, Errno::ESRCH
147
+ {}
148
+ end
149
+ end
150
+ end
151
+ end
152
+
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiBackgroundJob
4
+ class Error < StandardError
5
+ end
6
+
7
+ class InvalidConfig < Error
8
+ end
9
+
10
+ class NotDefinedWorker < Error
11
+ def initialize(worker_class)
12
+ @worker_class = worker_class
13
+ end
14
+
15
+ def message
16
+ format(
17
+ "The %<worker>p is not defined and the MultiBackgroundJob is configured to work on strict mode.\n" +
18
+ "it's highly recommended to include this worker class to the list of known workers.\n" +
19
+ "Example: `MultiBackgroundJob.configure { |config| config.workers = { %<worker>p => {} } }`\n" +
20
+ 'Another option is to set config.strict = false',
21
+ worker: @worker_class,
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiBackgroundJob
4
+ # Class Lock provides access to redis "sorted set" used to control unique jobs
5
+ class Lock
6
+ attr_reader :digest, :lock_id, :ttl
7
+
8
+ # @param :digest [String] It's the uniq string used to group similar jobs
9
+ # @param :lock_id [String] The uniq job id
10
+ # @param :ttl [Float] The timestamp related lifietime of the lock before being discarded.
11
+ def initialize(digest:, lock_id:, ttl:)
12
+ @digest = digest
13
+ @lock_id = lock_id
14
+ @ttl = ttl
15
+ end
16
+
17
+ # Initialize a Lock object from hash
18
+ #
19
+ # @param value [Hash] Hash with lock properties
20
+ # @return [MultiBackgroundJob::Lock, nil]
21
+ def self.coerce(value)
22
+ return unless value.is_a?(Hash)
23
+
24
+ digest = value[:digest] || value['digest']
25
+ lock_id = value[:lock_id] || value['lock_id']
26
+ ttl = value[:ttl] || value['ttl']
27
+ return if [digest, lock_id, ttl].any?(&:nil?)
28
+
29
+ new(digest: digest, lock_id: lock_id, ttl: ttl)
30
+ end
31
+
32
+ # Remove expired locks from redis "sorted set"
33
+ #
34
+ # @param [String] digest It's the uniq string used to group similar jobs
35
+ def self.flush_expired_members(digest, redis: nil)
36
+ return unless digest
37
+
38
+ caller = ->(redis) { redis.zremrangebyscore(digest, '-inf', "(#{now}") }
39
+
40
+ if redis
41
+ caller.(redis)
42
+ else
43
+ MultiBackgroundJob.redis_pool.with { |conn| caller.(conn) }
44
+ end
45
+ end
46
+
47
+ # Remove all locks from redis "sorted set"
48
+ #
49
+ # @param [String] digest It's the uniq string used to group similar jobs
50
+ def self.flush(digest, redis: nil)
51
+ return unless digest
52
+
53
+ caller = ->(conn) { conn.del(digest) }
54
+
55
+ if redis
56
+ caller.(redis)
57
+ else
58
+ MultiBackgroundJob.redis_pool.with { |conn| caller.(conn) }
59
+ end
60
+ end
61
+
62
+ # Number of locks
63
+ #
64
+ # @param digest [String] It's the uniq string used to group similar jobs
65
+ # @option [Number] from The begin of set. Default to 0
66
+ # @option [Number] to The end of set. Default to the timestamp of 1 week from now
67
+ # @return Number the amount of entries that within digest
68
+ def self.count(digest, from: 0, to: nil, redis: nil)
69
+ to ||= Time.now.to_f + MultiBackgroundJob::UniqueJob::VALID_OPTIONS[:timeout]
70
+ caller = ->(conn) { conn.zcount(digest, from, to) }
71
+
72
+ if redis
73
+ caller.(redis)
74
+ else
75
+ MultiBackgroundJob.redis_pool.with { |conn| caller.(conn) }
76
+ end
77
+ end
78
+
79
+ def to_hash
80
+ {
81
+ 'ttl' => ttl,
82
+ 'digest' => (digest.to_s if digest),
83
+ 'lock_id' => (lock_id.to_s if lock_id),
84
+ }
85
+ end
86
+
87
+ # @return [Float] A float timestamp of current time
88
+ def self.now
89
+ Time.now.to_f
90
+ end
91
+
92
+ # Remove lock_id lock from redis
93
+ # @return [Boolean] Returns true when it's locked or false when there is no lock
94
+ def unlock
95
+ MultiBackgroundJob.redis_pool.with do |conn|
96
+ conn.zrem(digest, lock_id)
97
+ end
98
+ end
99
+
100
+ # Adds lock_id lock to redis
101
+ # @return [Boolean] Returns true when it's a fresh lock or false when lock already exists
102
+ def lock
103
+ MultiBackgroundJob.redis_pool.with do |conn|
104
+ conn.zadd(digest, ttl, lock_id)
105
+ end
106
+ end
107
+
108
+ # Check if the lock_id lock exist
109
+ # @return [Boolean] true or false when lock exist or not
110
+ def locked?
111
+ locked = false
112
+
113
+ MultiBackgroundJob.redis_pool.with do |conn|
114
+ timestamp = conn.zscore(digest, lock_id)
115
+ return false unless timestamp
116
+
117
+ locked = timestamp >= now
118
+ self.class.flush_expired_members(digest, redis: conn)
119
+ end
120
+
121
+ locked
122
+ end
123
+
124
+ def eql?(other)
125
+ return false unless other.is_a?(self.class)
126
+
127
+ [digest, lock_id, ttl] == [other.digest, other.lock_id, other.ttl]
128
+ end
129
+ alias == eql?
130
+
131
+ protected
132
+
133
+ def now
134
+ self.class.now
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiBackgroundJob
4
+ # Class Lock generates the uniq digest acording to the uniq config
5
+ class LockDigest
6
+ BASE = 'uniqueness'.freeze
7
+ SEPARATOR = ':'.freeze
8
+
9
+ def initialize(*keys, across:)
10
+ @keys = keys.map { |k| k.to_s.strip.downcase }
11
+ @across = across.to_sym
12
+ end
13
+
14
+ def to_s
15
+ case @across
16
+ when :systemwide
17
+ build_name(*@keys.slice(0..-2))
18
+ when :queue
19
+ build_name(*@keys)
20
+ else
21
+ raise Error, format(
22
+ 'Could not resolve the lock digest using across %<across>p. ' +
23
+ 'Valid options are :systemwide and :queue',
24
+ across: @across,
25
+ )
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def build_name(*segments)
32
+ [namespace, BASE, *segments].compact.join(SEPARATOR)
33
+ end
34
+
35
+ def namespace
36
+ MultiBackgroundJob.config.redis_namespace
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_background_job/lock'
4
+ require 'multi_background_job/lock_digest'
5
+
6
+ module MultiBackgroundJob
7
+ module Middleware
8
+ # This middleware uses an external redis queue to control duplications. The locking key
9
+ # is composed of worker class and its arguments. Before enqueue new jobs it will check if have a "lock" active.
10
+ # The TTL of lock is 1 week as default. TTL is important to ensure locks won't last forever.
11
+ class UniqueJob
12
+ def self.bootstrap(service:)
13
+ services = Dir[File.expand_path('../unique_job/*.rb', __FILE__)].map { |f| File.basename(f, '.rb').to_sym }
14
+ unless services.include?(service)
15
+ msg = "UniqueJob is not supported for the `%<service>p' service. Supported options are: %<services>s."
16
+ raise MultiBackgroundJob::Error, format(msg, service: service.to_sym, services: services.map { |s| "`:#{s}'" }.join(', '))
17
+ end
18
+ if (require("multi_background_job/middleware/unique_job/#{service}"))
19
+ class_name = service.to_s.split('_').collect!{ |w| w.capitalize }.join
20
+ MultiBackgroundJob::Middleware::UniqueJob.const_get(class_name).bootstrap
21
+ end
22
+
23
+ MultiBackgroundJob.configure do |config|
24
+ config.unique_job_active = true
25
+ config.middleware.add(UniqueJob)
26
+ end
27
+ end
28
+
29
+ def call(worker, service)
30
+ if MultiBackgroundJob.config.unique_job_active? &&
31
+ (uniq_lock = unique_job_lock(worker: worker, service: service))
32
+ return false if uniq_lock.locked? # Don't push job to server
33
+
34
+ # Add unique job information to the job payload
35
+ worker.unique_job.lock = uniq_lock
36
+ worker.payload['uniq'] = worker.unique_job.to_hash
37
+
38
+ uniq_lock.lock
39
+ end
40
+
41
+ yield
42
+ end
43
+
44
+ protected
45
+
46
+ def unique_job_lock(worker:, service:)
47
+ return unless worker.unique_job?
48
+
49
+ digest = LockDigest.new(
50
+ *[service || worker.options[:service], worker.options[:queue]].compact,
51
+ across: worker.unique_job.across,
52
+ )
53
+
54
+ Lock.new(
55
+ digest: digest.to_s,
56
+ lock_id: unique_job_lock_id(worker),
57
+ ttl: worker.unique_job.ttl,
58
+ )
59
+ end
60
+
61
+ def unique_job_lock_id(worker)
62
+ identifier_data = [worker.worker_class, worker.payload.fetch('args'.freeze, [])]
63
+ Digest::SHA256.hexdigest(
64
+ MultiJson.dump(identifier_data, mode: :compat),
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end