whoosh 1.0.1 → 1.1.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: dcdd58c23ddbf6deaa47e53584d661eaad24ac247734aa7bf9cb3a3b3222aae3
4
- data.tar.gz: cb40914a6bcc3ed0bd53108320278b8cb339d00593ec5a8c312024aacc78c13f
3
+ metadata.gz: 03b756c310f71435839a3628bfdfa9499bb70869b9475e75eb6cce861caa9583
4
+ data.tar.gz: a848d792014e80cc6bee413ad14277881895a62a40854644f3a088bfe0ea164a
5
5
  SHA512:
6
- metadata.gz: 0f14ef114d78e7b220ad6afb8cbe90e2a5b9ff9493f1e549cb016c29f80c87b7e76bd281332f0244df04f79c7f0a63fc547e3892e00331f952a387fd008ca1db
7
- data.tar.gz: 55b556be2c0d39472350653331af872f16afd3f34fa1535d252f5cb033fffea60a57542a88dfb6cfd49f13933f77b6c9c7dda3c500878dbe8a0d32ff596fbc13
6
+ metadata.gz: b764fa79a9e8297c66e0ce0c56be1127c1d1780ccb3840a0868a55841c4cb1108efffeff90cdaf4105a7df9259764aff7a8afe1047a42809e0c039b1e74be11d
7
+ data.tar.gz: 297cc80e8ca6ba22df946e07204f114bda7634480f5af72520caa0bdfde7b8604e6b66bf1b081f3527bede997a320b9ffc1b44c14fba21c2a05e5778101451c5
data/README.md CHANGED
@@ -337,14 +337,65 @@ whoosh db status # migration status
337
337
 
338
338
  ## Performance
339
339
 
340
- | Benchmark | Result |
341
- |-----------|--------|
342
- | Simple JSON endpoint | **406K req/s** |
343
- | Schema-validated endpoint | **115K req/s** |
344
- | Router lookup (static) | **6.1M lookups/s** |
340
+ ### HTTP Benchmark: `GET /health → {"status":"ok"}`
341
+
342
+ > Apple Silicon arm64, 12 cores. [Full benchmark suite](benchmarks/comparison/)
343
+
344
+ **Single process** (fair 1:1 comparison):
345
+
346
+ | Framework | Language | Server | Req/sec |
347
+ |-----------|----------|--------|---------|
348
+ | Fastify | Node.js 22 | built-in | 69,200 |
349
+ | **Whoosh** | Ruby 3.4 +YJIT | **Falcon** | **24,400** |
350
+ | **Whoosh** | Ruby 3.4 +YJIT | Puma (5 threads) | **15,500** |
351
+ | FastAPI | Python 3.13 | uvicorn | 8,900 |
352
+ | Sinatra | Ruby 3.4 | Puma (5 threads) | 7,100 |
353
+ | PHP (raw) | PHP 8.5 | built-in | 2,000 |
354
+
355
+ > Whoosh + Falcon is **2.7x faster** than FastAPI single-core. Whoosh + Puma is **1.7x faster** than FastAPI. Use Falcon (recommended) for best performance.
356
+
357
+ **Multi-worker** (production deployment):
358
+
359
+ | Framework | Language | Server | Req/sec |
360
+ |-----------|----------|--------|---------|
361
+ | **Whoosh** | Ruby 3.4 +YJIT | **Falcon (4 workers)** | **87,400** |
362
+ | Fastify | Node.js 22 | built-in (single thread) | 69,200 |
363
+ | **Whoosh** | Ruby 3.4 +YJIT | Puma (4w×4t) | **52,500** |
364
+ | Roda | Ruby 3.4 | Puma (4w×4t) | 14,700 |
365
+
366
+ > **Note:** Fastify is single-threaded by design (Node.js event loop). It can scale via `cluster` module but was not tested in that mode. Whoosh + Falcon with 4 workers uses 4 cores.
367
+
368
+ ### Real-World Benchmark: `GET /users/:id` from PostgreSQL (1000 rows)
369
+
370
+ **Single process:**
371
+
372
+ | Framework | Language | Req/sec |
373
+ |-----------|----------|---------|
374
+ | Fastify + pg | Node.js 22 | 36,900 |
375
+ | **Whoosh + Falcon (fiber PG pool)** | Ruby 3.4 +YJIT | **13,400** |
376
+ | **Whoosh + Puma (Sequel)** | Ruby 3.4 +YJIT | **8,600** |
377
+ | Roda + Puma | Ruby 3.4 | 6,700 |
378
+ | Sinatra + Puma | Ruby 3.4 | 4,400 |
379
+ | FastAPI + uvicorn | Python 3.13 | 2,400 |
380
+
381
+ **Multi-worker (PostgreSQL):**
382
+
383
+ | Framework | Language | Req/sec |
384
+ |-----------|----------|---------|
385
+ | **Whoosh + Falcon (4 workers, fiber PG pool)** | Ruby 3.4 +YJIT | **45,900** |
386
+ | Fastify (single thread) | Node.js 22 | 36,900 |
387
+
388
+ > Whoosh + Falcon with fiber-aware PG pool is **5.6x faster** than FastAPI. Multi-worker Falcon **beats Fastify by 24%** on real PostgreSQL workloads.
389
+
390
+ ### Micro-benchmarks
391
+
392
+ | Component | Throughput |
393
+ |-----------|-----------|
394
+ | Router lookup (static, cached) | **6.1M ops/s** |
395
+ | JSON encode (Oj) | **5.4M ops/s** |
345
396
  | Framework overhead | **~2.5µs per request** |
