use std::collections::VecDeque;
use std::io::Cursor;
use std::sync::Arc;
use lex_vcs::{HistoryIndex, OpId, OpLog, OperationRecord};
use tiny_http::{Header, Response};
use crate::handlers::{
error_response, error_with_detail, has_cap, json_response, State, CAP_FILES_V1,
};
fn is_set_files(rec: &OperationRecord) -> bool {
matches!(rec.op.kind, lex_vcs::OperationKind::SetFiles { .. })
}
fn upgrade_required(rec: &OperationRecord) -> Response<Cursor<Vec<u8>>> {
error_with_detail(426, format!(
"this history carries repository files (a SetFiles op, #1007) that this client \
cannot store; upgrade lex to a version that speaks `{CAP_FILES_V1}` (it sends \
`X-Lex-Caps: {CAP_FILES_V1}`) and pull again"
), serde_json::json!({
"kind": "UpgradeRequired",
"required_cap": CAP_FILES_V1,
"op_id": rec.op_id,
}))
}
pub const NEXT_CURSOR_HEADER: &str = "X-Lex-Next-Cursor";
const MAX_INDEXES: usize = 2;
const MAX_DELTAS: usize = 8;
const MAX_CONTINUATIONS: usize = 256;
const CONTINUATIONS_FILE: &str = "ops_since_continuations.json";
const CONTINUATION_TTL_SECS: u64 = 15 * 60;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
struct Delta {
index: Arc<HistoryIndex>,
base: Option<OpId>,
order: Vec<u32>,
}
#[derive(Default)]
pub(crate) struct OpsSinceCache {
indexes: VecDeque<Arc<HistoryIndex>>,
deltas: VecDeque<Arc<Delta>>,
continuations: Option<VecDeque<Continuation>>,
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct Continuation {
head: OpId,
last: OpId,
base: Option<OpId>,
end: usize,
at: u64,
}
fn with_continuations<R>(state: &State, f: impl FnOnce(&mut VecDeque<Continuation>) -> R) -> R {
let path = state.root.join(CONTINUATIONS_FILE);
let mut cache = state.ops_since.lock().unwrap();
let conts = cache.continuations.get_or_insert_with(|| {
std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
});
let before = conts.clone();
let cutoff = now_secs().saturating_sub(CONTINUATION_TTL_SECS);
conts.retain(|c| c.at >= cutoff);
let r = f(conts);
if *conts != before {
if let Ok(bytes) = serde_json::to_vec(&*conts) {
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, bytes).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
r
}
fn take_continuation(state: &State, head: &OpId, last: &OpId) -> Option<Continuation> {
with_continuations(state, |conts| {
let hits: Vec<usize> = conts
.iter()
.enumerate()
.filter(|(_, c)| &c.head == head && &c.last == last)
.map(|(i, _)| i)
.collect();
let first = conts[*hits.first()?].clone();
if hits.iter().any(|&i| (&conts[i].base, conts[i].end) != (&first.base, first.end)) {
return None;
}
for &i in hits.iter().rev() {
conts.remove(i);
}
Some(first)
})
}
fn record_continuation(state: &State, c: Continuation) {
with_continuations(state, |conts| {
conts.retain(|o| !(o.head == c.head && o.last == c.last && o.base == c.base && o.end == c.end));
conts.push_back(c);
while conts.len() > MAX_CONTINUATIONS {
conts.pop_front();
}
})
}
fn forget_continuation(state: &State, head: &OpId, last: &OpId) {
with_continuations(state, |conts| conts.retain(|c| !(&c.head == head && &c.last == last)))
}
fn touch<T>(list: &mut VecDeque<Arc<T>>, i: usize) -> Arc<T> {
let e = list.remove(i).expect("index in range");
list.push_back(Arc::clone(&e));
e
}
fn insert<T>(list: &mut VecDeque<Arc<T>>, e: Arc<T>, cap: usize) {
list.push_back(e);
while list.len() > cap {
list.pop_front();
}
}
fn delta_for(
state: &State,
log: &OpLog,
head: &OpId,
base: Option<&OpId>,
) -> std::io::Result<Arc<Delta>> {
let index = {
let mut cache = state.ops_since.lock().unwrap();
if let Some(i) = cache.deltas.iter().position(|d| d.index.head() == head && d.base.as_ref() == base) {
return Ok(touch(&mut cache.deltas, i));
}
cache.indexes.iter().position(|x| x.head() == head).map(|i| touch(&mut cache.indexes, i))
};
let index = match index {
Some(i) => i,
None => {
let i = Arc::new(HistoryIndex::build(log, head)?);
insert(&mut state.ops_since.lock().unwrap().indexes, Arc::clone(&i), MAX_INDEXES);
i
}
};
let order = index.topological(&index.since(log, base)?);
let delta = Arc::new(Delta { index, base: base.cloned(), order });
insert(&mut state.ops_since.lock().unwrap().deltas, Arc::clone(&delta), MAX_DELTAS);
Ok(delta)
}
fn is_op_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn encode_cursor(head: &str, base: Option<&str>, offset: usize) -> String {
format!("v1.{head}.{}.{offset}", base.unwrap_or("_"))
}
fn decode_cursor(c: &str) -> Option<(OpId, Option<OpId>, usize)> {
let mut parts = c.split('.');
let (v, head, base, offset) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
if v != "v1" || parts.next().is_some() || !is_op_id(head) {
return None;
}
let base = match base {
"_" => None,
b if is_op_id(b) => Some(b.to_string()),
_ => return None,
};
Some((head.to_string(), base, offset.parse().ok()?))
}
pub(crate) fn ops_since_handler(state: &State, query: &str, x_lex_caps: Option<&str>)
-> Response<Cursor<Vec<u8>>>
{
let mut after: Option<String> = None;
let mut branch = String::from("main");
let mut limit: Option<usize> = None;
let mut cursor: Option<String> = None;
for kv in query.split('&') {
let Some((k, v)) = kv.split_once('=') else { continue };
match k {
"after" => after = Some(v.to_string()),
"branch" => branch = v.to_string(),
"cursor" => cursor = Some(v.to_string()),
"limit" => {
limit = Some(match v.parse::<usize>() {
Ok(n) => n,
Err(_) => return error_response(400,
format!("limit must be a positive integer, got `{v}`")),
});
}
_ => {}
}
}
let files_v1 = has_cap(x_lex_caps, CAP_FILES_V1);
let store = state.store.lock().unwrap();
let log = match OpLog::open(store.root()) {
Ok(l) => l,
Err(e) => return error_response(500, format!("opening op log: {e}")),
};
let (head, base, offset) = match &cursor {
Some(c) => match decode_cursor(c) {
Some(parsed) => parsed,
None => return error_response(400, format!("malformed cursor `{c}`")),
},
None => {
let head = match store.get_branch(&branch) {
Ok(Some(b)) => b.head_op,
Ok(None) => None,
Err(e) => return error_response(500, format!("get_branch: {e}")),
};
let Some(head) = head else {
return json_response(200, &serde_json::json!([]));
};
if limit.is_none() {
return match log.ops_since(&head, after.as_ref()) {
Ok(mut ops) => {
if !files_v1 {
if let Some(rec) = ops.iter().find(|r| is_set_files(r)) {
return upgrade_required(rec);
}
}
ops.reverse();
json_response(200, &serde_json::to_value(&ops).unwrap_or_default())
}
Err(e) => error_response(500, format!("ops_since: {e}")),
};
}
match after.as_ref().and_then(|a| take_continuation(state, &head, a)) {
Some(c) => (head, c.base, c.end),
None => (head, after.clone(), 0),
}
}
};
let delta = match delta_for(state, &log, &head, base.as_ref()) {
Ok(d) => d,
Err(e) => return error_response(500, format!("ops_since: {e}")),
};
let total = delta.order.len();
if cursor.is_some() && (delta.index.is_empty() || offset > total) {
return error_response(400, format!(
"cursor `{}` does not name a page of this store's history; restart the pull",
cursor.unwrap_or_default()));
}
let end = match limit {
Some(n) => offset.saturating_add(n).min(total),
None => total,
};
let mut ops: Vec<OperationRecord> = Vec::with_capacity(end - offset);
for &i in &delta.order[offset..end] {
match log.get(delta.index.id(i)) {
Ok(Some(rec)) => ops.push(rec),
Ok(None) => {}
Err(e) => return error_response(500, format!("reading op {}: {e}", delta.index.id(i))),
}
}
if !files_v1 {
if let Some(rec) = ops.iter().find(|r| is_set_files(r)) {
return upgrade_required(rec);
}
if cursor.is_none() && offset == 0 {
for &i in &delta.order[end..] {
match log.get(delta.index.id(i)) {
Ok(Some(rec)) if is_set_files(&rec) => return upgrade_required(&rec),
Ok(_) => {}
Err(e) => return error_response(500, format!("reading op {}: {e}", delta.index.id(i))),
}
}
}
}
if let (Some(_), Some(a)) = (&cursor, &after) {
forget_continuation(state, &head, a);
}
if end < total && cursor.is_none() {
if let Some(last) = ops.last() {
record_continuation(state, Continuation {
head: head.clone(),
last: last.op_id.clone(),
base: base.clone(),
end,
at: now_secs(),
});
}
}
let resp = json_response(200, &serde_json::to_value(&ops).unwrap_or_default());
if end < total {
let next = encode_cursor(&head, base.as_deref(), end);
if let Ok(h) = Header::from_bytes(NEXT_CURSOR_HEADER.as_bytes(), next.as_bytes()) {
return resp.with_header(h);
}
}
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_round_trips() {
let h = "ab".repeat(32);
for base in [None, Some("cd".repeat(32))] {
let c = encode_cursor(&h, base.as_deref(), 1000);
assert_eq!(decode_cursor(&c), Some((h.clone(), base, 1000)));
}
}
fn cont(last: &str, base: Option<&str>, end: usize, at: u64) -> Continuation {
Continuation { head: "h".into(), last: last.into(), base: base.map(String::from), end, at }
}
#[test]
fn a_continuation_is_taken_once() {
let tmp = tempfile::tempdir().unwrap();
let state = State::open(tmp.path().to_path_buf()).unwrap();
record_continuation(&state, cont("x", None, 5, now_secs()));
let got = take_continuation(&state, &"h".into(), &"x".into()).unwrap();
assert_eq!((got.base, got.end), (None, 5));
assert!(take_continuation(&state, &"h".into(), &"x".into()).is_none());
assert!(take_continuation(&state, &"other".into(), &"x".into()).is_none());
}
#[test]
fn continuations_survive_a_restart() {
let tmp = tempfile::tempdir().unwrap();
record_continuation(&State::open(tmp.path().to_path_buf()).unwrap(), cont("x", Some("b"), 7, now_secs()));
let fresh = State::open(tmp.path().to_path_buf()).unwrap();
let got = take_continuation(&fresh, &"h".into(), &"x".into()).unwrap();
assert_eq!((got.base.as_deref(), got.end), (Some("b"), 7));
}
#[test]
fn an_abandoned_continuation_expires() {
let tmp = tempfile::tempdir().unwrap();
let state = State::open(tmp.path().to_path_buf()).unwrap();
record_continuation(&state, cont("x", None, 5, now_secs() - CONTINUATION_TTL_SECS - 1));
assert!(take_continuation(&state, &"h".into(), &"x".into()).is_none());
}
#[test]
fn two_readings_of_one_page_end_fall_back_to_the_cutoff() {
let tmp = tempfile::tempdir().unwrap();
let state = State::open(tmp.path().to_path_buf()).unwrap();
record_continuation(&state, cont("x", None, 5, now_secs()));
record_continuation(&state, cont("x", Some("b"), 2, now_secs()));
assert!(take_continuation(&state, &"h".into(), &"x".into()).is_none());
}
#[test]
fn malformed_cursors_are_rejected() {
for c in ["", "v1", "v2.ab._.0", "v1.ab._", "v1.ab._.x", "v1.ab._.1.2", "v1.../x._.0", "v1.ab.c/d.0"] {
assert_eq!(decode_cursor(c), None, "{c}");
}
}
}