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::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    /// Group-wide hot payload gauge. Kept incrementally so append admission
103    /// and responses do not scan every stream in the group.
104    hot_payload_bytes: u64,
105    cold_gc: ColdGcQueue,
106    /// Live logical references to group-scoped shared cold objects. This is
107    /// derived from per-stream cold refs when snapshots are restored.
108    shared_cold_object_refs: HashMap<String, u64>,
109    /// Per-bucket committed usage for this group; see [`BucketUsage`] for the
110    /// monotonic-versus-gauge split. Mutated only by the accounting helpers
111    /// below so every counter change stays deterministic and auditable.
112    bucket_usage: HashMap<String, BucketUsage>,
113    /// Per-bucket data-plane quota backstops enforced against this group's
114    /// local counters; see [`BucketQuota`] for the enforcement semantics.
115    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    /// Records committed by one accepted append. JSON streams provide exact
199    /// canonical boundaries; a byte stream counts one message record per
200    /// non-empty append.
201    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    /// One accepted (non-deduplicated) append: monotonic counters grow and
216    /// the retained gauge grows by the same bytes.
217    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    /// A newly created stream, including any initial payload it was created
225    /// with.
226    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    /// Destructive retention reclaimed `reclaimed_bytes` of logical prefix.
235    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    /// A stream left the registry (delete or TTL expiry); its remaining
241    /// retained bytes leave the gauge with it.
242    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    /// Sets or clears the quota record for one bucket. Both limits `None`
249    /// removes the record so cleared quotas leave no residue in snapshots.
250    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    /// Data-plane backstop for stream creation: this group's local stream
272    /// count and the incoming initial payload must fit under the bucket's
273    /// quota. Runs after the idempotent already-exists paths so replays of
274    /// accepted creates never fail retroactively.
275    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    /// Data-plane backstop for appends: the payload must fit under the
302    /// bucket's retained-bytes quota against this group's local gauge.
303    /// Producer-deduplicated retries return before this check, so an
304    /// accepted append replay can never fail retroactively.
305    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    /// Current per-bucket quotas for this group, sorted for deterministic
345    /// output.
346    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    /// Current per-bucket usage for this group, sorted for deterministic
360    /// output.
361    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        // Pre-extension WAL and snapshot entries have no boundary metadata.
775        // Keep those JSON streams readable without activating coordinates
776        // part-way through their history.
777        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;