jobtick 0.1.4 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7db7f99928881432d43b819fb1ffa2772e6a690f659b0a54ac543bbbbed8e9db
4
- data.tar.gz: 8c67224a9ae754c411914958e3e9f8a3255bf9af5b09f2d2d2bd11ddffbf72f6
3
+ metadata.gz: abfce9cfddae0a798d132217b9376b130afe9446f85e279d3241e7016ed1a89f
4
+ data.tar.gz: 36590511cdf6e5b07541cdb66589fc5efd7f2250aeb4d5e5e4b20a18976642de
5
5
  SHA512:
6
- metadata.gz: 316eab73c6b4ef66752f86515113cf1f881e059c678045b47b63fc04a0dd67265753cd4c9a607db64e47b7629f71b2e107e7b0d62d959f3a1905326af42e5969
7
- data.tar.gz: 65725c57b8f2b1f6f97f3b93b799d3a2e57f5f4fae15cb8f0232f7259d3ee9285a37f60fa0b3bf2212b6f5faef77c565863e4158fe9109c49d712e7ecaa29115
6
+ metadata.gz: a81d1bf56a1e48ed5395bd5d913b8f49aafa163095261e7f066182f138c71f00259d7e428c5ee5a4e685a4320464aecd66aedde77a1f4c41cbb8431491999a39
7
+ data.tar.gz: f1fc3adfece35b9253e59be4beab12c5b2e1292c8330f287a386f37a71715d9cda922ca216a5219052e7097e1db13404b23c8118a10203fdbbf4024f37651e51
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.2.0] - 2026-05-28
2
+
3
+ - Performance: pings are now dispatched asynchronously on a single daemon thread, so job workers no longer block on network I/O. A persistent, keep-alive HTTPS connection is reused for all pings (no more TCP/TLS handshake per ping).
4
+ - Performance: switch to `Process.clock_gettime(CLOCK_MONOTONIC)` for duration measurement — no `Time` object allocation per job, and immune to wall-clock jumps.
5
+ - Performance: lazy-load parsers, hooks, middleware, and the registry — only Rails boots that have JobTick enabled pay for them.
6
+ - Performance: the monitor map is frozen after sync, and parser allocations are trimmed on the boot path.
7
+ - Add `Configuration#queue_limit` (default 1000) to bound the background ping queue; over-limit pings are dropped non-blockingly rather than back-pressuring the job thread.
8
+
9
+ ### Measured impact
10
+
11
+ Benchmarked with `spec/benchmarks/monitor_bench.rb` (10,000 iterations, WebMock-stubbed endpoint so the numbers reflect gem-internal overhead, not real network latency):
12
+
13
+ | Metric (per monitored job) | v0.1.4 | v0.2.0 | Change |
14
+ |---|---:|---:|---:|
15
+ | Job-thread blocking time | 400.6 µs | 2.0 µs | **~200× faster** |
16
+ | Object allocations on job thread | 2,390 | 9 | **~265× fewer** |
17
+ | End-to-end CPU time (incl. background dispatch) | 400.6 µs | 18.1 µs | **~22× less CPU** |
18
+
19
+ In production, where each ping pays real network RTT, the job-thread speedup is significantly larger: a single 20 ms RTT × 2–3 pings per job is ~50 ms blocking under v0.1.4, versus ~2 µs under v0.2.0 (~25,000× on the worker thread). Run `bundle exec ruby spec/benchmarks/monitor_bench.rb [iterations]` to reproduce.
20
+
1
21
  ## [0.1.4] - 2026-05-05
2
22
 
3
23
  - Add `prune` configuration option — when enabled, monitors absent from the latest sync payload are permanently deleted, keeping the dashboard in sync with your schedule config
data/README.md CHANGED
@@ -58,6 +58,38 @@ That's it. On next deploy, JobTick reads your schedule config, registers a monit
58
58
 
59
59
  No changes to individual job files. No manual monitor creation. No names to keep in sync.
60
60
 
