use std::collections::{BTreeMap, BTreeSet};
use std::num::{NonZeroU64, NonZeroUsize};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
use std::thread::JoinHandle;
use std::time::Duration;
use haematite::SyncCounts;
use haematite::branch::{Timestamp, current_timestamp};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[cfg(test)]
use super::storage::{Crash, crash_at};
use super::storage::{
Failure, IntentListing, Keep, ReleaseAnswer, ReleaseRules, ReleaseSource, Storage,
};
const MAX_ROWS_MOST: u64 = 65_536;
const DAYS_MOST: u64 = 36_500;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "release_max_rows")]
pub release_max_rows: u64,
#[serde(default = "release_batch")]
pub release_batch: u64,
#[serde(default)]
pub keep: KeepConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
release_max_rows: release_max_rows(),
release_batch: release_batch(),
keep: KeepConfig::default(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeepConfig {
pub versions: Option<u64>,
pub days: Option<u64>,
}
const fn release_max_rows() -> u64 {
1_024
}
const fn release_batch() -> u64 {
256
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Settings {
pub max_rows: NonZeroUsize,
pub batch: NonZeroUsize,
pub keep: Keep,
}
impl Config {
pub fn settings(&self) -> Result<Settings, String> {
let max_rows = Some(self.release_max_rows)
.filter(|rows| *rows <= MAX_ROWS_MOST)
.and_then(|rows| usize::try_from(rows).ok())
.and_then(NonZeroUsize::new)
.ok_or_else(|| {
format!(
"retention.release_max_rows must be a whole number from 1 to \
{MAX_ROWS_MOST}, not {}",
self.release_max_rows
)
})?;
let batch = usize::try_from(self.release_batch)
.ok()
.and_then(NonZeroUsize::new)
.ok_or_else(|| {
format!(
"retention.release_batch must be at least 1, not {}",
self.release_batch
)
})?;
let versions = match self.keep.versions {
None => None,
Some(versions) => Some(NonZeroU64::new(versions).ok_or_else(|| {
"retention.keep.versions must be at least 1: the head is always kept".to_owned()
})?),
};
if let Some(days) = self.keep.days
&& days > DAYS_MOST
{
return Err(format!(
"retention.keep.days must be at most {DAYS_MOST}, not {days}"
));
}
Ok(Settings {
max_rows,
batch,
keep: Keep {
versions,
days: self.keep.days,
},
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Failed {
pub branch: String,
pub at: Timestamp,
pub failure: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Last {
pub branch: String,
pub at: Timestamp,
pub answer: ReleaseAnswer,
pub kept_by_setting: bool,
}
#[derive(Debug, Default)]
struct State {
waiting: BTreeSet<String>,
releasing: bool,
stopping: bool,
releases: u64,
last: Option<Last>,
last_failure: Option<Failed>,
died: Option<String>,
durable: SyncCounts,
due: BTreeMap<String, Timestamp>,
lap: bool,
cursor: Option<Vec<u8>>,
#[cfg(test)]
seam: Seam,
}
enum Next {
Release(String),
Scan(Option<Vec<u8>>),
}
#[cfg(test)]
#[derive(Debug, Default)]
struct Seam {
kill: bool,
hold: bool,
held: bool,
crash: Option<Crash>,
locks: u64,
clock: u64,
wakes: u64,
pages: u64,
released: Vec<String>,
}
#[derive(Debug, Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
impl Shared {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
fn wait<'a>(&self, state: MutexGuard<'a, State>) -> MutexGuard<'a, State> {
self.changed
.wait(state)
.unwrap_or_else(PoisonError::into_inner)
}
fn wait_timeout<'a>(
&self,
state: MutexGuard<'a, State>,
timeout: Duration,
) -> MutexGuard<'a, State> {
self.changed.wait_timeout(state, timeout).map_or_else(
|poisoned| poisoned.into_inner().0,
|(state, _timed_out)| state,
)
}
}
fn now_in(state: &State) -> Timestamp {
current_timestamp().saturating_add(clock_moved(state))
}
#[cfg(test)]
const fn clock_moved(state: &State) -> u64 {
state.seam.clock
}
#[cfg(not(test))]
const fn clock_moved(_state: &State) -> u64 {
0
}
#[cfg(test)]
const TEST_WAIT: Duration = Duration::from_millis(10);
#[derive(Debug)]
pub struct Releaser {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
batch: NonZeroUsize,
unset: Option<&'static str>,
}
const fn unset(keep: Keep) -> Option<&'static str> {
match (keep.versions.is_none(), keep.days.is_none()) {
(false, false) => None,
(true, false) => Some("retention.keep.versions is not set"),
(false, true) => Some("retention.keep.days is not set"),
(true, true) => Some("retention.keep.versions and retention.keep.days are not set"),
}
}
impl Releaser {
pub fn start(storage: Arc<Mutex<Storage>>, settings: Settings) -> Result<Self, Failure> {
let unset = unset(settings.keep);
let shared = Arc::new(Shared::default());
let mut writer = storage
.lock()
.map_err(|_| Failure::new("writer_unavailable", "writer state requires recovery"))?;
let now = current_timestamp();
let rules = ReleaseRules {
keep: Some(settings.keep),
max_rows: settings.max_rows,
now,
};
for branch in writer.decided_branches()? {
let answer = writer
.release(&branch, None, &rules, &IntentListing)
.map_err(|failure| {
Failure::new(
"release_decision_unfinished",
format!("{branch}: {failure}"),
)
})?;
let mut state = shared.lock();
state.releases = state.releases.saturating_add(1);
state.last = Some(Last {
branch,
at: now,
answer,
kept_by_setting: false,
});
}
let live = if unset.is_none() {
writer.branch_names()
} else {
Vec::new()
};
drop(writer);
let mut state = shared.lock();
state.waiting.extend(live);
state.lap = unset.is_none();
drop(state);
let thread_shared = Arc::clone(&shared);
let thread = std::thread::Builder::new()
.name("haematite-release".to_owned())
.spawn(move || {
let ended = run(&thread_shared, &storage, settings);
let mut state = thread_shared.lock();
state.releasing = false;
if !state.stopping {
state.died = Some(ended.err().unwrap_or_else(|| {
"release_thread_ended: it stopped without being told".to_owned()
}));
}
drop(state);
thread_shared.changed.notify_all();
})
.map_err(|error| Failure::new("release_thread_unstarted", error.to_string()))?;
Ok(Self {
shared,
thread: Some(thread),
batch: settings.batch,
unset,
})
}
#[must_use]
pub const fn due(&self, advanced: usize) -> bool {
self.unset.is_none() && advanced >= self.batch.get()
}
pub fn wake(&self, branch: &str) {
let added = self.shared.lock().waiting.insert(branch.to_owned());
if added {
self.shared.changed.notify_all();
}
}
pub fn discarded(&self, branch: &str) {
let mut state = self.shared.lock();
state.waiting.insert(branch.to_owned());
state.lap |= self.unset.is_none();
drop(state);
self.shared.changed.notify_all();
}
#[must_use]
pub fn is_releasing(&self) -> bool {
self.shared.lock().releasing
}
#[must_use]
pub fn status(&self) -> Value {
let finished = self.thread.as_ref().is_none_or(JoinHandle::is_finished);
let state = self.shared.lock();
let (name, reason) = match (&state.died, finished, state.stopping) {
(Some(reason), _, _) => ("died", Some(reason.clone())),
(None, true, true) => ("stopped", None),
(None, true, false) => (
"died",
Some("release_thread_ended: it stopped without being told".to_owned()),
),
(None, false, _) if state.releasing => ("releasing", None),
(None, false, _) => ("waiting", None),
};
json!({
"state": name,
"died": reason,
"waiting": state.waiting.len(),
"releases": state.releases,
"last": state.last,
"last_failure": state.last_failure,
"not_released": self.unset,
"durable": {
"record_installs": state.durable.record_installs,
"barrier_device_flushes": state.durable.barrier_device_flushes,
"ref_file_full_flushes": state.durable.ref_file_full_flushes,
"directory_full_flushes": state.durable.directory_full_flushes,
},
})
}
}
impl Drop for Releaser {
fn drop(&mut self) {
self.shared.lock().stopping = true;
self.shared.changed.notify_all();
if let Some(thread) = self.thread.take() {
drop(thread.join());
}
}
}
fn run(shared: &Shared, storage: &Mutex<Storage>, settings: Settings) -> Result<(), String> {
loop {
let mut state = shared.lock();
let next = loop {
#[cfg(test)]
if state.seam.kill {
return Err("release_thread_killed: by the test seam".to_owned());
}
if state.stopping {
return Ok(());
}
if let Some(branch) = state.waiting.pop_first() {
if unset(settings.keep).is_some() {
continue;
}
state.releasing = true;
break Next::Release(branch);
}
let now = now_in(&state);
let fallen: Vec<String> = state
.due
.iter()
.filter(|(_branch, at)| **at <= now)
.map(|(branch, _at)| branch.clone())
.collect();
if !fallen.is_empty() {
for branch in fallen {
state.due.remove(&branch);
state.waiting.insert(branch);
}
continue;
}
if state.lap {
state.releasing = true;
break Next::Scan(state.cursor.clone());
}
state = match state.due.values().min().copied() {
Some(at) => {
let timeout = Duration::from_nanos(at.saturating_sub(now));
#[cfg(test)]
let timeout = timeout.min(TEST_WAIT);
shared.wait_timeout(state, timeout)
}
None => shared.wait(state),
};
#[cfg(test)]
{
state.seam.wakes = state.seam.wakes.saturating_add(1);
}
};
drop(state);
match next {
Next::Release(branch) => release_one(shared, storage, branch, settings)?,
Next::Scan(after) => scan_one(shared, storage, after.as_deref(), settings)?,
}
}
}
fn scan_one(
shared: &Shared,
storage: &Mutex<Storage>,
after: Option<&[u8]>,
settings: Settings,
) -> Result<(), String> {
let writer = storage
.lock()
.map_err(|_| "release_storage_poisoned: the storage lock was poisoned".to_owned())?;
let page = writer.dead_page(after, settings.max_rows);
drop(writer);
let mut state = shared.lock();
state.releasing = false;
#[cfg(test)]
{
state.seam.pages = state.seam.pages.saturating_add(1);
}
match page {
Ok(page) => {
state.waiting.extend(page.names);
state.lap = !page.end;
state.cursor = if page.end { None } else { page.last };
}
Err(failure) => {
state.lap = false;
let at = now_in(&state);
state.last_failure = Some(Failed {
branch: "discarded generations".to_owned(),
at,
failure: failure.to_string(),
});
}
}
drop(state);
shared.changed.notify_all();
Ok(())
}
fn release_one(
shared: &Shared,
storage: &Mutex<Storage>,
branch: String,
settings: Settings,
) -> Result<(), String> {
#[cfg(test)]
{
let mut state = shared.lock();
state.seam.locks = state.seam.locks.saturating_add(1);
}
let mut writer = storage
.lock()
.map_err(|_| "release_storage_poisoned: the storage lock was poisoned".to_owned())?;
#[cfg(test)]
hold_here(shared);
#[cfg(test)]
{
let crash = shared.lock().seam.crash.take();
if let Some(point) = crash {
crash_at(point);
}
}
let before = writer.sync_counts();
let now = now_in(&shared.lock());
let rules = ReleaseRules {
keep: Some(settings.keep),
max_rows: settings.max_rows,
now,
};
let outcome = writer.release(&branch, Some(settings.max_rows), &rules, &IntentListing);
let made = writer.sync_counts().since(before);
drop(writer);
let mut state = shared.lock();
state.releasing = false;
state.durable = plus(state.durable, made);
match outcome {
Ok(answer) => {
state.releases = state.releases.saturating_add(1);
#[cfg(test)]
if answer.released.is_some() {
state.seam.released.push(branch.clone());
}
if answer.more {
state.waiting.insert(branch.clone());
}
match answer.due_at {
Some(at) => {
state.due.insert(branch.clone(), at);
}
None => {
state.due.remove(&branch);
}
}
let kept_by_setting = answer.released.is_none()
&& !answer.held_by.is_empty()
&& answer.held_by.iter().all(|held| {
matches!(
held.source,
ReleaseSource::KeepVersions | ReleaseSource::KeepDays
)
});
state.last = Some(Last {
branch,
at: now,
answer,
kept_by_setting,
});
}
Err(failure) => {
state.due.remove(&branch);
state.last_failure = Some(Failed {
branch,
at: now,
failure: failure.to_string(),
});
}
}
drop(state);
shared.changed.notify_all();
Ok(())
}
const fn plus(left: SyncCounts, right: SyncCounts) -> SyncCounts {
SyncCounts {
record_installs: left.record_installs.saturating_add(right.record_installs),
barrier_device_flushes: left
.barrier_device_flushes
.saturating_add(right.barrier_device_flushes),
ref_file_full_flushes: left
.ref_file_full_flushes
.saturating_add(right.ref_file_full_flushes),
directory_full_flushes: left
.directory_full_flushes
.saturating_add(right.directory_full_flushes),
}
}
#[cfg(test)]
fn hold_here(shared: &Shared) {
let mut state = shared.lock();
if !state.seam.hold {
return;
}
state.seam.held = true;
shared.changed.notify_all();
while state.seam.hold {
state = shared.wait(state);
}
state.seam.held = false;
}
#[cfg(test)]
impl Releaser {
pub fn advance_clock(&self, nanos: u64) {
let mut state = self.shared.lock();
state.seam.clock = state.seam.clock.saturating_add(nanos);
drop(state);
}
#[must_use]
pub fn wakes(&self) -> u64 {
self.shared.lock().seam.wakes
}
#[must_use]
pub fn next_due(&self) -> Option<Timestamp> {
self.shared.lock().due.values().min().copied()
}
#[must_use]
pub fn wait_releases(&self, at_least: u64, bound: Duration) -> bool {
let until = std::time::Instant::now() + bound;
let mut state = self.shared.lock();
while state.releases < at_least {
let left = until.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return false;
}
state = self.shared.wait_timeout(state, left);
}
drop(state);
true
}
pub fn kill(&self) {
self.shared.lock().seam.kill = true;
self.shared.changed.notify_all();
}
pub fn hold_next(&self) {
self.shared.lock().seam.hold = true;
}
pub fn wait_held(&self) {
let mut state = self.shared.lock();
while !state.seam.held {
state = self.shared.wait(state);
}
drop(state);
}
pub fn let_go(&self) {
self.shared.lock().seam.hold = false;
self.shared.changed.notify_all();
}
pub fn wait_idle(&self) {
let mut state = self.shared.lock();
while (!state.waiting.is_empty() || state.releasing || state.lap) && state.died.is_none() {
state = self.shared.wait(state);
}
drop(state);
}
pub fn wait_died(&self) {
let mut state = self.shared.lock();
while state.died.is_none() {
state = self.shared.wait(state);
}
drop(state);
}
pub fn fail_next(&self, point: Crash) {
self.shared.lock().seam.crash = Some(point);
}
#[must_use]
pub fn pages(&self) -> u64 {
self.shared.lock().seam.pages
}
#[must_use]
pub fn released(&self) -> Vec<String> {
self.shared.lock().seam.released.clone()
}
#[must_use]
pub fn locks(&self) -> u64 {
self.shared.lock().seam.locks
}
#[must_use]
pub fn last_failure(&self) -> Option<Failed> {
self.shared.lock().last_failure.clone()
}
#[must_use]
pub fn releases(&self) -> u64 {
self.shared.lock().releases
}
#[must_use]
pub fn durable(&self) -> SyncCounts {
self.shared.lock().durable
}
#[must_use]
pub fn last(&self) -> Option<Last> {
self.shared.lock().last.clone()
}
}
#[cfg(test)]
#[path = "releaser_tests.rs"]
mod tests;