346
397
 
347
- Optimizations: YJIT auto-enabled, Oj JSON auto-detected (5-10x faster), O(1) static route cache, pre-frozen headers, compiled middleware chain.
398
+ Optimizations: YJIT auto-enabled, Oj JSON auto-detected, O(1) static route cache, compiled middleware chain, pre-frozen headers.
348
399
 
349
400
  ## Configuration
350
401
 
data/lib/whoosh/app.rb CHANGED
@@ -276,8 +276,10 @@ module Whoosh
276
276
  register_doc_routes if @config.docs_enabled?
277
277
  register_metrics_route
278
278
  @router.freeze!
279
- inner = method(:handle_request)
280
- app = @middleware_stack.build(inner)
279
+
280
+ # Compile the entire middleware + handler into a single lambda
281
+ # This eliminates 4x nested method calls per request
282
+ app = build_compiled_handler
281
283
  start_job_workers
282
284
  @shutdown.register { @di.close_all }
283
285
  @shutdown.register { @mcp_manager.shutdown_all }
@@ -301,7 +303,7 @@ module Whoosh
301
303
  end
302
304
 
303
305
  def auto_configure_jobs
304
- backend = Jobs::MemoryBackend.new
306
+ backend = Jobs.build_backend(@config.data)
305
307
  Jobs.configure(backend: backend, di: @di)
306
308
  end
307
309
 
@@ -319,7 +321,7 @@ module Whoosh
319
321
  worker = Jobs::Worker.new(
320
322
  backend: Jobs.backend, di: @di,
321
323
  max_retries: max_retries, retry_delay: retry_delay,
322
- instrumentation: @instrumentation
324
+ instrumentation: @instrumentation, logger: @logger
323
325
  )
324
326
  thread = Thread.new { worker.run_loop }
325
327
  thread.abort_on_exception = false
@@ -368,6 +370,78 @@ module Whoosh
368
370
  @middleware_stack.use(Middleware::RequestLogger, logger: @logger, metrics: @metrics)
369
371
  end
370
372
 
373
+ # Compiled handler inlines all default middleware into a single lambda
374
+ # Eliminates nested method calls — ~3-4x faster than middleware stack
375
+ def build_compiled_handler
376
+ logger = @logger
377
+ metrics = @metrics
378
+ max_bytes = 1_048_576
379
+ security_headers = Middleware::SecurityHeaders::HEADERS
380
+
381
+ -> (env) {
382
+ # 1. RequestLimit — check content length
383
+ content_length = env["CONTENT_LENGTH"]&.to_i || 0
384
+ if content_length > max_bytes
385
+ return [413, { "content-type" => "application/json" },
386
+ [JSON.generate({ error: "request_too_large", max_bytes: max_bytes })]]
387
+ end
388
+
389
+ # 2. Request ID (from RequestLogger)
390
+ request_id = env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
391
+ env["whoosh.request_id"] = request_id
392
+
393
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
394
+
395
+ # 3. CORS preflight
396
+ origin = env["HTTP_ORIGIN"]
397
+ if env["REQUEST_METHOD"] == "OPTIONS" && origin
398
+ cors_headers = {
399
+ "access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
400
+ "access-control-allow-headers" => "Content-Type, Authorization, X-API-Key, X-Request-ID",
401
+ "access-control-max-age" => "86400",
402
+ "access-control-allow-origin" => "*",
403
+ "access-control-expose-headers" => "X-Request-ID",
404
+ "vary" => "Origin"
405
+ }
406
+ return [204, cors_headers, []]
407
+ end
408
+
409
+ # 4. Handle request (core)
410
+ status, headers, body = handle_request(env)
411
+
412
+ # Ensure headers are mutable (streaming returns frozen headers)
413
+ headers = headers.dup if headers.frozen?
414
+
415
+ # 5. Security headers (inline, no allocation)
416
+ security_headers.each { |k, v| headers[k] ||= v }
417
+
418
+ # 6. CORS headers
419
+ if origin
420
+ headers["access-control-allow-origin"] = "*"
421
+ headers["access-control-expose-headers"] = "X-Request-ID"
422
+ headers["vary"] = "Origin"
423
+ end
424
+
425
+ # 7. Request ID in response
426
+ headers["x-request-id"] = request_id
427
+
428
+ # 8. Logging + metrics
429
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
430
+ logger.info("request_complete",
431
+ method: env["REQUEST_METHOD"], path: env["PATH_INFO"],
432
+ status: status, duration_ms: duration_ms, request_id: request_id)
433
+
434
+ if metrics
435
+ metrics.increment("whoosh_requests_total",
436
+ labels: { method: env["REQUEST_METHOD"], path: env["PATH_INFO"], status: status.to_s })
437
+ metrics.observe("whoosh_request_duration_seconds",
438
+ duration_ms / 1000.0, labels: { path: env["PATH_INFO"] })
439
+ end
440
+
441
+ [status, headers, body]
442
+ }
443
+ end
444
+
371
445
  def register_mcp_tools