61
+ ### Environments
62
+
63
+ **JobTick is only active in production by default.** In `development`, `staging`, or any other environment it silently does nothing — no pings are sent, no monitors are registered, no errors are raised. This means you can deploy the gem and configure it without worrying about local runs polluting your monitors or counting toward your plan.
64
+
65
+ If you want to enable JobTick in a non-production environment (e.g. to test your setup on staging before going live), opt in explicitly:
66
+
67
+ ```ruby
68
+ # config/initializers/jobtick.rb
69
+
70
+ # Enable on staging only
71
+ JobTick.configure do |config|
72
+ config.api_key = ENV['JOBTICK_API_KEY']
73
+ config.enabled = Rails.env.production? || Rails.env.staging?
74
+ end
75
+ ```
76
+
77
+ ```ruby
78
+ # Enable everywhere — useful for a quick local smoke-test
79
+ JobTick.configure do |config|
80
+ config.api_key = ENV['JOBTICK_API_KEY']
81
+ config.enabled = true
82
+ end
83
+ ```
84
+
85
+ ```ruby
86
+ # Drive it from an env var so you can toggle without a deploy
87
+ JobTick.configure do |config|
88
+ config.api_key = ENV['JOBTICK_API_KEY']
89
+ config.enabled = ENV['JOBTICK_ENABLED'] == 'true'
90
+ end
91
+ ```
92
+
61
93
  ### Removing stale monitors automatically
62
94
 
63
95
  By default, monitors are only added — nothing is removed when you delete a job from your schedule. To have each deploy also clean up monitors that are no longer in your config, enable pruning:
@@ -1,53 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
5
- require "uri"
3
+ require_relative "dispatcher"
6
4
 
7
5
  module JobTick
8
6
  class Client
9
- TIMEOUT = 5
7
+ PING_PREFIX = "/ping/"
8
+ SYNC_PATH = "/monitors/sync"
10
9
 
11
10
  def ping(monitor_key, status:, duration: nil, message: nil)
12
- return unless JobTick.config.enabled
13
- return if JobTick.config.api_key.nil?
11
+ config = JobTick.config
12
+ return unless config.enabled && !config.api_key.nil?
14
13
 
15
14
  payload = { status: status }
16
15
  payload[:duration] = duration.round(3) if duration
17
16
  payload[:message] = message if message
18
17
 
19
- post("/ping/#{monitor_key}", payload)
18
+ Dispatcher.enqueue("#{PING_PREFIX}#{monitor_key}", payload)
20
19
  end
21
20
 
22
21
  def register(monitors, app_name: nil, prune: false)
23
- return unless JobTick.config.enabled
24
- return if JobTick.config.api_key.nil?
22
+ config = JobTick.config
23
+ return unless config.enabled && !config.api_key.nil?
25
24
 
26
25
  payload = { monitors: monitors }
27
26
  payload[:app_name] = app_name if app_name && !app_name.empty?
28
27
  payload[:prune] = true if prune
29
- post("/monitors/sync", payload)
30
- end
31
28
 
32
- private
33
-
34
- def post(path, body)
35
- uri = URI("#{JobTick.config.endpoint}#{path}")
36
- http = Net::HTTP.new(uri.host, uri.port)
37
- http.use_ssl = uri.scheme == "https"
38
- http.open_timeout = TIMEOUT
39
- http.read_timeout = TIMEOUT
40
-
41
- request = Net::HTTP::Post.new(uri)
42
- request["Content-Type"] = "application/json"
43
- request["Authorization"] = "Bearer #{JobTick.config.api_key}"
44
- request["User-Agent"] = "jobtick-ruby/#{JobTick::VERSION}"
45
- request.body = body.to_json
46
-
47
- http.request(request)
48
- rescue StandardError => e
49
- JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
50
- nil
29
+ Dispatcher.send_sync(SYNC_PATH, payload)
51
30
  end
52
31
  end
53
32
  end
@@ -2,13 +2,20 @@
2
2
 
3
3
  module JobTick
4
4
  class Configuration
5
- attr_accessor :api_key, :endpoint, :environment, :enabled, :prune
5
+ DEFAULT_QUEUE_LIMIT = 1000
6
+
7
+ attr_accessor :api_key, :endpoint, :environment, :enabled, :prune, :queue_limit
6
8
 
7
9
  def initialize
8
10
  @endpoint = "https://api.jobtick.app/v1"
9
11
  @environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "production"
10
12
  @enabled = @environment == "production"
11
13
  @prune = false
14
+ @queue_limit = DEFAULT_QUEUE_LIMIT
15
+ end
16
+
17
+ def enabled?
18
+ @enabled && !@api_key.nil?
12
19
  end
