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::BucketQuota;
39use crate::model::BucketQuotaSnapshot;
40use crate::model::BucketUsage;
41use crate::model::BucketUsageSnapshot;
42use crate::model::COLD_INDEX_PAGE_SPAN_BYTES;
43use crate::model::ColdChunkRef;
44use crate::model::ColdFlushCandidate;
45use crate::model::ColdGcEntry;
46use crate::model::ColdGcTarget;
47use crate::model::ExternalPayloadRef;
48use crate::model::HotPayloadSegment;
49use crate::model::MAX_STREAM_ATTRS_BYTES;
50use crate::model::ObjectPayloadRef;
51use crate::model::ProducerAppendRecord;
52use crate::model::ProducerReceipt;
53use crate::model::ProducerRequest;
54use crate::model::ProducerSnapshot;
55use crate::model::ProducerState;
56use crate::model::StreamAttrs;
57use crate::model::StreamBatchAppend;
58use crate::model::StreamBatchAppendItem;
59use crate::model::StreamBootstrapPlan;
60use crate::model::StreamMessageRecord;
61use crate::model::StreamMetadata;
62use crate::model::StreamRead;
63use crate::model::StreamReadColdIndexSegment;
64use crate::model::StreamReadObjectSegment;
65use crate::model::StreamReadPlan;
66use crate::model::StreamReadSegment;
67use crate::model::StreamStatus;
68use crate::model::StreamVisibleSnapshot;
69use crate::record_index::StreamRecordIndex;
70use crate::record_index::canonical_json_record_ends;
71use crate::record_index::is_json_record_content_type;
72use crate::response::StreamErrorCode;
73use crate::response::StreamErrorContext;
74use crate::response::StreamResponse;
75use crate::snapshot::StreamSnapshot;
76use crate::snapshot::StreamSnapshotEntry;
77use crate::snapshot::StreamSnapshotError;
78use crate::validate::validate_bucket_id;
79use crate::validate::validate_stream_id;
80
81mod append;
82mod cold;
83mod cold_gc;
84mod cold_state;
85mod hot_buffer;
86mod lifecycle;
87mod persist;
88mod query;
89mod registry;
90mod ttl;
91
92const TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE: usize = 256;
93
94new_key_type! {
95 struct StreamKey;
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct StreamStateMachine {
100 buckets: HashSet<String>,
101 registry: StreamRegistry,
102 hot_payload_bytes: u64,
105 cold_gc: ColdGcQueue,
106 shared_cold_object_refs: HashMap<String, u64>,
109 bucket_usage: HashMap<String, BucketUsage>,
113 bucket_quotas: HashMap<String, BucketQuota>,
116}
117
118#[derive(Debug, Clone)]
119struct StreamSlot {
120 metadata: StreamMetadata,
121 attrs: Option<StreamAttrs>,
122 hot_buffer: HotBuffer,
123 cold: StreamColdState,
124 message_records: Vec<StreamMessageRecord>,
125 record_index: Option<StreamRecordIndex>,
126 integrity: StreamIntegrity,
127 retained_offset: u64,
128 visible_snapshot: Option<StreamVisibleSnapshot>,
129 producers: HashMap<String, ProducerState>,
130}
131
132impl StreamStateMachine {
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 fn stream_slot(&self, stream_id: &BucketStreamId) -> Option<&StreamSlot> {
138 self.registry.slot(stream_id)
139 }
140
141 fn stream_slot_mut(&mut self, stream_id: &BucketStreamId) -> Option<&mut StreamSlot> {
142 self.registry.slot_mut(stream_id)
143 }
144
145 fn stream_metadata(&self, stream_id: &BucketStreamId) -> Option<&StreamMetadata> {
146 self.registry.metadata(stream_id)
147 }
148
149 fn retain_shared_cold_object(&mut self, path: &str) {
150 let refs = self
151 .shared_cold_object_refs
152 .entry(path.to_owned())
153 .or_default();
154 *refs = refs.saturating_add(1);
155 }
156
157 fn release_shared_cold_objects(
158 &mut self,
159 paths: impl IntoIterator<Item = String>,
160 not_before_ms: u64,
161 ) {
162 let mut reclaim = Vec::new();
163 for path in paths {
164 let Some(refs) = self.shared_cold_object_refs.get_mut(&path) else {
165 continue;
166 };
167 *refs = refs.saturating_sub(1);
168 if *refs == 0 {
169 self.shared_cold_object_refs.remove(&path);
170 reclaim.push(path);
171 }
172 }
173 if !reclaim.is_empty() {
174 self.cold_gc
175 .enqueue_after(ColdGcTarget::Paths(reclaim), not_before_ms);
176 }
177 }
178
179 fn stream_metadata_mut(&mut self, stream_id: &BucketStreamId) -> Option<&mut StreamMetadata> {
180 self.registry.metadata_mut(stream_id)
181 }
182
183 fn insert_stream_slot(&mut self, slot: StreamSlot) -> Option<StreamKey> {
184 let hot_payload_bytes = u64::try_from(slot.hot_buffer.len()).expect("payload len fits u64");
185 let key = self.registry.insert(slot)?;
186 self.hot_payload_bytes = self.hot_payload_bytes.saturating_add(hot_payload_bytes);
187 Some(key)
188 }
189
190 fn add_hot_payload_bytes(&mut self, bytes: u64) {
191 self.hot_payload_bytes = self.hot_payload_bytes.saturating_add(bytes);
192 }
193
194 fn remove_hot_payload_bytes(&mut self, bytes: u64) {
195 self.hot_payload_bytes = self.hot_payload_bytes.saturating_sub(bytes);
196 }
197
198 fn appended_record_count(record_ends: &[u64], payload_len: u64) -> u64 {
202 if !record_ends.is_empty() {
203 record_ends.len() as u64
204 } else if payload_len > 0 {
205 1
206 } else {
207 0
208 }
209 }
210
211 fn usage_mut(&mut self, bucket_id: &str) -> &mut BucketUsage {
212 self.bucket_usage.entry(bucket_id.to_owned()).or_default()
213 }
214
215 fn usage_on_append(&mut self, bucket_id: &str, payload_bytes: u64, records: u64) {
218 let usage = self.usage_mut(bucket_id);
219 usage.committed_append_bytes = usage.committed_append_bytes.saturating_add(payload_bytes);
220 usage.committed_records = usage.committed_records.saturating_add(records);
221 usage.retained_bytes = usage.retained_bytes.saturating_add(payload_bytes);
222 }
223
224 fn usage_on_stream_created(&mut self, bucket_id: &str, initial_bytes: u64, records: u64) {
227 let usage = self.usage_mut(bucket_id);
228 usage.stream_count = usage.stream_count.saturating_add(1);
229 usage.committed_append_bytes = usage.committed_append_bytes.saturating_add(initial_bytes);
230 usage.committed_records = usage.committed_records.saturating_add(records);
231 usage.retained_bytes = usage.retained_bytes.saturating_add(initial_bytes);
232 }
233
234 fn usage_on_retention(&mut self, bucket_id: &str, reclaimed_bytes: u64) {
236 let usage = self.usage_mut(bucket_id);
237 usage.retained_bytes = usage.retained_bytes.saturating_sub(reclaimed_bytes);
238 }
239
240 fn usage_on_stream_removed(&mut self, bucket_id: &str, retained_bytes: u64) {
243 let usage = self.usage_mut(bucket_id);
244 usage.stream_count = usage.stream_count.saturating_sub(1);
245 usage.retained_bytes = usage.retained_bytes.saturating_sub(retained_bytes);
246 }
247
248 fn set_bucket_quota(
251 &mut self,
252 bucket_id: String,
253 max_streams: Option<u64>,
254 max_retained_bytes: Option<u64>,
255 ) -> StreamResponse {
256 if let Err(message) = validate_bucket_id(&bucket_id) {
257 return StreamResponse::error(StreamErrorCode::InvalidBucketId, message);
258 }
259 let quota = BucketQuota {
260 max_streams,
261 max_retained_bytes,
262 };
263 if quota.is_unlimited() {
264 self.bucket_quotas.remove(&bucket_id);
265 } else {
266 self.bucket_quotas.insert(bucket_id.clone(), quota);
267 }
268 StreamResponse::BucketQuotaSet { bucket_id }
269 }
270
271 fn check_create_quota(
276 &self,
277 bucket_id: &str,
278 initial_bytes: u64,
279 ) -> Result<(), StreamResponse> {
280 let Some(quota) = self.bucket_quotas.get(bucket_id) else {
281 return Ok(());
282 };
283 let usage = self
284 .bucket_usage
285 .get(bucket_id)
286 .copied()
287 .unwrap_or_default();
288 if let Some(max_streams) = quota.max_streams
289 && usage.stream_count >= max_streams
290 {
291 return Err(StreamResponse::error(
292 StreamErrorCode::QuotaExceeded,
293 format!(
294 "bucket '{bucket_id}' stream-count quota exceeded in this group ({max_streams} max)"
295 ),
296 ));
297 }
298 self.check_retained_quota_inner(bucket_id, quota, &usage, initial_bytes)
299 }
300
301 fn check_append_quota(
306 &self,
307 bucket_id: &str,
308 payload_bytes: u64,
309 ) -> Result<(), StreamResponse> {
310 if payload_bytes == 0 {
311 return Ok(());
312 }
313 let Some(quota) = self.bucket_quotas.get(bucket_id) else {
314 return Ok(());
315 };
316 let usage = self
317 .bucket_usage
318 .get(bucket_id)
319 .copied()
320 .unwrap_or_default();
321 self.check_retained_quota_inner(bucket_id, quota, &usage, payload_bytes)
322 }
323
324 fn check_retained_quota_inner(
325 &self,
326 bucket_id: &str,
327 quota: &BucketQuota,
328 usage: &BucketUsage,
329 incoming_bytes: u64,
330 ) -> Result<(), StreamResponse> {
331 if let Some(max_retained) = quota.max_retained_bytes
332 && usage.retained_bytes.saturating_add(incoming_bytes) > max_retained
333 {
334 return Err(StreamResponse::error(
335 StreamErrorCode::QuotaExceeded,
336 format!(
337 "bucket '{bucket_id}' retained-bytes quota exceeded in this group ({max_retained} max)"
338 ),
339 ));
340 }
341 Ok(())
342 }
343
344 pub fn bucket_quota_report(&self) -> Vec<BucketQuotaSnapshot> {
347 let mut report = self
348 .bucket_quotas
349 .iter()
350 .map(|(bucket_id, quota)| BucketQuotaSnapshot {
351 bucket_id: bucket_id.clone(),
352 quota: *quota,
353 })
354 .collect::<Vec<_>>();
355 report.sort_by(|left, right| left.bucket_id.cmp(&right.bucket_id));
356 report
357 }
358
359 pub fn bucket_usage_report(&self) -> Vec<BucketUsageSnapshot> {
362 let mut report = self
363 .bucket_usage
364 .iter()
365 .map(|(bucket_id, usage)| BucketUsageSnapshot {
366 bucket_id: bucket_id.clone(),
367 usage: *usage,
368 })
369 .collect::<Vec<_>>();
370 report.sort_by(|left, right| left.bucket_id.cmp(&right.bucket_id));
371 report
372 }
373
374 fn refresh_ttl_entry(&mut self, stream_id: &BucketStreamId) {
375 self.registry.refresh_ttl(stream_id);
376 }
377
378 fn message_records_for_append(
379 start_offset: u64,
380 end_offset: u64,
381 record_ends: &[u64],
382 ) -> Vec<StreamMessageRecord> {
383 if record_ends.is_empty() {
384 return (start_offset < end_offset)
385 .then_some(StreamMessageRecord {
386 start_offset,
387 end_offset,
388 })
389 .into_iter()
390 .collect();
391 }
392 let mut start = start_offset;
393 record_ends
394 .iter()
395 .map(|relative_end| {
396 let end = start_offset.saturating_add(*relative_end);
397 let record = StreamMessageRecord {
398 start_offset: start,
399 end_offset: end,
400 };
401 start = end;
402 record
403 })
404 .collect()
405 }
406
407 pub fn apply(&mut self, command: StreamCommand) -> StreamResponse {
408 match command {
409 StreamCommand::CreateBucket { bucket_id } => self.create_bucket(bucket_id),
410 StreamCommand::DeleteBucket { bucket_id } => self.delete_bucket(&bucket_id),
411 StreamCommand::CreateStream {
412 stream_id,
413 content_type,
414 initial_payload,
415 close_after,
416 stream_seq,
417 producer,
418 stream_ttl_seconds,
419 stream_expires_at_ms,
420 attrs,
421 now_ms,
422 } => {
423 let response = match canonical_json_record_ends(&content_type, &initial_payload) {
424 Ok(record_ends) => self.create_stream(CreateStreamInput {
425 stream_id,
426 content_type,
427 initial_payload: initial_payload.into(),
428 record_ends,
429 close_after,
430 stream_seq,
431 producer,
432 stream_ttl_seconds,
433 stream_expires_at_ms,
434 attrs,
435 now_ms,
436 }),
437 Err(_) => StreamResponse::error(
438 StreamErrorCode::InvalidRecordBoundaries,
439 "application/json initial payload must use canonical newline boundaries",
440 ),
441 };
442 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
443 response
444 }
445 StreamCommand::CreateExternal {
446 stream_id,
447 content_type,
448 initial_payload,
449 record_ends,
450 close_after,
451 stream_seq,
452 producer,
453 stream_ttl_seconds,
454 stream_expires_at_ms,
455 attrs,
456 now_ms,
457 } => {
458 let response = self.create_external_stream(CreateExternalStreamInput {
459 stream_id,
460 content_type,
461 initial_payload,
462 record_ends,
463 close_after,
464 stream_seq,
465 producer,
466 stream_ttl_seconds,
467 stream_expires_at_ms,
468 attrs,
469 now_ms,
470 });
471 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
472 response
473 }
474 StreamCommand::Append {
475 stream_id,
476 content_type,
477 payload,
478 close_after,
479 stream_seq,
480 producer,
481 now_ms,
482 record_match,
483 } => {
484 let response = self.append_borrowed(AppendStreamInput {
485 stream_id,
486 content_type: content_type.as_deref(),
487 payload: &payload,
488 close_after,
489 stream_seq,
490 producer,
491 now_ms,
492 record_match,
493 });
494 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
495 response
496 }
497 StreamCommand::AppendExternal {
498 stream_id,
499 content_type,
500 payload,
501 record_ends,
502 close_after,
503 stream_seq,
504 producer,
505 now_ms,
506 record_match,
507 } => {
508 let response = self.append_external(AppendExternalInput {
509 stream_id,
510 content_type: content_type.as_deref(),
511 payload,
512 record_ends,
513 close_after,
514 stream_seq,
515 producer,
516 now_ms,
517 record_match,
518 });
519 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
520 response
521 }
522 StreamCommand::AppendBatch {
523 stream_id,
524 content_type,
525 payloads,
526 producer,
527 now_ms,
528 } => {
529 let response = match self.append_batch_borrowed(
530 stream_id,
531 content_type.as_deref(),
532 &payloads.iter().map(Bytes::as_ref).collect::<Vec<_>>(),
533 producer,
534 now_ms,
535 ) {
536 Ok(batch) => batch
537 .items
538 .last()
539 .map(|item| StreamResponse::Appended {
540 offset: item.offset,
541 next_offset: item.next_offset,
542 closed: item.closed,
543 deduplicated: item.deduplicated,
544 producer: None,
545 })
546 .unwrap_or_else(|| {
547 StreamResponse::error(
548 StreamErrorCode::EmptyAppend,
549 "append batch must contain at least one payload",
550 )
551 }),
552 Err(response) => response,
553 };
554 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
555 response
556 }
557 StreamCommand::PublishSnapshot {
558 stream_id,
559 snapshot_offset,
560 content_type,
561 payload,
562 expected_digest,
563 now_ms,
564 } => {
565 let response = self.publish_snapshot(
566 stream_id,
567 snapshot_offset,
568 content_type,
569 payload.into(),
570 expected_digest,
571 now_ms,
572 );
573 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
574 response
575 }
576 StreamCommand::AdvanceRetention {
577 stream_id,
578 retained_offset,
579 now_ms,
580 } => {
581 let response = self.advance_retention(stream_id, retained_offset, now_ms);
582 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
583 response
584 }
585 StreamCommand::TouchStreamAccess {
586 stream_id,
587 now_ms,
588 renew_ttl,
589 } => {
590 let response = self.touch_stream_access(&stream_id, now_ms, renew_ttl);
591 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
592 response
593 }
594 StreamCommand::UpdateStreamAttrs {
595 stream_id,
596 attrs,
597 now_ms,
598 } => {
599 let response = self.update_stream_attrs(&stream_id, attrs, now_ms);
600 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
601 response
602 }
603 StreamCommand::FlushCold { stream_id, chunk } => self.flush_cold(stream_id, chunk),
604 StreamCommand::CompactCold {
605 stream_id,
606 old_chunks,
607 replacement,
608 gc_not_before_ms,
609 } => self.compact_cold(stream_id, old_chunks, replacement, gc_not_before_ms),
610 StreamCommand::Close {
611 stream_id,
612 stream_seq,
613 producer,
614 now_ms,
615 } => {
616 let response = self.close(stream_id, stream_seq, producer, now_ms);
617 self.sweep_expired_streams(now_ms, TTL_EXPIRY_SWEEP_MAX_STREAMS_PER_WRITE);
618 response
619 }
620 StreamCommand::DeleteStream { stream_id } => self.delete_stream(&stream_id),
621 StreamCommand::PurgeBucket { bucket_id } => self.purge_bucket(&bucket_id),
622 StreamCommand::AckColdGc { up_to_seq } => self.ack_cold_gc(up_to_seq),
623 StreamCommand::ImportSnapshot { snapshot } => self.import_snapshot(*snapshot),
624 StreamCommand::SetBucketQuota {
625 bucket_id,
626 max_streams,
627 max_retained_bytes,
628 } => self.set_bucket_quota(bucket_id, max_streams, max_retained_bytes),
629 }
630 }
631}
632
633#[derive(Debug)]
634struct CreateStreamInput {
635 stream_id: BucketStreamId,
636 content_type: String,
637 initial_payload: Vec<u8>,
638 record_ends: Vec<u64>,
639 close_after: bool,
640 stream_seq: Option<String>,
641 producer: Option<ProducerRequest>,
642 stream_ttl_seconds: Option<u64>,
643 stream_expires_at_ms: Option<u64>,
644 attrs: Option<StreamAttrs>,
645 now_ms: u64,
646}
647
648#[derive(Debug)]
649struct CreateExternalStreamInput {
650 stream_id: BucketStreamId,
651 content_type: String,
652 initial_payload: ExternalPayloadRef,
653 record_ends: Vec<u64>,
654 close_after: bool,
655 stream_seq: Option<String>,
656 producer: Option<ProducerRequest>,
657 stream_ttl_seconds: Option<u64>,
658 stream_expires_at_ms: Option<u64>,
659 attrs: Option<StreamAttrs>,
660 now_ms: u64,
661}
662
663impl CreateStreamInput {
664 fn initial_len(&self) -> u64 {
665 u64::try_from(self.initial_payload.len()).expect("payload len fits u64")
666 }
667}
668
669fn normalize_stream_attrs(attrs: Option<StreamAttrs>) -> Option<StreamAttrs> {
670 attrs.filter(|attrs| !attrs.is_empty())
671}
672
673fn stream_expiry_at_ms(stream: &StreamMetadata) -> Option<u64> {
674 if let Some(expires_at_ms) = stream.stream_expires_at_ms {
675 return Some(expires_at_ms);
676 }
677 stream.stream_ttl_seconds.map(|ttl_seconds| {
678 stream
679 .last_ttl_touch_at_ms
680 .saturating_add(ttl_seconds.saturating_mul(1000))
681 })
682}
683
684fn stream_is_expired(stream: &StreamMetadata, now_ms: u64) -> bool {
685 stream_expiry_at_ms(stream).is_some_and(|expires_at_ms| now_ms >= expires_at_ms)
686}
687
688fn stream_ttl_renewal_due(stream: &StreamMetadata, now_ms: u64) -> bool {
689 let Some(ttl_seconds) = stream.stream_ttl_seconds else {
690 return false;
691 };
692 if stream.stream_expires_at_ms.is_some() {
693 return false;
694 }
695 let ttl_ms = ttl_seconds.saturating_mul(1000);
696 let renewal_interval_ms = ttl_ms.div_ceil(4).max(1);
697 now_ms.saturating_sub(stream.last_ttl_touch_at_ms) >= renewal_interval_ms
698}
699
700fn renew_stream_ttl(stream: &mut StreamMetadata, now_ms: u64) {
701 if stream.stream_ttl_seconds.is_some() && stream.stream_expires_at_ms.is_none() {
702 stream.last_ttl_touch_at_ms = now_ms;
703 }
704}
705
706fn validate_producer_request(producer: Option<&ProducerRequest>) -> Result<(), StreamResponse> {
707 let Some(producer) = producer else {
708 return Ok(());
709 };
710 if producer.producer_id.trim().is_empty() {
711 return Err(StreamResponse::error(
712 StreamErrorCode::InvalidProducer,
713 "producer id must not be empty",
714 ));
715 }
716 const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
717 if producer.producer_epoch > MAX_JS_SAFE_INTEGER {
718 return Err(StreamResponse::error(
719 StreamErrorCode::InvalidProducer,
720 format!(
721 "producer epoch {} exceeds maximum {}",
722 producer.producer_epoch, MAX_JS_SAFE_INTEGER
723 ),
724 ));
725 }
726 if producer.producer_seq > MAX_JS_SAFE_INTEGER {
727 return Err(StreamResponse::error(
728 StreamErrorCode::InvalidProducer,
729 format!(
730 "producer sequence {} exceeds maximum {}",
731 producer.producer_seq, MAX_JS_SAFE_INTEGER
732 ),
733 ));
734 }
735 Ok(())
736}
737
738fn validate_external_payload_ref(payload: &ExternalPayloadRef) -> Result<(), StreamResponse> {
739 if payload.s3_path.trim().is_empty() {
740 return Err(StreamResponse::error(
741 StreamErrorCode::InvalidColdFlush,
742 "external payload S3 path must not be empty",
743 ));
744 }
745 if payload.payload_len == 0 {
746 return Err(StreamResponse::error(
747 StreamErrorCode::EmptyAppend,
748 "external payload length must be greater than zero",
749 ));
750 }
751 if payload.object_size < payload.payload_len {
752 return Err(StreamResponse::error(
753 StreamErrorCode::InvalidColdFlush,
754 "external payload object size must cover payload length",
755 ));
756 }
757 Ok(())
758}
759
760fn build_record_index(
761 content_type: &str,
762 payload_len: u64,
763 record_ends: &[u64],
764) -> Result<Option<StreamRecordIndex>, StreamResponse> {
765 if !is_json_record_content_type(content_type) {
766 return record_ends.is_empty().then_some(None).ok_or_else(|| {
767 StreamResponse::error(
768 StreamErrorCode::InvalidRecordBoundaries,
769 "record boundaries are only valid for application/json streams",
770 )
771 });
772 }
773 if payload_len > 0 && record_ends.is_empty() {
774 return Ok(None);
778 }
779 let mut index = StreamRecordIndex::new();
780 index
781 .append_relative_ends(0, payload_len, record_ends)
782 .map_err(|_| {
783 StreamResponse::error(
784 StreamErrorCode::InvalidRecordBoundaries,
785 "record boundaries do not match the canonical JSON payload",
786 )
787 })?;
788 Ok(Some(index))
789}
790
791fn prepare_record_append(
792 current: Option<&StreamRecordIndex>,
793 json_stream: bool,
794 base_offset: u64,
795 payload_len: u64,
796 record_ends: &[u64],
797) -> Result<Option<crate::PreparedRecordAppend>, StreamResponse> {
798 let Some(current) = current else {
799 if json_stream {
800 return Ok(None);
801 }
802 return record_ends.is_empty().then_some(None).ok_or_else(|| {
803 StreamResponse::error(
804 StreamErrorCode::InvalidRecordBoundaries,
805 "binary streams cannot carry JSON record boundaries",
806 )
807 });
808 };
809 current
810 .prepare_append(base_offset, payload_len, record_ends)
811 .map(Some)
812 .map_err(|_| {
813 StreamResponse::error(
814 StreamErrorCode::InvalidRecordBoundaries,
815 "record boundaries do not match the canonical JSON payload",
816 )
817 })
818}
819
820fn compare_stream_ids(left: &BucketStreamId, right: &BucketStreamId) -> std::cmp::Ordering {
821 left.bucket_id
822 .cmp(&right.bucket_id)
823 .then_with(|| left.stream_id.cmp(&right.stream_id))
824}
825
826fn snapshot_digest(content_type: &str, payload: &[u8]) -> String {
827 let mut hasher = blake3::Hasher::new();
828 hasher.update(&(content_type.len() as u64).to_le_bytes());
829 hasher.update(content_type.as_bytes());
830 hasher.update(payload);
831 hasher.finalize().to_hex().to_string()
832}
833
834#[cfg(test)]
835mod tests;