Skip to content

Commit a8811a7

Browse files
authored
Merge pull request #28 from kpumuk/demo-perf
Optimize the demo stand
2 parents 2e7e0f8 + e497e34 commit a8811a7

12 files changed

Lines changed: 199 additions & 69 deletions

File tree

README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,24 +111,30 @@ This will ensure we do not push any dependencies with known vulnerabilities, and
111111
There is a test environment prepared in the `demo/` directory. Simply start it with:
112112

113113
```bash
114-
docker-compose up --build
114+
mise run demo
115+
mise run demo-sidekiq73
116+
mise run demo-sidekiq80
117+
mise run demo-sidekiq81
115118
```
116119

117120
This will:
118121

119122
- Start a Redis server
120-
- Start Sidekiq servers for different Sidekiq versions with some demo jobs (with Web interface)
123+
- Start one Sidekiq demo stack for the selected version, with demo jobs and the Web interface
121124

122-
| Sidekiq | Redis URL | Web Dashboard |
123-
| --------- | -------------------------- | --------------------- |
124-
| **7.3.x** | `redis://localhost:6379/0` | http://localhost:9292 |
125-
| **8.0.0** | `redis://localhost:6379/1` | http://localhost:9293 |
126-
| **8.1.0** | `redis://localhost:6379/2` | http://localhost:9294 |
125+
| Command | Sidekiq | Redis URL | Web Dashboard |
126+
| ------------------------- | --------- | -------------------------- | --------------------- |
127+
| `mise run demo` | **8.1.0** | `redis://localhost:6379/0` | http://localhost:9292 |
128+
| `mise run demo-sidekiq73` | **7.3.x** | `redis://localhost:6379/2` | http://localhost:9294 |
129+
| `mise run demo-sidekiq80` | **8.0.0** | `redis://localhost:6379/1` | http://localhost:9293 |
130+
| `mise run demo-sidekiq81` | **8.1.0** | `redis://localhost:6379/0` | http://localhost:9292 |
131+
132+
The version-specific tasks can run in parallel because each demo stand uses its own Redis database and dashboard port.
127133

128134
You can connect to a specific Sidekiq version using:
129135

130136
```bash
131-
go run ./cmd/lazykiq --redis redis://localhost:6379/2
137+
go run ./cmd/lazykiq --redis redis://localhost:6379/1
132138
```
133139

134140
### Website

demo/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM ruby:3.4-alpine
1+
FROM ruby:4.0.2-alpine@sha256:e973e9aa7573233432565798734a891d648b8f9f6ae20d57db7338b63c35db02
22

33
ARG BUNDLE_GEMFILE=Gemfile
44

demo/README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22

33
A demo environment for testing Sidekiq TUI tools. Generates continuous job traffic across multiple queues with realistic failure scenarios.
44

5+
The current profile is intentionally lightweight: older Sidekiq versions stay compact, while the 8.1 demo uses five thin worker processes to stretch the process-oriented UI without bringing back the original CPU load.
6+
57
## Quick Start
68

79
```bash
8-
docker-compose up --build
10+
mise run demo
11+
mise run demo-sidekiq73
12+
mise run demo-sidekiq80
13+
mise run demo-sidekiq81
914
```
1015

11-
Web UI: http://localhost:9292
16+
`mise run demo` starts the latest Sidekiq demo (8.1) on `http://localhost:9292` with `redis://localhost:6379/0`.
17+
`mise run demo-sidekiq73` starts Sidekiq 7.3 on `http://localhost:9294` with `redis://localhost:6379/2`.
18+
`mise run demo-sidekiq80` starts Sidekiq 8.0 on `http://localhost:9293` with `redis://localhost:6379/1`.
19+
`mise run demo-sidekiq81` starts Sidekiq 8.1 on `http://localhost:9292` with `redis://localhost:6379/0`.
20+
21+
The three tasks can run in parallel because each demo stand uses its own Redis database and dashboard port.
1222

1323
## Structure
1424

@@ -29,15 +39,15 @@ Web UI: http://localhost:9292
2939

3040
**5 Queues** (by priority): critical, default, mailers, batch, low
3141

