1use 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 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;