13
20
  end
14
21
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "openssl"
7
+
8
+ module JobTick
9
+ # Asynchronous, single-threaded HTTP dispatcher with a persistent keep-alive
10
+ # connection. Job threads call .enqueue and return immediately; the dispatcher
11
+ # daemon thread drains the queue and posts to the JobTick API.
12
+ #
13
+ # All HTTP work (sync register + async pings) shares one Net::HTTP instance
14
+ # serialized by @http_mutex. The connection is reopened lazily after errors.
15
+ module Dispatcher
16
+ SHUTDOWN_SIGNAL = :__shutdown__
17
+ HEADER_CONTENT_TYPE = "application/json"
18
+ USER_AGENT = "jobtick-ruby/#{JobTick::VERSION}".freeze
19
+ OPEN_TIMEOUT = 5
20
+ READ_TIMEOUT = 5
21
+ KEEP_ALIVE_TIMEOUT = 30
22
+
23
+ NETWORK_ERRORS = [
24
+ IOError, EOFError,
25
+ Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ECONNABORTED,
26
+ Errno::EPIPE, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
27
+ Net::OpenTimeout, Net::ReadTimeout,
28
+ OpenSSL::SSL::SSLError, SocketError
29
+ ].freeze
30
+
31
+ class << self
32
+ attr_accessor :synchronous
33
+
34
+ def enqueue(path, payload)
35
+ return send_request(path, payload) if @synchronous
36
+
37
+ ensure_started
38
+ @queue.push([path, payload], true)
39
+ nil
40
+ rescue ThreadError
41
+ @dropped += 1
42
+ nil
43
+ end
44
+
45
+ def send_sync(path, payload)
46
+ send_request(path, payload)
47
+ end
48
+
49
+ def flush(timeout: 5)
50
+ return unless @running && @queue
51
+
52
+ deadline = monotonic + timeout
53
+ until @queue.empty? && @inflight.zero?
54
+ sleep 0.001
55
+ break if monotonic > deadline
56
+ end
57
+ end
58
+
59
+ def shutdown(timeout: 2)
60
+ return unless @running
61
+
62
+ @running = false
63
+ @queue&.push(SHUTDOWN_SIGNAL)
64
+ @thread&.join(timeout)
65
+ close_http
66
+ nil
67
+ end
68
+
69
+ def dropped
70
+ @dropped || 0
71
+ end
72
+
73
+ def reset!
74
+ shutdown(timeout: 1) if @running
75
+ @queue = nil
76
+ @thread = nil
77
+ @dropped = 0
78
+ @inflight = 0
79
+ @endpoint_uri = nil
80
+ @at_exit_registered = false
81
+ @synchronous = false
82
+ end
83
+
84
+ private
85
+
86
+ def ensure_started
87
+ return if @running
88
+
89
+ boot_mutex.synchronize do
90
+ return if @running
91
+
92
+ @queue = SizedQueue.new(queue_limit)
93
+ @dropped = 0
94
+ @inflight = 0
95
+ @thread = Thread.new { run_loop }
96
+ @thread.name = "jobtick-dispatcher" if @thread.respond_to?(:name=)
97
+ @running = true
98
+ register_at_exit
99
+ end
100
+ end
101
+
102
+ def boot_mutex
103
+ @boot_mutex ||= Mutex.new
104
+ end
105
+
106
+ def http_mutex
107
+ @http_mutex ||= Mutex.new
108
+ end
109
+
110
+ def queue_limit
111
+ JobTick.config.queue_limit || Configuration::DEFAULT_QUEUE_LIMIT
112
+ end
113
+
114
+ def register_at_exit
115
+ return if @at_exit_registered
116
+
117
+ @at_exit_registered = true
118
+ at_exit { shutdown }
119
+ end
120
+
121
+ def run_loop
122
+ while (item = @queue.pop)
123
+ break if item == SHUTDOWN_SIGNAL
124
+
125
+ @inflight = 1
126
+ path, payload = item
127
+ send_request(path, payload)
128
+ @inflight = 0
129
+ end
130
+ rescue StandardError => e
131
+ JobTick.logger.warn("[JobTick] Dispatcher thread crashed: #{e.message}")
132
+ ensure
133
+ close_http
134
+ end
135
+
136
+ def send_request(path, payload)
137
+ body = JSON.generate(payload)
138
+ full_path = "#{endpoint_uri.path}#{path}"
139
+ http_mutex.synchronize do
140
+ http = http_connection
141
+ request = Net::HTTP::Post.new(full_path)
142
+ request["Content-Type"] = HEADER_CONTENT_TYPE
143
+ request["Authorization"] = "Bearer #{JobTick.config.api_key}"
144
+ request["User-Agent"] = USER_AGENT
145
+ request.body = body
146
+ http.request(request)
147
+ end
148
+ rescue *NETWORK_ERRORS => e
149
+ JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
150
+ teardown_http
151
+ nil
152
+ rescue StandardError => e
153
+ JobTick.logger.warn("[JobTick] HTTP request failed (#{path}): #{e.message}")
154
+ nil
155
+ end
156
+
157
+ def endpoint_uri
158
+ @endpoint_uri ||= URI(JobTick.config.endpoint)
159
+ end
160
+
161
+ def http_connection
162
+ return @http if @http&.started?
163
+
164
+ uri = endpoint_uri
165
+ @http = Net::HTTP.new(uri.host, uri.port)
166
+ @http.use_ssl = uri.scheme == "https"
167
+ @http.open_timeout = OPEN_TIMEOUT
168
+ @http.read_timeout = READ_TIMEOUT
169
+ @http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
170
+ @http.start
171
+ @http
172
+ end
173
+
174
+ def teardown_http
175
+ return unless @http
176
+
177
+ @http.finish if @http.started?
178
+ rescue StandardError
179
+ nil
180
+ ensure
181
+ @http = nil
182
+ end
183
+
184
+ def close_http
185
+ http_mutex.synchronize { teardown_http }
186
+ end
187
+
188
+ def monotonic
189
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
190
+ end
191
+ end
192
+ end
193
+ end
@@ -5,7 +5,7 @@ module JobTick
5
5
  module ActiveJob
