tally_jobs 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b77ee7cc397f75d41288171fdda4c1b6afcfbd33f492b1a4b020ca32c8dc358
4
+ data.tar.gz: 44e3bf15cc99abe02cf3024bc1cf598fa6be7982fde5930b84f871eca64fa8f5
5
+ SHA512:
6
+ metadata.gz: cb8455dcfde48404752169a07c0ebe7b100916ff6dd6455a11f5735f805836d3cd3ea91b6871f2fac51465257c1b5ed857df4212c8b6bd9ad0b676adb31cdfc9
7
+ data.tar.gz: d2442c12d194153c62a69f0f9408d5fc4832f4f101f9c7685955b3648fa5666376303a9e5e417cb38207ce1a4c4f834202e432bca4cf4303e81dcf300b69a4a6
data/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # TallyJobs
2
+
3
+ Collect all params of the same jobs within an interval time then enqueue that job only one time.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "tally_jobs"
9
+
10
+ $ bundle install
11
+ $ rails g tally_jobs:install
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Assume that you have a job like this
17
+ ```ruby
18
+ class ReportSpamCommentJob < ApplicationJob
19
+ queue_as :default
20
+
21
+ def perform(comment_id)
22
+ comment = Comment.find(comment_id)
23
+ Report.report_spam_comment(comment) if SpamDetective.check_spam(comment)
24
+ end
25
+ end
26
+
27
+ class CommentController < ApplicationController
28
+ # ...
29
+ def create
30
+ # ...
31
+ ReportSpamCommentJob.enqueue_to_tally(comment.id)
32
+ # ...
33
+ end
34
+
35
+ def update
36
+ # ...
37
+ ReportSpamCommentJob.perform_later(comment.id)
38
+ # ...
39
+ end
40
+ # ...
41
+ end
42
+ ```
43
+
44
+ In a peak time, a lot of comments are created and so a lot of jobs are enqueued.
45
+ If we gather all comment ids within a small interval time then enqueue only one job for them, so what we need is just one read query to fetch all comments, and one write query to report all spam comments.
46
+
47
+ `tally_jobs` gem help you to do that:
48
+
49
+ ```ruby
50
+ class CommentController < ApplicationController
51
+ # ...
52
+ def create
53
+ # ...
54
+ ReportSpamCommentJob.enqueue_to_tally(comment.id)
55
+ # ...
56
+ end
57
+ # ...
58
+ end
59
+
60
+ class ReportSpamCommentJob < ApplicationJob
61
+ queue_as :default
62
+
63
+ include TallyJobs::TallyData
64
+
65
+ def perform(*comment_ids)
66
+ comments = Comment.where(id: comment_ids) # one read query
67
+ if spams = SpamDetective.check_spams(comments)
68
+ Report.report_spam_comments(spams) # one write query
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ The basic idea:
75
+
76
+ - you call `YouJob#enqueue_to_tally` to enqueue your job to a jobs-queue (in development, it is a `Thread::Queue`, in production it is `Redis List`).
77
+
78
+ - `tally_jobs` will start a counter thread which, every interval time, will pop enqueued jobs, collect params list group by ActiveJob/ConfiguredJob class, then enqueue each job with its params collection.
79
+
80
+
81
+ ## Notes
82
+
83
+ - `ActiveJob::ConfiguredJob` is counted separately from `ActiveJob`
84
+ ```ruby
85
+ ReportSpamCommentJob.enqueue_to_tally(1)
86
+ ReportSpamCommentJob.enqueue_to_tally(2)
87
+ ReportSpamCommentJob.set(wait_until: Date.tomorrow.noon).enqueue_to_tally(3)
88
+ ReportSpamCommentJob.set(wait_until: Date.tomorrow.noon).enqueue_to_tally(4)
89
+ # => enqueue 2 tally job
90
+ # one for ReportSpamCommentJob with [1,2]
91
+ # and one for [@job_class=ReportSpamCommentJob, @options={:wait_until=>Thu, 25 Apr 2024 12:00:00.000000000 UTC +00:00}] with [3,4]
92
+ #
93
+ ```
94
+
95
+ - support perform in-batch
96
+
97
+ ```ruby
98
+ class ReportSpamCommentJob < ApplicationJob
99
+ queue_as :default
100
+
101
+ include TallyJobs::TallyData
102
+
103
+ batch_size 100
104
+
105
+ def perform(one_hundred_comment_ids)
106
+ end
107
+ end
108
+ ```
109
+
110
+ - call `TallyJobs#stop` to stop counter thread, call `TallyJobs#restart` to restart counter thread
111
+ - in tests, just stop counter thread before all test cases, if you want to test job enqueued, you could start/flush/stop counter thread on each test case:
112
+
113
+ ```ruby
114
+ expect {
115
+ TallyJobs.restart
116
+
117
+ ReportSpamCommentJob.enqueue_to_tally(3)
118
+ ReportSpamCommentJob.enqueue_to_tally(4)
119
+ ReportSpamCommentJob.enqueue_to_tally(5)
120
+ TallyJobs.flush # force collect and enqueue jobs
121
+ # expect ...
122
+
123
+ TallyJobs.stop # this is also call flush
124
+ }.to have_enqueued_job(ReportSpamCommentJob).with([3,4,5])
125
+ ```
126
+
127
+ ## Todo
128
+
129
+ - handling back pressure
130
+ - support multiple tally jobs queues base on ActiveJob `queue_as`, set `interval-time` for each queue base on it's priority (higher priority, smaller interval time)
131
+ - support ActionMailer ???
132
+
133
+
134
+ ## Contributing
135
+
136
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/tally_jobs.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module TallyJobs
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("../../templates", __FILE__)
9
+
10
+ def copy_initializer
11
+ copy_file "tally_jobs.rb", "config/initializers/tally_jobs.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ TallyJobs.setup do |config|
4
+ config.interval = 120 # seconds
5
+ config.logger = Rails.logger
6
+ # config.counter_store = :memory_counter_store
7
+ config.counter_store = { store: :redis_counter_store, redis: Redis.new(host: "localhost") } if Rails.env.production?
8
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./counter_store/memory_counter_store"
4
+ require_relative "./counter_store/redis_counter_store"
5
+
6
+ module TallyJobs
7
+ class Configs
8
+ attr_accessor :interval, :logger, :redis
9
+
10
+ def redis=(_redis)
11
+ redis = _redis
12
+ end
13
+
14
+ def counter_store=(store)
15
+ JobsCounter.store = \
16
+ case store
17
+ when :memory_counter_store
18
+ TallyJobs::CounterStore::MemoryCounterStore.new
19
+ when Hash
20
+ case store[:store]
21
+ when :redis_counter_store
22
+ TallyJobs::CounterStore::RedisCounterStore.new(**store.except(:store))
23
+ else
24
+ store.delete(:store).to_s.classify.constantize.new(**store)
25
+ end
26
+ else
27
+ store.to_s.classify.constantize.new
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TallyJobs
4
+ module ConfiguredJob
5
+ def enqueue_to_tally(*params)
6
+ TallyJobs::JobsCounter.store.enqueue(
7
+ [self.instance_variable_get(:@job_class), self.instance_variable_get(:@options)],
8
+ *params
9
+ )
10
+ end
11
+ end
12
+ end
13
+
14
+ # to support enqueue_to_tally preconfigured jobs
15
+ require 'active_job'
16
+ ::ActiveJob::ConfiguredJob.include TallyJobs::ConfiguredJob
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../counter_store.rb"
4
+
5
+ module TallyJobs::CounterStore
6
+ class MemoryCounterStore < Base
7
+ # in-memory job queue
8
+ JOBS_QUEUE = Thread::Queue.new
9
+
10
+ def enqueue(job_clazz, *params)
11
+ JOBS_QUEUE.enq([job_clazz, *params])
12
+ end
13
+
14
+ def dequeue(n = 1)
15
+ JOBS_QUEUE.deq
16
+ end
17
+
18
+ delegate :empty?, :clear, to: "TallyJobs::CounterStore::MemoryCounterStore::JOBS_QUEUE"
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../counter_store.rb"
4
+
5
+ module TallyJobs::CounterStore
6
+ class RedisCounterStore < Base
7
+ attr_reader :redis
8
+
9
+ def initialize(redis:)
10
+ @redis = redis
11
+ end
12
+
13
+ def enqueue(job_clazz, *params)
14
+ @redis.rpush(KEY, Marshal.dump([job_clazz, *params]))
15
+ end
16
+
17
+ def dequeue(n = 1)
18
+ Marshal.load(@redis.lpop(KEY))
19
+ end
20
+
21
+ def empty?
22
+ @redis.llen(KEY).zero?
23
+ end
24
+
25
+ def clear
26
+ @redis.del(KEY)
27
+ end
28
+
29
+ private
30
+
31
+ KEY = "tally-jobs-queue".freeze
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TallyJobs::CounterStore
4
+ class Base
5
+ def enqueue(job_clazz, *params)
6
+ end
7
+
8
+ def dequeue(n = 1)
9
+ end
10
+
11
+ def empty?
12
+ end
13
+
14
+ def clear
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TallyJobs
4
+ class Engine < ::Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./counter_store/memory_counter_store"
4
+
5
+ module TallyJobs
6
+ class JobsCounter
7
+ cattr_accessor :store, default: TallyJobs::CounterStore::MemoryCounterStore.new
8
+
9
+ def self.collect_then_perform_later
10
+ groups = Hash.new { |h, k| h[k] = [] }
11
+ until store.empty?
12
+ job_clazz, *params = store.dequeue
13
+ groups[job_clazz] << (params.size == 1 ? params[0] : params)
14
+ end
15
+
16
+ groups.each do |job_clazz, params_list|
17
+ batch_size = nil
18
+
19
+ if job_clazz.is_a? Array
20
+ job_clazz, options = *job_clazz
21
+ batch_size = job_clazz._batch_size
22
+ job_clazz = job_clazz.set(**options)
23
+ else
24
+ batch_size = job_clazz._batch_size
25
+ end
26
+
27
+ next if job_clazz.nil?
28
+
29
+ if batch_size.nil?
30
+ job_clazz.perform_later(params_list)
31
+ else
32
+ params_list.each_slice(batch_size).each do |slice_params|
33
+ job_clazz.perform_later(slice_params)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TallyJobs
4
+ module TallyData
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ base.class_eval do
8
+ cattr_accessor :_batch_size
9
+
10
+ def self.batch_size(size)
11
+ self._batch_size = size
12
+ end
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def enqueue_to_tally(*params)
18
+ TallyJobs::JobsCounter.store.enqueue(self, *params)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TallyJobs
4
+ VERSION = "0.0.1"
5
+ end
data/lib/tally_jobs.rb ADDED
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tally_jobs/version"
4
+ require_relative "tally_jobs/engine"
5
+ require_relative "tally_jobs/configs"
6
+ require_relative "tally_jobs/jobs_counter"
7
+ require_relative "tally_jobs/tally_data"
8
+ require_relative "tally_jobs/configured_job"
9
+
10
+ module TallyJobs
11
+ class Error < StandardError; end
12
+
13
+ mattr_accessor :runnable, default: true
14
+ mattr_reader :configs, default: TallyJobs::Configs.new
15
+ class << self
16
+ def setup
17
+ yield configs
18
+
19
+ TallyJobs.start
20
+ end
21
+ end
22
+
23
+ module_function
24
+
25
+ # start a thread to flush in-memory enqueued jobs
26
+ # each {interval} time
27
+ MUTEX = Mutex.new
28
+ def start
29
+ MUTEX.synchronize do
30
+ @tally_thread = nil if !@tally_thread&.alive?
31
+
32
+ @tally_thread ||= Thread.new do
33
+ TallyJobs.configs.logger&.info("[TallyJobs] started ...")
34
+ while true
35
+ break unless TallyJobs.runnable
36
+ sleep(TallyJobs.configs.interval || 300)
37
+
38
+ JobsCounter.collect_then_perform_later
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def flush
45
+ TallyJobs.configs.logger&.info("[TallyJobs] flushing ...")
46
+ JobsCounter.collect_then_perform_later
47
+ end
48
+
49
+ def stop
50
+ flush
51
+
52
+ TallyJobs.runnable = false
53
+ if @tally_thread && @tally_thread.alive?
54
+ @tally_thread.wakeup
55
+ @tally_thread.join
56
+ end
57
+ TallyJobs.configs.logger&.info("[TallyJobs] stop ...")
58
+ end
59
+
60
+ def restart
61
+ stop
62
+ TallyJobs.runnable = true
63
+ start
64
+ end
65
+
66
+ # force flush all in-memory enqueued jobs before exit
67
+ at_exit do
68
+ TallyJobs.stop
69
+ end
70
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tally_jobs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - theforestvn88
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-04-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: redis
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Collect all params of the same jobs within an interval time then enqueue
98
+ that job only one time.
99
+ email:
100
+ - theforestvn88@gmail.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - README.md
106
+ - Rakefile
107
+ - lib/generators/tally_jobs/install_generator.rb
108
+ - lib/generators/templates/tally_jobs.rb
109
+ - lib/tally_jobs.rb
110
+ - lib/tally_jobs/configs.rb
111
+ - lib/tally_jobs/configured_job.rb
112
+ - lib/tally_jobs/counter_store.rb
113
+ - lib/tally_jobs/counter_store/memory_counter_store.rb
114
+ - lib/tally_jobs/counter_store/redis_counter_store.rb
115
+ - lib/tally_jobs/engine.rb
116
+ - lib/tally_jobs/jobs_counter.rb
117
+ - lib/tally_jobs/tally_data.rb
118
+ - lib/tally_jobs/version.rb
119
+ homepage: https://github.com/theforestvn88/tally_jobs.git
120
+ licenses: []
121
+ metadata:
122
+ homepage_uri: https://github.com/theforestvn88/tally_jobs.git
123
+ source_code_uri: https://github.com/theforestvn88/tally_jobs.git
124
+ changelog_uri: https://github.com/theforestvn88/tally_jobs.git
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: 1.0.0
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.4.10
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: Collect all params of the same jobs within an interval time then enqueue
144
+ that job only one time.
145
+ test_files: []