use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::atomic::write_atomic;
use crate::registry::table_registry::TableRegistry;
use crate::schema::{HaysonRecord, UNPARTITIONED};
use crate::storage::{LogFile, LogSet};
const SNAPSHOT_VERSION: u8 = 1;
const KIND: &str = "index";
const PAIRING_WINDOW: u64 = 4096;
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotHeader {
pub table: String,
pub gen: u64,
pub schema_hash: String,
pub seq: u64,
pub watermark: u64,
pub dead_bytes: u64,
pub log_hash: Option<String>,
pub prefix_hash: Option<String>,
pub parts: Option<Vec<SnapshotPart>>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotPart {
pub part: String,
pub watermark: u64,
pub dead_bytes: u64,
pub log_hash: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotEntry {
pub key: String,
pub offset: u64,
pub length: u64,
pub seq: u64,
pub projection: HaysonRecord,
pub part: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Snapshot {
pub header: SnapshotHeader,
pub entries: Vec<SnapshotEntry>,
}
#[derive(Serialize, Deserialize)]
struct RawHeader {
#[serde(rename = "snapshotVersion")]
snapshot_version: u8,
kind: String,
table: String,
#[serde(default, deserialize_with = "lenient_generation")]
gen: u64,
#[serde(rename = "schemaHash")]
schema_hash: String,
seq: u64,
watermark: u64,
#[serde(rename = "deadBytes")]
dead_bytes: u64,
#[serde(rename = "logHash", skip_serializing_if = "Option::is_none", default)]
log_hash: Option<String>,
#[serde(rename = "prefixHash", skip_serializing_if = "Option::is_none", default)]
prefix_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
parts: Option<Vec<RawPart>>,
}
#[derive(Serialize, Deserialize)]
struct RawPart {
p: String,
w: u64,
d: u64,
h: String,
}
#[derive(Serialize, Deserialize)]
struct RawEntry {
k: String,
o: u64,
l: u64,
s: u64,
p: HaysonRecord,
#[serde(skip_serializing_if = "Option::is_none", default)]
pt: Option<String>,
}
fn lenient_generation<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value.as_ref().and_then(serde_json::Value::as_u64).unwrap_or(0))
}
fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn log_fingerprint(log: &LogFile, up_to: u64) -> io::Result<String> {
let mut hash = fnv1a_update(FNV_OFFSET, format!("hayson-rust/log/1\n{up_to}\n").as_bytes());
let head_len = PAIRING_WINDOW.min(up_to);
if head_len > 0 {
hash = fnv1a_update(hash, &log.read_bytes(0, head_len)?);
if up_to > head_len {
let tail_len = PAIRING_WINDOW.min(up_to - head_len);
hash = fnv1a_update(hash, &log.read_bytes(up_to - tail_len, tail_len)?);
}
}
Ok(format!("{hash:016x}"))
}
pub fn prefix_window(size: u64) -> u64 {
PAIRING_WINDOW.min(size)
}
pub fn log_prefix_fingerprint(log: &LogFile, len: u64) -> io::Result<String> {
let mut hash = fnv1a_update(FNV_OFFSET, b"hayson-rust/log-prefix/1\n");
if len > 0 {
hash = fnv1a_update(hash, &log.read_bytes(0, len)?);
}
Ok(format!("{hash:016x}"))
}
pub fn write_snapshot(
path: &Path,
registry: &TableRegistry,
table: &str,
schema_hash: &str,
gen: u64,
log: &LogFile,
) -> io::Result<()> {
let watermark = log.size();
let log_hash = Some(log_fingerprint(log, watermark)?);
let prefix_hash = Some(log_prefix_fingerprint(log, prefix_window(watermark))?);
write_with_header(
path,
registry,
table,
schema_hash,
gen,
watermark,
log_hash,
prefix_hash,
None,
)
}
pub fn write_snapshot_set(
path: &Path,
registry: &TableRegistry,
table: &str,
schema_hash: &str,
gen: u64,
logs: &LogSet,
) -> io::Result<()> {
if !logs.is_partitioned() {
let Some(log) = logs.log_of(UNPARTITIONED) else {
return Err(io::Error::other(format!("table \"{table}\" has no log to snapshot")));
};
return write_snapshot(path, registry, table, schema_hash, gen, log);
}
let mut parts = Vec::new();
for part in logs.parts() {
let Some(log) = logs.log_of(&part) else { continue };
parts.push(RawPart {
p: part.clone(),
w: log.size(),
d: registry.stats_of(&part).dead_bytes,
h: log_fingerprint(log, log.size())?,
});
}
write_with_header(
path,
registry,
table,
schema_hash,
gen,
logs.total_size(),
None,
None,
Some(parts),
)
}
#[allow(clippy::too_many_arguments)]
fn write_with_header(
path: &Path,
registry: &TableRegistry,
table: &str,
schema_hash: &str,
gen: u64,
watermark: u64,
log_hash: Option<String>,
prefix_hash: Option<String>,
parts: Option<Vec<RawPart>>,
) -> io::Result<()> {
let header = RawHeader {
snapshot_version: SNAPSHOT_VERSION,
kind: KIND.to_string(),
table: table.to_string(),
gen,
schema_hash: schema_hash.to_string(),
seq: registry.max_seq(),
watermark,
dead_bytes: registry.stats().dead_bytes,
log_hash,
prefix_hash,
parts,
};
let mut lines = Vec::with_capacity(registry.records() + 1);
lines.push(serde_json::to_string(&header)?);
for key in registry.keys_in_arrival_order() {
let entry = registry
.slot_of(key)
.expect("key came from registry.keys_in_arrival_order()");
let raw = RawEntry {
k: key.clone(),
o: entry.offset,
l: entry.length,
s: entry.seq,
p: registry.projection_of(key).cloned().unwrap_or_default(),
pt: registry.part_of(key).filter(|p| !p.is_empty()).map(str::to_string),
};
lines.push(serde_json::to_string(&raw)?);
}
let payload = format!("{}\n", lines.join("\n"));
write_atomic(path, payload.as_bytes(), None)
}
pub fn read_snapshot(path: &Path) -> io::Result<Option<Snapshot>> {
let raw = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let mut lines = raw.split('\n').filter(|line| !line.is_empty());
let Some(first) = lines.next() else {
return Ok(None);
};
let Ok(header) = serde_json::from_str::<RawHeader>(first) else {
return Ok(None);
};
if header.snapshot_version != SNAPSHOT_VERSION || header.kind != KIND {
return Ok(None);
}
let mut entries = Vec::new();
for line in lines {
let Ok(raw_entry) = serde_json::from_str::<RawEntry>(line) else {
return Ok(None);
};
entries.push(SnapshotEntry {
key: raw_entry.k,
offset: raw_entry.o,
length: raw_entry.l,
seq: raw_entry.s,
projection: raw_entry.p,
part: raw_entry.pt.filter(|p| !p.is_empty()),
});
}
Ok(Some(Snapshot {
header: SnapshotHeader {
table: header.table,
gen: header.gen,
schema_hash: header.schema_hash,
seq: header.seq,
watermark: header.watermark,
dead_bytes: header.dead_bytes,
log_hash: header.log_hash,
prefix_hash: header.prefix_hash,
parts: header.parts.map(|parts| {
parts
.into_iter()
.map(|raw| SnapshotPart {
part: raw.p,
watermark: raw.w,
dead_bytes: raw.d,
log_hash: raw.h,
})
.collect()
}),
},
entries,
}))
}
pub fn remove_snapshot(path: &Path) -> io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}