118
crates/sq-models/src/config.rs
Normal file
118
crates/sq-models/src/config.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::message::TopicName;
|
||||
|
||||
/// Controls when fsync is called on WAL segment files.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SyncPolicy {
|
||||
/// Fsync after every write batch (maximum durability, lower throughput).
|
||||
EveryBatch,
|
||||
/// Fsync at a fixed interval via a background task. Writes go to OS page
|
||||
/// cache immediately. Data written within the interval window is at risk
|
||||
/// if the machine crashes without replication.
|
||||
Interval(Duration),
|
||||
/// Never explicitly fsync. Rely on OS page cache flush + replication.
|
||||
/// Similar to Kafka's default.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Default for SyncPolicy {
|
||||
fn default() -> Self {
|
||||
SyncPolicy::EveryBatch
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the Write-Ahead Log.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WalConfig {
|
||||
/// Maximum segment file size in bytes before rotation (default: 64MB).
|
||||
pub max_segment_bytes: u64,
|
||||
/// Maximum segment age in seconds before rotation (default: 60s).
|
||||
pub max_segment_age_secs: u64,
|
||||
/// Root data directory for WAL files.
|
||||
pub data_dir: PathBuf,
|
||||
/// When to fsync WAL segments (default: EveryBatch).
|
||||
pub sync_policy: SyncPolicy,
|
||||
}
|
||||
|
||||
impl Default for WalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_segment_bytes: 64 * 1024 * 1024, // 64MB
|
||||
max_segment_age_secs: 60,
|
||||
data_dir: PathBuf::from("./data"),
|
||||
sync_policy: SyncPolicy::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a topic.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TopicConfig {
|
||||
pub name: TopicName,
|
||||
/// Number of partitions (default: 1).
|
||||
pub partitions: u32,
|
||||
/// Replication factor across cluster nodes (default: 3).
|
||||
pub replication_factor: u32,
|
||||
}
|
||||
|
||||
impl TopicConfig {
|
||||
pub fn new(name: impl Into<TopicName>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
partitions: 1,
|
||||
replication_factor: 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_partitions(mut self, partitions: u32) -> Self {
|
||||
self.partitions = partitions;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_replication_factor(mut self, factor: u32) -> Self {
|
||||
self.replication_factor = factor;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the cluster node.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NodeConfig {
|
||||
pub node_id: String,
|
||||
pub grpc_host: std::net::SocketAddr,
|
||||
pub http_host: std::net::SocketAddr,
|
||||
pub seeds: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wal_config_defaults() {
|
||||
let config = WalConfig::default();
|
||||
assert_eq!(config.max_segment_bytes, 64 * 1024 * 1024);
|
||||
assert_eq!(config.max_segment_age_secs, 60);
|
||||
assert_eq!(config.data_dir, PathBuf::from("./data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_config_builder() {
|
||||
let config = TopicConfig::new("orders")
|
||||
.with_partitions(4)
|
||||
.with_replication_factor(3);
|
||||
|
||||
assert_eq!(config.name.as_str(), "orders");
|
||||
assert_eq!(config.partitions, 4);
|
||||
assert_eq!(config.replication_factor, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_config_defaults() {
|
||||
let config = TopicConfig::new("events");
|
||||
assert_eq!(config.partitions, 1);
|
||||
assert_eq!(config.replication_factor, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod message;
|
||||
|
||||
pub use config::*;
|
||||
pub use message::*;
|
||||
|
||||
195
crates/sq-models/src/message.rs
Normal file
195
crates/sq-models/src/message.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use std::fmt;
|
||||
|
||||
/// A single message in the queue.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
/// Monotonically increasing within a topic-partition. Assigned by the server.
|
||||
pub offset: u64,
|
||||
/// Topic this message belongs to.
|
||||
pub topic: TopicName,
|
||||
/// Partition within the topic.
|
||||
pub partition: u32,
|
||||
/// Optional partitioning key.
|
||||
pub key: Option<Vec<u8>>,
|
||||
/// The payload.
|
||||
pub value: Vec<u8>,
|
||||
/// User-defined headers (metadata).
|
||||
pub headers: Vec<Header>,
|
||||
/// Server-assigned wall-clock timestamp (millis since epoch).
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// A key-value header attached to a message.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Header {
|
||||
pub key: String,
|
||||
pub value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A topic name wrapper.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TopicName(pub String);
|
||||
|
||||
impl TopicName {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TopicName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for TopicName {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for TopicName {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a closed WAL segment ready for shipping.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClosedSegment {
|
||||
pub path: std::path::PathBuf,
|
||||
pub topic: TopicName,
|
||||
pub partition: u32,
|
||||
pub base_offset: u64,
|
||||
pub end_offset: u64,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_message_construction() {
|
||||
let msg = Message {
|
||||
offset: 42,
|
||||
topic: TopicName::from("orders"),
|
||||
partition: 0,
|
||||
key: Some(b"user-123".to_vec()),
|
||||
value: b"hello world".to_vec(),
|
||||
headers: vec![Header {
|
||||
key: "content-type".to_string(),
|
||||
value: b"text/plain".to_vec(),
|
||||
}],
|
||||
timestamp_ms: 1700000000000,
|
||||
};
|
||||
|
||||
assert_eq!(msg.offset, 42);
|
||||
assert_eq!(msg.topic.as_str(), "orders");
|
||||
assert_eq!(msg.partition, 0);
|
||||
assert_eq!(msg.key.as_deref(), Some(b"user-123".as_slice()));
|
||||
assert_eq!(msg.value, b"hello world");
|
||||
assert_eq!(msg.headers.len(), 1);
|
||||
assert_eq!(msg.headers[0].key, "content-type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_no_key_no_headers() {
|
||||
let msg = Message {
|
||||
offset: 0,
|
||||
topic: TopicName::from("events"),
|
||||
partition: 1,
|
||||
key: None,
|
||||
value: b"payload".to_vec(),
|
||||
headers: vec![],
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
assert!(msg.key.is_none());
|
||||
assert!(msg.headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_clone_eq() {
|
||||
let msg = Message {
|
||||
offset: 1,
|
||||
topic: TopicName::from("test"),
|
||||
partition: 0,
|
||||
key: None,
|
||||
value: b"data".to_vec(),
|
||||
headers: vec![],
|
||||
timestamp_ms: 100,
|
||||
};
|
||||
|
||||
let cloned = msg.clone();
|
||||
assert_eq!(msg, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_name_ordering() {
|
||||
let a = TopicName::from("alpha");
|
||||
let b = TopicName::from("beta");
|
||||
assert!(a < b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_topic_name_display() {
|
||||
let t = TopicName::from("my-topic");
|
||||
assert_eq!(format!("{t}"), "my-topic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_empty_value() {
|
||||
let msg = Message {
|
||||
offset: 0,
|
||||
topic: TopicName::from("t"),
|
||||
partition: 0,
|
||||
key: None,
|
||||
value: vec![],
|
||||
headers: vec![],
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
assert!(msg.value.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_large_value() {
|
||||
let large = vec![0xFFu8; 1024 * 1024]; // 1MB
|
||||
let msg = Message {
|
||||
offset: 0,
|
||||
topic: TopicName::from("t"),
|
||||
partition: 0,
|
||||
key: None,
|
||||
value: large.clone(),
|
||||
headers: vec![],
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
assert_eq!(msg.value.len(), 1024 * 1024);
|
||||
assert_eq!(msg.value, large);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_many_headers() {
|
||||
let headers: Vec<Header> = (0..100)
|
||||
.map(|i| Header {
|
||||
key: format!("header-{i}"),
|
||||
value: format!("value-{i}").into_bytes(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let msg = Message {
|
||||
offset: 0,
|
||||
topic: TopicName::from("t"),
|
||||
partition: 0,
|
||||
key: None,
|
||||
value: vec![],
|
||||
headers,
|
||||
timestamp_ms: 0,
|
||||
};
|
||||
|
||||
assert_eq!(msg.headers.len(), 100);
|
||||
assert_eq!(msg.headers[99].key, "header-99");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user