372
446
  @router.routes.each do |route|
373
447
  next unless route[:metadata] && route[:metadata][:mcp]
data/lib/whoosh/cache.rb CHANGED
@@ -1,4 +1,3 @@
1
- # lib/whoosh/cache.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Whoosh
@@ -6,20 +5,21 @@ module Whoosh
6
5
  autoload :MemoryStore, "whoosh/cache/memory_store"
7
6
  autoload :RedisStore, "whoosh/cache/redis_store"
8
7
 
8
+ # Auto-detect: REDIS_URL set → Redis, otherwise → Memory
9
9
  def self.build(config_data = {})
10
10
  cache_config = config_data["cache"] || {}
11
- store = cache_config["store"] || "memory"
12
11
  default_ttl = cache_config["default_ttl"] || 300
12
+ redis_url = ENV["REDIS_URL"] || cache_config["url"]
13
13
 
14
- case store
15
- when "memory"
16
- MemoryStore.new(default_ttl: default_ttl)
17
- when "redis"
18
- url = cache_config["url"] || "redis://localhost:6379"
19
- pool_size = cache_config["pool_size"] || 5
20
- RedisStore.new(url: url, default_ttl: default_ttl, pool_size: pool_size)
14
+ if redis_url && cache_config["store"] != "memory"
15
+ begin
16
+ RedisStore.new(url: redis_url, default_ttl: default_ttl)
17
+ rescue Errors::DependencyError
18
+ # Redis gem not installed, fall back to memory
19
+ MemoryStore.new(default_ttl: default_ttl)
20
+ end
21
21
  else
22
- raise ArgumentError, "Unknown cache store: #{store}"
22
+ MemoryStore.new(default_ttl: default_ttl)
23
23
  end
24
24
  end
25
25
  end
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "securerandom"
5
6
 
6
7
  module Whoosh
7
8
  module CLI
@@ -13,19 +14,47 @@ module Whoosh
13
14
  FileUtils.mkdir_p(File.join(dir, d))
14
15
  end
15
16
 
17
+ jwt_secret = SecureRandom.hex(32)
18
+
16
19
  write(dir, "app.rb", app_rb(name))
17
20
  write(dir, "config.ru", config_ru)
18
- write(dir, "Gemfile", gemfile(full: full))
19
- write(dir, "Rakefile", "require \"rspec/core/rake_task\"\nRSpec::Core::RakeTask.new(:spec)\ntask default: :spec\n")
21
+ write(dir, "Gemfile", gemfile(minimal: minimal, full: full))
22
+ write(dir, "Rakefile", rakefile)
20
23
  write(dir, "config/app.yml", app_yml(name))
21
- write(dir, "config/plugins.yml", plugins_yml_template)
24
+ write(dir, "config/plugins.yml", plugins_yml)
22
25
  write(dir, "endpoints/health.rb", health_endpoint)
23
26
  write(dir, "schemas/health.rb", health_schema)
24
27
  write(dir, "test/test_helper.rb", test_helper)
25
- write(dir, ".env.example", env_example_template)
26
- write(dir, "Dockerfile", dockerfile_template)
27
- write(dir, ".dockerignore", dockerignore_template)
28
- puts "Created #{name}/ — run `cd #{name} && bundle install && whoosh server`"
28
+ write(dir, ".env", env_file(jwt_secret))
29
+ write(dir, ".env.example", env_example)
30
+ write(dir, ".gitignore", gitignore)
31
+ write(dir, ".rspec", rspec_config)
32
+ write(dir, "Dockerfile", dockerfile)
33
+ write(dir, ".dockerignore", dockerignore)
34
+ write(dir, "README.md", readme(name))
35
+
36
+ # Create empty SQLite DB directory
37
+ FileUtils.mkdir_p(File.join(dir, "db"))
38
+
39
+ # Auto-run bundle install
40
+ puts "Created #{name}/"
41
+ puts ""
42
+ Dir.chdir(dir) do
43
+ puts "Installing dependencies..."
44
+ system("bundle install --quiet")
45
+ end
46
+ puts ""
47
+ puts " #{name}/ is ready!"
48
+ puts ""
49
+ puts " cd #{name}"
50
+ puts " whoosh s # start server at http://localhost:9292"
51
+ puts " whoosh s --reload # start with hot reload"
52
+ puts ""
53
+ puts " http://localhost:9292/health # health check"
54
+ puts " http://localhost:9292/healthz # health probes"
55
+ puts " http://localhost:9292/docs # Swagger UI"
56
+ puts " http://localhost:9292/metrics # Prometheus metrics"
57
+ puts ""
29
58
  end
30
59
 
31
60
  class << self
@@ -41,58 +70,147 @@ module Whoosh
41
70
 
42
71
  require "whoosh"
43
72
 
73
+ # Auto-enable YJIT + Oj for best performance
74
+ Whoosh::Performance.optimize!
75
+
44
76
  App = Whoosh::App.new
45
77
 
78
+ # --- API Documentation ---
46
79
  App.openapi do
