Skip to content

Commit 6645bf0

Browse files
userFRMclaude
andauthored
fix(fpss): evict in-flight subscribe correlation on unsubscribe so a superseded reject cannot drop a live sub (#883)
A per-contract or full-stream subscribe records a pending correlation keyed by its req_id so a rejecting REQ_RESPONSE can untrack exactly that subscription. The correlation is only an authority to untrack while the tracked entry it created is still live. An unsubscribe removed the tracked entry but left the correlation resident, so a subscribe -> unsubscribe -> re-subscribe of the same identity, all before the first subscribe's REQ_RESPONSE landed, left the first request's correlation pointing at a slot now occupied by a new live entry. A late rejection of that obsolete req_id then untracked the re-subscribed entry by value and dropped it from the reconnect-replay set. Evict the in-flight correlation for an identity at the unsubscribe boundary, alongside removing the tracked entry. This restores the invariant that at most one correlation per (kind, contract) or (kind, sec_type) identity is resident and it always names the current live entry, so apply_req_response can untrack purely by req_id lookup and a rejection of a superseded request is a no-op rather than a value match against a newer entry. Co-authored-by: preview <noreply@anthropic.com>
1 parent cbb94a1 commit 6645bf0

2 files changed

Lines changed: 206 additions & 9 deletions

File tree

crates/thetadatadx/src/fpss/io_loop/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,28 @@ pub(in crate::fpss) fn evict_stale_pending(map: &mut HashMap<i32, PendingSubEntr
140140
);
141141
}
142142
}
143+
144+
/// Drop the in-flight subscribe correlation(s) for one tracked identity.
145+
///
146+
/// A pending correlation is only an authority to untrack while the tracked
147+
/// entry it created is still live. Once that entry leaves the tracked set — an
148+
/// unsubscribe removes it — the correlation points at nothing, so a later
149+
/// rejection of its (now superseded) `req_id` must not act on the set: the
150+
/// `(kind, contract)` slot may since have been re-subscribed into a new live
151+
/// entry that a value match would wrongly drop. Removing the correlation at the
152+
/// unsubscribe boundary keeps the invariant that at most one resident
153+
/// correlation per identity exists and it always names the current live entry,
154+
/// so an `apply_req_response` rejection can untrack purely by `req_id` lookup.
155+
///
156+
/// The map is keyed by `req_id`, so identity removal is a single retain pass.
157+
/// The caller holds the `pending_subs` lock; this touches only in-memory map
158+
/// state and performs no I/O.
159+
pub(in crate::fpss) fn evict_pending_for_identity(
160+
map: &mut HashMap<i32, PendingSubEntry>,
161+
identity: &super::protocol::PendingSub,
162+
) {
163+
map.retain(|_, entry| &entry.sub != identity);
164+
}
143165
type ActiveFullSubs = Arc<
144166
Mutex<
145167
Vec<(
@@ -280,6 +302,16 @@ fn host_index(hosts: &[(String, u16)], addr: &str) -> Option<usize> {
280302
/// id from a span longer than the 31-bit counter cycle) leaves the tracked set
281303
/// untouched: with no correlation, untracking would risk dropping a healthy
282304
/// subscription, so the conservative choice is to keep it.
305+
///
306+
/// The `req_id` lookup is a safe untrack because the pending registry holds the
307+
/// invariant that at most one correlation per `(kind, contract)` (or `(kind,
308+
/// sec_type)`) identity is resident, and it always names the current live
309+
/// entry. A duplicate subscribe shares the live entry and registers no second
310+
/// correlation; an unsubscribe removes the entry and evicts its correlation
311+
/// (see [`evict_pending_for_identity`]). So a subscribe that is superseded by an
312+
/// unsubscribe + re-subscribe of the same identity has no resident correlation
313+
/// for its old `req_id`, and a late rejection of that id is a no-op rather than
314+
/// a value match that would drop the re-subscribed live entry.
283315
fn apply_req_response(
284316
req_id: i32,
285317
result: StreamResponseType,

crates/thetadatadx/src/fpss/mod.rs

Lines changed: 174 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,15 +2236,32 @@ impl StreamingClient {
22362236
unsubscribe,
22372237
"sent full-stream subscription frame"
22382238
);
2239-
// Track / untrack for reconnection.
2239+
// Untrack-on-unsubscribe is terminal: remove the tracked entry, then
2240+
// evict the in-flight correlation for this identity for the same reason
2241+
// as the per-contract unsubscribe — the removed entry is no longer live,
2242+
// so a late rejection of its subscribe must not survive to untrack a
2243+
// future re-subscribe of the same `(kind, sec_type)` by value.
2244+
if unsubscribe {
2245+
self.active_full_subs
2246+
.lock()
2247+
.unwrap_or_else(std::sync::PoisonError::into_inner)
2248+
.retain(|(k, s)| !(*k == kind_for_track && *s == sec_type));
2249+
io_loop::evict_pending_for_identity(
2250+
&mut self
2251+
.pending_subs
2252+
.lock()
2253+
.unwrap_or_else(std::sync::PoisonError::into_inner),
2254+
&protocol::PendingSub::Full(kind_for_track, sec_type),
2255+
);
2256+
return Ok(());
2257+
}
2258+
2259+
// Track for reconnection.
22402260
let mut subs = self
22412261
.active_full_subs
22422262
.lock()
22432263
.unwrap_or_else(std::sync::PoisonError::into_inner);
2244-
let newly_tracked = if unsubscribe {
2245-
subs.retain(|(k, s)| !(*k == kind_for_track && *s == sec_type));
2246-
false
2247-
} else if subs
2264+
let newly_tracked = if subs
22482265
.iter()
22492266
.any(|(k, s)| *k == kind_for_track && *s == sec_type)
22502267
{
@@ -2261,10 +2278,9 @@ impl StreamingClient {
22612278
// Record the pending full-stream subscribe by `req_id` so a server
22622279
// rejection drops exactly this entry from the replay set. Only the
22632280
// subscribe that actually added the tracked entry may carry an
2264-
// untrack-capable correlation: an unsubscribe removed its entry above
2265-
// and has nothing to untrack, and a duplicate subscribe shares the one
2266-
// live entry, so letting its rejection untrack by value would drop the
2267-
// live subscription.
2281+
// untrack-capable correlation: an unsubscribe is handled terminally
2282+
// above and a duplicate subscribe shares the one live entry, so letting
2283+
// its rejection untrack by value would drop the live subscription.
22682284
if newly_tracked {
22692285
let mut pending = self
22702286
.pending_subs
@@ -2372,6 +2388,21 @@ impl StreamingClient {
23722388
subs.retain(|(k, c)| !(k == &kind && c == contract));
23732389
}
23742390

2391+
// Evict any in-flight correlation for this identity. The removed entry
2392+
// is no longer live, so a late rejection of the subscribe that created
2393+
// it must not survive to untrack a future re-subscribe of the same
2394+
// `(kind, contract)` by value.
2395+
{
2396+
let mut pending = self
2397+
.pending_subs
2398+
.lock()
2399+
.unwrap_or_else(std::sync::PoisonError::into_inner);
2400+
io_loop::evict_pending_for_identity(
2401+
&mut pending,
2402+
&protocol::PendingSub::Contract(kind, contract.clone()),
2403+
);
2404+
}
2405+
23752406
tracing::debug!(
23762407
req_id,
23772408
kind = ?kind,
@@ -3425,6 +3456,140 @@ mod full_stream_guard_tests {
34253456
client.shutdown();
34263457
}
34273458

3459+
/// Subscribe, unsubscribe, then re-subscribe the same contract, all before
3460+
/// the first subscribe's `REQ_RESPONSE` lands; a late rejection of that
3461+
/// superseded first request must not drop the re-subscribed live entry.
3462+
///
3463+
/// The first subscribe owns the correlation for its `req_id`; the
3464+
/// unsubscribe both removes the tracked entry and evicts that correlation,
3465+
/// so when the re-subscribe re-adds the entry it owns a fresh correlation
3466+
/// under a new `req_id`. A server rejection of the obsolete first `req_id`
3467+
/// then finds no resident correlation and is a no-op, leaving the live
3468+
/// re-subscribed entry tracked and in the reconnect-replay set (which is a
3469+
/// clone of `active_subs`).
3470+
#[test]
3471+
fn unsub_resub_then_reject_superseded_keeps_live_sub() {
3472+
use super::io_loop::apply_req_response_for_test;
3473+
use crate::tdbe::types::enums::StreamResponseType;
3474+
3475+
let client = StreamingClient::for_self_join_test(
3476+
0,
3477+
64,
3478+
HarnessPublishMode::BlockingPublish,
3479+
None,
3480+
|_event| {},
3481+
);
3482+
3483+
// req_id counter starts at 1: subscribe -> 1, unsubscribe -> 2,
3484+
// re-subscribe -> 3.
3485+
let contract = Contract::stock("AAPL");
3486+
client
3487+
.subscribe(contract.clone().trade())
3488+
.expect("first subscribe");
3489+
client
3490+
.unsubscribe(contract.clone().trade())
3491+
.expect("unsubscribe");
3492+
client
3493+
.subscribe(contract.clone().trade())
3494+
.expect("re-subscribe");
3495+
3496+
// Exactly one resident correlation: the unsubscribe evicted the first
3497+
// subscribe's (req_id 1) correlation, and the re-subscribe registered a
3498+
// fresh one (req_id 3).
3499+
assert_eq!(
3500+
client
3501+
.pending_subs
3502+
.lock()
3503+
.unwrap_or_else(std::sync::PoisonError::into_inner)
3504+
.len(),
3505+
1,
3506+
"unsubscribe must evict the superseded correlation; only the \
3507+
re-subscribe's correlation may remain"
3508+
);
3509+
3510+
// The server rejects the obsolete first req_id (1). With its
3511+
// correlation already evicted this is a no-op, not a value match that
3512+
// would drop the live re-subscribed entry.
3513+
apply_req_response_for_test(
3514+
&client.pending_subs,
3515+
&client.active_subs,
3516+
&client.active_full_subs,
3517+
1,
3518+
StreamResponseType::MaxStreamsReached,
3519+
);
3520+
3521+
let tracked = client.active_subscriptions();
3522+
assert_eq!(
3523+
tracked.len(),
3524+
1,
3525+
"rejecting a superseded subscribe must leave the re-subscribed entry \
3526+
tracked, got {tracked:?}"
3527+
);
3528+
assert!(
3529+
tracked.iter().any(|(_, c)| *c == contract),
3530+
"the re-subscribed contract must remain in active_subscriptions() so \
3531+
it is replayed on reconnect, got {tracked:?}"
3532+
);
3533+
3534+
client.shutdown();
3535+
}
3536+
3537+
/// The full-stream analogue: subscribe, unsubscribe, re-subscribe a
3538+
/// full-stream `(kind, sec_type)`, then reject the superseded first
3539+
/// request — the re-subscribed full-stream entry must stay tracked.
3540+
#[test]
3541+
fn full_unsub_resub_then_reject_superseded_keeps_live_sub() {
3542+
use super::io_loop::apply_req_response_for_test;
3543+
use crate::tdbe::types::enums::StreamResponseType;
3544+
3545+
let client = StreamingClient::for_self_join_test(
3546+
0,
3547+
64,
3548+
HarnessPublishMode::BlockingPublish,
3549+
None,
3550+
|_event| {},
3551+
);
3552+
3553+
// subscribe -> 1, unsubscribe -> 2, re-subscribe -> 3.
3554+
client
3555+
.subscribe(SecType::Stock.full_trades())
3556+
.expect("first full subscribe");
3557+
client
3558+
.unsubscribe(SecType::Stock.full_trades())
3559+
.expect("full unsubscribe");
3560+
client
3561+
.subscribe(SecType::Stock.full_trades())
3562+
.expect("re-subscribe full");
3563+
3564+
assert_eq!(
3565+
client
3566+
.pending_subs
3567+
.lock()
3568+
.unwrap_or_else(std::sync::PoisonError::into_inner)
3569+
.len(),
3570+
1,
3571+
"full-stream unsubscribe must evict the superseded correlation"
3572+
);
3573+
3574+
apply_req_response_for_test(
3575+
&client.pending_subs,
3576+
&client.active_subs,
3577+
&client.active_full_subs,
3578+
1,
3579+
StreamResponseType::MaxStreamsReached,
3580+
);
3581+
3582+
let tracked = client.active_full_subscriptions();
3583+
assert_eq!(
3584+
tracked.len(),
3585+
1,
3586+
"rejecting a superseded full-stream subscribe must leave the \
3587+
re-subscribed entry tracked, got {tracked:?}"
3588+
);
3589+
3590+
client.shutdown();
3591+
}
3592+
34283593
/// The command channel is bounded: once it is saturated, a further
34293594
/// `try_send` reports `Full` rather than growing without limit. This
34303595
/// pins the backpressure contract `send_cmd` relies on to surface a

0 commit comments

Comments
 (0)