litestack 0.1.8 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ data: 123
2
+
3
+ production:
4
+ data: abc
5
+
6
+ developments:
7
+ data: xyz
@@ -43,7 +43,7 @@ module Litejob
43
43
  private
44
44
  def self.included(klass)
45
45
  klass.extend(ClassMethods)
46
- #klass.get_jobqueue
46
+ klass.get_jobqueue
47
47
  end
48
48
 
49
49
  module ClassMethods
@@ -80,9 +80,13 @@ module Litejob
80
80
  def queue=(queue_name)
81
81
  @@queue = queue_name.to_s
82
82
  end
83
+
84
+ def options
85
+ @options ||= self::DEFAULT_OPTIONS rescue {}
86
+ end
83
87
 
84
88
  def get_jobqueue
85
- Litejobqueue.jobqueue
89
+ Litejobqueue.jobqueue(options)
86
90
  end
87
91
  end
88
92
 
@@ -1,8 +1,7 @@
1
1
  # frozen_stringe_literal: true
2
- require 'logger'
3
- require 'oj'
4
- require 'yaml'
2
+
5
3
  require_relative './litequeue'
4
+ require_relative './litemetric'
6
5
 
7
6
  ##
8
7
  #Litejobqueue is a job queueing and processing system designed for Ruby applications. It is built on top of SQLite, which is an embedded relational database management system that is #lightweight and fast.
@@ -12,7 +11,9 @@ require_relative './litequeue'
12
11
  #Litejobqueue also integrates well with various I/O frameworks like Async and Polyphony, making it a great choice for Ruby applications that use these frameworks. It provides a #simple and easy-to-use API for adding jobs to the queue and for processing them.
13
12
  #
14
13
  #Overall, LiteJobQueue is an excellent choice for Ruby applications that require a lightweight, embedded job queueing and processing system that is fast, efficient, and easy to use.
15
- class Litejobqueue
14
+ class Litejobqueue < Litequeue
15
+
16
+ include Litemetric::Measurable
16
17
 
17
18
  # the default options for the job queue
18
19
  # can be overriden by passing new options in a hash
@@ -40,13 +41,16 @@ class Litejobqueue
40
41
  dead_job_retention: 10 * 24 * 3600,
41
42
  gc_sleep_interval: 7200,
42
43
  logger: 'STDOUT',
43
- sleep_intervals: [0.001, 0.005, 0.025, 0.125, 0.625, 1.0, 2.0]
44
+ sleep_intervals: [0.001, 0.005, 0.025, 0.125, 0.625, 1.0, 2.0],
45
+ metrics: false
44
46
  }
45
47
 
46
48
  @@queue = nil
47
49
 
48
50
  attr_reader :running
49
51
 
52
+ alias_method :_push, :push
53
+
50
54
  # a method that returns a single instance of the job queue
51
55
  # for use by Litejob
52
56
  def self.jobqueue(options = {})
@@ -64,31 +68,10 @@ class Litejobqueue
64
68
  # jobqueue = Litejobqueue.new
65
69
  #
66
70
  def initialize(options = {})
67
- @options = DEFAULT_OPTIONS.merge(options)
68
- config = YAML.load_file(@options[:config_path]) rescue {} # an empty hash won't hurt
69
- config.keys.each do |k| # symbolize keys
70
- config[k.to_sym] = config[k]
71
- config.delete k
72
- end
73
- @options.merge!(config)
74
- @options.merge!(options) # make sure options passed to initialize trump everything else
75
71
 
76
- @queue = Litequeue.new(@options) # create a new queue object
72
+ @queues = [] # a place holder to allow workers to process
73
+ super(options)
77
74
 
78
- # create logger
79
- if @options[:logger].respond_to? :info
80
- @logger = @options[:logger]
81
- elsif @options[:logger] == 'STDOUT'
82
- @logger = Logger.new(STDOUT)
83
- elsif @options[:logger] == 'STDERR'
84
- @logger = Logger.new(STDERR)
85
- elsif @options[:logger].nil?
86
- @logger = Logger.new(IO::NULL)
87
- elsif @options[:logger].is_a? String
88
- @logger = Logger.new(@options[:logger])
89
- else
90
- @logger = Logger.new(IO::NULL)
91
- end
92
75
  # group and order queues according to their priority
