tj-scale 1.0.2 → 1.1.1

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: bfa02cccd18ac22e845267ddac5835d60c620e793e65aa34b648d1cc7889eab3
4
- data.tar.gz: 28a78e5c2643a400910aaca6d535f191f521c1633a8eaab48d28365aa40835e6
3
+ metadata.gz: a15d2cfe793359d5678938c3f6cf974f496ac716b173acbf4f9b408668b88da4
4
+ data.tar.gz: '0490a176f40d292a3868a135ddd8ecdddfad30025a5d7458163c7159f2dbe144'
5
5
  SHA512:
6
- metadata.gz: a269ea3a6087b6bdf54f40a6ac98960d7e70219049ec02dea5d7de592277fba106c5e77769277bd04b3623e356ad90a03926cb0bfcc7edef0867f37ef1bc11d3
7
- data.tar.gz: e2ed378beb3196a9e9508132f04fea691a828ee6359650b8bb8e6daa96c8693199cbb091e06c8ee1d9aa31353596f7af305c23fbe7703ecbf8a35c9fceb78bb7
6
+ metadata.gz: d5978c29aeaac97ade3368b4c693ab5d445c9c43716490d8299fe22c2785a0014b335df040c3836db385de4f28093aa4c74b0abe2642f731eeb4295e69f4bbbc
7
+ data.tar.gz: 4e3ff1c12ff935b3f211c9698a718e54f1e5b41501dcf565cbdc8a64c08f7df1e50258cb4444b029040f117fce5cac9385e36a995dbe8b714c8f684baf6b52fb
data/CHANGELOG.md CHANGED
@@ -7,13 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+
11
+ ## [1.1.1] (tj-scale) - 2026-07-20
12
+
13
+ ### Fixed
14
+
15
+ - **Web `queue_time_ms` is now captured on Heroku.** The Rack queue-time
16
+ middleware only parsed the nginx-style `X-Request-Start: t=<seconds>` form, but
17
+ Heroku's router sends a **bare millisecond epoch** (e.g. `1731523200000`). The
18
+ header is now parsed in both formats, so `queue_time_ms` is actually produced on
19
+ Heroku when `TJ_SCALE_ENABLE_QUEUE_TIME_MIDDLEWARE=1` (previously always `nil`).
20
+ - **Stale `queue_time_ms` is no longer re-reported.** The captured router
21
+ queue-time snapshot is now consumed each ingest tick, so after web traffic stops
22
+ the gem stops resending the last elevated value (which kept the web dyno scaled
23
+ up and blocked downscaling on the `queue_time_ms` metric).
24
+ - **Delayed Job counting no longer depends on PostgreSQL.** The runnable-jobs
25
+ query used a Postgres-only `interval '5 seconds'` SQL literal that raises on
26
+ MySQL/SQLite; the exception was swallowed to `0`, silently masking the entire
27
+ worker backlog. The 5-second buffer is now computed in Ruby (`run_at < now - 5`),
28
+ so counting works on any Delayed Job-supported database.
29
+
30
+ ### Added
31
+
32
+ - Integration coverage for the Sidekiq backend against the real `Sidekiq::Queue`
33
+ API and a live Redis, alongside the existing double-based specs — those only
34
+ asserted our assumptions about `size` and `latency`, so an upstream API change
35
+ would have surfaced as broken autoscaling in a host app rather than a red test.
36
+ `sidekiq` is now a development dependency. The specs use
37
+ `TJ_SCALE_TEST_REDIS_URL` (default `redis://localhost:6379/15`), `FLUSHDB`
38
+ between examples, and skip themselves when no Redis is reachable, so
39
+ `rake spec` still passes without one.
40
+ - An explicit test that enqueued counts exclude the retry and scheduled sets,
41
+ pinning that as a deliberate limitation: a backlog waiting in `retry` or
42
+ `schedule` does not by itself scale workers up.
43
+
44
+
45
+ ## [1.1.0] (tj-scale) - 2026-06-12
46
+
47
+ ### Changed
48
+
49
+ - **Monitor process is now auto-detected from Heroku's `DYNO` env var**: `web.1`
50
+ monitors web and `worker.1` monitors worker, so one app monitors both process
51
+ types with no Procfile env prefixes or extra config. `TJ_SCALE_MONITOR_PROCESS`
52
+ still pins a single process type when set explicitly; off-Heroku (no `DYNO`)
53
+ the default remains `worker`. Non-web/worker dynos (`release.*`, `run.*`,
54
+ `scheduler.*`) never start the metrics loop.
55
+ - `target_process` (payload) follows the auto-detected process, so web samples
56
+ land on web scale rules and worker samples on worker rules automatically.
57
+
58
+ ### Added
59
+
60
+ - **Custom process types**: any Heroku process type (e.g. `bulk-worker.1`) now
61
+ runs the monitor and reports job metrics under its own `target_process`, not
62
+ just `web`/`worker`. Platform dynos (`release`, `run`, `scheduler`, `console`,
63
+ `rake`) are excluded.
64
+ - **Per-process Sidekiq queue filter**: `TJ_SCALE_SIDEKIQ_QUEUES_<PROCESS>`
65
+ (process name upcased, non-alphanumerics → `_`, e.g.
66
+ `TJ_SCALE_SIDEKIQ_QUEUES_BULK_WORKER=low`) overrides the global
67
+ `TJ_SCALE_SIDEKIQ_QUEUES` for that process, so a bulk fleet can scale on its
68
+ own queue's backlog while the worker fleet scales on the rest.
69
+
10
70
  ## [1.0.2] (tj-scale) - 2026-06-12
