stablemates-workhorse 0.8.0 → 0.9.0

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: 807d43f9b16d79430d292a3e6c70acdc55bb8cc3f2010b7e03f0356fb0731c90
4
- data.tar.gz: f84ab68d2d6d03450983e74dc337880546815472ece79d8cdf1d650679d3912a
3
+ metadata.gz: b030c71db8e2fa0f3b2e14e4ac34442270e1643ea1105efe904bc965ab6e4823
4
+ data.tar.gz: 90256bbd761dfa37265e9e0fce4f5f49534e378361a625bbc6bc27cf24b7634b
5
5
  SHA512:
6
- metadata.gz: 706d9cfd755c1bc971116245009433dba6d4880dc7b7dec081f486d95f5395395a69b55527527b33252f4cf4bbc5e1a1b94c74ad91b267fe6893884c3bdb09ef
7
- data.tar.gz: dd7e13f971e790cc10c4f07843a4b7ec9f086f32365c665f681206bf1376d1d7d03bf11e6dfa78aacc75ea8cb2ce292da3246b9471431d7fa6b1630c58c46a17
6
+ metadata.gz: 9d580a9360fe1772b51567673be3d787ea287ba7df8693d93d8ea4b153620f0a4c0b4c5ad492593830127ccd2c78a1063b5ac5f9174bbbea6aae3f1ca2880b70
7
+ data.tar.gz: d353d8e0ce3d2b03b869212dee916bcead4c41034de9403c83ab1f3e3a74eaaadfa36bf0c0c0c03f22728efdee770ef437c3de369f51d1f36765f5861948679f
data/CHANGELOG.md CHANGED
@@ -3,6 +3,126 @@
3
3
  `stablemates-workhorse` gem versions and release notes live here. The gem carries the version the
4
4
  other SDKs carry, because every tag names one release of all of them.
5
5
 