32-
**Limits**: 10k jobs/queue, 20k retry queue max
42+
**Limits**: 1.5k jobs/queue, 1k retry queue max, 500 scheduled max
3343

3444
**Extra cases**:
3545
- ActiveJob-wrapped jobs with GlobalID-serialized arguments
3646
- ActionMailer-wrapped jobs with arguments (including GlobalID)
3747
- Tagged jobs sharing the same 5 tags (intersection)
3848
- **DataSyncJob** uses `Sidekiq::IterableJob` to process entity IDs one at a time:
3949
- Iterates over 1-10 entity IDs per job
40-
- Random sleep duration per iteration (0.2-5s)
50+
- Random sleep duration per iteration (0.2-1.0s)
4151
- 8% failure rate per iteration for realistic retry behavior
4252
- Demonstrates job interruption and resumption on worker restart
4353

demo/config/sidekiq.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
:concurrency: 8
2+
:concurrency: 4
33
:queues:
44
- [critical, 10]
55
- [default, 5]

demo/docker-compose.yml

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
1+
x-sidekiq-service: &sidekiq-service
2+
build:
3+
context: .
4+
depends_on:
5+
redis:
6+
condition: service_healthy
7+
volumes:
8+
- .:/app
9+
110
services:
211
redis:
312
image: redis:7-alpine
4-
command: ["redis-server", "--save", ""]
13+
command:
14+
[
15+
"redis-server",
16+
"--save",
17+
"",
18+
"--hz",
19+
"1",
20+
"--dynamic-hz",
21+
"no",
22+
"--latency-tracking",
23+
"no",
24+
"--slowlog-log-slower-than",
25+
"-1"
26+
]
527
ports:
628
- "6379:6379"
729
healthcheck:
@@ -10,47 +32,38 @@ services:
1032
timeout: 3s
1133
retries: 5
1234

13-
sidekiq-7.3:
35+
sidekiq-8.1:
36+
<<: *sidekiq-service
1437
build:
1538
context: .
1639
args:
17-
BUNDLE_GEMFILE: Gemfile.sidekiq-7.3
18-
depends_on:
19-
redis:
20-
condition: service_healthy
40+
BUNDLE_GEMFILE: Gemfile.sidekiq-8.1
2141
environment:
42+
LAZYKIQ_DEMO_LAYOUT: stretched
2243
REDIS_URL: redis://redis:6379/0
2344
ports:
2445
- "9292:9292"
25-
volumes:
26-
- .:/app
2746

2847
sidekiq-8.0:
48+
<<: *sidekiq-service
2949
build:
3050
context: .
3151
args:
3252
BUNDLE_GEMFILE: Gemfile.sidekiq-8.0
33-
depends_on:
34-
redis:
35-
condition: service_healthy
3653
environment:
54+
LAZYKIQ_DEMO_LAYOUT: compact
3755
REDIS_URL: redis://redis:6379/1
3856
ports:
3957
- "9293:9292"
40-
volumes:
41-
- .:/app
4258

43-
sidekiq-8.1:
59+
sidekiq-7.3:
60+
<<: *sidekiq-service
4461
build:
4562
context: .
4663
args:
47-
BUNDLE_GEMFILE: Gemfile.sidekiq-8.1
48-
depends_on:
49-
redis:
50-
condition: service_healthy
64+
BUNDLE_GEMFILE: Gemfile.sidekiq-7.3
5165
environment:
66+
LAZYKIQ_DEMO_LAYOUT: compact
5267
REDIS_URL: redis://redis:6379/2
5368
ports:
5469
- "9294:9292"
55-
volumes:
56-
- .:/app

demo/lib/jobs/data_sync_job.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def each_iteration(entity_id, source, destination, entity_ids)
2626
raise error, "Sync failed for entity #{entity_id} from #{source} to #{destination}"
2727
end
2828

29-
# Log progress (visible in Sidekiq logs)
30-
logger.info "Synced entity #{entity_id} from #{source} to #{destination} (#{duration.round(2)}s)"
29+
# Keep logs quiet; the dashboard already shows the live job mix.
30+
logger.debug { "Synced entity #{entity_id} from #{source} to #{destination} (#{duration.round(2)}s)" }
3131
end
3232
end

