snerdmq 0.3.1 → 0.3.3

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: 7b03b1fe8237e88a2fac994242af4955576569853c3794dbc49671644c506921
4
- data.tar.gz: be2955f250acc99685168e2b5f9193faa5e9b8910fa5b7b209f3d027f96b7383
3
+ metadata.gz: 85e9f934b6976cdbd8c95718707762e2da87e93ea53448a51b3983b40d145f26
4
+ data.tar.gz: 82d99ccce4066f516da45f536e36f8539d569cdf47d1a74442b1557086830243
5
5
  SHA512:
6
- metadata.gz: 27ddbdaa2acb42240c500878c509affb9d32e7acf9e4ffac3679b2a80cb1039582420670cfc11092bceb872cf347c7ffc9436bef2346aada6a0dcaada070da7d
7
- data.tar.gz: ebbe4b904bdd68cd8750aaa339cc29cffa32437431686b36ca69547c95abf831b3faed39d1987544e614044fc729bbcd78c98fb66f3b869f9be8880eb12a69bd
6
+ metadata.gz: b18cee9ae796c2c612d44bea8c6aa0e81725e4a27f7f4001bb4c1d66acafc89ea2766c21e573dedebf45a8689b4b2a60abc885ad0d13a632de4da3d9934e2d5c
7
+ data.tar.gz: 815dc8ba1eb2977aba024af95fba389753a0428d3483c3db438e8ced86fada4666c0375d7d6d4b032a3705ac2f8c9fe30aea5c82d9967adc1260c17cfcf53d86
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ pull_request:
7
+ branches: [ "main" ]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Ruby
17
+ uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: '3.2'
20
+ bundler-cache: true
21
+
22
+ - name: Run tests
23
+ run: bundle exec rake test
data/README.md CHANGED
@@ -1,22 +1,24 @@
1
1
  <div align="center">
2
2
  <img src="./assets/Designer-9.png" height="120" alt="SnerdMQ Ruby Logo" />
3
- <h1>💎 SnerdMQ Ruby SDK v0.3.1</h1>
3
+ <h1>💎 SnerdMQ Ruby SDK v0.3.3</h1>
4
4
  <p>A zero-config, C-speed background job queue for Ruby. Ditch Redis and Sidekiq for lightweight, persistent background jobs.</p>
5
5
 