93
76
  pgroups = {}
94
77
  @options[:queues].each do |q|
@@ -96,25 +79,6 @@ class Litejobqueue
96
79
  pgroups[q[1]] << [q[0], q[2] == "spawn"]
97
80
  end
98
81
  @queues = pgroups.keys.sort.reverse.collect{|p| [p, pgroups[p]]}
99
- @running = true
100
- @workers = @options[:workers].times.collect{ create_worker }
101
-
102
- @gc = create_garbage_collector
103
- @jobs_in_flight = 0
104
- @mutex = Litesupport::Mutex.new
105
-
106
- at_exit do
107
- @running = false
108
- puts "--- Litejob detected an exit attempt, cleaning up"
109
- index = 0
110
- while @jobs_in_flight > 0 and index < 5
111
- puts "--- Waiting for #{@jobs_in_flight} jobs to finish"
112
- sleep 1
113
- index += 1
114
- end
115
- puts " --- Exiting with #{@jobs_in_flight} jobs in flight"
116
- end
117
-
118
82
  end
119
83
 
120
84
  # push a job to the queue
@@ -127,8 +91,16 @@ class Litejobqueue
127
91
  # jobqueue.push(EasyJob, params) # the job will be performed asynchronously
128
92
  def push(jobclass, params, delay=0, queue=nil)
129
93
  payload = Oj.dump({klass: jobclass, params: params, retries: @options[:retries], queue: queue})
130
- res = @queue.push(payload, delay, queue)
131
- @logger.info("[litejob]:[ENQ] id: #{res} job: #{jobclass}")
94
+ res = super(payload, delay, queue)
95
+ capture(:enqueue, queue)
96
+ @logger.info("[litejob]:[ENQ] queue:#{res[1]} class:#{jobclass} job:#{res[0]}")
97
+ res
98
+ end
99
+
100
+ def repush(id, job, delay=0, queue=nil)
101
+ res = super(id, Oj.dump(job), delay, queue)
102
+ capture(:enqueue, queue)
103
+ @logger.info("[litejob]:[ENQ] queue:#{res[0]} class:#{job[:klass]} job:#{id}")
132
104
  res
133
105
  end
134
106
 
@@ -140,9 +112,9 @@ class Litejobqueue
140
112
  # end
141
113
  # jobqueue = Litejobqueue.new
142
114
  # id = jobqueue.push(EasyJob, params, 10) # queue for processing in 10 seconds
143
- # jobqueue.delete(id, 'default')
144
- def delete(id, queue=nil)
145
- job = @queue.delete(id, queue)
115
+ # jobqueue.delete(id)
116
+ def delete(id)
117
+ job = super(id)
146
118
  @logger.info("[litejob]:[DEL] job: #{job}")
147
119
  job = Oj.load(job[0]) if job
148
120
  job
@@ -150,23 +122,40 @@ class Litejobqueue
150
122
 
151
123
  # delete all jobs in a certain named queue
152
124
  # or delete all jobs if the queue name is nil
153
- def clear(queue=nil)
154
- @queue.clear(queue)
155
- end
125
+ #def clear(queue=nil)
126
+ #@queue.clear(queue)
127
+ #end
156
128
 
157
129
  # stop the queue object (does not delete the jobs in the queue)
158
130
  # specifically useful for testing
159
131
  def stop
160
132
  @running = false
161
- @@queue = nil
133
+ #@@queue = nil
134
+ close
162
135
  end
163
136
 
164
137
 
165
- def count(queue=nil)
166
- @queue.count(queue)
167
- end
168
-
169
138
  private