6
6
  def self.included(base)
7
7
  base.around_perform do |job, block|
8
- key = JobTick.monitor_key_for(job.class.name)
8
+ key = JobTick.monitor_map[job.class.name]
9
9
  next block.call unless key
10
10
 
11
11
  JobTick::Monitor.run(key) { block.call }
@@ -7,7 +7,7 @@ module JobTick
7
7
  # Active Job wrappers are handled by the around_perform hook
8
8
  return yield if job["wrapped"]
9
9
 
10
- key = JobTick.monitor_key_for(job["class"])
10
+ key = JobTick.monitor_map[job["class"]]
11
11
  return yield unless key
12
12
 
13
13
  JobTick::Monitor.run(key, &)
@@ -2,14 +2,18 @@
2
2
 
3
3
  module JobTick
4
4
  class Monitor
5
+ MONOTONIC = Process::CLOCK_MONOTONIC
6
+
5
7
  def self.run(key)
6
- return yield unless JobTick.config.enabled
8
+ config = JobTick.config
9
+ return yield unless config.enabled && !config.api_key.nil?
7
10
 
8
- started_at = Time.now
9
- JobTick.client.ping(key, status: :started)
10
- result = yield
11
- duration = Time.now - started_at
12
- JobTick.client.ping(key, status: :completed, duration: duration)
11
+ client = JobTick.client
12
+ client.ping(key, status: :started)
13
+ started = Process.clock_gettime(MONOTONIC)
14
+ result = yield
15
+ duration = Process.clock_gettime(MONOTONIC) - started
16
+ client.ping(key, status: :completed, duration: duration)
13
17
  result
14
18
  rescue StandardError => e
15
19
  JobTick.client.ping(key, status: :failed, message: e.message)
@@ -2,8 +2,11 @@
2
2
 
3
3
  module JobTick
4
4
  module Parsers
5
+ SLUG_RE = /[^a-z0-9]+/
6
+ SLUG_TRIM_RE = /\A_+|_+\z/
7
+
5
8
  def self.slugify(str)
6
- str.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
9
+ str.downcase.gsub(SLUG_RE, "_").gsub(SLUG_TRIM_RE, "")
7
10
  end
8
11
 
9
12
  class Sidekiq
@@ -16,16 +16,16 @@ module JobTick
16
16
  tasks = yaml[env] || yaml["default"] || yaml
17
17
  return [] unless tasks.is_a?(Hash)
18
18
 
