skaidb 1.4.0 → 1.4.1

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 (6) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +8 -5
  4. data/docs/pooling.md +12 -6
  5. data/lib/skaidb.rb +38 -16
  6. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f947092b55d66cc8d7c55153cbea09ebcdd0298f48fcb6c97ea0f3a5edd5ac01
4
- data.tar.gz: 9b472d16803f161f2aa6d4554c19aede65f9a227b79d29c7f015204c04a7bcc7
3
+ metadata.gz: 0514ffe373981266d475977fd65030cbf7bdc8a2ba568c77b80afe963904e242
4
+ data.tar.gz: a7a1ce25bf193955ae0f765df14245ab0cf1a4d6ff6902f712291666646b0061
5
5
  SHA512:
6
- metadata.gz: 68d82f58f409113c18f1536023042343ffbfa86c8ec6029a0f799cd8f88fd038f418ced9c1be77beca7826c7d5756e523de221c6bf798e7069db1e14be7aaf12
7
- data.tar.gz: f95915e0918b17e31d2b9e63cfbcbe92ee717f07b49075bbd1da055cb49f60f592c346eb579b6c65a8dc855ddf669c41d467086dec90ccb59abc0861c784dacc
6
+ metadata.gz: e60490e9a1e5ef0381199c96981bf18a19cfd915da2926ce6ac3395bb31cdb20be2d3c5135a1cc7b6172ab48dae15877d0479ea6aa86b1aa0da0219e062f70e9
7
+ data.tar.gz: 9a8a3422a05a1eb3294bd67425a6d59291924254d6b9acac4002923d14867b3f30205a21446b254c2957a51090a76508deb69df6abd1058b5bdef96383402d95
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to the skaidb Ruby driver. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.4.1] - 2026-10-31
8
+
9
+ ### Fixed
10
+ - One slow round trip at the start can no longer get a healthy member
11
+ ejected for latency. A member's first round trip set its average
12
+ outright, so a statement that stalled in the client — a CPU-bound
13
+ application holding the thread while it starts — put seconds into it;
14
+ ten round trips later the member was compared with the others, and the
15
+ average had barely decayed: ejected (`latency 4153.7 ms vs median
16
+ 0.2 ms`) while it answered in milliseconds. The fastest of a member's
17
+ first three round trips now seeds its average (a round trip is only ever
18
+ lengthened), and later ones move it as before; until then the member
19
+ counts as not measured, and `pool.members` reports its `latency_ms` as
20
+ `nil`.
21
+ - A latency ejection's `logger` warning names the slowest of the member's
22
+ last 10 round trips alongside its average and the median, which tells a
23
+ slow member from one slow statement.
24
+
7
25
  ## [1.4.0] - 2026-10-29
8
26
 
9
27
  ### Fixed
@@ -206,6 +224,8 @@ batches, a 2 500-row stream, error handling, and the `drivers` table showing
206
224
  because it is a bundled gem since Ruby 3.4.
207
225
  - The example moved to `examples/basic.rb`.
208
226
 
