whoosh 1.0.2 → 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: 6d739583e7066d3f04675dbd6eb4e17809226c909f5c7d4d226b7672957e4a88
4
- data.tar.gz: 5f12610ddf11479be668f3b0dc88de6f6c3f5640620299ca3512f48ffaf14922
3
+ metadata.gz: 03b756c310f71435839a3628bfdfa9499bb70869b9475e75eb6cce861caa9583
4
+ data.tar.gz: a848d792014e80cc6bee413ad14277881895a62a40854644f3a088bfe0ea164a
5
5
  SHA512:
6
- metadata.gz: 305d2ea3aac7ba3a22be62c52c996aca50956cce5a0983a8d38cc0f76a55a817c25bdb3fe8877127752a0f3d1e3d81d894c80dc395ae4da39d562d2fad58098f
7
- data.tar.gz: 2cd02f1ab2374bd1334d6dc6bab05fc460e108081eadb8119c9082af41f522c7fb5a8e7f63781b9451aeb319cd001522b7d6998290c662b3c8dbf0706ac03308
6
+ metadata.gz: b764fa79a9e8297c66e0ce0c56be1127c1d1780ccb3840a0868a55841c4cb1108efffeff90cdaf4105a7df9259764aff7a8afe1047a42809e0c039b1e74be11d
7
+ data.tar.gz: 297cc80e8ca6ba22df946e07204f114bda7634480f5af72520caa0bdfde7b8604e6b66bf1b081f3527bede997a320b9ffc1b44c14fba21c2a05e5778101451c5
data/lib/whoosh/app.rb CHANGED
@@ -303,7 +303,7 @@ module Whoosh
303
303
  end
304
304
 
305
305
  def auto_configure_jobs
306
- backend = Jobs::MemoryBackend.new
306
+ backend = Jobs.build_backend(@config.data)
307
307
  Jobs.configure(backend: backend, di: @di)
308
308
  end
309
309
 
@@ -321,7 +321,7 @@ module Whoosh
321
321
  worker = Jobs::Worker.new(
322
322
  backend: Jobs.backend, di: @di,
323
323
  max_retries: max_retries, retry_delay: retry_delay,
324
- instrumentation: @instrumentation
324
+ instrumentation: @instrumentation, logger: @logger
325
325
  )
326
326
  thread = Thread.new { worker.run_loop }
327
327
  thread.abort_on_exception = false
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
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.2"
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.2
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