whoosh 1.0.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +413 -0
- data/exe/whoosh +6 -0
- data/lib/whoosh/app.rb +655 -0
- data/lib/whoosh/auth/access_control.rb +26 -0
- data/lib/whoosh/auth/api_key.rb +30 -0
- data/lib/whoosh/auth/jwt.rb +88 -0
- data/lib/whoosh/auth/oauth2.rb +33 -0
- data/lib/whoosh/auth/rate_limiter.rb +86 -0
- data/lib/whoosh/auth/token_tracker.rb +40 -0
- data/lib/whoosh/cache/memory_store.rb +57 -0
- data/lib/whoosh/cache/redis_store.rb +72 -0
- data/lib/whoosh/cache.rb +26 -0
- data/lib/whoosh/cli/generators.rb +133 -0
- data/lib/whoosh/cli/main.rb +277 -0
- data/lib/whoosh/cli/project_generator.rb +172 -0
- data/lib/whoosh/config.rb +160 -0
- data/lib/whoosh/database.rb +47 -0
- data/lib/whoosh/dependency_injection.rb +103 -0
- data/lib/whoosh/endpoint.rb +79 -0
- data/lib/whoosh/env_loader.rb +46 -0
- data/lib/whoosh/errors.rb +68 -0
- data/lib/whoosh/http/response.rb +26 -0
- data/lib/whoosh/http.rb +73 -0
- data/lib/whoosh/instrumentation.rb +22 -0
- data/lib/whoosh/job.rb +24 -0
- data/lib/whoosh/jobs/memory_backend.rb +45 -0
- data/lib/whoosh/jobs/worker.rb +73 -0
- data/lib/whoosh/jobs.rb +50 -0
- data/lib/whoosh/logger.rb +62 -0
- data/lib/whoosh/mcp/client.rb +71 -0
- data/lib/whoosh/mcp/client_manager.rb +73 -0
- data/lib/whoosh/mcp/protocol.rb +39 -0
- data/lib/whoosh/mcp/server.rb +66 -0
- data/lib/whoosh/mcp/transport/sse.rb +26 -0
- data/lib/whoosh/mcp/transport/stdio.rb +33 -0
- data/lib/whoosh/metrics.rb +84 -0
- data/lib/whoosh/middleware/cors.rb +61 -0
- data/lib/whoosh/middleware/plugin_hooks.rb +27 -0
- data/lib/whoosh/middleware/request_limit.rb +28 -0
- data/lib/whoosh/middleware/request_logger.rb +39 -0
- data/lib/whoosh/middleware/security_headers.rb +28 -0
- data/lib/whoosh/middleware/stack.rb +25 -0
- data/lib/whoosh/openapi/generator.rb +50 -0
- data/lib/whoosh/openapi/schema_converter.rb +48 -0
- data/lib/whoosh/openapi/ui.rb +62 -0
- data/lib/whoosh/paginate.rb +64 -0
- data/lib/whoosh/performance.rb +20 -0
- data/lib/whoosh/plugins/base.rb +42 -0
- data/lib/whoosh/plugins/registry.rb +139 -0
- data/lib/whoosh/request.rb +93 -0
- data/lib/whoosh/response.rb +39 -0
- data/lib/whoosh/router.rb +112 -0
- data/lib/whoosh/schema.rb +194 -0
- data/lib/whoosh/serialization/json.rb +73 -0
- data/lib/whoosh/serialization/msgpack.rb +51 -0
- data/lib/whoosh/serialization/negotiator.rb +37 -0
- data/lib/whoosh/serialization/protobuf.rb +43 -0
- data/lib/whoosh/shutdown.rb +30 -0
- data/lib/whoosh/storage/local.rb +24 -0
- data/lib/whoosh/storage/s3.rb +31 -0
- data/lib/whoosh/storage.rb +20 -0
- data/lib/whoosh/streaming/llm_stream.rb +51 -0
- data/lib/whoosh/streaming/sse.rb +61 -0
- data/lib/whoosh/streaming/stream_body.rb +59 -0
- data/lib/whoosh/streaming/websocket.rb +51 -0
- data/lib/whoosh/test.rb +70 -0
- data/lib/whoosh/types.rb +11 -0
- data/lib/whoosh/uploaded_file.rb +47 -0
- data/lib/whoosh/version.rb +5 -0
- data/lib/whoosh.rb +86 -0
- metadata +265 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
class DependencyInjection
|
|
5
|
+
def initialize
|
|
6
|
+
@providers = {}
|
|
7
|
+
@singletons = {}
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def provide(name, scope: :singleton, &block)
|
|
12
|
+
@providers[name] = { block: block, scope: scope }
|
|
13
|
+
@singletons.delete(name) # Clear cached value on re-register
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve(name, request: nil, resolving: [])
|
|
17
|
+
provider = @providers[name]
|
|
18
|
+
raise Errors::DependencyError, "Unknown dependency: #{name}" unless provider
|
|
19
|
+
|
|
20
|
+
if resolving.include?(name)
|
|
21
|
+
raise Errors::DependencyError, "Circular dependency detected: #{(resolving + [name]).join(' -> ')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
case provider[:scope]
|
|
25
|
+
when :singleton
|
|
26
|
+
# Check cache without lock first (double-checked locking pattern)
|
|
27
|
+
return @singletons[name] if @singletons.key?(name)
|
|
28
|
+
|
|
29
|
+
# Compute outside the lock to allow recursive singleton resolution
|
|
30
|
+
value = call_provider(provider[:block], request: request, resolving: resolving + [name])
|
|
31
|
+
@mutex.synchronize { @singletons[name] ||= value }
|
|
32
|
+
@singletons[name]
|
|
33
|
+
when :request
|
|
34
|
+
call_provider(provider[:block], request: request, resolving: resolving + [name])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inject_for(names, request: nil)
|
|
39
|
+
names.each_with_object({}) do |name, hash|
|
|
40
|
+
hash[name] = resolve(name, request: request)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate!
|
|
45
|
+
# Topological sort to detect circular deps and unknown refs at boot
|
|
46
|
+
visited = {}
|
|
47
|
+
sorted = []
|
|
48
|
+
|
|
49
|
+
visit = ->(name, path) do
|
|
50
|
+
return if visited[name] == :done
|
|
51
|
+
raise Errors::DependencyError, "Circular dependency detected: #{(path + [name]).join(' -> ')}" if visited[name] == :visiting
|
|
52
|
+
|
|
53
|
+
provider = @providers[name]
|
|
54
|
+
raise Errors::DependencyError, "Unknown dependency: #{name} (referenced by #{path.last})" unless provider
|
|
55
|
+
|
|
56
|
+
visited[name] = :visiting
|
|
57
|
+
deps = extract_deps(provider[:block])
|
|
58
|
+
deps.each { |dep| visit.call(dep, path + [name]) }
|
|
59
|
+
visited[name] = :done
|
|
60
|
+
sorted << name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@providers.each_key { |name| visit.call(name, []) unless visited[name] }
|
|
64
|
+
sorted
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def registered?(name)
|
|
68
|
+
@providers.key?(name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def close_all
|
|
72
|
+
@singletons.each_value do |instance|
|
|
73
|
+
instance.close if instance.respond_to?(:close)
|
|
74
|
+
end
|
|
75
|
+
@singletons.clear
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def extract_deps(block)
|
|
81
|
+
block.parameters
|
|
82
|
+
.select { |type, _| type == :keyreq || type == :key }
|
|
83
|
+
.map(&:last)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call_provider(block, request: nil, resolving: [])
|
|
87
|
+
# Inspect block parameters to determine what to inject
|
|
88
|
+
params = block.parameters
|
|
89
|
+
kwargs = params.select { |type, _| type == :keyreq || type == :key }.map(&:last)
|
|
90
|
+
|
|
91
|
+
if kwargs.any?
|
|
92
|
+
deps = kwargs.each_with_object({}) do |dep_name, hash|
|
|
93
|
+
hash[dep_name] = resolve(dep_name, request: request, resolving: resolving)
|
|
94
|
+
end
|
|
95
|
+
block.call(**deps)
|
|
96
|
+
elsif params.any? { |type, _| type == :req || type == :opt }
|
|
97
|
+
block.call(request)
|
|
98
|
+
else
|
|
99
|
+
block.call
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
class Endpoint
|
|
5
|
+
class Context
|
|
6
|
+
attr_reader :request
|
|
7
|
+
|
|
8
|
+
def initialize(app, request)
|
|
9
|
+
@app = app
|
|
10
|
+
@request = request
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
14
|
+
@app.respond_to?(method_name, include_private) || super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def method_missing(method_name, ...)
|
|
20
|
+
if @app.respond_to?(method_name)
|
|
21
|
+
@app.send(method_name, ...)
|
|
22
|
+
else
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def inherited(subclass)
|
|
30
|
+
super
|
|
31
|
+
subclass.instance_variable_set(:@declared_routes, [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def declared_routes
|
|
35
|
+
@declared_routes
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def get(path, **opts)
|
|
39
|
+
declare_route("GET", path, **opts)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def post(path, **opts)
|
|
43
|
+
declare_route("POST", path, **opts)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def put(path, **opts)
|
|
47
|
+
declare_route("PUT", path, **opts)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def patch(path, **opts)
|
|
51
|
+
declare_route("PATCH", path, **opts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(path, **opts)
|
|
55
|
+
declare_route("DELETE", path, **opts)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def options(path, **opts)
|
|
59
|
+
declare_route("OPTIONS", path, **opts)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def declare_route(method, path, request: nil, response: nil, **metadata)
|
|
65
|
+
@declared_routes << {
|
|
66
|
+
method: method,
|
|
67
|
+
path: path,
|
|
68
|
+
request_schema: request,
|
|
69
|
+
response_schema: response,
|
|
70
|
+
metadata: metadata
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def call(req)
|
|
76
|
+
raise NotImplementedError, "#{self.class}#call must be implemented"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# lib/whoosh/env_loader.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module EnvLoader
|
|
6
|
+
def self.load(root)
|
|
7
|
+
path = File.join(root, ".env")
|
|
8
|
+
return unless File.exist?(path)
|
|
9
|
+
|
|
10
|
+
if dotenv_available?
|
|
11
|
+
require "dotenv"
|
|
12
|
+
Dotenv.load(path)
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
parse(File.read(path)).each do |key, value|
|
|
17
|
+
ENV[key] ||= value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.parse(content)
|
|
22
|
+
pairs = {}
|
|
23
|
+
content.each_line do |line|
|
|
24
|
+
line = line.strip
|
|
25
|
+
next if line.empty? || line.start_with?("#")
|
|
26
|
+
key, value = line.split("=", 2)
|
|
27
|
+
next unless key && value
|
|
28
|
+
key = key.strip
|
|
29
|
+
value = value.strip
|
|
30
|
+
if (value.start_with?('"') && value.end_with?('"')) ||
|
|
31
|
+
(value.start_with?("'") && value.end_with?("'"))
|
|
32
|
+
value = value[1..-2]
|
|
33
|
+
end
|
|
34
|
+
pairs[key] = value
|
|
35
|
+
end
|
|
36
|
+
pairs
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.dotenv_available?
|
|
40
|
+
require "dotenv"
|
|
41
|
+
true
|
|
42
|
+
rescue LoadError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whoosh
|
|
4
|
+
module Errors
|
|
5
|
+
class WhooshError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class HttpError < WhooshError
|
|
8
|
+
attr_reader :status, :error_type
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, status: 500, error_type: "internal_error")
|
|
11
|
+
@status = status
|
|
12
|
+
@error_type = error_type
|
|
13
|
+
super(message || error_type)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{ error: error_type }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class ValidationError < HttpError
|
|
22
|
+
attr_reader :details
|
|
23
|
+
|
|
24
|
+
def initialize(details = [])
|
|
25
|
+
@details = details
|
|
26
|
+
super("Validation failed", status: 422, error_type: "validation_failed")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{ error: error_type, details: details }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class NotFoundError < HttpError
|
|
35
|
+
def initialize(message = "Not found")
|
|
36
|
+
super(message, status: 404, error_type: "not_found")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class UnauthorizedError < HttpError
|
|
41
|
+
def initialize(message = "Unauthorized")
|
|
42
|
+
super(message, status: 401, error_type: "unauthorized")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class ForbiddenError < HttpError
|
|
47
|
+
def initialize(message = "Forbidden")
|
|
48
|
+
super(message, status: 403, error_type: "forbidden")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class RateLimitExceeded < HttpError
|
|
53
|
+
attr_reader :retry_after
|
|
54
|
+
|
|
55
|
+
def initialize(message = "Rate limit exceeded", retry_after: 60)
|
|
56
|
+
@retry_after = retry_after
|
|
57
|
+
super(message, status: 429, error_type: "rate_limited")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
super.merge(retry_after: retry_after)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class DependencyError < WhooshError; end
|
|
66
|
+
class GuardrailsViolation < WhooshError; end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# lib/whoosh/http/response.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Whoosh
|
|
7
|
+
module HTTP
|
|
8
|
+
class Response
|
|
9
|
+
attr_reader :status, :body, :headers
|
|
10
|
+
|
|
11
|
+
def initialize(status:, body:, headers: {})
|
|
12
|
+
@status = status
|
|
13
|
+
@body = body
|
|
14
|
+
@headers = headers
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def json
|
|
18
|
+
@json ||= JSON.parse(@body)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ok?
|
|
22
|
+
@status >= 200 && @status < 300
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/whoosh/http.rb
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lib/whoosh/http.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Whoosh
|
|
9
|
+
module HTTP
|
|
10
|
+
autoload :Response, "whoosh/http/response"
|
|
11
|
+
|
|
12
|
+
class TimeoutError < Errors::WhooshError; end
|
|
13
|
+
class ConnectionError < Errors::WhooshError; end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def get(url, headers: {}, timeout: 30)
|
|
17
|
+
request(:get, url, headers: headers, timeout: timeout)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post(url, json: nil, body: nil, headers: {}, timeout: 30)
|
|
21
|
+
request(:post, url, json: json, body: body, headers: headers, timeout: timeout)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def put(url, json: nil, body: nil, headers: {}, timeout: 30)
|
|
25
|
+
request(:put, url, json: json, body: body, headers: headers, timeout: timeout)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def patch(url, json: nil, body: nil, headers: {}, timeout: 30)
|
|
29
|
+
request(:patch, url, json: json, body: body, headers: headers, timeout: timeout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(url, headers: {}, timeout: 30)
|
|
33
|
+
request(:delete, url, headers: headers, timeout: timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def request(method, url, json: nil, body: nil, headers: {}, timeout: 30)
|
|
39
|
+
uri = URI.parse(url)
|
|
40
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
41
|
+
http.use_ssl = (uri.scheme == "https")
|
|
42
|
+
http.open_timeout = timeout
|
|
43
|
+
http.read_timeout = timeout
|
|
44
|
+
|
|
45
|
+
request_body = json ? JSON.generate(json) : body
|
|
46
|
+
headers = { "Content-Type" => "application/json" }.merge(headers) if json
|
|
47
|
+
|
|
48
|
+
req = build_net_request(method, uri, headers)
|
|
49
|
+
req.body = request_body if request_body
|
|
50
|
+
|
|
51
|
+
response = http.request(req)
|
|
52
|
+
Response.new(status: response.code.to_i, body: response.body || "", headers: response.each_header.to_h)
|
|
53
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
54
|
+
raise TimeoutError, e.message
|
|
55
|
+
rescue Errno::ECONNREFUSED, SocketError, Errno::EHOSTUNREACH => e
|
|
56
|
+
raise ConnectionError, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_net_request(method, uri, headers)
|
|
60
|
+
path = uri.request_uri
|
|
61
|
+
req = case method
|
|
62
|
+
when :get then Net::HTTP::Get.new(path)
|
|
63
|
+
when :post then Net::HTTP::Post.new(path)
|
|
64
|
+
when :put then Net::HTTP::Put.new(path)
|
|
65
|
+
when :patch then Net::HTTP::Patch.new(path)
|
|
66
|
+
when :delete then Net::HTTP::Delete.new(path)
|
|
67
|
+
end
|
|
68
|
+
headers.each { |k, v| req[k] = v }
|
|
69
|
+
req
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# lib/whoosh/instrumentation.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
class Instrumentation
|
|
6
|
+
def initialize
|
|
7
|
+
@subscribers = Hash.new { |h, k| h[k] = [] }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def on(event, &block)
|
|
11
|
+
@subscribers[event] << block
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def emit(event, data = {})
|
|
15
|
+
@subscribers[event].each do |subscriber|
|
|
16
|
+
subscriber.call(data)
|
|
17
|
+
rescue => e
|
|
18
|
+
# Don't let subscriber errors crash the app
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/whoosh/job.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# lib/whoosh/job.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
class Job
|
|
6
|
+
class << self
|
|
7
|
+
def inject(*names)
|
|
8
|
+
@dependencies = names
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dependencies
|
|
12
|
+
@dependencies || []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def perform_async(**args)
|
|
16
|
+
Jobs.enqueue(self, **args)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def perform(**args)
|
|
21
|
+
raise NotImplementedError, "#{self.class}#perform must be implemented"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# lib/whoosh/jobs/memory_backend.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Jobs
|
|
6
|
+
class MemoryBackend
|
|
7
|
+
def initialize
|
|
8
|
+
@queue = []
|
|
9
|
+
@records = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@cv = ConditionVariable.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def push(job_data)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@queue << job_data
|
|
17
|
+
@cv.signal
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pop(timeout: 5)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@cv.wait(@mutex, timeout) if @queue.empty?
|
|
24
|
+
@queue.shift
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def save(record)
|
|
29
|
+
@mutex.synchronize { @records[record[:id]] = record }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find(id)
|
|
33
|
+
@mutex.synchronize { @records[id]&.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@mutex.synchronize { @queue.size }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def shutdown
|
|
41
|
+
@mutex.synchronize { @cv.broadcast }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lib/whoosh/jobs/worker.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Whoosh
|
|
5
|
+
module Jobs
|
|
6
|
+
class Worker
|
|
7
|
+
def initialize(backend:, di: nil, max_retries: 3, retry_delay: 5, instrumentation: nil)
|
|
8
|
+
@backend = backend
|
|
9
|
+
@di = di
|
|
10
|
+
@max_retries = max_retries
|
|
11
|
+
@retry_delay = retry_delay
|
|
12
|
+
@instrumentation = instrumentation
|
|
13
|
+
@running = true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run_once(timeout: 5)
|
|
17
|
+
job_data = @backend.pop(timeout: timeout)
|
|
18
|
+
return unless job_data
|
|
19
|
+
execute(job_data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_loop
|
|
23
|
+
while @running
|
|
24
|
+
run_once
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop
|
|
29
|
+
@running = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def execute(job_data)
|
|
35
|
+
id = job_data[:id]
|
|
36
|
+
record = @backend.find(id) || {}
|
|
37
|
+
record = record.merge(status: :running, started_at: Time.now.to_f)
|
|
38
|
+
@backend.save(record)
|
|
39
|
+
|
|
40
|
+
job_class = Object.const_get(job_data[:class_name])
|
|
41
|
+
job = job_class.new
|
|
42
|
+
|
|
43
|
+
# Inject DI deps
|
|
44
|
+
if @di && job_class.respond_to?(:dependencies)
|
|
45
|
+
job_class.dependencies.each do |dep|
|
|
46
|
+
value = @di.resolve(dep)
|
|
47
|
+
job.instance_variable_set(:"@#{dep}", value)
|
|
48
|
+
job.define_singleton_method(dep) { instance_variable_get(:"@#{dep}") }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
args = job_data[:args].transform_keys(&:to_sym)
|
|
53
|
+
result = job.perform(**args)
|
|
54
|
+
serialized = Serialization::Json.decode(Serialization::Json.encode(result))
|
|
55
|
+
|
|
56
|
+
@backend.save(record.merge(status: :completed, result: serialized, completed_at: Time.now.to_f))
|
|
57
|
+
rescue => e
|
|
58
|
+
record = @backend.find(id) || {}
|
|
59
|
+
retry_count = (record[:retry_count] || 0) + 1
|
|
60
|
+
|
|
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)
|
|
65
|
+
else
|
|
66
|
+
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))
|
|
68
|
+
@instrumentation&.emit(:job_failed, { job_id: id, error: error })
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/whoosh/jobs.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# lib/whoosh/jobs.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Whoosh
|
|
7
|
+
module Jobs
|
|
8
|
+
autoload :MemoryBackend, "whoosh/jobs/memory_backend"
|
|
9
|
+
autoload :Worker, "whoosh/jobs/worker"
|
|
10
|
+
|
|
11
|
+
@backend = nil
|
|
12
|
+
@di = nil
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_reader :backend, :di
|
|
16
|
+
|
|
17
|
+
def configure(backend:, di: nil)
|
|
18
|
+
@backend = backend
|
|
19
|
+
@di = di
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configured?
|
|
23
|
+
!!@backend
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def enqueue(job_class, **args)
|
|
27
|
+
raise Errors::DependencyError, "Jobs not configured — boot a Whoosh::App first" unless configured?
|
|
28
|
+
id = SecureRandom.uuid
|
|
29
|
+
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
|
|
33
|
+
}
|
|
34
|
+
@backend.save(record)
|
|
35
|
+
@backend.push({ id: id, class_name: job_class.name, args: args })
|
|
36
|
+
id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find(id)
|
|
40
|
+
raise Errors::DependencyError, "Jobs not configured" unless configured?
|
|
41
|
+
@backend.find(id)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@backend = nil
|
|
46
|
+
@di = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Whoosh
|
|
6
|
+
class Logger
|
|
7
|
+
LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(output: $stdout, format: :json, level: :info)
|
|
10
|
+
@output = output
|
|
11
|
+
@format = format
|
|
12
|
+
@level = LEVELS[level.to_sym] || 1
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def debug(event, **data)
|
|
16
|
+
log(:debug, event, **data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def info(event, **data)
|
|
20
|
+
log(:info, event, **data)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn(event, **data)
|
|
24
|
+
log(:warn, event, **data)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error(event, **data)
|
|
28
|
+
log(:error, event, **data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with_context(**context)
|
|
32
|
+
ScopedLogger.new(self, **context)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class ScopedLogger
|
|
36
|
+
def initialize(logger, **context)
|
|
37
|
+
@logger = logger
|
|
38
|
+
@context = context
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def debug(event, **data) = @logger.debug(event, **@context, **data)
|
|
42
|
+
def info(event, **data) = @logger.info(event, **@context, **data)
|
|
43
|
+
def warn(event, **data) = @logger.warn(event, **@context, **data)
|
|
44
|
+
def error(event, **data) = @logger.error(event, **@context, **data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def log(level, event, **data)
|
|
50
|
+
return if LEVELS[level] < @level
|
|
51
|
+
|
|
52
|
+
entry = { ts: Time.now.utc.iso8601, level: level.to_s, event: event }.merge(data)
|
|
53
|
+
|
|
54
|
+
case @format
|
|
55
|
+
when :json
|
|
56
|
+
@output.puts(JSON.generate(entry))
|
|
57
|
+
when :text
|
|
58
|
+
@output.puts("[#{entry[:ts]}] #{level.to_s.upcase} #{event} #{data.map { |k, v| "#{k}=#{v}" }.join(' ')}")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|