6
6
  [![Gem Version](https://badge.fury.io/rb/snerdmq.svg)](https://badge.fury.io/rb/snerdmq)
7
+ [![Docs](https://img.shields.io/badge/docs-speed--nerd.github.io-blue)](https://speed-nerd.github.io/docs/)
7
8
  </div>
8
9
 
9
10
  This is the official Ruby SDK wrapper for **SnerdMQ**. It handles all JSON-RPC communication and `IO.popen` orchestration so you can write lightning-fast background jobs without managing any external databases like Redis or Postgres.
10
11
 
11
- ## ✨ v0.3.1 AI Features
12
+ ## ✨ v0.3.3 AI Features
12
13
  - **Smart API Rate-Limiting**: Natively tracks `rate_limit_group` execution velocity to prevent 429 "Too Many Requests" API errors.
13
14
  - **Payload-Hashing Deduplication**: Automatically computes cryptographic hashes to drop duplicate tasks instantly.
14
15
  - **Dynamic Float Prioritization**: A native Binary Max-Heap bypasses standard FIFO rules for high urgency tasks.
16
+ - **Progress Streaming & Live Dashboard**: Handlers can stream progress updates to a built-in React UI dashboard served by the SDK.
15
17
  - **Ditch Sidekiq & Redis**: Gives your Ruby apps persistent state, automatic retries, and dead-letter queues right out of the box with zero external infrastructure.
16
18
  - **Zero Rust Required**: Our gem installation script automatically downloads the pre-compiled C-speed Rust binary for your OS.
17
19
  - **Thread Safe**: Uses native Ruby `Thread`s and `Mutex` locks to orchestrate I/O without blocking your main event loop.
18
20
 
19
- ### ⚙️ Advanced Task Configuration (v0.3.1)
21
+ ### ⚙️ Advanced Task Configuration (v0.3.3)
20
22
  To power complex AI workflows, tasks can now be configured with advanced orchestration parameters:
21
23
 
22
24
  * **`auto_dedupe` (`true/false`)**: If set to `true`, the daemon computes a cryptographic hash of the `task_type` and `data`. If an identical payload is currently sitting in the queue pending execution, this new task is silently dropped. Excellent for preventing duplicate generative AI requests from trigger-happy users!
@@ -24,6 +26,7 @@ To power complex AI workflows, tasks can now be configured with advanced orchest
24
26
  * **`rate_limit_group` (`String`)**: A custom string (e.g. `"openai_api"` or `"db_writes"`) that groups tasks together for backpressure control.
25
27
  * **`max_per_minute` (`Integer`)**: Used in conjunction with `rate_limit_group`. If the queue processes more tasks in this group than the allowed limit within a 60-second rolling window, further tasks in this group are temporarily paused. This natively prevents 429 "Too Many Requests" errors when bursting third-party APIs.
26
28
  * **`execute_at` (`String` | `Time`)**: A timestamp of when the job should be executed in the future.
29
+ * **`retry_after_hours` (`Float`)**: Backoff in **hours** before a failed job is retried (default `0.0`). See *Cron Jobs vs. Retryable Jobs* below.
27
30
  * **`cron` (`String`)**: A cron expression (e.g. `"0 * * * *"`) for recurring jobs. Shorthands like `"2h"` or `"10m"` are also supported.
28
31
  * **`webhook_url` (`String`)**: By providing a webhook URL, SnerdMQ will completely bypass your local Ruby blocks and dispatch the task payload via an HTTP POST request directly to the specified URL.
29
32
  * **`max_execution_seconds` (`Integer`)**: Optional hard timeout in seconds. If execution takes longer, it's marked as failed.
@@ -83,20 +86,28 @@ end
83
86
  queue.start_listening
84
87
  puts "SnerdMQ Ruby SDK is listening for jobs..."
85
88
 
86
- # 4. Enqueue a job from anywhere in your codebase (Now with v0.2.1 AI Features!)
89
+ # 4. Enqueue a job from anywhere in your codebase
87
90
  queue.enqueue(
88
91
  task_id: "email-123",
89
92
  task_type: "send_email",
90
93
  data: { "to" => "john@wick.com", "subject" => "Continental Update" },
91
94
  max_retries: 3,
92
- retry_after_hours: 0.0,
95
+ retry_after_hours: 0.5, # Wait 30 minutes before retrying a failed job
93
96
  rate_limit_group: "email_api",
94
- max_per_minute: 100,
95
- auto_dedupe: true,
96
- urgency_score: 0.99,
97
- cron: "1h",
98
- webhook_url: "https://api.example.com/webhook",
99
- max_execution_seconds: 300
97
+ max_per_minute: 100
98
+ )
99
+
100
+ # 5. Need scheduling, deduplication, or serverless execution? All
101
+ # orchestration options are opt-in — combine only what you need:
102
+ queue.enqueue(
103
+ task_id: "email-digest-1",
104
+ task_type: "send_email",
105
+ data: { "to" => "john@wick.com", "subject" => "Daily Digest" },
106
+ cron: "0 8 * * *", # Run every day at 08:00
107
+ auto_dedupe: true, # Drop identical pending payloads
108
+ urgency_score: 0.99, # Float to the front of the queue
109
+ webhook_url: "https://api.example.com/webhook", # Execute via HTTP instead of local blocks
110
+ max_execution_seconds: 300 # Hard timeout
100
111
  )
101
112
 
102
113
  # Keep main thread alive
@@ -105,7 +116,7 @@ sleep
105
116
 
106
117
  ### ☠️ Dead Letter Queue (Handling Permanent Failures)
107
118
 
108
- When a task fails repeatedly and exhausts its `maxRetries`, the SnerdMQ daemon permanently moves it to the Dead Letter Queue. You can hook into this event to alert your team, update your database, or send a Slack message by registering a Max Retry Handler.
119
+ When a task fails repeatedly and exhausts its `max_retries`, the SnerdMQ daemon permanently moves it to the Dead Letter Queue. You can hook into this event to alert your team, update your database, or send a Slack message by registering a Max Retry Handler.
109
120
 
110
121
  ```ruby
111
122
  # 5. Catch tasks that have permanently failed (Dead Letter Queue)
@@ -116,18 +127,117 @@ end
116
127
 
117
128
  ---
118
129
 
119
- ## 🌍 Advanced: Distributed Scaling
130
+ ## 📊 Live Dashboard
131
+
132
+ SnerdMQ ships with a built-in **React UI dashboard** served directly by the SDK — no extra services or ports to manage in your infrastructure. It gives you a real-time window into your queue:
133
+
134
+ - **Live stats**: total enqueued, processed, and failed jobs
135
+ - **Recent Jobs table**: per-task status (`queued`, `active`, `completed`, `failed`, `dead_letter`), retry counts, and badges showing which features a task uses (cron / webhook / timeout)
136
+ - **Real-time Progress Stream**: live output from `yield_progress` calls in your handlers
137
+
138
+ ```ruby
139
+ queue = Snerdmq::SnerdQueue.new
140
+
141
+ # Start the built-in dashboard on http://localhost:9090
142
+ queue.start_dashboard(port: 9090)
143
+
144
+ # ... register handlers, start listening, enqueue jobs ...
145
+ ```
146
+
147
+ Then open **http://localhost:9090** in your browser. The dashboard UI automatically uses HTTP polling to stay up to date (progress events included), and the SDK also exposes a small JSON API (`/api/stats`, `/api/tasks`, `/api/progress`) if you want to build your own tooling on top. The dashboard assets ship inside the gem — nothing extra to deploy.
148
+
149
+ > **Note:** `start_dashboard` only serves the UI — your jobs keep running whether or not the dashboard is open.
150
+
151
+ ---
152
+
153
+ ## 📡 Progress Reporting
154
+
155
+ Long-running handlers can stream live updates to the Dashboard's Progress Stream (ideal for streaming LLM tokens or multi-step ETL work):
156
+
157
+ ```ruby
158
+ queue.register_handler("generate_report") do |data|
159
+ (1..10).each do |step|
160
+ do_work(step)
161
+ queue.yield_progress("Step #{step}/10 complete")
162
+ end
163
+ end
164
+ ```
165
+
166
+ > `yield_progress` must be called **inside a task handler** — the SDK tracks which task is currently executing so each update lands on the right job in the dashboard.
167
+
168
+ ---
169
+
170
+ ## 🧩 Queue Topology: One Queue or Many?
171
+
172
+ ### ✅ Recommended: one queue, all job types (singleton)
173
+
174
+ Each `Snerdmq::SnerdQueue` client spawns its own Rust daemon and **exclusively owns** its storage directory (`.snerdata` by default). The recommended pattern is **one client per application process**: register every job type on it and serve a single shared dashboard:
175
+
176
+ ```ruby
177
+ require 'snerdmq'
178
+
179
+ # ONE queue client for the whole app
180
+ queue = Snerdmq::SnerdQueue.new
181
+
182
+ # Job type #1: image processing
183
+ queue.register_handler("process_image") do |data|
184
+ puts "Processing image: #{data['image_id']}"
185
+ end
186
+
187
+ # Job type #2: OTP emails — same queue, same daemon
188
+ queue.register_handler("send_otp_email") do |data|
189
+ puts "Sending OTP to: #{data['to']}"
190
+ end
191
+
192
+ queue.start_listening
193
+
194
+ # Both job types flow through the exact same queue
195
+ queue.enqueue(task_id: "img-1", task_type: "process_image", data: { "image_id" => "abc123" }, max_retries: 3, retry_after_hours: 0.5)
196
+ queue.enqueue(task_id: "otp-1", task_type: "send_otp_email", data: { "to" => "john@wick.com" }, max_retries: 3, retry_after_hours: 0.5)
197
+
198
+ # ONE dashboard shows every job type
199
+ queue.start_dashboard(port: 8080)
200
+ ```
201
+
202
+ All job types share everything: the same persistent job log, retry/DLQ pipeline, rate-limit state, stats — and one dashboard at `http://localhost:8080` showing all of them.
203
+
204
+ ### 🚫 Same storage twice = fails fast
120
205
 
121
- By default, the SDK spins up the Rust daemon which writes the queue to a local file (`.snerdata/tasks/tasks.log`).
206
+ The daemon takes an **exclusive OS-level lock** on its storage directory at startup. A second client on the same storage fails instead of silently double-executing your jobs:
122
207
 
123
- If you have multiple Ruby microservices (or Rails instances) running behind a load balancer and want them to share the exact same queue, simply mount a **Shared Network Drive** (like AWS EFS or NFS) to all of your servers and pass the shared path:
208
+ ```ruby
209
+ first = Snerdmq::SnerdQueue.new # ✅ owns .snerdata
210
+ second = Snerdmq::SnerdQueue.new # ❌ daemon refuses to start:
211
+ # "Another daemon is already running on storage '.snerdata'"
212
+ ```
213
+
214
+ This applies across processes too — with **Puma/Unicorn clustered workers, every worker is a separate process** that spawns its own daemon, so each worker needs its own `storage_path` (or run a single dedicated worker process for jobs).
215
+
216
+ ### 🔀 Need multiple queues? Give each one its own storage
217
+
218
+ ```ruby
219
+ images = Snerdmq::SnerdQueue.new(storage_path: ".snerdata-images")
220
+ emails = Snerdmq::SnerdQueue.new(storage_path: ".snerdata-emails")
221
+
222
+ images.start_dashboard(port: 8080) # separate dashboards, so separate ports
223
+ emails.start_dashboard(port: 8081)
224
+ ```
225
+
226
+ Now you have two fully independent engines: separate job logs, separate rate-limit state, separate dashboards. Only split when you actually need isolation (different teams, different retention, independent monitoring) — otherwise the singleton is simpler and recommended.
227
+
228
+ ---
229
+
230
+ ## 🌍 Advanced: Distributed Scaling
231
+
232
+ Because the daemon exclusively locks its storage directory, scaling horizontally means **one queue per server**, each with its own storage. Your load balancer routes requests across servers, and every server processes the jobs it enqueued:
124
233
 
125
234
  ```ruby
126
235
  require 'snerdmq'
127
236
 
128
- # All of your Ruby servers point to the exact same shared file!
129
- # SnerdMQ's native OS file-locking guarantees zero data corruption.
130
- queue = Snerdmq::SnerdQueue.new(storage_path: "/mnt/aws-efs-shared-drive/snerd_tasks.log")
237
+ # Each server runs its own daemon on its own storage dir (local disk works fine)
238
+ queue = Snerdmq::SnerdQueue.new(storage_path: "/var/data/snerd") # per-server storage
131
239
  ```
132
240
 
241
+ A shared network drive (AWS EFS or NFS) is still a good home for that storage when a single instance needs durable state — e.g. a container that restarts but must keep its queue. Native OS file locking (`flock`) keeps writes safe — no Redis required.
242
+
133
243
  *Built with ❤️ for John Wick tier engineering.*
data/bin/snerdmq-install CHANGED
@@ -5,7 +5,7 @@ require 'uri'
5
5
  require 'fileutils'
6
6
  require 'rbconfig'
7
7
 
8
- REPO = "greyhands2/snerdmq"
8
+ REPO = "speed-nerd/snerdmq"
9
9
  VERSION = "v0.1.1"
10
10
 
11
11
  # Determine OS and Architecture
data/lib/snerdmq/queue.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require 'json'
2
2
  require 'thread'
3
3
  require 'timeout'
4
+ require 'time'
4
5
 
5
6
  module Snerdmq
6
7
  class SnerdQueue
@@ -134,18 +135,6 @@ module Snerdmq
134
135
  @io.close if @io && !@io.closed?
135
136
  end
136
137
 
137
- private
138
-
139
- def send_message(msg)
140
- @stdin_mutex.synchronize do
141
- return if @shutting_down || @io.nil? || @io.closed?
142
- @io.puts(msg.to_json)
143
- @io.flush
144
- end
145
- rescue Errno::EPIPE
146
- # Broken pipe if the daemon died unexpectedly
147
- end
148
-
149
138
  def listen_to_stdout
150
139
  @io.each_line do |line|
151
140
  next if line.strip.empty?
@@ -178,6 +167,9 @@ module Snerdmq
178
167
  warn "[Snerd] Error from engine: #{msg['message']}"
179
168
  end
180
169
  elsif msg["action"] == "progress"
170
+ # Persist progress events so the dashboard (which falls back to
171
+ # HTTP polling in Ruby) can display them in the Progress Stream.
172
+ append_progress_event(msg)
181
173
  @ws_mutex.synchronize do
182
174
  @ws_clients.each do |ws|
183
175
  ws.send(msg.to_json)
@@ -275,6 +267,7 @@ module Snerdmq
275
267
 
276
268
  def start_dashboard(port: 8080)
277
269
  require 'rack'
270
+ require 'puma'
278
271
  require 'faye/websocket'
279
272
  require 'json'
280
273
 
@@ -305,26 +298,43 @@ module Snerdmq
305
298
  else
306
299
  return [404, {}, ['Dashboard UI not found']]
307
300
  end
301
+ elsif req.get? && req.path == '/api/progress'
302
+ events = []
303
+ progress_path = File.join(@storage_path || './.snerdata', 'progress_events.log')
304
+ if File.exist?(progress_path)
305
+ File.readlines(progress_path).last(100).each do |line|
306
+ begin
307
+ ev = JSON.parse(line)
308
+ events << ev if ev.is_a?(Hash) && ev['ts']
309
+ rescue
310
+ end
311
+ end
312
+ end
313
+ return [200, { 'Content-Type' => 'application/json' }.merge(cors_headers), [events.to_json]]
308
314
  elsif req.get? && req.path == '/api/stats'
309
315
  stats = { enqueued: 0, processed: 0, failed: 0 }
316
+ tasks_map = {}
310
317
  tasks_path = File.join(@storage_path || './.snerdata', 'tasks', 'tasks.log')
311
318
  if File.exist?(tasks_path)
312
319
  File.readlines(tasks_path).each do |line|
313
320
  next if line.strip.empty?
314
321
  begin
315
322
  t = JSON.parse(line)
316
- stats[:enqueued] += 1
317
- if t['deletedAt']
318
- if t['lastJobError']
319
- stats[:failed] += 1
320
- else
321
- stats[:processed] += 1
322
- end
323
- end
323
+ tasks_map[t['taskId']] = t if t['taskId']
324
324
  rescue
325
325
  end
326
326
  end
327
327
  end
328
+ tasks_map.values.each do |t|
329
+ stats[:enqueued] += 1
330
+ if t['deletedAt']
331
+ if t['LastJobError']
332
+ stats[:failed] += 1
333
+ else
334
+ stats[:processed] += 1
335
+ end
336
+ end
337
+ end
328
338
  return [200, { 'Content-Type' => 'application/json' }.merge(cors_headers), [stats.to_json]]
329
339
  elsif req.get? && req.path == '/api/tasks'
330
340
  tasks_map = {}
@@ -343,9 +353,23 @@ module Snerdmq
343
353
  formatted = []
344
354
  tasks_map.values.each do |t|
345
355
  if t['deletedAt']
346
- status = t['lastJobError'] ? 'failed' : 'completed'
356
+ if t['LastJobError'] && (t['retryCount'] || 0) >= (t['maxRetries'] || 3)
357
+ status = 'dead_letter'
358
+ elsif t['LastJobError']
359
+ status = 'failed'
360
+ else
361
+ status = 'completed'
362
+ end
363
+ elsif t['LastJobError']
364
+ status = 'failed'
347
365
  else
348
- status = t['lastJobError'] ? 'failed' : 'queued'
366
+ status = 'queued'
367
+ if t['executeAt']
368
+ begin
369
+ status = 'active' if Time.parse(t['executeAt']) <= Time.now
370
+ rescue
371
+ end
372
+ end
349
373
  end
350
374
  formatted << {
351
375
  id: t['taskId'],
@@ -354,7 +378,10 @@ module Snerdmq
354
378
  progress: 0,
355
379
  retryCount: t['retryCount'] || 0,
356
380
  maxRetries: t['maxRetries'] || 3,
357
- retryAfterTime: t['retryAfterTime']
381
+ retryAfterTime: t['retryAfterTime'],
382
+ cronExpression: t['cronExpression'],
383
+ webhookUrl: t['webhookUrl'],
384
+ maxExecutionSeconds: t['maxExecutionSeconds']
358
385
  }
359
386
  end
360
387
  return [200, { 'Content-Type' => 'application/json' }.merge(cors_headers), [formatted.first(50).to_json]]
@@ -365,9 +392,39 @@ module Snerdmq
365
392
 
366
393
  Thread.new do
367
394
  puts "[Snerd] Dashboard running on http://localhost:#{port}"
368
- Rack::Handler::Puma.run(app, Port: port, Silent: true)
395
+ server = Puma::Server.new(app)
396
+ server.add_tcp_listener('0.0.0.0', port)
397
+ server.run
398
+ end
399
+ end
400
+
401
+ private
402
+
403
+ def append_progress_event(msg)
404
+ dir = @storage_path || './.snerdata'
405
+ return unless File.directory?(dir)
406
+ path = File.join(dir, 'progress_events.log')
407
+
408
+ event = { ts: Time.now.to_f, task_id: msg['task_id'], data: msg['data'] }.to_json
409
+ File.open(path, 'a') { |f| f.puts(event) }
410
+
411
+ # Keep the file bounded: retain only the most recent events
412
+ if File.size(path) > 512 * 1024
413
+ lines = File.readlines(path).map(&:strip).reject(&:empty?)
414
+ File.write(path, lines.last(200).join("\n") + "\n")
369
415
  end
416
+ rescue
417
+ # Never break the listener loop over progress persistence
370
418
  end
371
419
 
420
+ def send_message(msg)
421
+ @stdin_mutex.synchronize do
422
+ return if @shutting_down || @io.nil? || @io.closed?
423
+ @io.puts(msg.to_json)
424
+ @io.flush
425
+ end
426
+ rescue Errno::EPIPE, Errno::EIO, IOError
427
+ # Broken pipe / IO error if the daemon died unexpectedly
428
+ end
372
429
  end
373
- end
430
+ end
data/snerdmq.gemspec CHANGED
@@ -3,13 +3,13 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
3
 
4
4
  Gem::Specification.new do |spec|
5
5
  spec.name = "snerdmq"
6
- spec.version = "0.3.1"
6
+ spec.version = "0.3.3"
7
7
  spec.authors = ["Greyhands2"]
8
8
  spec.email = ["developer@example.com"]
9
9
 
10
10
  spec.summary = "A zero-config, persistent background job queue for Ruby."
11
11
  spec.description = "The official Ruby SDK for the SnerdMQ Rust daemon. Execute robust, lightning-fast background jobs without Redis."
12
- spec.homepage = "https://github.com/greyhands2/snerdmq-ruby"
12
+ spec.homepage = "https://github.com/speed-nerd/snerdmq-ruby"
13
13
  spec.license = "MIT"
14
14
 
15
15
  spec.files = Dir.chdir(File.expand_path(__dir__)) do