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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7aff64eb7fb61e9d6ce29bb76786020087ea90c317ec04344bf1cb8d3b3fd4e8
4
- data.tar.gz: ccf361c3c12406e71e778976e63f580b6bd56c0d658f12a2acdef6c75d42143f
3
+ metadata.gz: 7f3eb540be54e104585249da101c6dd0a77c22c5f7ace642be44b11927d71643
4
+ data.tar.gz: c963843c42d593640a11201abbb64b698e353c3d674df53d027bbce363ee2df1
5
5
  SHA512:
6
- metadata.gz: 40ba7fd979d43af7ab056f4c4981245f3d19080d8f19b19026d6f3d132a947d1ae3e14c8cef326a4b65876250ddfc58cd00b5ce48f6301d65e5a21b3bffe945a
7
- data.tar.gz: 583dd05fd04481dcdc7f4c3a4312c9464e1660d7e27bf16caebe543ddda8cde7ff8e4f6a156eefcd54b484cc4031551a1e89361ea437ec76082f3e0a4fb44ebe
6
+ metadata.gz: cd19e8b5017cfa0d377a8940a6ed44ecdc78bb5ac5e0905cff9e364ff02d49f68714b4542d2af0fb5f5b89254c8736294c7a62e0ba1506138fa04c5b5ac6f645
7
+ data.tar.gz: 3a66bb58df4664a86ccaddf991d584de31b7b7ffce1aacf5c4d1f8f9151e252e4409719fc20af54d2f52aabae6f66df9092546e8c91d19eda52b4804a9017dd4
data/Gemfile CHANGED
@@ -4,7 +4,6 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
- gem 'connection_pool'
8
7
  gem 'redis-client'
9
8
 
10
9
  gem 'rake', require: false
data/README.md CHANGED
@@ -55,7 +55,7 @@ resolve in this order — the first one wins:
55
55
  ```ruby
56
56
  Emb.configure do |c|
57
57
  c.pool = 8
58
- c.batch = false # opt out of lazy batching app-wide
58
+ c.lazy = :multi # defer and coalesce embed calls app-wide
59
59
  end
60
60
 
61
61
  Emb.configuration # => the shared Emb::Configuration
@@ -67,11 +67,28 @@ Emb::Client.new(pool: 20) # per-call still wins
67
67
 
68
68
  ### Out-of-the-box defaults
69
69
 
70
- The shipped defaults are benchmark-derived (see `BENCHMARK.md`): **lazy batching is on by
71
- default** (`batch: true` — each embed coalesces into one `EMB.MULTI`), pool `5`, pure-Ruby
72
- RESP driver, `protocol: 2`, `reconnect_attempts: 3`. To keep the eager behavior (immediate
73
- `EMB` per call), opt out globally via `Emb.configure { |c| c.batch = false }` or per client
74
- with `Emb.new(batch: false)`.
70
+ The shipped defaults are benchmark-derived (see `BENCHMARK.md`) with secure-by-default
71
+ network behavior: **eager execution by default** (`lazy: false` — every `Emb[:model][t]`
72
+ sends one `EMB` round trip), pool `5`, pure-Ruby RESP driver, `protocol: 2`,
73
+ `read_timeout`/`write_timeout` both **10s**, and `reconnect_attempts: 0`. Opt into
74
+ coalescing with `lazy: :multi` or concurrent fan-out with `lazy: :batch` — globally via
75
+ `Emb.configure { |c| c.lazy = :batch }` or per client with `Emb.new(lazy: :batch)`.
76
+
77
+ **Why the timeout and reconnect defaults matter:** batched `EMB.MULTI` (up to 512 pairs)
78
+ can take over a second of inference on a shared CPU, and redis-client's silent default is
79
+ 1.0s — a slower reply times out. The gem therefore defaults to an explicit 10s timeout
80
+ and `reconnect_attempts: 0`: a failing batch fails closed after one attempt and raises
81
+ `Emb::ServerError` (see [Lazy batching](#lazy-batching)). Set
82
+ `Emb.configure { |c| c.reconnect_attempts = 2 }` and redis-client re-sends
83
+ **connection and protocol failures** up to that many extra times before the batch fails
84
+ closed — each re-send re-runs server inference, so keep the budget small. Operation
85
+ errors (unknown model, auth, bad pairs) are never retried, and neither are **read
86
+ timeouts**: redis-client treats `ReadTimeoutError` as terminal, and re-sending a timed-out
87
+ command could duplicate inference the server already did. `lazy: :batch` shares
88
+ additionally retry a refused connection on the next configured instance. Raise the
89
+ timeouts if you raise `batch_size`. `reconnect_attempts` also accepts an Array of
90
+ per-retry delays (a redis-client passthrough); each entry grants one retry and counts
91
+ toward `Emb::ServerError#attempts`.
75
92
 
