use std::collections::{BTreeMap, HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use gunnar_core::RepoRef;
use gunnar_grpc::{EventBus, EventFilter};
use gunnar_proto::{event, EventKind, RefUpdate, RepoPath};
use serde::{Deserialize, Serialize};
use crate::replication_view::RefCoverage;
use crate::stores::ServerStores;
pub const TARGET_REPLICATION: &str = "gunnar::replication";
pub const REPLICATION_CONFIG: &str = "replication.json";
pub const REPLICATION_KNOWN_HOSTS: &str = "replication_known_hosts";
pub const DEFAULT_QUEUE_CAPACITY: usize = 1024;
pub const DEFAULT_RETRY_BASE: Duration = Duration::from_secs(2);
pub const DEFAULT_RETRY_MAX: Duration = Duration::from_secs(300);
pub const MAX_LOCAL_ATTEMPTS: u32 = 3;
const IDLE_WAIT: Duration = Duration::from_secs(3600);
pub const RECONNECT_PROBE: Duration = Duration::from_secs(5);
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "scope", rename_all = "snake_case")]
pub enum ReplicationScope {
Server,
Namespace {
namespace: String,
},
Repo {
namespace: String,
repo: String,
},
}
impl ReplicationScope {
pub fn covers(&self, path: &RepoRef) -> bool {
match self {
ReplicationScope::Server => true,
ReplicationScope::Namespace { namespace } => path.namespace() == namespace,
ReplicationScope::Repo { namespace, repo } => {
path.namespace() == namespace && path.name() == repo
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplicationTarget {
pub name: String,
#[serde(flatten)]
pub scope: ReplicationScope,
pub remote: String,
#[serde(default)]
pub identity: Option<PathBuf>,
#[serde(default)]
pub known_hosts: Option<PathBuf>,
#[serde(default)]
pub strict_host_key: bool,
#[serde(default)]
pub visibility: VisibilityScope,
#[serde(default)]
pub control: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum VisibilityScope {
#[default]
All,
PublicOnly,
}
impl VisibilityScope {
pub fn carries(self, visibility: gunnar_core::Visibility) -> bool {
match self {
VisibilityScope::All => true,
VisibilityScope::PublicOnly => visibility == gunnar_core::Visibility::Public,
}
}
}
impl ReplicationTarget {
pub fn probe_addr(&self) -> Option<String> {
let after_scheme = self.remote.split("://").nth(1)?;
let authority = after_scheme.split(['/', '?', '#']).next()?;
let hostport = authority.rsplit('@').next()?;
if hostport.is_empty() {
return None;
}
if let Some(rest) = hostport.strip_prefix('[') {
let (host, tail) = rest.split_once(']')?;
let port = tail.strip_prefix(':').unwrap_or("22");
return Some(format!("[{host}]:{port}"));
}
match hostport.split_once(':') {
Some((host, port)) if !host.is_empty() && !port.is_empty() => {
Some(format!("{host}:{port}"))
}
_ => Some(format!("{hostport}:22")),
}
}
pub fn url_for(&self, path: &RepoRef) -> String {
if self.remote.ends_with(".git") {
return self.remote.clone();
}
format!(
"{}/{}/{}.git",
self.remote.trim_end_matches('/'),
path.namespace(),
path.name()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Role {
#[default]
Primary,
Standby,
Browse,
}
impl Role {
pub fn is_standby(self) -> bool {
matches!(self, Role::Standby)
}
pub fn is_browse(self) -> bool {
matches!(self, Role::Browse)
}
pub fn refuses_client_writes(self) -> bool {
matches!(self, Role::Standby | Role::Browse)
}
pub fn to_proto(self) -> gunnar_proto::ReplicationRole {
match self {
Role::Primary => gunnar_proto::ReplicationRole::Primary,
Role::Standby => gunnar_proto::ReplicationRole::Standby,
Role::Browse => gunnar_proto::ReplicationRole::Browse,
}
}
pub fn from_proto(role: gunnar_proto::ReplicationRole) -> Role {
match role {
gunnar_proto::ReplicationRole::Primary => Role::Primary,
gunnar_proto::ReplicationRole::Standby => Role::Standby,
gunnar_proto::ReplicationRole::Browse => Role::Browse,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplicationConfig {
#[serde(default)]
pub role: Role,
#[serde(default)]
pub targets: Vec<ReplicationTarget>,
#[serde(default = "default_queue_capacity")]
pub queue_capacity: usize,
#[serde(default = "default_retry_base_ms")]
pub retry_base_ms: u64,
#[serde(default = "default_retry_max_ms")]
pub retry_max_ms: u64,
#[serde(default = "default_ref_coverage_ttl_ms")]
pub ref_coverage_ttl_ms: u64,
#[serde(skip)]
pub subscriber_disabled: bool,
}
fn default_queue_capacity() -> usize {
DEFAULT_QUEUE_CAPACITY
}
fn default_retry_base_ms() -> u64 {
DEFAULT_RETRY_BASE.as_millis() as u64
}
fn default_retry_max_ms() -> u64 {
DEFAULT_RETRY_MAX.as_millis() as u64
}
fn default_ref_coverage_ttl_ms() -> u64 {
REF_COVERAGE_TTL.as_millis() as u64
}
impl Default for ReplicationConfig {
fn default() -> Self {
ReplicationConfig {
role: Role::Primary,
targets: Vec::new(),
queue_capacity: DEFAULT_QUEUE_CAPACITY,
retry_base_ms: default_retry_base_ms(),
retry_max_ms: default_retry_max_ms(),
ref_coverage_ttl_ms: default_ref_coverage_ttl_ms(),
subscriber_disabled: false,
}
}
}
impl ReplicationConfig {
pub fn load(root: impl AsRef<Path>) -> Result<Self> {
let path = root.as_ref().join(REPLICATION_CONFIG);
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(ReplicationConfig::default())
}
Err(err) => {
return Err(err).with_context(|| format!("reading {}", path.display()));
}
};
if text.trim().is_empty() {
tracing::warn!(
target: TARGET_REPLICATION,
event = "replication.config_empty",
path = %path.display(),
"replication.json is empty — the signature of a power cut during a save. \
Treating it as no replication configured and coming up serving; it is \
rewritten on the next apply. Reconfigure standbys over the control plane if \
this box had any."
);
return Ok(ReplicationConfig::default());
}
serde_json::from_str(&text).with_context(|| {
format!(
"parsing {}; gunnar will not start replicating nowhere because of a syntax error",
path.display()
)
})
}
pub fn save(&self, root: impl AsRef<Path>) -> Result<()> {
let text = serde_json::to_string_pretty(self)?;
crate::durable::write_atomic(root.as_ref(), REPLICATION_CONFIG, text.as_bytes())
}
pub fn save_mirrored(
&self,
root: impl AsRef<Path>,
slow: &crate::slow::SlowRoot,
) -> Result<()> {
let text = serde_json::to_string_pretty(self)?;
crate::durable::write_atomic_mirrored(
root.as_ref(),
slow,
REPLICATION_CONFIG,
text.as_bytes(),
)
}
pub fn set_target(&mut self, target: ReplicationTarget) {
match self.targets.iter_mut().find(|t| t.name == target.name) {
Some(existing) => *existing = target,
None => self.targets.push(target),
}
}
pub fn remove_target(&mut self, name: &str) -> bool {
let before = self.targets.len();
self.targets.retain(|t| t.name != name);
before != self.targets.len()
}
pub fn clear_targets(&mut self) -> usize {
let cleared = self.targets.len();
self.targets.clear();
cleared
}
fn retry_base(&self) -> Duration {
Duration::from_millis(self.retry_base_ms.max(1))
}
fn retry_max(&self) -> Duration {
Duration::from_millis(self.retry_max_ms.max(self.retry_base_ms.max(1)))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ReplicationCounters {
pub enqueued: u64,
pub coalesced: u64,
pub refused_queue_full: u64,
pub attempts: u64,
pub succeeded: u64,
pub failed: u64,
pub refs_replicated: u64,
pub bus_gaps: u64,
pub stopped_incompatible: u64,
pub stalled_local: u64,
pub forgotten: u64,
pub cancelled: u64,
}
#[derive(Debug, Default)]
struct Counters {
enqueued: AtomicU64,
coalesced: AtomicU64,
refused_queue_full: AtomicU64,
attempts: AtomicU64,
succeeded: AtomicU64,
failed: AtomicU64,
refs_replicated: AtomicU64,
bus_gaps: AtomicU64,
stopped_incompatible: AtomicU64,
stalled_local: AtomicU64,
forgotten: AtomicU64,
cancelled: AtomicU64,
}
impl Counters {
fn snapshot(&self) -> ReplicationCounters {
let read = |c: &AtomicU64| c.load(Ordering::Relaxed);
ReplicationCounters {
enqueued: read(&self.enqueued),
coalesced: read(&self.coalesced),
refused_queue_full: read(&self.refused_queue_full),
attempts: read(&self.attempts),
succeeded: read(&self.succeeded),
failed: read(&self.failed),
refs_replicated: read(&self.refs_replicated),
bus_gaps: read(&self.bus_gaps),
stopped_incompatible: read(&self.stopped_incompatible),
stalled_local: read(&self.stalled_local),
forgotten: read(&self.forgotten),
cancelled: read(&self.cancelled),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct JobKey {
pub namespace: String,
pub repo: String,
}
impl JobKey {
fn path(&self) -> Result<RepoRef> {
RepoRef::new(&self.namespace, &self.repo).with_context(|| {
format!(
"{}/{} is not a legal repository path",
self.namespace, self.repo
)
})
}
fn proto(&self) -> RepoPath {
RepoPath::new(&self.namespace, &self.repo)
}
}
impl std::fmt::Display for JobKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.namespace, self.repo)
}
}
#[derive(Debug, Clone)]
struct Job {
key: JobKey,
attempt: u32,
ready_at: Instant,
first_seen: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Enqueued {
Accepted,
Coalesced,
RefusedQueueFull,
}
#[derive(Debug)]
struct Queue {
capacity: usize,
order: VecDeque<JobKey>,
jobs: HashMap<JobKey, Job>,
}
impl Queue {
fn new(capacity: usize) -> Self {
Queue {
capacity: capacity.max(1),
order: VecDeque::new(),
jobs: HashMap::new(),
}
}
fn len(&self) -> usize {
self.jobs.len()
}
fn oldest_first_seen(&self) -> Option<Instant> {
self.jobs.values().map(|job| job.first_seen).min()
}
fn drain_jobs(&mut self) -> Vec<Job> {
let mut out = Vec::with_capacity(self.jobs.len());
while let Some(key) = self.order.pop_front() {
if let Some(job) = self.jobs.remove(&key) {
out.push(job);
}
}
self.jobs.clear();
out
}
fn adopt(&mut self, jobs: Vec<Job>) -> usize {
let mut lost = 0;
for job in jobs {
if self.jobs.contains_key(&job.key) {
continue;
}
if self.jobs.len() >= self.capacity {
lost += 1;
continue;
}
self.order.push_back(job.key.clone());
self.jobs.insert(job.key.clone(), job);
}
lost
}
fn remove(&mut self, key: &JobKey) -> bool {
self.jobs.remove(key).is_some()
}
fn push(&mut self, key: JobKey, ready_at: Instant) -> Enqueued {
if let Some(existing) = self.jobs.get_mut(&key) {
if ready_at < existing.ready_at {
existing.ready_at = ready_at;
}
return Enqueued::Coalesced;
}
if self.jobs.len() >= self.capacity {
return Enqueued::RefusedQueueFull;
}
self.order.push_back(key.clone());
self.jobs.insert(
key.clone(),
Job {
key,
attempt: 0,
ready_at,
first_seen: ready_at,
},
);
Enqueued::Accepted
}
fn requeue(&mut self, job: Job) {
if self.jobs.contains_key(&job.key) {
return;
}
self.order.push_back(job.key.clone());
self.jobs.insert(job.key.clone(), job);
}
fn ready_now(&mut self, now: Instant) -> usize {
let mut moved = 0;
for job in self.jobs.values_mut() {
if job.ready_at > now {
job.ready_at = now;
moved += 1;
}
}
moved
}
fn take_ready(&mut self, now: Instant) -> std::result::Result<Job, Option<Instant>> {
let mut rotated = 0;
let mut earliest: Option<Instant> = None;
while rotated < self.order.len() {
let key = self.order[0].clone();
let ready_at = match self.jobs.get(&key) {
Some(job) => job.ready_at,
None => {
self.order.pop_front();
continue;
}
};
if ready_at <= now {
self.order.pop_front();
return Ok(self.jobs.remove(&key).expect("just looked it up"));
}
earliest = Some(match earliest {
Some(e) if e <= ready_at => e,
_ => ready_at,
});
self.order.rotate_left(1);
rotated += 1;
}
Err(earliest)
}
}
pub fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration {
crate::durable::backoff(attempt, base, max)
}
async fn reachable_again(addr: &str) {
loop {
tokio::time::sleep(RECONNECT_PROBE).await;
if let Ok(Ok(stream)) =
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect(addr)).await
{
drop(stream);
return;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetStatus {
pub name: String,
pub remote: String,
pub pending: usize,
pub in_flight: Option<String>,
pub outstanding_for: Option<Duration>,
pub consecutive_failures: u32,
pub last_error: Option<String>,
pub last_success_unix_ms: Option<i64>,
pub needs_full_resync: bool,
}
#[derive(Debug, Clone, Default)]
struct RepoRecord {
last_success_unix_ms: i64,
refs_at_last_success: u64,
last_error: Option<String>,
stopped: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoCopy {
pub standby: String,
pub namespace: String,
pub repo: String,
pub last_success_unix_ms: i64,
pub refs_at_last_success: u64,
pub outstanding: bool,
pub last_error: String,
pub stopped: bool,
pub sha1: bool,
pub refs: Option<RefCoverage>,
}
pub const REF_COVERAGE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Default)]
struct TargetState {
consecutive_failures: u32,
last_error: Option<String>,
last_success_unix_ms: Option<i64>,
needs_full_resync: bool,
in_flight: Option<(JobKey, Instant)>,
}
struct TargetRunner {
target: ReplicationTarget,
queue: Mutex<Queue>,
state: Mutex<TargetState>,
history: Mutex<HashMap<JobKey, RepoRecord>>,
wake: tokio::sync::Notify,
counters: Arc<Counters>,
retry_base: Duration,
retry_max: Duration,
bus: Arc<EventBus>,
}
impl TargetRunner {
fn lock_queue(&self) -> std::sync::MutexGuard<'_, Queue> {
self.queue.lock().unwrap_or_else(|p| p.into_inner())
}
fn lock_state(&self) -> std::sync::MutexGuard<'_, TargetState> {
self.state.lock().unwrap_or_else(|p| p.into_inner())
}
fn lock_history(&self) -> std::sync::MutexGuard<'_, HashMap<JobKey, RepoRecord>> {
self.history.lock().unwrap_or_else(|p| p.into_inner())
}
fn cancel(&self, key: &JobKey) -> bool {
let dropped = self.lock_queue().remove(key);
self.lock_history().remove(key);
if dropped {
self.counters.cancelled.fetch_add(1, Ordering::Relaxed);
}
dropped
}
fn enqueue(&self, key: JobKey) -> Enqueued {
let outcome = self.lock_queue().push(key.clone(), Instant::now());
match outcome {
Enqueued::Accepted => {
self.counters.enqueued.fetch_add(1, Ordering::Relaxed);
self.wake.notify_one();
}
Enqueued::Coalesced => {
self.counters.coalesced.fetch_add(1, Ordering::Relaxed);
self.wake.notify_one();
}
Enqueued::RefusedQueueFull => {
self.counters
.refused_queue_full
.fetch_add(1, Ordering::Relaxed);
self.lock_state().needs_full_resync = true;
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.queue_full",
standby = %self.target.name,
repo = %key,
capacity = self.lock_queue().capacity,
"the replication queue for this standby is full; the repository was NOT \
queued and the standby now needs a full resync. Retrying will not close \
this hole; run `gunnar replication resync`."
);
self.fail_event(
&key,
0,
Duration::ZERO,
"the replication queue is full",
true,
);
}
}
outcome
}
fn fail_event(
&self,
key: &JobKey,
attempt: u32,
retry_in: Duration,
reason: &str,
needs_full_resync: bool,
) {
self.bus.publish(event::Payload::ReplicationFailed(
gunnar_proto::ReplicationFailed {
repo: Some(key.proto()),
target: self.target.name.clone(),
remote_url: key
.path()
.map(|p| self.target.url_for(&p))
.unwrap_or_else(|_| self.target.remote.clone()),
attempt,
retry_in_ms: retry_in.as_millis() as u64,
reason: reason.to_owned(),
needs_full_resync,
},
));
}
fn status(&self) -> TargetStatus {
let (queued, queued_oldest) = {
let queue = self.lock_queue();
(queue.len(), queue.oldest_first_seen())
};
let state = self.lock_state();
let now = Instant::now();
let oldest = match (queued_oldest, state.in_flight.as_ref()) {
(Some(q), Some((_, f))) => Some(q.min(*f)),
(Some(q), None) => Some(q),
(None, Some((_, f))) => Some(*f),
(None, None) => None,
};
TargetStatus {
name: self.target.name.clone(),
remote: self.target.remote.clone(),
pending: queued + usize::from(state.in_flight.is_some()),
in_flight: state.in_flight.as_ref().map(|(key, _)| key.to_string()),
outstanding_for: oldest.map(|at| now.saturating_duration_since(at)),
consecutive_failures: state.consecutive_failures,
last_error: state.last_error.clone(),
last_success_unix_ms: state.last_success_unix_ms,
needs_full_resync: state.needs_full_resync,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResyncReport {
pub standby: String,
pub in_scope: u64,
pub queued: u64,
pub already_pending: u64,
pub refused: u64,
pub sha1_repos: Vec<String>,
pub excluded: Vec<String>,
pub private_skipped: u64,
}
type CoverageWalk = (Instant, Arc<BTreeMap<String, RefCoverage>>);
pub struct Replicator {
stores: Arc<ServerStores>,
bus: Arc<EventBus>,
config: ReplicationConfig,
runners: Vec<Arc<TargetRunner>>,
counters: Arc<Counters>,
observed_since_unix_ms: i64,
coverage: Mutex<Option<CoverageWalk>>,
}
pub struct ReplicationTasks {
tasks: Vec<tokio::task::JoinHandle<()>>,
}
impl ReplicationTasks {
pub fn abort(&self) {
for task in &self.tasks {
task.abort();
}
}
pub fn len(&self) -> usize {
self.tasks.len()
}
pub fn is_empty(&self) -> bool {
self.tasks.is_empty()
}
}
impl Drop for ReplicationTasks {
fn drop(&mut self) {
self.abort();
}
}
impl Replicator {
pub fn new(
stores: Arc<ServerStores>,
bus: Arc<EventBus>,
config: ReplicationConfig,
) -> Arc<Self> {
Self::new_carrying(stores, bus, config, None)
}
pub fn new_carrying(
stores: Arc<ServerStores>,
bus: Arc<EventBus>,
config: ReplicationConfig,
previous: Option<&Replicator>,
) -> Arc<Self> {
let counters = match previous {
Some(previous) => Arc::clone(&previous.counters),
None => Arc::new(Counters::default()),
};
let retry_base = config.retry_base();
let retry_max = config.retry_max();
let runners = config
.targets
.iter()
.map(|target| {
if target.identity.is_none() {
tracing::warn!(
target: TARGET_REPLICATION,
event = "replication.no_identity",
standby = %target.name,
"this replication target names no identity file, so the push will fall \
back to the client key ladder (~/.ssh). A server should authenticate to \
its standby with an explicit deploy key."
);
}
let mut queue = Queue::new(config.queue_capacity);
let mut state = TargetState::default();
let mut history: HashMap<JobKey, RepoRecord> = HashMap::new();
if let Some(old) = previous
.iter()
.flat_map(|p| p.runners.iter())
.find(|r| r.target.name == target.name)
{
let same_peer = old.target.remote == target.remote;
let mut carried = old.lock_queue().drain_jobs();
let mut old_state = old.lock_state();
if let Some((key, first_seen)) = old_state.in_flight.take() {
carried.push(Job {
key,
attempt: 0,
ready_at: Instant::now(),
first_seen,
});
}
let lost = queue.adopt(carried);
state = TargetState {
needs_full_resync: old_state.needs_full_resync
|| lost > 0
|| !same_peer,
..TargetState::default()
};
if same_peer {
state.consecutive_failures = old_state.consecutive_failures;
state.last_error = old_state.last_error.clone();
state.last_success_unix_ms = old_state.last_success_unix_ms;
history = old.lock_history().clone();
} else {
tracing::warn!(
target: TARGET_REPLICATION,
event = "replication.retargeted",
standby = %target.name,
was = %old.target.remote,
now = %target.remote,
"this standby's name was kept and its URL changed, so it is a DIFFERENT box: its success history is cleared rather than carried, and it needs a full resync"
);
}
}
Arc::new(TargetRunner {
target: target.clone(),
queue: Mutex::new(queue),
state: Mutex::new(state),
history: Mutex::new(history),
wake: tokio::sync::Notify::new(),
counters: Arc::clone(&counters),
retry_base,
retry_max,
bus: Arc::clone(&bus),
})
})
.collect();
Arc::new(Replicator {
stores,
bus,
config,
runners,
counters,
observed_since_unix_ms: previous
.map(|p| p.observed_since_unix_ms)
.unwrap_or_else(gunnar_grpc::bus::now_unix_ms),
coverage: Mutex::new(None),
})
}
pub fn ref_coverage_by_repo(&self, max_age: Duration) -> Arc<BTreeMap<String, RefCoverage>> {
let mut slot = match self.coverage.lock() {
Ok(slot) => slot,
Err(poisoned) => poisoned.into_inner(),
};
if let Some((taken, coverage)) = slot.as_ref() {
if !max_age.is_zero() && taken.elapsed() < max_age {
return Arc::clone(coverage);
}
}
let fresh = Arc::new(self.walk_ref_coverage());
*slot = Some((Instant::now(), Arc::clone(&fresh)));
fresh
}
pub fn ref_coverage(&self, max_age: Duration) -> RefCoverage {
let mut total = RefCoverage::default();
for coverage in self.ref_coverage_by_repo(max_age).values() {
total.add(*coverage);
}
total
}
pub fn ref_coverage_max_age(&self) -> Duration {
Duration::from_millis(self.config.ref_coverage_ttl_ms)
}
pub fn ref_coverage_cached(&self) -> RefCoverage {
let slot = match self.coverage.lock() {
Ok(slot) => slot,
Err(poisoned) => poisoned.into_inner(),
};
let mut total = RefCoverage::default();
if let Some((_taken, coverage)) = slot.as_ref() {
for c in coverage.values() {
total.add(*c);
}
}
total
}
fn walk_ref_coverage(&self) -> BTreeMap<String, RefCoverage> {
use gunnar_client::mirror::is_mirrored_ref;
use gunnar_store::git_storage_trait::GitOps;
let repos = match self.stores.catalog().list(None) {
Ok(repos) => repos,
Err(err) => {
tracing::warn!(
target: TARGET_REPLICATION,
%err,
"could not list repositories to count the refs a mirror does not carry"
);
return BTreeMap::new();
}
};
let mut out = BTreeMap::new();
for repo in repos {
let path = repo.path();
let key = format!("{}/{}", path.namespace(), path.name());
let rows = match self.stores.repo_store(&repo).and_then(|store| {
let git = store.serve();
GitOps::refs(git).context("reading this repository's refs")
}) {
Ok(rows) => rows,
Err(err) => {
tracing::warn!(
target: TARGET_REPLICATION,
repo = %key,
%err,
"could not count this repository's refs; its unmirrored count is \
reported as unknown rather than as zero"
);
continue;
}
};
let mut coverage = RefCoverage::default();
for row in rows {
coverage.total += 1;
if !is_mirrored_ref(&row.name) {
coverage.unmirrored += 1;
}
}
out.insert(key, coverage);
}
out
}
pub fn config(&self) -> &ReplicationConfig {
&self.config
}
pub fn observed_since_unix_ms(&self) -> i64 {
self.observed_since_unix_ms
}
pub fn counters(&self) -> ReplicationCounters {
self.counters.snapshot()
}
pub fn status(&self) -> Vec<TargetStatus> {
self.runners.iter().map(|r| r.status()).collect()
}
pub fn repo_copies(
&self,
coverage: Option<&BTreeMap<String, RefCoverage>>,
) -> Result<Vec<RepoCopy>> {
let repos = self
.stores
.catalog()
.list(None)
.context("listing repositories to report their second copies")?;
let empty = BTreeMap::new();
let coverage = coverage.unwrap_or(&empty);
let mut out = Vec::new();
for runner in &self.runners {
let outstanding: std::collections::HashSet<JobKey> = {
let queue = runner.lock_queue();
let state = runner.lock_state();
queue
.jobs
.keys()
.cloned()
.chain(state.in_flight.iter().map(|(key, _)| key.clone()))
.collect()
};
let history = runner.lock_history();
for repo in &repos {
let path = repo.path();
if !runner.target.scope.covers(&path) {
continue;
}
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
let record = history.get(&key).cloned().unwrap_or_default();
out.push(RepoCopy {
standby: runner.target.name.clone(),
refs: coverage
.get(&format!("{}/{}", key.namespace, key.repo))
.copied(),
namespace: key.namespace.clone(),
repo: key.repo.clone(),
last_success_unix_ms: record.last_success_unix_ms,
refs_at_last_success: record.refs_at_last_success,
outstanding: outstanding.contains(&key),
last_error: record.last_error.unwrap_or_default(),
stopped: record.stopped,
sha1: repo.hash_kind == gunnar_core::HashKind::Sha1,
});
}
}
Ok(out)
}
pub fn enqueue(&self, path: &RepoRef) -> Vec<(String, Enqueued)> {
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
let visibility = self.visibility_of(path);
self.runners
.iter()
.filter(|r| r.target.scope.covers(path))
.filter(|r| r.target.visibility.carries(visibility))
.map(|r| (r.target.name.clone(), r.enqueue(key.clone())))
.collect()
}
pub fn enqueue_forget(&self, path: &RepoRef) -> Vec<(String, Enqueued)> {
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
self.runners
.iter()
.filter(|r| r.target.scope.covers(path))
.filter(|r| r.target.visibility == VisibilityScope::PublicOnly)
.map(|r| (r.target.name.clone(), r.enqueue(key.clone())))
.collect()
}
pub fn cancel(&self, path: &RepoRef) -> Vec<(String, bool)> {
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
self.runners
.iter()
.filter(|r| r.target.scope.covers(path))
.map(|r| (r.target.name.clone(), r.cancel(&key)))
.collect()
}
fn visibility_of(&self, path: &RepoRef) -> gunnar_core::Visibility {
match self.stores.catalog().get(path) {
Ok(Some(repo)) => repo.visibility,
Ok(None) => gunnar_core::Visibility::Private,
Err(err) => {
tracing::warn!(
target: TARGET_REPLICATION,
repo = %path,
%err,
"the catalog could not be read for a replication visibility check; \
treating the repository as Private"
);
gunnar_core::Visibility::Private
}
}
}
pub fn resync(&self) -> Result<usize> {
Ok(self
.resync_scoped(None, None, &[])?
.iter()
.map(|report| report.queued)
.sum::<u64>() as usize)
}
pub fn resync_scoped(
&self,
standby: Option<&str>,
only: Option<&RepoRef>,
except: &[RepoRef],
) -> Result<Vec<ResyncReport>> {
if let Some(name) = standby {
anyhow::ensure!(
self.runners.iter().any(|r| r.target.name == name),
"no replication target named {name:?}; configured: [{}]",
self.runners
.iter()
.map(|r| r.target.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
let repos = match only {
None => self
.stores
.catalog()
.list(None)
.context("listing repositories for a replication resync")?,
Some(path) => vec![self
.stores
.catalog()
.get(path)
.with_context(|| format!("looking {path} up for a replication resync"))?
.with_context(|| {
format!("{path} is not a repository on this server, so it cannot be resynced")
})?],
};
let mut out = Vec::new();
for runner in &self.runners {
if standby.is_some_and(|name| runner.target.name != name) {
continue;
}
let mut report = ResyncReport {
standby: runner.target.name.clone(),
..ResyncReport::default()
};
for repo in &repos {
let path = repo.path();
if !runner.target.scope.covers(&path) {
continue;
}
if only.is_none()
&& except
.iter()
.any(|e| e.namespace() == path.namespace() && e.name() == path.name())
{
report.excluded.push(path.to_string());
continue;
}
if !runner.target.visibility.carries(repo.visibility) {
report.private_skipped += 1;
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
match runner.enqueue(key) {
Enqueued::Accepted | Enqueued::Coalesced => {}
Enqueued::RefusedQueueFull => report.refused += 1,
}
continue;
}
report.in_scope += 1;
if repo.hash_kind == gunnar_core::HashKind::Sha1 {
report.sha1_repos.push(path.to_string());
}
let key = JobKey {
namespace: path.namespace().to_owned(),
repo: path.name().to_owned(),
};
match runner.enqueue(key) {
Enqueued::Accepted => report.queued += 1,
Enqueued::Coalesced => report.already_pending += 1,
Enqueued::RefusedQueueFull => report.refused += 1,
}
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.resync",
standby = %runner.target.name,
scope = %match only {
Some(path) => path.to_string(),
None => "every covered repository".to_owned(),
},
in_scope = report.in_scope,
excluded = report.excluded.len(),
queued = report.queued,
already_pending = report.already_pending,
refused = report.refused,
sha1 = report.sha1_repos.len(),
private_skipped = report.private_skipped,
"a replication resync queued a standby's backfill"
);
if only.is_none() && report.refused == 0 && report.excluded.is_empty() {
let mut state = runner.lock_state();
if state.needs_full_resync {
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.resync_complete",
standby = %runner.target.name,
"every covered repository was queued; the standby no longer needs a \
full resync"
);
state.needs_full_resync = false;
}
}
out.push(report);
}
Ok(out)
}
pub fn spawn(self: &Arc<Self>) -> ReplicationTasks {
let mut tasks = Vec::new();
if self.runners.is_empty() {
return ReplicationTasks { tasks };
}
for runner in &self.runners {
let runner = Arc::clone(runner);
let me = Arc::clone(self);
tasks.push(tokio::spawn(async move { worker(me, runner).await }));
}
if self.config.subscriber_disabled {
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.subscriber_disabled",
"the replication subscriber is DISABLED. Nothing will ever be enqueued from a \
push. This is a test seam and is not reachable from configuration."
);
} else {
let me = Arc::clone(self);
let bus = Arc::clone(&self.bus);
tasks.push(tokio::spawn(async move { subscribe(me, bus).await }));
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.started",
standbys = self.runners.len(),
queue_capacity = self.config.queue_capacity,
tasks = tasks.len(),
"replication is running"
);
ReplicationTasks { tasks }
}
}
pub struct ReplicationSlot {
stores: Arc<ServerStores>,
bus: Arc<EventBus>,
runtime: tokio::runtime::Handle,
running: Mutex<Option<(Arc<Replicator>, ReplicationTasks)>>,
}
impl ReplicationSlot {
pub fn new(
stores: Arc<ServerStores>,
bus: Arc<EventBus>,
runtime: tokio::runtime::Handle,
) -> Arc<Self> {
Arc::new(ReplicationSlot {
stores,
bus,
runtime,
running: Mutex::new(None),
})
}
pub fn replicator(&self) -> Option<Arc<Replicator>> {
self.running
.lock()
.expect("the replication slot lock is not poisoned")
.as_ref()
.map(|(replicator, _)| Arc::clone(replicator))
}
pub fn config(&self) -> Result<ReplicationConfig> {
if let Some(replicator) = self.replicator() {
return Ok(replicator.config().clone());
}
ReplicationConfig::load(self.stores.root()).context("reading the replication configuration")
}
pub fn apply(&self, config: ReplicationConfig) -> Result<usize> {
let saved = if self.stores.is_twin() {
config.save(self.stores.root())
} else {
config.save_mirrored(self.stores.root(), self.stores.slow())
};
saved.context("writing the replication configuration")?;
self.stores
.writes_frozen()
.store(config.role.refuses_client_writes(), std::sync::atomic::Ordering::SeqCst);
self.stores
.browse()
.store(config.role.is_browse(), std::sync::atomic::Ordering::SeqCst);
let mut running = self
.running
.lock()
.expect("the replication slot lock is not poisoned");
let previous = running.take().map(|(replicator, tasks)| {
tasks.abort();
replicator
});
if config.targets.is_empty() {
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.stopped",
"the last replication target was removed; the replicator is stopped"
);
return Ok(0);
}
let replicator = Replicator::new_carrying(
Arc::clone(&self.stores),
Arc::clone(&self.bus),
config,
previous.as_deref(),
);
let tasks = {
let _guard = self.runtime.enter();
replicator.spawn()
};
anyhow::ensure!(
!tasks.is_empty(),
"a replicator with standbys configured started no tasks; it would be a replication \
that silently never runs, which is the failure replication is about"
);
let standbys = replicator.runners.len();
*running = Some((replicator, tasks));
Ok(standbys)
}
pub fn stop(&self) {
if let Some((_, tasks)) = self
.running
.lock()
.expect("the replication slot lock is not poisoned")
.take()
{
tasks.abort();
}
}
}
async fn subscribe(replicator: Arc<Replicator>, bus: Arc<EventBus>) {
use tokio_stream::StreamExt;
let filter = EventFilter {
kinds: vec![
EventKind::RefUpdated,
EventKind::RefDeleted,
EventKind::VisibilityChanged,
EventKind::RepoDeleted,
],
..EventFilter::default()
};
let mut stream = Box::pin(bus.subscribe(filter));
while let Some(next) = stream.next().await {
let event = match next {
Ok(event) => event,
Err(status) => {
tracing::warn!(
target: TARGET_REPLICATION,
%status,
"the replication subscription yielded an error; continuing"
);
continue;
}
};
match event.payload.as_ref() {
Some(event::Payload::Gap(gap)) => {
replicator.counters.bus_gaps.fetch_add(1, Ordering::Relaxed);
for runner in &replicator.runners {
runner.lock_state().needs_full_resync = true;
}
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.bus_gap",
dropped = gap.dropped,
"the replication subscriber fell behind the event bus and lost events; \
re-queueing every covered repository"
);
if let Err(err) = replicator.resync() {
tracing::error!(
target: TARGET_REPLICATION,
%err,
"the resync that a bus gap requires could not be started; the standbys \
remain marked as needing one"
);
}
}
Some(event::Payload::RefUpdated(update)) => {
enqueue_from(&replicator, update.repo.as_ref());
}
Some(event::Payload::RefDeleted(deleted)) => {
enqueue_from(&replicator, deleted.repo.as_ref());
}
Some(event::Payload::VisibilityChanged(changed)) => {
let private = changed.to == gunnar_proto::Visibility::Private as i32;
let Some(repo) = changed.repo.as_ref() else { continue };
match RepoRef::new(&repo.namespace, &repo.repo) {
Ok(path) if private => {
forget_from(&replicator, &path, "a repository went Private");
}
Ok(_) => enqueue_from(&replicator, Some(repo)),
Err(err) => tracing::warn!(
target: TARGET_REPLICATION,
%err,
"a VisibilityChanged event named a path this server cannot resolve"
),
}
}
Some(event::Payload::RepoDeleted(deleted)) => {
let Some(repo) = deleted.repo.as_ref() else { continue };
match RepoRef::new(&repo.namespace, &repo.repo) {
Ok(path) => {
cancel_from(&replicator, &path, "a repository was deleted");
forget_from(&replicator, &path, "a repository was deleted");
}
Err(err) => tracing::warn!(
target: TARGET_REPLICATION,
%err,
"a RepoDeleted event named a path this server cannot resolve"
),
}
}
_ => {}
}
}
tracing::warn!(
target: TARGET_REPLICATION,
"the event stream ended; replication is no longer being fed by pushes"
);
}
fn forget_from(replicator: &Arc<Replicator>, path: &RepoRef, why: &str) {
for (standby, outcome) in replicator.enqueue_forget(path) {
if outcome == Enqueued::RefusedQueueFull {
if let Some(runner) = replicator.runners.iter().find(|r| r.target.name == standby) {
runner.lock_state().needs_full_resync = true;
}
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.forget_refused",
%standby,
repo = %path,
"{why}, and the public-only replica's queue was full, so the instruction to \
forget it could not be queued. The standby is marked as needing a full \
resync, which is what re-derives the forget; until then the replica may \
still be serving this repository"
);
continue;
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.forget_enqueued",
%standby,
repo = %path,
outcome = ?outcome,
"{why}; the public-only replica is told to forget it"
);
}
}
fn cancel_from(replicator: &Arc<Replicator>, path: &RepoRef, why: &str) {
for (standby, dropped) in replicator.cancel(path) {
if !dropped {
continue;
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.cancelled",
%standby,
repo = %path,
"{why}; the replication job that was still queued for it is dropped rather than \
left to fail five times and ask for a full resync"
);
}
}
fn enqueue_from(replicator: &Arc<Replicator>, repo: Option<&RepoPath>) {
let Some(repo) = repo else { return };
let path = match RepoRef::new(&repo.namespace, &repo.repo) {
Ok(path) => path,
Err(err) => {
tracing::warn!(
target: TARGET_REPLICATION,
namespace = %repo.namespace,
repo = %repo.repo,
%err,
"a ref event named a repository path this server cannot resolve; not replicated"
);
return;
}
};
for (standby, outcome) in replicator.enqueue(&path) {
tracing::debug!(
target: TARGET_REPLICATION,
event = "replication.enqueue",
%standby,
repo = %path,
outcome = ?outcome,
"a push enqueued a replication job"
);
}
}
async fn worker(replicator: Arc<Replicator>, runner: Arc<TargetRunner>) {
loop {
let now = Instant::now();
let taken = {
let mut queue = runner.lock_queue();
queue.take_ready(now)
};
let job = match taken {
Ok(job) => job,
Err(earliest) => {
let wait = earliest
.map(|at| at.saturating_duration_since(now))
.unwrap_or(IDLE_WAIT);
let failing = runner.lock_state().consecutive_failures > 0;
let probe = failing
.then(|| runner.target.probe_addr())
.flatten()
.filter(|_| wait > RECONNECT_PROBE);
match probe {
None => {
let _ = tokio::time::timeout(wait, runner.wake.notified()).await;
}
Some(addr) => {
tokio::select! {
_ = tokio::time::sleep(wait) => {}
_ = runner.wake.notified() => {}
_ = reachable_again(&addr) => {
let moved = runner.lock_queue().ready_now(Instant::now());
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.reconnected",
standby = %runner.target.name,
addr = %addr,
jobs = moved,
"the standby is accepting connections again; {moved} \
queued repository(ies) were pulled off the backoff \
instead of waiting it out"
);
}
}
}
}
continue;
}
};
runner.counters.attempts.fetch_add(1, Ordering::Relaxed);
runner.lock_state().in_flight = Some((job.key.clone(), job.first_seen));
let stores = Arc::clone(&replicator.stores);
let target = runner.target.clone();
let key = job.key.clone();
let known_hosts_default = replicator.stores.root().join(REPLICATION_KNOWN_HOSTS);
let forget = target.visibility == VisibilityScope::PublicOnly
&& !target
.visibility
.carries(replicator.visibility_of(&key.path().unwrap_or_else(|_| {
RepoRef::new("_", "_").expect("a legal placeholder")
})));
let outcome = if forget {
forget_once(&target, &key).await.map(|()| Replicated {
url: target.control.clone().unwrap_or_default(),
updates: Vec::new(),
up_to_date: 0,
objects_sent: 0,
pack_bytes: 0,
forgotten: true,
})
} else {
let attempt = {
let stores = Arc::clone(&stores);
let target = target.clone();
let key = key.clone();
let known_hosts_default = known_hosts_default.clone();
move || {
replicate_once(&stores, &target, &key, &known_hosts_default)
}
};
let first = tokio::task::spawn_blocking(attempt).await;
match first {
Err(join) => Err(AttemptError::from(anyhow::anyhow!(
"the replication task panicked: {join}"
))),
Ok(Err(AttemptError {
create_first: Some(create),
..
})) => match create_once(&target, &key, *create).await {
Err(err) => Err(err),
Ok(()) => {
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.precreated",
standby = %target.name,
repo = %key,
control = %target.control.clone().unwrap_or_default(),
"the standby did not hold the repository and a push-create would \
have been the wrong object format; it was created over the \
control plane in the repository's own format and the push \
follows"
);
let stores = Arc::clone(&stores);
let target = target.clone();
let key = key.clone();
match tokio::task::spawn_blocking(move || {
replicate_once(&stores, &target, &key, &known_hosts_default)
})
.await
{
Ok(outcome) => outcome,
Err(join) => Err(AttemptError::from(anyhow::anyhow!(
"the replication task panicked: {join}"
))),
}
}
},
Ok(outcome) => outcome,
}
};
match outcome {
Ok(report) => {
runner.counters.succeeded.fetch_add(1, Ordering::Relaxed);
runner
.counters
.refs_replicated
.fetch_add(report.updates.len() as u64, Ordering::Relaxed);
{
let mut state = runner.lock_state();
state.consecutive_failures = 0;
state.last_error = None;
state.last_success_unix_ms = Some(gunnar_grpc::bus::now_unix_ms());
state.in_flight = None;
}
runner.lock_history().insert(
job.key.clone(),
RepoRecord {
last_success_unix_ms: gunnar_grpc::bus::now_unix_ms(),
refs_at_last_success: (report.up_to_date + report.updates.len()) as u64,
last_error: None,
stopped: false,
},
);
if report.forgotten {
runner.counters.forgotten.fetch_add(1, Ordering::Relaxed);
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.forgot",
standby = %runner.target.name,
repo = %job.key,
control = %report.url,
"the public-only replica deleted its copy of a repository that is now \
Private"
);
continue;
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.synced",
standby = %runner.target.name,
repo = %job.key,
refs = report.updates.len(),
up_to_date = report.up_to_date,
objects = report.objects_sent,
pack_bytes = report.pack_bytes,
"replicated to a standby"
);
runner
.bus
.publish(event::Payload::MirrorSynced(gunnar_proto::MirrorSynced {
repo: Some(job.key.proto()),
remote_url: report.url,
refs_changed: report.updates.len() as u32,
updates: report.updates,
}));
}
Err(AttemptError { fault: Fault::Gone, err, .. }) => {
runner.counters.cancelled.fetch_add(1, Ordering::Relaxed);
runner.lock_history().remove(&job.key);
{
let mut state = runner.lock_state();
state.in_flight = None;
state.consecutive_failures = 0;
state.last_error = None;
}
tracing::info!(
target: TARGET_REPLICATION,
event = "replication.cancelled",
standby = %runner.target.name,
repo = %job.key,
reason = %format!("{err:#}"),
"the repository was deleted after this job was queued; the job is dropped. \
No retry, no resync: a delete is not a failure to replicate."
);
continue;
}
Err(AttemptError { fault, err, .. }) => {
let attempt = job.attempt + 1;
runner.counters.failed.fetch_add(1, Ordering::Relaxed);
let reason = format!("{err:#}");
let decision = after_failure(fault, attempt, runner.retry_base, runner.retry_max);
let stalled = decision == AfterFailure::Stop;
let delay = match decision {
AfterFailure::Retry(d) => d,
AfterFailure::Stop => Duration::ZERO,
};
{
let mut history = runner.lock_history();
let record = history.entry(job.key.clone()).or_default();
record.last_error = Some(reason.clone());
record.stopped = stalled;
}
{
let mut state = runner.lock_state();
state.in_flight = None;
state.consecutive_failures = attempt;
state.last_error = Some(match (stalled, fault) {
(true, Fault::Incompatible) => format!(
"STOPPED, and no retry can change it: {reason}"
),
(true, _) => format!(
"STALLED after {attempt} attempts, this server cannot read its own \
repository and retrying will not change that: {reason}"
),
(false, _) => reason.clone(),
});
if stalled {
state.needs_full_resync = true;
}
}
if stalled {
if fault == Fault::Incompatible {
runner
.counters
.stopped_incompatible
.fetch_add(1, Ordering::Relaxed);
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.incompatible",
standby = %runner.target.name,
repo = %job.key,
error = %reason,
"replication STOPPED for this repository and retrying cannot help: \
this server and the standby cannot hold the same repository. The \
standby does NOT have it and will not get it from a push either."
);
runner.fail_event(&job.key, attempt, Duration::ZERO, &reason, true);
continue;
}
runner.counters.stalled_local.fetch_add(1, Ordering::Relaxed);
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.stalled",
standby = %runner.target.name,
repo = %job.key,
attempts = attempt,
error = %reason,
"replication STOPPED for this repository: this server could not read it \
{attempt} times running, and no retry can change that. The standby is \
not at fault and is otherwise up to date. This repository's next push \
re-enqueues it, and so does `gunnar control replication resync`; until \
then the standby does NOT have it."
);
runner.fail_event(&job.key, attempt, Duration::ZERO, &reason, true);
continue;
}
tracing::error!(
target: TARGET_REPLICATION,
event = "replication.failed",
standby = %runner.target.name,
repo = %job.key,
attempt,
fault = ?fault,
retry_in_ms = delay.as_millis() as u64,
error = %reason,
"replication to a standby failed; it will be retried. The originating push \
is unaffected and remains accepted."
);
runner.fail_event(&job.key, attempt, delay, &reason, false);
runner.lock_queue().requeue(Job {
key: job.key,
attempt,
ready_at: Instant::now() + delay,
first_seen: job.first_seen,
});
runner.wake.notify_one();
}
}
}
}
struct Replicated {
url: String,
updates: Vec<RefUpdate>,
up_to_date: usize,
objects_sent: u32,
pack_bytes: u64,
forgotten: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fault {
Remote,
Local,
Incompatible,
Gone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AfterFailure {
Retry(Duration),
Stop,
}
pub fn after_failure(
fault: Fault,
attempt: u32,
retry_base: Duration,
retry_max: Duration,
) -> AfterFailure {
if fault == Fault::Incompatible || fault == Fault::Gone {
return AfterFailure::Stop;
}
if fault == Fault::Local && attempt >= MAX_LOCAL_ATTEMPTS {
return AfterFailure::Stop;
}
AfterFailure::Retry(backoff(attempt, retry_base, retry_max))
}
#[derive(Debug)]
struct AttemptError {
fault: Fault,
err: anyhow::Error,
create_first: Option<Box<gunnar_proto::CreateRepoRequest>>,
}
impl AttemptError {
fn local(err: anyhow::Error) -> Self {
Self { fault: Fault::Local, err, create_first: None }
}
fn incompatible(err: anyhow::Error) -> Self {
Self { fault: Fault::Incompatible, err, create_first: None }
}
fn absent(create: gunnar_proto::CreateRepoRequest, err: anyhow::Error) -> Self {
Self { fault: Fault::Incompatible, err, create_first: Some(Box::new(create)) }
}
fn gone(err: anyhow::Error) -> Self {
Self { fault: Fault::Gone, err, create_first: None }
}
}
impl<E: Into<anyhow::Error>> From<E> for AttemptError {
fn from(err: E) -> Self {
Self { fault: Fault::Remote, err: err.into(), create_first: None }
}
}
const CONTROL_CLIENT_CERT_ENV: &str = "GUNNAR_CLIENT_CERT";
const CONTROL_CLIENT_KEY_ENV: &str = "GUNNAR_CLIENT_KEY";
const CONTROL_CA_ENV: &str = "GUNNAR_CA";
struct ControlTrust {
ca: Vec<u8>,
cert: Vec<u8>,
key: Vec<u8>,
}
impl ControlTrust {
fn from_env() -> std::result::Result<Option<ControlTrust>, String> {
let read = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
let (cert, key, ca) = (
read(CONTROL_CLIENT_CERT_ENV),
read(CONTROL_CLIENT_KEY_ENV),
read(CONTROL_CA_ENV),
);
if cert.is_none() && key.is_none() && ca.is_none() {
return Ok(None);
}
let want = |value: Option<std::ffi::OsString>, name: &str| {
let path = value.ok_or_else(|| {
format!(
"mutual TLS to a replica's control plane needs all three of \
{CONTROL_CLIENT_CERT_ENV}, {CONTROL_CLIENT_KEY_ENV} and {CONTROL_CA_ENV}; \
{name} is not set. `gunnar control bootstrap` writes all three files."
)
})?;
let path = PathBuf::from(path);
std::fs::read(&path).map_err(|err| format!("reading {}: {err}", path.display()))
};
Ok(Some(ControlTrust {
cert: want(cert, CONTROL_CLIENT_CERT_ENV)?,
key: want(key, CONTROL_CLIENT_KEY_ENV)?,
ca: want(ca, CONTROL_CA_ENV)?,
}))
}
}
fn control_url(control: &str, have_trust: bool) -> std::result::Result<(String, bool), String> {
let url = if control.contains("://") {
control.to_owned()
} else if have_trust {
format!("https://{control}")
} else {
format!("http://{control}")
};
let tls = url.starts_with("https://");
if tls && !have_trust {
return Err(format!(
"{url} is an https:// control address and no client certificate is configured on \
this server. Set {CONTROL_CLIENT_CERT_ENV}, {CONTROL_CLIENT_KEY_ENV} and \
{CONTROL_CA_ENV} to the files `gunnar control bootstrap` wrote, or name the \
address without a scheme to dial it in the clear."
));
}
Ok((url, tls))
}
async fn forget_once(target: &ReplicationTarget, key: &JobKey) -> Result<(), AttemptError> {
let (mut repos, credential) = control_plane_client(target, FORGET_METHOD).await?;
let path = key.path().map_err(AttemptError::local)?;
let mut request = tonic::Request::new(gunnar_proto::DeleteRepoRequest {
path: Some(key.proto()),
purge: true,
..Default::default()
});
request
.metadata_mut()
.insert(gunnar_grpc::CREDENTIAL_METADATA_KEY, credential);
match repos.delete(request).await {
Ok(_) => Ok(()),
Err(status) if status.code() == tonic::Code::NotFound => {
tracing::debug!(
target: TARGET_REPLICATION,
standby = %target.name,
repo = %path,
"the replica did not hold the repository; nothing to forget"
);
Ok(())
}
Err(status) => Err(AttemptError::from(anyhow::anyhow!(
"{}'s control plane refused Repos.Delete for {path}: {} {}",
target.name,
status.code(),
status.message()
))),
}
}
async fn create_once(
target: &ReplicationTarget,
key: &JobKey,
request: gunnar_proto::CreateRepoRequest,
) -> Result<(), AttemptError> {
let (mut repos, credential) = control_plane_client(target, CREATE_METHOD).await?;
let path = key.path().map_err(AttemptError::local)?;
let hash_kind = request.hash_kind;
let mut request = tonic::Request::new(request);
request
.metadata_mut()
.insert(gunnar_grpc::CREDENTIAL_METADATA_KEY, credential);
match repos.create(request).await {
Ok(_) => Ok(()),
Err(status) if status.code() == tonic::Code::AlreadyExists => {
Err(AttemptError::incompatible(anyhow::anyhow!(
"{path} could not be created on {} in its own object format ({}): the standby \
already holds an EMPTY repository of that name in the other format, and git \
cannot replicate between object formats. Delete it there and `gunnar control \
replication resync {}`",
target.name,
match gunnar_proto::HashKind::try_from(hash_kind) {
Ok(gunnar_proto::HashKind::Sha1) => "sha1",
_ => "sha256",
},
target.name,
)))
}
Err(status) => Err(AttemptError::from(anyhow::anyhow!(
"{}'s control plane refused Repos.Create for {path}: {} {}",
target.name,
status.code(),
status.message()
))),
}
}
const FORGET_METHOD: &str = "Repos.Delete";
const CREATE_METHOD: &str = "Repos.Create";
async fn control_plane_client(
target: &ReplicationTarget,
method: &str,
) -> Result<
(
gunnar_proto::repos_client::ReposClient<tonic::transport::Channel>,
tonic::metadata::MetadataValue<tonic::metadata::Ascii>,
),
AttemptError,
> {
use gunnar_proto::repos_client::ReposClient;
let Some(control) = target.control.as_deref() else {
return Err(AttemptError::incompatible(anyhow::anyhow!(
"target {} names no control address, so a repository cannot be deleted there when \
it goes Private, nor created there in its own object format before it is pushed; \
re-add it with `--control host:port`",
target.name
)));
};
let Some(identity) = target.identity.as_deref() else {
return Err(AttemptError::incompatible(anyhow::anyhow!(
"target {} names no identity file; a control-plane write there must be signed \
with the deploy key the replica knows",
target.name
)));
};
let key_bytes = std::fs::read(identity)
.with_context(|| format!("reading the identity file {}", identity.display()))
.map_err(AttemptError::local)?;
let signer = gunnar_core::HostKey::from_openssh(key_bytes)
.with_context(|| format!("{} is not an OpenSSH private key", identity.display()))
.map_err(AttemptError::local)?;
let trust = ControlTrust::from_env().map_err(|err| {
AttemptError::local(anyhow::anyhow!(
"the client certificate for {}'s control plane could not be read: {err}",
target.name
))
})?;
let (url, tls) = control_url(control, trust.is_some())
.map_err(|err| AttemptError::incompatible(anyhow::anyhow!("{err}")))?;
if !tls {
tracing::warn!(
target: TARGET_REPLICATION,
event = "replication.forget_plaintext",
standby = %target.name,
control = %url,
"the delete to this replica's control plane carries a signed credential over an \
UNENCRYPTED channel. Set {CONTROL_CLIENT_CERT_ENV}, {CONTROL_CLIENT_KEY_ENV} and \
{CONTROL_CA_ENV} on this server to dial it over mutual TLS"
);
}
let endpoint = tonic::transport::Channel::from_shared(url.clone())
.map_err(|err| anyhow::anyhow!("{url} is not a control-plane address: {err}"))
.map_err(AttemptError::incompatible)?
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60));
let endpoint = match &trust {
None => endpoint,
Some(trust) => {
gunnar_grpc::ensure_crypto_provider();
endpoint
.tls_config(
tonic::transport::ClientTlsConfig::new()
.ca_certificate(tonic::transport::Certificate::from_pem(&trust.ca))
.identity(tonic::transport::Identity::from_pem(&trust.cert, &trust.key)),
)
.map_err(|err| {
AttemptError::local(anyhow::anyhow!(
"configuring mutual TLS for {}'s control plane: {err}",
target.name
))
})?
}
};
let channel = endpoint
.connect()
.await
.with_context(|| format!("dialing {}'s control plane at {url}", target.name))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let header =
control_credential(signer.private_key(), now, method).map_err(AttemptError::local)?;
Ok((ReposClient::new(channel), header))
}
fn control_credential(
key: &gunnar_core::ssh_key::PrivateKey,
now_unix_ms: i64,
method: &str,
) -> Result<tonic::metadata::MetadataValue<tonic::metadata::Ascii>> {
let nonce = {
use gunnar_core::ssh_key::rand_core::Rng as _;
let mut bytes = [0u8; 24];
gunnar_core::os_rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
};
let credential = gunnar_grpc::sign_credential_for(key, now_unix_ms, &nonce, method)
.map_err(|err| anyhow::anyhow!("signing the control-plane credential: {err}"))?;
credential
.to_header_value()
.parse()
.map_err(|err| anyhow::anyhow!("the credential is not a legal header value: {err}"))
}
fn replicate_once(
stores: &Arc<ServerStores>,
target: &ReplicationTarget,
key: &JobKey,
known_hosts_default: &Path,
) -> std::result::Result<Replicated, AttemptError> {
use gunnar_client::mirror::is_mirrored_ref;
use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
use gunnar_store::git_storage_trait::{Caps, GitOps, GitServe};
let path = key.path().map_err(AttemptError::local)?;
let repo_row = match stores.catalog().get(&path) {
Ok(Some(row)) => row,
Ok(None) => {
return Err(AttemptError::gone(anyhow::anyhow!(
"{path} is not in the catalogue: it was deleted after this replication job was \
queued, so there is nothing to replicate and the job is dropped"
)))
}
Err(err) => {
return Err(AttemptError::local(anyhow::Error::new(err).context(format!(
"reading the catalogue for {path} to replicate it"
))))
}
};
let store = stores
.repo_store(&repo_row)
.with_context(|| format!("opening {path} to replicate it"))
.map_err(AttemptError::local)?;
let git = store.serve();
let hash_kind = store.hash_kind();
let mut ours: Vec<(String, gix_hash::ObjectId)> = Vec::new();
for row in GitOps::refs(git)
.context("reading this server's refs to replicate them")
.map_err(AttemptError::local)?
{
if !is_mirrored_ref(&row.name) {
continue;
}
let Some(raw) = row.oid.as_deref() else {
continue;
};
let oid = gix_hash::oid::try_from_bytes(raw)
.map_err(gix_error::Error::from).with_context(|| format!("{} holds an oid this store cannot decode", row.name))
.map_err(AttemptError::local)?
.to_owned();
ours.push((row.name.clone(), oid));
}
ours.sort();
let url = target.url_for(&path);
let mut ssh = SshOptions::new();
ssh.use_agent = false;
ssh.key_path = target.identity.clone();
ssh.known_hosts = Some(
target
.known_hosts
.clone()
.unwrap_or_else(|| known_hosts_default.to_path_buf()),
);
ssh.host_key = if target.strict_host_key {
HostKeyPolicy::StrictKnownHosts
} else {
HostKeyPolicy::TofuKnownHosts
};
let loc = nornir_git::ssh::parse_ssh_url(&url)
.map_err(|err| anyhow::anyhow!("{url} is not an ssh url: {err}"))?;
let mut channel = gunnar_client::connect_receive_pack(&loc, &ssh)
.with_context(|| format!("opening git-receive-pack on {} ({url})", target.name))?;
let adv = {
let (reader, _) = channel.io();
gunnar_client::read_advertisement(reader)
.with_context(|| format!("reading {}'s ref advertisement", target.name))?
};
if adv.hash_kind != hash_kind && adv.refs.iter().all(|r| r.oid.is_null()) {
{
let (_, mut writer) = channel.io();
gunnar_pktline::write_flush(&mut writer)?;
std::io::Write::flush(&mut writer)?;
}
let create = gunnar_proto::CreateRepoRequest {
path: Some(key.proto()),
hash_kind: match hash_kind {
gix_hash::Kind::Sha1 => gunnar_proto::HashKind::Sha1,
_ => gunnar_proto::HashKind::Sha256,
} as i32,
default_branch: repo_row.default_branch.clone(),
visibility: match repo_row.visibility {
gunnar_core::Visibility::Public => gunnar_proto::Visibility::Public,
gunnar_core::Visibility::Private => gunnar_proto::Visibility::Private,
} as i32,
description: repo_row.description.clone(),
adopt: false,
readme: String::new(),
};
return Err(AttemptError::absent(
create,
anyhow::anyhow!(
"{path} is {hash_kind:?} and {} does not hold it; a push would create it as \
{:?}, so it has to be created there first, in its own format",
target.name,
adv.hash_kind,
),
));
}
if adv.hash_kind != hash_kind {
return Err(AttemptError::incompatible(anyhow::anyhow!(
"{path} is {hash_kind:?} but {} holds it as {:?}; git cannot replicate between \
object formats. Delete it there and `gunnar control replication resync {}`: \
the replicator then creates it in this repository's format before it pushes",
target.name,
adv.hash_kind,
target.name,
)));
}
let theirs: Vec<(String, gix_hash::ObjectId)> = adv
.refs
.iter()
.filter_map(|r| {
let name = std::str::from_utf8(r.name.as_slice()).ok()?;
is_mirrored_ref(name).then(|| (name.to_owned(), r.oid))
})
.collect();
let commands: Vec<gunnar_client::PushCommand> = gunnar_client::plan_ref_sync(&theirs, &ours)
.into_iter()
.map(|change| match change {
gunnar_client::RefChange::Created { name, new } => {
gunnar_client::PushCommand::create(name, new)
}
gunnar_client::RefChange::Updated { name, old, new } => {
gunnar_client::PushCommand::update(name, old, new)
}
gunnar_client::RefChange::Deleted { name, old } => {
gunnar_client::PushCommand::delete(name, old)
}
})
.collect();
let moving: std::collections::BTreeSet<&[u8]> =
commands.iter().map(|c| c.name.as_slice()).collect();
let up_to_date = ours
.iter()
.filter(|(name, _)| !moving.contains(name.as_bytes()))
.count();
if commands.is_empty() {
{
let (_, mut writer) = channel.io();
gunnar_pktline::write_flush(&mut writer)?;
std::io::Write::flush(&mut writer)?;
}
return Ok(Replicated {
url,
updates: Vec::new(),
up_to_date,
objects_sent: 0,
pack_bytes: 0,
forgotten: false,
});
}
let all_deletes = commands.iter().all(gunnar_client::PushCommand::is_delete);
let want: Vec<Vec<u8>> = commands
.iter()
.filter(|c| !c.is_delete())
.map(|c| c.new.as_bytes().to_vec())
.collect();
let have: Vec<Vec<u8>> = adv
.refs
.iter()
.filter(|r| !r.oid.is_null())
.map(|r| r.oid.as_bytes().to_vec())
.collect();
let have_refs: Vec<&[u8]> = have.iter().map(Vec::as_slice).collect();
let objects: Vec<Vec<u8>> = if all_deletes {
Vec::new()
} else {
let want_refs: Vec<&[u8]> = want.iter().map(Vec::as_slice).collect();
gunnar_wire::objects_to_send(git, hash_kind, &want_refs, &have_refs)
.context("selecting the objects the standby is missing")
.map_err(AttemptError::local)?
};
let object_refs: Vec<&[u8]> = objects.iter().map(Vec::as_slice).collect();
let caps = Caps {
thin: false,
ofs_delta: adv.capabilities.has("ofs-delta"),
};
let mut pack_bytes: u64 = 0;
let mut objects_sent: u32 = 0;
let mut emit_failed = false;
let report = {
let emit = (!all_deletes).then_some(
|w: &mut dyn std::io::Write| -> std::result::Result<(), gunnar_client::Error> {
let stats = GitServe::emit_pack(git, &object_refs, &have_refs, &caps, w)
.map_err(|e| {
emit_failed = true;
gunnar_client::Error::Repo(e)
})?;
pack_bytes = stats.bytes;
objects_sent = stats.objects as u32;
Ok(())
},
);
gunnar_client::send_pack(
&mut channel,
&adv,
&commands,
hash_kind,
emit,
&gunnar_client::send_pack::options(),
)
.with_context(|| format!("replicating {path} to {} ({url})", target.name))
.map_err(|e| {
if emit_failed {
AttemptError::local(e)
} else {
AttemptError::from(e)
}
})?
};
if let Some(why) = report.failure_summary() {
return Err(AttemptError::from(anyhow::anyhow!(
"{} refused the replication push: {why}",
target.name
)));
}
Ok(Replicated {
url,
updates: commands
.iter()
.map(|c| RefUpdate {
ref_name: c.name.to_string(),
old_oid: c.old.to_string(),
new_oid: c.new.to_string(),
})
.collect(),
up_to_date,
objects_sent,
pack_bytes,
forgotten: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn target(name: &str) -> ReplicationTarget {
ReplicationTarget {
name: name.to_owned(),
scope: ReplicationScope::Server,
remote: "ssh://git@standby:2222".into(),
identity: None,
known_hosts: None,
strict_host_key: false,
visibility: Default::default(),
control: None,
}
}
fn key(ns: &str, repo: &str) -> JobKey {
JobKey {
namespace: ns.to_owned(),
repo: repo.to_owned(),
}
}
#[test]
fn the_replica_writes_sign_a_credential_bound_to_their_own_method() {
let dir = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(dir.path()).unwrap());
let pem = gunnar_core::mint_ed25519().unwrap();
let signer = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem).unwrap();
stores
.auth()
.bootstrap_admin(&signer.public_key().to_openssh().unwrap())
.unwrap();
let plane: Arc<dyn gunnar_grpc::ControlPlane> = Arc::new(
crate::control_plane::CoreControlPlane::new(Arc::clone(&stores)),
);
let gate = gunnar_grpc::AuthGate::new(plane, gunnar_grpc::AuthMode::Required);
for method in [FORGET_METHOD, CREATE_METHOD] {
let now = gunnar_core::now_ms() as i64;
let header = control_credential(&signer, now, method).unwrap();
let text = header.to_str().unwrap();
assert!(
text.starts_with(gunnar_grpc::CREDENTIAL_SCHEME),
"{method}: the header must be {}, got {text}",
gunnar_grpc::CREDENTIAL_SCHEME
);
let mut md = tonic::metadata::MetadataMap::new();
md.insert(gunnar_grpc::CREDENTIAL_METADATA_KEY, header.clone());
assert!(
gate.authenticate_bound(&md, now, Some(method)).is_ok(),
"{method}: served at the method it was minted for"
);
let fresh = control_credential(&signer, now, method).unwrap();
let mut md = tonic::metadata::MetadataMap::new();
md.insert(gunnar_grpc::CREDENTIAL_METADATA_KEY, fresh);
assert!(
matches!(
gate.authenticate_bound(&md, now, Some("Access.AddKey")),
Err(gunnar_grpc::AuthFailure::MethodMismatch { .. })
),
"{method}: refused anywhere else"
);
}
}
#[test]
fn a_forget_dials_tls_when_the_box_has_a_client_certificate() {
assert_eq!(
control_url("browse:50051", false),
Ok(("http://browse:50051".to_owned(), false))
);
assert_eq!(
control_url("browse:50051", true),
Ok(("https://browse:50051".to_owned(), true))
);
assert_eq!(
control_url("http://browse:50051", true),
Ok(("http://browse:50051".to_owned(), false))
);
assert_eq!(
control_url("https://browse:50051", true),
Ok(("https://browse:50051".to_owned(), true))
);
let err = control_url("https://browse:50051", false)
.expect_err("https with no client certificate");
assert!(err.contains(CONTROL_CLIENT_CERT_ENV), "{err}");
assert!(err.contains(CONTROL_CLIENT_KEY_ENV), "{err}");
assert!(err.contains(CONTROL_CA_ENV), "{err}");
}
#[test]
fn the_queue_bound_refuses_rather_than_growing() {
let mut queue = Queue::new(4);
let now = Instant::now();
for i in 0..4 {
assert_eq!(
queue.push(key("ns", &format!("r{i}")), now),
Enqueued::Accepted,
"the first {} must fit under a bound of 4",
i + 1
);
}
assert_eq!(queue.len(), 4);
for i in 4..12 {
assert_eq!(
queue.push(key("ns", &format!("r{i}")), now),
Enqueued::RefusedQueueFull,
"past the bound every distinct repository must be REFUSED, not dropped-oldest"
);
}
assert_eq!(queue.len(), 4, "the bound must hold, not merely be logged");
for i in 0..4 {
assert!(
queue.jobs.contains_key(&key("ns", &format!("r{i}"))),
"r{i} was queued first and must not have been evicted by a later refusal"
);
}
}
#[test]
fn a_thousand_pushes_to_one_repo_are_one_pending_job() {
let mut queue = Queue::new(4);
let now = Instant::now();
assert_eq!(queue.push(key("ns", "hot"), now), Enqueued::Accepted);
for _ in 0..1000 {
assert_eq!(queue.push(key("ns", "hot"), now), Enqueued::Coalesced);
}
assert_eq!(
queue.len(),
1,
"a job means `converge this repository`, so repeated pushes to it cost nothing"
);
}
#[test]
fn backoff_doubles_and_then_stops_at_the_cap() {
let base = Duration::from_millis(100);
let max = Duration::from_millis(800);
assert_eq!(
backoff(0, base, max),
Duration::ZERO,
"no failures, no wait"
);
assert_eq!(backoff(1, base, max), Duration::from_millis(100));
assert_eq!(backoff(2, base, max), Duration::from_millis(200));
assert_eq!(backoff(3, base, max), Duration::from_millis(400));
assert_eq!(backoff(4, base, max), Duration::from_millis(800));
assert_eq!(backoff(5, base, max), max, "capped, not doubling past max");
assert_eq!(
backoff(4000, base, max),
max,
"an attempt count that would overflow the shift must still be the cap"
);
}
#[test]
fn a_requeued_job_keeps_the_instant_it_first_became_outstanding() {
let mut queue = Queue::new(4);
let t0 = Instant::now();
queue.push(key("ns", "waiting"), t0);
assert_eq!(queue.oldest_first_seen(), Some(t0));
let job = queue.take_ready(t0).expect("ready now");
assert_eq!(job.first_seen, t0);
queue.requeue(Job {
key: job.key.clone(),
attempt: 1,
ready_at: t0 + Duration::from_secs(300),
first_seen: job.first_seen,
});
assert_eq!(
queue.oldest_first_seen(),
Some(t0),
"a retry must not restart the clock, or a standby that fails forever is never behind"
);
let later = t0 + Duration::from_secs(60);
assert_eq!(queue.push(key("ns", "waiting"), later), Enqueued::Coalesced);
assert_eq!(
queue.oldest_first_seen(),
Some(t0),
"coalescing must not restart the clock"
);
queue.push(key("ns", "fresh"), later);
assert_eq!(queue.oldest_first_seen(), Some(t0));
}
#[test]
fn a_queue_hands_its_outstanding_work_to_its_successor() {
let mut old = Queue::new(4);
let t0 = Instant::now();
for i in 0..3 {
old.push(key("ns", &format!("r{i}")), t0);
}
let carried = old.drain_jobs();
assert_eq!(carried.len(), 3);
assert_eq!(old.len(), 0, "draining must empty the queue it drained");
let mut new = Queue::new(4);
assert_eq!(new.adopt(carried), 0, "three jobs fit a bound of four");
assert_eq!(new.len(), 3);
assert_eq!(new.oldest_first_seen(), Some(t0), "the clocks carry too");
let mut small = Queue::new(2);
let mut more = Queue::new(4);
for i in 0..3 {
more.push(key("ns", &format!("r{i}")), t0);
}
assert_eq!(
small.adopt(more.drain_jobs()),
1,
"one of three cannot fit a bound of two, and the count is the alarm"
);
assert_eq!(small.len(), 2);
}
#[test]
fn a_job_in_backoff_is_not_ready_until_its_time() {
let mut queue = Queue::new(4);
let now = Instant::now();
let later = now + Duration::from_secs(30);
queue.push(key("ns", "waiting"), later);
match queue.take_ready(now) {
Err(Some(at)) => assert_eq!(at, later, "the loop must be told when to wake"),
other => panic!("a job in backoff must not be handed out: {other:?}"),
}
let job = queue
.take_ready(later)
.expect("once its time comes, the job is ready");
assert_eq!(job.key, key("ns", "waiting"));
assert_eq!(queue.len(), 0);
}
#[test]
fn a_deleted_repository_drops_its_job_and_is_not_a_failure() {
let mut queue = Queue::new(4);
let now = Instant::now();
queue.push(key("proof", "f13-1789907688270"), now);
queue.push(key("alice", "keeps-existing"), now);
assert_eq!(queue.len(), 2);
assert!(
queue.remove(&key("proof", "f13-1789907688270")),
"the job for the deleted repository was queued, so cancelling it drops something"
);
assert!(
!queue.remove(&key("proof", "f13-1789907688270")),
"cancelling twice is not an error and is not a second drop"
);
assert!(
!queue.remove(&key("nobody", "never-queued")),
"cancelling a repository with nothing outstanding drops nothing"
);
assert_eq!(queue.len(), 1, "only the deleted repository's job went");
let job = queue
.take_ready(now)
.expect("the repository that still exists is still due");
assert_eq!(job.key, key("alice", "keeps-existing"));
assert!(queue.take_ready(now).is_err(), "and nothing else is outstanding");
let base = Duration::from_millis(100);
let max = Duration::from_millis(800);
for attempt in [1u32, 2, 5, 1000] {
assert_eq!(
after_failure(Fault::Gone, attempt, base, max),
AfterFailure::Stop,
"no retry brings back a deleted repository, not even the first one (attempt \
{attempt})"
);
}
assert_ne!(
Fault::Gone,
Fault::Local,
"THE DEFECT: a deleted repository was classified as this box failing to read its \
own, which is what asked for the resync"
);
}
#[test]
fn a_remote_fault_retries_forever_and_a_local_one_stops_by_the_third_attempt() {
let base = Duration::from_millis(100);
let max = Duration::from_millis(800);
for attempt in [1u32, 2, 3, 4, 10, 1000] {
assert_eq!(
after_failure(Fault::Remote, attempt, base, max),
AfterFailure::Retry(backoff(attempt, base, max)),
"a standby that is down must be retried at attempt {attempt}; giving up on it \
is the rotting standby this module refuses"
);
}
assert_eq!(
after_failure(Fault::Local, 1, base, max),
AfterFailure::Retry(Duration::from_millis(100)),
"one bad moment (a store handle racing a repository being created) must not \
condemn durable bytes"
);
assert_eq!(
after_failure(Fault::Local, 2, base, max),
AfterFailure::Retry(Duration::from_millis(200))
);
assert_eq!(
after_failure(Fault::Local, MAX_LOCAL_ATTEMPTS, base, max),
AfterFailure::Stop,
"three identical failures to read our OWN repository is the answer, not a phase"
);
assert_eq!(
after_failure(Fault::Local, 40, base, max),
AfterFailure::Stop,
"and it stays stopped — a stall is cleared by new information (the next push, or \
a resync), never by the clock"
);
}
#[test]
fn an_unclassified_failure_is_remote() {
let e: AttemptError = anyhow::anyhow!("something nobody classified").into();
assert_eq!(e.fault, Fault::Remote);
let io: AttemptError = std::io::Error::other("a wire read").into();
assert_eq!(io.fault, Fault::Remote);
assert_eq!(
AttemptError::local(anyhow::anyhow!("our own store")).fault,
Fault::Local
);
assert_eq!(
AttemptError::incompatible(anyhow::anyhow!("sha1 vs sha256")).fault,
Fault::Incompatible
);
}
#[test]
fn an_incompatible_object_format_stops_at_the_first_attempt() {
let base = Duration::from_millis(100);
let max = Duration::from_millis(800);
for attempt in [1u32, 2, 3, 1000] {
assert_eq!(
after_failure(Fault::Incompatible, attempt, base, max),
AfterFailure::Stop,
"attempt {attempt} of a repository the standby cannot hold must STOP; the next \
attempt reads the same advertisement"
);
}
assert_eq!(
after_failure(Fault::Remote, 1, base, max),
AfterFailure::Retry(Duration::from_millis(100))
);
assert_eq!(
after_failure(Fault::Local, 1, base, max),
AfterFailure::Retry(Duration::from_millis(100))
);
}
#[test]
fn scopes_select_what_they_say_they_do() {
let repo = RepoRef::new("nordisk", "gunnar").unwrap();
let other = RepoRef::new("other", "gunnar").unwrap();
assert!(ReplicationScope::Server.covers(&repo));
assert!(ReplicationScope::Server.covers(&other));
let ns = ReplicationScope::Namespace {
namespace: "nordisk".into(),
};
assert!(ns.covers(&repo));
assert!(
!ns.covers(&other),
"a namespace scope must not take another"
);
let one = ReplicationScope::Repo {
namespace: "nordisk".into(),
repo: "gunnar".into(),
};
assert!(one.covers(&repo));
assert!(!one.covers(&RepoRef::new("nordisk", "other").unwrap()));
}
#[test]
fn a_base_url_gets_the_repository_path_and_a_full_one_does_not() {
let path = RepoRef::new("nordisk", "gunnar").unwrap();
let mut t = target("standby");
assert_eq!(
t.url_for(&path),
"ssh://git@standby:2222/nordisk/gunnar.git"
);
t.remote = "ssh://git@standby:2222/".into();
assert_eq!(
t.url_for(&path),
"ssh://git@standby:2222/nordisk/gunnar.git",
"a trailing slash must not produce a doubled one"
);
t.remote = "ssh://git@standby:2222/elsewhere/copy.git".into();
assert_eq!(
t.url_for(&path),
"ssh://git@standby:2222/elsewhere/copy.git",
"an explicit .git URL is used verbatim"
);
}
#[test]
fn the_config_round_trips_and_a_missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let empty = ReplicationConfig::load(dir.path()).expect("an absent config is empty");
assert!(empty.targets.is_empty());
assert_eq!(empty.queue_capacity, DEFAULT_QUEUE_CAPACITY);
let mut config = ReplicationConfig::default();
config.set_target(target("standby"));
config.set_target(ReplicationTarget {
scope: ReplicationScope::Namespace {
namespace: "nordisk".into(),
},
..target("nordisk-only")
});
config.save(dir.path()).expect("save");
let back = ReplicationConfig::load(dir.path()).expect("load");
assert_eq!(back.targets, config.targets);
let mut config = back;
config.set_target(ReplicationTarget {
remote: "ssh://git@elsewhere".into(),
..target("standby")
});
assert_eq!(config.targets.len(), 2);
assert_eq!(
config
.targets
.iter()
.find(|t| t.name == "standby")
.unwrap()
.remote,
"ssh://git@elsewhere"
);
assert!(config.remove_target("standby"));
assert!(!config.remove_target("standby"), "removing twice is false");
}
#[test]
fn a_malformed_config_is_an_error_and_an_absent_one_is_not() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(REPLICATION_CONFIG), b"{ not json").unwrap();
let err = ReplicationConfig::load(dir.path()).expect_err("malformed JSON must not pass");
assert!(
format!("{err:#}").contains(REPLICATION_CONFIG),
"the error must name the file an operator has to fix: {err:#}"
);
}
#[test]
fn an_empty_config_from_a_powercut_is_treated_as_absent_not_a_fatal_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(REPLICATION_CONFIG), b"").unwrap();
let config = ReplicationConfig::load(dir.path())
.expect("a 0-byte replication.json must load as an empty config, not fail the boot");
assert!(
config.targets.is_empty(),
"an empty file must mean no replication configured"
);
std::fs::write(dir.path().join(REPLICATION_CONFIG), b"\n \n").unwrap();
assert!(ReplicationConfig::load(dir.path())
.expect("a whitespace-only replication.json must also load as empty")
.targets
.is_empty());
}
}
#[cfg(test)]
mod reconnect_tests {
use super::*;
#[test]
fn a_probe_address_is_the_authority_and_nothing_else() {
let t = |remote: &str| ReplicationTarget {
name: "s".into(),
scope: ReplicationScope::Server,
remote: remote.into(),
identity: None,
known_hosts: None,
strict_host_key: false,
visibility: Default::default(),
control: None,
};
assert_eq!(
t("ssh://git@standby:2222").probe_addr().as_deref(),
Some("standby:2222")
);
assert_eq!(
t("ssh://git@standby").probe_addr().as_deref(),
Some("standby:22")
);
assert_eq!(
t("ssh://git@standby:2222/ns/repo.git")
.probe_addr()
.as_deref(),
Some("standby:2222")
);
assert_eq!(
t("ssh://standby:2222").probe_addr().as_deref(),
Some("standby:2222")
);
assert_eq!(
t("ssh://git@[fd00::1]:2222").probe_addr().as_deref(),
Some("[fd00::1]:2222")
);
assert_eq!(
t("ssh://git@[fd00::1]").probe_addr().as_deref(),
Some("[fd00::1]:22")
);
assert_eq!(t("standby:2222").probe_addr(), None);
assert_eq!(t("ssh://").probe_addr(), None);
}
#[test]
fn ready_now_pulls_every_backed_off_job_forward() {
let mut q = Queue::new(8);
let later = Instant::now() + Duration::from_secs(300);
for name in ["a", "b", "c"] {
q.push(
JobKey {
namespace: "ns".into(),
repo: name.into(),
},
later,
);
}
let now = Instant::now();
assert!(
q.take_ready(now).is_err(),
"a backed-off queue yields nothing"
);
assert_eq!(q.ready_now(now), 3, "all three were waiting");
assert_eq!(q.ready_now(now), 0, "already ready, nothing to move");
for _ in 0..3 {
q.take_ready(now).expect("ready after the reconnect wake");
}
assert!(q.take_ready(now).is_err(), "and the queue is empty");
}
}