skaidb 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a452610f682221a59cfbbc48e42c9a37f1c0e880d2191037f1c9c7151a3034fa
4
- data.tar.gz: 1a73f7f552091bfaed9b4072dfd684b57daac637ff12d163a34bf1c278bef784
3
+ metadata.gz: 0514ffe373981266d475977fd65030cbf7bdc8a2ba568c77b80afe963904e242
4
+ data.tar.gz: a7a1ce25bf193955ae0f765df14245ab0cf1a4d6ff6902f712291666646b0061
5
5
  SHA512:
6
- metadata.gz: ce44a1e28e0add382aa9fde578eb09350e1b5306d22c3cf5d272faa5d606c8ec05be08b77967d014a33ceddba5fefd0ed449503c24d4997680c37af156ad2e5f
7
- data.tar.gz: 3d34175d862982988e593fb42c631bdbb9cd6f23682908ec40fc53a7d25fe3868105201dd50988108508783bfb8bf076065737864d71c6cc5337c8b6c371e6c3
6
+ metadata.gz: e60490e9a1e5ef0381199c96981bf18a19cfd915da2926ce6ac3395bb31cdb20be2d3c5135a1cc7b6172ab48dae15877d0479ea6aa86b1aa0da0219e062f70e9
7
+ data.tar.gz: 9a8a3422a05a1eb3294bd67425a6d59291924254d6b9acac4002923d14867b3f30205a21446b254c2957a51090a76508deb69df6abd1058b5bdef96383402d95
data/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ 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
+
25
+ ## [1.4.0] - 2026-10-29
26
+
27
+ ### Fixed
28
+ - A streamed `CALL` whose procedure body `EMIT`s result sets returns them.
29
+ The server answers such a call with every set in one `ResultSets` frame
30
+ (tag 8) even to a stream request, and `conn.stream`, with or without
31
+ parameters, raised `QueryError` (`unexpected response tag 8 to stream
32
+ request`) and lost the sets. The frame is now decoded by the code `exec`
33
+ uses: the block receives the rows `exec`'s `Result` would yield (the
34
+ call's final result, the LAST set) and `stream` returns that same `:rows`
35
+ `Result`, every set in order under `result_sets`. Nothing is left in
36
+ flight, so the connection stays usable — the block may even run
37
+ statements on it.
38
+
7
39
  ## [1.3.0] - 2026-10-29
8
40
 
9
41
  ### Added
@@ -192,6 +224,8 @@ batches, a 2 500-row stream, error handling, and the `drivers` table showing
192
224
  because it is a bundled gem since Ruby 3.4.
193
225
  - The example moved to `examples/basic.rb`.
