1use std::fs::{File, OpenOptions};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use crate::branch::BranchCatalog;
10use crate::stream::StreamCatalog;
11use crate::{Result, SalamanderError};
12
13const MAGIC: &[u8; 8] = b"SLMRETN1";
14pub(crate) const FORMAT_VERSION: u32 = 5;
15const MAX_ANCHOR_BYTES: usize = 256 * 1024 * 1024;
16pub const MAX_BOOTSTRAP_BYTES: usize = 64 * 1024 * 1024;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RetentionAnchorInfo {
22 pub format_version: u32,
24 pub database_id: [u8; 16],
26 pub floor: u64,
28 pub head: u64,
30 pub bytes: u64,
32 pub checksum: u32,
34 pub projection_checkpoints: usize,
36 pub branch_bootstraps: usize,
38 pub consumer_bootstraps: usize,
40 pub bootstrap_bytes: u64,
42 pub system_records: usize,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct RetentionProjectionCoverage {
49 pub name: String,
51 pub descriptor_fingerprint: [u8; 16],
53 pub branch_id: [u8; 16],
55 pub cursor: u64,
57 pub snapshot_ids: Vec<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct RetentionBranchBootstrap {
64 pub branch_id: [u8; 16],
66 pub floor: u64,
68 pub checksum: u32,
70 pub checkpoint: Vec<u8>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct RetentionConsumerBootstrap {
77 pub consumer_id: String,
79 pub floor: u64,
81 pub checksum: u32,
83 pub checkpoint: Vec<u8>,
85 #[serde(default)]
87 pub checkpoint_id: [u8; 16],
88 #[serde(default)]
90 pub scope: RetentionFeedScope,
91 #[serde(default = "default_bootstrap_codec")]
93 pub codec: String,
94 #[serde(default = "default_bootstrap_codec_version")]
96 pub codec_version: u32,
97}
98
99fn default_bootstrap_codec() -> String {
100 "opaque".into()
101}
102
103fn default_bootstrap_codec_version() -> u32 {
104 1
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct RetentionFeedScope {
110 pub branches: Vec<[u8; 16]>,
112 pub streams: Vec<[u8; 16]>,
114 pub event_types: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub(crate) struct AnchoredSystemRecord {
120 pub event_type: String,
121 pub payload: Vec<u8>,
122}
123
124#[derive(Serialize, Deserialize)]
125pub(crate) struct CoreRetentionAnchor {
126 pub format_version: u32,
127 pub database_id: [u8; 16],
128 pub floor: u64,
129 pub head: u64,
130 pub stream_catalog: StreamCatalog,
131 pub branch_catalog: BranchCatalog,
132 #[serde(default)]
133 pub projection_coverage: Vec<RetentionProjectionCoverage>,
134 #[serde(default)]
135 pub branch_bootstraps: Vec<RetentionBranchBootstrap>,
136 #[serde(default)]
137 pub consumer_bootstraps: Vec<RetentionConsumerBootstrap>,
138 #[serde(default)]
139 pub system_records: Vec<AnchoredSystemRecord>,
140}
141
142pub(crate) fn publish(root: &Path, anchor: CoreRetentionAnchor) -> Result<RetentionAnchorInfo> {
143 validate_bootstraps(
144 anchor.floor,
145 &anchor.branch_bootstraps,
146 &anchor.consumer_bootstraps,
147 )?;
148 if let Ok(Some((existing, info))) = load(root) {
149 if validate_identity(&existing, anchor.database_id, anchor.floor, anchor.head).is_ok()
150 && existing.projection_coverage == anchor.projection_coverage
151 && existing.branch_bootstraps == anchor.branch_bootstraps
152 && existing.consumer_bootstraps == anchor.consumer_bootstraps
153 {
154 return Ok(info);
155 }
156 }
157 let payload = bincode::serialize(&anchor)
158 .map_err(|error| SalamanderError::Serialization(error.to_string()))?;
159 if payload.len() > MAX_ANCHOR_BYTES {
160 return Err(SalamanderError::ResourceLimit {
161 resource: "retention anchor bytes",
162 actual: payload.len() as u64,
163 maximum: MAX_ANCHOR_BYTES as u64,
164 });
165 }
166 let checksum = crc32c::crc32c(&payload);
167 let mut encoded = Vec::with_capacity(16 + payload.len());
168 encoded.extend_from_slice(MAGIC);
169 encoded.extend_from_slice(&(payload.len() as u32).to_le_bytes());
170 encoded.extend_from_slice(&checksum.to_le_bytes());
171 encoded.extend_from_slice(&payload);
172
173 let dir = anchor_dir(root);
174 std::fs::create_dir_all(&dir)?;
175 let temporary = dir.join("core.anchor.tmp");
176 crash_point("before_anchor_publish");
177 {
178 let mut file = OpenOptions::new()
179 .create(true)
180 .truncate(true)
181 .write(true)
182 .open(&temporary)?;
183 file.write_all(&encoded)?;
184 file.sync_all()?;
185 }
186 crash_point("after_anchor_fsync");
187 let (verified, info) = decode(&temporary)?;
188 validate_identity(&verified, anchor.database_id, anchor.floor, anchor.head)?;
189 if anchor_path(root).exists() {
190 std::fs::remove_file(anchor_path(root))?;
191 }
192 std::fs::rename(&temporary, anchor_path(root))?;
193 sync_dir(&dir)?;
194 crash_point("after_anchor_publish");
195 Ok(info)
196}
197
198pub(crate) fn crash_point(name: &str) {
199 if std::env::var_os("SALAMANDER_RETENTION_CRASH_AT").is_some_and(|value| value == name) {
200 std::process::abort();
201 }
202}
203
204pub(crate) fn load(root: &Path) -> Result<Option<(CoreRetentionAnchor, RetentionAnchorInfo)>> {
205 let path = anchor_path(root);
206 match decode(&path) {
207 Ok(value) => Ok(Some(value)),
208 Err(SalamanderError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
209 Err(error) => Err(error),
210 }
211}
212
213pub(crate) fn validate_identity(
214 anchor: &CoreRetentionAnchor,
215 database_id: [u8; 16],
216 floor: u64,
217 head: u64,
218) -> Result<()> {
219 if anchor.format_version != FORMAT_VERSION {
220 return Err(corrupt(format!(
221 "unsupported retention anchor format {}",
222 anchor.format_version
223 )));
224 }
225 if anchor.database_id != database_id {
226 return Err(corrupt("retention anchor belongs to another database"));
227 }
228 if anchor.floor != floor {
229 return Err(corrupt(format!(
230 "retention anchor floor {} does not match required floor {floor}",
231 anchor.floor
232 )));
233 }
234 if anchor.head != head {
235 return Err(corrupt(format!(
236 "retention anchor head {} does not match durable head {head}",
237 anchor.head
238 )));
239 }
240 Ok(())
241}
242
243fn decode(path: &Path) -> Result<(CoreRetentionAnchor, RetentionAnchorInfo)> {
244 let length = std::fs::metadata(path)?.len() as usize;
245 if !(16..=16 + MAX_ANCHOR_BYTES).contains(&length) {
246 return Err(corrupt("retention anchor length is invalid"));
247 }
248 let mut file = File::open(path)?;
249 let mut prefix = [0u8; 16];
250 file.read_exact(&mut prefix)?;
251 if &prefix[..8] != MAGIC {
252 return Err(corrupt("retention anchor magic is invalid"));
253 }
254 let payload_len = u32::from_le_bytes(prefix[8..12].try_into().unwrap()) as usize;
255 if payload_len == 0 || payload_len > MAX_ANCHOR_BYTES || payload_len + 16 != length {
256 return Err(corrupt("retention anchor payload length is invalid"));
257 }
258 let expected_checksum = u32::from_le_bytes(prefix[12..16].try_into().unwrap());
259 let mut payload = vec![0; payload_len];
260 file.read_exact(&mut payload)?;
261 if crc32c::crc32c(&payload) != expected_checksum {
262 return Err(corrupt("retention anchor checksum mismatch"));
263 }
264 let anchor: CoreRetentionAnchor = bincode::deserialize(&payload)
265 .map_err(|error| corrupt(format!("retention anchor decode: {error}")))?;
266 validate_bootstraps(
267 anchor.floor,
268 &anchor.branch_bootstraps,
269 &anchor.consumer_bootstraps,
270 )?;
271 let info = RetentionAnchorInfo {
272 format_version: anchor.format_version,
273 database_id: anchor.database_id,
274 floor: anchor.floor,
275 head: anchor.head,
276 bytes: length as u64,
277 checksum: expected_checksum,
278 projection_checkpoints: anchor
279 .projection_coverage
280 .iter()
281 .map(|coverage| coverage.snapshot_ids.len())
282 .sum(),
283 branch_bootstraps: anchor.branch_bootstraps.len(),
284 consumer_bootstraps: anchor.consumer_bootstraps.len(),
285 bootstrap_bytes: anchor
286 .branch_bootstraps
287 .iter()
288 .map(|item| item.checkpoint.len() as u64)
289 .chain(
290 anchor
291 .consumer_bootstraps
292 .iter()
293 .map(|item| item.checkpoint.len() as u64),
294 )
295 .sum(),
296 system_records: anchor.system_records.len(),
297 };
298 Ok((anchor, info))
299}
300
301fn validate_bootstraps(
302 floor: u64,
303 branches: &[RetentionBranchBootstrap],
304 consumers: &[RetentionConsumerBootstrap],
305) -> Result<()> {
306 let mut branch_ids = std::collections::BTreeSet::new();
307 for item in branches {
308 if item.floor != floor
309 || item.checkpoint.len() > MAX_BOOTSTRAP_BYTES
310 || crc32c::crc32c(&item.checkpoint) != item.checksum
311 || !branch_ids.insert(item.branch_id)
312 {
313 return Err(corrupt("invalid or duplicate branch bootstrap coverage"));
314 }
315 }
316 let mut consumer_ids = std::collections::BTreeSet::new();
317 let mut checkpoint_ids = std::collections::BTreeSet::new();
318 for item in consumers {
319 if item.floor != floor
320 || item.checkpoint.len() > MAX_BOOTSTRAP_BYTES
321 || crc32c::crc32c(&item.checkpoint) != item.checksum
322 || !consumer_ids.insert(item.consumer_id.as_str())
323 || item.checkpoint_id == [0; 16]
324 || !checkpoint_ids.insert(item.checkpoint_id)
325 || item.codec.is_empty()
326 || item.codec.len() > 128
327 {
328 return Err(corrupt("invalid or duplicate consumer bootstrap coverage"));
329 }
330 }
331 Ok(())
332}
333
334fn anchor_dir(root: &Path) -> PathBuf {
335 root.join("retention")
336}
337
338fn anchor_path(root: &Path) -> PathBuf {
339 anchor_dir(root).join("core.anchor")
340}
341
342fn corrupt(reason: impl Into<String>) -> SalamanderError {
343 SalamanderError::InvalidFormat(reason.into())
344}
345
346#[cfg(unix)]
347fn sync_dir(dir: &Path) -> Result<()> {
348 File::open(dir)?.sync_all()?;
349 Ok(())
350}
351
352#[cfg(not(unix))]
353fn sync_dir(_dir: &Path) -> Result<()> {
354 Ok(())
355}