whoosh 1.0.2 → 1.2.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 +4 -4
- data/lib/whoosh/app.rb +7 -2
- data/lib/whoosh/cache.rb +10 -10
- data/lib/whoosh/cli/project_generator.rb +9 -1
- data/lib/whoosh/job.rb +32 -1
- data/lib/whoosh/jobs/memory_backend.rb +39 -3
- data/lib/whoosh/jobs/redis_backend.rb +117 -0
- data/lib/whoosh/jobs/worker.rb +45 -11
- data/lib/whoosh/jobs.rb +29 -6
- data/lib/whoosh/vector_store/memory_store.rb +79 -0
- data/lib/whoosh/vector_store.rb +42 -0
- data/lib/whoosh/version.rb +1 -1
- data/lib/whoosh.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 133af39d011ac77fc6e0773ea19652cb990f5c8d129d40b7f301796062e23e1f
|
|
4
|
+
data.tar.gz: 5145a7c3177f2ddef7667f8faa6a6c2681b38fd279351c95ceda93ce69999c2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e99f75634ea05e7e2673164f6bdd8b71ea704e6ee02023370b8b77207666bee6291a02d7423d8d036c4014651ba17673c3f23639e64a2a68b5038799b1dca99
|
|
7
|
+
data.tar.gz: 4427cce1a58e9d35841a372ca782f831d6aa3ef513e2309f14e4f6b284a4d12461b102fe5a963768e1a01af2e98db7fc82f7084e970f1a150ef7092f2033c8bf
|
data/lib/whoosh/app.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Whoosh
|
|
|
29
29
|
auto_register_database
|
|
30
30
|
auto_register_storage
|
|
31
31
|
auto_register_http
|
|
32
|
+
auto_register_vectors
|
|
32
33
|
auto_configure_jobs
|
|
33
34
|
@metrics = Metrics.new
|
|
34
35
|
auto_register_metrics
|
|
@@ -302,8 +303,12 @@ module Whoosh
|
|
|
302
303
|
@di.provide(:http) { HTTP }
|
|
303
304
|
end
|
|
304
305
|
|
|
306
|
+
def auto_register_vectors
|
|
307
|
+
@di.provide(:vectors) { VectorStore.build(@config.data) }
|
|
308
|
+
end
|
|
309
|
+
|
|
305
310
|
def auto_configure_jobs
|
|
306
|
-
backend = Jobs
|
|
311
|
+
backend = Jobs.build_backend(@config.data)
|
|
307
312
|
Jobs.configure(backend: backend, di: @di)
|
|
308
313
|
end
|
|
309
314
|
|
|
@@ -321,7 +326,7 @@ module Whoosh
|
|
|
321
326
|
worker = Jobs::Worker.new(
|
|
322
327
|
backend: Jobs.backend, di: @di,
|
|
323
328
|
max_retries: max_retries, retry_delay: retry_delay,
|
|
324
|
-
instrumentation: @instrumentation
|
|
329
|
+
instrumentation: @instrumentation, logger: @logger
|
|
325
330
|
)
|
|
326
331
|
thread = Thread.new { worker.run_loop }
|
|
327
332
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
22
|
+
MemoryStore.new(default_ttl: default_ttl)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
end
|
|
@@ -175,8 +175,10 @@ module Whoosh
|
|
|
175
175
|
max_connections: 10
|
|
176
176
|
log_level: debug
|
|
177
177
|
|
|
178
|
+
# Cache & Jobs auto-detect:
|
|
179
|
+
# No REDIS_URL → in-memory (just works)
|
|
180
|
+
# Set REDIS_URL → auto-switches to Redis
|
|
178
181
|
cache:
|
|
179
|
-
store: memory
|
|
180
182
|
default_ttl: 300
|
|
181
183
|
|
|
182
184
|
jobs:
|
|
@@ -188,6 +190,12 @@ module Whoosh
|
|
|
188
190
|
level: info
|
|
189
191
|
format: json
|
|
190
192
|
|
|
193
|
+
# Vector store auto-detect:
|
|
194
|
+
# zvec gem installed → uses zvec, otherwise → in-memory
|
|
195
|
+
# vector:
|
|
196
|
+
# adapter: auto
|
|
197
|
+
# path: db/vectors
|
|
198
|
+
|
|
191
199
|
docs:
|
|
192
200
|
enabled: true
|
|
193
201
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/whoosh/jobs/worker.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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 <=
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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,
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module VectorStore
|
|
5
|
+
class MemoryStore
|
|
6
|
+
def initialize
|
|
7
|
+
@collections = {}
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Store a vector with metadata
|
|
12
|
+
def insert(collection, id:, vector:, metadata: {})
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
@collections[collection] ||= {}
|
|
15
|
+
@collections[collection][id] = { vector: vector, metadata: metadata }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Search by cosine similarity, return top-k results
|
|
20
|
+
def search(collection, vector:, limit: 10)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
items = @collections[collection]
|
|
23
|
+
return [] unless items && !items.empty?
|
|
24
|
+
|
|
25
|
+
scored = items.map do |id, data|
|
|
26
|
+
score = cosine_similarity(vector, data[:vector])
|
|
27
|
+
{ id: id, score: score, metadata: data[:metadata] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
scored.sort_by { |r| -r[:score] }.first(limit)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Delete a vector
|
|
35
|
+
def delete(collection, id:)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@collections[collection]&.delete(id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Count vectors in a collection
|
|
42
|
+
def count(collection)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@collections[collection]&.size || 0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Drop a collection
|
|
49
|
+
def drop(collection)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@collections.delete(collection)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def close
|
|
56
|
+
# No-op
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def cosine_similarity(a, b)
|
|
62
|
+
return 0.0 if a.empty? || b.empty? || a.length != b.length
|
|
63
|
+
|
|
64
|
+
dot = 0.0
|
|
65
|
+
mag_a = 0.0
|
|
66
|
+
mag_b = 0.0
|
|
67
|
+
|
|
68
|
+
a.length.times do |i|
|
|
69
|
+
dot += a[i] * b[i]
|
|
70
|
+
mag_a += a[i] * a[i]
|
|
71
|
+
mag_b += b[i] * b[i]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
denom = Math.sqrt(mag_a) * Math.sqrt(mag_b)
|
|
75
|
+
denom.zero? ? 0.0 : dot / denom
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module VectorStore
|
|
5
|
+
autoload :MemoryStore, "whoosh/vector_store/memory_store"
|
|
6
|
+
|
|
7
|
+
# Auto-detect: zvec gem → use it, otherwise → in-memory
|
|
8
|
+
def self.build(config_data = {})
|
|
9
|
+
vector_config = config_data["vector"] || {}
|
|
10
|
+
adapter = vector_config["adapter"] || "auto"
|
|
11
|
+
|
|
12
|
+
case adapter
|
|
13
|
+
when "auto"
|
|
14
|
+
# Try zvec first, fall back to memory
|
|
15
|
+
if zvec_available?
|
|
16
|
+
require "whoosh/vector_store/zvec_store"
|
|
17
|
+
ZvecStore.new(**zvec_options(vector_config))
|
|
18
|
+
else
|
|
19
|
+
MemoryStore.new
|
|
20
|
+
end
|
|
21
|
+
when "memory"
|
|
22
|
+
MemoryStore.new
|
|
23
|
+
when "zvec"
|
|
24
|
+
require "whoosh/vector_store/zvec_store"
|
|
25
|
+
ZvecStore.new(**zvec_options(vector_config))
|
|
26
|
+
else
|
|
27
|
+
MemoryStore.new
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.zvec_available?
|
|
32
|
+
require "zvec"
|
|
33
|
+
true
|
|
34
|
+
rescue LoadError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.zvec_options(config)
|
|
39
|
+
{ path: config["path"] || "db/vectors" }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/whoosh/version.rb
CHANGED
data/lib/whoosh.rb
CHANGED
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
|
|
4
|
+
version: 1.2.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
|
|
@@ -240,6 +241,8 @@ files:
|
|
|
240
241
|
- lib/whoosh/test.rb
|
|
241
242
|
- lib/whoosh/types.rb
|
|
242
243
|
- lib/whoosh/uploaded_file.rb
|
|
244
|
+
- lib/whoosh/vector_store.rb
|
|
245
|
+
- lib/whoosh/vector_store/memory_store.rb
|
|
243
246
|
- lib/whoosh/version.rb
|
|
244
247
|
homepage: https://github.com/johannesdwicahyo/whoosh
|
|
245
248
|
licenses:
|