Skip to main content

laser_sdk/agent/
control.rs

1use crate::types::ConversationId;
2use laser_wire::agent::{
3    AgentEnvelope, AgentId, AgentKind, RecordId, SessionPauseRequest, TaskState,
4};
5use laser_wire::dispatch::{
6    OPERATION_SESSION_CANCEL, OPERATION_SESSION_PAUSE, OPERATION_SESSION_RESUME,
7};
8use std::collections::HashMap;
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
11
12/// The operator requests a session has on `agent.control`, as this process
13/// observed them. A handler reads them through
14/// [`Session::pending_control`](crate::agent::Session::pending_control) and
15/// decides itself when to stop: the runtime never interrupts a handler.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct PendingControl {
18    /// A pause was requested and no later resume lifted it.
19    pub pause_requested: bool,
20    /// A cancel was requested, or an operator forced the session canceled.
21    pub cancel_requested: bool,
22    /// A control request for the session arrived on another partition of
23    /// `agent.control` than the one its conversation id routes to. Such a
24    /// request is never applied, here or by the managed backend.
25    pub control_conflict: bool,
26}
27
28impl PendingControl {
29    fn apply(&mut self, action: ControlAction) {
30        match action {
31            ControlAction::Pause => self.pause_requested = true,
32            ControlAction::Resume => self.pause_requested = false,
33            ControlAction::Cancel => self.cancel_requested = true,
34        }
35    }
36}
37
38/// One control request a session record carries.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum ControlAction {
41    Pause,
42    Resume,
43    Cancel,
44}
45
46/// One control request as one agent reads it.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) struct ControlRequest {
49    pub(crate) action: ControlAction,
50    /// The request names this agent: it is addressed to it, or it is a pause
51    /// that lists it among the participants whose acknowledgments it needs.
52    pub(crate) named: bool,
53    /// The request's record id, for the causal link of an acknowledgment.
54    pub(crate) record: Option<RecordId>,
55}
56
57/// The control request `envelope` read from `agent.control` makes, when it is
58/// addressed to `me` or to every agent. A forced cancel is a canceled session
59/// status on the control topic. A pause applies to every agent it reaches,
60/// and its participant list only decides which agents it names.
61pub(crate) fn control_request(
62    envelope: &AgentEnvelope,
63    me: Option<&AgentId>,
64) -> Option<ControlRequest> {
65    if let (Some(target), Some(me)) = (&envelope.target, me)
66        && target != me
67    {
68        return None;
69    }
70    let action = match envelope.kind {
71        AgentKind::Command => match envelope.operation.as_deref()? {
72            OPERATION_SESSION_PAUSE => ControlAction::Pause,
73            OPERATION_SESSION_RESUME => ControlAction::Resume,
74            OPERATION_SESSION_CANCEL => ControlAction::Cancel,
75            _ => return None,
76        },
77        AgentKind::Status if envelope.task_state == Some(TaskState::Canceled) => {
78            ControlAction::Cancel
79        }
80        _ => return None,
81    };
82    let addressed = envelope.target.is_some() && me.is_some();
83    let listed = action == ControlAction::Pause
84        && me.is_some_and(|me| {
85            serde_json::from_slice::<SessionPauseRequest>(&envelope.body)
86                .is_ok_and(|request| request.participants.contains(me))
87        });
88    Some(ControlRequest {
89        action,
90        named: addressed || listed,
91        record: envelope.record,
92    })
93}
94
95/// Where a control record sits: its partition and offset on `agent.control`.
96pub(crate) type ControlPosition = (u32, u64);
97
98/// The `agent.control` partition a session's control records belong on: the
99/// partition Iggy routes the conversation id key to, XXH32 with seed zero
100/// over the conversation string modulo the partition count. The managed backend applies
101/// the same rule, so both refuse a record on any other partition.
102pub(crate) fn control_partition(conversation: ConversationId, partitions: u32) -> Option<u32> {
103    (partitions > 0).then(|| xxh32(conversation.to_string().as_bytes()) % partitions)
104}
105
106const PRIME_1: u32 = 0x9E37_79B1;
107const PRIME_2: u32 = 0x85EB_CA77;
108const PRIME_3: u32 = 0xC2B2_AE3D;
109const PRIME_4: u32 = 0x27D4_EB2F;
110const PRIME_5: u32 = 0x1656_67B1;
111
112/// XXH32 with seed zero.
113fn xxh32(input: &[u8]) -> u32 {
114    let lane =
115        |at: usize| u32::from_le_bytes([input[at], input[at + 1], input[at + 2], input[at + 3]]);
116    let round = |acc: u32, value: u32| {
117        acc.wrapping_add(value.wrapping_mul(PRIME_2))
118            .rotate_left(13)
119            .wrapping_mul(PRIME_1)
120    };
121    let mut at = 0;
122    let mut hash = if input.len() >= 16 {
123        let mut acc = [
124            PRIME_1.wrapping_add(PRIME_2),
125            PRIME_2,
126            0,
127            0u32.wrapping_sub(PRIME_1),
128        ];
129        while at + 16 <= input.len() {
130            for (index, slot) in acc.iter_mut().enumerate() {
131                *slot = round(*slot, lane(at + index * 4));
132            }
133            at += 16;
134        }
135        acc[0]
136            .rotate_left(1)
137            .wrapping_add(acc[1].rotate_left(7))
138            .wrapping_add(acc[2].rotate_left(12))
139            .wrapping_add(acc[3].rotate_left(18))
140    } else {
141        PRIME_5
142    };
143    hash = hash.wrapping_add(input.len() as u32);
144    while at + 4 <= input.len() {
145        hash = hash
146            .wrapping_add(lane(at).wrapping_mul(PRIME_3))
147            .rotate_left(17)
148            .wrapping_mul(PRIME_4);
149        at += 4;
150    }
151    while at < input.len() {
152        hash = hash
153            .wrapping_add(u32::from(input[at]).wrapping_mul(PRIME_5))
154            .rotate_left(11)
155            .wrapping_mul(PRIME_1);
156        at += 1;
157    }
158    hash ^= hash >> 15;
159    hash = hash.wrapping_mul(PRIME_2);
160    hash ^= hash >> 13;
161    hash = hash.wrapping_mul(PRIME_3);
162    hash ^ (hash >> 16)
163}
164
165/// A control request at its position on `agent.control`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub(crate) struct PlacedRequest {
168    pub(crate) at: ControlPosition,
169    pub(crate) named: bool,
170    pub(crate) record: Option<RecordId>,
171}
172
173/// The control state of one session: its flags, the pause request in force,
174/// and the latest resume request.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub(crate) struct ControlState {
177    pub(crate) flags: PendingControl,
178    /// The pause request in force, set while a pause is requested.
179    pub(crate) pause: Option<PlacedRequest>,
180    /// The latest resume request.
181    pub(crate) resume: Option<PlacedRequest>,
182}
183
184impl ControlState {
185    fn apply(&mut self, at: ControlPosition, request: ControlRequest) {
186        self.flags.apply(request.action);
187        let placed = PlacedRequest {
188            at,
189            named: request.named,
190            record: request.record,
191        };
192        match request.action {
193            ControlAction::Pause => self.pause = Some(placed),
194            ControlAction::Resume => {
195                self.pause = None;
196                self.resume = Some(placed);
197            }
198            ControlAction::Cancel => {}
199        }
200    }
201
202    /// The pause request in force, `None` when no pause is requested.
203    pub(crate) fn paused(&self) -> Option<PlacedRequest> {
204        self.pause.filter(|_| self.flags.pause_requested)
205    }
206}
207
208/// The control state of the sessions one runtime has seen. The runtime loads
209/// it from a bounded read of `agent.control` at startup, and the control
210/// follower records every later request, so a process that joined after a
211/// request still sees it. A book that was never loaded folds a session on its
212/// first read, and live records that arrive before that fold are kept and
213/// replayed on top of it.
214#[derive(Default)]
215pub(crate) struct ControlBook {
216    entries: Mutex<HashMap<ConversationId, Entry>>,
217    // Whether a follower keeps folded entries current. Without one every
218    // read folds the log again.
219    live: AtomicBool,
220    // Whether the startup read loaded every session, so a session without an
221    // entry has no requests.
222    loaded: AtomicBool,
223    // The partition count of agent.control, zero while unknown.
224    partitions: AtomicU32,
225}
226
227#[derive(Default)]
228struct Entry {
229    state: ControlState,
230    last: Option<ControlPosition>,
231    folded: bool,
232    pending: Vec<(ControlPosition, ControlRequest)>,
233    // A request arrived off the session's control route.
234    conflict: bool,
235}
236
237impl Entry {
238    fn with_conflict(&self) -> ControlState {
239        let mut state = self.state;
240        state.flags.control_conflict |= self.conflict;
241        state
242    }
243
244    fn apply(&mut self, at: ControlPosition, request: ControlRequest) {
245        if self.last.is_some_and(|last| !newer(at, last)) {
246            return;
247        }
248        self.state.apply(at, request);
249        self.last = Some(at);
250    }
251}
252
253// Offsets order records within one partition. A session's control records
254// share one partition, so a record on another partition is taken as newer.
255fn newer(at: ControlPosition, last: ControlPosition) -> bool {
256    at.0 != last.0 || at.1 > last.1
257}
258
259impl ControlBook {
260    /// The partition count of `agent.control`, which fixes each session's
261    /// control route.
262    pub(crate) fn set_partitions(&self, partitions: u32) {
263        self.partitions.store(partitions, Ordering::Release);
264    }
265
266    // Whether `partition` is the control route of `conversation`. Every
267    // partition is while the count is unknown.
268    fn on_route(&self, conversation: ConversationId, partition: u32) -> bool {
269        control_partition(conversation, self.partitions.load(Ordering::Acquire))
270            .is_none_or(|route| route == partition)
271    }
272
273    /// Forget every session, for a recreated `agent.control` whose history
274    /// starts again.
275    pub(crate) fn reset(&self) {
276        self.lock().clear();
277        self.loaded.store(false, Ordering::Release);
278    }
279
280    /// Mark whether a follower is keeping the book current.
281    pub(crate) fn set_live(&self, live: bool) {
282        self.live.store(live, Ordering::Release);
283    }
284
285    /// Whether a follower is keeping the book current.
286    pub(crate) fn is_live(&self) -> bool {
287        self.live.load(Ordering::Acquire)
288    }
289
290    /// Load the requests a bounded read of `agent.control` found, in log
291    /// order. Every session is folded afterwards: one without an entry has
292    /// no requests.
293    pub(crate) fn load(
294        &self,
295        records: impl IntoIterator<Item = (ConversationId, ControlPosition, ControlRequest)>,
296    ) {
297        let mut entries = self.lock();
298        for (conversation, at, request) in records {
299            let on_route = self.on_route(conversation, at.0);
300            let entry = entries.entry(conversation).or_default();
301            entry.folded = true;
302            if on_route {
303                entry.apply(at, request);
304            } else {
305                entry.conflict = true;
306            }
307        }
308        self.loaded.store(true, Ordering::Release);
309    }
310
311    /// Record one live control request.
312    pub(crate) fn observe(
313        &self,
314        conversation: ConversationId,
315        at: ControlPosition,
316        request: ControlRequest,
317    ) {
318        let loaded = self.loaded.load(Ordering::Acquire);
319        let on_route = self.on_route(conversation, at.0);
320        let mut entries = self.lock();
321        let entry = entries.entry(conversation).or_default();
322        if !on_route {
323            entry.conflict = true;
324        } else if entry.folded || loaded {
325            entry.folded = true;
326            entry.apply(at, request);
327        } else {
328            entry.pending.push((at, request));
329        }
330    }
331
332    /// The folded control state of `conversation`, `None` when it must be
333    /// folded from the log first.
334    pub(crate) fn state(&self, conversation: ConversationId) -> Option<ControlState> {
335        if !self.is_live() {
336            return None;
337        }
338        let loaded = self.loaded.load(Ordering::Acquire);
339        match self.lock().get(&conversation) {
340            Some(entry) if entry.folded => Some(entry.with_conflict()),
341            Some(_) => None,
342            None => loaded.then(ControlState::default),
343        }
344    }
345
346    /// Whether any session has a pause or resume request, so the runtime has
347    /// held work to look for.
348    pub(crate) fn has_pause_history(&self) -> bool {
349        self.lock()
350            .values()
351            .any(|entry| entry.state.pause.is_some() || entry.state.resume.is_some())
352    }
353
354    /// The sessions with a pause or resume request.
355    pub(crate) fn paused_or_resumed(&self) -> Vec<ConversationId> {
356        self.lock()
357            .iter()
358            .filter(|(_, entry)| entry.state.pause.is_some() || entry.state.resume.is_some())
359            .map(|(conversation, _)| *conversation)
360            .collect()
361    }
362
363    /// Install the state folded from the log through `last`, replay the live
364    /// requests that arrived after it, and return the result. An entry a
365    /// live follower keeps current is kept as it is.
366    pub(crate) fn install(
367        &self,
368        conversation: ConversationId,
369        state: ControlState,
370        last: Option<ControlPosition>,
371    ) -> ControlState {
372        let live = self.is_live();
373        let mut entries = self.lock();
374        let entry = entries.entry(conversation).or_default();
375        if entry.folded && live {
376            return entry.with_conflict();
377        }
378        let pending = std::mem::take(&mut entry.pending);
379        entry.state = state;
380        entry.last = last;
381        entry.folded = true;
382        for (at, request) in pending {
383            entry.apply(at, request);
384        }
385        entry.with_conflict()
386    }
387
388    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<ConversationId, Entry>> {
389        self.entries
390            .lock()
391            .unwrap_or_else(std::sync::PoisonError::into_inner)
392    }
393}
394
395/// Fold control records in log order into a state and the position of the
396/// last one. A record off `route`, the session's control partition, is not
397/// applied and flags the state.
398pub(crate) fn fold(
399    records: impl IntoIterator<Item = (ControlPosition, ControlRequest)>,
400    route: Option<u32>,
401) -> (ControlState, Option<ControlPosition>) {
402    let mut entry = Entry::default();
403    for (at, request) in records {
404        if route.is_none_or(|route| route == at.0) {
405            entry.apply(at, request);
406        } else {
407            entry.conflict = true;
408        }
409    }
410    (entry.with_conflict(), entry.last)
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn given_known_inputs_when_hashed_then_should_match_the_xxh32_reference() {
419        assert_eq!(xxh32(b""), 0x02CC_5D05);
420        assert_eq!(xxh32(b"abc"), 0x32D1_53FF);
421        assert_eq!(
422            xxh32(b"Nobody inspects the spammish repetition"),
423            0xE229_3B2F
424        );
425    }
426
427    #[test]
428    fn given_a_request_off_the_control_route_when_recorded_then_should_flag_and_never_apply() {
429        let session: ConversationId = "01KWM3K3XEP3NP5TN850J17YBP".parse().unwrap();
430        let route = control_partition(session, 2).expect("a partition count");
431        let book = ControlBook::default();
432        book.set_partitions(2);
433        book.load([
434            (session, (route, 1), request(ControlAction::Pause)),
435            (session, (1 - route, 2), request(ControlAction::Cancel)),
436        ]);
437        book.set_live(true);
438        book.observe(session, (1 - route, 3), request(ControlAction::Resume));
439        let state = book.state(session).expect("folded");
440        assert!(state.flags.pause_requested, "the routed pause applies");
441        assert!(
442            !state.flags.cancel_requested,
443            "the misrouted cancel never applies"
444        );
445        assert!(state.flags.control_conflict);
446
447        let (folded, _) = fold(
448            [
449                ((1 - route, 5), request(ControlAction::Cancel)),
450                ((route, 6), request(ControlAction::Pause)),
451            ],
452            Some(route),
453        );
454        assert_eq!(
455            folded.flags, state.flags,
456            "the log fold agrees with the book"
457        );
458
459        book.reset();
460        assert_eq!(
461            book.state(session),
462            None,
463            "a reset book folds again from the log"
464        );
465    }
466    use laser_wire::agent::CorrelationId;
467
468    fn command(operation: &str, target: Option<&str>) -> AgentEnvelope {
469        let envelope = AgentEnvelope::command(
470            RecordId::from_u128(1),
471            laser_wire::agent::ConversationId::from_u128(2),
472            "operator".parse().expect("operator parses"),
473            CorrelationId::from_u128(3),
474            b"{}".to_vec(),
475        )
476        .with_operation(operation);
477        match target {
478            Some(target) => envelope.with_target(target.parse().expect("target parses")),
479            None => envelope,
480        }
481    }
482
483    fn flags(book: &ControlBook, session: ConversationId) -> Option<PendingControl> {
484        book.state(session).map(|state| state.flags)
485    }
486
487    fn request(action: ControlAction) -> ControlRequest {
488        ControlRequest {
489            action,
490            named: false,
491            record: None,
492        }
493    }
494
495    #[test]
496    fn given_control_commands_when_classified_then_should_map_each_request_and_skip_other_agents() {
497        let me: AgentId = "worker".parse().expect("worker parses");
498        let action = |envelope: &AgentEnvelope, me: Option<&AgentId>| {
499            control_request(envelope, me).map(|request| request.action)
500        };
501        assert_eq!(
502            action(&command(OPERATION_SESSION_PAUSE, Some("worker")), Some(&me)),
503            Some(ControlAction::Pause)
504        );
505        assert_eq!(
506            action(&command(OPERATION_SESSION_RESUME, None), Some(&me)),
507            Some(ControlAction::Resume)
508        );
509        assert_eq!(
510            action(&command(OPERATION_SESSION_CANCEL, None), None),
511            Some(ControlAction::Cancel)
512        );
513        assert_eq!(
514            action(
515                &command(OPERATION_SESSION_CANCEL, Some("critic")),
516                Some(&me)
517            ),
518            None
519        );
520        assert_eq!(action(&command("chat", None), Some(&me)), None);
521    }
522
523    #[test]
524    fn given_a_pause_with_participants_when_classified_then_should_name_only_listed_agents() {
525        let me: AgentId = "worker".parse().expect("worker parses");
526        let other: AgentId = "critic".parse().expect("critic parses");
527        let mut pause = command(OPERATION_SESSION_PAUSE, None);
528        pause.body = br#"{"participants":["worker"]}"#.to_vec();
529        let named = control_request(&pause, Some(&me)).expect("the pause applies");
530        assert!(named.named);
531        assert_eq!(named.record, Some(RecordId::from_u128(1)));
532        let unnamed = control_request(&pause, Some(&other)).expect("the pause still applies");
533        assert!(!unnamed.named, "an unlisted agent still pauses");
534        let addressed =
535            control_request(&command(OPERATION_SESSION_PAUSE, Some("worker")), Some(&me))
536                .expect("the pause applies");
537        assert!(addressed.named);
538        let broadcast = control_request(&command(OPERATION_SESSION_PAUSE, None), Some(&me))
539            .expect("the pause applies");
540        assert!(!broadcast.named);
541    }
542
543    #[test]
544    fn given_pause_resume_and_cancel_when_folded_then_should_keep_only_the_latest_pause_state() {
545        let (state, last) = fold(
546            [
547                ((0, 1), request(ControlAction::Pause)),
548                ((0, 2), request(ControlAction::Resume)),
549                ((0, 3), request(ControlAction::Cancel)),
550            ],
551            None,
552        );
553        assert_eq!(
554            state.flags,
555            PendingControl {
556                pause_requested: false,
557                cancel_requested: true,
558                control_conflict: false,
559            }
560        );
561        assert_eq!(state.paused(), None);
562        assert_eq!(state.resume.map(|resume| resume.at), Some((0, 2)));
563        assert_eq!(last, Some((0, 3)));
564    }
565
566    #[test]
567    fn given_live_requests_before_the_fold_when_installed_then_should_replay_only_newer_ones() {
568        let book = ControlBook::default();
569        book.set_live(true);
570        let session = ConversationId::new();
571        book.observe(session, (0, 4), request(ControlAction::Pause));
572        book.observe(session, (0, 6), request(ControlAction::Cancel));
573        assert_eq!(flags(&book, session), None, "unfolded until the first read");
574        let (state, last) = fold(
575            [
576                ((0, 4), request(ControlAction::Pause)),
577                ((0, 5), request(ControlAction::Resume)),
578            ],
579            None,
580        );
581        let installed = book.install(session, state, last);
582        assert_eq!(
583            installed.flags,
584            PendingControl {
585                pause_requested: false,
586                cancel_requested: true,
587                control_conflict: false,
588            }
589        );
590        book.observe(session, (0, 7), request(ControlAction::Pause));
591        assert_eq!(
592            flags(&book, session),
593            Some(PendingControl {
594                pause_requested: true,
595                cancel_requested: true,
596                control_conflict: false,
597            })
598        );
599        assert_eq!(
600            book.state(session)
601                .and_then(|state| state.paused())
602                .map(|pause| pause.at),
603            Some((0, 7))
604        );
605        book.set_live(false);
606        assert_eq!(
607            flags(&book, session),
608            None,
609            "a stopped follower forces a fold"
610        );
611        let refolded = book.install(session, ControlState::default(), None);
612        assert_eq!(
613            refolded,
614            ControlState::default(),
615            "a stopped follower trusts the fold"
616        );
617    }
618
619    #[test]
620    fn given_a_loaded_book_when_reading_an_unseen_session_then_should_report_no_requests() {
621        let book = ControlBook::default();
622        let paused = ConversationId::new();
623        book.load([(paused, (1, 3), request(ControlAction::Pause))]);
624        book.set_live(true);
625        assert!(book.has_pause_history());
626        assert_eq!(book.paused_or_resumed(), vec![paused]);
627        assert_eq!(
628            flags(&book, ConversationId::new()),
629            Some(PendingControl::default())
630        );
631        let later = ConversationId::new();
632        book.observe(later, (2, 9), request(ControlAction::Cancel));
633        assert_eq!(
634            flags(&book, later),
635            Some(PendingControl {
636                pause_requested: false,
637                cancel_requested: true,
638                control_conflict: false,
639            })
640        );
641    }
642}