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 +4 -4
- data/CHANGELOG.md +120 -0
- data/README.md +8 -1
- data/lib/stablemates/workhorse/contracts.rb +14 -7
- data/lib/stablemates/workhorse/dashboard/bundle.json +1 -1
- data/lib/stablemates/workhorse/dashboard/read-surface-1.tar.gz +0 -0
- data/lib/stablemates/workhorse/sql_catalogue_generated.rb +7 -4
- data/lib/stablemates/workhorse/telemetry.rb +1 -1
- data/lib/stablemates/workhorse/types.rb +5 -3
- data/lib/stablemates/workhorse/version.rb +1 -1
- data/lib/stablemates/workhorse/worker/heartbeat.rb +311 -51
- data/lib/stablemates/workhorse/worker.rb +59 -14
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b030c71db8e2fa0f3b2e14e4ac34442270e1643ea1105efe904bc965ab6e4823
|
|
4
|
+
data.tar.gz: 90256bbd761dfa37265e9e0fce4f5f49534e378361a625bbc6bc27cf24b7634b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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 (
|
|
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
|
|
Binary file
|
|
@@ -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 =
|
|
14
|
-
MAXIMUM_SCHEMA_VERSION =
|
|
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
|
-
# `
|
|
136
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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+
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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
|
|
95
|
-
# A
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
100
|
-
|
|
171
|
+
interval = shortest_interval_or_stop
|
|
172
|
+
if interval.nil?
|
|
173
|
+
@busy = nil
|
|
174
|
+
return true
|
|
175
|
+
end
|
|
101
176
|
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
@thread = nil if @members.empty?
|
|
353
|
+
@members.empty?
|
|
354
|
+
end
|
|
355
|
+
end
|
|
168
356
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
177
|
-
raise
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
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. +
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|