47
- title "#{name.capitalize} API"
80
+ title "#{name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")} API"
48
81
  version "0.1.0"
49
82
  end
50
83
 
84
+ App.docs enabled: true, redoc: true
85
+
86
+ # --- Security ---
87
+ App.auth do
88
+ jwt secret: ENV["JWT_SECRET"], algorithm: :hs256, expiry: 3600
89
+ end
90
+
91
+ App.rate_limit do
92
+ default limit: 60, period: 60
93
+ end
94
+
95
+ # --- Health Check ---
96
+ App.health_check do
97
+ probe(:api) { true }
98
+ end
99
+
100
+ # --- Load Endpoints ---
51
101
  App.load_endpoints(File.join(__dir__, "endpoints"))
52
102
  RUBY
53
103
  end
54
104
 
55
105
  def config_ru
56
- "# frozen_string_literal: true\n\nrequire_relative \"app\"\n\nrun App.to_rack\n"
106
+ <<~RUBY
107
+ # frozen_string_literal: true
108
+
109
+ require_relative "app"
110
+
111
+ run App.to_rack
112
+ RUBY
57
113
  end
58
114
 
59
- def gemfile(full: false)
60
- g = "source \"https://rubygems.org\"\n\ngem \"whoosh\"\n"
115
+ def gemfile(minimal: false, full: false)
116
+ g = <<~GEM
117
+ source "https://rubygems.org"
118
+
119
+ gem "whoosh"
120
+
121
+ # Server (Falcon recommended for best performance)
122
+ gem "falcon"
123
+
124
+ # Fast JSON (5-10x faster than stdlib)
125
+ gem "oj"
126
+
127
+ # Database
128
+ gem "sequel"
129
+ gem "sqlite3"
130
+ GEM
131
+
61
132
  if full
62
- g += "\n# AI & NLP\ngem \"ruby_llm\"\ngem \"lingua-ruby\"\ngem \"ner-ruby\"\n\n# Database\ngem \"sequel\"\ngem \"sqlite3\"\n"
133
+ g += <<~GEM
134
+
135
+ # AI & NLP
136
+ gem "ruby_llm"
137
+ gem "lingua-ruby"
138
+ gem "ner-ruby"
139
+ gem "guardrails-ruby"
140
+ GEM
63
141
  end
64
- g += "\ngroup :development, :test do\n gem \"rspec\"\n gem \"rack-test\"\nend\n"
65
- end
66
142
 
67
- def app_yml(name)
68
- app_yml_template(name)
143
+ g += <<~GEM
144
+
145
+ group :development, :test do
146
+ gem "rspec"
147
+ gem "rack-test"
148
+ end
149
+ GEM
150
+
151
+ g
69
152
  end
70
153
 
71
- def env_example_template
72
- "# WHOOSH_PORT=9292\n# WHOOSH_ENV=development\n# DATABASE_URL=sqlite://db/development.sqlite3\n# REDIS_URL=redis://localhost:6379\n"
154
+ def rakefile
155
+ <<~RUBY
156
+ require "rspec/core/rake_task"
157
+ RSpec::Core::RakeTask.new(:spec)
158
+ task default: :spec
159
+ RUBY
73
160
  end
74
161
 
75
- def app_yml_template(name)
162
+ def app_yml(name)
76
163
  <<~YAML
77
164
  app:
78
- name: "#{name.capitalize} API"
165
+ name: "#{name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")} API"
79
166
  port: 9292
80
167
  host: localhost
81
168
 
169
+ server:
170
+ type: falcon
171
+ workers: auto
172
+
82
173
  database:
83
174
  url: <%= ENV.fetch("DATABASE_URL", "sqlite://db/development.sqlite3") %>
84
175
  max_connections: 10
176
+ log_level: debug
85
177
 
86
178
  cache:
87
179
  store: memory
88
180
  default_ttl: 300
89
181
 
182
+ jobs:
183
+ backend: memory
184
+ workers: 2
185
+ retry: 3
186
+
90
187
  logging:
91
188
  level: info
92
189
  format: json
93
190
 
94
191
  docs:
95
192
  enabled: true
193
+
194
+ performance:
195
+ yjit: true
196
+ YAML
197
+ end
198
+
199
+ def plugins_yml
200
+ <<~YAML
201
+ # Plugin configuration
202
+ # Gems are auto-discovered from Gemfile.lock
203
+ # Configure or disable here:
204
+ #
205
+ # lingua:
206
+ # languages: [en, id, ms]
207
+ #
208
+ # guardrails:
209
+ # language_check:
210
+ # enabled: true
211
+ #
212
+ # ner:
213
+ # enabled: false
96
214
  YAML
97
215
  end
98
216
 
@@ -111,61 +229,176 @@ module Whoosh
111
229
  end
112
230
 
113
231
  def health_schema
114
- "# frozen_string_literal: true\n\nclass HealthResponse < Whoosh::Schema\n field :status, String, required: true, desc: \"Health status\"\n field :version, String, desc: \"API version\"\nend\n"
232
+ <<~RUBY
233
+ # frozen_string_literal: true
234
+
235
+ class HealthResponse < Whoosh::Schema
236
+ field :status, String, required: true, desc: "Health status"
237
+ field :version, String, desc: "API version"
238
+ end
239
+ RUBY
115
240
  end