139
+
140
+ def exit_callback
141
+ @running = false # stop all workers
142
+ puts "--- Litejob detected an exit, cleaning up"
143
+ index = 0
144
+ while @jobs_in_flight > 0 and index < 30 # 3 seconds grace period for jobs to finish
145
+ puts "--- Waiting for #{@jobs_in_flight} jobs to finish"
146
+ sleep 0.1
147
+ index += 1
148
+ end
149
+ puts " --- Exiting with #{@jobs_in_flight} jobs in flight"
150
+ end
151
+
152
+ def setup
153
+ super
154
+ @jobs_in_flight = 0
155
+ @workers = @options[:workers].times.collect{ create_worker }
156
+ @gc = create_garbage_collector
157
+ @mutex = Litesupport::Mutex.new
158
+ end
170
159
 
171
160
  def job_started
172
161
  Litesupport.synchronize(@mutex){@jobs_in_flight += 1}
@@ -176,6 +165,11 @@ class Litejobqueue
176
165
  Litesupport.synchronize(@mutex){@jobs_in_flight -= 1}
177
166
  end
178
167
 
168
+ # return a hash encapsulating the info about the current jobqueue
169
+ def snapshot
170
+ info
171
+ end
172
+
179
173
  # optionally run a job in its own context
180
174
  def schedule(spawn = false, &block)
181
175
  if spawn
@@ -195,40 +189,40 @@ class Litejobqueue
195
189
  level[1].each do |q| # iterate through the queues in the level
196
190
  index = 0
197
191
  max = level[0]
198
- while index < max && payload = @queue.pop(q[0], 1) # fearlessly use the same queue object
192
+ while index < max && payload = pop(q[0], 1) # fearlessly use the same queue object
193
+ capture(:dequeue, q[0])
199
194
  processed += 1
200
195
  index += 1
201
196
  begin
202
197
  id, job = payload[0], payload[1]
203
198
  job = Oj.load(job)
204
- # first capture the original job id
205
- job[:id] = id if job[:retries].to_i == @options[:retries].to_i
206
- @logger.info "[litejob]:[DEQ] job:#{job}"
199
+ @logger.info "[litejob]:[DEQ] queue:#{q[0]} class:#{job[:klass]} job:#{id}"
207
200
  klass = eval(job[:klass])
208
201
  schedule(q[1]) do # run the job in a new context
209
202
  job_started #(Litesupport.current_context)
210
203
  begin
211
- klass.new.perform(*job[:params])
212
- @logger.info "[litejob]:[END] job:#{job}"
204
+ measure(:perform, q[0]){ klass.new.perform(*job[:params]) }
205
+ @logger.info "[litejob]:[END] queue:#{q[0]} class:#{job[:klass]} job:#{id}"
213
206
  rescue Exception => e
214
207
  # we can retry the failed job now
208
+ capture(:fail, q[0])
215
209
  if job[:retries] == 0
216
- @logger.error "[litejob]:[ERR] job: #{job} failed with #{e}:#{e.message}, retries exhausted, moved to _dead queue"
217
- @queue.push(Oj.dump(job), @options[:dead_job_retention], '_dead')
210
+ @logger.error "[litejob]:[ERR] queue:#{q[0]} class:#{job[:klass]} job:#{id} failed with #{e}:#{e.message}, retries exhausted, moved to _dead queue"
211
+ repush(id, job, @options[:dead_job_retention], '_dead')
218
212
  else
213
+ capture(:retry, q[0])
219
214
  retry_delay = @options[:retry_delay_multiplier].pow(@options[:retries] - job[:retries]) * @options[:retry_delay]
220
215
  job[:retries] -= 1
221
- @logger.error "[litejob]:[ERR] job: #{job} failed with #{e}:#{e.message}, retrying in #{retry_delay}"
222
- @queue.push(Oj.dump(job), retry_delay, q[0])
223
- @logger.info "[litejob]:[ENQ] job: #{job} enqueued"
216
+ @logger.error "[litejob]:[ERR] queue:#{q[0]} class:#{job[:klass]} job:#{id} failed with #{e}:#{e.message}, retrying in #{retry_delay} seconds"
217
+ repush(id, job, retry_delay, q[0])
224
218
  end
225
219
  end
226
220
  job_finished #(Litesupport.current_context)
227
221
  end
228
222
  rescue Exception => e
229
- # this is an error in the extraction of job info
230
- # retrying here will not be useful
223
+ # this is an error in the extraction of job info, retrying here will not be useful
231
224
  @logger.error "[litejob]:[ERR] failed to extract job info for: #{payload} with #{e}:#{e.message}"