76
93
  ### Connection pool
77
94
 
@@ -79,11 +96,48 @@ with `Emb.new(batch: false)`.
79
96
  Emb.setup(url: "redis://localhost:6379", pool: 10)
80
97
  ```
81
98
 
82
- The default pool size is **5**. The pool is usually not the bottleneck for
83
- inference-bound workloads (small pools are fine); it becomes a knob only at high
84
- concurrency on a multi-model box see [Performance](#performance). If a pool checkout
85
- would wait too long, `RedisClient`'s `connect_timeout`/`read_timeout` (above) bound the
86
- wait.
99
+ The default pool size is **5**. The client keeps `pool` persistent connections per
100
+ emb **instance** and routes commands through them **in round-robin order**
101
+ consecutive commands use different connections instead of reusing one hot connection.
102
+
103
+ `url` also accepts an **array of instances** (interchangeable replicas serving the same
104
+ model set); commands then round-robin across instances first, then across connections
105
+ within the selected instance:
106
+
107
+ ```ruby
108
+ Emb.setup(url: ["redis://emb-a:6379", "redis://emb-b:6379", "redis://emb-c:6379"], pool: 5)
109
+ ```
110
+
111
+ Each url gets its own pool of `pool` connections, so `pool: 5` with three urls maintains
112
+ 15 connections total. If an instance refuses a connection **before a command is sent**
113
+ (connection refused/unreachable), the command is retried on the next instance — this is
114
+ why `reconnect_attempts` stays `0`: a retry after a *sent* command could duplicate
115
+ inference, but a never-sent command is safe to move elsewhere.
116
+
117
+ This matters when emb runs as a pool of instances behind a **connection-level load
118
+ balancer** (AWS Service Connect, an NLB, or Envoy): the balancer assigns each
119
+ keep-alive connection to exactly one instance for its lifetime, so relying on a
120
+ single connection would pin all of a process's embedding traffic to one instance —
121
+ and queue it there even while other instances sit idle. Round-robin spreading across
122
+ `pool` connections spreads traffic across up to `pool` instances, so set `pool` to
123
+ **at least your expected emb instance count** (e.g. `pool: 10` for 10 instances).
124
+
125
+ Commands beyond the pool's parallelism do **not** time out: when `pool` commands are
126
+ already in flight, later commands wait on the shared connections until one frees
127
+ (there is no checkout timeout — unlike the previous pool's 5s
128
+ `ConnectionPool::TimeoutError`). Only the wait for a free pool connection is
129
+ unbounded — once a command runs, `RedisClient`'s `connect_timeout`,
130
+ `read_timeout`, and `write_timeout` still apply. For inference workloads this
131
+ lets commands ride out token-budget batching instead of erroring.
132
+
133
+ Locally (a single server) only the backend selection is identical whatever the
134
+ pool size — every command reaches the same server. The pool size still controls
135
+ how many persistent connections the client holds and how many commands run in
136
+ parallel. The pool is usually not the bottleneck for
137
+ inference-bound workloads (small pools are fine); it becomes a knob at high
138
+ concurrency on a multi-model box — see [Performance](#performance). With
139
+ round-robin selection up to `pool` commands run in parallel; beyond that, commands
140
+ share connections and serialize on them.
87
141
 
88
142
  ### Authentication
89
143
 
@@ -124,8 +178,8 @@ Emb.setup(
124
178
  ```
125
179
 
