emb 0.3.0 → 0.4.0.pre3
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/Gemfile +0 -1
- data/README.md +291 -56
- data/lib/emb/batch.rb +92 -38
- data/lib/emb/batch_dispatch.rb +171 -0
- data/lib/emb/batch_scope.rb +20 -0
- data/lib/emb/client.rb +30 -27
- data/lib/emb/commands.rb +97 -0
- data/lib/emb/configuration.rb +31 -5
- data/lib/emb/connection_router.rb +72 -0
- data/lib/emb/errors.rb +17 -0
- data/lib/emb/job_middleware.rb +16 -0
- data/lib/emb/middleware.rb +4 -6
- data/lib/emb/proxy.rb +34 -2
- data/lib/emb/railtie.rb +49 -0
- data/lib/emb/round_robin_pool.rb +100 -0
- data/lib/emb/script_reply_decode.rb +85 -0
- data/lib/emb/values_batch.rb +94 -0
- data/lib/emb/values_reply.rb +35 -0
- data/lib/emb.rb +20 -2
- metadata +12 -16
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'round_robin_pool'
|
|
4
|
+
require 'redis_client'
|
|
5
|
+
|
|
6
|
+
module Emb
|
|
7
|
+
# Owns a client's instance fan-out: one RoundRobinPool per configured url,
|
|
8
|
+
# instance-level round-robin on top of each pool's connection rotation, and
|
|
9
|
+
# the pre-send retry across instances.
|
|
10
|
+
#
|
|
11
|
+
# Retry is deliberately limited to PRESEND_CONNECTION_ERROR: a connection
|
|
12
|
+
# that was never established means the command was not written, so moving it
|
|
13
|
+
# to another instance is safe. Errors after a command may have been sent
|
|
14
|
+
# (timeouts, mid-flight connection loss) are never re-dispatched — retrying
|
|
15
|
+
# them would duplicate inference on the server.
|
|
16
|
+
class ConnectionRouter
|
|
17
|
+
# Captured at load so `rescue` keeps working when tests or apps replace the
|
|
18
|
+
# RedisClient constant (stub_const) — a runtime constant lookup in the
|
|
19
|
+
# rescue clause would otherwise resolve against the replacement.
|
|
20
|
+
PRESEND_CONNECTION_ERROR = RedisClient::CannotConnectError
|
|
21
|
+
|
|
22
|
+
attr_reader :pools
|
|
23
|
+
|
|
24
|
+
def initialize(size, urls, redis_options)
|
|
25
|
+
@pools = urls.map do |url|
|
|
26
|
+
RoundRobinPool.new(size) do
|
|
27
|
+
RedisClient.new(url: url, **redis_options)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
@next_instance = 0
|
|
31
|
+
@instance_mutex = Mutex.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(*args)
|
|
35
|
+
attempts = @pools.size
|
|
36
|
+
idx = pick_instance
|
|
37
|
+
last_error = nil
|
|
38
|
+
attempts.times do
|
|
39
|
+
return perform_command(@pools[idx], args)
|
|
40
|
+
rescue PRESEND_CONNECTION_ERROR => e
|
|
41
|
+
last_error = e
|
|
42
|
+
idx = (idx + 1) % @pools.size if @pools.size > 1
|
|
43
|
+
end
|
|
44
|
+
raise last_error
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def pick_instance
|
|
50
|
+
return 0 if @pools.size == 1
|
|
51
|
+
|
|
52
|
+
@instance_mutex.synchronize do
|
|
53
|
+
idx = @next_instance % @pools.size
|
|
54
|
+
@next_instance += 1
|
|
55
|
+
idx
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def perform_command(pool, args)
|
|
60
|
+
debug = Emb.debug?
|
|
61
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if debug
|
|
62
|
+
result = pool.with { |r| r.call(*args) }
|
|
63
|
+
log_command(args, started) if debug
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def log_command(args, started)
|
|
68
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
|
|
69
|
+
$stdout.puts "[EMB] #{args.map(&:inspect).join(' ')} (#{format('%.2f', elapsed)}ms)"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/emb/errors.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Raised when an EMB.MULTI batch fails — after redis-client exhausted its
|
|
5
|
+
# configured re-sends (`reconnect_attempts`) on a transient error, or on the
|
|
6
|
+
# first attempt for an operation error (server error reply) or the default
|
|
7
|
+
# `reconnect_attempts: 0` configuration. The underlying redis error is
|
|
8
|
+
# preserved as `cause`; `attempts` counts the wire sends that were made.
|
|
9
|
+
class ServerError < StandardError
|
|
10
|
+
attr_reader :attempts
|
|
11
|
+
|
|
12
|
+
def initialize(message, attempts: 1)
|
|
13
|
+
super(message)
|
|
14
|
+
@attempts = attempts
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'batch_loader'
|
|
4
|
+
require_relative 'batch_scope'
|
|
5
|
+
|
|
6
|
+
module Emb
|
|
7
|
+
# Job middleware: clears the per-thread batch scope after each job execution,
|
|
8
|
+
# even when the job raises. Registered as a Sidekiq/Shoryuken server
|
|
9
|
+
# middleware (positional args differ per framework and are unused) and used by
|
|
10
|
+
# the ActiveJob perform callback.
|
|
11
|
+
class JobMiddleware
|
|
12
|
+
def call(*_args, &)
|
|
13
|
+
BatchScope.wrap(&)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/emb/middleware.rb
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'batch_loader'
|
|
4
|
+
require_relative 'batch_scope'
|
|
4
5
|
|
|
5
6
|
module Emb
|
|
6
|
-
# Rack middleware
|
|
7
|
-
#
|
|
8
|
-
# cleared even when the app raises; a fresh scope starts with the next request.
|
|
7
|
+
# Rack middleware: clears the per-thread batch scope after each request,
|
|
8
|
+
# even when the app raises.
|
|
9
9
|
class Middleware
|
|
10
10
|
def initialize(app)
|
|
11
11
|
@app = app
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def call(env)
|
|
15
|
-
@app.call(env)
|
|
16
|
-
ensure
|
|
17
|
-
BatchLoader::Executor.clear_current
|
|
15
|
+
BatchScope.wrap { @app.call(env) }
|
|
18
16
|
end
|
|
19
17
|
end
|
|
20
18
|
end
|
data/lib/emb/proxy.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'values_reply'
|
|
4
|
+
|
|
3
5
|
module Emb
|
|
4
6
|
class Proxy
|
|
5
7
|
attr_reader :name
|
|
@@ -9,8 +11,20 @@ module Emb
|
|
|
9
11
|
@name = name
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
# Queries the model for one or more texts. format: :binary (default)
|
|
15
|
+
# returns the float32-unpacked vectors from the compact BLOB wire;
|
|
16
|
+
# format: :values sends the VALUES keyword and returns the decoded
|
|
17
|
+
# envelope — a Hash with dtype:/shape:/values: (Flats, flat for a single
|
|
18
|
+
# text, grouped per text when several are requested).
|
|
19
|
+
def [](text, *texts, format: :binary)
|
|
20
|
+
format = normalize_format(format)
|
|
21
|
+
if @client.lazy?
|
|
22
|
+
return Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts], format: format)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if format == :values
|
|
26
|
+
return values_query(text, texts)
|
|
27
|
+
end
|
|
14
28
|
|
|
15
29
|
set = Array(@client.send_command('EMB', @name.to_s, text, *texts))
|
|
16
30
|
result = set.map { |entry| entry.unpack('e*') }
|
|
@@ -23,5 +37,23 @@ module Emb
|
|
|
23
37
|
def inspect
|
|
24
38
|
"#<Emb::Proxy #{@name}>"
|
|
25
39
|
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def normalize_format(format)
|
|
44
|
+
unless %i[binary values].include?(format)
|
|
45
|
+
raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
format
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def values_query(text, texts)
|
|
52
|
+
reply = @client.send_command('EMB', @name.to_s, 'VALUES', text, *texts)
|
|
53
|
+
envelope = Emb::ValuesReply.parse(reply)
|
|
54
|
+
return envelope if texts.empty?
|
|
55
|
+
|
|
56
|
+
envelope.merge(values: Emb::ValuesReply.rows(envelope[:shape], envelope[:values]))
|
|
57
|
+
end
|
|
26
58
|
end
|
|
27
59
|
end
|
data/lib/emb/railtie.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails::Railtie)
|
|
4
|
+
require_relative '../emb'
|
|
5
|
+
|
|
6
|
+
module Emb
|
|
7
|
+
# Rails integration, loaded by emb.rb when Rails is present.
|
|
8
|
+
#
|
|
9
|
+
# config.emb.middleware = false # skip Emb::Middleware in the stack
|
|
10
|
+
# config.emb.job_middleware = false # skip job-scope protection
|
|
11
|
+
class Railtie < Rails::Railtie
|
|
12
|
+
config.emb = ActiveSupport::OrderedOptions.new
|
|
13
|
+
config.emb.middleware = true
|
|
14
|
+
config.emb.job_middleware = true
|
|
15
|
+
|
|
16
|
+
initializer 'emb.middleware' do |app|
|
|
17
|
+
next if config.emb.middleware == false
|
|
18
|
+
next if app.middleware.include?(Emb::Middleware)
|
|
19
|
+
|
|
20
|
+
app.middleware.use Emb::Middleware
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after_initialize do
|
|
24
|
+
next if config.emb.job_middleware == false
|
|
25
|
+
|
|
26
|
+
ActiveSupport.on_load(:active_job) do |base|
|
|
27
|
+
base.around_perform { |_job, block| Emb::BatchScope.wrap { block.call } }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if defined?(Sidekiq)
|
|
31
|
+
Sidekiq.configure_server do |sidekiq_config|
|
|
32
|
+
sidekiq_config.server_middleware { |chain| chain.add Emb::JobMiddleware }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if defined?(Sidekiq::Testing)
|
|
37
|
+
# Testing modes use their own middleware chain.
|
|
38
|
+
Sidekiq::Testing.server_middleware { |chain| chain.add Emb::JobMiddleware }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if defined?(Shoryuken)
|
|
42
|
+
Shoryuken.configure_server do |shoryuken_config|
|
|
43
|
+
shoryuken_config.server_middleware { |chain| chain.add Emb::JobMiddleware }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# A thread-safe pool of N RedisClient connections with round-robin selection.
|
|
5
|
+
# Behind connection-level load balancers (AWS Service Connect, an NLB, or an
|
|
6
|
+
# Envoy TCP proxy) each keep-alive connection is pinned to one upstream
|
|
7
|
+
# instance, so rotating commands across the pool spreads traffic across every
|
|
8
|
+
# instance — even single-threaded at zero concurrency. Connections are created
|
|
9
|
+
# up front but connect lazily on first use.
|
|
10
|
+
#
|
|
11
|
+
# Two behaviors deliberately match the connection_pool gem it replaces: a
|
|
12
|
+
# nested `with` from the same thread re-enters the held connection, and after
|
|
13
|
+
# `fork` (Puma preload_app, unicorn, resque) the pool closes inherited sockets
|
|
14
|
+
# and rebuilds its mutexes in the child so parent and child never share a
|
|
15
|
+
# connection.
|
|
16
|
+
class RoundRobinPool
|
|
17
|
+
# Pools are tracked only to reset them in forked children. WeakMap so a
|
|
18
|
+
# pool is reclaimed together with its client.
|
|
19
|
+
INSTANCES = Process.respond_to?(:fork) ? ObjectSpace::WeakMap.new : nil
|
|
20
|
+
private_constant :INSTANCES
|
|
21
|
+
|
|
22
|
+
THREAD_KEY = :emb_round_robin_pool_held
|
|
23
|
+
private_constant :THREAD_KEY
|
|
24
|
+
|
|
25
|
+
attr_reader :size, :connections
|
|
26
|
+
|
|
27
|
+
def self.after_fork
|
|
28
|
+
INSTANCES&.each_value(&:reload_after_fork!)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(size, &)
|
|
32
|
+
raise ArgumentError, "pool size must be >= 1 (got #{size})" if size < 1
|
|
33
|
+
|
|
34
|
+
@size = size
|
|
35
|
+
@connections = Array.new(size, &)
|
|
36
|
+
@locks = Array.new(size) { Mutex.new }
|
|
37
|
+
@next = 0
|
|
38
|
+
@index_mutex = Mutex.new
|
|
39
|
+
INSTANCES&.[]=(self, self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Yields the next connection in rotation order. Safe from multiple threads:
|
|
43
|
+
# up to `size` commands run in parallel, each on its own connection. A
|
|
44
|
+
# nested `with` from the same thread re-enters the connection this pool
|
|
45
|
+
# already holds without re-locking; other pools are unaffected.
|
|
46
|
+
def with(&)
|
|
47
|
+
held = Thread.current[THREAD_KEY]
|
|
48
|
+
if held&.key?(self)
|
|
49
|
+
yield @connections[held[self]]
|
|
50
|
+
else
|
|
51
|
+
take(held, &)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Child side of after_fork(): drop inherited sockets and sync state.
|
|
56
|
+
def reload_after_fork!
|
|
57
|
+
@connections.each { |conn| conn.close if conn.respond_to?(:close) }
|
|
58
|
+
@locks = Array.new(@size) { Mutex.new }
|
|
59
|
+
@next = 0
|
|
60
|
+
@index_mutex = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if Process.respond_to?(:fork)
|
|
64
|
+
# Hooks Process._fork (MRI 3.1+) so registered pools reset in the child.
|
|
65
|
+
module ForkTracker
|
|
66
|
+
def _fork
|
|
67
|
+
pid = super
|
|
68
|
+
RoundRobinPool.after_fork if pid.zero?
|
|
69
|
+
pid
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
Process.singleton_class.prepend(ForkTracker)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Acquires the next connection, records it as held by this thread/pool, and
|
|
78
|
+
# releases both on exit — even when the block raises.
|
|
79
|
+
def take(held)
|
|
80
|
+
idx, connection = pick
|
|
81
|
+
held ||= {}
|
|
82
|
+
Thread.current[THREAD_KEY] = held
|
|
83
|
+
held[self] = idx
|
|
84
|
+
begin
|
|
85
|
+
@locks[idx].synchronize { yield connection }
|
|
86
|
+
ensure
|
|
87
|
+
held.delete(self)
|
|
88
|
+
Thread.current[THREAD_KEY] = nil if held.empty?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pick
|
|
93
|
+
@index_mutex.synchronize do
|
|
94
|
+
idx = @next % @size
|
|
95
|
+
@next += 1
|
|
96
|
+
[idx, @connections[idx]]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Opt-in float decoding for scripted replies — the decode: keyword on
|
|
5
|
+
# Emb::Commands#eval / #evalsha. Kept out of the Commands module so the
|
|
6
|
+
# command surface stays small. decode: is validated (normalize_decode) before
|
|
7
|
+
# any command is sent, then applied after reply parsing.
|
|
8
|
+
module ScriptReplyDecode
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Validates an (un-normalized) decode option and canonicalizes it. Raises
|
|
12
|
+
# ArgumentError for unknown modes so callers fail before any command is
|
|
13
|
+
# sent. nil and an empty hash both mean "no decoding".
|
|
14
|
+
def normalize_decode(decode)
|
|
15
|
+
case decode
|
|
16
|
+
when nil, :f32
|
|
17
|
+
decode
|
|
18
|
+
when Hash
|
|
19
|
+
schema = decode.each_with_object({}) do |(field, mode), acc|
|
|
20
|
+
unless mode == :f32
|
|
21
|
+
raise ArgumentError, "unsupported decode mode #{mode.inspect} for field #{field.inspect} (supported: :f32)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
acc[field.to_s] = :f32
|
|
25
|
+
end
|
|
26
|
+
schema.empty? ? nil : schema
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError, "unsupported decode mode #{decode.inspect} (supported: :f32 or {field => :f32})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Applies a normalized decode to the parsed (script_hash'ed) reply array.
|
|
33
|
+
def apply_decode(parsed, decode)
|
|
34
|
+
return parsed if decode.nil?
|
|
35
|
+
|
|
36
|
+
case decode
|
|
37
|
+
when :f32
|
|
38
|
+
parsed.each_with_index.map { |v, i| decode_f32(v, position(i)) }
|
|
39
|
+
when Hash
|
|
40
|
+
parsed.each_with_index.map { |v, i| decode_hash_fields(v, decode, position(i)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def position(index)
|
|
45
|
+
index.zero? ? 'the reply' : "element #{index + 1}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# decode: :f32 — a float32-packed bulk unpacks to floats; a numeric array
|
|
49
|
+
# (the pre-float32_bytes reply style) passes through unchanged; anything
|
|
50
|
+
# else is a contract mismatch and raises.
|
|
51
|
+
def decode_f32(value, where)
|
|
52
|
+
case value
|
|
53
|
+
when String
|
|
54
|
+
unless (value.bytesize % 4).zero?
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"decode :f32: #{where} is a #{value.bytesize}-byte bulk, not a multiple of 4 — not a float32 vector"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
value.unpack('e*')
|
|
60
|
+
when Array
|
|
61
|
+
return value if value.all?(Numeric)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "decode :f32: #{where} is an array of non-numbers, not a float vector"
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"decode :f32: #{where} is a #{value.class}, expected a float32-packed bulk or a numeric array"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# decode: {field => :f32} — the reply (or each per-text reply) must be a
|
|
71
|
+
# hash; present fields decode as :f32, absent fields stay untouched.
|
|
72
|
+
def decode_hash_fields(value, schema, where)
|
|
73
|
+
unless value.is_a?(Hash)
|
|
74
|
+
raise ArgumentError, "decode #{schema.inspect}: #{where} is a #{value.class}, expected a hash reply"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
schema.each_key do |field|
|
|
78
|
+
next unless value.key?(field)
|
|
79
|
+
|
|
80
|
+
value[field] = decode_f32(value[field], "field #{field.inspect} of #{where}")
|
|
81
|
+
end
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'values_reply'
|
|
4
|
+
|
|
5
|
+
module Emb
|
|
6
|
+
# VALUES-format batch resolution for the deferred path. Split out of
|
|
7
|
+
# BatchDispatch so both modules stay under the Metrics rubric.
|
|
8
|
+
module ValuesBatch
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Resolves a VALUES reply onto the slice's items. Single-model slices get
|
|
12
|
+
# ONE envelope reply carrying every text's flattened values (row-major),
|
|
13
|
+
# so the rows are re-sliced per item; mixed-model slices get per-pair
|
|
14
|
+
# envelopes and map one row each. Failures stay nil, mirroring the BLOB
|
|
15
|
+
# path's MGET semantics.
|
|
16
|
+
def resolve(loader, slice, results)
|
|
17
|
+
if single_model?(slice)
|
|
18
|
+
rows = envelope_rows(results)
|
|
19
|
+
expect_rows!(slice, rows.size)
|
|
20
|
+
assign_rows(loader, slice, rows)
|
|
21
|
+
else
|
|
22
|
+
resolve_entries(loader, slice, results)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def single_model?(slice)
|
|
27
|
+
slice.map { |item| item[1] }.uniq.size == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def envelope_rows(results)
|
|
31
|
+
envelope = Emb::ValuesReply.parse(rehydrate_envelope(results))
|
|
32
|
+
Emb::ValuesReply.rows(envelope[:shape], envelope[:values])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Under RESP3, redis-client decodes the server's single-model VALUES map
|
|
36
|
+
# into a Ruby Hash, but dispatch_slice wraps every reply in Array(), which
|
|
37
|
+
# turns that Hash into an array of [key, value] pairs. Rehydrate those
|
|
38
|
+
# pairs back into a Hash so parse sees a real envelope; RESP2 flat pair
|
|
39
|
+
# arrays (top-level strings) pass through untouched.
|
|
40
|
+
def rehydrate_envelope(results)
|
|
41
|
+
return results.to_h if results.all? { |e| e.is_a?(Array) && e.size == 2 }
|
|
42
|
+
|
|
43
|
+
results
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def expect_rows!(slice, got)
|
|
47
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
48
|
+
return if got >= expected
|
|
49
|
+
|
|
50
|
+
raise ShortReplyError, "expected #{expected} VALUES rows, got #{got}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assign_rows(loader, slice, rows)
|
|
54
|
+
offset = 0
|
|
55
|
+
slice.each do |item|
|
|
56
|
+
_, _, text, = item
|
|
57
|
+
texts = Array(text)
|
|
58
|
+
values = rows[offset, texts.size]
|
|
59
|
+
offset += texts.size
|
|
60
|
+
loader.call(item, values.size == 1 ? values.first : values)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_entries(loader, slice, results)
|
|
65
|
+
expect_entries!(slice, results.size)
|
|
66
|
+
offset = 0
|
|
67
|
+
slice.each do |item|
|
|
68
|
+
_, _, text, = item
|
|
69
|
+
texts = Array(text)
|
|
70
|
+
values = entry_values(results, offset, texts)
|
|
71
|
+
offset += texts.size
|
|
72
|
+
loader.call(item, values.size == 1 ? values.first : values)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A short multi-model reply is a protocol violation: an explicit null slot
|
|
77
|
+
# is the only legal way to signal a missing pair (see the BLOB path).
|
|
78
|
+
def expect_entries!(slice, got)
|
|
79
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
80
|
+
return if got >= expected
|
|
81
|
+
|
|
82
|
+
raise ShortReplyError, "expected #{expected} VALUES reply entries, got #{got}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entry_values(results, offset, texts)
|
|
86
|
+
results[offset, texts.size].map { |entry| entry && row_of(entry) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def row_of(entry)
|
|
90
|
+
envelope = Emb::ValuesReply.parse(entry)
|
|
91
|
+
Emb::ValuesReply.rows(envelope[:shape], envelope[:values]).first
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Decoding for EMB VALUES envelopes (the RedisAI META+VALUES shape). The
|
|
5
|
+
# server replies with flat alternating key/value pairs under RESP2 — dtype,
|
|
6
|
+
# shape, values — where values are decimal bulk strings carrying the float64
|
|
7
|
+
# widening of each float32 dimension.
|
|
8
|
+
module ValuesReply
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Parses one envelope into a Hash. RESP2 replies arrive as a flat 6-element
|
|
12
|
+
# pair array; under `protocol: 3` redis-client already decoded the server's
|
|
13
|
+
# RESP3 map into a Ruby Hash (keys frozen strings, values already Floats),
|
|
14
|
+
# which is used directly. values are converted to Ruby Floats; an
|
|
15
|
+
# unparsable entry raises ArgumentError naming the offending value.
|
|
16
|
+
def parse(reply)
|
|
17
|
+
pairs = reply.is_a?(Hash) ? reply : reply.each_slice(2).to_h
|
|
18
|
+
shape = pairs.fetch('shape')
|
|
19
|
+
values = Array(pairs.fetch('values')).map { |v| float_value(v) }
|
|
20
|
+
{ dtype: pairs.fetch('dtype'), shape: shape, values: values }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# rows reshapes flat values into per-text rows using shape [m, dim].
|
|
24
|
+
def rows(shape, values)
|
|
25
|
+
dim = shape[1].to_i
|
|
26
|
+
values.each_slice(dim).to_a
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def float_value(v)
|
|
30
|
+
Float(v)
|
|
31
|
+
rescue ArgumentError, TypeError
|
|
32
|
+
raise ArgumentError, "EMB VALUES: unparsable decimal value #{v.inspect} (expected a numeric bulk string)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/emb.rb
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'emb/version'
|
|
4
4
|
require_relative 'emb/configuration'
|
|
5
|
+
require_relative 'emb/errors'
|
|
5
6
|
require_relative 'emb/commands'
|
|
6
7
|
require_relative 'emb/runtime_config'
|
|
7
8
|
require_relative 'emb/client'
|
|
8
9
|
require_relative 'emb/proxy'
|
|
10
|
+
require_relative 'emb/batch_scope'
|
|
11
|
+
require_relative 'emb/middleware'
|
|
12
|
+
require_relative 'emb/job_middleware'
|
|
9
13
|
require_relative 'emb/multi'
|
|
10
14
|
require_relative 'emb/batch'
|
|
11
|
-
require_relative 'emb/
|
|
15
|
+
require_relative 'emb/railtie' if defined?(Rails::Railtie)
|
|
12
16
|
|
|
13
17
|
module Emb
|
|
14
18
|
class << self
|
|
@@ -28,19 +32,33 @@ module Emb
|
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def [](name) = default_client[name]
|
|
31
|
-
def batch = default_client.batch
|
|
32
35
|
def models = default_client.models
|
|
33
36
|
def info(name) = default_client.info(name)
|
|
34
37
|
def stats = default_client.stats
|
|
35
38
|
def server_info(*sections) = default_client.server_info(*sections)
|
|
39
|
+
def cache_flush(model = nil) = default_client.cache_flush(model)
|
|
40
|
+
def save_cache = default_client.save_cache
|
|
36
41
|
def config = default_client.config
|
|
37
42
|
def help = default_client.help
|
|
38
43
|
def ping = default_client.ping
|
|
39
44
|
def ready = default_client.ready
|
|
40
45
|
def ready? = default_client.ready?
|
|
41
46
|
def multi(&) = default_client.multi(&)
|
|
47
|
+
|
|
48
|
+
def eval(model, script, texts, args = [], decode: nil)
|
|
49
|
+
default_client.eval(model, script, texts, args, decode: decode)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def evalsha(model, sha, texts, args = [], decode: nil)
|
|
53
|
+
default_client.evalsha(model, sha, texts, args, decode: decode)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def script = default_client.script
|
|
57
|
+
|
|
42
58
|
def reset_registry! = default_client.reset_registry!
|
|
59
|
+
|
|
43
60
|
def debug? = @debug
|
|
61
|
+
|
|
44
62
|
def send_command(*) = default_client.send_command(*)
|
|
45
63
|
|
|
46
64
|
def debug!
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: emb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0.pre3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- elcuervo
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: batch-loader
|
|
@@ -24,20 +24,6 @@ dependencies:
|
|
|
24
24
|
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
26
|
version: '2.0'
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: connection_pool
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - "~>"
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '2.5'
|
|
34
|
-
type: :runtime
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - "~>"
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '2.5'
|
|
41
27
|
- !ruby/object:Gem::Dependency
|
|
42
28
|
name: redis-client
|
|
43
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -63,13 +49,23 @@ files:
|
|
|
63
49
|
- README.md
|
|
64
50
|
- lib/emb.rb
|
|
65
51
|
- lib/emb/batch.rb
|
|
52
|
+
- lib/emb/batch_dispatch.rb
|
|
53
|
+
- lib/emb/batch_scope.rb
|
|
66
54
|
- lib/emb/client.rb
|
|
67
55
|
- lib/emb/commands.rb
|
|
68
56
|
- lib/emb/configuration.rb
|
|
57
|
+
- lib/emb/connection_router.rb
|
|
58
|
+
- lib/emb/errors.rb
|
|
59
|
+
- lib/emb/job_middleware.rb
|
|
69
60
|
- lib/emb/middleware.rb
|
|
70
61
|
- lib/emb/multi.rb
|
|
71
62
|
- lib/emb/proxy.rb
|
|
63
|
+
- lib/emb/railtie.rb
|
|
64
|
+
- lib/emb/round_robin_pool.rb
|
|
72
65
|
- lib/emb/runtime_config.rb
|
|
66
|
+
- lib/emb/script_reply_decode.rb
|
|
67
|
+
- lib/emb/values_batch.rb
|
|
68
|
+
- lib/emb/values_reply.rb
|
|
73
69
|
- lib/emb/version.rb
|
|
74
70
|
homepage: https://github.com/elcuervo/emb
|
|
75
71
|
licenses:
|