225
+ job_finished #(Litesupport.current_context)
232
226
  end
233
227
  Litesupport.switch #give other contexts a chance to run here
234
228
  end
@@ -248,7 +242,7 @@ class Litejobqueue
248
242
  def create_garbage_collector
249
243
  Litesupport.spawn do
250
244
  while @running do
251
- while jobs = @queue.pop('_dead', 100)
245
+ while jobs = pop('_dead', 100)
252
246
  if jobs[0].is_a? Array
253
247
  @logger.info "[litejob]:[DEL] garbage collector deleted #{jobs.length} dead jobs"
254
248
  else
@@ -0,0 +1,228 @@
1
+ # frozen_stringe_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ require_relative './litesupport'
6
+
7
+ # this class is a singleton
8
+ # and should remain so
9
+ class Litemetric
10
+
11
+ include Singleton
12
+ include Litesupport::Liteconnection
13
+
14
+ DEFAULT_OPTIONS = {
15
+ config_path: "./litemetric.yml",
16
+ path: "./metrics.db",
17
+ sync: 1,
18
+ mmap_size: 16 * 1024 * 1024, # 16MB of memory to easily process 1 year worth of data
19
+ flush_interval: 10, # flush data every 1 minute
20
+ summarize_interval: 10 # summarize data every 1 minute
21
+ }
22
+
23
+ RESOLUTIONS = {
24
+ minute: 300, # 5 minutes (highest resolution)
25
+ hour: 3600, # 1 hour
26
+ day: 24*3600, # 1 day
27
+ week: 7*24*3600 # 1 week (lowest resolution)
28
+ }
29
+
30
+ # :nodoc:
31
+ def initialize(options = {})
32
+ init(options)
33
+ end
34
+
35
+ # registers a class for metrics to be collected
36
+ def register(identifier)
37
+ @registered[identifier] = true
38
+ @metrics[identifier] = {} unless @metrics[identifier]
39
+ run_stmt(:register_topic, identifier) # it is safe to call register topic multiple times with the same identifier
40
+ end
41
+
42
+ ## event capturing
43
+ ##################
44
+
45
+ def capture(topic, event, key=event, value=nil)
46
+ if key.is_a? Array
47
+ key.each{|k| capture_single_key(topic, event, k, value)}
48
+ else
49
+ capture_single_key(topic, event, key, value)
50
+ end
51
+ end
52
+
53
+ def capture_single_key(topic, event, key=event, value=nil)
54
+ @mutex.synchronize do
55
+ time_slot = current_time_slot # should that be 5 minutes?
56
+ topic_slot = @metrics[topic]
57
+ if event_slot = topic_slot[event]
58
+ if key_slot = event_slot[key]
59
+ if key_slot[time_slot]
60
+ key_slot[time_slot][:count] += 1
61
+ key_slot[time_slot][:value] += value unless value.nil?
62
+ else # new time slot
63
+ key_slot[time_slot] = {count: 1, value: value}
64
+ end
65
+ else
66
+ event_slot[key] = {time_slot => {count: 1, value: value}}
67
+ end
68
+ else # new event
69
+ topic_slot[event] = {key => {time_slot => {count: 1, value: value}}}
70
+ end
71
+ end
72
+ end
73
+
74
+
75
+ ## event reporting
76
+ ##################
77
+
78
+ def topics
79
+ run_stmt(:list_topics).to_a
80
+ end
81
+
82
+ def event_names(resolution, topic)
83
+ run_stmt(:list_event_names, resolution, topic).to_a
84
+ end
85
+
86
+ def keys(resolution, topic, event_name)
87
+ run_stmt(:list_event_keys, resolution, topic, event_name).to_a
88
+ end
89
+
90
+ def event_data(resolution, topic, event_name, key)
91
+ run_stmt(:list_events_by_key, resolution, topic, event_name, key).to_a
92
+ end
93
+
94
+ ## summarize data
95
+ #################
96
+
97
+ def summarize
98
+ run_stmt(:summarize_events, RESOLUTIONS[:hour], "hour", "minute")
99
+ run_stmt(:summarize_events, RESOLUTIONS[:day], "day", "hour")
100
+ run_stmt(:summarize_events, RESOLUTIONS[:week], "week", "day")
101
+ run_stmt(:delete_events, "minute", RESOLUTIONS[:hour]*1)
102
+ run_stmt(:delete_events, "hour", RESOLUTIONS[:day]*1)
103
+ run_stmt(:delete_events, "day", RESOLUTIONS[:week]*1)
104
+ end
105
+
106
+ ## background stuff
107
+ ###################
108
+
109
+ private
110
+
111
+ def exit_callback
112
+ puts "--- Litemetric detected an exit, flushing metrics"
113
+ @running = false
114
+ flush
115
+ end
116
+
117
+ def setup
118
+ super
119
+ @metrics = {}
120
+ @registered = {}
121
+ @flusher = create_flusher
122
+ @summarizer = create_summarizer
123
+ @mutex = Litesupport::Mutex.new
124
+ end
125
+
126
+ def current_time_slot
127
+ (Time.now.to_i / 300) * 300 # every 5 minutes
128
+ end
129
+
130
+ def flush
131
+ to_delete = []
132
+ @conn.acquire do |conn|
133
+ conn.transaction(:immediate) do
134
+ @metrics.each_pair do |topic, event_hash|
135
+ event_hash.each_pair do |event, key_hash|
136
+ key_hash.each_pair do |key, time_hash|
137
+ time_hash.each_pair do |time, data|
138
+ conn.stmts[:capture_event].execute!(topic, event.to_s, key, time, data[:count], data[:value]) if data
139
+ time_hash[time] = nil
140
+ to_delete << [topic, event, key, time]
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ to_delete.each do |r|
148
+ @metrics[r[0]][r[1]][r[2]].delete(r[3])
149
+ @metrics[r[0]][r[1]].delete(r[2]) if @metrics[r[0]][r[1]][r[2]].empty?
150
+ @metrics[r[0]].delete(r[1]) if @metrics[r[0]][r[1]].empty?
151
+ end
152
+ end
153
+
154
+ def create_connection
155
+ conn = super
156
+ conn.wal_autocheckpoint = 10000
157
+ sql = YAML.load_file("#{__dir__}/litemetric.sql.yml")
158
+ version = conn.get_first_value("PRAGMA user_version")
159
+ sql["schema"].each_pair do |v, obj|
160
+ if v > version
161
+ conn.transaction do
162
+ obj.each{|k, s| conn.execute(s)}
163
+ conn.user_version = v
164
+ end
165
+ end
166
+ end
167
+ sql["stmts"].each { |k, v| conn.stmts[k.to_sym] = conn.prepare(v) }
168
+ conn
169
+ end
170
+
171
+ def create_flusher
172
+ Litesupport.spawn do
173
+ while @running do
174
+ sleep @options[:flush_interval]
175
+ @mutex.synchronize do
176
+ flush
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ def create_summarizer
183
+ Litesupport.spawn do
184
+ while @running do
185
+ sleep @options[:summarize_interval]
186
+ summarize
187
+ end
188
+ end
189
+ end
190
+
191
+ end
192
+
193
+ ## Measurable Module
194
+ ####################
195
+
196
+ class Litemetric
197
+ module Measurable
198
+
199
+ def collect_metrics
200
+ @litemetric = Litemetric.instance
201
+ @litemetric.register(metrics_identifier)
202
+ end
203
+
204
+ def metrics_identifier
205
+ self.class.name # override in included classes
206
+ end
207
+
208
+ def capture(event, key=event, value=nil)
209
+ return unless @litemetric
210
+ @litemetric.capture(metrics_identifier, event, key, value)
211
+ end
212
+
213
+ def measure(event, key=event)
214
+ return yield unless @litemetric
215
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
216
+ res = yield
217
+ t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
218
+ value = (( t2 - t1 ) * 1000).round # capture time in milliseconds
219
+ capture(event, key, value)
220
+ res
221
+ end
222
+
223
+ def snapshot
224
+ raise Litestack::NotImplementedError
225
+ end
226
+
227
+ end
228
+ end
@@ -0,0 +1,69 @@
1
+ schema:
2
+ 1:
3
+ create_topics: >
4
+ CREATE TABLE IF NOT EXISTS topics(
5
+ name text PRIMARY KEY NOT NULL
6
+ ) WITHOUT ROWID;
7
+ create_events: >
8
+ CREATE TABLE IF NOT EXISTS events(
9
+ topic text NOT NULL references topics(name) ON DELETE CASCADE,
10
+ name TEXT NOT NULL,
11
+ key TEXT NOT NULL,
12
+ count INTEGER DEFAULT(0) NOT NULL ON CONFLICT REPLACE,
13
+ value INTEGER,
14
+ minimum INTEGER,
15
+ maximum INTEGER,
16
+ created_at INTEGER DEFAULT((unixepoch()/300*300)) NOT NULL,
17
+ resolution TEXT DEFAULT('minute') NOT NULL,
18
+ PRIMARY KEY(resolution, topic, name, key, created_at)
19
+ ) WITHOUT ROWID;
20
+ create_index_on_event: CREATE INDEX IF NOT EXISTS events_by_resolution ON events(resolution, created_at);
21
+
22
+ stmts:
23
+ # register topic
24
+ register_topic: INSERT INTO topics VALUES (?) ON CONFLICT DO NOTHING;
25
+
26
+ capture_event: >
27
+ INSERT INTO events(topic, name, key, created_at, count, value, minimum, maximum) VALUES ($1, $2, $3, $4, $5, $6, $6, $6)
28
+ ON CONFLICT DO
29
+ UPDATE SET count = count + EXCLUDED.count, value = value + EXCLUDED.value, minimum = min(minimum, EXCLUDED.minimum), maximum = max(maximum, EXCLUDED.maximum)
30
+
31
+ # requires an index on (resolution, created_at)
32
+ summarize_events: >
33
+ INSERT INTO events (topic, name, key, count, value, minimum, maximum, created_at, resolution ) SELECT
34
+ topic,
35
+ name,
36
+ key,
37
+ sum(count) as count,
38
+ sum(value) as value,
39
+ min(minimum) as minimum,
40
+ max(maximum) as maximum,
41
+ (created_at/$1)*$1 as created,
42
+ $2
43
+ FROM events WHERE resolution = $3 AND created_at < (unixepoch()/$1)*$1 GROUP BY topic, name, key, created ON CONFLICT DO UPDATE
44
+ SET count = count + EXCLUDED.count, value = value + EXCLUDED.value, minimum = min(minimum, EXCLUDED.minimum), maximum = max(maximum, EXCLUDED.maximum);
45
+
46
+ # requires an index on (resolution, created_at)
47
+ delete_events: DELETE FROM events WHERE resolution = $3 AND created_at < (unixepoch() - $4);
48
+
49
+ # select topics from the topics table
50
+ list_topics: SELECT name FROM topics;
51
+
52
+ # requires an index on (resolution, topic, name)
53
+ list_event_names: >
54
+ SELECT name, sum(count) as count, count(distinct name) as name, sum(value) as value, min(minimum), max(maximum)
55
+ FROM events WHERE resolution = ? AND topic = ? GROUP BY name ORDER BY count;
56
+
57
+ # requires an index on (resolution, topic, name, key)
58
+ list_event_keys: >
59
+ SELECT key, sum(count) as count, sum(value) as value, min(minimum), max(maximum)
60
+ FROM events WHERE resolution = ? AND topic = ? AND name = ? GROUP BY key ORDER BY count;
61
+
62
+ # requires an index on (resolution, topic, name, key, created_at)
63
+ list_events_by_key: >
64
+ SELECT * FROM events WHERE resolution = $1 AND topic = $2 AND name = $3 AND key = $4 ORDER BY created_at ASC;
65
+
66
+ # requires an index on (resolution, topic, name, key, created_at)
67
+ list_all_events: >
68
+ SELECT * FROM events WHERE resolution = ? AND topic = ? ORDER BY name, key, created_at ASC;
69
+