Skip to main content

digdigdig3_station/series/
store.rs

1use std::fs::{create_dir_all, File, OpenOptions};
2use std::io::{BufWriter, Read, Seek, SeekFrom, Write};
3use std::marker::PhantomData;
4use std::path::{Path, PathBuf};
5
6use super::{DataPoint, SeriesKey};
7
8/// Generic fixed-record binary writer + reader for `T: DataPoint`.
9///
10/// Layout under `<storage_root>/<kind-slug>/<exchange>/<account>/<symbol>/<YYYY-MM-DD>.dat`:
11/// - `.dat`: append-only, `T::RECORD_SIZE` bytes per record (little-endian).
12/// - `.idx`: sparse `[u64 ts_ms, u64 file_offset]` every N records.
13/// - `.blob`: append-only UTF-8 byte stream (only for types where
14///   [`DataPoint::blob_pointer_offset`] is `Some(_)`). Each header's trailing
15///   `(blob_offset: u64, blob_len: u32)` references a slice in this file.
16///
17/// UTC day rotation. Idx interval is configurable (default 1024).
18pub struct DiskStore<T: DataPoint> {
19    root: PathBuf,
20    key: SeriesKey,
21    current_day: String,
22    dat: BufWriter<File>,
23    idx: BufWriter<File>,
24    blob: Option<BufWriter<File>>,
25    blob_pos: u64,
26    records: u32,
27    file_offset: u64,
28    idx_every: u32,
29    _phantom: PhantomData<T>,
30}
31
32impl<T: DataPoint> std::fmt::Debug for DiskStore<T> {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("DiskStore")
35            .field("root", &self.root)
36            .field("key", &self.key)
37            .field("day", &self.current_day)
38            .field("records", &self.records)
39            .field("offset", &self.file_offset)
40            .field("blob_pos", &self.blob_pos)
41            .finish()
42    }
43}
44
45impl<T: DataPoint> DiskStore<T> {
46    /// Open (or create) a DiskStore. `async` to match the wasm32 sibling API
47    /// — the actual work is synchronous; the `async` wrapper is zero-cost.
48    pub async fn new(storage_root: &Path, key: SeriesKey) -> std::io::Result<Self> {
49        Self::with_idx_every(storage_root, key, 1024).await
50    }
51
52    /// Like `new` but with a custom idx sparsity factor.
53    /// `async` to match the wasm32 sibling API — synchronous internally.
54    pub async fn with_idx_every(
55        storage_root: &Path,
56        key: SeriesKey,
57        idx_every: u32,
58    ) -> std::io::Result<Self> {
59        let day = utc_today();
60        let paths = paths(storage_root, &key, &day);
61        let (dat, idx, offset) = open_pair(&paths.dat, &paths.idx)?;
62        let (blob, blob_pos) = if T::blob_pointer_offset().is_some() {
63            let (f, len) = open_blob(&paths.blob)?;
64            (Some(BufWriter::new(f)), len)
65        } else {
66            (None, 0)
67        };
68        Ok(Self {
69            root: storage_root.to_path_buf(),
70            key,
71            current_day: day,
72            dat: BufWriter::new(dat),
73            idx: BufWriter::new(idx),
74            blob,
75            blob_pos,
76            records: 0,
77            file_offset: offset,
78            idx_every: idx_every.max(1),
79            _phantom: PhantomData,
80        })
81    }
82
83    /// Append one record, possibly rotating to a new UTC day file.
84    ///
85    /// Write order: blob bytes first, header second. Crash mid-blob leaves
86    /// orphan bytes (no header references them); crash between blob and
87    /// header still references valid blob range only if the header itself
88    /// was flushed — partial header is detected on read.
89    pub fn append(&mut self, point: &T) -> std::io::Result<()> {
90        self.rotate_if_new_day()?;
91
92        let mut buf = vec![0u8; T::RECORD_SIZE];
93        point.encode(&mut buf);
94
95        if let (Some(blob_w), Some(tail_off)) = (self.blob.as_mut(), T::blob_pointer_offset()) {
96            if let Some(blob_bytes) = point.encode_blob() {
97                let off = self.blob_pos;
98                let len = blob_bytes.len() as u32;
99                blob_w.write_all(&blob_bytes)?;
100                self.blob_pos += blob_bytes.len() as u64;
101                buf[tail_off..tail_off + 8].copy_from_slice(&off.to_le_bytes());
102                buf[tail_off + 8..tail_off + 12].copy_from_slice(&len.to_le_bytes());
103            }
104        }
105
106        self.dat.write_all(&buf)?;
107
108        if self.records % self.idx_every == 0 {
109            let mut idx_buf = [0u8; 16];
110            idx_buf[0..8].copy_from_slice(&(point.timestamp_ms() as u64).to_le_bytes());
111            idx_buf[8..16].copy_from_slice(&self.file_offset.to_le_bytes());
112            self.idx.write_all(&idx_buf)?;
113        }
114
115        self.records = self.records.wrapping_add(1);
116        self.file_offset += T::RECORD_SIZE as u64;
117        Ok(())
118    }
119
120    /// Append many points (batch). Same per-record semantics.
121    pub fn append_batch(&mut self, points: &[T]) -> std::io::Result<()> {
122        for p in points {
123            self.append(p)?;
124        }
125        Ok(())
126    }
127
128    /// Flush OS write buffers. `async` to match the wasm32 sibling API —
129    /// synchronous internally. The work completes before the future resolves.
130    pub async fn flush(&mut self) -> std::io::Result<()> {
131        self.flush_sync()
132    }
133
134    /// Synchronous flush — called from `Drop` where `async` is unavailable.
135    fn flush_sync(&mut self) -> std::io::Result<()> {
136        self.dat.flush()?;
137        self.idx.flush()?;
138        if let Some(b) = self.blob.as_mut() {
139            b.flush()?;
140        }
141        Ok(())
142    }
143
144    /// Read up to `limit` most-recent records from the current day's `.dat`.
145    /// Used for warm-start. Returns oldest → newest order.
146    ///
147    /// For types with `blob_pointer_offset()`, opens the `.blob` file
148    /// read-only and reconstructs string fields via [`DataPoint::decode_blob`].
149    ///
150    /// `async` to match the wasm32 sibling API — synchronous internally.
151    pub async fn read_tail(&self, limit: usize) -> std::io::Result<Vec<T>> {
152        if limit == 0 {
153            return Ok(Vec::new());
154        }
155        let paths = paths(&self.root, &self.key, &self.current_day);
156        if !paths.dat.exists() {
157            return Ok(Vec::new());
158        }
159        let mut f = File::open(&paths.dat)?;
160        let total = f.metadata()?.len();
161        if total < T::RECORD_SIZE as u64 {
162            return Ok(Vec::new());
163        }
164        let max_records = (total / T::RECORD_SIZE as u64) as usize;
165        let take = limit.min(max_records);
166        let offset = total - (take as u64 * T::RECORD_SIZE as u64);
167        f.seek(SeekFrom::Start(offset))?;
168        let mut buf = vec![0u8; take * T::RECORD_SIZE];
169        f.read_exact(&mut buf)?;
170
171        let mut blob_file = match T::blob_pointer_offset() {
172            Some(_) if paths.blob.exists() => Some(File::open(&paths.blob)?),
173            _ => None,
174        };
175        let blob_len_total = blob_file
176            .as_ref()
177            .and_then(|f| f.metadata().ok())
178            .map(|m| m.len())
179            .unwrap_or(0);
180
181        let mut out = Vec::with_capacity(take);
182        for chunk in buf.chunks_exact(T::RECORD_SIZE) {
183            let decoded = if let (Some(tail_off), Some(bf)) =
184                (T::blob_pointer_offset(), blob_file.as_mut())
185            {
186                let off = u64::from_le_bytes(
187                    chunk[tail_off..tail_off + 8].try_into().unwrap_or([0u8; 8]),
188                );
189                let len = u32::from_le_bytes(
190                    chunk[tail_off + 8..tail_off + 12]
191                        .try_into()
192                        .unwrap_or([0u8; 4]),
193                ) as u64;
194                if len == 0 {
195                    T::decode_blob(chunk, &[])
196                } else if off
197                    .checked_add(len)
198                    .map(|end| end > blob_len_total)
199                    .unwrap_or(true)
200                {
201                    tracing::warn!(
202                        target: "dig3_station::disk_store",
203                        off, len, blob_len_total,
204                        "blob pointer out of bounds, skipping record"
205                    );
206                    None
207                } else {
208                    bf.seek(SeekFrom::Start(off))?;
209                    let mut blob_buf = vec![0u8; len as usize];
210                    if bf.read_exact(&mut blob_buf).is_err() {
211                        None
212                    } else {
213                        T::decode_blob(chunk, &blob_buf)
214                    }
215                }
216            } else {
217                T::decode(chunk)
218            };
219            if let Some(p) = decoded {
220                out.push(p);
221            }
222        }
223        Ok(out)
224    }
225
226    pub fn key(&self) -> &SeriesKey {
227        &self.key
228    }
229
230    pub fn root(&self) -> &Path {
231        &self.root
232    }
233
234    fn rotate_if_new_day(&mut self) -> std::io::Result<()> {
235        let today = utc_today();
236        if today == self.current_day {
237            return Ok(());
238        }
239        self.flush_sync()?;
240        let paths = paths(&self.root, &self.key, &today);
241        let (dat, idx, offset) = open_pair(&paths.dat, &paths.idx)?;
242        self.dat = BufWriter::new(dat);
243        self.idx = BufWriter::new(idx);
244        if T::blob_pointer_offset().is_some() {
245            let (f, len) = open_blob(&paths.blob)?;
246            self.blob = Some(BufWriter::new(f));
247            self.blob_pos = len;
248        } else {
249            self.blob = None;
250            self.blob_pos = 0;
251        }
252        self.records = 0;
253        self.file_offset = offset;
254        self.current_day = today;
255        Ok(())
256    }
257}
258
259impl<T: DataPoint> Drop for DiskStore<T> {
260    fn drop(&mut self) {
261        // flush_sync flushes OS write buffers synchronously — safe in Drop.
262        // The public flush() is async (matches wasm32 sibling API) so cannot
263        // be called from Drop; flush_sync() is the sync-only entry point.
264        let _ = self.flush_sync();
265    }
266}
267
268struct DayPaths {
269    dat: PathBuf,
270    idx: PathBuf,
271    blob: PathBuf,
272}
273
274fn paths(root: &Path, key: &SeriesKey, day: &str) -> DayPaths {
275    let dir = root
276        .join(key.kind.slug())
277        .join(key.exchange_label())
278        .join(key.account_label())
279        .join(key.symbol.to_lowercase());
280    DayPaths {
281        dat: dir.join(format!("{day}.dat")),
282        idx: dir.join(format!("{day}.idx")),
283        blob: dir.join(format!("{day}.blob")),
284    }
285}
286
287fn open_pair(dat_path: &Path, idx_path: &Path) -> std::io::Result<(File, File, u64)> {
288    if let Some(parent) = dat_path.parent() {
289        create_dir_all(parent)?;
290    }
291    let dat = OpenOptions::new().create(true).append(true).open(dat_path)?;
292    let offset = dat.metadata()?.len();
293    let idx = OpenOptions::new().create(true).append(true).open(idx_path)?;
294    Ok((dat, idx, offset))
295}
296
297fn open_blob(blob_path: &Path) -> std::io::Result<(File, u64)> {
298    if let Some(parent) = blob_path.parent() {
299        create_dir_all(parent)?;
300    }
301    let f = OpenOptions::new().create(true).append(true).open(blob_path)?;
302    let len = f.metadata()?.len();
303    Ok((f, len))
304}
305
306fn utc_today() -> String {
307    use chrono::Utc;
308    Utc::now().format("%Y-%m-%d").to_string()
309}