Skip to content

Commit a3d8501

Browse files
committed
Distinguish the relay no-receivers pump exit from a clean stream end via PumpOutcome; return Err so the retry loop backs off instead of re-subscribing every ~1s on dispatcher death
1 parent 3479f34 commit a3d8501

1 file changed

Lines changed: 63 additions & 5 deletions

File tree

src/relay_subscriber.rs

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,39 @@ fn spawn_subscription_shutdown(client: Arc<Client>, cancellation_token: Cancella
133133
});
134134
}
135135

136+
/// How a notification-pump attempt ended, so [`retry_until_cancelled`] can tell a
137+
/// clean stream end from a dispatcher-death with no receivers.
138+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139+
enum PumpOutcome {
140+
/// The relay closed the stream (or shutdown was requested) with receivers
141+
/// still attached — reconnect promptly.
142+
StreamEnded,
143+
/// The dispatcher fan-out has no receivers left. Re-subscribing immediately
144+
/// would hot-spin (the retry loop resets the backoff on a clean `Ok`), so this
145+
/// is surfaced as an error to trigger backoff instead.
146+
ReceiversGone,
147+
}
148+
149+
impl PumpOutcome {
150+
/// Map the outcome onto the retry loop's `Result` contract: a clean stream end
151+
/// is `Ok` (backoff resets, reconnect promptly); no receivers is `Err` (backoff
152+
/// grows, so a persistent dispatcher-death backs off rather than re-subscribing
153+
/// every second). Never cancels the shared token either way.
154+
fn into_result(self) -> Result<()> {
155+
match self {
156+
PumpOutcome::StreamEnded => Ok(()),
157+
PumpOutcome::ReceiversGone => {
158+
bail!("relay subscription dropped: no event receivers remain")
159+
}
160+
}
161+
}
162+
}
163+
136164
/// One subscription attempt: (re)subscribe and pump notifications until the relay
137165
/// stream ends or the shared token is cancelled. Returns `Err` on a subscribe
138-
/// failure so the retry loop can back off — but never cancels the shared token,
139-
/// so a relay problem cannot tear down the HTTP server or the worker pools.
166+
/// failure *or* when the dispatcher fan-out has no receivers left, so the retry
167+
/// loop backs off instead of re-subscribing tightly — but never cancels the shared
168+
/// token, so a relay problem cannot tear down the HTTP server or the worker pools.
140169
async fn run_subscription(
141170
client: &Client,
142171
filters: &[Filter],
@@ -156,6 +185,10 @@ async fn run_subscription(
156185
// retry loop drives it back down when this attempt ends.
157186
mark_subscription_up(relay_subscribed);
158187

188+
// Set when the pump leaves because the dispatcher fan-out has no receivers, so
189+
// we can back off instead of re-subscribing tightly (see [`PumpOutcome`]).
190+
let receivers_gone = AtomicBool::new(false);
191+
159192
client
160193
.handle_notifications(|notification| async {
161194
if cancellation_token.is_cancelled() {
@@ -165,9 +198,10 @@ async fn run_subscription(
165198
if let RelayPoolNotification::Event { event, .. } = notification {
166199
debug!("Received event: {}", event.id);
167200
if let Err(e) = event_tx.send(event) {
168-
// No receivers left; leave the notification loop so the retry
169-
// loop can re-establish later. Do NOT cancel the shared token.
201+
// No receivers left; leave the notification loop and flag it so
202+
// the retry loop backs off. Do NOT cancel the shared token.
170203
error!("Failed to send nostr event: {}", e);
204+
receivers_gone.store(true, Ordering::Relaxed);
171205
return Ok(true);
172206
}
173207
}
@@ -177,7 +211,14 @@ async fn run_subscription(
177211
})
178212
.await?;
179213

180-
Ok(())
214+
// Distinguish a no-receivers exit (back off) from a clean stream end (reconnect
215+
// promptly). A cancelled token also lands here as `StreamEnded`; the retry loop
216+
// then observes the cancellation and returns without another attempt.
217+
if receivers_gone.load(Ordering::Relaxed) {
218+
PumpOutcome::ReceiversGone.into_result()
219+
} else {
220+
PumpOutcome::StreamEnded.into_result()
221+
}
181222
}
182223

183224
/// Backoff bounds for re-establishing a dropped relay subscription.
@@ -400,4 +441,21 @@ mod tests {
400441
"a clean return must also flip the /health readiness flag to not-ready"
401442
);
402443
}
444+
445+
/// The retry loop resets the backoff to 1s on `Ok(())` and grows it on `Err`.
446+
/// So the pump outcome must map a genuine clean stream end to `Ok` (reconnect
447+
/// promptly) but a no-receivers exit to `Err` — otherwise a dispatcher-death
448+
/// hot-spins re-subscribing every ~1s instead of backing off. This locks that
449+
/// contract.
450+
#[test]
451+
fn pump_outcome_maps_receivers_gone_to_error_and_stream_end_to_ok() {
452+
assert!(
453+
PumpOutcome::StreamEnded.into_result().is_ok(),
454+
"a clean stream end must reconnect promptly (Ok resets the backoff)"
455+
);
456+
assert!(
457+
PumpOutcome::ReceiversGone.into_result().is_err(),
458+
"no receivers must back off (Err grows the backoff), not re-subscribe tightly"
459+
);
460+
}
403461
}

0 commit comments

Comments
 (0)