digdigdig3_station/series/
store.rs1use 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
8pub 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 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 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 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 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 pub async fn flush(&mut self) -> std::io::Result<()> {
131 self.flush_sync()
132 }
133
134 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 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 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}