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.
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
- # Chunk at batch_size pairs per EMB.MULTI so a single command stays well
13
- # under the server's max_pairs cap and within client read timeouts.
14
- client_items.each_slice(chunk) do |slice|
15
- pairs = slice.flat_map { |_, model, text| Array(text).flat_map { |t| [model.to_s, t] } }
16
- results = Array(client.send_command('EMB.MULTI', *pairs))
17
-
18
- offset = 0
19
- slice.each do |item|
20
- _, _, text = item
21
- texts = Array(text)
22
- values = results[offset, texts.size].map { |entry| entry&.unpack('e*') }
23
- offset += texts.size
24
-
25
- # eager Proxy#[] shape: vector for a single text, vectors for many
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
- def build_batch_loader(client, model, text)
34
- BatchLoader.for([client, model, text]).batch(key: BATCH_KEY, &BATCH_BLOCK)
35
- end
36
- end
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
- class BatchProxy
39
- def initialize(client)
40
- @client = client
41
- @models = {}
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
- def [](name)
45
- @models[name.to_sym] ||= BatchModelProxy.new(@client, name.to_sym)
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
- def inspect
49
- "#<Emb::BatchProxy client=#{@client.inspect}>"
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
- class BatchModelProxy
54
- attr_reader :name
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
- def initialize(client, name)
57
- @client = client
58
- @name = name
106
+ value.is_a?(Array) ? value.count(&:itself) : 0
59
107
  end
60
108
 
61
- def [](text, *texts)
62
- Emb.build_batch_loader(@client, @name, texts.empty? ? text : [text, *texts])
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
- def inspect
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
- require 'connection_pool'
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
- attr_reader :pool, :batch_size
11
-
12
- def initialize(pool: nil, batch: nil, **redis_options)
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
- @batch_enabled = batch.nil? ? cfg.batch : batch
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
- @pool = ConnectionPool.new(size: size) do
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(*args)
28
- return @pool.with { |r| r.call(*args) } unless Emb.debug?
26
+ def send_command(...) = @router.call(...)
29
27
 
30
- start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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
- $stdout.puts "[EMB] #{args.map(&:inspect).join(' ')} (#{format('%.2f', elapsed)}ms)"
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 batch
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 batch batch_size]
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
@@ -3,25 +3,51 @@
3
3
  module Emb
4
4
  class Configuration
5
5
  OPTIONS = %i[
6
- host port url pool batch batch_size driver protocol
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.batch = true
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
- self.read_timeout = nil
23
- self.write_timeout = nil
24
- self.reconnect_attempts = 3
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