1use crate::error::LaserError;
2use crate::laser::Laser;
3use crate::provenance::AgentTopic;
4use crate::types::ConversationId;
5use iggy::prelude::{Identifier, StreamClient};
6use laser_wire::agent::{AgentId, OPERATION_PROGRESS};
7use laser_wire::content::ContentType;
8use laser_wire::framing::encode_named;
9use laser_wire::limits::MAX_HEARTBEAT_SESSIONS;
10use laser_wire::session::SessionHeartbeat;
11use std::collections::{BTreeMap, HashMap};
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14use tracing::warn;
15
16pub struct SessionLease {
22 registry: Arc<LeaseRegistry>,
23 key: LeaseKey,
24}
25
26impl SessionLease {
27 pub fn session(&self) -> ConversationId {
29 self.key.session
30 }
31
32 pub fn stream(&self) -> &str {
34 &self.key.stream
35 }
36
37 pub fn release(self) {}
39}
40
41impl Drop for SessionLease {
42 fn drop(&mut self) {
43 self.registry.release(&self.key);
44 }
45}
46
47impl std::fmt::Debug for SessionLease {
48 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 formatter
50 .debug_struct("SessionLease")
51 .field("stream", &self.key.stream)
52 .field("session", &self.key.session)
53 .finish()
54 }
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Hash)]
60pub(crate) struct LeaseKey {
61 pub(crate) stream: String,
62 pub(crate) stream_generation: u64,
63 pub(crate) session: ConversationId,
64}
65
66struct LeaseEntry {
67 holders: usize,
68 agent: AgentId,
69 interval: Duration,
70}
71
72#[derive(Default)]
73struct RegistryState {
74 leases: HashMap<LeaseKey, LeaseEntry>,
75 running: bool,
76}
77
78pub(crate) struct LeaseRegistry {
80 process: String,
81 state: Mutex<RegistryState>,
82}
83
84impl Default for LeaseRegistry {
85 fn default() -> Self {
86 Self {
87 process: ulid::Ulid::generate().to_string(),
88 state: Mutex::new(RegistryState::default()),
89 }
90 }
91}
92
93impl LeaseRegistry {
94 pub(crate) fn acquire(
97 self: &Arc<Self>,
98 laser: &Laser,
99 key: LeaseKey,
100 agent: AgentId,
101 interval: Duration,
102 ) -> SessionLease {
103 let start = {
104 let mut state = self.state.lock().expect("lease registry lock");
105 state
106 .leases
107 .entry(key.clone())
108 .and_modify(|entry| {
109 entry.holders += 1;
110 entry.interval = entry.interval.min(interval);
111 })
112 .or_insert(LeaseEntry {
113 holders: 1,
114 agent,
115 interval,
116 });
117 let start = !state.running;
118 state.running = true;
119 start
120 };
121 if start {
122 let registry = Arc::clone(self);
123 let laser = laser.clone();
124 tokio::spawn(async move { registry.heartbeat_loop(laser).await });
125 }
126 SessionLease {
127 registry: Arc::clone(self),
128 key,
129 }
130 }
131
132 fn release(&self, key: &LeaseKey) {
133 let mut state = self.state.lock().expect("lease registry lock");
134 if let Some(entry) = state.leases.get_mut(key) {
135 entry.holders -= 1;
136 if entry.holders == 0 {
137 state.leases.remove(key);
138 }
139 }
140 }
141
142 async fn heartbeat_loop(self: Arc<Self>, laser: Laser) {
147 loop {
148 let Some(interval) = self.interval() else {
149 return;
150 };
151 tokio::time::sleep(interval).await;
152 for (stream, generation, agent, sessions) in self.snapshot() {
153 if let Err(error) = self
154 .beat(&laser, &stream, generation, agent, sessions)
155 .await
156 {
157 warn!(stream = %stream, %error, "session heartbeat failed, retrying next tick");
158 }
159 }
160 }
161 }
162
163 fn interval(&self) -> Option<Duration> {
164 let mut state = self.state.lock().expect("lease registry lock");
165 let interval = state.leases.values().map(|entry| entry.interval).min();
166 if interval.is_none() {
167 state.running = false;
168 }
169 interval
170 }
171
172 fn snapshot(&self) -> Vec<(String, u64, AgentId, Vec<ConversationId>)> {
175 let state = self.state.lock().expect("lease registry lock");
176 let mut by_stream: BTreeMap<(String, u64), (AgentId, Vec<ConversationId>)> =
177 BTreeMap::new();
178 for (key, entry) in &state.leases {
179 by_stream
180 .entry((key.stream.clone(), key.stream_generation))
181 .or_insert_with(|| (entry.agent.clone(), Vec::new()))
182 .1
183 .push(key.session);
184 }
185 by_stream
186 .into_iter()
187 .map(|((stream, generation), (agent, mut sessions))| {
188 sessions.sort_by_key(ToString::to_string);
189 sessions.dedup();
190 (stream, generation, agent, sessions)
191 })
192 .collect()
193 }
194
195 async fn beat(
196 &self,
197 laser: &Laser,
198 stream: &str,
199 generation: u64,
200 agent: AgentId,
201 sessions: Vec<ConversationId>,
202 ) -> Result<(), LaserError> {
203 let details = laser
204 .client()
205 .get_stream(&Identifier::named(stream)?)
206 .await?;
207 if details.is_none_or(|details| details.created_at.as_micros() != generation) {
208 return Ok(());
209 }
210 let laser = laser.with_default_stream(stream);
211 let conversation = ConversationId::derive(&format!("heartbeat/{}", self.process));
212 let producer = laser
213 .agdx(AgentTopic::Heartbeats, agent, conversation.into())
214 .with_stream_generation(generation);
215 for chunk in sessions.chunks(MAX_HEARTBEAT_SESSIONS) {
216 let body = encode_named(&SessionHeartbeat {
217 process: self.process.clone(),
218 stream: stream.to_owned(),
219 sessions: chunk.iter().copied().map(Into::into).collect(),
220 })?;
221 producer
222 .status(OPERATION_PROGRESS)
223 .body(body)
224 .content_type(ContentType::Cbor)
225 .send()
226 .await?;
227 }
228 Ok(())
229 }
230}