Skip to content

Commit fe5797a

Browse files
committed
fix(server): drop long-terminal swarm members from SwarmStatus broadcasts
A swarm accumulates hundreds of terminal (stopped/completed/failed) members over a day (24h listing retention). Every status broadcast re-serialized all of them and fanned the full list out to every attached client, dominating event payloads (~240 KB of member JSON resident per client with ~700 mostly-stopped members) and feeding idle-client heap churn. Keep terminal members in broadcasts for 15 minutes after their final transition (so done/failed notices still fire), then exclude them from the live fan-out. They stay queryable via swarm listings for the full 24h retention. Tunable via JCODE_SWARM_STATUS_BROADCAST_TERMINAL_SECS.
1 parent 0b5b884 commit fe5797a

1 file changed

Lines changed: 53 additions & 4 deletions

File tree

  • crates/jcode-app-core/src/server

crates/jcode-app-core/src/server/swarm.rs

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ const DEFAULT_SWARM_TASK_STALE_AFTER_SECS: u64 = 45;
9191
const DEFAULT_SWARM_TASK_SWEEP_INTERVAL_SECS: u64 = 5;
9292
const DEFAULT_SWARM_TERMINAL_MEMBER_RETENTION_SECS: u64 = 24 * 60 * 60;
9393
const DEFAULT_SWARM_TERMINAL_MEMBER_GC_INTERVAL_SECS: u64 = 60;
94+
/// How long terminal members stay in live SwarmStatus broadcasts. Terminal
95+
/// members remain queryable for the full retention window above, but
96+
/// re-sending hundreds of long-finished members to every attached client on
97+
/// every status change dominates broadcast payloads (measured ~240 KB of
98+
/// member JSON resident per client with ~700 mostly-stopped members). Keep
99+
/// them in broadcasts briefly so done/failed transition notices still fire,
100+
/// then drop them from the live fan-out.
101+
const DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS: u64 = 15 * 60;
94102
#[derive(Default, Clone, Copy)]
95103
struct PendingSwarmStatusBroadcast {
96104
scheduled: bool,
@@ -197,6 +205,22 @@ pub(super) fn swarm_terminal_member_gc_interval() -> Duration {
197205
))
198206
}
199207

208+
/// How long terminal members remain included in live SwarmStatus broadcasts.
209+
/// See [`DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS`].
210+
pub(super) fn swarm_status_broadcast_terminal_retention() -> Duration {
211+
Duration::from_secs(configured_positive_u64(
212+
"JCODE_SWARM_STATUS_BROADCAST_TERMINAL_SECS",
213+
DEFAULT_SWARM_STATUS_BROADCAST_TERMINAL_SECS,
214+
))
215+
}
216+
217+
/// Whether a member belongs in live SwarmStatus broadcasts: every live member,
218+
/// plus terminal members whose status changed recently enough that clients may
219+
/// still want to announce or display the transition.
220+
pub(super) fn member_in_status_broadcast(member: &SwarmMember, retention: Duration) -> bool {
221+
!member_status_is_terminal(&member.status) || member.last_status_change.elapsed() < retention
222+
}
223+
200224
/// Terminal members are historical records, not live agents. They remain
201225
/// visible temporarily for reports and diagnostics but must not consume the
202226
/// runaway-prevention spawn budget.
@@ -631,11 +655,13 @@ async fn broadcast_swarm_status_now(
631655
}
632656

633657
let members_guard = swarm_members.read().await;
658+
let broadcast_terminal_retention = swarm_status_broadcast_terminal_retention();
634659
let members_list: Vec<crate::protocol::SwarmMemberStatus> = session_ids
635660
.iter()
636661
.filter_map(|sid| {
637662
members_guard
638663
.get(sid)
664+
.filter(|m| member_in_status_broadcast(m, broadcast_terminal_retention))
639665
.map(|m| crate::protocol::SwarmMemberStatus {
640666
session_id: m.session_id.clone(),
641667
friendly_name: m.friendly_name.clone(),
@@ -1672,10 +1698,11 @@ fn parse_swarm_tasks(text: &str) -> Vec<SwarmTaskSpec> {
16721698
mod tests {
16731699
use super::{
16741700
broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status,
1675-
member_status_is_dead, now_unix_ms, parse_swarm_tasks, refresh_swarm_task_staleness,
1676-
remove_session_from_swarm, salvage_assignments_of_dead_member, swarm_ancestors,
1677-
swarm_is_self_or_ancestor, swarm_spawn_depth, touch_swarm_task_progress,
1678-
update_member_status, update_member_status_with_report,
1701+
member_in_status_broadcast, member_status_is_dead, now_unix_ms, parse_swarm_tasks,
1702+
refresh_swarm_task_staleness, remove_session_from_swarm,
1703+
salvage_assignments_of_dead_member, swarm_ancestors, swarm_is_self_or_ancestor,
1704+
swarm_spawn_depth, touch_swarm_task_progress, update_member_status,
1705+
update_member_status_with_report,
16791706
};
16801707
use crate::plan::PlanItem;
16811708
use crate::protocol::{NotificationType, ServerEvent};
@@ -1785,6 +1812,28 @@ mod tests {
17851812
member
17861813
}
17871814

1815+
#[test]
1816+
fn status_broadcast_keeps_live_and_recently_terminal_members_only() {
1817+
let retention = Duration::from_secs(900);
1818+
1819+
let (live, _rx) = swarm_member("live", "agent", false);
1820+
assert!(member_in_status_broadcast(&live, retention));
1821+
1822+
let (mut fresh_terminal, _rx) = swarm_member("fresh", "agent", false);
1823+
fresh_terminal.status = "completed".to_string();
1824+
assert!(member_in_status_broadcast(&fresh_terminal, retention));
1825+
1826+
let (mut stale_terminal, _rx) = swarm_member("stale", "agent", false);
1827+
stale_terminal.status = "stopped".to_string();
1828+
stale_terminal.last_status_change = Instant::now() - Duration::from_secs(901);
1829+
assert!(!member_in_status_broadcast(&stale_terminal, retention));
1830+
1831+
// A stale *live* status is never filtered, no matter how old.
1832+
let (mut old_live, _rx) = swarm_member("old-live", "agent", false);
1833+
old_live.last_status_change = Instant::now() - Duration::from_secs(100_000);
1834+
assert!(member_in_status_broadcast(&old_live, retention));
1835+
}
1836+
17881837
#[test]
17891838
fn swarm_depth_and_ancestry_follow_report_back_chain() {
17901839
let mut members: HashMap<String, SwarmMember> = HashMap::new();

0 commit comments

Comments
 (0)