126
180
  See the [redis-client documentation](https://github.com/redis-rb/redis-client) for
127
- all available options. Only `pool` and `batch` are handled by the gem — everything
128
- else passes through to `RedisClient.new`.
181
+ all available options. Only `pool`, `lazy`, and `batch_size` are handled by the
182
+ gem — everything else passes through to `RedisClient.new`.
129
183
 
130
184
  ## Instance-based clients
131
185
 
@@ -169,6 +223,12 @@ automatically.
169
223
 
170
224
  ## Server info & config
171
225
 
226
+ > **Per-instance data.** `Emb.server_info`, `stats`, `info`, and `config` read
227
+ > from ONE instance, and with round-robin connection selection that instance
228
+ > may change between calls (with `pool: 1` it never does) — each call is a
229
+ > sample. `Emb.config[key] = value` mutates one (arbitrary) instance. Use
230
+ > per-connection queries if you need a specific or aggregated view.
231
+
172
232
  The server exposes Redis-style `INFO` and `CONFIG` commands; the gem wraps them.
173
233
 
174
234
  ### `Emb.stats` — server statistics as a hash
@@ -190,15 +250,18 @@ Emb.stats
190
250
  ### `Emb.server_info` — sectioned INFO, parsed
191
251
 
192
252
  The Redis-style `INFO` reply is parsed into a nested Hash. **No arguments = all
193
- sections**; pass section names to filter (`:server`, `:cache`, `:keyspace`, `:stats`, `:clients`):
253
+ sections**; pass section names to filter (`:server`, `:cache`, `:keyspace`, `:stats`, `:memory`, `:cpu`, `:clients`):
194
254
 
195
255
  ```ruby
196
256
  Emb.server_info
197
257
  # => {Server: {redis_version: "0.2.4", emb_version: "0.2.4", uptime_secs: "7", ...},
198
258
  # Cache: {cache_hits: 0, cache_misses: 0, cache_hit_rate: "0.0%", ...},
199
- # Keyspace: {db0: "model=minilm,keys=0,hits=0,misses=0,hit_rate=0.0%"}, ...}
259
+ # Keyspace: {db0: "model=minilm,keys=0,hits=0,misses=0,hit_rate=0.0%"},
260
+ # Memory: {used_memory_rss_bytes: 262438912, used_memory_heap_bytes: 154960,
261
+ # goroutines: 4, total_system_memory_bytes: 25769803776},
262
+ # CPU: {used_cpu_user_usec: 188900, used_cpu_sys_usec: 50462, gomaxprocs: 10}, ...}
200
263
 
201
- Emb.server_info(:server, :cache) # only those two sections
264
+ Emb.server_info(:memory, :cpu) # live process resources only
202
265
  ```
203
266
 
204
267
  ### `Emb.config` — hot config read & change
@@ -228,6 +291,28 @@ exceptions (`RedisClient::CommandError`): read-only parameters (`listen`, `tls_*
228
291
  `models`), invalid values, and `NOAUTH` on password-protected servers are not
229
292
  swallowed.
230
293
 
294
+ ### Cache lifecycle commands
295
+
296
+ When server-side caching is enabled, the Ruby client exposes the lifecycle
297
+ commands on module, instance, pooled, and round-robin clients:
298
+
299
+ ```ruby
300
+ Emb.cache_flush # all models; => removed entry count
301
+ Emb.cache_flush(:minilm) # one model; => removed entry count
302
+ Emb.save_cache # => "OK" once the background save is accepted
303
+
304
+ Emb.stats[:cache_snapshot_in_progress] # 0 or 1
305
+ Emb.server_info(:cache) # completion, failure, restore limits
306
+ ```
307
+
308
+ Snapshot persistence is configured on the server with `cache_file`,
309
+ `cache_load`, `cache_save`, `cache_save_on_shutdown`, `cache_restore_limit`,
310
+ `cache_restore_reserve`, and `cache_save_rate_limit`. Automatic saves keep
311
+ serving inference and control commands while encoding and I/O run in the
312
+ background. Snapshot files include original input text and embeddings; use
313
+ protected/encrypted storage where appropriate and treat them as disposable
314
+ warm-start data rather than a durable database.
315
+
231
316
  ## Usage
232
317
 
233
318
  ### Single text
@@ -244,6 +329,32 @@ client = Emb.new(url: "redis://localhost:6379")
244
329
  result = client[:minilm]["hello world"]
245
330
  ```
246
331
 
332
+ ### VALUE format (RESP decimal reply)
333
+
334
+ By default the server replies with the compact float32 binary wire and the gem
335
+ unpacks it. Pass `format: :values` to send the RedisAI-style `VALUES` keyword
336
+ and get the server's self-describing envelope back: `dtype`, `shape`, and the
337
+ embedding values as decimal floats (the float64 widening the server stores, so
338
+ `unpack` is not needed). Single texts return the envelope with flat `values`;
339
+ multiple texts group the values per text (rows of `shape`).
340
+
341
+ ```ruby
342
+ Emb[:minilm]["hello world", format: :values]
343
+ # => { dtype: "FLOAT", shape: [1, 384], values: [0.0123, -0.0456, ...] }
344
+
345
+ Emb[:minilm]["hello", "world", format: :values]
346
+ # => { dtype: "FLOAT", shape: [2, 384], values: [[...], [...]] }
347
+ ```
348
+
349
+ The batch loaders accept the same keyword; mixed formats within one batch
350
+ raise `ArgumentError`.
351
+
352
+ > **RESP3 note:** the server emits the decimal values as typed RESP3 doubles when
353
+ > the connection negotiated `HELLO 3` and as decimal bulk strings otherwise.
354
+ > The gem speaks RESP2 (binary default, decimal `values` opt-in) and does not
355
+ > parse RESP3 replies itself — connect with a RESP3-capable client to use the
356
+ > typed doubles on the wire.
357
+
247
358
  ### Multiple texts
248
359
 
249
360
  ```ruby
@@ -273,38 +384,93 @@ client.multi do |m|
273
384
  end
274
385
  ```
275
386
 
276
- ### Lazy batching (`Emb.batch`)
387
+ ### Script replies
388
+
389
+ `Emb.eval` / `Emb.evalsha` run a Lua script against a model (KEYS = texts,
390
+ ARGV = args) and parse replies through the RESP grammar — hashes, arrays,
391
+ strings, errors. Unlike the embed path, a scripted reply has no fixed shape,
392
+ so packed vectors are **not** auto-decoded: a `float32_bytes` reply comes back
393
+ as the raw bulk String. Decode it per call with the `decode:` keyword:
394
+
395
+ ```ruby
396
+ sha = client.script.load(:siglip2, siglip_source)
397
+
398
+ # decode: :f32 — the reply is a packed float32 vector (or a numeric array)
399
+ vec = client.evalsha(:siglip2, sha, ["a photo of a cat"], ["normalize"], decode: :f32)
400
+ # => [0.0123, -0.0456, ...] 768 floats
401
+
402
+ # multi-text replies decode each element
403
+ vecs = client.evalsha(:siglip2, sha, ["a", "b"], ["normalize"], decode: :f32)
404
+ # => [[0.0123, ...], [-0.0456, ...]]
405
+
406
+ # decode: {field => :f32} — structured replies decode a named field
407
+ out = client.evalsha(:siglip2, sha, ["a"], ["normalize"], decode: { embedding: :f32 })
408
+ # => {"dim" => 768, "embedding" => [0.0123, ...]}
409
+ ```
410
+
411
+ `decode:` defaults to `nil` (no decoding, exactly today's behavior). Supported
412
+ modes are `:f32` and `{field => :f32}`; anything else, or a reply that is not
413
+ actually a float vector / hash at the decodable position, raises
414
+ `ArgumentError`. A raw bulk can always be decoded by hand with
415
+ `reply.unpack("e*")`.
277
416
 
278
- Instead of collecting pairs by hand, `Emb.batch` returns lazy embeddings that all
279
- coalesce into a single `EMB.MULTI` round trip when the first one is used. This is
280
- powered by the [batch-loader](https://github.com/exAspArk/batch-loader) gem.
417
+ ### Lazy batching
418
+
419
+ In `:batch` mode the shares fan out across the configured instances (one share per
420
+ instance when the share count allows) or across the instance's pool connections when a
421
+ single url is configured — a batch whose pieces take 60ms and 10ms completes in roughly
422
+ the slowest share (~60ms) instead of the sum (~70ms). Deferred work is powered by the
423
+ [batch-loader](https://github.com/exAspArk/batch-loader) gem:
281
424
 
282
425
  ```ruby
426
+ Emb.setup(lazy: :multi) # or :batch — Emb[:minilm] now defers
427
+
283
428
  users = User.all # some application objects
284
429
 
285
430
  # Create loaders first...
286
- l1 = Emb.batch[:minilm]["hello"]
287
- l2 = Emb.batch[:minilm]["world"]
288
- l3 = Emb.batch[:bge]["bonjour"]
431
+ l1 = Emb[:minilm]["hello"]
432
+ l2 = Emb[:minilm]["world"]
433
+ l3 = Emb[:bge]["bonjour"]
289
434
 
290
- # ...then consume them. The first use sends ONE EMB.MULTI for all three.
435
+ # ...then consume them. The first use sends ONE EMB (single-model scope)
436
+ # or ONE EMB.MULTI (mixed-model scope) for all three.
291
437
  l1.sum # => 12.345
292
438
  l2.sum # => -0.678
293
439
  l3.sum # => 3.141
294
440
  ```
295
441
 
296
- Instance clients expose the same API:
297
-
298
- ```ruby
299
- client.batch[:minilm]["hello"].sum
300
- ```
301
-
302
442
  Each lazy value materializes to the same shape as the eager API: a single text
303
- yields an `Array<Float>`, multiple texts yield `Array<Array<Float>>`:
443
+ yields an `Array<Float>`, multiple texts yield `Array<Array<Float>>`. For explicit
444
+ composition that always sends immediately regardless of mode, use `Emb.multi { }`
445
+ (see [Multi-model queries](#multi-model-queries)).
446
+
447
+ The batch executes in the current thread's scope; the Rack/ActiveJob middleware
448
+ clears that scope after every request/job (`Emb::BatchScope`), so deferred work
449
+ can never accumulate across requests (in eager mode there is nothing to clear).
450
+
451
+ **Fail-closed batches.** If a batch command fails — a timeout, an operation error, or in
452
+ `:batch` mode a share failure after pre-send retries are exhausted — the batch raises
453
+ **`Emb::ServerError`** to the code that first used it, and every deferred item of that
454
+ batch is removed from the scope — retrying (or using other items of the failed batch)
455
+ **does not re-send the batch** and resolves to `[]` instead. The error's `cause` is the
456
+ underlying redis error (`RedisClient::ReadTimeoutError`, `RedisClient::CommandError`, ...)
457
+ and its message includes the model(s), text count, and attempt count. Transient failures
458
+ (timeout, connection error, protocol error) re-send up to `reconnect_attempts` extra
459
+ times when that option is set above 0 (default 0 = a single attempt); operation errors
460
+ are never retried. A non-redis error raised by the batch call itself (e.g. a `TypeError`
461
+ from bad arguments) is a local bug, not a server failure: the batch's pending items are
462
+ still cleared, but the original error is re-raised. This
463
+ prevents a slow server from turning one failed batch into endless duplicate work
464
+ (retries re-running the whole batch) or growth of the pending set across retries.
465
+ Pair-level failures the server reports as `null` (MGET semantics) are unaffected.
466
+
467
+ > **Breaking change (gem ≥ next release):** a failed batch raises `Emb::ServerError`
468
+ > instead of the raw `RedisClient::*` error. Rescue `Emb::ServerError` and read `cause`
469
+ > for the original error. Eager `Emb.multi` and the `lazy: false` path are unchanged.
304
470
 
305
471
  ```ruby
306
- vec = Emb.batch[:minilm]["hello"] # use -> Array of Float
307
- vecs = Emb.batch[:minilm]["hello", "world"] # use -> Array of Array of Float
472
+ vec = Emb[:minilm]["hello"] # use -> Array of Float
473
+ vecs = Emb[:minilm]["hello", "world"] # use -> Array of Array of Float
308
474
  ```
309
475
 
310
476
  Embeddings are cached per thread, so reusing a lazy value (or creating an
@@ -318,43 +484,59 @@ Loaders only fire when a value is **used**. Create all loaders *first*, then
318
484
  consume them, so they share one round trip:
319
485
 
320
486
  ```ruby
321
- texts.each { |t| process(Emb.batch[:minilm][t]) } # wrong: one MULTI per item
322
- loaders = texts.map { |t| Emb.batch[:minilm][t] } # right: ONE MULTI for all
487
+ texts.each { |t| process(Emb[:minilm][t]) } # wrong: one EMB per item
488
+ loaders = texts.map { |t| Emb[:minilm][t] } # right: ONE EMB for all
323
489
  loaders.each { |l| process(l) }
324
490
  ```
325
491
 
326
492
  A loader that is created but never used **never embeds** (unless a sibling batch
327
493
  fires first) and is silently dropped when the thread's scope ends. Duration and
328
- scope: batching is per-thread — a multithreaded app issues one `EMB.MULTI` per
494
+ scope: batching is per-thread — a multithreaded app issues one `EMB`/`EMB.MULTI` per
329
495
  thread per flush.
330
496
 
331
- ### `batch` configuration option
497
+ ### `lazy` configuration option
332
498
 
333
- Setting `batch: true` makes the standard proxy API lazy, so existing call sites
334
- batch automatically without restructuring:
499
+ Setting `lazy: :multi` or `lazy: :batch` makes the standard proxy API defer, so
500
+ existing call sites batch automatically without restructuring:
335
501
 
336
502
  ```ruby
337
- Emb.setup(url: "redis://localhost:6379", batch: true)
503
+ Emb.setup(url: "redis://localhost:6379", lazy: :multi)
338
504
  # or
339
- Emb.new(url: "redis://localhost:6379", batch: true)
505
+ Emb.new(url: "redis://localhost:6379", lazy: :batch)
340
506
  ```
341
507
 
342
- With `batch: true`, `Emb[:minilm]["hello"]` returns a lazy embedding that sends
343
- `EMB.MULTI` on first use. The default is `false` the proxy API stays eager,
344
- sending `EMB` immediately. `Emb.batch` works regardless of the option, and
345
- `Emb.multi` remains the explicit, eager, deterministic batching API.
508
+ Under `lazy: :multi`, `Emb[:minilm]["hello"]` returns a lazy embedding that sends
509
+ `EMB` on first use (serial chunks; `EMB.MULTI` only for mixed-model scopes). Under `lazy: :batch`, the chunk shares
510
+ execute concurrently with multiple `url`s they fan out across instances. The
511
+ default is eager (`lazy: false`). `Emb.multi` remains the explicit, eager,
512
+ deterministic composition API in every mode.
346
513
 
347
514
  ### Clearing the cache per request
348
515
 
349
- The per-thread batch scope holds cached embeddings for the life of the thread. In
350
- request-shaped processes (Rails, Rack apps, Sidekiq) mount `Emb::Middleware` to
351
- clear the scope at the end of each request:
516
+ The per-thread batch scope holds cached embeddings for the life of the thread.
517
+ In a Rails application the bundled Railtie mounts `Emb::Middleware`
518
+ automatically, so the scope is cleared at the end of every request with no
519
+ configuration:
352
520
 
353
521
  ```ruby
354
- # config/application.rb (Rails)
355
- config.middleware.use Emb::Middleware
522
+ # nothing to do — Emb::Railtie inserts Emb::Middleware for you
523
+ ```
524
+
525
+ Opt out if you want to manage the stack yourself:
356
526
 
357
- # Any Rack app
527
+ ```ruby
528
+ # config/application.rb
529
+ config.emb.middleware = false
530
+ ```
531
+
532
+ If your Gemfile loads `emb` before Rails is required (non-standard boot order), the
533
+ guarded railtie require in `emb.rb` is skipped — add `require "emb/railtie"` in
534
+ `config/application.rb` right after `require "rails/all"` (or in an initializer).
535
+
536
+ The middleware is also safe to mount manually in any Rack app (the Railtie
537
+ skips insertion when it is already present):
538
+
539
+ ```ruby
358
540
  use Emb::Middleware
359
541
  ```
360
542
 
@@ -362,6 +544,46 @@ The scope is cleared even when the app raises, and a fresh scope starts
362
544
  automatically with the next request. Loaders created but never used within a
363
545
  request are dropped — the create-then-consume contract applies per request.
364
546
 
547
+ ### Job-scoped cache clearing
548
+
549
+ The Railtie also registers `Emb::JobMiddleware` — the same per-scope clearing
550
+ for background work — for every job framework present. Each job execution
551
+ starts with a fresh batch scope: cached embeddings and loaders created but
552
+ never used are dropped at the end of the job (even when it raises), so worker
553
+ threads never leak scope state between jobs.
554
+
555
+ - **ActiveJob** (SolidQueue, Sidekiq, Shoryuken, async, test adapters): an
556
+ `around_perform` callback on `ActiveJob::Base` registered by the Railtie.
557
+ - **Plain Sidekiq workers** (non-ActiveJob): a server middleware added by the
558
+ Railtie.
559
+ - **Plain Shoryuken workers** (non-ActiveJob): a server middleware added by the
560
+ Railtie.
561
+
562
+ ```ruby
563
+ # opt out of all job-scope protection
564
+ config.emb.job_middleware = false
565
+ ```
566
+
567
+ In a non-Rails process, register the middleware manually:
568
+
569
+ ```ruby
570
+ # sidekiq.rb / an initializer
571
+ Sidekiq.configure_server do |config|
572
+ config.server_middleware { |chain| chain.add Emb::JobMiddleware }
573
+ end
574
+
575
+ # Shoryuken
576
+ Shoryuken.configure_server do |config|
577
+ config.server_middleware { |chain| chain.add Emb::JobMiddleware }
578
+ end
579
+ ```
580
+
581
+ The Railtie also registers `Emb::JobMiddleware` into the `Sidekiq::Testing` middleware
582
+ chain when `Sidekiq::Testing` is loaded *before the app finishes booting* (e.g. a dev
583
+ `INLINE_SIDEKIQ` initializer). For RSpec test modes, `sidekiq/testing` typically loads
584
+ after the app boots, so require it before your environment to get per-job clearing in
585
+ fake/inline tests — e.g. `gem "sidekiq", require: ["sidekiq", "sidekiq/testing"]`.
586
+
365
587
  ## Performance
366
588
 
367
589
  ### Eager-burst pipelining (no new API)
@@ -372,7 +594,7 @@ batching doesn't fit), coalesce them into one packet with `RedisClient#pipelined
372
594
  ```ruby
373
595
  client = Emb.new(url: "redis://localhost:6379")
374
596
 
375
- a = client.pool.with do |conn|
597
+ a = client.pools.first.with do |conn|
376
598
  conn.pipelined do |pipe|
377
599
  texts.each { |t| pipe.call("EMB", "minilm", t) }
378
600
  end
@@ -380,9 +602,22 @@ end
380
602
  # a -> Array of Float32-binary replies; unpack each with r.unpack("e*")
381
603
  ```
382
604
 
383
- `Client#pool` exposes the pool's `RedisClient` connections. This measurably beats
384
- plain per-call round trips; use it for bursts, and `Emb.batch`/`Emb.multi` when you want
385
- server-side coalescing into one `EMB.MULTI`.
605
+ `Client#pools` exposes the per-instance pools' `RedisClient` connections (the
606
+ first pool for a single-instance client). This measurably beats
607
+ plain per-call round trips; use it for bursts, and `Emb.multi` or a deferred `lazy`
608
+ mode when you want
609
+ server-side coalescing into one packed inference.
610
+
611
+ ### Benchmark harness coverage
612
+
613
+ The client benchmark harness (`gems/emb/bench/bench.rb`, `just bench-ruby`)
614
+ reports one row per execution mechanism: `eager` (one `EMB` per call), `multi`
615
+ (coalesced `EMB`, or `EMB.MULTI` for mixed models), `batch` (concurrent `EMB` chunk shares),
616
+ `pipelined` (raw RESP pipelining), and `threaded` (eager across threads).
617
+ `just bench-ruby-multi` starts two partitioned emb instances and adds the
618
+ url-array rows `eager-2node` (round-robin distribution) and `batch-2node`
619
+ (concurrent fan-out across instances); the two-node rows are skipped unless a
620
+ second instance is reachable (`EMB_BENCH_PORT2`).
386
621
 
387
622
  ### RESP driver: pure-Ruby default, `hiredis` on demand
388
623
 
@@ -409,7 +644,7 @@ instance. Scale out without a cluster client:
409
644
  (optionally seed with `-cache` and a warmup pass).
410
645
 
411
646
  Lazy batching stays per server (a thread's loaders target one client), so batching and
412
- horizontal scale compose: each instance receives one `EMB.MULTI` per request.
647
+ horizontal scale compose: each instance receives one `EMB` (single model) or `EMB.MULTI` (mixed) share per request.
413
648
 
414
649
  ### Commands
415
650