116
241
 
117
242
  def test_helper
118
- "# frozen_string_literal: true\n\nrequire \"rack/test\"\nrequire_relative \"../app\"\n\nRSpec.configure do |config|\n config.include Rack::Test::Methods\nend\n"
243
+ <<~RUBY
244
+ # frozen_string_literal: true
245
+
246
+ require "whoosh/test"
247
+ require_relative "../app"
248
+
249
+ RSpec.configure do |config|
250
+ config.include Whoosh::Test
251
+
252
+ def app
253
+ App.to_rack
254
+ end
255
+ end
256
+ RUBY
119
257
  end
120
258
 
121
- def plugins_yml_template
122
- <<~YAML
123
- # Plugin configuration
124
- # Uncomment and configure as needed:
125
- #
126
- # lingua:
127
- # languages: [en, id, ms]
128
- #
129
- # guardrails:
130
- # language_check:
131
- # enabled: true
132
- #
133
- # ner:
134
- # enabled: false
135
- YAML
259
+ def env_file(jwt_secret)
260
+ <<~ENV
261
+ # Generated by whoosh new — do NOT commit this file
262
+ JWT_SECRET=#{jwt_secret}
263
+ WHOOSH_ENV=development
264
+ DATABASE_URL=sqlite://db/development.sqlite3
265
+ ENV
266
+ end
267
+
268
+ def env_example
269
+ <<~ENV
270
+ # Copy to .env and fill in values
271
+ JWT_SECRET=change_me_to_a_random_64_char_hex
272
+ WHOOSH_ENV=development
273
+ DATABASE_URL=sqlite://db/development.sqlite3
274
+ # REDIS_URL=redis://localhost:6379
275
+ ENV
276
+ end
277
+
278
+ def gitignore
279
+ <<~GIT
280
+ # Dependencies
281
+ /vendor/bundle
282
+ /.bundle
283
+
284
+ # Environment
285
+ .env
286
+ .env.local
287
+ .env.production
288
+
289
+ # Database
290
+ db/*.sqlite3
291
+
292
+ # Logs
293
+ /log/*
294
+ /tmp/*
295
+
296
+ # OS
297
+ .DS_Store
298
+ *.swp
299
+ *~
300
+
301
+ # IDE
302
+ .idea/
303
+ .vscode/
304
+ *.code-workspace
305
+
306
+ # Gems
307
+ *.gem
308
+ Gemfile.lock
309
+ GIT
136
310
  end
137
311
 
138
- def dockerfile_template
312
+ def rspec_config
313
+ <<~RSPEC
314
+ --require spec_helper
315
+ --format documentation
316
+ --color
317
+ --order random
318
+ RSPEC
319
+ end
320
+
321
+ def dockerfile
139
322
  <<~DOCKERFILE
140
323
  FROM ruby:3.4-slim
141
324
 
142
325
  WORKDIR /app
143
326
 
144
- # Install dependencies
327
+ # Install system dependencies
328
+ RUN apt-get update -qq && apt-get install -y build-essential libsqlite3-dev
329
+
330
+ # Install gems
145
331
  COPY Gemfile Gemfile.lock ./
146
332
  RUN bundle install --without development test
147
333
 
148
334
  # Copy app
149
335
  COPY . .
150
336
 
151
- # Expose port
152
337
  EXPOSE 9292
153
338
 
154
- # Start server
339
+ # Start with Falcon for best performance
155
340
  CMD ["bundle", "exec", "whoosh", "s", "-p", "9292", "--host", "0.0.0.0"]
156
341
  DOCKERFILE
157
342
  end
158
343
 
159
- def dockerignore_template
344
+ def dockerignore
160
345
  <<~IGNORE
161
346
  .git
162
347
  .env
348
+ .env.*
163
349
  node_modules
164
350
  tmp
165
351
  log
166
352
  db/*.sqlite3
353
+ *.gem
354
+ .DS_Store
167
355
  IGNORE
168
356
  end
357
+
358
+ def readme(name)
359
+ title = name.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
360
+ <<~MD
361
+ # #{title} API
362
+
363
+ Built with [Whoosh](https://github.com/johannesdwicahyo/whoosh) — AI-first Ruby API framework.
364
+
365
+ ## Quick Start
366
+
367
+ ```bash
368
+ whoosh s # http://localhost:9292
369
+ whoosh s --reload # hot reload
370
+ ```
371
+
372
+ ## Endpoints
373
+
374
+ | Method | Path | Description |
375
+ |--------|------|-------------|
376
+ | GET | /health | Health check |
377
+ | GET | /healthz | Health probes |
378
+ | GET | /docs | Swagger UI |
379
+ | GET | /redoc | ReDoc |
380
+ | GET | /openapi.json | OpenAPI spec |
381
+ | GET | /metrics | Prometheus metrics |
382
+
383
+ ## Development
384
+
385
+ ```bash
386
+ whoosh generate endpoint users # create endpoint + schema + test
387
+ whoosh generate schema CreateUser # create schema
388
+ whoosh generate model User name:string email:string
389
+ whoosh routes # list all routes
390
+ whoosh console # IRB with app loaded
391
+ bundle exec rspec # run tests
392
+ ```
393
+
394
+ ## Deploy
395
+
396
+ ```bash
397
+ docker build -t #{name} .
398
+ docker run -p 9292:9292 -e JWT_SECRET=your_secret #{name}
399
+ ```
400
+ MD
401
+ end
169
402
  end
170
403
  end
171
404
  end
data/lib/whoosh/job.rb CHANGED
@@ -1,4 +1,3 @@
1
- # lib/whoosh/job.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Whoosh
@@ -12,9 +11,41 @@ module Whoosh
12
11
  @dependencies || []
13
12
  end
14
13
 
14
+ def queue(name = nil)
15
+ if name
16
+ @queue_name = name.to_s
17
+ else
18
+ @queue_name || "default"
19
+ end
20
+ end
21
+
22
+ def retry_limit(n = nil)
23
+ if n
24
+ @retry_limit = n
25
+ else
26
+ @retry_limit
27
+ end
28
+ end
29
+
30
+ def retry_backoff(strategy = nil)
31
+ if strategy
32
+ @retry_backoff = strategy
33
+ else
34
+ @retry_backoff || :linear
35
+ end
36
+ end
37
+
15
38
  def perform_async(**args)
16
39
  Jobs.enqueue(self, **args)
17
40
  end
41
+
42
+ def perform_in(delay_seconds, **args)
43
+ Jobs.enqueue(self, run_at: Time.now.to_f + delay_seconds, **args)
44
+ end
45
+
46
+ def perform_at(time, **args)
47
+ Jobs.enqueue(self, run_at: time.to_f, **args)
48
+ end
18
49
  end
19
50
 
20
51
  def perform(**args)
@@ -1,4 +1,3 @@
1
- # lib/whoosh/jobs/memory_backend.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Whoosh
@@ -6,6 +5,7 @@ module Whoosh
6
5
  class MemoryBackend
7
6
  def initialize
8
7
  @queue = []
8
+ @scheduled = []
9
9
  @records = {}
10
10
  @mutex = Mutex.new
11
11
  @cv = ConditionVariable.new
@@ -13,14 +13,25 @@ module Whoosh
13
13
 
14
14
  def push(job_data)
15
15
  @mutex.synchronize do
16
- @queue << job_data
16
+ if job_data[:run_at] && job_data[:run_at] > Time.now.to_f
17
+ @scheduled << job_data
18
+ @scheduled.sort_by! { |j| j[:run_at] }
19
+ else
20
+ @queue << job_data
21
+ end
17
22
  @cv.signal
18
23
  end
19
24
  end
20
25
 
21
26
  def pop(timeout: 5)
22
27
  @mutex.synchronize do
23
- @cv.wait(@mutex, timeout) if @queue.empty?
28
+ # Promote scheduled jobs that are ready
29
+ promote_scheduled
30
+
31
+ if @queue.empty?
32
+ @cv.wait(@mutex, timeout)
33
+ promote_scheduled
34
+ end
24
35
  @queue.shift
25
36
  end
26
37
  end
@@ -34,12 +45,37 @@ module Whoosh
34
45
  end
35
46
 
36
47
  def size
48
+ @mutex.synchronize { @queue.size + @scheduled.size }
49
+ end
50
+
51
+ def pending_count
37
52
  @mutex.synchronize { @queue.size }
38
53
  end
39
54
 
55
+ def scheduled_count
56
+ @mutex.synchronize { @scheduled.size }
57
+ end
58
+
40
59
  def shutdown
41
60
  @mutex.synchronize { @cv.broadcast }
42
61
  end
62
+
63
+ private
64
+
65
+ def promote_scheduled
66
+ now = Time.now.to_f
67
+ ready = []
68
+ remaining = []
69
+ @scheduled.each do |job|
70
+ if job[:run_at] <= now
71
+ ready << job
72
+ else
73
+ remaining << job
74
+ end
75
+ end
76
+ @scheduled = remaining
77
+ @queue.concat(ready)
78
+ end
43
79
  end
44
80
  end
45
81
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Whoosh
4
+ module Jobs
5
+ class RedisBackend
6
+ @redis_available = nil
7
+
8
+ def self.available?
9
+ if @redis_available.nil?
10
+ @redis_available = begin
11
+ require "redis"
12
+ true
13
+ rescue LoadError
14
+ false
15
+ end
16
+ end
17
+ @redis_available
18
+ end
19
+
20
+ def initialize(url:, prefix: "whoosh:jobs")
21
+ unless self.class.available?
22
+ raise Errors::DependencyError, "Jobs Redis backend requires the 'redis' gem"
23
+ end
24
+ @redis = Redis.new(url: url)
25
+ @prefix = prefix
26
+ end
27
+
28
+ def push(job_data)
29
+ serialized = Serialization::Json.encode(job_data)
30
+ if job_data[:run_at] && job_data[:run_at] > Time.now.to_f
31
+ # Scheduled: use sorted set with run_at as score
32
+ @redis.zadd("#{@prefix}:scheduled", job_data[:run_at], serialized)
33
+ else
34
+ @redis.lpush("#{@prefix}:queue:#{job_data[:queue] || "default"}", serialized)
35
+ end
36
+ end
37
+
38
+ def pop(timeout: 5, queues: ["default"])
39
+ # First, promote scheduled jobs
40
+ promote_scheduled
41
+
42
+ # Try each queue in priority order
43
+ queues.each do |queue|
44
+ result = @redis.rpop("#{@prefix}:queue:#{queue}")
45
+ if result
46
+ return Serialization::Json.decode(result).transform_keys(&:to_sym)
47
+ end
48
+ end
49
+
50
+ # Block-wait on default queue
51
+ result = @redis.brpop("#{@prefix}:queue:#{queues.first}", timeout: timeout)
52
+ if result
53
+ Serialization::Json.decode(result[1]).transform_keys(&:to_sym)
54
+ end
55
+ rescue => e
56
+ nil
57
+ end
58
+
59
+ def save(record)
60
+ serialized = Serialization::Json.encode(record)
61
+ @redis.set("#{@prefix}:record:#{record[:id]}", serialized, ex: 86400) # 24h TTL
62
+ end
63
+
64
+ def find(id)
65
+ raw = @redis.get("#{@prefix}:record:#{id}")
66
+ return nil unless raw
67
+ data = Serialization::Json.decode(raw)
68
+ data.transform_keys(&:to_sym)
69
+ end
70
+
71
+ def size
72
+ pending_count + scheduled_count
73
+ end
74
+
75
+ def pending_count
76
+ count = 0
77
+ @redis.keys("#{@prefix}:queue:*").each do |key|
78
+ count += @redis.llen(key)
79
+ end
80
+ count
81
+ rescue => e
82
+ 0
83
+ end
84
+
85
+ def scheduled_count
86
+ @redis.zcard("#{@prefix}:scheduled")
87
+ rescue => e
88
+ 0
89
+ end
90
+
91
+ def shutdown
92
+ @redis.close
93
+ rescue => e
94
+ # Already closed
95
+ end
96
+
97
+ private
98
+
99
+ def promote_scheduled
100
+ now = Time.now.to_f
101
+ # Get all jobs ready to run
102
+ ready = @redis.zrangebyscore("#{@prefix}:scheduled", "-inf", now.to_s)
103
+ ready.each do |raw|
104
+ # Remove from scheduled set
105
+ removed = @redis.zrem("#{@prefix}:scheduled", raw)
106
+ next unless removed
107
+
108
+ job_data = Serialization::Json.decode(raw)
109
+ queue = job_data["queue"] || "default"
110
+ @redis.lpush("#{@prefix}:queue:#{queue}", raw)
111
+ end
112
+ rescue => e
113
+ # Don't crash on promote errors
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,15 +1,15 @@
1
- # lib/whoosh/jobs/worker.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module Whoosh
5
4
  module Jobs
6
5
  class Worker
7
- def initialize(backend:, di: nil, max_retries: 3, retry_delay: 5, instrumentation: nil)
6
+ def initialize(backend:, di: nil, max_retries: 3, retry_delay: 5, instrumentation: nil, logger: nil)
8
7
  @backend = backend
9
8
  @di = di
10
9
  @max_retries = max_retries
11
10
  @retry_delay = retry_delay
12
11
  @instrumentation = instrumentation
12
+ @logger = logger
13
13
  @running = true
14
14
  end
15
15
 
@@ -33,13 +33,27 @@ module Whoosh
33
33
 
34
34
  def execute(job_data)
35
35
  id = job_data[:id]
36
+ class_name = job_data[:class_name]
37
+
38
+ # Skip scheduled jobs that aren't ready yet
39
+ if job_data[:run_at] && job_data[:run_at].to_f > Time.now.to_f
40
+ @backend.push(job_data)
41
+ return
42
+ end
43
+
36
44
  record = @backend.find(id) || {}
37
- record = record.merge(status: :running, started_at: Time.now.to_f)
45
+ record = record.merge(id: id, status: :running, started_at: Time.now.to_f)
38
46
  @backend.save(record)
39
47
 
40
- job_class = Object.const_get(job_data[:class_name])
48
+ @logger&.info("job_started", job_id: id, class: class_name)
49
+
50
+ job_class = Object.const_get(class_name)
41
51
  job = job_class.new
42
52
 
53
+ # Determine retry settings from job class or defaults
54
+ max_retries = job_class.respond_to?(:retry_limit) && job_class.retry_limit ? job_class.retry_limit : @max_retries
55
+ backoff_strategy = job_class.respond_to?(:retry_backoff) ? job_class.retry_backoff : :linear
56
+
43
57
  # Inject DI deps
44
58
  if @di && job_class.respond_to?(:dependencies)
45
59
  job_class.dependencies.each do |dep|
@@ -49,23 +63,43 @@ module Whoosh
49
63
  end
50
64
  end
51
65
 
52
- args = job_data[:args].transform_keys(&:to_sym)
66
+ args = job_data[:args]
67
+ args = args.transform_keys(&:to_sym) if args.is_a?(Hash)
53
68
  result = job.perform(**args)
54
69
  serialized = Serialization::Json.decode(Serialization::Json.encode(result))
55
70
 
56
71
  @backend.save(record.merge(status: :completed, result: serialized, completed_at: Time.now.to_f))
72
+ @logger&.info("job_completed", job_id: id, class: class_name)
73
+
57
74
  rescue => e
58
- record = @backend.find(id) || {}
75
+ record = @backend.find(id) || { id: id }
59
76
  retry_count = (record[:retry_count] || 0) + 1
60
77
 
61
- if retry_count <= @max_retries
62
- sleep(@retry_delay) if @retry_delay > 0
63
- @backend.save(record.merge(retry_count: retry_count, status: :pending))
64
- @backend.push(job_data)
78
+ if retry_count <= max_retries
79
+ # Non-blocking retry: re-enqueue with delay timestamp instead of sleeping
80
+ delay = calculate_delay(retry_count, backoff_strategy)
81
+ run_at = Time.now.to_f + delay
82
+ @backend.save(record.merge(retry_count: retry_count, status: :scheduled, run_at: run_at))
83
+ @backend.push(job_data.merge(run_at: run_at))
84
+ @logger&.warn("job_retry", job_id: id, class: class_name, retry_count: retry_count, delay: delay)
65
85
  else
66
86
  error = { message: e.message, backtrace: e.backtrace&.first(10)&.join("\n") }
67
- @backend.save(record.merge(status: :failed, error: error, retry_count: retry_count, completed_at: Time.now.to_f))
87
+ @backend.save(record.merge(
88
+ status: :failed, error: error, retry_count: retry_count, completed_at: Time.now.to_f
89
+ ))
68
90
  @instrumentation&.emit(:job_failed, { job_id: id, error: error })
91
+ @logger&.error("job_failed", job_id: id, class: class_name, error: e.message)
92
+ end
93
+ end
94
+
95
+ def calculate_delay(retry_count, strategy)
96
+ case strategy
97
+ when :exponential
98
+ @retry_delay * (2**(retry_count - 1)) # 5, 10, 20, 40...
99
+ when :linear
100
+ @retry_delay * retry_count # 5, 10, 15, 20...
101
+ else
102
+ @retry_delay
69
103
  end
70
104
  end
71
105
  end
data/lib/whoosh/jobs.rb CHANGED
@@ -1,4 +1,3 @@
1
- # lib/whoosh/jobs.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "securerandom"
@@ -6,6 +5,7 @@ require "securerandom"
6
5
  module Whoosh
7
6
  module Jobs
8
7
  autoload :MemoryBackend, "whoosh/jobs/memory_backend"
8
+ autoload :RedisBackend, "whoosh/jobs/redis_backend"
9
9
  autoload :Worker, "whoosh/jobs/worker"
10
10
 
11
11
  @backend = nil
@@ -23,16 +23,27 @@ module Whoosh
23
23
  !!@backend
24
24
  end
25
25
 
26
- def enqueue(job_class, **args)
26
+ def enqueue(job_class, run_at: nil, **args)
27
27
  raise Errors::DependencyError, "Jobs not configured — boot a Whoosh::App first" unless configured?
28
+
28
29
  id = SecureRandom.uuid
30
+ queue_name = job_class.respond_to?(:queue) ? job_class.queue : "default"
29
31
  record = {
30
- id: id, class_name: job_class.name, args: args, status: :pending,
31
- result: nil, error: nil, retry_count: 0,
32
- created_at: Time.now.to_f, started_at: nil, completed_at: nil
32
+ id: id,
33
+ class_name: job_class.name,
34
+ args: args,
35
+ queue: queue_name,
36
+ status: run_at ? :scheduled : :pending,
37
+ run_at: run_at,
38
+ result: nil,
39
+ error: nil,
40
+ retry_count: 0,
41
+ created_at: Time.now.to_f,
42
+ started_at: nil,
43
+ completed_at: nil
33
44
  }
34
45
  @backend.save(record)
35
- @backend.push({ id: id, class_name: job_class.name, args: args })
46
+ @backend.push({ id: id, class_name: job_class.name, args: args, queue: queue_name, run_at: run_at })
36
47
  id
37
48
  end
38
49
 
@@ -41,6 +52,18 @@ module Whoosh
41
52
  @backend.find(id)
42
53
  end
43
54
 
55
+ # Build the right backend from config (auto-detect pattern)
56
+ def build_backend(config_data = {})
57
+ jobs_config = config_data["jobs"] || {}
58
+ redis_url = ENV["REDIS_URL"] || jobs_config["redis_url"]
59
+
60
+ if redis_url && jobs_config["backend"] != "memory"
61
+ RedisBackend.new(url: redis_url)
62
+ else
63
+ MemoryBackend.new
64
+ end
65
+ end
66
+
44
67
  def reset!
45
68
  @backend = nil
46
69
  @di = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Whoosh
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whoosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwi Cahyo
@@ -199,6 +199,7 @@ files:
199
199
  - lib/whoosh/job.rb
200
200
  - lib/whoosh/jobs.rb
201
201
  - lib/whoosh/jobs/memory_backend.rb
202
+ - lib/whoosh/jobs/redis_backend.rb
202
203
  - lib/whoosh/jobs/worker.rb
203
204
  - lib/whoosh/logger.rb
204
205
  - lib/whoosh/mcp/client.rb