1use std::collections::BTreeMap;
33use std::path::Path;
34use std::sync::Arc;
35
36use anyhow::{Result, anyhow};
37use znippy_common::GUNNAR_REFS_MODULE;
38use znippy_common::arrow::array::{Array, StringArray, StringBuilder, UInt64Array, UInt64Builder};
39use znippy_common::arrow::datatypes::{DataType, Field, Schema};
40use znippy_common::arrow::record_batch::RecordBatch;
41
42use crate::pushlog::{PushLog, PushLogScan, read_sealed};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RefUpdate {
47 pub name: String,
48 pub target: Option<String>,
50 pub peeled: Option<String>,
51 pub symref_target: Option<String>,
52}
53
54impl RefUpdate {
55 pub fn set(name: impl Into<String>, target: impl Into<String>) -> Self {
56 Self {
57 name: name.into(),
58 target: Some(target.into()),
59 peeled: None,
60 symref_target: None,
61 }
62 }
63
64 pub fn delete(name: impl Into<String>) -> Self {
65 Self { name: name.into(), target: None, peeled: None, symref_target: None }
66 }
67
68 pub fn symbolic(name: impl Into<String>, points_to: impl Into<String>) -> Self {
69 Self {
70 name: name.into(),
71 target: None,
72 peeled: None,
73 symref_target: Some(points_to.into()),
74 }
75 }
76
77 pub fn with_peeled(mut self, peeled: impl Into<String>) -> Self {
78 self.peeled = Some(peeled.into());
79 self
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct RefState {
86 pub target: Option<String>,
87 pub peeled: Option<String>,
88 pub symref_target: Option<String>,
89 pub push_seq: u64,
90 pub updated_ms: u64,
91}
92
93pub fn refs_schema() -> Arc<Schema> {
94 Arc::new(Schema::new(vec![
95 Field::new("name", DataType::Utf8, false),
96 Field::new("target", DataType::Utf8, true),
97 Field::new("peeled", DataType::Utf8, true),
98 Field::new("symref_target", DataType::Utf8, true),
99 Field::new("push_seq", DataType::UInt64, false),
100 Field::new("updated_ms", DataType::UInt64, false),
101 ]))
102}
103
104pub fn build_push_batch(updates: &[RefUpdate], push_seq: u64, updated_ms: u64) -> Result<RecordBatch> {
106 let n = updates.len();
107 let mut name = StringBuilder::with_capacity(n, n * 32);
108 let mut target = StringBuilder::with_capacity(n, n * 64);
109 let mut peeled = StringBuilder::with_capacity(n, n * 64);
110 let mut symref = StringBuilder::with_capacity(n, n * 32);
111 let mut seq = UInt64Builder::with_capacity(n);
112 let mut ms = UInt64Builder::with_capacity(n);
113
114 for u in updates {
115 name.append_value(&u.name);
116 match &u.target {
117 Some(t) => target.append_value(t),
118 None => target.append_null(),
119 }
120 match &u.peeled {
121 Some(t) => peeled.append_value(t),
122 None => peeled.append_null(),
123 }
124 match &u.symref_target {
125 Some(t) => symref.append_value(t),
126 None => symref.append_null(),
127 }
128 seq.append_value(push_seq);
129 ms.append_value(updated_ms);
130 }
131
132 RecordBatch::try_new(
133 refs_schema(),
134 vec![
135 Arc::new(name.finish()),
136 Arc::new(target.finish()),
137 Arc::new(peeled.finish()),
138 Arc::new(symref.finish()),
139 Arc::new(seq.finish()),
140 Arc::new(ms.finish()),
141 ],
142 )
143 .map_err(|e| anyhow!("refs push batch: {e}"))
144}
145
146pub struct RefLog {
148 log: PushLog,
149}
150
151impl RefLog {
152 pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
153 Self { log: PushLog::new(path, refs_schema()) }
154 }
155
156 pub fn next_push_seq(&self) -> Result<u64> {
160 let scan = self.log.scan()?;
161 Ok(max_push_seq(&scan.pushes).map_or(0, |m| m + 1))
162 }
163
164 pub fn push(&self, updates: &[RefUpdate]) -> Result<u64> {
166 let seq = self.next_push_seq()?;
167 let ms = std::time::SystemTime::now()
168 .duration_since(std::time::UNIX_EPOCH)
169 .map(|d| d.as_millis() as u64)
170 .unwrap_or(0);
171 let batch = build_push_batch(updates, seq, ms)?;
172 self.log.append(&batch)?;
173 Ok(seq)
174 }
175
176 pub fn append_batch(&self, batch: &RecordBatch) -> Result<u64> {
182 self.log.append(batch)
183 }
184
185 pub fn scan(&self) -> Result<PushLogScan> {
186 self.log.scan()
187 }
188
189 pub fn compact(&self) -> Result<crate::pushlog::CompactionReport> {
192 self.log.compact()
193 }
194
195 pub fn maybe_compact(
197 &self,
198 policy: crate::pushlog::CompactionPolicy,
199 ) -> Result<Option<crate::pushlog::CompactionReport>> {
200 self.log.maybe_compact(policy)
201 }
202
203 pub fn current(&self) -> Result<BTreeMap<String, RefState>> {
205 Ok(fold(&self.log.scan()?.pushes)?)
206 }
207
208 pub fn seal_section(&self) -> Result<znippy_common::ReservedSection> {
210 self.log.seal_section(GUNNAR_REFS_MODULE)
211 }
212}
213
214fn max_push_seq(batches: &[RecordBatch]) -> Option<u64> {
215 let mut max = None;
216 for b in batches {
217 let seq = b.column_by_name("push_seq")?.as_any().downcast_ref::<UInt64Array>()?;
218 for i in 0..seq.len() {
219 max = Some(max.map_or(seq.value(i), |m: u64| m.max(seq.value(i))));
220 }
221 }
222 max
223}
224
225pub fn fold(batches: &[RecordBatch]) -> Result<BTreeMap<String, RefState>> {
231 let mut rows: Vec<(u64, usize, RefState, String)> = Vec::new();
232
233 for (bi, b) in batches.iter().enumerate() {
234 let name = col::<StringArray>(b, "name")?;
235 let target = col::<StringArray>(b, "target")?;
236 let peeled = col::<StringArray>(b, "peeled")?;
237 let symref = col::<StringArray>(b, "symref_target")?;
238 let seq = col::<UInt64Array>(b, "push_seq")?;
239 let ms = col::<UInt64Array>(b, "updated_ms")?;
240
241 for i in 0..b.num_rows() {
242 rows.push((
243 seq.value(i),
244 bi,
245 RefState {
246 target: (!target.is_null(i)).then(|| target.value(i).to_string()),
247 peeled: (!peeled.is_null(i)).then(|| peeled.value(i).to_string()),
248 symref_target: (!symref.is_null(i)).then(|| symref.value(i).to_string()),
249 push_seq: seq.value(i),
250 updated_ms: ms.value(i),
251 },
252 name.value(i).to_string(),
253 ));
254 }
255 }
256
257 rows.sort_by_key(|(seq, bi, _, _)| (*seq, *bi));
258
259 let mut out: BTreeMap<String, RefState> = BTreeMap::new();
260 for (_, _, state, name) in rows {
261 if state.target.is_none() && state.symref_target.is_none() {
262 out.remove(&name);
263 } else {
264 out.insert(name, state);
265 }
266 }
267 Ok(out)
268}
269
270pub fn read_refs(archive: &Path) -> Result<Option<BTreeMap<String, RefState>>> {
273 match read_sealed(archive, GUNNAR_REFS_MODULE)? {
274 Some(batches) => Ok(Some(fold(&batches)?)),
275 None => Ok(None),
276 }
277}
278
279fn col<'a, T: Array + 'static>(b: &'a RecordBatch, name: &str) -> Result<&'a T> {
280 b.column_by_name(name)
281 .ok_or_else(|| anyhow!("refs: no `{name}` column"))?
282 .as_any()
283 .downcast_ref::<T>()
284 .ok_or_else(|| anyhow!("refs: `{name}` has an unexpected type"))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use crate::pushlog::truncate_for_test;
291
292 fn tmpdir(tag: &str) -> std::path::PathBuf {
293 let ns = std::time::SystemTime::now()
294 .duration_since(std::time::UNIX_EPOCH)
295 .unwrap()
296 .as_nanos();
297 let d = std::env::temp_dir().join(format!("znippy_refs_{tag}_{ns}"));
298 std::fs::create_dir_all(&d).unwrap();
299 d
300 }
301
302 fn oid(c: char) -> String {
303 std::iter::repeat_n(c, 40).collect()
304 }
305
306 #[test]
307 fn last_writer_wins_and_a_null_target_deletes() {
308 let dir = tmpdir("fold");
309 let log = RefLog::new(dir.join("refs.log"));
310 log.push(&[
311 RefUpdate::set("refs/heads/main", oid('a')),
312 RefUpdate::set("refs/heads/topic", oid('b')),
313 ])
314 .unwrap();
315 log.push(&[RefUpdate::set("refs/heads/main", oid('c'))]).unwrap();
316 log.push(&[RefUpdate::delete("refs/heads/topic")]).unwrap();
317
318 let refs = log.current().unwrap();
319 assert_eq!(refs["refs/heads/main"].target, Some(oid('c')), "second push must win");
320 assert!(!refs.contains_key("refs/heads/topic"), "a null target deletes the ref");
321 assert_eq!(refs.len(), 1);
322
323 std::fs::remove_dir_all(&dir).ok();
324 }
325
326 #[test]
331 fn a_crash_mid_push_leaves_no_partial_ref_update() {
332 let dir = tmpdir("atomic");
333 let path = dir.join("refs.log");
334 let log = RefLog::new(&path);
335 log.push(&[RefUpdate::set("refs/heads/main", oid('a'))]).unwrap();
336 let before = std::fs::metadata(&path).unwrap().len();
337
338 log.push(&[
340 RefUpdate::set("refs/heads/main", oid('9')),
341 RefUpdate::set("refs/heads/a", oid('1')),
342 RefUpdate::set("refs/heads/b", oid('2')),
343 ])
344 .unwrap();
345 let after = std::fs::metadata(&path).unwrap().len();
346 let intact = std::fs::read(&path).unwrap();
347
348 for cut in (before + 1)..after {
349 std::fs::write(&path, &intact).unwrap();
350 truncate_for_test(&path, cut).unwrap();
351 let refs = log.current().unwrap();
352 assert_eq!(
353 refs.len(),
354 1,
355 "cut at {cut}: a torn push must not publish ANY of its refs (got {refs:?})"
356 );
357 assert_eq!(
358 refs["refs/heads/main"].target,
359 Some(oid('a')),
360 "cut at {cut}: main must still be the pre-push value"
361 );
362 assert!(!refs.contains_key("refs/heads/a"), "cut at {cut}: leaked a partial ref");
363 assert!(!refs.contains_key("refs/heads/b"), "cut at {cut}: leaked a partial ref");
364 }
365
366 std::fs::write(&path, &intact).unwrap();
368 let refs = log.current().unwrap();
369 assert_eq!(refs.len(), 3, "the complete push publishes all three refs");
370 assert_eq!(refs["refs/heads/main"].target, Some(oid('9')));
371
372 std::fs::remove_dir_all(&dir).ok();
373 }
374
375 #[test]
378 fn push_seq_is_recovered_from_the_log_not_from_memory() {
379 let dir = tmpdir("seq");
380 let path = dir.join("refs.log");
381 let a = RefLog::new(&path);
382 assert_eq!(a.push(&[RefUpdate::set("refs/heads/main", oid('a'))]).unwrap(), 0);
383 assert_eq!(a.push(&[RefUpdate::set("refs/heads/main", oid('b'))]).unwrap(), 1);
384
385 let b = RefLog::new(&path);
387 assert_eq!(
388 b.next_push_seq().unwrap(),
389 2,
390 "a restarted writer must continue the sequence, not restart it"
391 );
392 assert_eq!(b.push(&[RefUpdate::set("refs/heads/main", oid('c'))]).unwrap(), 2);
393 assert_eq!(b.current().unwrap()["refs/heads/main"].target, Some(oid('c')));
394
395 std::fs::remove_dir_all(&dir).ok();
396 }
397
398 #[test]
402 fn ordering_is_by_push_seq_not_by_timestamp_or_position() {
403 let newer = build_push_batch(&[RefUpdate::set("refs/heads/main", oid('c'))], 7, 1000).unwrap();
405 let older = build_push_batch(&[RefUpdate::set("refs/heads/main", oid('a'))], 3, 9999).unwrap();
406 let refs = fold(&[newer, older]).unwrap();
407 assert_eq!(
408 refs["refs/heads/main"].target,
409 Some(oid('c')),
410 "push_seq 7 must beat push_seq 3 regardless of order or clock"
411 );
412 assert_eq!(refs["refs/heads/main"].push_seq, 7);
413 }
414
415 #[test]
416 fn symbolic_and_peeled_refs_round_trip() {
417 let dir = tmpdir("sym");
418 let log = RefLog::new(dir.join("refs.log"));
419 log.push(&[
420 RefUpdate::symbolic("HEAD", "refs/heads/main"),
421 RefUpdate::set("refs/tags/v1", oid('t')).with_peeled(oid('e')),
422 ])
423 .unwrap();
424 let refs = log.current().unwrap();
425 assert_eq!(refs["HEAD"].symref_target.as_deref(), Some("refs/heads/main"));
426 assert!(refs["HEAD"].target.is_none(), "a symref has no direct target");
427 assert_eq!(refs["refs/tags/v1"].peeled, Some(oid('e')));
428 std::fs::remove_dir_all(&dir).ok();
429 }
430}