emb 0.4.1 → 0.4.3.pre1

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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/lib/emb/round_robin_pool.rb +45 -37
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d30fe18a35d49193f1fc98fed618c871617cd144e5a06a5332106498ffb09836
4
- data.tar.gz: 43ec2aa7a9ce174b219688ff0f4e537467ab434c425c3cd90ab0b6cb408fd2bc
3
+ metadata.gz: 0b8dde2d96792769f263d041d6385fe18968bea14f7366887069df92397473e1
4
+ data.tar.gz: f18d769881356e77e7c3449be87b0e541272300a71a7b3c427da61e646f05e3c
5
5
  SHA512:
6
- metadata.gz: 24b32a4a561dfabc2ff1711e28ef11b1f2a9e009e3ae16b6d1b27befb2f412116c20f747043a553c57e805408f17cd0d6197e1560e366fda4382f1c77a468a04
7
- data.tar.gz: 2a863ec65770fcd5630855eccd77173e47d6384d8f457b26ae4ed141c237771cac2dca84a2770437020e61b735e4e6c9a64d074d758957a5cdfcd503cf6679b5
6
+ metadata.gz: a39342b62646c8b9f6c8eedb3f4cb480a1f13ff03c11b682453a4728423dc27bc19602679db26c121ff63a0f609450bd9345a9e48ce210443754c6c661472668
7
+ data.tar.gz: f870981d417fa513f588ec1394e09e14bf864460b46666cb9d605fba9c2400d88f643345c793430298dfed375dc80828cb1e2f8fb3e96d4e0e522bd9e58bb048
data/README.md CHANGED
@@ -102,6 +102,11 @@ Emb.setup(url: "redis://localhost:6379", pool: 10)
102
102
  The default pool size is **5**. The client keeps `pool` persistent connections per
103
103
  emb **instance** and routes commands through them **in round-robin order** —
104
104
  consecutive commands use different connections instead of reusing one hot connection.
105
+ Selection is **work-conserving**: a command takes the next *available* connection and
106
+ never waits behind a busy one while another connection is free, so an occasional slow
107
+ inference cannot stall commands that another connection could serve. A released
108
+ connection returns to the tail of the free queue, so sequential commands still rotate
109
+ in order.
105
110
 
106
111
  `url` also accepts an **array of instances** (interchangeable replicas serving the same
107
112
  model set); commands then round-robin across instances first, then across connections
@@ -126,7 +131,7 @@ and queue it there even while other instances sit idle. Round-robin spreading ac
126
131
  **at least your expected emb instance count** (e.g. `pool: 10` for 10 instances).
127
132
 
128
133
  Commands beyond the pool's parallelism do **not** time out: when `pool` commands are
129
- already in flight, later commands wait on the shared connections until one frees
134
+ already in flight, later commands wait for the first connection to free
130
135
  (there is no checkout timeout — unlike the previous pool's 5s
131
136
  `ConnectionPool::TimeoutError`). Only the wait for a free pool connection is
132
137
  unbounded — once a command runs, `RedisClient`'s `connect_timeout`,
@@ -138,9 +143,10 @@ pool size — every command reaches the same server. The pool size still control
138
143
  how many persistent connections the client holds and how many commands run in
139
144
  parallel. The pool is usually not the bottleneck for
140
145
  inference-bound workloads (small pools are fine); it becomes a knob at high
