mirror of
https://seed.radicle.dev/rad:z2UcCU1LgMshWvXj6hXSDDrwB8q8M.git
synced 2026-10-18 19:30:30 +08:00
radicle-job
The genesis of the `Job` collaborative object
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+2005
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "radicle-job"
|
||||||
|
description = "Radicle Job Collaborative Object"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Fintan Halpenny <fintan.halpenny@gmail.com>"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
indexmap = { version = "2.7.1", features = ["serde"] }
|
||||||
|
nonempty = "0.11.0"
|
||||||
|
once_cell = "1.20.3"
|
||||||
|
qcheck = "1.0.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
thiserror = "2.0.11"
|
||||||
|
url = { version = "2.5.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.13.1", features = ["serde", "v4"] }
|
||||||
|
|
||||||
|
# TODO: we depend on a commit that improves the `Transaction::initial` method
|
||||||
|
[dependencies.radicle]
|
||||||
|
git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
|
||||||
|
rev= "refs/namespaces/z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/refs/heads/cob-transaction"
|
||||||
|
|
||||||
|
[dev-dependencies.radicle]
|
||||||
|
git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
|
||||||
|
rev= "refs/namespaces/z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/refs/heads/cob-transaction"
|
||||||
|
features = ["test"]
|
||||||
Generated
+98
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"crane": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1742394900,
|
||||||
|
"narHash": "sha256-vVOAp9ahvnU+fQoKd4SEXB2JG2wbENkpqcwlkIXgUC0=",
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"rev": "70947c1908108c0c551ddfd73d4f750ff2ea67cd",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "ipetkov",
|
||||||
|
"repo": "crane",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1742998488,
|
||||||
|
"narHash": "sha256-r7dqSLNkzg1rOPsq4Tuc6p+UtAGqNRMma4riupsuZv4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "8e0b1e9c8c4dfaef0ff85e09c91294ba4afa4168",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "release-24.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"crane": "crane",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1742956365,
|
||||||
|
"narHash": "sha256-Slrqmt6kJ/M7Z/ce4ebQWsz2aeEodrX56CsupOEPoz0=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "a0e3395c63cdbc9c1ec17915f8328c077c79c4a1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
description = "Radicle Job Collaborative Object";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/release-24.11";
|
||||||
|
|
||||||
|
crane.url = "github:ipetkov/crane";
|
||||||
|
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
crane,
|
||||||
|
flake-utils,
|
||||||
|
rust-overlay,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
|
lib = nixpkgs.lib;
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [(import rust-overlay)];
|
||||||
|
};
|
||||||
|
|
||||||
|
rustToolChain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||||
|
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolChain;
|
||||||
|
|
||||||
|
src = lib.cleanSourceWith {
|
||||||
|
src = ./.;
|
||||||
|
};
|
||||||
|
|
||||||
|
basicArgs = {
|
||||||
|
inherit src;
|
||||||
|
pname = "radicle-job";
|
||||||
|
strictDeps = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Build *just* the cargo dependencies, so we can reuse
|
||||||
|
# all of that work (e.g. via cachix) when running in CI
|
||||||
|
cargoArtifacts = craneLib.buildDepsOnly basicArgs;
|
||||||
|
|
||||||
|
# Common arguments can be set here to avoid repeating them later
|
||||||
|
commonArgs =
|
||||||
|
basicArgs
|
||||||
|
// {
|
||||||
|
inherit cargoArtifacts;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
# Add additional build inputs here
|
||||||
|
];
|
||||||
|
buildInputs = lib.optionals pkgs.stdenv.buildPlatform.isDarwin (with pkgs; [
|
||||||
|
darwin.apple_sdk.frameworks.Security
|
||||||
|
pkgs.libiconv
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
formatter = pkgs.alejandra;
|
||||||
|
|
||||||
|
checks = {
|
||||||
|
inherit (self.packages.${system}) radicle-job;
|
||||||
|
|
||||||
|
clippy = craneLib.cargoClippy (commonArgs
|
||||||
|
// {
|
||||||
|
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
|
||||||
|
});
|
||||||
|
|
||||||
|
doc = craneLib.cargoDoc commonArgs;
|
||||||
|
fmt = craneLib.cargoFmt basicArgs;
|
||||||
|
# Run tests with cargo-nextest
|
||||||
|
nextest = craneLib.cargoNextest (commonArgs
|
||||||
|
// {
|
||||||
|
partitions = 1;
|
||||||
|
partitionType = "count";
|
||||||
|
# Ensure dev is used since we rely on env variables being
|
||||||
|
# set in tests.
|
||||||
|
env.CARGO_PROFILE = "dev";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
packages.radicle-job = craneLib.buildPackage (commonArgs
|
||||||
|
// {
|
||||||
|
src = craneLib.cleanCargoSource ./.;
|
||||||
|
strictDeps = true;
|
||||||
|
doCheck = false;
|
||||||
|
});
|
||||||
|
packages.default = self.packages.${system}.radicle-job;
|
||||||
|
|
||||||
|
devShells.default = craneLib.devShell {
|
||||||
|
# Inherit inputs from checks.
|
||||||
|
checks = self.checks.${system};
|
||||||
|
|
||||||
|
# Extra inputs can be added here; cargo and rustc are provided by default
|
||||||
|
# from the toolchain that was specified earlier.
|
||||||
|
packages = with pkgs; [
|
||||||
|
cargo-watch
|
||||||
|
ripgrep
|
||||||
|
rust-analyzer
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.84"
|
||||||
|
profile = "default"
|
||||||
|
components = [ "rust-src" ]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use radicle::{cob, git};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Build {
|
||||||
|
#[error("initial action of job must request an OID")]
|
||||||
|
Initial,
|
||||||
|
#[error("missing commit for job run {oid}: {err}")]
|
||||||
|
MissingCommit {
|
||||||
|
oid: git::Oid,
|
||||||
|
#[source]
|
||||||
|
err: git::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Apply {
|
||||||
|
#[error(transparent)]
|
||||||
|
Build(#[from] Build),
|
||||||
|
#[error(transparent)]
|
||||||
|
Op(#[from] cob::op::OpEncodingError),
|
||||||
|
}
|
||||||
+807
@@ -0,0 +1,807 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use indexmap::IndexMap;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use radicle::cob::store::Cob;
|
||||||
|
use radicle::cob::{self, store, EntryId, Evaluate, ObjectId, Op, TypeName};
|
||||||
|
use radicle::crypto::Signer;
|
||||||
|
use radicle::node::NodeId;
|
||||||
|
use radicle::prelude::ReadRepository;
|
||||||
|
use radicle::storage::{RepositoryError, SignRepository, WriteRepository};
|
||||||
|
use radicle::{cob::store::CobAction, git::Oid};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
|
/// Type name of a patch.
|
||||||
|
pub static TYPENAME: Lazy<TypeName> =
|
||||||
|
Lazy::new(|| FromStr::from_str("xyz.radworks.job").expect("type name is valid"));
|
||||||
|
|
||||||
|
/// A `Job` describes a generic task run for a given commit [`Oid`] by a set of
|
||||||
|
/// nodes.
|
||||||
|
///
|
||||||
|
/// A node may run the task many times, which are accumulated in its [`Runs`]
|
||||||
|
/// set. Each [`Run`] is identified by a [`Uuid`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Job {
|
||||||
|
oid: Oid,
|
||||||
|
runs: HashMap<NodeId, Runs>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A set of [`Run`]s identified by a [`Uuid`].
|
||||||
|
///
|
||||||
|
/// Iteration over this set is guaranteed to come in insertion order.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Runs(IndexMap<Uuid, Run>);
|
||||||
|
|
||||||
|
impl Runs {
|
||||||
|
/// Insert a new [`Run`], identified by the given [`Uuid`].
|
||||||
|
pub fn insert(&mut self, uuid: Uuid, run: Run) -> Option<Run> {
|
||||||
|
self.0.insert(uuid, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that the set of runs contains the given [`Uuid`].
|
||||||
|
pub fn contains_key(&self, uuid: &Uuid) -> bool {
|
||||||
|
self.0.contains_key(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the [`Run`] identifier by the given [`Uuid`].
|
||||||
|
///
|
||||||
|
/// Return `None` if the `Uuid` does not exist.`
|
||||||
|
pub fn get(&self, uuid: &Uuid) -> Option<&Run> {
|
||||||
|
self.0.get(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the `nth` [`Run`] of the set.
|
||||||
|
pub fn get_index(&self, nth: usize) -> Option<(&Uuid, &Run)> {
|
||||||
|
self.0.get_index(nth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the latest [`Run`] and its corresponding [`Uuid`].
|
||||||
|
pub fn latest(&self) -> Option<(&Uuid, &Run)> {
|
||||||
|
self.0.iter().next_back()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all [`Run`]s that have started [`Status`].
|
||||||
|
pub fn started(&self) -> Runs {
|
||||||
|
self.iter()
|
||||||
|
.filter_map(|(uuid, run)| run.is_started().then_some((*uuid, run.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all [`Run`]s that have finished [`Status`].
|
||||||
|
pub fn finished(&self) -> Runs {
|
||||||
|
self.iter()
|
||||||
|
.filter_map(|(uuid, run)| run.is_finished().then_some((*uuid, run.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all [`Run`]s that have finished [`Status`] and have the succeeded
|
||||||
|
/// [`Reason`].
|
||||||
|
pub fn succeeded(&self) -> Runs {
|
||||||
|
self.iter()
|
||||||
|
.filter_map(|(uuid, run)| run.succeeded().then_some((*uuid, run.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all [`Run`]s that have finished [`Status`] and have the failed
|
||||||
|
/// [`Reason`].
|
||||||
|
pub fn failed(&self) -> Runs {
|
||||||
|
self.iter()
|
||||||
|
.filter_map(|(uuid, run)| run.failed().then_some((*uuid, run.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partition all [`Run`]s into started, succeeded, and failed.
|
||||||
|
pub fn partition(&self) -> (Runs, Runs, Runs) {
|
||||||
|
let mut started = IndexMap::new();
|
||||||
|
let mut succeeded = IndexMap::new();
|
||||||
|
let mut failed = IndexMap::new();
|
||||||
|
|
||||||
|
for (uuid, run) in self.0.iter() {
|
||||||
|
match run.status {
|
||||||
|
Status::Started => started.insert(*uuid, run.clone()),
|
||||||
|
Status::Finished(Reason::Succeeded) => succeeded.insert(*uuid, run.clone()),
|
||||||
|
Status::Finished(Reason::Failed) => failed.insert(*uuid, run.clone()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(Runs(started), Runs(succeeded), Runs(failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check is the set of [`Runs`] empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of [`Runs`] in the set.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over the [`Run`]s and their corresponding [`Uuid`]s.
|
||||||
|
///
|
||||||
|
/// The order of the iteration is guaranteed to be insertion order.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &Run)> {
|
||||||
|
self.0.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromIterator<(Uuid, Run)> for Runs {
|
||||||
|
fn from_iter<T: IntoIterator<Item = (Uuid, Run)>>(iter: T) -> Self {
|
||||||
|
Self(iter.into_iter().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> IntoIterator for &'a Runs {
|
||||||
|
type Item = (&'a Uuid, &'a Run);
|
||||||
|
type IntoIter = indexmap::map::Iter<'a, Uuid, Run>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.0.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoIterator for Runs {
|
||||||
|
type Item = (Uuid, Run);
|
||||||
|
type IntoIter = indexmap::map::IntoIter<Uuid, Run>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.0.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The collaborative object actions that are used for Radicle COB operations.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Request a [`Job`] be created for the given [`Oid`].
|
||||||
|
///
|
||||||
|
/// Note this is expected only once for initializing the [`Job`]. Every
|
||||||
|
/// other `Request` for the same `Oid` will be ignored.
|
||||||
|
Request {
|
||||||
|
/// The commit the [`Job`] corresponds to.
|
||||||
|
oid: Oid,
|
||||||
|
},
|
||||||
|
/// Notify that the node has started a [`Run`].
|
||||||
|
Run {
|
||||||
|
/// The [`Uuid`] that identifies this particular [`Run`] of the node.
|
||||||
|
uuid: Uuid,
|
||||||
|
/// The [`Url`] where the node will log any information or data.
|
||||||
|
log: Url,
|
||||||
|
},
|
||||||
|
/// Notify that the node has finished a [`Run`].
|
||||||
|
Finished {
|
||||||
|
/// The [`Uuid`] that identifies the [`Run`] that is finished.
|
||||||
|
uuid: Uuid,
|
||||||
|
/// The [`Reason`] which the node finished with.
|
||||||
|
reason: Reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CobAction for Action {
|
||||||
|
fn parents(&self) -> Vec<radicle::git::Oid> {
|
||||||
|
match self {
|
||||||
|
Action::Request { oid } => vec![*oid],
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
/// Construct a new [`Job`].
|
||||||
|
fn new(oid: Oid) -> Self {
|
||||||
|
Self {
|
||||||
|
oid,
|
||||||
|
runs: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the [`Oid`] this [`Job`] is running tasks for.
|
||||||
|
pub fn oid(&self) -> &Oid {
|
||||||
|
&self.oid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all the nodes that have started, but not finished, [`Runs`].
|
||||||
|
pub fn started(&self) -> HashMap<NodeId, Runs> {
|
||||||
|
self.filter_map_by(|runs| runs.started())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all the nodes that have started, and finished, [`Runs`].
|
||||||
|
pub fn finished(&self) -> HashMap<NodeId, Runs> {
|
||||||
|
self.filter_map_by(|runs| runs.finished())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all the nodes that have succeeded [`Runs`].
|
||||||
|
pub fn succeeded(&self) -> HashMap<NodeId, Runs> {
|
||||||
|
self.filter_map_by(|runs| runs.succeeded())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all the nodes that have failed [`Runs`].
|
||||||
|
pub fn failed(&self) -> HashMap<NodeId, Runs> {
|
||||||
|
self.filter_map_by(|runs| runs.failed())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all nodes' started, succeeded, and failed runs – respectively.
|
||||||
|
pub fn partition(&self) -> HashMap<NodeId, (Runs, Runs, Runs)> {
|
||||||
|
self.runs
|
||||||
|
.iter()
|
||||||
|
.map(|(node, runs)| (*node, runs.partition()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the latest [`Run`] of the given [`NodeId`].
|
||||||
|
pub fn latest_of(&self, node: &NodeId) -> Option<(&Uuid, &Run)> {
|
||||||
|
self.runs
|
||||||
|
.get(node)
|
||||||
|
.and_then(|runs| runs.0.iter().next_back())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the latest [`Run`]s of all [`NodeId`]s.
|
||||||
|
pub fn latest(&self) -> impl Iterator<Item = (&NodeId, &Uuid, &Run)> + '_ {
|
||||||
|
self.runs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(node, runs)| runs.latest().map(|(uuid, run)| (node, uuid, run)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the raw `HashMap` of the node runs.
|
||||||
|
pub fn runs(&self) -> &HashMap<NodeId, Runs> {
|
||||||
|
&self.runs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the [`Runs`] of a given [`NodeId`].
|
||||||
|
pub fn runs_of(&self, node: &NodeId) -> Option<&Runs> {
|
||||||
|
self.runs.get(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_map_by<P>(&self, p: P) -> HashMap<NodeId, Runs>
|
||||||
|
where
|
||||||
|
P: Fn(&Runs) -> Runs,
|
||||||
|
{
|
||||||
|
self.runs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(node, runs)| {
|
||||||
|
let runs = p(runs);
|
||||||
|
(!runs.is_empty()).then_some((*node, runs))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, node: NodeId, uuid: Uuid, run: Run) -> bool {
|
||||||
|
let runs = self.runs.entry(node).or_default();
|
||||||
|
if runs.contains_key(&uuid) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
runs.insert(uuid, run);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, node: NodeId, uuid: Uuid, reason: Reason) -> bool {
|
||||||
|
let Some(runs) = self.runs.get_mut(&node) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut updated = false;
|
||||||
|
runs.0.entry(uuid).and_modify(|run| {
|
||||||
|
updated = true;
|
||||||
|
*run = run.clone().finish(reason);
|
||||||
|
});
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(&mut self, node: NodeId, action: Action) -> Result<(), error::Build> {
|
||||||
|
match action {
|
||||||
|
// Cannot request for another `oid`, so we ignore any superfluous
|
||||||
|
// request actions
|
||||||
|
Action::Request { .. } => Ok(()),
|
||||||
|
Action::Run { uuid, log } => {
|
||||||
|
self.insert(node, uuid, Run::new(log));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Action::Finished { uuid, reason } => {
|
||||||
|
self.update(node, uuid, reason);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl store::Cob for Job {
|
||||||
|
type Action = Action;
|
||||||
|
type Error = error::Build;
|
||||||
|
|
||||||
|
fn type_name() -> &'static TypeName {
|
||||||
|
&TYPENAME
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_root<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error> {
|
||||||
|
let mut actions = op.actions.into_iter();
|
||||||
|
let Some(Action::Request { oid }) = actions.next() else {
|
||||||
|
return Err(error::Build::Initial);
|
||||||
|
};
|
||||||
|
repo.commit(oid)
|
||||||
|
.map_err(|err| error::Build::MissingCommit { oid, err })?;
|
||||||
|
let mut runs = Self::new(oid);
|
||||||
|
for action in actions {
|
||||||
|
runs.action(op.author, action)?;
|
||||||
|
}
|
||||||
|
Ok(runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a radicle::cob::Entry>>(
|
||||||
|
&mut self,
|
||||||
|
op: Op<Self::Action>,
|
||||||
|
_concurrent: I,
|
||||||
|
_repo: &R,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
for action in op.actions {
|
||||||
|
self.action(op.author, action)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: ReadRepository> Evaluate<R> for Job {
|
||||||
|
type Error = error::Apply;
|
||||||
|
|
||||||
|
fn init(entry: &radicle::cob::Entry, store: &R) -> Result<Self, Self::Error> {
|
||||||
|
let op = Op::try_from(entry)?;
|
||||||
|
let object = Job::from_root(op, store)?;
|
||||||
|
Ok(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply<'a, I: Iterator<Item = (&'a Oid, &'a radicle::cob::Entry)>>(
|
||||||
|
&mut self,
|
||||||
|
entry: &radicle::cob::Entry,
|
||||||
|
concurrent: I,
|
||||||
|
store: &R,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
let op = Op::try_from(entry)?;
|
||||||
|
self.op(op, concurrent.map(|(_, e)| e), store)
|
||||||
|
.map_err(error::Apply::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Run` represents a task run for a [`Job`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Run {
|
||||||
|
/// The status of the run.
|
||||||
|
///
|
||||||
|
/// A run can be in one of three states:
|
||||||
|
/// - Started
|
||||||
|
/// - Succeeded – implies that it has finished
|
||||||
|
/// - Failed – implies that it has finished
|
||||||
|
status: Status,
|
||||||
|
/// The [`Url`] of the [`Run`] where information is logged by the node.
|
||||||
|
log: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
fn new(log: Url) -> Self {
|
||||||
|
Self {
|
||||||
|
status: Status::Started,
|
||||||
|
log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self, reason: Reason) -> Self {
|
||||||
|
Self {
|
||||||
|
status: Status::Finished(reason),
|
||||||
|
log: self.log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> &Status {
|
||||||
|
&self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_started(&self) -> bool {
|
||||||
|
match self.status {
|
||||||
|
Status::Started => true,
|
||||||
|
Status::Finished(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_finished(&self) -> bool {
|
||||||
|
!self.is_started()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn succeeded(&self) -> bool {
|
||||||
|
match self.status {
|
||||||
|
Status::Started => false,
|
||||||
|
Status::Finished(Reason::Failed) => false,
|
||||||
|
Status::Finished(Reason::Succeeded) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(&self) -> bool {
|
||||||
|
match self.status {
|
||||||
|
Status::Started => false,
|
||||||
|
Status::Finished(Reason::Failed) => true,
|
||||||
|
Status::Finished(Reason::Succeeded) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status of a [`Run`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Status {
|
||||||
|
/// The [`Run`] has started.
|
||||||
|
Started,
|
||||||
|
/// The [`Run`] has finished with the given [`Reason`].
|
||||||
|
Finished(Reason),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason for a [`Status`] to have finished.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Reason {
|
||||||
|
Failed,
|
||||||
|
Succeeded,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Jobs<'a, R> {
|
||||||
|
raw: store::Store<'a, Job, R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> Deref for Jobs<'a, R> {
|
||||||
|
type Target = store::Store<'a, Job, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> Jobs<'a, R>
|
||||||
|
where
|
||||||
|
R: ReadRepository + cob::Store,
|
||||||
|
{
|
||||||
|
/// Open a jobs store.
|
||||||
|
pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
|
||||||
|
let identity = repository.identity_head()?;
|
||||||
|
let raw = store::Store::open(repository)?.identity(identity);
|
||||||
|
|
||||||
|
Ok(Self { raw })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the number of [`Job`]s in the store.
|
||||||
|
pub fn counts(&self) -> Result<usize, store::Error> {
|
||||||
|
Ok(self.all()?.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a [`Job`].
|
||||||
|
pub fn get(&self, id: &ObjectId) -> Result<Option<Job>, store::Error> {
|
||||||
|
self.raw.get(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, R> Jobs<'a, R>
|
||||||
|
where
|
||||||
|
R: ReadRepository + SignRepository + cob::Store,
|
||||||
|
{
|
||||||
|
/// Get a [`JobMut`].
|
||||||
|
pub fn get_mut<'g, C>(&'g mut self, id: &ObjectId) -> Result<JobMut<'a, 'g, R>, store::Error> {
|
||||||
|
let job = self
|
||||||
|
.raw
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
|
||||||
|
|
||||||
|
Ok(JobMut {
|
||||||
|
id: *id,
|
||||||
|
job,
|
||||||
|
store: self,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create<'g, G>(
|
||||||
|
&'g mut self,
|
||||||
|
oid: Oid,
|
||||||
|
signer: &G,
|
||||||
|
) -> Result<JobMut<'a, 'g, R>, store::Error>
|
||||||
|
where
|
||||||
|
G: Signer,
|
||||||
|
{
|
||||||
|
let (id, job) = store::Transaction::initial::<_, _, Transaction<R>>(
|
||||||
|
"Request job",
|
||||||
|
&mut self.raw,
|
||||||
|
signer,
|
||||||
|
|tx, _| {
|
||||||
|
tx.request(oid)?;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(JobMut {
|
||||||
|
id,
|
||||||
|
job,
|
||||||
|
store: self,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobMut<'a, 'g, R> {
|
||||||
|
pub id: ObjectId,
|
||||||
|
|
||||||
|
job: Job,
|
||||||
|
store: &'g mut Jobs<'a, R>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> Deref for JobMut<'_, '_, R> {
|
||||||
|
type Target = Job;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.job
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'g, R> JobMut<'a, 'g, R>
|
||||||
|
where
|
||||||
|
R: WriteRepository + cob::Store,
|
||||||
|
{
|
||||||
|
pub fn new(id: ObjectId, job: Job, store: &'g mut Jobs<'a, R>) -> Self {
|
||||||
|
Self { id, job, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &ObjectId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload the patch data from storage.
|
||||||
|
pub fn reload(&mut self) -> Result<(), store::Error> {
|
||||||
|
self.job = self
|
||||||
|
.store
|
||||||
|
.get(&self.id)?
|
||||||
|
.ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new [`Run`] for the node, where the run is identified by the
|
||||||
|
/// given [`Uuid`].
|
||||||
|
pub fn run<G>(&mut self, uuid: Uuid, log: Url, signer: &G) -> Result<EntryId, store::Error>
|
||||||
|
where
|
||||||
|
G: Signer,
|
||||||
|
{
|
||||||
|
self.transaction("Run node job", signer, |tx| tx.run(uuid, log))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish a [`Run`], identified by the given [`Uuid`], for the node, with
|
||||||
|
/// the provided [`Reason`].
|
||||||
|
pub fn finish<G>(
|
||||||
|
&mut self,
|
||||||
|
uuid: Uuid,
|
||||||
|
reason: Reason,
|
||||||
|
signer: &G,
|
||||||
|
) -> Result<EntryId, store::Error>
|
||||||
|
where
|
||||||
|
G: Signer,
|
||||||
|
{
|
||||||
|
self.transaction("Finished node job", signer, |tx| tx.finish(uuid, reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transaction<G, F>(
|
||||||
|
&mut self,
|
||||||
|
message: &str,
|
||||||
|
signer: &G,
|
||||||
|
operations: F,
|
||||||
|
) -> Result<EntryId, store::Error>
|
||||||
|
where
|
||||||
|
G: Signer,
|
||||||
|
F: FnOnce(&mut Transaction<R>) -> Result<(), store::Error>,
|
||||||
|
{
|
||||||
|
let mut tx = Transaction::default();
|
||||||
|
operations(&mut tx)?;
|
||||||
|
|
||||||
|
let (job, commit) = tx.0.commit(message, self.id, &mut self.store.raw, signer)?;
|
||||||
|
self.job = job;
|
||||||
|
|
||||||
|
Ok(commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Transaction<R: ReadRepository>(store::Transaction<Job, R>);
|
||||||
|
|
||||||
|
impl<R> From<store::Transaction<Job, R>> for Transaction<R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
fn from(tx: store::Transaction<Job, R>) -> Self {
|
||||||
|
Self(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> From<Transaction<R>> for store::Transaction<Job, R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
fn from(Transaction(tx): Transaction<R>) -> Self {
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> Default for Transaction<R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(Default::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> Deref for Transaction<R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
type Target = store::Transaction<Job, R>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> DerefMut for Transaction<R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> Transaction<R>
|
||||||
|
where
|
||||||
|
R: ReadRepository,
|
||||||
|
{
|
||||||
|
pub fn request(&mut self, oid: Oid) -> Result<(), store::Error> {
|
||||||
|
self.0.push(Action::Request { oid })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self, uuid: Uuid, log: Url) -> Result<(), store::Error> {
|
||||||
|
self.0.push(Action::Run { uuid, log })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(&mut self, uuid: Uuid, reason: Reason) -> Result<(), store::Error> {
|
||||||
|
self.0.push(Action::Finished { uuid, reason })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
mod test {
|
||||||
|
use radicle::crypto::Signer;
|
||||||
|
use radicle::git::{raw::Repository, Oid};
|
||||||
|
use radicle::test;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Jobs, Reason, Run, Runs, Status};
|
||||||
|
|
||||||
|
fn node_run() -> (Uuid, Url) {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let log = Url::parse(&format!("https://example.com/ci/logs?run={}", uuid)).unwrap();
|
||||||
|
(uuid, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit(repo: &Repository) -> Oid {
|
||||||
|
let tree = {
|
||||||
|
let tree = repo.treebuilder(None).unwrap();
|
||||||
|
let oid = tree.write().unwrap();
|
||||||
|
repo.find_tree(oid).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let author = repo.signature().unwrap();
|
||||||
|
repo.commit(None, &author, &author, "Test Commit", &tree, &[])
|
||||||
|
.unwrap()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn e2e() {
|
||||||
|
let test::setup::NodeWithRepo {
|
||||||
|
node: alice, repo, ..
|
||||||
|
} = test::setup::NodeWithRepo::default();
|
||||||
|
let oid = commit(&repo.backend);
|
||||||
|
let mut jobs = Jobs::open(&*repo).unwrap();
|
||||||
|
|
||||||
|
let test::setup::NodeWithRepo { node: bob, .. } = test::setup::NodeWithRepo::default();
|
||||||
|
let mut job = jobs.create(oid, &alice.signer).unwrap();
|
||||||
|
|
||||||
|
let (alice_uuid, alice_log) = node_run();
|
||||||
|
job.run(alice_uuid, alice_log.clone(), &alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (bob_uuid, bob_log) = node_run();
|
||||||
|
job.run(bob_uuid, bob_log.clone(), &bob.signer).unwrap();
|
||||||
|
|
||||||
|
let alice_runs = job.runs_of(alice.signer.public_key());
|
||||||
|
assert!(alice_runs.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
*alice_runs.unwrap(),
|
||||||
|
[(alice_uuid, Run::new(alice_log))]
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Runs>()
|
||||||
|
);
|
||||||
|
|
||||||
|
let bob_runs = job.runs_of(bob.signer.public_key());
|
||||||
|
assert!(bob_runs.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
*bob_runs.unwrap(),
|
||||||
|
[(bob_uuid, Run::new(bob_log))]
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Runs>()
|
||||||
|
);
|
||||||
|
|
||||||
|
job.finish(alice_uuid, Reason::Succeeded, &alice.signer)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let finished = job.finished();
|
||||||
|
assert!(finished.contains_key(alice.signer.public_key()));
|
||||||
|
assert!(!finished.contains_key(bob.signer.public_key()));
|
||||||
|
|
||||||
|
job.finish(bob_uuid, Reason::Failed, &bob.signer).unwrap();
|
||||||
|
|
||||||
|
let succeeded = job.succeeded();
|
||||||
|
assert!(succeeded.contains_key(alice.signer.public_key()));
|
||||||
|
assert!(!succeeded.contains_key(bob.signer.public_key()));
|
||||||
|
let failed = job.failed();
|
||||||
|
assert!(!failed.contains_key(alice.signer.public_key()));
|
||||||
|
assert!(failed.contains_key(bob.signer.public_key()));
|
||||||
|
let started = job.started();
|
||||||
|
assert!(started.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_commit() {
|
||||||
|
let test::setup::NodeWithRepo {
|
||||||
|
node: alice, repo, ..
|
||||||
|
} = test::setup::NodeWithRepo::default();
|
||||||
|
let mut jobs = Jobs::open(&*repo).unwrap();
|
||||||
|
let oid = test::arbitrary::oid();
|
||||||
|
let job = jobs.create(oid, &alice.signer);
|
||||||
|
assert!(job.is_err())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idempotent_create() {
|
||||||
|
let test::setup::NodeWithRepo {
|
||||||
|
node: alice, repo, ..
|
||||||
|
} = test::setup::NodeWithRepo::default();
|
||||||
|
let oid = commit(&repo.backend);
|
||||||
|
let mut jobs = Jobs::open(&*repo).unwrap();
|
||||||
|
let job1 = {
|
||||||
|
let job1 = jobs.create(oid, &alice.signer).unwrap();
|
||||||
|
job1.id
|
||||||
|
};
|
||||||
|
let job2 = {
|
||||||
|
let job2 = jobs.create(oid, &alice.signer).unwrap();
|
||||||
|
job2.id
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(job1, job2);
|
||||||
|
assert_eq!(jobs.get(&job1).unwrap(), jobs.get(&job2).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runs_insertion_order_iteration() {
|
||||||
|
let mut runs = Runs::default();
|
||||||
|
let uuids = (0..10).map(|_| Uuid::new_v4()).collect::<Vec<_>>();
|
||||||
|
for uuid in &uuids {
|
||||||
|
runs.insert(
|
||||||
|
*uuid,
|
||||||
|
Run {
|
||||||
|
status: Status::Started,
|
||||||
|
log: Url::parse("https://example.com/ci/logs").unwrap(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
uuids,
|
||||||
|
runs.iter()
|
||||||
|
.map(|(uuid, _)| uuid)
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user