19
- tasks.map do |key, config|
19
+ tasks.each_with_object([]) do |(key, config), out|
20
20
  next unless config.is_a?(Hash)
21
21
 
22
- {
22
+ out << {
23
23
  key: "solid_queue.#{key}",
24
24
  schedule: config["schedule"],
25
25
  source: "solid_queue",
26
26
  task: config["class"]
27
27
  }
28
- end.compact
28
+ end
29
29
  rescue StandardError => e
30
30
  JobTick.logger.warn("[JobTick] Solid Queue parser failed: #{e.message}")
31
31
  []
@@ -6,6 +6,13 @@ module JobTick
6
6
  ActiveSupport.on_load(:after_initialize) do
7
7
  next unless JobTick.config.enabled
8
8
 
9
+ require_relative "parsers/whenever"
10
+ require_relative "parsers/solid_queue"
11
+ require_relative "parsers/sidekiq"
12
+ require_relative "registry"
13
+ require_relative "hooks/active_job"
14
+ require_relative "middleware/sidekiq"
15
+
9
16
  JobTick::Registry.sync
10
17
 
11
18
  ::ActiveJob::Base.include(JobTick::Hooks::ActiveJob) if defined?(::ActiveJob::Base)
@@ -9,9 +9,9 @@ module JobTick
9
9
  Parsers::Sidekiq.parse
10
10
  ].flatten.compact
11
11
 
12
- JobTick.monitor_map = monitors.each_with_object({}) do |m, map|
13
- map[m[:task]] = m[:key] if m[:task]
14
- end
12
+ map = {}
13
+ monitors.each { |m| map[m[:task]] = m[:key] if m[:task] }
14
+ JobTick.monitor_map = map.freeze
15
15
 
16
16
  return [] if monitors.empty?
17
17
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobTick
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/jobtick.rb CHANGED
@@ -5,15 +5,11 @@ require_relative "jobtick/version"
5
5
  require_relative "jobtick/configuration"
6
6
  require_relative "jobtick/client"
7
7
  require_relative "jobtick/monitor"
8
- require_relative "jobtick/parsers/whenever"
9
- require_relative "jobtick/parsers/solid_queue"
10
- require_relative "jobtick/parsers/sidekiq"
11
- require_relative "jobtick/registry"
12
- require_relative "jobtick/hooks/active_job"
13
- require_relative "jobtick/middleware/sidekiq"
14
8
  require_relative "jobtick/railtie" if defined?(Rails::Railtie)
15
9
 
16
10
  module JobTick
11
+ EMPTY_MAP = {}.freeze
12
+
17
13
  class Error < StandardError; end
18
14
 
19
15
  class << self
@@ -34,7 +30,7 @@ module JobTick
34
30
  end
35
31
 
36
32
  def monitor_map
37
- @monitor_map ||= {}
33
+ @monitor_map ||= EMPTY_MAP
38
34
  end
39
35
 
40
36
  attr_writer :monitor_map
@@ -44,9 +40,10 @@ module JobTick
44
40
  end
45
41
 
46
42
  def reset!
43
+ Dispatcher.reset! if defined?(Dispatcher)
47
44
  @config = nil
48
45
  @client = nil
49
- @monitor_map = {}
46
+ @monitor_map = EMPTY_MAP
50
47
  end
51
48
  end
52
49
  end
@@ -3,6 +3,11 @@
3
3
  namespace :jobtick do
4
4
  desc "Sync discovered jobs with jobtick.app"
5
5
  task sync: :environment do
6
+ require "jobtick/parsers/whenever"
7
+ require "jobtick/parsers/solid_queue"
8
+ require "jobtick/parsers/sidekiq"
9
+ require "jobtick/registry"
10
+
6
11
  monitors = JobTick::Registry.sync
7
12
  count = monitors&.length || 0
8
13
  puts "[JobTick] Synced #{count} monitor(s)"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jobtick
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clearstack Labs
@@ -38,6 +38,7 @@ files:
38
38
  - lib/jobtick.rb
39
39
  - lib/jobtick/client.rb
40
40
  - lib/jobtick/configuration.rb
41
+ - lib/jobtick/dispatcher.rb
41
42
  - lib/jobtick/hooks/active_job.rb
42
43
  - lib/jobtick/middleware/sidekiq.rb
43
44
  - lib/jobtick/monitor.rb