227
+ [1.4.1]: https://github.com/porcupin26/skaidb-ruby/compare/v1.4.0...v1.4.1
228
+ [1.4.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.3.0...v1.4.0
209
229
  [1.3.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.2.0...v1.3.0
210
230
  [1.2.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.1.0...v1.2.0
211
231
  [1.1.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.0.3...v1.1.0
data/README.md CHANGED
@@ -55,13 +55,13 @@ Xcode command-line tools on macOS). `gem install skaidb` and vendoring reuse
55
55
  the `bigdecimal` already installed.
56
56
 
57
57
  Or vendor the single file: copy
58
- [`lib/skaidb.rb`](https://github.com/porcupin26/skaidb-ruby/blob/v1.4.0/lib/skaidb.rb)
58
+ [`lib/skaidb.rb`](https://github.com/porcupin26/skaidb-ruby/blob/v1.4.1/lib/skaidb.rb)
59
59
  into your project and `require_relative` it. It has no dependencies beyond
60
60
  Ruby's standard library.
61
61
 
62
62
  Every release is also attached as a `.gem` to its
63
63
  [GitHub release](https://github.com/porcupin26/skaidb-ruby/releases) for
64
- installs without registry access: `gem install ./skaidb-1.4.0.gem`.
64
+ installs without registry access: `gem install ./skaidb-1.4.1.gem`.
65
65
 
66
66
  ## Quick start
67
67
 
@@ -359,8 +359,11 @@ pool.members # => [{ endpoint: "db1:7000", canonical: "10.0.0.1:7000", state: :r
359
359
 
360
360
  A member with consecutive transport failures, or a round-trip average far
361
361
  above the others', is ejected and re-admitted by a background probe (the last
362
- usable member never is); `idle_order: :fifo` keeps every idle connection in
363
- use. Details: [docs/pooling.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/pooling.md).
362
+ usable member never is). The fastest of a member's first three round trips
363
+ seeds its average, so a statement that stalls in your own process cannot get
364
+ a healthy member ejected; a latency ejection's `logger:` warning names the
365
+ average, the median and the slowest of the member's last 10 round trips.
366
+ `idle_order: :fifo` keeps every idle connection in use. Details: [docs/pooling.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/pooling.md).
364
367
 
365
368
  ## Streams (`CREATE STREAM`)
366
369
 
@@ -461,7 +464,7 @@ frame after every handshake. It shows up as `client_name = 'ruby'` /
461
464
 
462
465
  ```sql
463
466
  SELECT client_name, client_version FROM drivers;
464
- -- ruby | 1.4.0
467
+ -- ruby | 1.4.1
465
468
  ```
466
469
 
467
470
  The server records the row asynchronously, so a `SELECT` immediately after
data/docs/pooling.md CHANGED
@@ -81,7 +81,7 @@ pool = Skaidb::Pool.new(
81
81
  | `idle_order` | `:lifo` | `:lifo` hands out the most recently returned connection first, so a quiet pool keeps few sockets warm. `:fifo` hands out the longest-idle one first, so every idle connection keeps being used and a server-side idle timeout never finds one. |
82
82
  | `discover` | `false` | Reads the members with `SHOW ENDPOINTS` (server ≥ 0.327; no privilege needed) when a member is first reached and every `refresh_interval` (60 s) after — every 5 s while a member is `resyncing` or `unreachable`. New members are added, members that left are dropped (seeds are kept, marked `unlisted`), and members the server reports `resyncing` or `unreachable` get no new connections until they are `ready` again, when they start with a fresh latency average. An older or standalone server leaves the seeds as they are, and the pool stops asking. |
83
83
  | `eject_errors` | `3` | Consecutive transport failures (a failed dial, an I/O error mid-request, a capacity refusal) that eject a member. Statement errors and refused logins do not count. |
84
- | `eject_latency_factor`, `eject_latency_floor` | `3.0`, `0.05` | A member whose round-trip average is above factor × the other usable members' median **and** more than floor seconds above it is ejected (once it and another member have 10 samples each). A factor of `0` disables it. |
84
+ | `eject_latency_factor`, `eject_latency_floor` | `3.0`, `0.05` | A member whose round-trip average is above factor × the other usable members' median **and** more than floor seconds above it is ejected (once it and another member have 10 samples each). The fastest of a member's first three round trips seeds its average, so a statement that stalls in your own process — a CPU-bound start-up — cannot get a healthy member ejected; the `logger` warning names the average, the median and the slowest of the member's last 10 round trips. A factor of `0` disables it. |
85
85
  | `eject_backoff`, `eject_backoff_max` | `5.0`, `60.0` | How long an ejection lasts, doubling for each repeat up to the max; a member that stays healthy for `eject_backoff_max` after a re-admission starts over at the base. When the time is up a background thread dials the member and runs one statement: success re-admits it (with a fresh latency average), failure ejects it again. The last usable member is never ejected — a slow or flaky member beats none. |
86
86
  | `read_your_writes` | `0.0` | Seconds after a write during which checkouts with the same `affinity` go to the member that took the write. |
87
87
  | `logger` | `nil` | A `Logger` (or anything with `warn`) told of each ejection and re-admission. |
@@ -94,10 +94,15 @@ through to the other usable members, then to the unusable ones, so a
94
94
  checkout never has nowhere to go while any member is configured.
95
95
 
96
96
  **Latency.** Per member the pool keeps an exponentially weighted average of
97
- round trips (request sent to the first response frame): each sample moves it
98
- 5 % of the way, and no sample counts for more than 10 × the larger of the
99
- current average and `eject_latency_floor`. One slow statement barely moves
100
- it; a member that stays slow is noticed within a few dozen statements.
97
+ round trips (request sent to the first response frame). The fastest of the
98
+ member's first three round trips seeds it: a round trip is only ever
99
+ lengthened — by the member, the network or your own process (a thread that
100
+ stalls while the application starts, say) — so one or two slow first
101
+ statements cannot set it. From then on each sample moves it 5 % of the way,
102
+ and no sample counts for more than 10 × the larger of the current average
103
+ and `eject_latency_floor`. One slow statement barely moves it; a member that
104
+ stays slow is noticed within a few dozen statements. Until its average is
105
+ seeded a member counts as not measured.
101
106
 
102
107
  **Read-your-writes.** The member that coordinated a write reads it back at
103
108
  any consistency, `ONE` included; another member may not have applied it yet.
@@ -139,7 +144,8 @@ endpoint:
139
144
  `endpoint` is what the pool dials, `canonical` the name the member reports
140
145
  for itself (once discovered), `state` one of `:ready`, `:ejected`,
141
146
  `:resyncing`, `:unreachable`, `:unlisted`, and `latency_ms` is `nil` until
142
- the member has been measured. The rules are the same in every skaidb driver
147
+ the member has been measured (its first three round trips). The rules are
148
+ the same in every skaidb driver
143
149
  ([PROTOCOL.md §6.1](https://skaidb.org/docs/PROTOCOL.html#61-balancing-a-pool-across-members)).
144
150
 
145
151
  ## Behaviour
data/lib/skaidb.rb CHANGED
@@ -35,7 +35,7 @@ require "bigdecimal"
35
35
  module Skaidb
36
36
  # The package version — the single source of truth. The gemspec reads it,
37
37
  # and it is what the driver reports to the server in the Hello frame.
38
- VERSION = "1.4.0"
38
+ VERSION = "1.4.1"
39
39
 
40
40
  # Base class for every error raised by this driver.
41
41
  class Error < StandardError; end
@@ -1568,13 +1568,18 @@ module Skaidb
1568
1568
  class Pool
1569
1569
  POLICIES = %i[random round_robin least_latency].freeze
1570
1570
  IDLE_ORDERS = %i[lifo fifo].freeze
1571
- # Latency EWMA: each round trip moves a member's average 5% toward it,
1572
- # and no sample counts for more than 10x the larger of the current
1573
- # average and the ejection floor — one slow query does not eject a
1574
- # healthy member, a member that stays slow is ejected within a few dozen
1575
- # statements.
1571
+ # Latency EWMA: the fastest of a member's first round trips seeds it (a
1572
+ # round trip is only ever lengthened — by the member, the network or this
1573
+ # process stalling — so a slow first statement cannot set it); then each
1574
+ # round trip moves it 5% toward it, and no sample counts for more than
1575
+ # 10x the larger of the current average and the ejection floor — one slow
1576
+ # query does not eject a healthy member, a member that stays slow is
1577
+ # ejected within a few dozen statements.
1578
+ EWMA_SEED_SAMPLES = 3
1576
1579
  EWMA_WEIGHT = 0.05
1577
1580
  EWMA_CLAMP = 10.0
1581
+ # Round trips a latency ejection's warning reports the slowest of.
1582
+ RECENT_SAMPLES = 10
1578
1583
  # Round trips a member (and at least one other) needs before its average
1579
1584
  # can eject it.
1580
1585
  EWMA_MIN_SAMPLES = 10
@@ -1591,12 +1596,13 @@ module Skaidb
1591
1596
  MAINTAIN_TICK = 0.25
1592
1597
  # Read-your-writes keys kept before the expired ones are swept.
1593
1598
  AFFINITY_SWEEP = 4096
1594
- private_constant :EWMA_WEIGHT, :EWMA_CLAMP, :EWMA_MIN_SAMPLES, :STALE_SAMPLE,
1595
- :REFRESH_NOT_READY, :MAINTAIN_TICK, :AFFINITY_SWEEP
1599
+ private_constant :EWMA_SEED_SAMPLES, :EWMA_WEIGHT, :EWMA_CLAMP, :RECENT_SAMPLES,
1600
+ :EWMA_MIN_SAMPLES, :STALE_SAMPLE, :REFRESH_NOT_READY, :MAINTAIN_TICK,
1601
+ :AFFINITY_SWEEP
1596
1602
 
1597
1603
  # One member as the pool sees it.
1598
1604
  class Member
1599
- attr_reader :endpoint, :host, :port, :seed
1605
+ attr_reader :endpoint, :host, :port, :seed, :firsts, :recent
1600
1606
  attr_accessor :canonical, :listed, :ewma, :samples, :last_sample, :errors,
1601
1607
  :ejected_until, :ejections, :readmitted_at, :in_use, :probing
1602
1608
 
@@ -1608,6 +1614,8 @@ module Skaidb
1608
1614
  @canonical = nil # its own SHOW ENDPOINTS name
1609
1615
  @listed = :ready # :ready, :resyncing, :unreachable, :unlisted
1610
1616
  @ewma = nil # seconds
1617
+ @firsts = [] # round trips before the average is seeded
1618
+ @recent = [] # the last RECENT_SAMPLES round trips
1611
1619
  @samples = 0
1612
1620
  @last_sample = 0.0
1613
1621
  @errors = 0 # consecutive transport failures
@@ -1617,6 +1625,14 @@ module Skaidb
1617
1625
  @in_use = 0
1618
1626
  @probing = false
1619
1627
  end
1628
+
1629
+ # Measure the member afresh (a re-admission, back to ready).
1630
+ def reset_latency
1631
+ @ewma = nil
1632
+ @firsts.clear
1633
+ @recent.clear
1634
+ @samples = 0
1635
+ end
1620
1636
  end
1621
1637
  private_constant :Member
1622
1638
 
@@ -1787,15 +1803,21 @@ module Skaidb
1787
1803
  next if m.nil?
1788
1804
 
1789
1805
  m.errors = 0
1806
+ m.recent.push(elapsed)
1807
+ m.recent.shift if m.recent.length > RECENT_SAMPLES
1790
1808
  if m.ewma.nil?
1791
- m.ewma = elapsed
1809
+ m.firsts.push(elapsed)
1810
+ if m.firsts.length >= EWMA_SEED_SAMPLES
1811
+ m.ewma = m.firsts.min
1812
+ m.firsts.clear
1813
+ end
1792
1814
  else
1793
1815
  cap = EWMA_CLAMP * [m.ewma, @eject_floor].max
1794
1816
  m.ewma += EWMA_WEIGHT * ([elapsed, cap].min - m.ewma)
1795
1817
  end
1796
1818
  m.samples += 1
1797
1819
  m.last_sample = now
1798
- next unless m.samples >= EWMA_MIN_SAMPLES && @eject_factor.positive? && m.ejected_until.nil?
1820
+ next unless m.ewma && m.samples >= EWMA_MIN_SAMPLES && @eject_factor.positive? && m.ejected_until.nil?
1799
1821
 
1800
1822
  others = @members.each_value.select do |o|
1801
1823
  !o.equal?(m) && usable?(o) && o.ewma && o.samples >= EWMA_MIN_SAMPLES
@@ -1804,7 +1826,9 @@ module Skaidb
1804
1826
 
1805
1827
  median = others[(others.length - 1) / 2]
1806
1828
  if m.ewma > @eject_factor * median && m.ewma - median > @eject_floor
1807
- eject(m, format("latency %.1f ms vs median %.1f ms", m.ewma * 1000, median * 1000))
1829
+ eject(m, format("latency %.1f ms vs median %.1f ms; " \
1830
+ "slowest of the last %d round trips %.1f ms",
1831
+ m.ewma * 1000, median * 1000, m.recent.length, m.recent.max * 1000))
1808
1832
  end
1809
1833
  end
1810
1834
  end
@@ -2009,8 +2033,7 @@ module Skaidb
2009
2033
  if ok && !@closed
2010
2034
  m.ejected_until = nil
2011
2035
  m.errors = 0
2012
- m.ewma = nil
2013
- m.samples = 0
2036
+ m.reset_latency
2014
2037
  m.readmitted_at = now
2015
2038
  log("re-admitted #{m.endpoint}")
2016
2039
  if @idle.length < @maxsize
@@ -2112,8 +2135,7 @@ module Skaidb
2112
2135
  state = %w[ready resyncing unreachable].include?(state) ? state.to_sym : :ready
2113
2136
  if state == :ready && m.listed != :ready
2114
2137
  # Back in rotation: measured afresh, like a re-admission.
2115
- m.ewma = nil
2116
- m.samples = 0
2138
+ m.reset_latency
2117
2139
  m.errors = 0
2118
2140
  end
2119
2141
  m.listed = state
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skaidb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - skaidb contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-30 00:00:00.000000000 Z
11
+ date: 2026-10-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal