Skip to main content

optimization_job_api/
storage.rs

1//! Durable primitives extracted from route_search and route_archive (3ef2673).
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fs::{self, File, OpenOptions};
6use std::io::{self, BufRead, BufReader, BufWriter, Write};
7use std::path::{Path, PathBuf};
8static TEMP_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
9pub fn append_jsonl<T: Serialize>(path: &Path, value: &T) -> Result<(), io::Error> {
10    let mut file = OpenOptions::new()
11        .create(true)
12        .read(true)
13        .append(true)
14        .open(path)?;
15    seal_tail(&mut file)?;
16    let mut writer = BufWriter::new(file);
17    serde_json::to_writer(&mut writer, value)?;
18    writer.write_all(b"\n")?;
19    writer.flush()?;
20    writer.get_ref().sync_data()?;
21    Ok(())
22}
23
24/// Ends a file whose last line lacks its newline (an append that was cut
25/// short) the way readers see it: a valid JSON tail is a record and gets its
26/// newline, anything else is dropped. Appending after an unsealed tail would
27/// join it with the next record into one invalid line, making every earlier
28/// record unreadable.
29fn seal_tail(file: &mut File) -> io::Result<()> {
30    use std::io::{Read, Seek, SeekFrom};
31    let len = file.metadata()?.len();
32    if len == 0 {
33        return Ok(());
34    }
35    let mut last = [0u8];
36    file.seek(SeekFrom::Start(len - 1))?;
37    file.read_exact(&mut last)?;
38    if last[0] == b'\n' {
39        return Ok(());
40    }
41    let mut data = Vec::new();
42    file.seek(SeekFrom::Start(0))?;
43    file.read_to_end(&mut data)?;
44    let start = data.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
45    if serde_json::from_slice::<serde_json::Value>(&data[start..]).is_ok() {
46        file.write_all(b"\n")?;
47    } else {
48        file.set_len(start as u64)?;
49    }
50    file.sync_data()
51}
52
53/// Reads JSONL while ignoring only an incomplete final record.
54///
55/// A malformed newline-terminated record or corruption before the final line
56/// remains fatal.
57///
58/// # Errors
59///
60/// Returns an error for I/O or non-final JSON corruption.
61pub fn read_jsonl_resilient<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, io::Error> {
62    let file = File::open(path)?;
63    let mut reader = BufReader::new(file);
64    let mut records = Vec::new();
65    let mut line = Vec::new();
66    loop {
67        line.clear();
68        let bytes = reader.read_until(b'\n', &mut line)?;
69        if bytes == 0 {
70            break;
71        }
72        let terminated = line.last() == Some(&b'\n');
73        if terminated {
74            line.pop();
75            if line.last() == Some(&b'\r') {
76                line.pop();
77            }
78        }
79        if line.iter().all(u8::is_ascii_whitespace) {
80            continue;
81        }
82        match serde_json::from_slice(&line) {
83            Ok(record) => records.push(record),
84            Err(_) if !terminated => break,
85            Err(error) => return Err(io::Error::other(error)),
86        }
87    }
88    Ok(records)
89}
90
91/// Writes a JSON snapshot to a sibling temporary file and atomically renames
92/// it over the target.
93///
94/// # Errors
95///
96/// Returns an I/O or serialization error.
97pub fn write_atomic_json<T: Serialize>(path: &Path, value: &T) -> Result<(), io::Error> {
98    let parent = path.parent().unwrap_or_else(|| Path::new("."));
99    fs::create_dir_all(parent)?;
100    let filename = path
101        .file_name()
102        .and_then(|name| name.to_str())
103        .ok_or_else(|| io::Error::other("snapshot path has no file name".to_owned()))?;
104    let temporary = temporary_path(parent, filename);
105    let result = (|| {
106        let file = OpenOptions::new()
107            .create_new(true)
108            .write(true)
109            .open(&temporary)?;
110        let mut writer = BufWriter::new(file);
111        serde_json::to_writer_pretty(&mut writer, value)?;
112        writer.write_all(b"\n")?;
113        writer.flush()?;
114        writer.get_ref().sync_all()?;
115        fs::rename(&temporary, path)?;
116        File::open(parent)?.sync_all()?;
117        Ok::<(), io::Error>(())
118    })();
119    if result.is_err() {
120        let _ = fs::remove_file(&temporary);
121    }
122    result
123}
124
125fn temporary_path(parent: &Path, filename: &str) -> PathBuf {
126    parent.join(format!(
127        ".{filename}.{}.{}.tmp",
128        std::process::id(),
129        TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
130    ))
131}
132
133/// Route archive v2 framing, kept byte compatible.
134#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Envelope {
137    pub schema_version: u32,
138    pub checksum: String,
139    pub payload_json: String,
140}
141pub fn digest(bytes: &[u8]) -> String {
142    format!("{:x}", Sha256::digest(bytes))
143}
144pub fn append_checked<T: Serialize>(path: &Path, version: u32, value: &T) -> io::Result<()> {
145    let payload_json = serde_json::to_string(value)?;
146    append_jsonl(
147        path,
148        &Envelope {
149            schema_version: version,
150            checksum: digest(payload_json.as_bytes()),
151            payload_json,
152        },
153    )
154}
155pub fn read_checked<T: DeserializeOwned>(path: &Path, version: u32) -> io::Result<Vec<T>> {
156    let lines: Vec<Envelope> = read_jsonl_resilient(path)?;
157    lines
158        .into_iter()
159        .map(|line| {
160            if line.schema_version != version
161                || line.checksum != digest(line.payload_json.as_bytes())
162            {
163                return Err(io::Error::other("checksum or schema mismatch"));
164            }
165            Ok(serde_json::from_str(&line.payload_json)?)
166        })
167        .collect()
168}
169/// Repair only after verified replay, under exclusive job ownership.
170/// A valid unterminated record is retained and receives its newline.
171pub fn repair_tail<T: DeserializeOwned>(path: &Path, version: u32) -> io::Result<Vec<T>> {
172    let records = read_checked(path, version)?;
173    let data = fs::read(path)?;
174    if !data.is_empty() && !data.ends_with(b"\n") {
175        let start = data.iter().rposition(|b| *b == b'\n').map_or(0, |i| i + 1);
176        let mut file = OpenOptions::new().append(true).open(path)?;
177        if serde_json::from_slice::<Envelope>(&data[start..]).is_ok() {
178            file.write_all(b"\n")?;
179        } else {
180            file.set_len(start as u64)?;
181        }
182        file.sync_all()?;
183    }
184    Ok(records)
185}
186/// Liveness probe for a lock that a process holds for its whole lifetime.
187///
188/// The kernel releases `flock` locks when their holder exits, however it
189/// exits, so a free lock proves the holder is gone. Returns `Ok(true)` when
190/// the lock is free or the file does not exist, `Ok(false)` when another open
191/// file description holds it. The probe takes the lock only momentarily.
192///
193/// # Errors
194///
195/// Returns an error when the lock state cannot be determined.
196pub fn lock_is_free(path: &Path) -> io::Result<bool> {
197    let file = match OpenOptions::new().read(true).write(true).open(path) {
198        Ok(file) => file,
199        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(true),
200        Err(e) => return Err(e),
201    };
202    match fs2::FileExt::try_lock_exclusive(&file) {
203        Ok(()) => Ok(true),
204        Err(e) if e.raw_os_error() == fs2::lock_contended_error().raw_os_error() => Ok(false),
205        Err(e) => Err(e),
206    }
207}
208/// Stable inode lock. Never unlink it; atomic replacement is for data only.
209pub fn lock(path: &Path) -> io::Result<File> {
210    let file = OpenOptions::new()
211        .create(true)
212        .truncate(false)
213        .read(true)
214        .write(true)
215        .open(path)?;
216    fs2::FileExt::lock_exclusive(&file)?;
217    Ok(file)
218}