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
data/lib/emb/batch.rb
CHANGED
|
@@ -1,69 +1,123 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'batch_loader'
|
|
4
|
+
require 'redis_client'
|
|
5
|
+
require_relative 'batch_dispatch'
|
|
6
|
+
require_relative 'values_batch'
|
|
4
7
|
|
|
5
8
|
module Emb
|
|
9
|
+
extend BatchDispatch
|
|
10
|
+
|
|
6
11
|
BATCH_KEY = :emb
|
|
7
12
|
|
|
13
|
+
# Raised by resolve_slice when the server reply carries fewer entries than
|
|
14
|
+
# the slice's texts. Subclasses RedisClient::ProtocolError so the existing
|
|
15
|
+
# fail-closed rescue/wrap paths treat it as a client error, but stays
|
|
16
|
+
# distinct so transient_error? does not count it as a transport retry: the
|
|
17
|
+
# reply shape was wrong on the single send — nothing was re-sent.
|
|
18
|
+
ShortReplyError = Class.new(RedisClient::ProtocolError)
|
|
19
|
+
|
|
8
20
|
BATCH_BLOCK = lambda do |items, loader, _args|
|
|
9
21
|
items.group_by(&:first).each do |client, client_items|
|
|
10
22
|
chunk = client.respond_to?(:batch_size) && client.batch_size ? client.batch_size : Emb.configuration.batch_size
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
loader.call(item, values.size == 1 ? values.first : values)
|
|
24
|
+
slices = pack_slices(client_items, chunk)
|
|
25
|
+
if client.respond_to?(:parallel_batch?) && client.parallel_batch? && slices.size > 1
|
|
26
|
+
dispatch_parallel(client, slices, loader)
|
|
27
|
+
else
|
|
28
|
+
# Serial dispatch: one share in flight at a time. Redis errors fail
|
|
29
|
+
# closed with context; anything else is a local bug and is re-raised
|
|
30
|
+
# unchanged after the pending set is dropped.
|
|
31
|
+
slices.each do |slice|
|
|
32
|
+
resolve_slice(loader, slice, dispatch_slice(client, slice))
|
|
33
|
+
rescue RedisClient::Error => e
|
|
34
|
+
fail_batch!(e, slice: slice, budget: retry_budget(client))
|
|
35
|
+
rescue StandardError
|
|
36
|
+
clear_batch_pending!
|
|
37
|
+
raise
|
|
27
38
|
end
|
|
28
39
|
end
|
|
29
40
|
end
|
|
30
41
|
end
|
|
31
42
|
|
|
32
43
|
class << self
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
# BatchDispatch mechanics (pack_slices, dispatch_parallel, resolve_slice,
|
|
45
|
+
# ...) are internal to BATCH_BLOCK; nothing outside Emb calls them with an
|
|
46
|
+
# explicit receiver.
|
|
47
|
+
private(*BatchDispatch.instance_methods(false))
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
def build_batch_loader(client, model, text, format: :binary)
|
|
50
|
+
unless %i[binary values].include?(format)
|
|
51
|
+
raise ArgumentError, "unknown format #{format.inspect} (expected :binary or :values)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# default_value []: an item whose batch failed (fail-closed) resolves to
|
|
55
|
+
# an empty vector collection instead of nil, so resolver methods like
|
|
56
|
+
# `loader.sum` do not blow up with NoMethodError-on-nil.
|
|
57
|
+
# Items carry [client, model, text, format] so the dispatch knows how to
|
|
58
|
+
# shape and parse the reply.
|
|
59
|
+
BatchLoader.for([client, model, text, format]).batch(default_value: [], key: BATCH_KEY, &BATCH_BLOCK)
|
|
42
60
|
end
|
|
43
61
|
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
# Removes every pending item of the batch scope. batch-loader prunes
|
|
63
|
+
# pending items only after a successful batch block, so failed batches
|
|
64
|
+
# would otherwise stay queued: retries would re-run the whole batch and
|
|
65
|
+
# stale items would be re-sent by later batches in the same scope. Guarded
|
|
66
|
+
# for a nil executor (no batch scope).
|
|
67
|
+
def clear_batch_pending!
|
|
68
|
+
key = [BATCH_BLOCK.source_location, BATCH_KEY]
|
|
69
|
+
BatchLoader::Executor.current&.items_by_block&.delete(key)
|
|
46
70
|
end
|
|
47
71
|
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
# Fail-closed tail for a failed batch: clear the pending set, then raise
|
|
73
|
+
# Emb::ServerError carrying the cause and the models/texts/attempts
|
|
74
|
+
# context. The raise happens inside a rescue of the original error so Ruby
|
|
75
|
+
# attaches it as `cause` regardless of whether this runs while a rescue is
|
|
76
|
+
# active (serial path) or from the forcing thread after parallel workers
|
|
77
|
+
# captured the error (parallel path). `attempts` counts the error's retry
|
|
78
|
+
# class: connection/protocol errors (the ones redis-client actually
|
|
79
|
+
# re-sends) report `budget + 1`; operation errors and read timeouts (never
|
|
80
|
+
# re-sent — a timeout may already have executed server work) report 1. A
|
|
81
|
+
# pre-send connection refusal is retried across instances by the
|
|
82
|
+
# connection router before it can reach here as a terminal error.
|
|
83
|
+
def fail_batch!(error, slice:, budget:)
|
|
84
|
+
clear_batch_pending!
|
|
85
|
+
attempts = transient_error?(error) ? budget + 1 : 1
|
|
86
|
+
models = slice.map { |_, model, _| model }.uniq.join(', ')
|
|
87
|
+
texts = slice.sum { |_, _, text| Array(text).size }
|
|
88
|
+
message = "batch failed after #{attempts} attempt(s) " \
|
|
89
|
+
"(models: #{models}, #{texts} text(s)) #{error.class}: #{error.message}"
|
|
90
|
+
begin
|
|
91
|
+
raise error
|
|
92
|
+
rescue StandardError
|
|
93
|
+
raise ServerError.new(message, attempts: attempts)
|
|
94
|
+
end
|
|
50
95
|
end
|
|
51
|
-
end
|
|
52
96
|
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
# How many additional re-sends redis-client performs for transient
|
|
98
|
+
# failures: the per-client option when set, else the global default.
|
|
99
|
+
# Normalized to an Integer (redis-client also accepts a delay Array, whose
|
|
100
|
+
# truthy slots each grant one retry).
|
|
101
|
+
def retry_budget(client)
|
|
102
|
+
value = client.reconnect_attempts if client.respond_to?(:reconnect_attempts)
|
|
103
|
+
value = Emb.configuration.reconnect_attempts if value.nil?
|
|
104
|
+
return value if value.is_a?(Integer)
|
|
55
105
|
|
|
56
|
-
|
|
57
|
-
@client = client
|
|
58
|
-
@name = name
|
|
106
|
+
value.is_a?(Array) ? value.count(&:itself) : 0
|
|
59
107
|
end
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
109
|
+
# The error classes redis-client actually re-dispatches under
|
|
110
|
+
# reconnect_attempts: ConnectionError (connect/transport breaks) and
|
|
111
|
+
# ProtocolError. ReadTimeoutError is intercepted before the retry loop
|
|
112
|
+
# (a timed-out command may already have executed server work), so it is
|
|
113
|
+
# terminal and counts a single attempt. Emb::ShortReplyError is raised
|
|
114
|
+
# locally by resolve_slice (reply shape wrong on the single send), so it
|
|
115
|
+
# is likewise terminal and counts one attempt.
|
|
116
|
+
def transient_error?(error)
|
|
117
|
+
(error.is_a?(RedisClient::ConnectionError) && !error.is_a?(RedisClient::ReadTimeoutError)) ||
|
|
118
|
+
(error.is_a?(RedisClient::ProtocolError) && !error.is_a?(ShortReplyError))
|
|
63
119
|
end
|
|
64
120
|
|
|
65
|
-
|
|
66
|
-
"#<Emb::BatchModelProxy #{@name}>"
|
|
67
|
-
end
|
|
121
|
+
private :clear_batch_pending!, :fail_batch!, :retry_budget, :transient_error?
|
|
68
122
|
end
|
|
69
123
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Emb
|
|
4
|
+
# Share-dispatch mechanics for the deferred batch path: EMB/MULTI wire
|
|
5
|
+
# shaping, per-slice result mapping, and the bounded concurrent fan-out used
|
|
6
|
+
# by `lazy: :batch`. Extended into Emb by batch.rb.
|
|
7
|
+
module BatchDispatch
|
|
8
|
+
# Sends one chunk share and returns the raw reply entries. Single-model
|
|
9
|
+
# shares use plain `EMB <model> <text>...` (one inference, model once);
|
|
10
|
+
# mixed-model shares keep EMB.MULTI per-pair nil semantics. Errors after
|
|
11
|
+
# the command may have been sent are terminal and propagate so the forcing
|
|
12
|
+
# thread can fail closed.
|
|
13
|
+
def dispatch_slice(client, slice)
|
|
14
|
+
models = slice.map { |_, model, _, _| model }.uniq
|
|
15
|
+
args = models.size == 1 ? same_model_args(slice, models.first) : mixed_model_args(slice)
|
|
16
|
+
Array(client.send_command(*args))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def same_model_args(slice, model)
|
|
20
|
+
texts = slice.flat_map { |_, _, text, _| Array(text) }
|
|
21
|
+
if slice_format(slice) == :values
|
|
22
|
+
['EMB', model.to_s, 'VALUES', *texts]
|
|
23
|
+
else
|
|
24
|
+
['EMB', model.to_s, *texts]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mixed_model_args(slice)
|
|
29
|
+
pairs = slice.flat_map { |_, model, text, _| Array(text).flat_map { |t| [model.to_s, t] } }
|
|
30
|
+
if slice_format(slice) == :values
|
|
31
|
+
['EMB.MULTI', 'VALUES', *pairs]
|
|
32
|
+
else
|
|
33
|
+
['EMB.MULTI', *pairs]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# slice_format: the batch item layout is [client, model, text, format];
|
|
38
|
+
# items created before the format slot existed default to :binary.
|
|
39
|
+
def slice_format(slice)
|
|
40
|
+
formats = slice.map { |item| item[3] }.uniq
|
|
41
|
+
if formats.size > 1
|
|
42
|
+
raise ArgumentError, "cannot mix :binary and :values formats in one batch"
|
|
43
|
+
end
|
|
44
|
+
formats.first || :binary
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Maps a slice's reply entries onto its items in deferral order. Runs on
|
|
48
|
+
# the forcing thread (batch-loader's executor is per-thread), so workers
|
|
49
|
+
# never resolve loaders. A reply shorter than the slice's texts is a
|
|
50
|
+
# protocol violation: fail the batch via Emb::ShortReplyError (distinct
|
|
51
|
+
# from a client-raised ProtocolError so it is not counted as a transport
|
|
52
|
+
# retry).
|
|
53
|
+
def resolve_slice(loader, slice, results)
|
|
54
|
+
if slice_format(slice) == :values
|
|
55
|
+
return ValuesBatch.resolve(loader, slice, results)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
expected = slice.sum { |_, _, text, _| Array(text).size }
|
|
59
|
+
unless results.size >= expected
|
|
60
|
+
raise ShortReplyError, "expected #{expected} reply entries, got #{results.size}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
offset = 0
|
|
64
|
+
slice.each do |item|
|
|
65
|
+
_, _, text, _ = item
|
|
66
|
+
texts = Array(text)
|
|
67
|
+
values = entry_values(results, offset, texts)
|
|
68
|
+
offset += texts.size
|
|
69
|
+
|
|
70
|
+
# eager Proxy#[] shape: vector for a single text, vectors for many
|
|
71
|
+
loader.call(item, values)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def entry_values(results, offset, texts)
|
|
76
|
+
values = results[offset, texts.size].map { |entry| entry&.unpack('e*') }
|
|
77
|
+
values.size == 1 ? values.first : values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The worker captures failures as outcomes; only the forcing thread
|
|
81
|
+
# resolves loaders (see resolve_slice).
|
|
82
|
+
def dispatch_share(client, slice)
|
|
83
|
+
[:ok, slice, dispatch_slice(client, slice)]
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
[:error, slice, e]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Dispatches all shares concurrently over at most the client's connection
|
|
89
|
+
# capacity workers, then fails closed on the first terminal error.
|
|
90
|
+
def dispatch_parallel(client, slices, loader)
|
|
91
|
+
workers = slices.size.clamp(1, worker_capacity(client))
|
|
92
|
+
queue = share_queue(slices, workers)
|
|
93
|
+
outcomes = Array.new(slices.size)
|
|
94
|
+
run_worker_threads(client, queue, outcomes, workers)
|
|
95
|
+
resolve_outcomes(client, outcomes, loader)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def share_queue(slices, workers)
|
|
99
|
+
queue = Queue.new
|
|
100
|
+
slices.each_with_index { |slice, index| queue << [index, slice] }
|
|
101
|
+
workers.times { queue << nil }
|
|
102
|
+
queue
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def worker_capacity(client)
|
|
106
|
+
return Float::INFINITY unless client.respond_to?(:pools)
|
|
107
|
+
|
|
108
|
+
client.pools.sum { |pool| pool.respond_to?(:size) ? pool.size : 1 }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def run_worker_threads(client, queue, outcomes, workers)
|
|
112
|
+
workers.times.map do
|
|
113
|
+
Thread.new do
|
|
114
|
+
while (job = queue.pop)
|
|
115
|
+
index, slice = job
|
|
116
|
+
outcomes[index] = dispatch_share(client, slice)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end.each(&:join)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Redis errors fail closed with context (Emb::ServerError, matching the
|
|
123
|
+
# serial path); non-redis errors are local bugs and re-raise unchanged.
|
|
124
|
+
def resolve_outcomes(client, outcomes, loader)
|
|
125
|
+
first_error, failed_slice = collect_outcomes(outcomes, loader)
|
|
126
|
+
return unless first_error
|
|
127
|
+
|
|
128
|
+
raise first_error unless first_error.is_a?(RedisClient::Error)
|
|
129
|
+
|
|
130
|
+
fail_batch!(first_error, slice: failed_slice, budget: retry_budget(client))
|
|
131
|
+
rescue StandardError
|
|
132
|
+
clear_batch_pending!
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def collect_outcomes(outcomes, loader)
|
|
137
|
+
first_error = nil
|
|
138
|
+
failed_slice = nil
|
|
139
|
+
outcomes.each do |status, slice, result|
|
|
140
|
+
if status == :ok
|
|
141
|
+
resolve_slice(loader, slice, result)
|
|
142
|
+
else
|
|
143
|
+
first_error ||= result
|
|
144
|
+
failed_slice ||= slice
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
[first_error, failed_slice]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Packs items into shares by accumulated text count so one command stays
|
|
151
|
+
# within `chunk` texts. An item larger than the chunk goes alone; the
|
|
152
|
+
# server truncates it with null reply slots, as in the eager path. `used`
|
|
153
|
+
# carries the running text count of the current slice instead of
|
|
154
|
+
# re-summing it for every item (O(n) per item would be O(n²)).
|
|
155
|
+
def pack_slices(items, chunk)
|
|
156
|
+
slices = []
|
|
157
|
+
used = 0
|
|
158
|
+
items.each do |item|
|
|
159
|
+
size = Array(item[2]).size
|
|
160
|
+
if slices.empty? || used + size > chunk
|
|
161
|
+
slices << [item]
|
|
162
|
+
used = size
|
|
163
|
+
else
|
|
164
|
+
slices.last << item
|
|
165
|
+
used += size
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
slices
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'batch_loader'
|
|
4
|
+
|
|
5
|
+
module Emb
|
|
6
|
+
# Runs a block of work and clears the per-thread batch scope afterwards,
|
|
7
|
+
# even when the block raises. Clearing is unconditional: batch-loader scopes
|
|
8
|
+
# are thread-global and shared by every client, so a deferred-mode client
|
|
9
|
+
# (global `lazy` config or a per-call override) inside the block would
|
|
10
|
+
# otherwise leak cached values and pending loaders into the next request/job.
|
|
11
|
+
# Under the eager default (`lazy: false`) embed calls never create a scope,
|
|
12
|
+
# so the clear is a no-op — the middleware is observably inert there.
|
|
13
|
+
module BatchScope
|
|
14
|
+
def self.wrap
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
BatchLoader::Executor.clear_current
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/emb/client.rb
CHANGED
|
@@ -1,52 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'connection_router'
|
|
4
4
|
require 'redis_client'
|
|
5
5
|
|
|
6
6
|
module Emb
|
|
7
7
|
class Client
|
|
8
8
|
include Commands
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Accepts a single redis URL (String) or several (Array of Strings) naming
|
|
11
|
+
# interchangeable emb instances serving the same model set; each url gets
|
|
12
|
+
# its own pool of `pool` connections (see ConnectionRouter).
|
|
13
|
+
def initialize(pool: nil, lazy: nil, **redis_options)
|
|
13
14
|
cfg = Emb.configuration
|
|
14
|
-
@
|
|
15
|
+
@lazy_mode = lazy.nil? ? cfg.lazy : validate_lazy!(lazy)
|
|
15
16
|
@batch_size = redis_options.delete(:batch_size) || cfg.batch_size
|
|
16
|
-
size = pool.nil? ? cfg.pool : pool
|
|
17
17
|
url = extract_url!(redis_options, cfg)
|
|
18
|
+
# Captured before ConnectionRouter consumes the merged options, so
|
|
19
|
+
# fail-closed batches can report the retry budget (Emb::ServerError).
|
|
18
20
|
redis_options = merged_redis_options(redis_options, cfg, url)
|
|
19
|
-
|
|
20
|
-
@
|
|
21
|
-
RedisClient.new(url: url, **redis_options)
|
|
22
|
-
end
|
|
23
|
-
|
|
21
|
+
@reconnect_attempts = redis_options.fetch(:reconnect_attempts, cfg.reconnect_attempts)
|
|
22
|
+
@router = ConnectionRouter.new(pool || cfg.pool, instance_urls(url), redis_options)
|
|
24
23
|
@registry = {}
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
def send_command(
|
|
28
|
-
return @pool.with { |r| r.call(*args) } unless Emb.debug?
|
|
26
|
+
def send_command(...) = @router.call(...)
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
result = @pool.with { |r| r.call(*args) }
|
|
32
|
-
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
|
28
|
+
def pools = @router.pools
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
result
|
|
37
|
-
end
|
|
30
|
+
attr_reader :batch_size, :lazy_mode, :reconnect_attempts
|
|
38
31
|
|
|
39
32
|
def [](name)
|
|
40
33
|
@registry[name] ||= Proxy.new(self, name.to_sym)
|
|
41
34
|
end
|
|
42
35
|
|
|
43
|
-
def
|
|
44
|
-
@batch ||= BatchProxy.new(self)
|
|
45
|
-
end
|
|
36
|
+
def lazy? = @lazy_mode != false
|
|
46
37
|
|
|
47
|
-
def batch
|
|
48
|
-
@batch_enabled
|
|
49
|
-
end
|
|
38
|
+
def parallel_batch? = @lazy_mode == :batch
|
|
50
39
|
|
|
51
40
|
# Live view of the server's runtime configuration (CONFIG GET/SET).
|
|
52
41
|
def config
|
|
@@ -103,9 +92,23 @@ module Emb
|
|
|
103
92
|
|
|
104
93
|
private
|
|
105
94
|
|
|
95
|
+
def validate_lazy!(value)
|
|
96
|
+
unless Configuration::LAZY_MODES.include?(value)
|
|
97
|
+
raise ArgumentError, "lazy must be false, :multi, or :batch (got #{value.inspect})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
value
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def instance_urls(url)
|
|
104
|
+
raise ArgumentError, 'url array must not be empty' if url.is_a?(Array) && url.empty?
|
|
105
|
+
|
|
106
|
+
url.nil? ? [nil] : Array(url)
|
|
107
|
+
end
|
|
108
|
+
|
|
106
109
|
def merged_redis_options(opts, cfg, url)
|
|
107
110
|
defaults = cfg.to_h
|
|
108
|
-
keys = defaults.keys - %i[url pool
|
|
111
|
+
keys = defaults.keys - %i[url pool lazy batch_size]
|
|
109
112
|
keys -= %i[host port] if url
|
|
110
113
|
|
|
111
114
|
keys.each do |key|
|
data/lib/emb/commands.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'script_reply_decode'
|
|
4
|
+
|
|
3
5
|
module Emb
|
|
4
6
|
# Server-status and runtime-config command wrappers. They only depend on
|
|
5
7
|
# #send_command, so they live in this module (included into Emb::Client)
|
|
6
8
|
# rather than growing the client class.
|
|
7
9
|
module Commands
|
|
10
|
+
include ScriptReplyDecode
|
|
11
|
+
|
|
8
12
|
# EMB.STATS as a Symbol-keyed Hash. No client-side type layer: values are
|
|
9
13
|
# exactly what the RESP decoder returned (Integer where the server sends
|
|
10
14
|
# RESP integers, String otherwise).
|
|
@@ -19,8 +23,101 @@ module Emb
|
|
|
19
23
|
parse_info(send_command('INFO', *sections.map(&:to_s)))
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# Evaluate a script against a model (EMB.EVAL). texts become the script's
|
|
27
|
+
# KEYS, args its ARGV. Replies are typed per the reply grammar: hash
|
|
28
|
+
# replies (flat field/value pair arrays under RESP2) become Ruby Hashes,
|
|
29
|
+
# nested values recurse. decode: opt-in float decoding — see
|
|
30
|
+
# parse_script_reply (decode: :f32 or decode: {field => :f32}).
|
|
31
|
+
def eval(model, script, texts, args = [], decode: nil)
|
|
32
|
+
texts = Array(texts)
|
|
33
|
+
schema = normalize_decode(decode)
|
|
34
|
+
reply = send_command('EMB.EVAL', model.to_s, script, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
|
|
35
|
+
parse_script_reply(reply, multi: texts.size > 1, decode: schema)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Evaluate a previously loaded script by SHA1 (EMB.EVSHA). See #eval.
|
|
39
|
+
def evalsha(model, sha, texts, args = [], decode: nil)
|
|
40
|
+
texts = Array(texts)
|
|
41
|
+
schema = normalize_decode(decode)
|
|
42
|
+
reply = send_command('EMB.EVSHA', model.to_s, sha, texts.size, *texts.map(&:to_s), *args.map(&:to_s))
|
|
43
|
+
parse_script_reply(reply, multi: texts.size > 1, decode: schema)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# EMB.SCRIPT subcommands: a small command object so the surface reads
|
|
47
|
+
# naturally (script.load / script.exists / script.flush).
|
|
48
|
+
def script
|
|
49
|
+
@script ||= ScriptCommands.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# EMB.SCRIPT LOAD/EXISTS/FLUSH.
|
|
53
|
+
class ScriptCommands
|
|
54
|
+
def initialize(client)
|
|
55
|
+
@client = client
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compile, cache, and return the script's SHA1 for the model.
|
|
59
|
+
def load(model, source)
|
|
60
|
+
@client.send_command('EMB.SCRIPT', 'LOAD', model.to_s, source)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether each SHA is cached for the model.
|
|
64
|
+
def exists(model, *shas)
|
|
65
|
+
@client.send_command('EMB.SCRIPT', 'EXISTS', model.to_s, *shas.map(&:to_s)).map { |flag| flag == 1 }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Clear the model's cached scripts, or all models when omitted.
|
|
69
|
+
def flush(model = nil)
|
|
70
|
+
args = ['EMB.SCRIPT', 'FLUSH']
|
|
71
|
+
args << model.to_s if model
|
|
72
|
+
@client.send_command(*args)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove all cached embeddings, or only entries belonging to +model+.
|
|
77
|
+
# Returns the server's integer removed-entry count.
|
|
78
|
+
def cache_flush(model = nil)
|
|
79
|
+
args = ['EMB.CACHE.FLUSH']
|
|
80
|
+
args << model.to_s unless model.nil?
|
|
81
|
+
send_command(*args)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Ask the server to start an asynchronous cache snapshot. Completion and
|
|
85
|
+
# failures are observable through #stats or #server_info(:cache).
|
|
86
|
+
def save_cache
|
|
87
|
+
send_command('EMB.SAVE')
|
|
88
|
+
end
|
|
89
|
+
|
|
22
90
|
private
|
|
23
91
|
|
|
92
|
+
# Converts a scripted reply (single value, or an array of per-text values
|
|
93
|
+
# for multi-text calls) through the hash grammar: a flat field/value pair
|
|
94
|
+
# array becomes a Hash, recursively. Pure even-length string lists are
|
|
95
|
+
# indistinguishable from two-field hashes on the RESP2 wire, so scripts
|
|
96
|
+
# that must return them should nest them (wrap the list in a table).
|
|
97
|
+
#
|
|
98
|
+
# decode is the normalized (see normalize_decode) opt-in float decoding:
|
|
99
|
+
# nil → no decoding, parsing exactly as before
|
|
100
|
+
# :f32 → each value position is unpack('e*')'d when
|
|
101
|
+
# it is a packed float bulk; numeric arrays
|
|
102
|
+
# pass through unchanged (element types kept)
|
|
103
|
+
# {field => :f32, ...} → after hash parsing, the named field(s) of
|
|
104
|
+
# each hash reply decode as :f32 (fields
|
|
105
|
+
# absent from a reply are left untouched)
|
|
106
|
+
def parse_script_reply(reply, multi:, decode: nil)
|
|
107
|
+
parsed = (multi ? reply : [reply]).map { |v| script_hash(v) }
|
|
108
|
+
decoded = apply_decode(parsed, decode)
|
|
109
|
+
multi ? decoded : decoded.first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def script_hash(value)
|
|
113
|
+
return value unless value.is_a?(Array) && value.size.even?
|
|
114
|
+
|
|
115
|
+
pairs = value.each_slice(2).to_a
|
|
116
|
+
return value unless pairs.all? { |field, _| field.is_a?(String) }
|
|
117
|
+
|
|
118
|
+
pairs.to_h { |field, val| [field, script_hash(val)] }
|
|
119
|
+
end
|
|
120
|
+
|
|
24
121
|
# Parse Redis INFO section text into a nested Hash:
|
|
25
122
|
# {Server: {redis_version: "0.2.4", uptime_secs: "7"}, Cache: {…}, …}
|
|
26
123
|
# Section names and keys are Symbols; values pass through as the server
|
data/lib/emb/configuration.rb
CHANGED
|
@@ -3,25 +3,51 @@
|
|
|
3
3
|
module Emb
|
|
4
4
|
class Configuration
|
|
5
5
|
OPTIONS = %i[
|
|
6
|
-
host port url pool
|
|
6
|
+
host port url pool lazy batch_size driver protocol
|
|
7
7
|
connect_timeout read_timeout write_timeout reconnect_attempts
|
|
8
8
|
].freeze
|
|
9
9
|
|
|
10
|
+
# Execution modes for embed calls. false = eager (default, one EMB round
|
|
11
|
+
# trip per call); :multi = defer and coalesce into EMB.MULTI, serial;
|
|
12
|
+
# :batch = defer and execute chunk shares concurrently. Mutually exclusive
|
|
13
|
+
# by construction.
|
|
14
|
+
LAZY_MODES = [false, :multi, :batch].freeze
|
|
15
|
+
|
|
10
16
|
attr_accessor(*OPTIONS)
|
|
11
17
|
|
|
18
|
+
def lazy=(value)
|
|
19
|
+
unless LAZY_MODES.include?(value)
|
|
20
|
+
raise ArgumentError, "lazy must be false, :multi, or :batch (got #{value.inspect})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@lazy = value
|
|
24
|
+
end
|
|
25
|
+
|
|
12
26
|
def initialize
|
|
13
27
|
self.host = 'localhost'
|
|
14
28
|
self.port = 6379
|
|
15
29
|
self.url = nil
|
|
16
30
|
self.pool = 5
|
|
17
|
-
self.
|
|
31
|
+
self.lazy = false
|
|
18
32
|
self.batch_size = 512
|
|
19
33
|
self.driver = nil
|
|
20
34
|
self.protocol = 2
|
|
21
35
|
self.connect_timeout = nil
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
# Read/write timeouts are explicit, NOT nil: nil forwards nothing and
|
|
37
|
+
# redis-client silently applies its 1.0s default, which makes 512-pair
|
|
38
|
+
# EMB.MULTI batches fail under load. 10s covers worst-case inference on
|
|
39
|
+
# shared CPUs; scale up if you raise batch_size.
|
|
40
|
+
self.read_timeout = 10
|
|
41
|
+
self.write_timeout = 10
|
|
42
|
+
# 0 = default: a failing batch fails closed after one attempt and raises
|
|
43
|
+
# Emb::ServerError. Set > 0 to opt into bounded re-sends: redis-client
|
|
44
|
+
# retries connection/protocol failures (never read timeouts) up to that
|
|
45
|
+
# many extra times — EMB.MULTI is not idempotent, so each re-send
|
|
46
|
+
# duplicates inference — and the batch still terminates in
|
|
47
|
+
# Emb::ServerError. An Array of per-retry delays is also accepted (one
|
|
48
|
+
# retry per entry). Operation errors (server error replies) are never
|
|
49
|
+
# retried.
|
|
50
|
+
self.reconnect_attempts = 0
|
|
25
51
|
end
|
|
26
52
|
|
|
27
53
|
def to_h
|