background_job 0.0.1.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/specs.yml +34 -0
  3. data/.gitignore +11 -0
  4. data/.rspec +1 -0
  5. data/.tool-versions +1 -0
  6. data/CHANGELOG.md +10 -0
  7. data/Gemfile +12 -0
  8. data/Gemfile.lock +58 -0
  9. data/LICENSE.txt +21 -0
  10. data/README.md +247 -0
  11. data/Rakefile +2 -0
  12. data/background_job.gemspec +39 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +6 -0
  15. data/docker-compose.yml +6 -0
  16. data/lib/background-job.rb +3 -0
  17. data/lib/background_job/configuration/base.rb +102 -0
  18. data/lib/background_job/configuration/faktory.rb +6 -0
  19. data/lib/background_job/configuration/middleware_chain.rb +109 -0
  20. data/lib/background_job/configuration/sidekiq.rb +23 -0
  21. data/lib/background_job/configuration.rb +63 -0
  22. data/lib/background_job/errors.rb +24 -0
  23. data/lib/background_job/jobs/faktory.rb +87 -0
  24. data/lib/background_job/jobs/job.rb +126 -0
  25. data/lib/background_job/jobs/sidekiq.rb +75 -0
  26. data/lib/background_job/jobs.rb +8 -0
  27. data/lib/background_job/lock.rb +141 -0
  28. data/lib/background_job/lock_digest.rb +36 -0
  29. data/lib/background_job/middleware/unique_job/faktory.rb +41 -0
  30. data/lib/background_job/middleware/unique_job/sidekiq.rb +48 -0
  31. data/lib/background_job/middleware/unique_job.rb +67 -0
  32. data/lib/background_job/mixin/faktory.rb +56 -0
  33. data/lib/background_job/mixin/shared_interface.rb +49 -0
  34. data/lib/background_job/mixin/sidekiq.rb +61 -0
  35. data/lib/background_job/mixin.rb +6 -0
  36. data/lib/background_job/redis_pool.rb +28 -0
  37. data/lib/background_job/testing.rb +76 -0
  38. data/lib/background_job/unique_job.rb +84 -0
  39. data/lib/background_job/version.rb +5 -0
  40. data/lib/background_job.rb +87 -0
  41. metadata +131 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Server midleware for Faktory
