multi-background-job 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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