detached 9cfdc13

fix(broker): back off watched-host retries up to 30 seconds

commit
9cfdc13ed07685424aeedb8c8331491445bc76ff
author
brice · · 2026-09-23 19:48 UTC
parent
4eaa45d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +21 -9
M internal/usecase/broker/registry.go +16 -2
@@ -32,6 +32,10 @@ const (
3232 // while at least one client subscription is live (see Registry.SetDemand).
3333 defaultDemandFreshForLocal = 1 * time.Second
3434 defaultDemandFreshForRemote = 2 * time.Second
35+ // demandRetryMax caps the failure backoff while a client watches: retries
36+ // start at the demand cadence and double up to this bound, so an
37+ // unreachable or refusing host is not redialed every two seconds.
38+ demandRetryMax = 30 * time.Second
3539 // defaultProbeTimeout bounds one observation attempt end to end: dial,
3640 // SSH/QUIC bootstrap, daemonmux preamble, and the catalogue exchange. It
3741 // sits below the passive retry cap so a peer that never answers costs one
@@ -1103,8 +1107,8 @@ func (r *Registry) apply(result probeResult) {
11031107 current.ConsecutiveFailures++
11041108 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
11051109 delay := r.retryDelay(current.ConsecutiveFailures)
1106- if r.demand > 0 && delay > r.demandFreshForRemote {
1107- delay = r.demandFreshForRemote
1110+ if r.demand > 0 {
1111+ delay = demandRetryDelay(r.demandFreshForRemote, current.ConsecutiveFailures)
11081112 }
11091113 current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
11101114 r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
@@ -1159,6 +1163,16 @@ func availabilityFor(kind domain.RemoteFailureKind) domain.RemoteAvailability {
11591163 }
11601164 }
11611165
1166+// demandRetryDelay is the watched-host failure backoff: the demand cadence
1167+// doubled per consecutive failure, capped at demandRetryMax.
1168+func demandRetryDelay(base time.Duration, failures uint) time.Duration {
1169+ delay := base
1170+ for i := uint(1); i < failures && delay < demandRetryMax; i++ {
1171+ delay *= 2
1172+ }
1173+ return min(delay, demandRetryMax)
1174+}
1175+
11621176 // retryDelay is the capped exponential retry cadence for a failure streak.
11631177 func (r *Registry) retryDelay(failures uint) time.Duration {
11641178 delay := r.retryBase
@@ -32,6 +32,10 @@ const (
32 // while at least one client subscription is live (see Registry.SetDemand).32 // while at least one client subscription is live (see Registry.SetDemand).
33 defaultDemandFreshForLocal = 1 * time.Second33 defaultDemandFreshForLocal = 1 * time.Second
34 defaultDemandFreshForRemote = 2 * time.Second34 defaultDemandFreshForRemote = 2 * time.Second
35+ // demandRetryMax caps the failure backoff while a client watches: retries
36+ // start at the demand cadence and double up to this bound, so an
37+ // unreachable or refusing host is not redialed every two seconds.
38+ demandRetryMax = 30 * time.Second
35 // defaultProbeTimeout bounds one observation attempt end to end: dial,39 // defaultProbeTimeout bounds one observation attempt end to end: dial,
36 // SSH/QUIC bootstrap, daemonmux preamble, and the catalogue exchange. It40 // SSH/QUIC bootstrap, daemonmux preamble, and the catalogue exchange. It
37 // sits below the passive retry cap so a peer that never answers costs one41 // sits below the passive retry cap so a peer that never answers costs one
@@ -1103,8 +1107,8 @@ func (r *Registry) apply(result probeResult) {
1103 current.ConsecutiveFailures++1107 current.ConsecutiveFailures++
1104 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}1108 current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
1105 delay := r.retryDelay(current.ConsecutiveFailures)1109 delay := r.retryDelay(current.ConsecutiveFailures)
1106- if r.demand > 0 && delay > r.demandFreshForRemote {1110+ if r.demand > 0 {
1107- delay = r.demandFreshForRemote1111+ delay = demandRetryDelay(r.demandFreshForRemote, current.ConsecutiveFailures)
1108 }1112 }
1109 current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))1113 current.NextDue = result.at.Add(r.jitter(delay, result.endpoint, result.attempt))
1110 r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))1114 r.log.Debug("broker_probe_failed", "endpoint", result.endpoint, "kind", kind, "failures", current.ConsecutiveFailures, "err", result.err, "cause", errors.Unwrap(result.err))
@@ -1159,6 +1163,16 @@ func availabilityFor(kind domain.RemoteFailureKind) domain.RemoteAvailability {
1159 }1163 }
1160 }1164 }
1161 1165
1166+// demandRetryDelay is the watched-host failure backoff: the demand cadence
1167+// doubled per consecutive failure, capped at demandRetryMax.
1168+func demandRetryDelay(base time.Duration, failures uint) time.Duration {
1169+ delay := base
1170+ for i := uint(1); i < failures && delay < demandRetryMax; i++ {
1171+ delay *= 2
1172+ }
1173+ return min(delay, demandRetryMax)
1174+}
1175+
1162 // retryDelay is the capped exponential retry cadence for a failure streak.1176 // retryDelay is the capped exponential retry cadence for a failure streak.
1163 func (r *Registry) retryDelay(failures uint) time.Duration {1177 func (r *Registry) retryDelay(failures uint) time.Duration {
1164 delay := r.retryBase1178 delay := r.retryBase
M internal/usecase/broker/registry_test.go +5 -7
@@ -1661,7 +1661,7 @@ func TestRegistryDemandCapsFailureBackoff(t *testing.T) {
16611661 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
16621662 return !host.Checking && host.ConsecutiveFailures == failure
16631663 })
1664- require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), defaultDemandFreshForRemote, "demanded retry gap must never exceed the freshness interval")
1664+ require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), demandRetryMax, "demanded retry gap must never exceed the demand retry cap")
16651665 clock.Advance(host.NextDue.Sub(clock.Now()))
16661666 registry.RequestProbe(reg.Endpoint)
16671667 }
@@ -1683,13 +1683,11 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
16831683 startRegistry(t, registry)
16841684
16851685 probeErr := errors.New("offline")
1686- // A watched host retries on its two-second demand cadence even as the
1687- // underlying exponential failure count increases.
1686+ // A watched host backs off from its two-second demand cadence, doubling
1687+ // per failure up to the demand retry cap.
16881688 want := []time.Duration{
1689- defaultDemandFreshForRemote, defaultDemandFreshForRemote,
1690- defaultDemandFreshForRemote, defaultDemandFreshForRemote,
1691- defaultDemandFreshForRemote, defaultDemandFreshForRemote,
1692- defaultDemandFreshForRemote,
1689+ 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second,
1690+ demandRetryMax, demandRetryMax, demandRetryMax,
16931691 }
16941692 for index, delay := range want {
16951693 call := receiveCall(t, probe)
@@ -1661,7 +1661,7 @@ func TestRegistryDemandCapsFailureBackoff(t *testing.T) {
1661 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {1661 host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1662 return !host.Checking && host.ConsecutiveFailures == failure1662 return !host.Checking && host.ConsecutiveFailures == failure
1663 })1663 })
1664- require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), defaultDemandFreshForRemote, "demanded retry gap must never exceed the freshness interval")1664+ require.LessOrEqual(t, host.NextDue.Sub(clock.Now()), demandRetryMax, "demanded retry gap must never exceed the demand retry cap")
1665 clock.Advance(host.NextDue.Sub(clock.Now()))1665 clock.Advance(host.NextDue.Sub(clock.Now()))
1666 registry.RequestProbe(reg.Endpoint)1666 registry.RequestProbe(reg.Endpoint)
1667 }1667 }
@@ -1683,13 +1683,11 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
1683 startRegistry(t, registry)1683 startRegistry(t, registry)
1684 1684
1685 probeErr := errors.New("offline")1685 probeErr := errors.New("offline")
1686- // A watched host retries on its two-second demand cadence even as the1686+ // A watched host backs off from its two-second demand cadence, doubling
1687- // underlying exponential failure count increases.1687+ // per failure up to the demand retry cap.
1688 want := []time.Duration{1688 want := []time.Duration{
1689- defaultDemandFreshForRemote, defaultDemandFreshForRemote,1689+ 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second,
1690- defaultDemandFreshForRemote, defaultDemandFreshForRemote,1690+ demandRetryMax, demandRetryMax, demandRetryMax,
1691- defaultDemandFreshForRemote, defaultDemandFreshForRemote,
1692- defaultDemandFreshForRemote,
1693 }1691 }
1694 for index, delay := range want {1692 for index, delay := range want {
1695 call := receiveCall(t, probe)1693 call := receiveCall(t, probe)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close