194
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
195
229
  [1.3.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.2.0...v1.3.0
196
230
  [1.2.0]: https://github.com/porcupin26/skaidb-ruby/compare/v1.1.0...v1.2.0
197
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.3.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.3.0.gem`.
64
+ installs without registry access: `gem install ./skaidb-1.4.1.gem`.
65
65
 
66
66
  ## Quick start
67
67
 
@@ -306,7 +306,10 @@ unordered or in storage-key order), and no scan budget applies to a streamed
306
306
  page (server ≥ 0.330). With a block `stream` returns a `Result` whose `kind`
307
307
  is `:stream` and whose `fields` are the column names (the rows went to the
308
308
  block); a non-row statement returns its `:mutation` / `:ddl` Result without
309
- yielding.
309
+ yielding. A `CALL` whose body `EMIT`s result sets is answered with all of
310
+ them in one frame even when streamed: the block receives the rows `exec`
311
+ would return (the last set), and `stream` returns that same `Result`, every
312
+ set under `result_sets`.
310
313
 
311
314
  **The abandon/drain rule.** The connection is busy for the whole stream:
312
315
  a statement from another thread waits for it. Leaving the block early —
@@ -356,8 +359,11 @@ pool.members # => [{ endpoint: "db1:7000", canonical: "10.0.0.1:7000", state: :r
356
359
 
357
360
  A member with consecutive transport failures, or a round-trip average far
358
361
  above the others', is ejected and re-admitted by a background probe (the last
359
- usable member never is); `idle_order: :fifo` keeps every idle connection in
360
- 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).
361
367
 
362
368
  ## Streams (`CREATE STREAM`)
363
369
 
@@ -458,7 +464,7 @@ frame after every handshake. It shows up as `client_name = 'ruby'` /
458
464
 
459
465
  ```sql
460
466
  SELECT client_name, client_version FROM drivers;
461
- -- ruby | 1.3.0
467
+ -- ruby | 1.4.1
462
468
  ```
463
469
 
464
470
  The server records the row asynchronously, so a `SELECT` immediately after
data/docs/api.md CHANGED
@@ -67,7 +67,10 @@ Raises `Skaidb::Error` before dialing for an unknown `auth_mechanism`, a
67
67
  `ArgumentError`. For a row stream the returned Result has
68
68
  `kind == :stream`, the column names in `fields` and no `rows` (they went to
69
69
  the block); a statement without rows returns its `:mutation` / `:ddl`
70
- Result without yielding. Without a block returns an Enumerator.
70
+ Result without yielding; a `CALL` whose body `EMIT`s result sets (answered
71
+ in one frame even when streamed) yields the rows `exec` would return (the
72
+ last set) and returns that same `:rows` Result, every set under
73
+ `result_sets`. Without a block returns an Enumerator.
71
74
  - `subscribe(stream_name, after: nil, poll: 0.5) { |event| … }` — yields a
72
75
  `CREATE STREAM` log's events forever, each a Hash with `"id"`, `"op"`,
73
76
  `"k"`, `"ts"`, `"doc"`. Polls `_stream_<name>` 500 rows at a time with a
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/docs/streaming.md CHANGED
@@ -28,6 +28,12 @@ end
28
28
  - A non-row statement (an `INSERT`, DDL, `USE`) answers with its ordinary
29
29
  result over the same opcode; `stream` returns that Result (`kind`
30
30
  `:mutation` with `cmd_tuples`, or `:ddl`) without yielding.
31
+ - A `CALL` whose procedure body `EMIT`s result sets answers with all of them
32
+ in one frame, as it does to `exec`: the block receives the rows `exec`
33
+ would return (the call's final result, the last set) and `stream` returns
34
+ that same `:rows` Result, every set in order under `result_sets`. Nothing
35
+ is left in flight, so the connection is free again before the first row
36
+ reaches the block.
31
37
  - A server without the streaming opcode raises `Skaidb::QueryError`
32
38
  (`server does not support streaming: …`).
33
39
 
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.3.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
@@ -973,7 +973,12 @@ module Skaidb
973
973
  # +kind+ is +:stream+ and +fields+ holds the column names (known even when
974
974
  # no row arrived) while +rows+ stays empty — the rows went to the block;
975
975
  # a statement that produces no rows returns its ordinary +:mutation+ or
976
- # +:ddl+ Result without yielding.
976
+ # +:ddl+ Result without yielding. A CALL whose body EMITs result sets is
977
+ # answered with all of them in one frame, streamed or not: the block
978
+ # receives the rows exec's Result would yield (the call's final result,
979
+ # the LAST set) and the return value is that same +:rows+ Result, every
980
+ # set under +result_sets+ — with nothing left in flight, so the block may
981
+ # use the connection.
977
982
  def stream(sql, params = [], consistency: nil)
978
983
  return enum_for(:stream, sql, params, consistency: consistency) unless block_given?
979
984
  raise ConnectionError, "connection is closed" if @closed
@@ -991,6 +996,7 @@ module Skaidb
991
996
 
992
997
  live = false
993
998
  summary = nil
999
+ sets = nil
994
1000
  @mutex.synchronize do
995
1001
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
996
1002
  begin
@@ -1015,6 +1021,12 @@ module Skaidb
1015
1021
  return Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation) # not row-producing
1016
1022
  when 2
1017
1023
  return Result.new(fields: [], rows: [], cmd_tuples: 0, kind: :ddl)
1024
+ when 8
1025
+ # ResultSets: a CALL whose body EMITted is answered with every set
1026
+ # in ONE frame, streamed or not — the frame exec receives, decoded
1027
+ # by the same code. Nothing follows it, so the rows are yielded
1028
+ # below, with the connection free.
1029
+ sets = parse_result_sets(r)
1018
1030
  when 5
1019
1031
  # The server has already committed to a row stream, so from here
1020
1032
  # the socket carries frames this call owns — mark it BEFORE
@@ -1065,6 +1077,12 @@ module Skaidb
1065
1077
  raise QueryError, "unexpected response tag #{tag} to stream request"
1066
1078
  end
1067
1079
  end
1080
+ if sets
1081
+ # What exec's Result yields (the call's final result, the LAST set),
1082
+ # and that same Result back, every set under +result_sets+.
1083
+ sets.each { |row| yield row }
1084
+ return sets
1085
+ end
1068
1086
  summary
1069
1087
  end
1070
1088
 
@@ -1388,27 +1406,9 @@ module Skaidb
1388
1406
  tag = r.u8
1389
1407
  case tag
1390
1408
  when 0 # Rows
1391
- ncols = r.u32
1392
- columns = Array.new(ncols) { r.text }
1393
- nrows = r.u32
1394
- rows = Array.new(nrows) do
1395
- ncells = r.u32
1396
- Array.new(ncells) { Skaidb.decode_value(Reader.new(r.blob)) }
1397
- end
1398
- Result.new(fields: columns, rows: rows, cmd_tuples: 0)
1409
+ parse_rows(r)
1399
1410
  when 8 # ResultSets: a CALL whose body EMITted
1400
- sets = Array.new(r.u32) do
1401
- ncols = r.u32
1402
- columns = Array.new(ncols) { r.text }
1403
- nrows = r.u32
1404
- rows = Array.new(nrows) do
1405
- ncells = r.u32
1406
- Array.new(ncells) { Skaidb.decode_value(Reader.new(r.blob)) }
1407
- end
1408
- Result.new(fields: columns, rows: rows, cmd_tuples: 0)
1409
- end
1410
- last = sets.last || Result.new(fields: [], rows: [], cmd_tuples: 0)
1411
- Result.new(fields: last.fields, rows: last.rows, cmd_tuples: 0, result_sets: sets)
1411
+ parse_result_sets(r)
1412
1412
  when 1 # Mutation
1413
1413
  Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation)
1414
1414
  when 2 # Ddl
@@ -1419,6 +1419,29 @@ module Skaidb
1419
1419
  raise ConnectionError, "unknown response tag #{tag}"
1420
1420
  end
1421
1421
  end
1422
+
1423
+ # A Rows body (PROTOCOL.md §3.2), also one set of ResultSets; +r+ sits
1424
+ # just past the tag.
1425
+ def parse_rows(r)
1426
+ ncols = r.u32
1427
+ columns = Array.new(ncols) { r.text }
1428
+ nrows = r.u32
1429
+ rows = Array.new(nrows) do
1430
+ ncells = r.u32
1431
+ Array.new(ncells) { Skaidb.decode_value(Reader.new(r.blob)) }
1432
+ end
1433
+ Result.new(fields: columns, rows: rows, cmd_tuples: 0)
1434
+ end
1435
+
1436
+ # A ResultSets body (a CALL whose body EMITted), +r+ just past the tag:
1437
+ # the LAST set is the Result's own rows, every set is under
1438
+ # +result_sets+. The buffered path and a stream request both receive
1439
+ # this frame, and both decode it here.
1440
+ def parse_result_sets(r)
1441
+ sets = Array.new(r.u32) { parse_rows(r) }
1442
+ last = sets.last || Result.new(fields: [], rows: [], cmd_tuples: 0)
1443
+ Result.new(fields: last.fields, rows: last.rows, cmd_tuples: 0, result_sets: sets)
1444
+ end
1422
1445
  end
1423
1446
 
1424
1447
  # ---- module entry point --------------------------------------------------
@@ -1545,13 +1568,18 @@ module Skaidb
1545
1568
  class Pool
1546
1569
  POLICIES = %i[random round_robin least_latency].freeze
1547
1570
  IDLE_ORDERS = %i[lifo fifo].freeze
1548
- # Latency EWMA: each round trip moves a member's average 5% toward it,
1549
- # and no sample counts for more than 10x the larger of the current
1550
- # average and the ejection floor — one slow query does not eject a
1551
- # healthy member, a member that stays slow is ejected within a few dozen
1552
- # 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
1553
1579
  EWMA_WEIGHT = 0.05
1554
1580
  EWMA_CLAMP = 10.0
1581
+ # Round trips a latency ejection's warning reports the slowest of.
1582
+ RECENT_SAMPLES = 10
1555
1583
  # Round trips a member (and at least one other) needs before its average
1556
1584
  # can eject it.
1557
1585
  EWMA_MIN_SAMPLES = 10
@@ -1568,12 +1596,13 @@ module Skaidb
1568
1596
  MAINTAIN_TICK = 0.25
1569
1597
  # Read-your-writes keys kept before the expired ones are swept.
1570
1598
  AFFINITY_SWEEP = 4096
1571
- private_constant :EWMA_WEIGHT, :EWMA_CLAMP, :EWMA_MIN_SAMPLES, :STALE_SAMPLE,
1572
- :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
1573
1602
 
1574
1603
  # One member as the pool sees it.
1575
1604
  class Member
1576
- attr_reader :endpoint, :host, :port, :seed
1605
+ attr_reader :endpoint, :host, :port, :seed, :firsts, :recent
1577
1606
  attr_accessor :canonical, :listed, :ewma, :samples, :last_sample, :errors,
1578
1607
  :ejected_until, :ejections, :readmitted_at, :in_use, :probing
1579
1608
 
@@ -1585,6 +1614,8 @@ module Skaidb
1585
1614
  @canonical = nil # its own SHOW ENDPOINTS name
1586
1615
  @listed = :ready # :ready, :resyncing, :unreachable, :unlisted
1587
1616
  @ewma = nil # seconds
1617
+ @firsts = [] # round trips before the average is seeded
1618
+ @recent = [] # the last RECENT_SAMPLES round trips
1588
1619
  @samples = 0
1589
1620
  @last_sample = 0.0
1590
1621
  @errors = 0 # consecutive transport failures
@@ -1594,6 +1625,14 @@ module Skaidb
1594
1625
  @in_use = 0
1595
1626
  @probing = false
1596
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
1597
1636
  end
1598
1637
  private_constant :Member
1599
1638
 
@@ -1764,15 +1803,21 @@ module Skaidb
1764
1803
  next if m.nil?
1765
1804
 
1766
1805
  m.errors = 0
1806
+ m.recent.push(elapsed)
1807
+ m.recent.shift if m.recent.length > RECENT_SAMPLES
1767
1808
  if m.ewma.nil?
1768
- 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
1769
1814
  else
1770
1815
  cap = EWMA_CLAMP * [m.ewma, @eject_floor].max
1771
1816
  m.ewma += EWMA_WEIGHT * ([elapsed, cap].min - m.ewma)
1772
1817
  end
1773
1818
  m.samples += 1
1774
1819
  m.last_sample = now
1775
- 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?
1776
1821
 
1777
1822
  others = @members.each_value.select do |o|
1778
1823
  !o.equal?(m) && usable?(o) && o.ewma && o.samples >= EWMA_MIN_SAMPLES
@@ -1781,7 +1826,9 @@ module Skaidb
1781
1826
 
1782
1827
  median = others[(others.length - 1) / 2]
1783
1828
  if m.ewma > @eject_factor * median && m.ewma - median > @eject_floor
1784
- 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))
1785
1832
  end
1786
1833
  end
1787
1834
  end
@@ -1986,8 +2033,7 @@ module Skaidb
1986
2033
  if ok && !@closed
1987
2034
  m.ejected_until = nil
1988
2035
  m.errors = 0
1989
- m.ewma = nil
1990
- m.samples = 0
2036
+ m.reset_latency
1991
2037
  m.readmitted_at = now
1992
2038
  log("re-admitted #{m.endpoint}")
1993
2039
  if @idle.length < @maxsize
@@ -2089,8 +2135,7 @@ module Skaidb
2089
2135
  state = %w[ready resyncing unreachable].include?(state) ? state.to_sym : :ready
2090
2136
  if state == :ready && m.listed != :ready
2091
2137
  # Back in rotation: measured afresh, like a re-admission.
2092
- m.ewma = nil
2093
- m.samples = 0
2138
+ m.reset_latency
2094
2139
  m.errors = 0
2095
2140
  end
2096
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.3.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-29 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