1use std::time::Duration;
2
3use liminal_sdk::{OBSERVABILITY_CHANNEL, PushWriter, SdkError};
4
5use crate::authority::{AuthorityError, Cut, FeedAuthority};
6use crate::envelope::{
7 ComponentId, ContractId, EnvelopeCodec, EnvelopeError, EnvelopeHeader, FrameKind,
8};
9use crate::graph::{GraphError, GraphState};
10
11pub const TICK_INTERVAL: Duration = Duration::from_millis(250);
14
15pub const SNAPSHOT_PERIOD: usize = 20;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum PublishOutcome {
21 Written,
23}
24
25#[derive(Debug, thiserror::Error)]
26pub enum PublishError {
27 #[error("SDK opaque publish failed: {0}")]
28 Sdk(#[from] SdkError),
29}
30
31pub trait PublishSink {
32 fn publish(&mut self, channel: &str, payload: Vec<u8>) -> Result<PublishOutcome, PublishError>;
33}
34
35impl PublishSink for PushWriter {
36 fn publish(&mut self, channel: &str, payload: Vec<u8>) -> Result<PublishOutcome, PublishError> {
37 Self::publish(self, channel, payload)?;
38 Ok(PublishOutcome::Written)
39 }
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum CadenceError {
44 #[error("demo feed channel cannot be empty")]
45 EmptyChannel,
46 #[error("demo feed must not publish to reserved observability channel {0:?}")]
47 ReservedChannel(&'static str),
48 #[error("snapshot period must be greater than zero")]
49 ZeroSnapshotPeriod,
50 #[error("initial snapshot has already been emitted")]
51 AlreadyStarted,
52 #[error("initial snapshot must be emitted before a delta")]
53 NotStarted,
54 #[error(transparent)]
55 Authority(#[from] AuthorityError),
56 #[error(transparent)]
57 Envelope(#[from] EnvelopeError),
58 #[error(transparent)]
59 Graph(#[from] GraphError),
60 #[error(transparent)]
61 Publish(#[from] PublishError),
62 #[error("encoded envelope failed its own round-trip invariant")]
63 EncodedFrameMismatch,
64}
65
66pub struct CadenceEngine<C, G, S> {
69 channel: String,
70 component_id: ComponentId,
71 contract: ContractId,
72 authority: FeedAuthority,
73 codec: C,
74 graph: G,
75 sink: S,
76 snapshot_period: usize,
77 deltas_since_snapshot: usize,
78 started: bool,
79}
80
81impl<C, G, S> CadenceEngine<C, G, S>
82where
83 C: EnvelopeCodec,
84 G: GraphState,
85 S: PublishSink,
86{
87 pub fn new(
88 channel: String,
89 component_id: ComponentId,
90 authority: FeedAuthority,
91 codec: C,
92 graph: G,
93 sink: S,
94 snapshot_period: usize,
95 ) -> Result<Self, CadenceError> {
96 if channel.is_empty() {
97 return Err(CadenceError::EmptyChannel);
98 }
99 if channel == OBSERVABILITY_CHANNEL {
100 return Err(CadenceError::ReservedChannel(OBSERVABILITY_CHANNEL));
101 }
102 if snapshot_period == 0 {
103 return Err(CadenceError::ZeroSnapshotPeriod);
104 }
105 let contract = ContractId::new("frame", "graph-view", 1)?;
106 Ok(Self {
107 channel,
108 component_id,
109 contract,
110 authority,
111 codec,
112 graph,
113 sink,
114 snapshot_period,
115 deltas_since_snapshot: 0,
116 started: false,
117 })
118 }
119
120 pub fn emit_initial_snapshot(&mut self) -> Result<PublishOutcome, CadenceError> {
121 if self.started {
122 return Err(CadenceError::AlreadyStarted);
123 }
124 let state = self.graph.snapshot_bytes()?;
125 let cut = self.authority.mint_baseline(&self.channel)?;
126 let outcome = self.publish(FrameKind::Snapshot, cut, &state)?;
127 self.started = true;
128 Ok(outcome)
129 }
130
131 pub fn emit_tick(&mut self) -> Result<PublishOutcome, CadenceError> {
132 if !self.started {
133 return Err(CadenceError::NotStarted);
134 }
135 let delta = self.graph.advance_delta_bytes()?;
136 let cut = self.authority.mint_delta(&self.channel)?;
137 let delta_outcome = self.publish(FrameKind::Delta, cut, &delta)?;
138 self.deltas_since_snapshot += 1;
139 if self.deltas_since_snapshot == self.snapshot_period {
140 let snapshot = self.graph.refresh_snapshot_bytes()?;
141 self.authority.advance_generation()?;
142 let baseline = self.authority.mint_baseline(&self.channel)?;
143 let snapshot_outcome = self.publish(FrameKind::Snapshot, baseline, &snapshot)?;
144 self.deltas_since_snapshot = 0;
145 Ok(snapshot_outcome)
146 } else {
147 Ok(delta_outcome)
148 }
149 }
150
151 fn publish(
152 &mut self,
153 kind: FrameKind,
154 cut: Cut,
155 state: &[u8],
156 ) -> Result<PublishOutcome, CadenceError> {
157 let header = EnvelopeHeader {
158 component_id: self.component_id.clone(),
159 contract_id: self.contract.clone(),
160 generation: cut.generation().get(),
161 kind,
162 seq: cut.seq(),
163 };
164 let payload = self.codec.encode(&header, state)?;
165 let decoded = self.codec.decode(&payload)?;
166 if decoded.header != header || decoded.state != state {
167 return Err(CadenceError::EncodedFrameMismatch);
168 }
169 Ok(self.sink.publish(&self.channel, payload)?)
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use std::cell::Cell;
176 use std::rc::Rc;
177
178 use super::{
179 CadenceEngine, CadenceError, PublishError, PublishOutcome, PublishSink, SNAPSHOT_PERIOD,
180 };
181 use crate::authority::{FeedAuthority, GenerationStore, GenerationStoreError};
182 use crate::envelope::{ComponentId, EnvelopeCodec, FrameEnvelopeCodec, FrameKind};
183 use crate::graph::GraphViewState;
184
185 #[derive(Clone, Default)]
186 struct MemoryGenerationStore(Rc<Cell<Option<u64>>>);
187
188 impl GenerationStore for MemoryGenerationStore {
189 fn load(&mut self) -> Result<Option<u64>, GenerationStoreError> {
190 Ok(self.0.get())
191 }
192
193 fn persist(&mut self, generation: u64) -> Result<(), GenerationStoreError> {
194 self.0.set(Some(generation));
195 Ok(())
196 }
197 }
198
199 #[derive(Default)]
200 struct FakeSink {
201 frames: Vec<Vec<u8>>,
202 channels: Vec<String>,
203 fail_at: Option<usize>,
204 }
205
206 impl PublishSink for FakeSink {
207 fn publish(
208 &mut self,
209 channel: &str,
210 payload: Vec<u8>,
211 ) -> Result<PublishOutcome, PublishError> {
212 if self.fail_at == Some(self.frames.len()) {
213 return Err(PublishError::Sdk(liminal_sdk::SdkError::Connection {
214 description: "injected sink failure".to_owned(),
215 }));
216 }
217 self.channels.push(channel.to_owned());
218 self.frames.push(payload);
219 Ok(PublishOutcome::Written)
220 }
221 }
222
223 fn engine(
224 sink: FakeSink,
225 period: usize,
226 ) -> Result<
227 CadenceEngine<FrameEnvelopeCodec, GraphViewState, FakeSink>,
228 Box<dyn std::error::Error>,
229 > {
230 Ok(CadenceEngine::new(
231 "frame.demo.graph-view".to_owned(),
232 ComponentId::new("graph-view-demo")?,
233 FeedAuthority::start(MemoryGenerationStore::default())?,
234 FrameEnvelopeCodec,
235 GraphViewState::new()?,
236 sink,
237 period,
238 )?)
239 }
240
241 #[test]
242 fn baseline_snapshot_precedes_monotonic_deltas() -> Result<(), Box<dyn std::error::Error>> {
243 let mut cadence = engine(FakeSink::default(), SNAPSHOT_PERIOD)?;
244 cadence.emit_initial_snapshot()?;
245 cadence.emit_tick()?;
246 let first = FrameEnvelopeCodec.decode(&cadence.sink.frames[0])?;
247 let second = FrameEnvelopeCodec.decode(&cadence.sink.frames[1])?;
248 assert_eq!(first.header.kind, FrameKind::Snapshot);
249 assert_eq!(second.header.kind, FrameKind::Delta);
250 assert_eq!((first.header.generation, first.header.seq), (1, 0));
251 assert_eq!((second.header.generation, second.header.seq), (1, 1));
252 assert!(
253 cadence
254 .sink
255 .channels
256 .iter()
257 .all(|channel| channel == "frame.demo.graph-view")
258 );
259 Ok(())
260 }
261
262 #[test]
263 fn periodic_snapshot_bumps_generation_and_resets_sequence()
264 -> Result<(), Box<dyn std::error::Error>> {
265 let mut cadence = engine(FakeSink::default(), SNAPSHOT_PERIOD)?;
266 cadence.emit_initial_snapshot()?;
267 for _ in 0..SNAPSHOT_PERIOD {
268 cadence.emit_tick()?;
269 }
270 let headers: Result<Vec<_>, _> = cadence
271 .sink
272 .frames
273 .iter()
274 .map(|frame| {
275 FrameEnvelopeCodec
276 .decode(frame)
277 .map(|decoded| decoded.header)
278 })
279 .collect();
280 let headers = headers?;
281 assert_eq!(headers.len(), SNAPSHOT_PERIOD + 2);
282 assert_eq!(
283 headers.first().map(|header| header.kind),
284 Some(FrameKind::Snapshot)
285 );
286 assert_eq!(
287 headers.last().map(|header| header.kind),
288 Some(FrameKind::Snapshot)
289 );
290 assert_eq!(
291 headers
292 .first()
293 .map(|header| (header.generation, header.seq)),
294 Some((1, 0))
295 );
296 assert_eq!(
297 headers.last().map(|header| (header.generation, header.seq)),
298 Some((2, 0))
299 );
300 assert!(
301 headers[1..=SNAPSHOT_PERIOD]
302 .iter()
303 .enumerate()
304 .all(|(index, header)| {
305 header.kind == FrameKind::Delta
306 && header.generation == 1
307 && usize::try_from(header.seq) == Ok(index + 1)
308 })
309 );
310 let cuts: Vec<_> = headers
311 .iter()
312 .map(|header| (header.generation, header.seq))
313 .collect();
314 assert!(cuts.windows(2).all(|pair| pair[0] < pair[1]));
315 Ok(())
316 }
317
318 #[test]
319 fn sink_failure_is_typed_and_its_cut_is_not_reused() -> Result<(), Box<dyn std::error::Error>> {
320 let sink = FakeSink {
321 frames: Vec::new(),
322 channels: Vec::new(),
323 fail_at: Some(1),
324 };
325 let mut cadence = engine(sink, SNAPSHOT_PERIOD)?;
326 cadence.emit_initial_snapshot()?;
327 let error = cadence.emit_tick();
328 assert!(matches!(error, Err(CadenceError::Publish(_))));
329 cadence.sink.fail_at = None;
330 cadence.emit_tick()?;
331 let after_gap = FrameEnvelopeCodec.decode(&cadence.sink.frames[1])?;
332 assert_eq!((after_gap.header.generation, after_gap.header.seq), (1, 2));
333 Ok(())
334 }
335
336 #[test]
337 fn reserved_observability_channel_is_refused() -> Result<(), Box<dyn std::error::Error>> {
338 let result = CadenceEngine::new(
339 liminal_sdk::OBSERVABILITY_CHANNEL.to_owned(),
340 ComponentId::new("graph-view-demo")?,
341 FeedAuthority::start(MemoryGenerationStore::default())?,
342 FrameEnvelopeCodec,
343 GraphViewState::new()?,
344 FakeSink::default(),
345 SNAPSHOT_PERIOD,
346 );
347 assert!(matches!(result, Err(CadenceError::ReservedChannel(_))));
348 Ok(())
349 }
350}