demo/scheduler.rb

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
class JobScheduler
88
QUEUES = %w[critical default mailers batch low unsafe unprocessed_1 unprocessed_2 unprocessed_3 unprocessed_4 unprocessed_5].freeze
9-
MAX_JOBS_PER_QUEUE = 10_000
10-
MAX_UNSAFE_JOBS = 42
11-
MAX_UNPROCESSED_JOBS = 5
12-
MAX_RETRY_QUEUE = 20_000
13-
MAX_SCHEDULED_JOBS = 5_000
14-
SCHEDULE_BATCH_SIZE = 100
9+
MAX_JOBS_PER_QUEUE = 1_500
10+
MAX_UNSAFE_JOBS = 12
11+
MAX_UNPROCESSED_JOBS = 2
12+
MAX_RETRY_QUEUE = 1_000
13+
MAX_SCHEDULED_JOBS = 500
14+
SCHEDULE_BATCH_SIZE = 12
15+
SCHEDULER_INTERVAL = 2.0
16+
SCHEDULED_REFRESH_EVERY = 4
1517
ACTIVEJOB_WRAPPER = "Sidekiq::ActiveJob::Wrapper"
1618
ACTION_MAILER_DELIVERY = "ActionMailer::MailDeliveryJob"
1719
COMMON_TAGS = %w[tag-alpha tag-bravo tag-charlie tag-delta tag-echo].freeze
@@ -152,6 +154,9 @@ def self.tagged_payload(job_class, queue, args, tags)
152154
def initialize
153155
@weighted_jobs = build_weighted_job_list
154156
@running = false
157+
@loop_count = 0
158+
@scheduled_sizes = Hash.new(0)
159+
@scheduled_total = 0
155160
end
156161

157162
def start
@@ -163,9 +168,11 @@ def start
163168
puts "Queues: #{QUEUES.join(", ")}"
164169

165170
while @running
171+
refresh_scheduled_sizes if refresh_scheduled_sizes?
166172
maintain_queues
167173
maintain_scheduled
168-
sleep 0.5
174+
@loop_count += 1
175+
sleep SCHEDULER_INTERVAL
169176
end
170177
end
171178

@@ -189,7 +196,7 @@ def maintain_queues
189196
end
190197

191198
queue_sizes = fetch_queue_sizes
192-
scheduled_sizes = fetch_scheduled_sizes
199+
scheduled_sizes = @scheduled_sizes
193200

194201
QUEUES.each do |queue_name|
195202
current_size = queue_sizes[queue_name] || 0
@@ -223,12 +230,11 @@ def schedule_jobs_for_queue(queue_name, count)
223230
end
224231

225232
def maintain_scheduled
226-
scheduled_size = Sidekiq::ScheduledSet.new.size
227233
queue_sizes = fetch_queue_sizes
228-
scheduled_sizes = fetch_scheduled_sizes
234+
scheduled_sizes = @scheduled_sizes
229235

230-
if scheduled_size < MAX_SCHEDULED_JOBS * 0.8
231-
jobs_to_add = [MAX_SCHEDULED_JOBS - scheduled_size, SCHEDULE_BATCH_SIZE].min
236+
if @scheduled_total < MAX_SCHEDULED_JOBS * 0.8
237+
jobs_to_add = [MAX_SCHEDULED_JOBS - @scheduled_total, SCHEDULE_BATCH_SIZE].min
232238
add_scheduled_jobs(jobs_to_add, queue_sizes, scheduled_sizes) if jobs_to_add > 0
233239
end
234240
end
@@ -249,6 +255,7 @@ def add_scheduled_jobs(count, queue_sizes, scheduled_sizes)
249255
delay = rand(1..86400) # Schedule between 1 second and 24 hours
250256
enqueue_job(job_def, delay: delay)
251257
scheduled_sizes[queue_name] = scheduled_size + 1
258+
@scheduled_total += 1
252259
added += 1
253260
end
254261

@@ -257,11 +264,24 @@ def add_scheduled_jobs(count, queue_sizes, scheduled_sizes)
257264

