use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use onevcs::{Bound, SessionRequest, WorkspaceCapacity};
use serde_json::{json, Value};
use crate::channel::Surface;
use crate::error::Result;
use crate::journal::Journal;
use crate::ledger::RunPaths;
pub const WAIT_SURFACE_KIND: &str = "workspace-wait";
pub const EXHAUSTED_REASON: &str = "workspace-exhausted";
pub(crate) const HOLD_KIND: &str = "workspace";
pub(crate) fn requeue_payload(
because: &str,
pinned: Option<(&str, std::num::NonZeroU32)>,
) -> serde_json::Map<String, Value> {
let mut payload = crate::journal::payload(&[
("reason", json!(EXHAUSTED_REASON)),
("detail", json!(crate::engine::bounded(because))),
]);
if let Some((branch, attempt)) = pinned {
payload.insert("branch".to_owned(), json!(branch));
payload.insert("attempt".to_owned(), json!(attempt));
}
payload
}
pub const POLL_ENV: &str = "ONEPIPELINE_WORKSPACE_POLL_SECONDS";
pub const DEFAULT_POLL_SECONDS: u64 = 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceHold {
pub identity: String,
pub pool: u32,
pub slots: u32,
pub idle: u32,
pub maintaining: u32,
pub overflow: Bound,
pub overflow_in_use: u32,
}
impl WorkspaceHold {
pub(crate) fn of(capacity: &WorkspaceCapacity) -> Self {
Self {
identity: capacity.identity.clone(),
pool: capacity.pool,
slots: capacity.slots,
idle: capacity.idle,
maintaining: capacity.maintaining,
overflow: capacity.overflow,
overflow_in_use: capacity.overflow_in_use,
}
}
pub(crate) fn payload(&self) -> Value {
json!({
"kind": HOLD_KIND,
"identity": self.identity,
"pool": self.pool,
"slots": self.slots,
"idle": self.idle,
"maintaining": self.maintaining,
"overflow": self.overflow,
"overflow_in_use": self.overflow_in_use,
})
}
pub(crate) fn of_payload(entry: &Value) -> Option<Self> {
if entry.get("kind")?.as_str()? != HOLD_KIND {
return None;
}
let count = |key: &str| -> Option<u32> { u32::try_from(entry.get(key)?.as_u64()?).ok() };
Some(Self {
identity: entry.get("identity")?.as_str()?.to_owned(),
pool: count("pool")?,
slots: count("slots")?,
idle: count("idle")?,
maintaining: count("maintaining")?,
overflow: serde_json::from_value(entry.get("overflow")?.clone()).ok()?,
overflow_in_use: count("overflow_in_use")?,
})
}
pub(crate) fn describe(&self) -> String {
format!(
"pool {} ({} slot(s): {} idle, {} maintaining), overflow {} with {} in use",
self.pool, self.slots, self.idle, self.maintaining, self.overflow, self.overflow_in_use
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Admission {
Admitted,
Held,
Unread,
}
pub(crate) struct Workspaces {
placing: BTreeMap<String, String>,
held: BTreeMap<String, WorkspaceHold>,
refused: BTreeMap<String, (Instant, WorkspaceHold)>,
resuming: BTreeMap<String, Box<crate::lifecycle::Continuation>>,
read_at: Option<Instant>,
every: Duration,
surfaced: BTreeMap<String, Instant>,
surface_every: Duration,
}
impl Workspaces {
pub(crate) fn new() -> Self {
Self {
placing: BTreeMap::new(),
held: BTreeMap::new(),
refused: BTreeMap::new(),
resuming: BTreeMap::new(),
read_at: None,
every: Duration::from_secs(poll_seconds()),
surfaced: BTreeMap::new(),
surface_every: Duration::from_secs(crate::release::surface_every_seconds()),
}
}
pub(crate) fn begin_pass(&mut self, in_flight: impl Fn(&str) -> bool) {
self.held.clear();
self.placing.retain(|node, _| in_flight(node));
}
pub(crate) fn placed(&mut self, node: &str) {
self.placing.remove(node);
}
pub(crate) fn admit(&mut self, node: &str, request: &SessionRequest) -> Admission {
if let Some((at, hold)) = self.refused.get(node) {
if at.elapsed() < self.every {
self.held.insert(node.to_owned(), hold.clone());
return Admission::Held;
}
self.refused.remove(node);
}
let Ok(capacity) = crate::vcs::workspace_capacity(request) else {
return Admission::Unread;
};
self.read_at = Some(Instant::now());
let already = u32::try_from(
self.placing
.values()
.filter(|identity| **identity == capacity.identity)
.count(),
)
.unwrap_or(u32::MAX);
let admitted = capacity.admitted && (already == 0 || capacity.admits.admits(already));
if !admitted {
self.held
.insert(node.to_owned(), WorkspaceHold::of(&capacity));
return Admission::Held;
}
self.placing.insert(node.to_owned(), capacity.identity);
Admission::Admitted
}
pub(crate) fn refused(
&mut self,
node: &str,
hold: Option<WorkspaceHold>,
resume: Option<Box<crate::lifecycle::Continuation>>,
) {
if let Some(hold) = hold {
self.refused.insert(node.to_owned(), (Instant::now(), hold));
}
match resume {
Some(continuation) => self.resuming.insert(node.to_owned(), continuation),
None => self.resuming.remove(node),
};
}
pub(crate) fn resuming(&mut self, node: &str) -> Option<Box<crate::lifecycle::Continuation>> {
self.resuming.remove(node)
}
pub(crate) fn keep_resuming(
&mut self,
node: &str,
resume: Option<Box<crate::lifecycle::Continuation>>,
) {
if let Some(continuation) = resume {
self.resuming.insert(node.to_owned(), continuation);
}
}
pub(crate) fn held(&self) -> &BTreeMap<String, WorkspaceHold> {
&self.held
}
pub(crate) fn next_read(&self) -> Duration {
if self.held.is_empty() && self.refused.is_empty() {
return Duration::MAX;
}
let until = |since: Instant| self.every.saturating_sub(since.elapsed());
let refusals = self.refused.values().map(|(at, _)| until(*at));
let reads = self.read_at.map(until).into_iter();
refusals.chain(reads).min().unwrap_or(Duration::ZERO)
}
pub(crate) fn surface_waits(&mut self, paths: &RunPaths, journal: &mut Journal) -> Result<()> {
for (node, hold) in &self.held {
let due = self
.surfaced
.get(node)
.is_none_or(|last| last.elapsed() >= self.surface_every);
if !due {
continue;
}
self.surfaced.insert(node.clone(), Instant::now());
crate::engine::raise(paths, journal, wait_surface(node, hold))?;
}
let held = &self.held;
self.surfaced.retain(|node, _| held.contains_key(node));
Ok(())
}
}
fn wait_surface(node: &str, hold: &WorkspaceHold) -> Surface {
Surface {
id: 0,
kind: WAIT_SURFACE_KIND.to_owned(),
message: format!(
"node '{node}' is held: the '{identity}' workspace admits no more sessions now — \
{numbers}. `onevcs pool status {identity}` names the holders. Nothing times this \
out and nothing will fail the node: it dispatches the pass the identity admits \
it. Close a session, raise pool or overflow for this identity in the host's \
workspaces.yml, or stop the run.",
identity = hold.identity,
numbers = hold.describe(),
),
source: crate::channel::source::PROPOSAL.to_owned(),
blocking: false,
queued_at: crate::sys::now_millis(),
abandoned: false,
asker: None,
workstream: Some(node.to_owned()),
correlation: None,
}
}
fn poll_seconds() -> u64 {
std::env::var(POLL_ENV)
.ok()
.and_then(|value| value.parse().ok())
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_POLL_SECONDS)
}
#[cfg(test)]
mod tests {
use super::*;
fn a_hold() -> WorkspaceHold {
WorkspaceHold {
identity: "github.com/owner/service".into(),
pool: 1,
slots: 1,
idle: 0,
maintaining: 0,
overflow: Bound::Bounded(0),
overflow_in_use: 0,
}
}
#[test]
fn the_hold_entry_is_what_the_divergence_record_fixes_and_round_trips() {
let record = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/contract-divergences.md"),
)
.expect("the divergence record reads");
let entry = record
.split("\n## ")
.find(|entry| entry.starts_with("82."))
.expect("the divergence record still carries entry 82");
let block = entry
.split("```json")
.nth(1)
.and_then(|rest| rest.split("```").next())
.expect("entry 82 carries the json block this test drives");
let block: Value = serde_json::from_str(block).expect("entry 82's block is JSON");
let written = block["hold"].clone();
let hold = WorkspaceHold::of_payload(&written).expect("entry 82's hold entry reads");
assert_eq!(
hold.payload(),
written,
"entry 82's hold entry does not round-trip as written"
);
assert_eq!(block["surface_kind"], json!(WAIT_SURFACE_KIND));
assert_eq!(block["requeue"]["reason"], json!(EXHAUSTED_REASON));
let fields = |payload: serde_json::Map<String, Value>| -> Vec<String> {
payload.keys().cloned().collect()
};
let named = |key: &str| -> Vec<String> {
serde_json::from_value(block["requeue"][key].clone()).expect("a field list")
};
let mut unpinned = fields(requeue_payload("pool exhausted", None));
unpinned.sort();
let mut first = named("fields");
first.sort();
assert_eq!(unpinned, first);
let pinned = requeue_payload(
"pool exhausted",
Some((
"onepipeline/service",
std::num::NonZeroU32::new(2).expect("two"),
)),
);
assert_eq!(pinned["reason"], json!(EXHAUSTED_REASON));
assert_eq!(pinned["branch"], json!("onepipeline/service"));
assert_eq!(pinned["attempt"], json!(2));
let mut all = fields(pinned.clone());
all.sort();
let mut both = named("fields");
both.extend(named("pinned_fields"));
both.sort();
assert_eq!(all, both);
let registry = crate::event::registry();
let id = crate::payload::schema_of(crate::event::PipelineKind::NodeRequeued);
let document = registry.schema(&id).expect("node-requeued is registered");
let required: Vec<String> =
serde_json::from_value(document["required"].clone()).expect("required keys");
for key in named("fields") {
assert!(
required.contains(&key),
"`{key}` is not required: {document}"
);
}
for key in named("pinned_fields") {
assert!(
document["properties"].get(&key).is_some(),
"the document does not name the pin's `{key}`: {document}"
);
assert!(
!required.contains(&key),
"the pin's `{key}` is required: {document}"
);
}
for shape in [requeue_payload("pool exhausted", None), pinned] {
registry
.check(&id, &Value::Object(shape.clone()))
.unwrap_or_else(|refusal| {
panic!("the document refuses a requeue this crate writes: {refusal}\n{shape:?}")
});
}
let unlimited = WorkspaceHold {
overflow: Bound::Unlimited,
..a_hold()
};
assert_eq!(unlimited.payload()["overflow"], json!("unlimited"));
assert_eq!(
WorkspaceHold::of_payload(&unlimited.payload()),
Some(unlimited)
);
assert_eq!(
WorkspaceHold::of_payload(&json!({"kind": "release", "awaiting": []})),
None
);
let mut short = a_hold().payload();
short.as_object_mut().expect("an object").remove("idle");
assert_eq!(WorkspaceHold::of_payload(&short), None);
}
#[test]
fn the_wait_surface_says_what_a_reader_needs_and_blocks_nothing() {
let surface = wait_surface("service", &a_hold());
assert_eq!(surface.kind, WAIT_SURFACE_KIND);
assert!(!surface.blocking);
assert_eq!(surface.workstream.as_deref(), Some("service"));
for names in [
"'github.com/owner/service' workspace admits no more sessions",
"pool 1 (1 slot(s): 0 idle, 0 maintaining), overflow 0 with 0 in use",
"onevcs pool status github.com/owner/service",
"Nothing times this out",
] {
assert!(
surface.message.contains(names),
"the surface does not say {names:?}: {}",
surface.message
);
}
}
#[test]
fn an_unusable_poll_bound_falls_back_to_the_shipped_one() {
let _held = crate::vcs::scratch_home_held();
for unusable in ["0", "", "soon", "-1"] {
std::env::set_var(POLL_ENV, unusable);
assert_eq!(
poll_seconds(),
DEFAULT_POLL_SECONDS,
"{POLL_ENV}={unusable:?}"
);
}
std::env::set_var(POLL_ENV, "7");
assert_eq!(poll_seconds(), 7);
assert_eq!(Workspaces::new().every, Duration::from_secs(7));
std::env::remove_var(POLL_ENV);
assert_eq!(poll_seconds(), DEFAULT_POLL_SECONDS);
}
#[test]
fn a_refusal_holds_the_node_until_the_paced_read_and_paces_the_wait() {
let mut workspaces = Workspaces::new();
assert_eq!(workspaces.next_read(), Duration::MAX);
workspaces.every = Duration::from_secs(3_600);
workspaces.refused("service", Some(a_hold()), None);
let request = SessionRequest {
repo: "nowhere".into(),
branch: None,
base: None,
execution_checkout: None,
pool: None,
overflow: None,
labels: Default::default(),
};
workspaces.begin_pass(|_| false);
assert_eq!(workspaces.admit("service", &request), Admission::Held);
assert_eq!(workspaces.held().get("service"), Some(&a_hold()));
assert!(workspaces.next_read() <= Duration::from_secs(3_600));
assert!(workspaces.next_read() > Duration::from_secs(3_000));
}
}