4
+ #
5
+ # @see https://github.com/contribsys/faktory_worker_ruby/wiki/Middleware
6
+ module BackgroundJob
7
+ module Middleware
8
+ class UniqueJob
9
+ module Faktory
10
+ def self.bootstrap
11
+ if defined?(::Faktory)
12
+ ::Faktory.configure_worker do |config|
13
+ config.worker_middleware do |chain|
14
+ chain.add Worker
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Worker middleware runs around the execution of a job
21
+ class Worker
22
+ def call(_jobinst, payload)
23
+ if payload.is_a?(Hash) && (unique_lock = unique_job_lock(payload))
24
+ unique_lock.unlock
25
+ end
26
+ yield
27
+ end
28
+
29
+ protected
30
+
31
+ def unique_job_lock(payload)
32
+ return unless payload['uniq'].is_a?(Hash)
33
+
34
+ unique_job = ::BackgroundJob::UniqueJob.coerce(payload['uniq'])
35
+ unique_job&.lock
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ # Provides the Sidekiq middleware that make the unique job control work
5
+ #
6
+ # @see https://github.com/contribsys/faktory_worker_ruby/wiki/Middleware
7
+ module BackgroundJob
8
+ module Middleware
9
+ class UniqueJob
10
+ module Sidekiq
11
+ def self.bootstrap
12
+ if defined?(::Sidekiq)
13
+ ::Sidekiq.configure_worker do |config|
14
+ config.worker_middleware do |chain|
15
+ chain.add Worker
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ # Worker middleware runs around the execution of a job
22
+ class Worker
23
+ # @param jobinst [Object] the worker/job instance
24
+ # @param payload [Hash] the full job payload
25
+ # * @see https://github.com/mperham/sidekiq/wiki/Job-Format
26
+ # @param queue [String] the name of the queue the job was pulled from
27
+ # @yield the next middleware in the chain or worker `perform` method
28
+ # @return [Void]
29
+ def call(_jobinst, payload, _queue)
30
+ if payload.is_a?(Hash) && (unique_lock = unique_job_lock(payload))
31
+ unique_lock.unlock
32
+ end
33
+ yield
34
+ end
35
+
36
+ protected
37
+
38
+ def unique_job_lock(job)
39
+ return unless job['uniq'].is_a?(Hash)
40
+
41
+ unique_job = ::BackgroundJob::UniqueJob.coerce(job['uniq'])
42
+ unique_job&.lock
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'background_job/lock'
4
+ require 'background_job/lock_digest'
5
+
6
+ module BackgroundJob
7
+ module Middleware
8
+ # This middleware uses an external redis queue to control duplications. The locking key
9
+ # is composed of job 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 BackgroundJob::Error, format(msg, service: service.to_sym, services: services.map { |s| "`:#{s}'" }.join(', '))
17
+ end
18
+ if (require("background_job/middleware/unique_job/#{service}"))
19
+ class_name = service.to_s.split('_').collect!{ |w| w.capitalize }.join
20
+ BackgroundJob::Middleware::UniqueJob.const_get(class_name).bootstrap
21
+ end
22
+
23
+ service_config = BackgroundJob.config_for(service)
24
+ service_config.unique_job_active = true
25
+ service_config.middleware.add(UniqueJob)
26
+ end
27
+
28
+ def call(job, service)
29
+ if BackgroundJob.config_for(service).unique_job_active? &&
30
+ (uniq_lock = unique_job_lock(job: job, service: service))
31
+ return false if uniq_lock.locked? # Don't push job to server
32
+
33
+ # Add unique job information to the job payload
34
+ job.unique_job.lock = uniq_lock
35
+ job.payload['uniq'] = job.unique_job.to_hash
36
+
37
+ uniq_lock.lock
38
+ end
39
+
40
+ yield
41
+ end
42
+
43
+ protected
44
+
45
+ def unique_job_lock(job:, service:)
46
+ return unless job.unique_job?
47
+
48
+ digest = LockDigest.new(
49
+ *[service || job.options[:service], job.options[:queue]].compact,
50
+ across: job.unique_job.across,
51
+ )
52
+ Lock.new(
53
+ digest: digest.to_s,
54
+ lock_id: unique_job_lock_id(job),
55
+ ttl: job.unique_job.ttl,
56
+ )
57
+ end
58
+
59
+ def unique_job_lock_id(job)
60
+ identifier_data = [job.job_class, job.payload.fetch('args'.freeze, [])]
61
+ Digest::SHA256.hexdigest(
62
+ MultiJson.dump(identifier_data, mode: :compat),
63
+ )
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,56 @@
1
+ # frizen_string_literal: true
2
+
3
+ require_relative './shared_interface'
4
+
5
+ module BackgroundJob
6
+ module Mixin
7
+ module Faktory
8
+ DEFAULT = {
9
+ queue: "default",
10
+ retry: 25
11
+ }.freeze
12
+
13
+ def self.background_job_options(job_class_name, strict_check: false)
14
+ BackgroundJob.config.faktory.validate_strict_job!(job_class_name) if strict_check
15
+ options = {}
16
+ BackgroundJob.config.faktory.jobs[job_class_name]&.each do |key, value|
17
+ options[key] = value
18
+ end
19
+ if defined?(::Faktory)
20
+ ::Faktory.default_job_options.each do |key, value|
21
+ options[key.to_sym] ||= value
22
+ end
23
+ end
24
+ DEFAULT.each do |key, value|
25
+ options[key] ||= value
26
+ end
27
+ options
28
+ end
29
+
30
+ class Builder < Module
31
+ def initialize(**options)
32
+ @runtime_mod = Module.new do
33
+ define_method(:background_job_user_options) { options }
34
+ end
35
+ end
36
+
37
+ def extended(base)
38
+ base.include(::Faktory::Job) if defined?(::Faktory)
39
+ base.extend BackgroundJob::Mixin::SharedInterface
40
+ base.extend ClassMethods
41
+ base.extend @runtime_mod
42
+ end
43
+
44
+ module ClassMethods
45
+ def background_job_service
46
+ :faktory
47
+ end
48
+
49
+ def background_job_default_options
50
+ BackgroundJob::Mixin::Faktory.background_job_options(name)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,49 @@
1
+ # frizen_string_literal: true
2
+
3
+ module BackgroundJob
4
+ module Mixin
5
+ module SharedInterface
6
+ def perform_async(*args)
7
+ build_job.with_args(*args).push
8
+ end
9
+
10
+ def perform_in(interval, *args)
11
+ build_job.with_args(*args).at(interval).push
12
+ end
13
+ alias_method :perform_at, :perform_in
14
+
15
+ # This method should be overridden in the including class
16
+ # @return [Symbol]
17
+ # @see BackgroundJob::Mixin::Sidekiq::ClassMethods
18
+ # @see BackgroundJob::Mixin::Faktory::ClassMethods
19
+ #
20
+ # @abstract
21
+ def background_job_service
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # This method should be overridden in the including class
26
+ # @return [Hash]
27
+ # @see BackgroundJob::Mixin::Sidekiq::ClassMethods
28
+ # @see BackgroundJob::Mixin::Faktory::ClassMethods
29
+ #
30
+ # @abstract
31
+ def background_job_default_options
32
+ raise NotImplementedError
33
+ end
34
+
35
+ # This method will be defined as a singleton method when the including class is extended
36
+ # @return [Hash] The hash of options to be passed to the background job
37
+ # @see BackgroundJob.mixin to see how this method is defined
38
+ def background_job_user_options
39
+ raise NotImplementedError
40
+ end
41
+
42
+ protected
43
+
44
+ def build_job
45
+ BackgroundJob.send(background_job_service, name, **background_job_default_options, **background_job_user_options)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,61 @@
1
+ # frizen_string_literal: true
2
+
3
+ require_relative './shared_interface'
4
+
5
+ module BackgroundJob
6
+ module Mixin
7
+ module Sidekiq
8
+ DEFAULT = {
9
+ queue: "default",
10
+ retry: true
11
+ }.freeze
12
+
13
+ def self.background_job_options(job_class_name, strict_check: false)
14
+ BackgroundJob.config.sidekiq.validate_strict_job!(job_class_name) if strict_check
15
+ options = {}
16
+ BackgroundJob.config.sidekiq.jobs[job_class_name]&.each do |key, value|
17
+ options[key] = value
18
+ end
19
+ if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:default_job_options)
20
+ ::Sidekiq.default_job_options.each do |key, value|
21
+ options[key.to_sym] ||= value
22
+ end
23
+ end
24
+ if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:default_worker_options)
25
+ ::Sidekiq.default_worker_options.each do |key, value|
26
+ options[key.to_sym] ||= value
27
+ end
28
+ end
29
+ DEFAULT.each do |key, value|
30
+ options[key] ||= value
31
+ end
32
+ options
33
+ end
34
+
35
+ class Builder < Module
36
+ def initialize(**options)
37
+ @runtime_mod = Module.new do
38
+ define_method(:background_job_user_options) { options }
39
+ end
40
+ end
41
+
42
+ def extended(base)
43
+ base.include(::Sidekiq::Worker) if defined?(::Sidekiq)
44
+ base.extend BackgroundJob::Mixin::SharedInterface
45
+ base.extend ClassMethods
46
+ base.extend @runtime_mod
47
+ end
48
+
49
+ module ClassMethods
50
+ def background_job_service
51
+ :sidekiq
52
+ end
53
+
54
+ def background_job_default_options
55
+ BackgroundJob::Mixin::Sidekiq.background_job_options(name)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackgroundJob
4
+ module Mixin
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module BackgroundJob
6
+ class RedisPool
7
+ extend Forwardable
8
+ def_delegator :@connection, :with
9
+
10
+ module ConnectionPoolLike
11
+ def with
12
+ yield self
13
+ end
14
+ end
15
+
16
+ def initialize(connection)
17
+ if connection.respond_to?(:with)
18
+ @connection = connection
19
+ elsif connection.is_a?(::Redis)
20
+ @connection = connection
21
+ @connection.extend(ConnectionPoolLike)
22
+ else
23
+ @connection = connection ? ::Redis.new(connection) : ::Redis.new
24
+ @connection.extend(ConnectionPoolLike)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackgroundJob
4
+ class Testing
5
+ class << self
6
+ def enable!
7
+ Thread.current[:background_job_testing] = true
8
+ end
9
+
10
+ def disable!
11
+ Thread.current[:background_job_testing] = false
12
+ end
13
+
14
+ def enabled?
15
+ Thread.current[:background_job_testing] == true
16
+ end
17
+
18
+ def disabled?
19
+ !enabled?
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ BackgroundJob::Testing.disable!
26
+
27
+ module BackgroundJob::Jobs
28
+ class << self
29
+ def jobs
30
+ @jobs ||= []
31
+ end
32
+
33
+ def push(job)
34
+ jobs.push(job)
35
+ end
36
+
37
+ def clear
38
+ jobs.clear
39
+ end
40
+
41
+ def size
42
+ jobs.size
43
+ end
44
+
45
+ def jobs_for(service: nil, class_name: nil)
46
+ filtered = jobs
47
+ if service
48
+ filtered = filtered.select do |job|
49
+ job.class.name.split("::").last.downcase == service.to_s
50
+ end
51
+ end
52
+ if class_name
53
+ filtered = filtered.select do |job|
54
+ job.job_class == class_name
55
+ end
56
+ end
57
+ filtered
58
+ end
59
+ end
60
+ end
61
+
62
+ module BackgroundJob::JobsInterceptorAdapter
63
+ def push
64
+ return super unless BackgroundJob::Testing.enabled?
65
+
66
+ normalize_before_push!
67
+ BackgroundJob::Jobs.push(self)
68
+ end
69
+ end
70
+
71
+ BackgroundJob::SERVICES.each do |service_name, class_name|
72
+ require_relative "./jobs/#{service_name}"
73
+
74
+ klass = Object.const_get("BackgroundJob::Jobs::#{class_name}")
75
+ klass.prepend(BackgroundJob::JobsInterceptorAdapter)
76
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './lock'
4
+
5
+ module BackgroundJob
6
+ class UniqueJob
7
+ VALID_OPTIONS = {
8
+ across: %i[queue systemwide],
9
+ timeout: 604800, # 1 week
10
+ unlock_policy: %i[success start],
11
+ }.freeze
12
+
13
+ attr_reader :across, :timeout, :unlock_policy, :lock
14
+
15
+ # @options [Hash] Unique definitions
16
+ # @option [Symbol] :across Valid options are :queue and :systemwide. If jobs should not to be duplicated on
17
+ # current queue or the entire system
18
+ # @option [Integer] :timeout Amount of times in seconds. Timeout decides how long to wait for acquiring the lock.
19
+ # A default timeout is defined to 1 week so unique locks won't last forever.
20
+ # @option [Symbol] :unlock_policy Control when the unique lock is removed. The default value is `success`.
21
+ # The job will not unlock until it executes successfully, it will remain locked even if it raises an error and
22
+ # goes into the retry queue. The alternative value is `start` the job will unlock right before it starts executing
23
+ def initialize(across: :queue, timeout: nil, unlock_policy: :success, lock: nil)
24
+ unless VALID_OPTIONS[:across].include?(across.to_sym)
25
+ raise Error, format('Invalid `across: %<given>p` option. Only %<expected>p are allowed.',
26
+ given: across,
27
+ expected: VALID_OPTIONS[:across])
28
+ end
29
+ unless VALID_OPTIONS[:unlock_policy].include?(unlock_policy.to_sym)
30
+ raise Error, format('Invalid `unlock_policy: %<given>p` option. Only %<expected>p are allowed.',
31
+ given: unlock_policy,
32
+ expected: VALID_OPTIONS[:unlock_policy])
33
+ end
34
+ timeout = VALID_OPTIONS[:timeout] if timeout.to_i <= 0
35
+
36
+ @across = across.to_sym
37
+ @timeout = timeout.to_i
38
+ @unlock_policy = unlock_policy.to_sym
39
+ @lock = lock if lock.is_a?(BackgroundJob::Lock)
40
+ end
41
+
42
+ def self.coerce(value)
43
+ return unless value.is_a?(Hash)
44
+
45
+ new(
46
+ across: (value['across'] || value[:across] || :queue).to_sym,
47
+ timeout: (value['timeout'] || value[:timeout] || nil),
48
+ unlock_policy: (value['unlock_policy'] || value[:unlock_policy] || :success).to_sym,
49
+ ).tap do |instance|
50
+ instance.lock = value['lock'] || value[:lock]
51
+ end
52
+ end
53
+
54
+ def ttl
55
+ Time.now.to_f + timeout
56
+ end
57
+
58
+ def to_hash
59
+ {
60
+ 'across' => (across.to_s if across),
61
+ 'timeout' => timeout,
62
+ 'unlock_policy' => (unlock_policy.to_s if unlock_policy),
63
+ }.tap do |hash|
64
+ hash['lock'] = lock.to_hash if lock
65
+ end
66
+ end
67
+
68
+ def eql?(other)
69
+ return false unless other.is_a?(self.class)
70
+
71
+ [across, timeout, unlock_policy] == [other.across, other.timeout, other.unlock_policy]
72
+ end
73
+ alias == eql?
74
+
75
+ def lock=(value)
76
+ @lock = case value
77
+ when BackgroundJob::Lock then value
78
+ when Hash then BackgroundJob::Lock.coerce(value)
79
+ else
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BackgroundJob
4
+ VERSION = '0.0.1.rc1'
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'time'
5
+ require 'securerandom'
6
+ require 'multi_json'
7
+
8
+ require_relative 'background_job/version'
9
+ require_relative 'background_job/errors'
10
+ require_relative "background_job/redis_pool"
11
+ require_relative 'background_job/mixin'
12
+ require_relative 'background_job/jobs'
13
+ require_relative 'background_job/configuration'
14
+ # require_relative 'background_job/adapters/adapter'
15
+ # require_relative 'background_job/adapters/sidekiq'
16
+ # require_relative 'background_job/adapters/faktory'
17
+
18
+ # This is a central point of our background job enqueuing system.
19
+ # Example:
20
+ #
21
+ # Standard job.
22
+ # BackgroundJob.sidekiq('UserWorker', queue: 'default')
23
+ # .with_args(1)
24
+ # .push
25
+ # Schedule the time when a job will be executed.
26
+ # BackgroundJob.sidekiq('UserWorker')
27
+ # .with_args(1)
28
+ # .at(timestamp)
29
+ # .push
30
+ # BackgroundJob.sidekiq('UserWorker')
31
+ # .with_args(1)
32
+ # .in(10.minutes)
33
+ # .push
34
+ #
35
+ # Unique jobs.
36
+ # BackgroundJob.sidekiq('UserWorker', uniq: { across: :queue, timeout: 1.minute, unlock_policy: :start })
37
+ # .with_args(1)
38
+ # .push
39
+ module BackgroundJob
40
+ SERVICES = {
41
+ sidekiq: 'Sidekiq',
42
+ faktory: 'Faktory',
43
+ }.freeze
44
+
45
+ SERVICES.each do |id, name|
46
+ define_singleton_method(id) do |job_name, **options|
47
+ Jobs.const_get(name).new(job_name, **options)
48
+ end
49
+ end
50
+
51
+ def self.mixin(service, **options)
52
+ service = service.to_sym
53
+ unless SERVICES.key?(service)
54
+ raise Error, "Service `#{service}' is not supported. Supported services are: #{SERVICES.keys.join(', ')}"
55
+ end
56
+ require_relative "background_job/mixin/#{service}"
57
+ require_relative "background_job/jobs/#{service}"
58
+
59
+ module_name = service.to_s.split(/_/i).collect!{ |w| w.capitalize }.join
60
+ mod = Mixin.const_get(module_name)
61
+ mod::Builder.new(**options)
62
+ end
63
+
64
+ def self.jid
65
+ SecureRandom.hex(12)
66
+ end
67
+
68
+ def self.config
69
+ @config ||= Configuration.new
70
+ end
71
+
72
+ def self.configure
73
+ return unless block_given?
74
+
75
+ yield(config)
76
+ end
77
+
78
+ def self.config_for(service)
79
+ service = service.to_sym
80
+ unless SERVICES.key?(service)
81
+ raise Error, "Service `#{service}' is not supported. Supported services are: #{SERVICES.keys.join(', ')}"
82
+ end
83
+ service_config = config.send(service)
84
+ yield(service_config) if block_given?
85
+ service_config
86
+ end
87
+ end