141
- concurrency on a multi-model box — see [Performance](#performance). With
142
- round-robin selection up to `pool` commands run in parallel; beyond that, commands
143
- share connections and serialize on them.
146
+ concurrency on a multi-model box — see [Performance](#performance). Up to `pool`
147
+ commands run in parallel; beyond that, commands wait for the first connection to
148
+ free rather than blocking on a specific busy one, so size `pool` to your expected
149
+ concurrency.
144
150
 
145
151
  ### Authentication
146
152
 
@@ -1,17 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # A thread-safe pool of N RedisClient connections with work-conserving
5
+ # round-robin selection. Behind connection-level load balancers (AWS Service
6
+ # Connect, an NLB, or an Envoy TCP proxy) each keep-alive connection is pinned
7
+ # to one upstream instance, so rotating commands across the pool spreads
8
+ # traffic across every instance — even single-threaded at zero concurrency.
9
+ # Connections are created up front but connect lazily on first use.
10
+ #
11
+ # Selection is work-conserving: a command takes the next *available*
12
+ # connection, never waiting behind a busy connection while another is free.
13
+ # A released connection returns to the tail of the free queue, so sequential
14
+ # commands still rotate in order.
10
15
  #
11
16
  # Two behaviors deliberately match the connection_pool gem it replaces: a
12
17
  # nested `with` from the same thread re-enters the held connection, and after
13
18
  # `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
19
+ # and rebuilds its free queue in the child so parent and child never share a
15
20
  # connection.
16
21
  class RoundRobinPool
17
22
  # Pools are tracked only to reset them in forked children. WeakMap so a
@@ -33,31 +38,30 @@ module Emb
33
38
 
34
39
  @size = size
35
40
  @connections = Array.new(size, &)
36
- @locks = Array.new(size) { Mutex.new }
37
- @next = 0
38
- @index_mutex = Mutex.new
41
+ @free = free_queue
39
42
  INSTANCES&.[]=(self, self)
40
43
  end
41
44
 
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.
45
+ # Yields an available connection. Safe from multiple threads: up to `size`
46
+ # commands run in parallel, each on its own connection; a command beyond that
47
+ # waits for the first connection to free. A nested `with` from the same
48
+ # thread re-enters the connection this pool already holds; other pools are
49
+ # unaffected.
46
50
  def with(&)
47
51
  held = Thread.current[THREAD_KEY]
48
52
  if held&.key?(self)
49
53
  yield @connections[held[self]]
50
54
  else
51
- take(held, &)
55
+ acquire(held, &)
52
56
  end
53
57
  end
54
58
 
55
- # Child side of after_fork(): drop inherited sockets and sync state.
59
+ # Child side of after_fork(): drop inherited sockets and rebuild the free
60
+ # queue, keeping the connection this thread still holds out of it (its
61
+ # holder re-adds it on release).
56
62
  def reload_after_fork!
57
63
  @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
64
+ @free = free_queue(exclude: Thread.current[THREAD_KEY]&.[](self))
61
65
  end
62
66
 
63
67
  if Process.respond_to?(:fork)
@@ -74,26 +78,30 @@ module Emb
74
78
 
75
79
  private
76
80
 
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
81
+ def free_queue(exclude: nil)
82
+ free = Queue.new
83
+ @size.times { |idx| free << idx unless idx == exclude }
84
+ free
90
85
  end
91
86
 
92
- def pick
93
- @index_mutex.synchronize do
94
- idx = @next % @size
95
- @next += 1
96
- [idx, @connections[idx]]
87
+ # Takes the next free connection, records it as held by this thread/pool,
88
+ # and returns it to the queue on exit — even when the block raises.
89
+ def acquire(held)
90
+ idx = nil
91
+ begin
92
+ idx = @free.pop
93
+ held ||= {}
94
+ Thread.current[THREAD_KEY] = held
95
+ held[self] = idx
96
+ yield @connections[idx]
97
+ ensure
98
+ # idx stays nil when no connection was taken; otherwise the taken index
99
+ # is returned even on async interrupt.
100
+ if idx
101
+ held&.delete(self)
102
+ Thread.current[THREAD_KEY] = nil if held&.empty?
103
+ @free << idx
104
+ end
97
105
  end
98
106
  end
99
107
  end
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.1
4
+ version: 0.4.3.pre1
5
5
  platform: ruby
6
6
  authors:
7
7
  - elcuervo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: batch-loader