6
+ ## 0.9.0 — 2026-10-20
7
+
8
+ The npm packages, Python distribution, Go module, Rust crate, Ruby gem, and JVM artifacts release from one source commit.
9
+
10
+ Requires **schema v73**, Ruby **3.3** or newer, and PostgreSQL **15** or newer.
11
+
12
+ ### Upgrade steps
13
+
14
+ **Migrate the schema before starting updated processes.** Run `workhorse schema migrate` before any
15
+ process of this release starts, producers too. Migrations 0073 and 0074 move the schema to version
16
+ **73**. The SDK compatibility floor rises from 69 to **73**, because every SDK statement catalogue
17
+ now names `heartbeat_many_v2` and `heartbeat_v2`. An updated process refuses a schema below version
18
+ 73 at startup. A 0.8 process keeps working on version 73, because a migration inside a major line
19
+ only adds.
20
+
21
+ ### Heartbeats do not wait for a locked task row
22
+
23
+ Before this release, a heartbeat waited for a task row that another transaction locked. If a
24
+ settlement held the row, the heartbeat then kept the row locked after the settlement committed, and
25
+ a claim skipped the ready task (SM-1434). The worker now renews leases through `heartbeat_many_v2`
26
+ (SM-1443, ADR 0105). It skips a locked row and reports that lease as `busy`: no renewal and no
27
+ ownership verdict. The worker keeps a busy lease, retries only the busy leases after a short delay
28
+ with jitter, and does not move the regular heartbeat schedule. A busy answer never extends the local
29
+ lease watchdog, so a lease that stays busy until the watchdog ends is lost, as before. Old and new
30
+ workers can run together: an old worker keeps calling `heartbeat_many_v1`, which does not change.
31
+
32
+ **One-lease heartbeat without a row lock wait.** Migration 0074 adds `heartbeat_v2` (SM-1461). It
33
+ renews one lease by the rule of `heartbeat_many_v2`, so it reports a locked row as `busy`. This SDK
34
+ names it in its statement catalogue but does not call it. `heartbeat_v1` does not change.
35
+
36
+ ### Heartbeat rounds keep a renewal margin and end on the server
37
+
38
+ Before this release, the dedicated heartbeat connection bounded each heartbeat statement by one full
39
+ interval. After a failed round, the heartbeat thread started a new schedule on its new connection.
40
+ With the defaults, one timed-out round thus left about 0 s before the lease watchdog expired
41
+ (SM-1444). Also, when the worker stopped waiting for a round, PostgreSQL continued to run the
42
+ heartbeat statement, and that statement kept its row locks (SM-1442). These changes apply to each
43
+ round on the dedicated heartbeat connection:
44
+
45
+ - **Rounds start on a fixed schedule.** A round falls due one interval after the previous round
46
+ started, not after it ended. The schedule uses a monotonic clock. If a round ends after its next
47
+ slot, the worker starts one overdue round at once and sends no round for the other missed slots.
48
+ - **The round bound is shorter.** A round ends by
49
+ `max(1 ms, interval × 3 / 4, min(50 ms, interval))`. For an interval of 200 ms or more, this is
50
+ three quarters of the interval. With the defaults (lease 30 s, interval 10 s), the bound is 7.5 s,
51
+ so one timed-out round leaves a margin of 2.5 s. An attempt keeps its lease after one timed-out
52
+ round if `2 × interval + round bound < lease`. The worker does not reject an interval that breaks
53
+ this rule. Before this release, each statement of a round had its own bound of one interval. Now
54
+ all statements of one round share one deadline, and a statement that would start after it waits
55
+ for the next round. Each round starts with the next statement in turn, so a statement that is
56
+ always slow cannot keep a later statement from its turn.
57
+ - **Each round sets server timeouts.** The round sends `BEGIN`, then one `set_config` statement for
58
+ `statement_timeout` and `idle_in_transaction_session_timeout`, then `heartbeat_many_v2`, then
59
+ `COMMIT`. Both settings are transaction-local. The heartbeat statement gets one half of the time
60
+ that remains, and each idle gap one eighth. If less than 8 ms remains at the settings statement,
61
+ the round fails before the heartbeat. The worker counts a renewal only after a `COMMIT` that ends
62
+ before the deadline. No setting bounds `COMMIT` on PostgreSQL 15 and later.
63
+ - **A failed round discards its connection only when a statement may still run** (SM-1492). A
64
+ round that expires before it sends the heartbeat statement rolls back and keeps its connection.
65
+ Under load, PostgreSQL can log
66
+ `canceling statement due to statement timeout` for a heartbeat statement. It can also end the
67
+ session with `terminating connection due to idle-in-transaction timeout`. Each message means that
68
+ one round failed. Every task continues, and the next round tries again. A deadlock (`40P01`) also
69
+ fails the round.
70
+ - **A busy retry with less than 20 ms left sends nothing** (SM-1492). A bounded busy retry runs
71
+ only if its deadline is at least 20 ms after its start: the 8 ms that a round needs at its
72
+ settings statement, plus a 12 ms margin. Otherwise the next regular round renews the busy leases.
73
+ - **Replacement connections.** The heartbeat thread keeps the start of the latest round when it
74
+ replaces a failed connection.
75
+ - **100 ms is the smallest supported heartbeat interval** (SM-1471). This applies to `heartbeat:` of
76
+ `Worker`, in seconds: the smallest supported value is 0.1. At 100 ms, the round bound is 75 ms.
77
+ The worker still accepts a shorter interval, and it does not warn. On a loaded host, a shorter
78
+ interval can make each round miss its deadline, and attempts can then lose their leases.
79
+
80
+ Rounds under `shared_heartbeats: true` have no client deadline and set no server timeouts, as
81
+ before. They follow the new schedule. The [heartbeat round
82
+ deadline](../docs/architecture/lifecycle.md#heartbeat-round-deadline) and the [heartbeat
83
+ schedule](../docs/architecture/lifecycle.md#heartbeat-schedule) state each rule.
84
+
85
+ ### A payload contract compares the decimals of numbers
86
+
87
+ Before this release, `ContractSchema` compared an Integer with the binary value of a Float in the
88
+ bound keywords, `const`, `enum`, and `uniqueItems` (SM-1539). Thus `1152921504606846980` failed
89
+ `{"maximum": 1.152921504606847e18}`, although PostgreSQL stores the constant as
90
+ `1152921504606847000`. A contract now compares each Float through the decimal of `Float#to_s`
91
+ (ADR 0039). Ruby still reads a stored schema constant that a double cannot hold as a Float
92
+ (SM-1541).
93
+
94
+ ### A bad worker interval raises ArgumentError
95
+
96
+ Before this release, a bad value for `heartbeat`, `poll_interval`, `maintenance_interval`,
97
+ `maintenance_routine_interval`, or `registry_interval` raised `NoMethodError`, because these options
98
+ have no upper limit (SM-1511). For example, `heartbeat: 0` did this. Now `Worker.new` raises
99
+ `ArgumentError`, and the message names the option and its lower limit.
100
+
101
+ ### The `workhorse.tasks.retried` description matches `api/telemetry.txt`
102
+
103
+ The Ruby worker now describes `workhorse.tasks.retried` as "Failed or expired tasks returned to live
104
+ work", as the contract does (SM-1514). The counter already counted expired tasks. Only the
105
+ description changes.
106
+
107
+ ### A wrapped durable replay conflict is terminal
108
+
109
+ Before this release, the Ruby worker checked only the outermost error for a durable replay conflict
110
+ (ADR 0098). If a handler wrapped a conflict in its own error, the worker recorded a retryable
111
+ failure, and the conflict repeated until `max_attempts` (SM-1512). The worker now follows the cause
112
+ chain of the error, as the Go and Rust workers do. It reads at most 16 errors, so a cyclic chain
113
+ cannot stop it. It reads `Exception#cause`, so an error raised in a `rescue` block also counts. A
114
+ wrapped conflict fails the task at once. The failure envelope records the name of the conflict. Its
115
+ fields `message` and `stack` stay those of the outer error.
116
+
117
+ ### A heartbeat round sends at most 100 leases in each statement
118
+
119
+ `heartbeat_many_v2` accepts at most 100 leases. A fused completion claim registers its tasks before
120
+ the completing attempts unregister, so a round can hold more leases than the concurrency limit. That
121
+ round raised an error, and no lease in it renewed (SM-1495). The worker now splits a round into
122
+ statements of at most 100 leases. Each statement counts on its own. A failed statement renews none
123
+ of its leases, and the statements before it still count, as before for the statements of each
124
+ worker.
125
+
6
126
  ## 0.8.0 — 2026-10-13
7
127
 
8
128
  The npm packages, Python distribution, Go module, Rust crate, and Ruby gem release from one source commit.
data/README.md CHANGED
@@ -3,6 +3,13 @@
3
3
  The Ruby queue client, operator client, and worker runtime for the Workhorse durable task queue for
4
4
  PostgreSQL.
5
5
 
6
+ <picture>
7
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://workhorse.run/diagrams/readme-overview-narrow-dark.png">
8
+ <source media="(max-width: 600px)" srcset="https://workhorse.run/diagrams/readme-overview-narrow-light.png">
9
+ <source media="(prefers-color-scheme: dark)" srcset="https://workhorse.run/diagrams/readme-overview-dark.png">
10
+ <img src="https://workhorse.run/diagrams/readme-overview-light.png" width="960" alt="One protocol for your workers and your dashboard">
11
+ </picture>
12
+
6
13
  > **Public beta:** Workhorse is usable for evaluation and early production adoption. A 0.x minor
7
14
  > release may change behaviour, so read the
8
15
  > [changelog](https://github.com/stablemates/workhorse/blob/main/ruby/CHANGELOG.md) before you
@@ -23,7 +30,7 @@ bundle add connection_pool
23
30
  Install the schema once, as a deployment step. The application never installs or migrates it.
24
31
 
25
32
  ```bash
26
- npx --package @stablemates/workhorse@0.8.0 workhorse schema install
33
+ npx --package @stablemates/workhorse@0.9.0 workhorse schema install
27
34
  ```
28
35
 
29
36
  The machine that runs that deployment step needs Node.js 22 or newer. The application itself needs
@@ -144,7 +144,7 @@ module Stablemates
144
144
 
145
145
  private
146
146
 
147
- # A reference names the root schema or one root definition. The libraries behind the five SDKs
147
+ # A reference names the root schema or one root definition. The validators behind the six SDKs
148
148
  # resolve those two forms alike, and both name a schema position the profile check visited. The
149
149
  # check runs before +valid?+, so +valid?+ cannot fail on the schema.
150
150
  def check_references(schema, path)
@@ -213,11 +213,11 @@ module Stablemates
213
213
 
214
214
  def number_valid?(keyword, value, instance)
215
215
  case keyword
216
- when "minimum" then instance >= value
217
- when "maximum" then instance <= value
218
- when "exclusiveMinimum" then instance > value
219
- when "exclusiveMaximum" then instance < value
220
- when "multipleOf" then (Rational(instance.to_s) / Rational(value.to_s)).denominator == 1
216
+ when "minimum" then exact(instance) >= exact(value)
217
+ when "maximum" then exact(instance) <= exact(value)
218
+ when "exclusiveMinimum" then exact(instance) > exact(value)
219
+ when "exclusiveMaximum" then exact(instance) < exact(value)
220
+ when "multipleOf" then finite?(instance) && (exact(instance) / exact(value)).denominator == 1
221
221
  else true
222
222
  end
223
223
  end
@@ -287,6 +287,13 @@ module Stablemates
287
287
 
288
288
  def number?(instance) = instance.is_a?(Integer) || instance.is_a?(Float)
289
289
 
290
+ # The decimal that JSON writes to PostgreSQL for a number. Float#to_s gives the shortest
291
+ # decimal that reads back as the same Float, so 2**60 as a Float compares as
292
+ # 1152921504606847000, not as its binary value. A non-finite Float stays a Float.
293
+ def exact(number) = (number.is_a?(Float) && number.finite?) ? Rational(number.to_s) : number
294
+
295
+ def finite?(number) = !number.is_a?(Float) || number.finite?
296
+
290
297
  # JSON equality: 1 equals 1.0, and true equals neither 1 nor 1.0.
291
298
  def equal?(left, right)
292
299
  case left
@@ -295,7 +302,7 @@ module Stablemates
295
302
  end
296
303
  when Array then right.is_a?(Array) && left.size == right.size && left.zip(right).all? { |l, r| equal?(l, r) }
297
304
  when true, false, nil then left.equal?(right)
298
- when Integer, Float then number?(right) && left == right
305
+ when Integer, Float then number?(right) && exact(left) == exact(right)
299
306
  else left == right
300
307
  end
301
308
  end
@@ -2,5 +2,5 @@
2
2
  "formatVersion": 1,
3
3
  "readSurfaceVersion": 1,
4
4
  "archive": "read-surface-1.tar.gz",
5
- "sha256": "b65d68d2a7dd68e1cb7e58512f4d6467e9f26dea7f405515503da5f78d974234"
5
+ "sha256": "698c094e8ef4a0ccee589f425b03243abb97e7c817b271b920ddbb394cf99454"
6
6
  }
@@ -10,8 +10,8 @@ module Stablemates
10
10
  CLIENT_PROTOCOL_VERSION = 5
11
11
  MINIMUM_PROTOCOL_VERSION = 5
12
12
  MAXIMUM_PROTOCOL_VERSION = 5
13
- MINIMUM_SCHEMA_VERSION = 69
14
- MAXIMUM_SCHEMA_VERSION = 71
13
+ MINIMUM_SCHEMA_VERSION = 73
14
+ MAXIMUM_SCHEMA_VERSION = 73
15
15
  # PostgreSQL's atomic enqueue batch limit.
16
16
  MAX_ENQUEUE_BATCH_SIZE = 1000
17
17
  DEFAULT_TASK_VALUE_MAX_BYTES = 1048576
@@ -132,12 +132,15 @@ module Stablemates
132
132
  # `get_wait` (admin)
133
133
  GET_WAIT = 'SELECT task_id::text task_id,wait_name,mode,duration_ms::text duration_ms,requested_wake_at,wake_at,attempt,fence_token::text fence_token,worker_id,created_at FROM workhorse.task_wait WHERE task_id=$1::uuid AND wait_name=$2::text'
134
134
 
135
- # `heartbeat_many_v1` (protocol)
136
- HEARTBEAT_MANY_V1 = 'SELECT task_id::text AS task_id, status FROM workhorse.heartbeat_many_v1($1::text, $2::jsonb) ORDER BY ordinal'
135
+ # `heartbeat_many_v2` (protocol)
136
+ HEARTBEAT_MANY_V2 = 'SELECT task_id::text AS task_id, status FROM workhorse.heartbeat_many_v2($1::text, $2::jsonb) ORDER BY ordinal'
137
137
 
138
138
  # `heartbeat_v1` (protocol)
139
139
  HEARTBEAT_V1 = 'SELECT workhorse.heartbeat_v1($1::uuid, $2::text, $3::bigint, $4::integer) AS status'
140
140
 
141
+ # `heartbeat_v2` (protocol)
142
+ HEARTBEAT_V2 = 'SELECT workhorse.heartbeat_v2($1::uuid, $2::text, $3::bigint, $4::integer) AS status'
143
+
141
144
  # `list_checkpoint` (internal)
142
145
  LIST_CHECKPOINT = 'SELECT checkpoint_value FROM workhorse.task_checkpoint WHERE task_id = $1::uuid AND checkpoint_name = $2::text'
143
146
 
@@ -16,7 +16,7 @@ module Stablemates
16
16
  "workhorse.tasks.claimed" => [:counter, "Tasks claimed for handler execution", "{task}"],
17
17
  "workhorse.tasks.completed" => [:counter, "Tasks completed under a valid lease", "{task}"],
18
18
  "workhorse.tasks.failed" => [:counter, "Handler failures submitted to PostgreSQL", "{task}"],
19
- "workhorse.tasks.retried" => [:counter, "Failed tasks returned to live work", "{task}"],
19
+ "workhorse.tasks.retried" => [:counter, "Failed or expired tasks returned to live work", "{task}"],
20
20
  "workhorse.leases.expired" => [:counter, "Expired leases recovered by maintenance", "{lease}"],
21
21
  "workhorse.handler.runtime" => [:counter, "Cumulative handler execution time", "ms"],
22
22
  "workhorse.handler.executions" => [:counter, "Worker handler activations by outcome", "{execution}"],
@@ -216,13 +216,15 @@ module Stablemates
216
216
  end
217
217
 
218
218
  # Whole milliseconds from a finite Numeric count of seconds, within +range+ milliseconds.
219
- # An ActiveSupport::Duration is Numeric and converts the same way.
219
+ # An ActiveSupport::Duration is Numeric and converts the same way. An endless +range+ has
220
+ # only a lower limit.
220
221
  def milliseconds(value, label, range)
221
222
  milliseconds = whole_milliseconds(value)
222
223
  return milliseconds if milliseconds && range.cover?(milliseconds)
223
224
 
224
- raise ArgumentError, "#{label} must be a finite Numeric count of seconds between " \
225
- "#{seconds(range.begin)} and #{seconds(range.end)}"
225
+ limits = range.end.nil? ? "of at least #{seconds(range.begin)}" :
226
+ "between #{seconds(range.begin)} and #{seconds(range.end)}"
227
+ raise ArgumentError, "#{label} must be a finite Numeric count of seconds #{limits}"
226
228
  end
227
229
 
228
230
  def whole_milliseconds(value)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Stablemates
4
4
  module Workhorse
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
@@ -12,14 +12,58 @@ module Stablemates
12
12
  # the executor for each round instead.
13
13
  class Heartbeat # :nodoc:
14
14
  # One running attempt. +renew+ takes the monotonic send time of an accepted round;
15
- # +settle+ takes a status other than +accepted+; +fail+ takes an error +settle+ raised.
16
- Member = Data.define(:worker_id, :task, :lease_ms, :interval, :renew, :settle, :fail, :logger)
15
+ # +settle+ takes a status other than +accepted+ and +busy+; +fail+ takes an error +settle+
16
+ # raised. +lease_ends_at+ returns the monotonic end of the local lease window, or nil once
17
+ # the window is closed. A busy retry never runs past it.
18
+ Member = Data.define(:worker_id, :task, :lease_ms, :interval, :renew, :settle, :fail, :logger,
19
+ :lease_ends_at)
17
20
 
18
- # A round that did not finish within its interval.
21
+ # A round that did not finish by its deadline.
19
22
  class RoundTimeout < StandardError; end
20
23
 
24
+ # A round that stopped before it sent its heartbeat statement, and left its connection clean:
25
+ # it sent nothing, or its transaction rolled back. The worker keeps the connection.
26
+ class RoundExpired < StandardError; end
27
+
21
28
  STRINGS = PG::TypeMapAllStrings.new
22
- private_constant :STRINGS
29
+ # Sets both server timeouts for the current transaction only. SET LOCAL cannot take a bind
30
+ # parameter, and set_config(..., true) has the same scope.
31
+ ROUND_TIMEOUTS = "SELECT set_config('statement_timeout', $1, true), " \
32
+ "set_config('idle_in_transaction_session_timeout', $2, true)"
33
+ # A round with less time than this left, in seconds, fails before it sends its heartbeat. At
34
+ # this size every budget of +round_timeouts+ is at least 1 ms, and the budgets still fit.
35
+ MINIMUM_REMAINING = 0.008
36
+ # The longest base delay before a busy retry, in seconds (Heartbeat.busy_retry_delay).
37
+ BUSY_RETRY_BASE = 0.1
38
+ # A bounded busy retry with less time than BUSY_RETRY_MINIMUM left does not run, and the next
39
+ # regular round renews its leases. The minimum is the time a round needs at the settings
40
+ # statement, plus a margin for the payload, the wait for the connection, and BEGIN.
41
+ BUSY_RETRY_MARGIN = 0.012
42
+ BUSY_RETRY_MINIMUM = MINIMUM_REMAINING + BUSY_RETRY_MARGIN
43
+ # The wait after a shared round raised an unexpected error, in seconds, so that an error that
44
+ # repeats cannot keep the thread busy.
45
+ FAILED_ROUND_DELAY = 0.1
46
+ # heartbeat_many_v2 raises an error for more leases than this in one statement.
47
+ STATEMENT_LEASE_LIMIT = 100
48
+ private_constant :STRINGS, :ROUND_TIMEOUTS, :MINIMUM_REMAINING, :BUSY_RETRY_BASE, :BUSY_RETRY_MARGIN,
49
+ :BUSY_RETRY_MINIMUM, :FAILED_ROUND_DELAY, :STATEMENT_LEASE_LIMIT
50
+
51
+ # The deadline of one round, in seconds, for an +interval+ in seconds. It is three quarters
52
+ # of the interval, so that one timed-out round leaves a renewal margin when 2 × interval +
53
+ # bound < lease. Below an interval of 200 ms the bound is at least min(50 ms, interval). Up
54
+ # to 50 ms the bound thus equals the interval, as before SM-1444, and a timed-out round
55
+ # leaves about no margin. It is at least 1 ms. Every SDK uses the same formula.
56
+ def self.round_bound(interval)
57
+ milliseconds = (interval * 1000).round
58
+ [1, milliseconds * 3 / 4, [50, milliseconds].min].max / 1000.0
59
+ end
60
+
61
+ # The delay before a busy retry, in seconds, for the shortest member +interval+ in seconds:
62
+ # base × (1 + +random+), with base = min(100 ms, interval / 8). The jitter keeps two workers
63
+ # from meeting the same lock holder in step. Every SDK uses the same formula.
64
+ def self.busy_retry_delay(interval, random = rand)
65
+ [BUSY_RETRY_BASE, interval / 8.0].min * (1 + random)
66
+ end
23
67
 
24
68
  @dedicated = {}.compare_by_identity
25
69
  @dedicated_lock = Mutex.new
@@ -36,6 +80,13 @@ module Stablemates
36
80
  @members = {}
37
81
  @wake = Concurrent::Event.new
38
82
  @thread = nil
83
+ # The worker that the next dedicated round sends first. Only the heartbeat thread uses it.
84
+ @rotation = 0
85
+ # The busy members of the latest round, which a retry renews before the next regular
86
+ # round, and the monotonic time when that retry falls due. A round of either kind
87
+ # replaces both. Only the heartbeat thread uses them.
88
+ @busy = nil
89
+ @busy_at = 0
39
90
  end
40
91
 
41
92
  # Adds +member+ and returns a callable that removes it. Removing twice is harmless. A member
@@ -64,15 +115,31 @@ module Stablemates
64
115
  private
65
116
 
66
117
  def run
67
- if @dedicated
68
- run_dedicated
69
- else
70
- rounds { |sql, params, _timeout| Executor.for(@source).rows(sql, params) }
118
+ # The start of the latest round, on the monotonic clock. A new connection keeps it, so a
119
+ # failed round does not start a new schedule.
120
+ @previous = Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
+ @busy = nil
122
+ @dedicated ? run_dedicated : run_shared
123
+ end
124
+
125
+ # Runs the rounds of a borrowing heartbeat. An unexpected error does not end the thread while
126
+ # a member remains: the thread ends only through +stop_if_idle+, so a register that races
127
+ # the end starts a new thread.
128
+ def run_shared
129
+ loop do
130
+ return if rounds { |sql, params, _timeout| Executor.for(@source).rows(sql, params) }
131
+ rescue
132
+ @busy = nil
133
+ return if stop_if_idle
134
+
135
+ @wake.wait(FAILED_ROUND_DELAY)
136
+ @wake.reset
71
137
  end
72
138
  end
73
139
 
74
140
  # Holds one connection across rounds. A round that fails or overruns discards the
75
- # connection, and the next round checks out a new one.
141
+ # connection, and the next round checks out a new one. A round that expires before its
142
+ # heartbeat statement keeps the connection (+bounded_rows+).
76
143
  def run_dedicated
77
144
  loop do
78
145
  finished = false
@@ -84,62 +151,181 @@ module Stablemates
84
151
  end
85
152
  rescue
86
153
  # The pool refused a connection. The lease watchdog covers rounds that keep failing.
87
- finished = idle?
154
+ finished = stop_if_idle
88
155
  end
89
156
  return if finished
90
157
  end
91
158
  end
92
159
 
93
160
  # Runs rounds until no member remains, and returns true then. A round falls due one shortest
94
- # member interval after the previous one; a wake recomputes that from the current members.
95
- # A failed round raises only from a dedicated connection, so the caller can replace it.
96
- def rounds
97
- previous = Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ # member interval after the previous regular round started; a wake recomputes that from the
162
+ # current members. A round that ends after its next slot starts one overdue round at once,
163
+ # and the interval after it counts from that start, so missed slots are never replayed. A
164
+ # failed round raises only from a dedicated connection, so the caller can replace it.
165
+ #
166
+ # Between two regular rounds, a busy retry (+take_busy_retry+) sends only the members that
167
+ # the latest round reported busy. It runs on the same thread, so it is a round for the
168
+ # one-round-in-flight rule, and it never moves the regular slot.
169
+ def rounds(&)
98
170
  loop do
99
- interval = @lock.synchronize { @members.each_value.map(&:interval).min }
100
- return true if interval.nil? && stop_if_idle
171
+ interval = shortest_interval_or_stop
172
+ if interval.nil?
173
+ @busy = nil
174
+ return true
175
+ end
101
176
 
102
- remaining = previous + (interval || 0) - Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
+ regular_at = @previous + interval
178
+ retrying = !@busy.nil? && @busy_at < regular_at
179
+ remaining = (retrying ? @busy_at : regular_at) - Process.clock_gettime(Process::CLOCK_MONOTONIC)
103
180
  if remaining.positive?
104
181
  @wake.wait(remaining)
105
182
  @wake.reset
106
183
  next
107
184
  end
108
- previous = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
- members = @lock.synchronize { @members.values }
110
- return true if members.empty? && stop_if_idle
111
-
112
- members.group_by(&:worker_id).each do |worker_id, group|
113
- sent_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
114
- leases = group.map do |member|
115
- {"taskId" => member.task.id, "fenceToken" => member.task.fence_token.to_s, "leaseMs" => member.lease_ms}
116
- end
117
- rows = begin
118
- yield SqlCatalogue::HEARTBEAT_MANY_V1, [worker_id, JSON.generate(leases)], interval
119
- rescue RoundTimeout, PG::Error
120
- raise if @dedicated
185
+ if retrying
186
+ selection = take_busy_retry(regular_at)
187
+ # With nothing left to retry, the next regular round waits for its slot.
188
+ next if selection.nil?
121
189
 
122
- next
123
- rescue
124
- # A skipped round is silent: the lease watchdog handles rounds that keep failing.
125
- next
126
- end
127
- settle(group, rows.to_h { |row| [row["task_id"], row["status"]] }, sent_at)
190
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ deadline = retry_deadline(selection, interval, regular_at, started)
192
+ # A bounded retry with too little time left would fail before its heartbeat statement.
193
+ # It sends nothing, and the next regular round renews every lease, the busy ones too.
194
+ next if @dedicated && deadline - started < BUSY_RETRY_MINIMUM
195
+
196
+ send_round(selection, deadline, interval, rotate: false, &)
197
+ next
128
198
  end
199
+ @previous = Process.clock_gettime(Process::CLOCK_MONOTONIC)
200
+ # The deadline starts before the payloads are built, so serialization counts against it.
201
+ deadline = @previous + round_bound(interval)
202
+ members = members_or_stop
203
+ return true if members.nil?
204
+
205
+ send_round(members, deadline, interval, rotate: true, &)
206
+ end
207
+ end
208
+
209
+ # Sends one round for +members+. A round sends one statement for each worker, and more for a
210
+ # worker with more than +STATEMENT_LEASE_LIMIT+ members (+statements+). On the
211
+ # dedicated connection, all of them share one +deadline+. A statement that would start after
212
+ # it waits for the next round, so a slow statement of one worker cannot delay the renewal of
213
+ # another past it. Each regular round starts with the next worker (+ordered_groups+), so a
214
+ # worker whose statement is always slow cannot keep another from its turn. A borrowing
215
+ # heartbeat has no deadline, and sends every statement.
216
+ #
217
+ # A round replaces the pending busy retry. If one or more members are busy, it schedules one
218
+ # retry for them. A round that raises schedules none, and the next regular round tries again.
219
+ #
220
+ # A statement that expires before its heartbeat (+RoundExpired+) stops the round on its
221
+ # connection. The statements before it still count, and the next round sends the rest. A
222
+ # busy retry that stops this way schedules no other retry.
223
+ def send_round(members, deadline, interval, rotate:)
224
+ @busy = nil
225
+ busy = []
226
+ expired = false
227
+ statements(members, rotate:).each do |worker_id, group|
228
+ sent_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
229
+ leases = group.map do |member|
230
+ {"taskId" => member.task.id, "fenceToken" => member.task.fence_token.to_s, "leaseMs" => member.lease_ms}
231
+ end
232
+ params = [worker_id, JSON.generate(leases)]
233
+ timeout = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
234
+ break if @dedicated && !timeout.positive?
235
+
236
+ rows = begin
237
+ yield SqlCatalogue::HEARTBEAT_MANY_V2, params, timeout
238
+ rescue RoundExpired
239
+ expired = true
240
+ break
241
+ rescue RoundTimeout, PG::Error
242
+ raise if @dedicated
243
+
244
+ next
245
+ rescue
246
+ # A skipped round is silent: the lease watchdog handles rounds that keep failing.
247
+ next
248
+ end
249
+ busy.concat(settle(group, rows.to_h { |row| [row["task_id"], row["status"]] }, sent_at))
250
+ end
251
+ return if busy.empty? || (expired && !rotate)
252
+
253
+ @busy = busy
254
+ @busy_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Heartbeat.busy_retry_delay(interval)
255
+ end
256
+
257
+ # Takes the pending busy retry, and returns the members it can still renew, or nil. Each
258
+ # member must still be registered as the same object, so a member that completed, left, or
259
+ # was replaced is gone, and its local lease window must not have ended. At or after
260
+ # +regular_at+ the retry is dropped, and the regular round takes every member.
261
+ def take_busy_retry(regular_at)
262
+ busy = @busy
263
+ @busy = nil
264
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
265
+ return nil if busy.nil? || now >= regular_at
266
+
267
+ selection = busy.select do |member|
268
+ ends_at = member.lease_ends_at.call
269
+ ends_at && ends_at > now && registered?(member)
129
270
  end
271
+ selection.empty? ? nil : selection
130
272
  end
131
273
 
274
+ # A busy retry ends by its own round bound, by the next regular slot, and by the end of the
275
+ # earliest local lease window in it, so it never renews a lease that the worker has already
276
+ # given up. A borrowing heartbeat ignores the deadline, as in its regular rounds. +started+ is
277
+ # the monotonic start of the retry.
278
+ def retry_deadline(selection, interval, regular_at, started)
279
+ ends = selection.map { |member| member.lease_ends_at.call || started }
280
+ [started + round_bound(interval), regular_at, *ends].min
281
+ end
282
+
283
+ # The statements of one round, as [worker_id, members] pairs: the +ordered_groups+, each
284
+ # split into slices of at most +STATEMENT_LEASE_LIMIT+ members. A fused completion claim
285
+ # registers its tasks before the completing attempts unregister, so a worker can hold more
286
+ # members than its concurrency.
287
+ def statements(members, rotate:)
288
+ ordered_groups(members, rotate:).flat_map do |worker_id, group|
289
+ group.each_slice(STATEMENT_LEASE_LIMIT).map { |slice| [worker_id, slice] }
290
+ end
291
+ end
292
+
293
+ # The members of one round, one group for each worker, as [worker_id, members] pairs. On the
294
+ # dedicated connection, each regular round starts one worker later than the round before
295
+ # it. A busy retry (+rotate+ false) keeps the order and does not move the rotation.
296
+ def ordered_groups(members, rotate: true)
297
+ groups = members.group_by(&:worker_id).to_a
298
+ return groups unless rotate && @dedicated && groups.size > 1
299
+
300
+ first = @rotation % groups.size
301
+ @rotation = first + 1
302
+ groups.rotate(first)
303
+ end
304
+
305
+ # The deadline of one round, in seconds, for the shortest member +interval+
306
+ # (Heartbeat.round_bound).
307
+ def round_bound(interval) = Heartbeat.round_bound(interval)
308
+
309
+ # Delivers each status to its member, and returns the busy members. A result counts only
310
+ # for the member object that the round sent, so a member that left or was replaced while
311
+ # the round ran gets nothing from it. A busy member got no renewal and no ownership verdict:
312
+ # its lease watchdog keeps its window, and the member stays registered.
132
313
  def settle(group, statuses, sent_at)
133
- group.each do |member|
314
+ group.filter_map do |member|
134
315
  next unless registered?(member)
135
316
 
136
317
  status = statuses.fetch(member.task.id, "stale")
137
318
  begin
138
319
  report(member, status)
139
- (status == "accepted") ? member.renew.call(sent_at) : member.settle.call(status)
320
+ case status
321
+ when "accepted" then member.renew.call(sent_at)
322
+ when "busy" then next member
323
+ else member.settle.call(status)
324
+ end
140
325
  rescue => e
141
326
  member.fail.call(e)
142
327
  end
328
+ nil
143
329
  end
144
330
  end
145
331
 
@@ -159,34 +345,108 @@ module Stablemates
159
345
  @lock.synchronize { @members[[member.worker_id, member.task.id]].equal?(member) }
160
346
  end
161
347
 
162
- def idle? = @lock.synchronize { @members.empty? }
163
-
164
- # Ends the thread when no member remains. A register that races this starts a new one.
348
+ # Ends the thread when no member remains, and returns true then. A register that races this
349
+ # starts a new one.
165
350
  def stop_if_idle
166
351
  @lock.synchronize do
167
- return false unless @members.empty?
352
+ @thread = nil if @members.empty?
353
+ @members.empty?
354
+ end
355
+ end
168
356
 
169
- @thread = nil
170
- true
357
+ # The shortest member interval, or nil when no member remains. Nil also ends the thread. The
358
+ # read and the end take one lock, so a register between them cannot find a thread that is
359
+ # about to end.
360
+ def shortest_interval_or_stop
361
+ @lock.synchronize do
362
+ @thread = nil if @members.empty?
363
+ @members.each_value.map(&:interval).min
171
364
  end
172
365
  end
173
366
 
174
- # Sends one round without blocking past +timeout+ seconds.
367
+ # The current members, or nil when none remains. Nil also ends the thread, under the same
368
+ # lock as the read (+shortest_interval_or_stop+).
369
+ def members_or_stop
370
+ @lock.synchronize do
371
+ @thread = nil if @members.empty?
372
+ @members.values unless @members.empty?
373
+ end
374
+ end
375
+
376
+ # Sends one round in its own transaction, without blocking past +timeout+ seconds.
377
+ #
378
+ # Closing a connection does not stop its statement: PostgreSQL notices a closed client only
379
+ # when it next writes to it. So the server must end a round that the client stopped waiting
380
+ # for. The timeouts (+round_timeouts+) end the heartbeat, and a transaction whose client
381
+ # stopped before COMMIT, by the deadline. A COMMIT that PostgreSQL has already started can
382
+ # still finish later. Both settings end with the transaction, so the connection keeps no
383
+ # session state.
384
+ #
385
+ # A round that expires before its heartbeat statement, while no statement is in flight,
386
+ # raises RoundExpired: with less than MINIMUM_REMAINING left before BEGIN it sends nothing,
387
+ # and after BEGIN it rolls back first (+expire+). Any other failure raises RoundTimeout or
388
+ # PG::Error, and the caller discards the connection, which rolls back.
175
389
  def bounded_rows(connection, sql, params, timeout)
176
- connection.send_query_params(sql, params, 0, STRINGS)
177
- raise RoundTimeout unless connection.block(timeout)
390
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
391
+ raise RoundExpired if timeout < MINIMUM_REMAINING
392
+
393
+ bounded_result(connection, "BEGIN", [], deadline).clear
394
+ set_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
395
+ remaining = deadline - set_at
396
+ expire(connection, deadline) if remaining < MINIMUM_REMAINING
178
397
 
179
- result = connection.get_last_result
180
- begin
398
+ bounded_result(connection, ROUND_TIMEOUTS, round_timeouts(remaining), deadline).clear
399
+ # The budgets fit only if the settings statement used at most the quarter kept for COMMIT.
400
+ expire(connection, deadline) if Process.clock_gettime(Process::CLOCK_MONOTONIC) - set_at > remaining / 4
401
+
402
+ result = bounded_result(connection, sql, params, deadline)
403
+ rows = begin
181
404
  result.type_map = STRINGS
182
405
  result.to_a
183
406
  ensure
184
407
  result.clear
185
408
  end
409
+ bounded_result(connection, "COMMIT", [], deadline).clear
410
+ # A COMMIT that ends after the deadline does not count as a renewal.
411
+ raise RoundTimeout unless deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
412
+
413
+ rows
414
+ end
415
+
416
+ # Rolls back a round that expired before its heartbeat statement, and raises RoundExpired. The
417
+ # ROLLBACK has the round's deadline, so it is not sent after it. If the ROLLBACK cannot be
418
+ # sent, fails, or does not end by the deadline, RoundTimeout or PG::Error goes to the
419
+ # caller, which discards the connection.
420
+ def expire(connection, deadline)
421
+ bounded_result(connection, "ROLLBACK", [], deadline).clear
422
+ raise RoundExpired
423
+ end
424
+
425
+ # The server budgets for the rest of a round with +remaining+ seconds left, in whole
426
+ # milliseconds. PostgreSQL measures +statement_timeout+ for each statement and
427
+ # +idle_in_transaction_session_timeout+ for each idle gap, and restarts both every time.
428
+ # While the round can hold a row lock, it has one idle gap before the heartbeat, the
429
+ # heartbeat, and one idle gap before COMMIT. So the heartbeat gets one half, each gap one
430
+ # eighth, and the last quarter is kept for COMMIT. No setting bounds COMMIT itself.
431
+ def round_timeouts(remaining)
432
+ milliseconds = (remaining * 1000).floor
433
+ [[1, milliseconds / 2].max.to_s, [1, milliseconds / 8].max.to_s]
434
+ end
435
+
436
+ # Sends one statement and waits for its result until +deadline+, a monotonic time. Nothing
437
+ # is sent after the deadline.
438
+ def bounded_result(connection, sql, params, deadline)
439
+ raise RoundTimeout unless deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
440
+
441
+ connection.send_query_params(sql, params, 0, STRINGS)
442
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
443
+ raise RoundTimeout unless remaining.positive? && connection.block(remaining)
444
+
445
+ connection.get_last_result
186
446
  end
187
447
 
188
- # A connection that overran a round still runs its statement, so it cannot go back to the
189
- # pool. A pool without +discard_current_connection+ gets the connection back closed, and
448
+ # A connection that overran a round can still hold its transaction until the server
449
+ # timeouts end it, so it cannot go back to the pool. A pool without +discard_current_connection+ gets the connection back closed, and
190
450
  # its owner reconnects it before reuse.
191
451
  def discard(connection)
192
452
  if @source.respond_to?(:discard_current_connection)
@@ -9,11 +9,15 @@ module Stablemates
9
9
  # tasks run at once. Every write an attempt makes carries its fence token, so an attempt that
10
10
  # lost its lease cannot change the task.
11
11
  #
12
- # Durations are Numeric seconds. +run+ blocks until +stop+; +run_once+ claims and runs one
13
- # batch. A stopping worker claims nothing new, waits up to +shutdown_grace+ for running
14
- # handlers, cancels the rest with +:shutdown+, and raises ShutdownIncompleteError when any
15
- # handler still runs after one short unwind window. A handler that raises the CancelledError of
16
- # that cancellation, as +check!+ does, returns its task to the queue without charging the attempt.
12
+ # Durations are Numeric seconds. +heartbeat+ defaults to a third of +lease+, at least 0.1. The
13
+ # smallest supported +heartbeat+ is 0.1. The worker accepts a shorter value, but on a loaded
14
+ # host it can renew no lease.
15
+ #
16
+ # +run+ blocks until +stop+; +run_once+ claims and runs one batch. A stopping worker claims
17
+ # nothing new, waits up to +shutdown_grace+ for running handlers, cancels the rest with
18
+ # +:shutdown+, and raises ShutdownIncompleteError when any handler still runs after one short
19
+ # unwind window. A handler that raises the CancelledError of that cancellation, as +check!+
20
+ # does, returns its task to the queue without charging the attempt.
17
21
  class Worker
18
22
  MAX_EMPTY_POLL_MS = 5_000
19
23
  NOTIFICATION_POLL_MS = 5_000
@@ -72,10 +76,26 @@ module Stablemates
72
76
  @watchdog = nil
73
77
  end
74
78
 
75
- def renew(sent_at) = @renewal.synchronize { @renewed_at = [@renewed_at, sent_at].max }
79
+ # Moves the local lease window to start at +sent_at+, and returns true. If the window has
80
+ # already ended, it stays closed and this returns false: a late accepted heartbeat cannot
81
+ # reopen it. The check and the move hold the lock that +lease_remaining+ holds, so they
82
+ # cannot race the watchdog.
83
+ def renew(sent_at)
84
+ @renewal.synchronize do
85
+ return false unless Process.clock_gettime(Process::CLOCK_MONOTONIC) < @renewed_at + @lease_seconds
86
+
87
+ @renewed_at = [@renewed_at, sent_at].max
88
+ true
89
+ end
90
+ end
76
91
 
77
92
  def lease_deadline = @renewal.synchronize { @renewed_at + @lease_seconds }
78
93
 
94
+ # The seconds until the local lease window ends, zero or less once it has ended.
95
+ def lease_remaining
96
+ @renewal.synchronize { @renewed_at + @lease_seconds - Process.clock_gettime(Process::CLOCK_MONOTONIC) }
97
+ end
98
+
79
99
  def start(unregister, watchdog)
80
100
  @unregister = unregister
81
101
  @watchdog = watchdog
@@ -1324,7 +1344,9 @@ module Stablemates
1324
1344
  end
1325
1345
  member = Heartbeat::Member.new(
1326
1346
  worker_id: @worker_id, task: task, lease_ms: @lease_ms, interval: @heartbeat_ms / 1000.0,
1327
- renew: ->(sent_at) { ownership.renew(sent_at) },
1347
+ # An accepted heartbeat that arrives after the window ended ends the attempt, as the
1348
+ # watchdog does.
1349
+ renew: ->(sent_at) { ownership.renew(sent_at) || lose_lease(ownership, arbiter, cancellation) },
1328
1350
  settle: lambda do |status|
1329
1351
  ownership.settlement.synchronize do
1330
1352
  if %w[deadline_exceeded timeout_exceeded].include?(status)
@@ -1335,7 +1357,9 @@ module Stablemates
1335
1357
  end
1336
1358
  end,
1337
1359
  fail: fail_attempt,
1338
- logger: @logger
1360
+ logger: @logger,
1361
+ # The local lease window closes when the watchdog stops the attempt.
1362
+ lease_ends_at: -> { ownership.lease_deadline unless ownership.stop.set? }
1339
1363
  )
1340
1364
  unregister = @heartbeat.register(member)
1341
1365
  watchdog = Thread.new { watch_expiration(task, ownership, arbiter, cancellation, parent, fail_attempt) }
@@ -1354,16 +1378,14 @@ module Stablemates
1354
1378
  elsif expiration_at
1355
1379
  expiration_at - Time.now
1356
1380
  end
1357
- lease_delay = ownership.lease_deadline - monotonic
1381
+ lease_delay = ownership.lease_remaining
1358
1382
  if delay.nil? || lease_delay < delay
1359
1383
  if lease_delay.positive?
1360
1384
  return if ownership.stop.wait(lease_delay)
1361
1385
 
1362
1386
  next
1363
1387
  end
1364
- arbiter.submit(:lease_expired)
1365
- ownership.abandon
1366
- cancellation.cancel(:lease_lost)
1388
+ lose_lease(ownership, arbiter, cancellation)
1367
1389
  return
1368
1390
  end
1369
1391
  return if ownership.stop.wait([0, delay].max)
@@ -1377,6 +1399,14 @@ module Stablemates
1377
1399
  fail_attempt.call(e)
1378
1400
  end
1379
1401
 
1402
+ # Ends an attempt whose local lease window ended: the lease counts as lost, the heartbeat stops,
1403
+ # and the handler is cancelled. The watchdog and a late accepted heartbeat both end it here.
1404
+ def lose_lease(ownership, arbiter, cancellation)
1405
+ arbiter.submit(:lease_expired)
1406
+ ownership.abandon
1407
+ cancellation.cancel(:lease_lost)
1408
+ end
1409
+
1380
1410
  def settle_expiration(task, arbiter, cancellation, parent)
1381
1411
  row = fenced_row(SqlCatalogue::EXPIRE_OWNED_TELEMETRY_V1, task)
1382
1412
  status = row["status"]
@@ -1477,7 +1507,7 @@ module Stablemates
1477
1507
  def settle_failure(task, error, arbiter)
1478
1508
  envelope = error_envelope(error, task.redact_error_details)
1479
1509
  state = Telemetry.span("workhorse.retry", Telemetry.task_span_attributes(task)) do |span|
1480
- delay = error.is_a?(ConflictError) ? -1 : retry_delay_override(task)
1510
+ delay = durable_replay_conflict(error) ? -1 : retry_delay_override(task)
1481
1511
  state = fenced_row(SqlCatalogue::FAIL_V1, task, JSON.generate(envelope), delay&.to_s)["state"]
1482
1512
  Telemetry.set_attribute(span, "workhorse.retry.outcome", state.to_s)
1483
1513
  state
@@ -1509,6 +1539,20 @@ module Stablemates
1509
1539
  false
1510
1540
  end
1511
1541
 
1542
+ # The durable replay conflict (ADR 0098) that +error+ is or wraps, or nil. A handler can wrap a
1543
+ # conflict in its own error. The conflict is terminal also then, as in Go and Rust, and the
1544
+ # failure envelope records the name of the conflict. The walk stays bounded against a cyclic
1545
+ # cause chain.
1546
+ def durable_replay_conflict(error)
1547
+ 16.times do
1548
+ return nil if error.nil?
1549
+ return error if error.is_a?(ConflictError)
1550
+
1551
+ error = error.cause
1552
+ end
1553
+ nil
1554
+ end
1555
+
1512
1556
  def retry_delay_override(task)
1513
1557
  delay = @retry_delay.respond_to?(:call) ? @retry_delay.call(task.attempt, task) : @retry_delay
1514
1558
  return nil if delay.nil?
@@ -1519,7 +1563,8 @@ module Stablemates
1519
1563
  def error_envelope(error, redact)
1520
1564
  return {"name" => REDACTED_NAME, "message" => REDACTED_MESSAGE} if redact
1521
1565
 
1522
- {"name" => error.class.name, "message" => error.message.to_s,
1566
+ # A wrapped durable replay conflict keeps its name. The message and stack stay the outer ones.
1567
+ {"name" => (durable_replay_conflict(error) || error).class.name, "message" => error.message.to_s,
1523
1568
  "stack" => error.full_message(highlight: false, order: :top)}
1524
1569
  end
1525
1570
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stablemates-workhorse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stablemates