Skip to main content

ursula_stream/
state_machine.rs

1//! Deterministic stream state machine driving a single Raft group.
2//!
3//! The state machine lives in this module root; its behavior is split across
4//! cohesive submodules to keep each surface readable:
5//!
6//! - [`query`]: read paths — heads, accessors, read plans, snapshots, bootstrap.
7//! - [`append`]: append paths and idempotent producer bookkeeping.
8//! - [`lifecycle`]: bucket/stream create, close, delete, attrs, and TTL expiry.
9//! - [`cold`]: cold-tier flush planning, GC, retention compaction, snapshot publishing.
10//! - [`persist`]: snapshot / restore / integrity serialization.
11//! - [`hot_buffer`], [`cold_state`], [`ttl`]: internal per-stream data structures.
12//!
13//! The root keeps the [`StreamStateMachine`] type, its core slot/TTL accessors,
14//! the [`StreamStateMachine::apply`] command dispatcher, and cross-cutting helpers.
15
16use std::cmp::Ordering;
17use std::cmp::Reverse;
18use std::collections::BinaryHeap;
19use std::collections::HashMap;
20use std::collections::HashSet;
21use std::collections::VecDeque;
22
23use bytes::Bytes;
24use slotmap::Key;
25use slotmap::new_key_type;
26use ursula_shard::BucketStreamId;
27
28use self::cold_gc::ColdGcQueue;
29use self::cold_state::StreamColdState;
30use self::hot_buffer::HotBuffer;
31use self::registry::StreamRegistry;
32use self::ttl::TtlEntry;
33use self::ttl::TtlIndex;
34use crate::command::StreamCommand;
35use crate::integrity::StreamIntegrity;
36use crate::model::AppendExternalInput;
37use crate::model::AppendStreamInput;
38use crate::model::COLD_INDEX_PAGE_SPAN_BYTES;
39use crate::model::ColdChunkRef;
40use crate::model::ColdFlushCandidate;
41use crate::model::ColdGcEntry;
42use crate::model::ColdGcTarget;
43use crate::model::ExternalPayloadRef;
44use crate::model::HotPayloadSegment;
45use crate::model::MAX_STREAM_ATTRS_BYTES;
46use crate::model::ObjectPayloadRef;
47use crate::model::ProducerAppendRecord;
48use crate::model::ProducerRequest;
49use crate::model::ProducerSnapshot;
50use crate::model::ProducerState;
51use crate::model::StreamAttrs;
52use crate::model::StreamBatchAppend;
53use crate::model::StreamBatchAppendItem;
54use crate::model::StreamBootstrapPlan;
55use crate::model::StreamMessageRecord;
56use crate::model::StreamMetadata;
57use crate::model::StreamRead;
58use crate::model::StreamReadColdIndexSegment;
59use crate::model::StreamReadObjectSegment;
60use crate::model::StreamReadPlan;
61use crate::model::StreamReadSegment;
62use crate::model::StreamStatus;
63use crate::model::StreamVisibleSnapshot;
64use crate::record_index::StreamRecordIndex;
65use crate::record_index::canonical_json_record_ends;
66use crate::record_index::is_json_record_content_type;
67use crate::response::StreamErrorCode;
68use crate::response::StreamErrorContext;
69use crate::response::StreamResponse;
70use crate::snapshot::StreamSnapshot;
71use crate::snapshot::StreamSnapshotEntry;
72use crate::snapshot::StreamSnapshotError;
73use crate::validate::validate_bucket_id;
74use crate::validate::validate_stream_id;
75
76mod append;
77mod cold;
78mod cold_gc;
79mod cold_state;
80mod hot_buffer;
81mod lifecycle;
82mod persist;
83mod query;
84mod registry;
85mod ttl;
86
87const TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE: usize = 256;
88
89new_key_type! {
90    struct StreamKey;
91}
92
93#[derive(Debug, Clone, Default)]
94pub struct StreamStateMachine {
95    buckets: HashSet<String>,
96    registry: StreamRegistry,
97    cold_gc: ColdGcQueue,
98}
99
100#[derive(Debug, Clone)]
101struct StreamSlot {
102    metadata: StreamMetadata,
103    attrs: Option<StreamAttrs>,
104    hot_buffer: HotBuffer,
105    cold: StreamColdState,
106    message_records: Vec<StreamMessageRecord>,
107    record_index: Option<StreamRecordIndex>,
108    integrity: StreamIntegrity,
109    visible_snapshot: Option<StreamVisibleSnapshot>,
110    producers: HashMap<String, ProducerState>,
111}
112
113impl StreamStateMachine {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    fn stream_slot(&self, stream_id: &BucketStreamId) -> Option<&StreamSlot> {
119        self.registry.slot(stream_id)
120    }
121
122    fn stream_slot_mut(&mut self, stream_id: &BucketStreamId) -> Option<&mut StreamSlot> {
123        self.registry.slot_mut(stream_id)
124    }
125
126    fn stream_metadata(&self, stream_id: &BucketStreamId) -> Option<&StreamMetadata> {
127        self.registry.metadata(stream_id)
128    }
129
130    fn stream_metadata_mut(&mut self, stream_id: &BucketStreamId) -> Option<&mut StreamMetadata> {
131        self.registry.metadata_mut(stream_id)
132    }
133
134    fn insert_stream_slot(&mut self, slot: StreamSlot) -> Option<StreamKey> {
135        self.registry.insert(slot)
136    }
137
138    fn refresh_ttl_entry(&mut self, stream_id: &BucketStreamId) {
139        self.registry.refresh_ttl(stream_id);
140    }
141
142    fn message_records_for_append(
143        start_offset: u64,
144        end_offset: u64,
145        record_ends: &[u64],
146    ) -> Vec<StreamMessageRecord> {
147        if record_ends.is_empty() {
148            return (start_offset < end_offset)
149                .then_some(StreamMessageRecord {
150                    start_offset,
151                    end_offset,
152                })
153                .into_iter()
154                .collect();
155        }
156        let mut start = start_offset;
157        record_ends
158            .iter()
159            .map(|relative_end| {
160                let end = start_offset.saturating_add(*relative_end);
161                let record = StreamMessageRecord {
162                    start_offset: start,
163                    end_offset: end,
164                };
165                start = end;
166                record
167            })
168            .collect()
169    }
170
171    pub fn apply(&mut self, command: StreamCommand) -> StreamResponse {
172        match command {
173            StreamCommand::CreateBucket { bucket_id } => self.create_bucket(bucket_id),
174            StreamCommand::DeleteBucket { bucket_id } => self.delete_bucket(&bucket_id),
175            StreamCommand::CreateStream {
176                stream_id,
177                content_type,
178                initial_payload,
179                close_after,
180                stream_seq,
181                producer,
182                stream_ttl_seconds,
183                stream_expires_at_ms,
184                attrs,
185                now_ms,
186            } => {
187                let response = match canonical_json_record_ends(&content_type, &initial_payload) {
188                    Ok(record_ends) => self.create_stream(CreateStreamInput {
189                        stream_id,
190                        content_type,
191                        initial_payload: initial_payload.into(),
192                        record_ends,
193                        close_after,
194                        stream_seq,
195                        producer,
196                        stream_ttl_seconds,
197                        stream_expires_at_ms,
198                        attrs,
199                        now_ms,
200                    }),
201                    Err(_) => StreamResponse::error(
202                        StreamErrorCode::InvalidRecordBoundaries,
203                        "application/json initial payload must use canonical newline boundaries",
204                    ),
205                };
206                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
207                response
208            }
209            StreamCommand::CreateExternal {
210                stream_id,
211                content_type,
212                initial_payload,
213                record_ends,
214                close_after,
215                stream_seq,
216                producer,
217                stream_ttl_seconds,
218                stream_expires_at_ms,
219                attrs,
220                now_ms,
221            } => {
222                let response = self.create_external_stream(CreateExternalStreamInput {
223                    stream_id,
224                    content_type,
225                    initial_payload,
226                    record_ends,
227                    close_after,
228                    stream_seq,
229                    producer,
230                    stream_ttl_seconds,
231                    stream_expires_at_ms,
232                    attrs,
233                    now_ms,
234                });
235                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
236                response
237            }
238            StreamCommand::Append {
239                stream_id,
240                content_type,
241                payload,
242                close_after,
243                stream_seq,
244                producer,
245                now_ms,
246                record_match,
247            } => {
248                let response = self.append_borrowed(AppendStreamInput {
249                    stream_id,
250                    content_type: content_type.as_deref(),
251                    payload: &payload,
252                    close_after,
253                    stream_seq,
254                    producer,
255                    now_ms,
256                    record_match,
257                });
258                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
259                response
260            }
261            StreamCommand::AppendExternal {
262                stream_id,
263                content_type,
264                payload,
265                record_ends,
266                close_after,
267                stream_seq,
268                producer,
269                now_ms,
270                record_match,
271            } => {
272                let response = self.append_external(AppendExternalInput {
273                    stream_id,
274                    content_type: content_type.as_deref(),
275                    payload,
276                    record_ends,
277                    close_after,
278                    stream_seq,
279                    producer,
280                    now_ms,
281                    record_match,
282                });
283                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
284                response
285            }
286            StreamCommand::AppendBatch {
287                stream_id,
288                content_type,
289                payloads,
290                producer,
291                now_ms,
292            } => {
293                let response = match self.append_batch_borrowed(
294                    stream_id,
295                    content_type.as_deref(),
296                    &payloads.iter().map(Bytes::as_ref).collect::<Vec<_>>(),
297                    producer,
298                    now_ms,
299                ) {
300                    Ok(batch) => batch
301                        .items
302                        .last()
303                        .map(|item| StreamResponse::Appended {
304                            offset: item.offset,
305                            next_offset: item.next_offset,
306                            closed: item.closed,
307                            deduplicated: item.deduplicated,
308                            producer: None,
309                        })
310                        .unwrap_or_else(|| {
311                            StreamResponse::error(
312                                StreamErrorCode::EmptyAppend,
313                                "append batch must contain at least one payload",
314                            )
315                        }),
316                    Err(response) => response,
317                };
318                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
319                response
320            }
321            StreamCommand::PublishSnapshot {
322                stream_id,
323                snapshot_offset,
324                content_type,
325                payload,
326                now_ms,
327            } => {
328                let response = self.publish_snapshot(
329                    stream_id,
330                    snapshot_offset,
331                    content_type,
332                    payload.into(),
333                    now_ms,
334                );
335                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
336                response
337            }
338            StreamCommand::TouchStreamAccess {
339                stream_id,
340                now_ms,
341                renew_ttl,
342            } => {
343                let response = self.touch_stream_access(&stream_id, now_ms, renew_ttl);
344                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
345                response
346            }
347            StreamCommand::UpdateStreamAttrs {
348                stream_id,
349                attrs,
350                now_ms,
351            } => {
352                let response = self.update_stream_attrs(&stream_id, attrs, now_ms);
353                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
354                response
355            }
356            StreamCommand::FlushCold { stream_id, chunk } => self.flush_cold(stream_id, chunk),
357            StreamCommand::Close {
358                stream_id,
359                stream_seq,
360                producer,
361                now_ms,
362            } => {
363                let response = self.close(stream_id, stream_seq, producer, now_ms);
364                self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
365                response
366            }
367            StreamCommand::DeleteStream { stream_id } => self.delete_stream(&stream_id),
368            StreamCommand::AckColdGc { up_to_seq } => self.ack_cold_gc(up_to_seq),
369        }
370    }
371}
372
373#[derive(Debug)]
374struct CreateStreamInput {
375    stream_id: BucketStreamId,
376    content_type: String,
377    initial_payload: Vec<u8>,
378    record_ends: Vec<u64>,
379    close_after: bool,
380    stream_seq: Option<String>,
381    producer: Option<ProducerRequest>,
382    stream_ttl_seconds: Option<u64>,
383    stream_expires_at_ms: Option<u64>,
384    attrs: Option<StreamAttrs>,
385    now_ms: u64,
386}
387
388#[derive(Debug)]
389struct CreateExternalStreamInput {
390    stream_id: BucketStreamId,
391    content_type: String,
392    initial_payload: ExternalPayloadRef,
393    record_ends: Vec<u64>,
394    close_after: bool,
395    stream_seq: Option<String>,
396    producer: Option<ProducerRequest>,
397    stream_ttl_seconds: Option<u64>,
398    stream_expires_at_ms: Option<u64>,
399    attrs: Option<StreamAttrs>,
400    now_ms: u64,
401}
402
403impl CreateStreamInput {
404    fn initial_len(&self) -> u64 {
405        u64::try_from(self.initial_payload.len()).expect("payload len fits u64")
406    }
407}
408
409fn normalize_stream_attrs(attrs: Option<StreamAttrs>) -> Option<StreamAttrs> {
410    attrs.filter(|attrs| !attrs.is_empty())
411}
412
413fn stream_expiry_at_ms(stream: &StreamMetadata) -> Option<u64> {
414    if let Some(expires_at_ms) = stream.stream_expires_at_ms {
415        return Some(expires_at_ms);
416    }
417    stream.stream_ttl_seconds.map(|ttl_seconds| {
418        stream
419            .last_ttl_touch_at_ms
420            .saturating_add(ttl_seconds.saturating_mul(1000))
421    })
422}
423
424fn stream_is_expired(stream: &StreamMetadata, now_ms: u64) -> bool {
425    stream_expiry_at_ms(stream).is_some_and(|expires_at_ms| now_ms >= expires_at_ms)
426}
427
428fn renew_stream_ttl(stream: &mut StreamMetadata, now_ms: u64) {
429    if stream.stream_ttl_seconds.is_some() && stream.stream_expires_at_ms.is_none() {
430        stream.last_ttl_touch_at_ms = now_ms;
431    }
432}
433
434fn validate_producer_request(producer: Option<&ProducerRequest>) -> Result<(), StreamResponse> {
435    let Some(producer) = producer else {
436        return Ok(());
437    };
438    if producer.producer_id.trim().is_empty() {
439        return Err(StreamResponse::error(
440            StreamErrorCode::InvalidProducer,
441            "producer id must not be empty",
442        ));
443    }
444    const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
445    if producer.producer_epoch > MAX_JS_SAFE_INTEGER {
446        return Err(StreamResponse::error(
447            StreamErrorCode::InvalidProducer,
448            format!(
449                "producer epoch {} exceeds maximum {}",
450                producer.producer_epoch, MAX_JS_SAFE_INTEGER
451            ),
452        ));
453    }
454    if producer.producer_seq > MAX_JS_SAFE_INTEGER {
455        return Err(StreamResponse::error(
456            StreamErrorCode::InvalidProducer,
457            format!(
458                "producer sequence {} exceeds maximum {}",
459                producer.producer_seq, MAX_JS_SAFE_INTEGER
460            ),
461        ));
462    }
463    Ok(())
464}
465
466fn validate_external_payload_ref(payload: &ExternalPayloadRef) -> Result<(), StreamResponse> {
467    if payload.s3_path.trim().is_empty() {
468        return Err(StreamResponse::error(
469            StreamErrorCode::InvalidColdFlush,
470            "external payload S3 path must not be empty",
471        ));
472    }
473    if payload.payload_len == 0 {
474        return Err(StreamResponse::error(
475            StreamErrorCode::EmptyAppend,
476            "external payload length must be greater than zero",
477        ));
478    }
479    if payload.object_size < payload.payload_len {
480        return Err(StreamResponse::error(
481            StreamErrorCode::InvalidColdFlush,
482            "external payload object size must cover payload length",
483        ));
484    }
485    Ok(())
486}
487
488fn build_record_index(
489    content_type: &str,
490    payload_len: u64,
491    record_ends: &[u64],
492) -> Result<Option<StreamRecordIndex>, StreamResponse> {
493    if !is_json_record_content_type(content_type) {
494        return record_ends.is_empty().then_some(None).ok_or_else(|| {
495            StreamResponse::error(
496                StreamErrorCode::InvalidRecordBoundaries,
497                "record boundaries are only valid for application/json streams",
498            )
499        });
500    }
501    if payload_len > 0 && record_ends.is_empty() {
502        // Pre-extension WAL and snapshot entries have no boundary metadata.
503        // Keep those JSON streams readable without activating coordinates
504        // part-way through their history.
505        return Ok(None);
506    }
507    let mut index = StreamRecordIndex::new();
508    index
509        .append_relative_ends(0, payload_len, record_ends)
510        .map_err(|_| {
511            StreamResponse::error(
512                StreamErrorCode::InvalidRecordBoundaries,
513                "record boundaries do not match the canonical JSON payload",
514            )
515        })?;
516    Ok(Some(index))
517}
518
519fn prepare_record_append(
520    current: Option<&StreamRecordIndex>,
521    json_stream: bool,
522    base_offset: u64,
523    payload_len: u64,
524    record_ends: &[u64],
525) -> Result<Option<crate::PreparedRecordAppend>, StreamResponse> {
526    let Some(current) = current else {
527        if json_stream {
528            return Ok(None);
529        }
530        return record_ends.is_empty().then_some(None).ok_or_else(|| {
531            StreamResponse::error(
532                StreamErrorCode::InvalidRecordBoundaries,
533                "binary streams cannot carry JSON record boundaries",
534            )
535        });
536    };
537    current
538        .prepare_append(base_offset, payload_len, record_ends)
539        .map(Some)
540        .map_err(|_| {
541            StreamResponse::error(
542                StreamErrorCode::InvalidRecordBoundaries,
543                "record boundaries do not match the canonical JSON payload",
544            )
545        })
546}
547
548fn compare_stream_ids(left: &BucketStreamId, right: &BucketStreamId) -> std::cmp::Ordering {
549    left.bucket_id
550        .cmp(&right.bucket_id)
551        .then_with(|| left.stream_id.cmp(&right.stream_id))
552}
553
554#[cfg(test)]
555mod tests;