Skip to main content

laser_sdk/agent/
lease.rs

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
16/// Ownership of one session's liveness. While any lease on a session is held,
17/// the process lists the session in its heartbeat on `agent.heartbeats`. Drop
18/// the lease, or call [`release`](Self::release), when the process stops
19/// working on the session. A terminal verb on the session does not release
20/// leases held elsewhere in the process.
21pub struct SessionLease {
22    registry: Arc<LeaseRegistry>,
23    key: LeaseKey,
24}
25
26impl SessionLease {
27    /// The leased session.
28    pub fn session(&self) -> ConversationId {
29        self.key.session
30    }
31
32    /// The stream the leased session lives in.
33    pub fn stream(&self) -> &str {
34        &self.key.stream
35    }
36
37    /// Release the lease now.
38    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// One lease identity: the stream by name and creation time, and the session.
58// A recreated stream is a different lease scope.
59#[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
78/// The per-connection lease map read by the heartbeat task every tick.
79pub(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    /// Take a lease on `key` for `agent`, starting the heartbeat task when it is
95    /// not already running.
96    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    // One heartbeat per stream per tick, at the shortest interval among the
143    // held leases. The task ends when the last lease is released, so a process
144    // that holds no lease publishes nothing. A failed publish is retried on the
145    // next tick and never fails a session.
146    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    // The held sessions grouped by stream, so one stream's ids never ride
173    // another stream's heartbeat.
174    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}