258265
def fetch_scheduled_sizes
259266
sizes = Hash.new(0)
267+
total = 0
260268
Sidekiq::ScheduledSet.new.each do |job|
261269
queue_name = job.item["queue"]
262-
sizes[queue_name] += 1 if queue_name
270+
next unless queue_name
271+
272+
sizes[queue_name] += 1
273+
total += 1
263274
end
264-
sizes
275+
@scheduled_sizes = sizes
276+
@scheduled_total = total
277+
end
278+
279+
def refresh_scheduled_sizes?
280+
@scheduled_sizes.empty? || (@loop_count % SCHEDULED_REFRESH_EVERY).zero?
281+
end
282+
283+
def refresh_scheduled_sizes
284+
fetch_scheduled_sizes
265285
end
266286

267287
def enqueue_job(job_def, delay: nil)

demo/start.rb

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@
44
require_relative "boot"
55
require_relative "scheduler"
66

7+
def worker_profiles_for(layout)
8+
case layout
9+
when "stretched"
10+
[
11+
{queues: ["default,5"], concurrency: 2},
12+
{queues: ["low,1"], concurrency: 1},
13+
{queues: ["critical,10"], concurrency: 2},
14+
{queues: ["mailers,5"], concurrency: 1},
15+
{queues: ["batch,3"], concurrency: 1, unsafe_capsule: true}
16+
]
17+
else
18+
[
19+
{queues: ["default,5", "low,1"], concurrency: 4},
20+
{queues: ["critical,10", "mailers,5", "batch,3"], concurrency: 4, unsafe_capsule: true}
21+
]
22+
end
23+
end
24+
725
# Determine mode
826
mode = ARGV[0] || "all"
927

@@ -34,23 +52,16 @@
3452

3553
pids = []
3654

37-
# Fork worker processes with 8 workers each, different queues per process
38-
worker_queues = [
39-
# Process 1: default and low queues (weighted)
40-
["default,5", "low,1"],
41-
# Process 2: default and low queues (weighted)
42-
["default,5", "low,1"],
43-
# Process 3: critical and mailers queues (weighted)
44-
["critical,10", "mailers,5"],
45-
# Process 4: batch queue only (weighted) with unsafe capsule
46-
["batch,3"]
47-
]
48-
49-
worker_queues.each_with_index do |queues, i|
55+
# Stretch the latest demo with more Sidekiq processes while keeping older
56+
# versions compact and cheap.
57+
worker_profiles = worker_profiles_for(ENV.fetch("LAZYKIQ_DEMO_LAYOUT", "compact"))
58+
59+
worker_profiles.each do |profile|
5060
pids << fork do
5161
cmd = ["bundle", "exec", "sidekiq", "-r", "./boot.rb", "-C", "config/sidekiq.yml"] +
52-
queues.flat_map { |q| ["-q", q] }
53-
if i == 3
62+
profile[:queues].flat_map { |q| ["-q", q] } +
63+
["-c", profile[:concurrency].to_s]
64+
if profile[:unsafe_capsule]
5465
exec({"LAZYKIQ_UNSAFE_CAPSULE" => "1"}, *cmd)
5566
else
5667
exec(*cmd)

internal/sidekiq/parse.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ func parseOptionalInt64(field any) (int64, bool) {
3838
return 0, false
3939
}
4040

41+
// parseOptionalInt parses various types to int with success indication.
42+
// Values outside the platform int range are rejected.
43+
func parseOptionalInt(field any) (int, bool) {
44+
parsed, ok := parseOptionalInt64(field)
45+
if !ok || parsed < math.MinInt || parsed > math.MaxInt {
46+
return 0, false
47+
}
48+
return int(parsed), true
49+
}
50+
4151
// parseOptionalFloat64 parses various types to float64 with success indication.
4252
func parseOptionalFloat64(field any) (float64, bool) {
4353
switch value := field.(type) {

internal/sidekiq/process.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ func (p *Process) refreshFromFields(fields []any) {
300300
}
301301
}
302302

303-
if busyCount, ok := parseOptionalInt64(fieldAt(fields, 1)); ok {
304-
p.Busy = int(busyCount)
303+
if busyCount, ok := parseOptionalInt(fieldAt(fields, 1)); ok {
304+
p.Busy = busyCount
305305
}
306306

307307
if beat, ok := parseOptionalFloat64(fieldAt(fields, 2)); ok {

0 commit comments

Comments
 (0)