11
71
 
12
72
  ### Changed
13
73
 
14
74
  - README fully updated for the `tj-scale` name: install/build/publish instructions,
15
75
  repository URLs (`Untechnickle/tj-scale-gem`), and generic example app names.
16
- - Supersedes 1.0.0 and 1.0.1, which are yanked (same code, stale docs/description).
17
76
 
18
77
  ## [1.0.1] (tj-scale) - 2026-06-12
19
78
 
data/README.md CHANGED
@@ -153,7 +153,7 @@ Then `bundle install`.
153
153
 
154
154
  3. **No extra `require`** is needed in `application.rb`: the gem registers a **Rails Railtie** and loads with Rails. Ensure your app already bundles its job backend — **`delayed_job_active_record`** or **`sidekiq`** (the gem auto-detects which one is loaded, or set `TJ_SCALE_JOB_BACKEND` explicitly).
155
155
 
156
- 4. Deploy or run the server as usual. The monitoring thread starts only on **`web.1`** or **`worker.1`**, depending on `TJ_SCALE_MONITOR_PROCESS` (see below).
156
+ 4. Deploy or run the server as usual. The monitoring thread starts on **`web.1`** and **`worker.1`** automatically (process type auto-detected from Heroku's `DYNO`), so one app reports both web and worker metrics with no extra config. Set `TJ_SCALE_MONITOR_PROCESS` to pin a single process type (see below).
157
157
 
158
158
  ### 5. Configure environment variables
159
159
 
@@ -165,7 +165,7 @@ Configuration is **entirely via environment variables** (and Heroku **config var
165
165
  | `TJ_SCALE_API_URL` | Yes | Full URL of the Agent API ingest path: **`POST /api/v1/metrics`** or **`POST /api/v1/sys-logs`** (same handler). Example local: `http://127.0.0.1:5001/api/v1/metrics`. |
166
166
  | `TJ_SCALE_TARGET_APP` | No | Heroku app name to scale; defaults to **`HEROKU_APP_NAME`** if unset. |
167
167
  | `TJ_SCALE_TARGET_PROCESS` | No | `web` or `worker` to scale; defaults to `TJ_SCALE_MONITOR_PROCESS`. |
168
- | `TJ_SCALE_MONITOR_PROCESS` | No | `web` or `worker` only that process’s **`.1`** dyno runs the reporter (default **`worker`**). |
168
+ | `TJ_SCALE_MONITOR_PROCESS` | No | `web` or `worker` to pin the reporter to one process type. Default: auto-detected from `DYNO` (`web.1` reports web, `worker.1` reports worker); **`worker`** when `DYNO` is unset (non-Heroku). |
169
169
  | `TJ_SCALE_INTERVAL_SECONDS` | No | Seconds between posts (default **20**). |
170
170
  | `TJ_SCALE_JOB_BACKEND` | No | Queue backend for the **worker** reporter: `delayed_job`, `sidekiq`, or `auto` (default). `auto` uses Sidekiq when the `sidekiq` gem is loaded, otherwise Delayed Job. |
171
171
  | `TJ_SCALE_SIDEKIQ_QUEUES` | No | Sidekiq backend only: comma-separated queue names to count (empty = all queues). |
@@ -272,7 +272,7 @@ heroku config:set TJ_SCALE_API_TOKEN=your_api_token_here
272
272
  heroku config:set TJ_SCALE_API_URL=https://your-control-plane.example.com/api/v1/metrics
273
273
  ```
274
274
 
275
- The `DYNO` environment variable is automatically set by Heroku and is used to determine which dyno should run the monitoring loop (only `web.1` or `worker.1`, depending on `TJ_SCALE_MONITOR_PROCESS`).
275
+ The `DYNO` environment variable is automatically set by Heroku and is used to determine which dynos run the monitoring loop: `web.1` and `worker.1` each report their own process type (override with `TJ_SCALE_MONITOR_PROCESS`).
276
276
 
277
277
  ### Example: web on `my-app-web`, workers on `my-app-workers`
278
278
 
@@ -307,7 +307,7 @@ Ensure both apps have the `web` / `worker` process types in the `Procfile` as ne
307
307
 
308
308
  ### Automatic Monitoring
309
309
 
310
- Once installed and configured, the gem starts monitoring on the first dyno of the configured process type (`web.1` or `worker.1`, see `TJ_SCALE_MONITOR_PROCESS`). The loop runs every `TJ_SCALE_INTERVAL_SECONDS` (default 20) and POSTs metrics to the configured API.
310
+ Once installed and configured, the gem starts monitoring on the first dyno of each process type (`web.1` and `worker.1`; pin one with `TJ_SCALE_MONITOR_PROCESS`). The loop runs every `TJ_SCALE_INTERVAL_SECONDS` (default 20) and POSTs metrics to the configured API.
311
311
 
312
312
  ### Manual Usage
313
313
 
@@ -338,7 +338,7 @@ ENV['TJ_MAX_PRIORITY'] = '10'
338
338
 
339
339
  ## How It Works
340
340
 
341
- 1. **Initialization**: When your Rails application starts, the `Railtie` checks if it's running on the first monitored dyno (`web.1` or `worker.1`, per `TJ_SCALE_MONITOR_PROCESS`)
341
+ 1. **Initialization**: When your Rails application starts, the `Railtie` checks if it's running on the first dyno of a monitored process type (`web.1` or `worker.1`, auto-detected from `DYNO`; `TJ_SCALE_MONITOR_PROCESS` pins one)
342
342
  2. **Background Thread**: If conditions are met, a background thread is started
343
343
  3. **Monitoring Loop**: Every N seconds (`TJ_SCALE_INTERVAL_SECONDS`), the thread POSTs a payload: on **worker** reporters it counts waiting jobs on the active backend; on **web** reporters it sends router queue time (middleware / env) plus request volume and average response time for the interval.
344
344
  4. **Job counting (worker reporters, Delayed Job backend)**: `pending_job_count` filters jobs based on:
data/lib/tj-scale.rb CHANGED
@@ -11,8 +11,10 @@ require "tj_scale_ruby/models/tj_scale_ruby"
11
11
  # TJ Scale (the control plane) scales Heroku formation when ingested metrics cross rules
12
12
  # you configure there. This gem only **reports signals**; it never calls Heroku directly.
13
13
  #
14
- # **Where it runs:** A single background thread on **one** dyno — the first dyno of the
15
- # process you choose (`web.1` or `worker.1` via +TJ_SCALE_MONITOR_PROCESS+). Heroku sets +DYNO+.
14
+ # **Where it runs:** A single background thread on the **first dyno of each process type**.
15
+ # The process is auto-detected from Heroku's +DYNO+ (`web.1` monitors web, `worker.1` monitors
16
+ # worker — so one app monitors both with no extra config). Set +TJ_SCALE_MONITOR_PROCESS+ to
17
+ # pin a single process type instead; off-Heroku (no +DYNO+) the default is `worker`.
16
18
  #
17
19
  # **What it sends:** On each tick (+TJ_SCALE_INTERVAL_SECONDS+), it POSTs JSON to
18
20
  # +TJ_SCALE_API_URL+ (e.g. +/api/v1/metrics+ or +/api/v1/sys-logs+ — same handler on the
@@ -21,12 +23,12 @@ require "tj_scale_ruby/models/tj_scale_ruby"
21
23
  # includes +heroku_app+ and +target_process+; scaling limits (min/max dynos) live in the
22
24
  # control plane's settings, not in this gem.
23
25
  #
24
- # - **Worker monitor** (+TJ_SCALE_MONITOR_PROCESS=worker+): counts waiting jobs
26
+ # - **Worker monitor** (on +worker.1+): counts waiting jobs
25
27
  # (+job_count+, +queue_time_s+) on the configured queue backend — **Delayed Job** or **Sidekiq**,
26
28
  # toggled with +TJ_SCALE_JOB_BACKEND+ (+delayed_job+ / +sidekiq+ / +auto+, the default, which
27
29
  # prefers Sidekiq when loaded). Delayed Job needs +delayed_job_active_record+ and a shared DB;
28
30
  # Sidekiq needs the +sidekiq+ gem and Redis (+TJ_SCALE_SIDEKIQ_QUEUES+ limits counted queues).
29
- # - **Web monitor** (+TJ_SCALE_MONITOR_PROCESS=web+): sends +queue_time_ms+ from Rack middleware
31
+ # - **Web monitor** (on +web.1+): sends +queue_time_ms+ from Rack middleware
30
32
  # (router queue via +X-Request-Start+) when +TJ_SCALE_ENABLE_QUEUE_TIME_MIDDLEWARE=1+, plus
31
33
  # request volume and average +response_time_ms+ for the tick. Without middleware, set
32
34
  # +TJ_SCALE_QUEUE_TIME_MS+ or the platform will not scale on queue time until data exists.
@@ -69,7 +71,8 @@ module TjScaleRuby
69
71
  end
70
72
  end
71
73
 
72
- # True when this dyno should run the metrics loop (first of TJ_SCALE_MONITOR_PROCESS).
74
+ # True when this dyno should run the metrics loop: the first dyno (.1) of the
75
+ # monitored process type (auto-detected from DYNO, or TJ_SCALE_MONITOR_PROCESS).
73
76
  # Result is memoized; cleared by {.reset_configuration!}.
74
77
  def self.monitor_on_this_dyno?
75
78
  return @monitor_on_this_dyno if defined?(@monitor_on_this_dyno)
@@ -5,14 +5,21 @@ module TjScaleRuby
5
5
  class Configuration
6
6
  DEFAULT_INTERVAL_SECONDS = 20
7
7
  JOB_BACKENDS = %w[auto delayed_job sidekiq].freeze
8
+ # Any Heroku process type (web, worker, bulk-worker, …) is monitorable;
9
+ # platform dynos never run app monitoring.
10
+ PROCESS_NAME = /\A[a-z][a-z0-9_-]*\z/
11
+ NON_APP_PROCESSES = %w[release run scheduler console rake].freeze
8
12
 
9
13
  attr_reader :monitor_process, :interval_seconds,
10
14
  :min_priority, :max_priority, :job_backend, :sidekiq_queues
11
15
 
12
16
  def initialize(env = ENV)
13
- @monitor_process = normalize_process(env["TJ_SCALE_MONITOR_PROCESS"]) || "worker"
17
+ @monitor_process = normalize_process(env["TJ_SCALE_MONITOR_PROCESS"]) ||
18
+ process_from_dyno(env["DYNO"]) || "worker"
14
19
  @job_backend = normalize_job_backend(env["TJ_SCALE_JOB_BACKEND"])
15
- @sidekiq_queues = parse_list(env["TJ_SCALE_SIDEKIQ_QUEUES"])
20
+ @sidekiq_queues = parse_list(
21
+ env[sidekiq_queues_key(@monitor_process)] || env["TJ_SCALE_SIDEKIQ_QUEUES"]
22
+ )
16
23
  @interval_seconds = parse_positive_int(env["TJ_SCALE_INTERVAL_SECONDS"], DEFAULT_INTERVAL_SECONDS)
17
24
  @target_app = nonempty_string(env["TJ_SCALE_TARGET_APP"])
18
25
  @heroku_app_name = nonempty_string(env["HEROKU_APP_NAME"])
@@ -33,11 +40,24 @@ module TjScaleRuby
33
40
 
34
41
  private
35
42
 
43
+ # Auto-detect from Heroku's DYNO (web.1 → web, worker.1 → worker) so one app
44
+ # can monitor both process types without per-process env. Nil off-Heroku and
45
+ # for non-web/worker dynos (release.1, run.x, scheduler.x).
46
+ def process_from_dyno(dyno)
47
+ normalize_process(dyno.to_s.split(".").first)
48
+ end
49
+
36
50
  def normalize_process(value)
37
51
  p = value.to_s.strip.downcase
38
- return nil if p.empty?
52
+ return nil if p.empty? || NON_APP_PROCESSES.include?(p)
53
+
54
+ PROCESS_NAME.match?(p) ? p : nil
55
+ end
39
56
 
40
- %w[web worker].include?(p) ? p : nil
57
+ # Per-process Sidekiq queue filter, e.g. TJ_SCALE_SIDEKIQ_QUEUES_BULK_WORKER=low
58
+ # for a +bulk-worker+ process; falls back to TJ_SCALE_SIDEKIQ_QUEUES.
59
+ def sidekiq_queues_key(process)
60
+ "TJ_SCALE_SIDEKIQ_QUEUES_#{process.to_s.upcase.gsub(/[^A-Z0-9]/, '_')}"
41
61
  end
42
62
 
43
63
  # "delayed_job" | "sidekiq" | "auto" (default; detects from loaded gems).
@@ -91,6 +111,17 @@ module TjScaleRuby
91
111
  web_queue_mutex.synchronize { @web_queue_time_ms }
92
112
  end
93
113
 
114
+ # Reads and clears the latest captured router queue time (ms). Consuming it each
115
+ # ingest tick prevents a stale elevated value from being re-reported (and keeping
116
+ # the web dyno scaled up) after traffic stops. Thread-safe.
117
+ def flush_web_queue_time_ms!
118
+ web_queue_mutex.synchronize do
119
+ v = @web_queue_time_ms
120
+ @web_queue_time_ms = nil
121
+ v
122
+ end
123
+ end
124
+
94
125
  def clear_web_queue_time_snapshot!
95
126
  web_queue_mutex.synchronize { @web_queue_time_ms = nil }
96
127
  end
@@ -16,7 +16,10 @@ module TjScaleRuby
16
16
  min_priority = TjScaleRuby.configuration.min_priority
17
17
  max_priority = TjScaleRuby.configuration.max_priority
18
18
 
19
- query = ::Delayed::Job.where("run_at + interval '5 seconds' < ?", now)
19
+ # 5-second buffer past run_at. Compute the cutoff in Ruby (run_at < now - 5s)
20
+ # instead of a SQL "interval" literal, which is PostgreSQL-only and would raise
21
+ # on MySQL/SQLite — silently masking the whole backlog as 0.
22
+ query = ::Delayed::Job.where("run_at < ?", now - 5)
20
23
  query = query.where("failed_at IS NULL")
21
24
  query = query.where("locked_at IS NULL")
22
25
  query = query.where("priority >= ?", min_priority) unless min_priority.zero?
@@ -80,7 +80,7 @@ module TjScaleRuby
80
80
  private
81
81
 
82
82
  def web_queue_time_ms_for_payload
83
- snap = TjScaleRuby.last_web_queue_time_ms
83
+ snap = TjScaleRuby.flush_web_queue_time_ms!
84
84
  return snap unless snap.nil?
85
85
 
86
86
  env = ENV["TJ_SCALE_QUEUE_TIME_MS"]
@@ -29,14 +29,27 @@ module TjScaleRuby
29
29
  [now_ms - start_ms, 0].max
30
30
  end
31
31
 
32
+ # Heroku's router sends a bare unix-millisecond integer ("1751376000123"); nginx
33
+ # sends a "t=" prefixed value in seconds ("t=1751376000.123"). Match the prefixed
34
+ # form first, then fall back to a bare leading number, so the primary deployment
35
+ # target (Heroku) is not silently unparsed — a nil here means +queue_time_ms+ is
36
+ # never reported and web autoscaling on that metric can never fire.
32
37
  def self.request_start_epoch_ms(env)
33
38
  raw = env["HTTP_X_REQUEST_START"]
34
- return nil if raw.nil? || raw.strip.empty?
39
+ return nil if raw.nil?
35
40
 
36
- m = raw.to_s.match(/t=(\d+(?:\.\d+)?)/i)
41
+ s = raw.to_s.strip
42
+ return nil if s.empty?
43
+
44
+ # Heroku's router sends a bare millisecond epoch ("1731523200000"); nginx and
45
+ # some proxies send "t=<seconds>[.<frac>]". Accept both — prefer the +t=+ capture
46
+ # when present, otherwise fall back to the leading numeric timestamp.
47
+ m = s.match(/t=(\d+(?:\.\d+)?)/i) || s.match(/(\d+(?:\.\d+)?)/)
37
48
  return nil unless m
38
49
 
39
50
  v = m[1].to_f
51
+ return nil unless v.positive?
52
+
40
53
  v *= 1000 if v < 1_000_000_000_000 # seconds (or fractional) -> ms
41
54
  v.round
42
55
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Version module for TjScaleRuby gem
4
4
  module TjScaleRuby
5
- VERSION = "1.0.2"
5
+ VERSION = "1.1.1"
6
6
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tj-scale
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tanuj
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-12 00:00:00.000000000 Z
10
+ date: 2026-07-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -57,6 +57,20 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '4.1'
60
+ - !ruby/object:Gem::Dependency
61
+ name: sidekiq
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '7.3'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '7.3'
60
74
  - !ruby/object:Gem::Dependency
61
75
  name: bundler
62
76
  requirement: !ruby/object:Gem::Requirement