v0.3.6-pre.002
This commit is contained in:
14
crates/ksp-job-api/Cargo.toml
Normal file
14
crates/ksp-job-api/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
# file: crates/ksp-job-api/Cargo.toml
|
||||
# version: 1
|
||||
|
||||
[package]
|
||||
name = "ksp-job-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
44
crates/ksp-job-api/src/cancellation.rs
Normal file
44
crates/ksp-job-api/src/cancellation.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
// file: crates/ksp-job-api/src/cancellation.rs
|
||||
// version: 1
|
||||
|
||||
/// Runtime-neutral cloneable token carrying cooperative cancellation intent.
|
||||
#[derive(Clone)]
|
||||
pub struct JobCancellationToken {
|
||||
requested: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl JobCancellationToken {
|
||||
/// Creates a token with no cancellation request.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
return Self { requested: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) };
|
||||
}
|
||||
|
||||
/// Requests cancellation and returns `true` only for the first request shared by all clones.
|
||||
#[must_use]
|
||||
pub fn cancel(&self) -> bool {
|
||||
return !self.requested.swap(true, std::sync::atomic::Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Reports whether cancellation has been requested through any clone.
|
||||
#[must_use]
|
||||
pub fn is_cancellation_requested(&self) -> bool {
|
||||
return self.requested.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::default::Default for JobCancellationToken {
|
||||
fn default() -> Self {
|
||||
return Self::new();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JobCancellationToken {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("JobCancellationToken").field("cancellation_requested", &self.is_cancellation_requested()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/cancellation.rs"]
|
||||
mod tests;
|
||||
9
crates/ksp-job-api/src/error.rs
Normal file
9
crates/ksp-job-api/src/error.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
// file: crates/ksp-job-api/src/error.rs
|
||||
// version: 1
|
||||
|
||||
/// Error code used when a Job identifier violates its bounded safe-code contract.
|
||||
pub const ERROR_CODE_JOB_ID_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_api", "job_id_invalid");
|
||||
/// Error code used when a Job kind code violates its bounded safe-code contract.
|
||||
pub const ERROR_CODE_JOB_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_api", "job_kind_invalid");
|
||||
/// Error code used when a requested Job lifecycle transition is not allowed.
|
||||
pub const ERROR_CODE_JOB_TRANSITION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_api", "job_transition_invalid");
|
||||
76
crates/ksp-job-api/src/identity.rs
Normal file
76
crates/ksp-job-api/src/identity.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
// file: crates/ksp-job-api/src/identity.rs
|
||||
// version: 1
|
||||
|
||||
/// Maximum UTF-8 byte length admitted for one Job identifier.
|
||||
pub const MAX_JOB_ID_BYTES: usize = 128;
|
||||
/// Maximum UTF-8 byte length admitted for one Job kind code.
|
||||
pub const MAX_JOB_KIND_CODE_BYTES: usize = 128;
|
||||
|
||||
/// Bounded caller-supplied identity of one logical Job and its controlled resumptions.
|
||||
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct JobId(std::string::String);
|
||||
|
||||
impl JobId {
|
||||
/// Creates one non-empty Job identifier using the KSP safe-code alphabet.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> crate::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_job_code(value.as_str(), crate::MAX_JOB_ID_BYTES) {
|
||||
return std::result::Result::Err(identity_error(crate::ERROR_CODE_JOB_ID_INVALID, "job_id"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated Job identifier.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JobId {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.write_str("JobId(..)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded stable code identifying one concrete family of Jobs.
|
||||
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct JobKindCode(std::string::String);
|
||||
|
||||
impl JobKindCode {
|
||||
/// Creates one non-empty Job kind using the KSP safe-code alphabet.
|
||||
pub fn new(value: impl std::convert::Into<std::string::String>) -> crate::Result<Self> {
|
||||
let value = value.into();
|
||||
if !valid_job_code(value.as_str(), crate::MAX_JOB_KIND_CODE_BYTES) {
|
||||
return std::result::Result::Err(identity_error(crate::ERROR_CODE_JOB_KIND_INVALID, "job_kind"));
|
||||
}
|
||||
return std::result::Result::Ok(Self(value));
|
||||
}
|
||||
|
||||
/// Returns the validated stable Job kind code.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
return self.0.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JobKindCode {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_tuple("JobKindCode").field(&self.0).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_error(code: crate::ErrorCode, field: &'static str) -> crate::Error {
|
||||
return crate::Error::new(code, "invalid bounded Job identity").with_context("field", field);
|
||||
}
|
||||
|
||||
fn valid_job_code(value: &str, maximum_len: usize) -> bool {
|
||||
if value.is_empty() || value.len() > maximum_len {
|
||||
return false;
|
||||
}
|
||||
return value.bytes().all(|byte| return byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/identity.rs"]
|
||||
mod tests;
|
||||
49
crates/ksp-job-api/src/lib.rs
Normal file
49
crates/ksp-job-api/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
// file: crates/ksp-job-api/src/lib.rs
|
||||
// version: 1
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Passive runtime-neutral lifecycle contracts for bounded KSP Jobs.
|
||||
//!
|
||||
//! This foundation owns validated Job identity, explicit lifecycle transitions
|
||||
//! and cooperative cancellation intent. Notification delivery, concrete Job
|
||||
//! behavior, runtime spawning, Transport, Store and Worker contracts remain
|
||||
//! outside this crate.
|
||||
|
||||
mod cancellation;
|
||||
mod error;
|
||||
mod identity;
|
||||
mod lifecycle;
|
||||
|
||||
/// Runtime-neutral cloneable token carrying cooperative cancellation intent.
|
||||
pub use self::cancellation::JobCancellationToken;
|
||||
/// Error code used when a Job identifier violates its bounded safe-code contract.
|
||||
pub use self::error::ERROR_CODE_JOB_ID_INVALID;
|
||||
/// Error code used when a Job kind code violates its bounded safe-code contract.
|
||||
pub use self::error::ERROR_CODE_JOB_KIND_INVALID;
|
||||
/// Error code used when a requested Job lifecycle transition is not allowed.
|
||||
pub use self::error::ERROR_CODE_JOB_TRANSITION_INVALID;
|
||||
/// Bounded caller-supplied identity of one logical Job and its controlled resumptions.
|
||||
pub use self::identity::JobId;
|
||||
/// Bounded stable code identifying one concrete family of Jobs.
|
||||
pub use self::identity::JobKindCode;
|
||||
/// Maximum UTF-8 byte length admitted for one Job identifier.
|
||||
pub use self::identity::MAX_JOB_ID_BYTES;
|
||||
/// Maximum UTF-8 byte length admitted for one Job kind code.
|
||||
pub use self::identity::MAX_JOB_KIND_CODE_BYTES;
|
||||
/// Completion classification of a Job that reached its normal terminal state.
|
||||
pub use self::lifecycle::JobCompletion;
|
||||
/// Passive owner of one Job identity and its validated lifecycle state.
|
||||
pub use self::lifecycle::JobLifecycle;
|
||||
/// Current lifecycle state of one bounded Job.
|
||||
pub use self::lifecycle::JobState;
|
||||
/// Common KSP error type used by Job-facing contracts.
|
||||
pub use ksp_core_lib::Error;
|
||||
/// Stable structured code identifying a KSP error category and condition.
|
||||
pub use ksp_core_lib::ErrorCode;
|
||||
/// Structured contextual field attached to a KSP error.
|
||||
pub use ksp_core_lib::ErrorContext;
|
||||
/// Common KSP result alias using [`Error`].
|
||||
pub use ksp_core_lib::Result;
|
||||
168
crates/ksp-job-api/src/lifecycle.rs
Normal file
168
crates/ksp-job-api/src/lifecycle.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
// file: crates/ksp-job-api/src/lifecycle.rs
|
||||
// version: 1
|
||||
|
||||
/// Completion classification of a Job that reached its normal terminal state.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum JobCompletion {
|
||||
/// Every selected candidate or unit of work reached a durable complete result.
|
||||
Complete,
|
||||
/// The Job terminated normally while retaining one or more explicitly observable gaps.
|
||||
Partial,
|
||||
}
|
||||
|
||||
impl JobCompletion {
|
||||
/// Returns the stable safe code for this completion classification.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Complete => "complete",
|
||||
Self::Partial => "partial",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Current lifecycle state of one bounded Job.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum JobState {
|
||||
/// The Job was admitted but has not started producing work.
|
||||
Created,
|
||||
/// The Job is actively producing or draining normal work.
|
||||
Running,
|
||||
/// Cancellation was observed and new work must no longer be admitted.
|
||||
Cancelling,
|
||||
/// The Job reached a normal terminal state with an explicit completion classification.
|
||||
Completed(crate::JobCompletion),
|
||||
/// The Job reached its cooperative cancellation terminal state.
|
||||
Cancelled,
|
||||
/// The Job reached a terminal failure state.
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl JobState {
|
||||
/// Returns the stable safe lifecycle code without rendering Job data.
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
return match self {
|
||||
Self::Created => "created",
|
||||
Self::Running => "running",
|
||||
Self::Cancelling => "cancelling",
|
||||
Self::Completed(_) => "completed",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Failed => "failed",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the normal completion classification when the state is [`Self::Completed`].
|
||||
#[must_use]
|
||||
pub const fn completion(&self) -> std::option::Option<crate::JobCompletion> {
|
||||
return match self {
|
||||
Self::Completed(completion) => std::option::Option::Some(*completion),
|
||||
_ => std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reports whether no later lifecycle transition is permitted.
|
||||
#[must_use]
|
||||
pub const fn is_terminal(&self) -> bool {
|
||||
return matches!(self, Self::Completed(_) | Self::Cancelled | Self::Failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Passive owner of one Job identity and its validated lifecycle state.
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub struct JobLifecycle {
|
||||
id: crate::JobId,
|
||||
kind: crate::JobKindCode,
|
||||
state: crate::JobState,
|
||||
}
|
||||
|
||||
impl JobLifecycle {
|
||||
/// Creates one lifecycle in [`JobState::Created`] state.
|
||||
#[must_use]
|
||||
pub const fn new(id: crate::JobId, kind: crate::JobKindCode) -> Self {
|
||||
return Self { id, kind, state: crate::JobState::Created };
|
||||
}
|
||||
|
||||
/// Returns the logical Job identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> &crate::JobId {
|
||||
return &self.id;
|
||||
}
|
||||
|
||||
/// Returns the stable Job family code.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> &crate::JobKindCode {
|
||||
return &self.kind;
|
||||
}
|
||||
|
||||
/// Returns the current lifecycle state.
|
||||
#[must_use]
|
||||
pub const fn state(&self) -> crate::JobState {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
/// Transitions a newly created Job into its running state.
|
||||
pub fn start(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::JobState::Running);
|
||||
}
|
||||
|
||||
/// Records that a running Job observed cooperative cancellation intent.
|
||||
pub fn mark_cancelling(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::JobState::Cancelling);
|
||||
}
|
||||
|
||||
/// Completes a running or cancelling Job with its explicit normal outcome.
|
||||
pub fn complete(&mut self, completion: crate::JobCompletion) -> crate::Result<()> {
|
||||
return self.transition(crate::JobState::Completed(completion));
|
||||
}
|
||||
|
||||
/// Marks a created or cancelling Job as cooperatively cancelled.
|
||||
pub fn mark_cancelled(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::JobState::Cancelled);
|
||||
}
|
||||
|
||||
/// Marks a running or cancelling Job as failed.
|
||||
pub fn fail(&mut self) -> crate::Result<()> {
|
||||
return self.transition(crate::JobState::Failed);
|
||||
}
|
||||
|
||||
fn transition(&mut self, target: crate::JobState) -> crate::Result<()> {
|
||||
if !allowed_transition(self.state, target) {
|
||||
return std::result::Result::Err(transition_error(self.state, target));
|
||||
}
|
||||
self.state = target;
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for JobLifecycle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter.debug_struct("JobLifecycle").field("id", &self.id).field("kind", &self.kind).field("state", &self.state).finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_transition(source: crate::JobState, target: crate::JobState) -> bool {
|
||||
return matches!(
|
||||
(source, target),
|
||||
(crate::JobState::Created, crate::JobState::Running)
|
||||
| (crate::JobState::Created, crate::JobState::Cancelled)
|
||||
| (crate::JobState::Running, crate::JobState::Cancelling)
|
||||
| (crate::JobState::Running, crate::JobState::Completed(_))
|
||||
| (crate::JobState::Running, crate::JobState::Failed)
|
||||
| (crate::JobState::Cancelling, crate::JobState::Completed(_))
|
||||
| (crate::JobState::Cancelling, crate::JobState::Cancelled)
|
||||
| (crate::JobState::Cancelling, crate::JobState::Failed)
|
||||
);
|
||||
}
|
||||
|
||||
fn transition_error(source: crate::JobState, target: crate::JobState) -> crate::Error {
|
||||
return crate::Error::new(crate::ERROR_CODE_JOB_TRANSITION_INVALID, "invalid Job lifecycle transition")
|
||||
.with_context("source_state", source.code())
|
||||
.with_context("target_state", target.code());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/lifecycle.rs"]
|
||||
mod tests;
|
||||
81
crates/ksp-job-api/tests/dependency_boundary.rs
Normal file
81
crates/ksp-job-api/tests/dependency_boundary.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
// file: crates/ksp-job-api/tests/dependency_boundary.rs
|
||||
// version: 1
|
||||
|
||||
//! Dependency and runtime-neutrality canaries for the Job API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_manifest_has_exact_core_only_dependency_graph() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
assert!(!manifest.contains("[features]"));
|
||||
assert!(!manifest.contains("[dev-dependencies]"));
|
||||
assert!(!manifest.contains("[build-dependencies]"));
|
||||
assert_eq!(manifest.matches("[dependencies]").count(), 1);
|
||||
let dependencies_tail = manifest.split("[dependencies]").nth(1);
|
||||
assert!(dependencies_tail.is_some());
|
||||
let dependencies_tail = match dependencies_tail {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let dependencies = match dependencies_tail.split("[lints]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert_eq!(manifest_dependency_names(dependencies), std::vec!["ksp-core-lib"]);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_production_sources_forbid_runtime_domain_and_wire_dependencies() {
|
||||
let sources = [
|
||||
include_str!("../src/cancellation.rs"),
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/identity.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/lifecycle.rs"),
|
||||
];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"ksp_config_lib::",
|
||||
"ksp_interface_lib::",
|
||||
"ksp_logging_lib::",
|
||||
"ksp_offchain_transport_lib::",
|
||||
"ksp_onchain_transport_lib::",
|
||||
"ksp_store_api::",
|
||||
"ksp_store_lib::",
|
||||
"ksp_worker",
|
||||
"reqwest::",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"solana_",
|
||||
"tauri::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
concat!("tracing", "::"),
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden Job API dependency path detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn manifest_dependency_names(section: &str) -> std::vec::Vec<&str> {
|
||||
let mut names = std::vec::Vec::new();
|
||||
for line in section.lines() {
|
||||
let content = match line.split('#').next() {
|
||||
std::option::Option::Some(value) => value.trim(),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = match content.split('=').next() {
|
||||
std::option::Option::Some(value) => value.trim().trim_end_matches(".workspace"),
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
return names;
|
||||
}
|
||||
53
crates/ksp-job-api/tests/public_api.rs
Normal file
53
crates/ksp-job-api/tests/public_api.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// file: crates/ksp-job-api/tests/public_api.rs
|
||||
// version: 1
|
||||
|
||||
//! External-consumer canaries for the public Job API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_lifecycle_and_cancellation_are_consumable_from_crate_root() {
|
||||
let id = ksp_job_api::JobId::new("campaign-2026.09.01:001");
|
||||
let kind = ksp_job_api::JobKindCode::new("backfill_raw");
|
||||
assert!(id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let id = match id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut lifecycle = ksp_job_api::JobLifecycle::new(id.clone(), kind.clone());
|
||||
assert_eq!(lifecycle.id(), &id);
|
||||
assert_eq!(lifecycle.kind(), &kind);
|
||||
assert_eq!(lifecycle.state(), ksp_job_api::JobState::Created);
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.complete(ksp_job_api::JobCompletion::Partial).is_ok());
|
||||
assert_eq!(lifecycle.state().completion(), std::option::Option::Some(ksp_job_api::JobCompletion::Partial));
|
||||
let token = ksp_job_api::JobCancellationToken::new();
|
||||
let clone = token.clone();
|
||||
assert!(token.cancel());
|
||||
assert!(clone.is_cancellation_requested());
|
||||
assert!(!clone.cancel());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_error_codes_are_stable_and_core_owned() {
|
||||
let codes: [ksp_core_lib::ErrorCode; 3] =
|
||||
[ksp_job_api::ERROR_CODE_JOB_ID_INVALID, ksp_job_api::ERROR_CODE_JOB_KIND_INVALID, ksp_job_api::ERROR_CODE_JOB_TRANSITION_INVALID];
|
||||
assert_eq!(codes[0].domain(), "job_api");
|
||||
assert_eq!(codes[0].code(), "job_id_invalid");
|
||||
assert_eq!(codes[1].domain(), "job_api");
|
||||
assert_eq!(codes[1].code(), "job_kind_invalid");
|
||||
assert_eq!(codes[2].domain(), "job_api");
|
||||
assert_eq!(codes[2].code(), "job_transition_invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_public_bounds_are_exact() {
|
||||
assert_eq!(ksp_job_api::MAX_JOB_ID_BYTES, 128);
|
||||
assert_eq!(ksp_job_api::MAX_JOB_KIND_CODE_BYTES, 128);
|
||||
return;
|
||||
}
|
||||
102
crates/ksp-job-api/tests/release_completeness.rs
Normal file
102
crates/ksp-job-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
// file: crates/ksp-job-api/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Completeness canaries for the initial Job API foundation.
|
||||
|
||||
#[test]
|
||||
fn pre_002_crate_root_export_inventory_is_exact() {
|
||||
let crate_root = include_str!("../src/lib.rs");
|
||||
let mut actual = std::vec::Vec::new();
|
||||
for line in crate_root.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("pub use ") {
|
||||
actual.push(trimmed);
|
||||
}
|
||||
}
|
||||
actual.sort_unstable();
|
||||
let mut expected = std::vec![
|
||||
"pub use self::cancellation::JobCancellationToken;",
|
||||
"pub use self::error::ERROR_CODE_JOB_ID_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_JOB_KIND_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_JOB_TRANSITION_INVALID;",
|
||||
"pub use self::identity::JobId;",
|
||||
"pub use self::identity::JobKindCode;",
|
||||
"pub use self::identity::MAX_JOB_ID_BYTES;",
|
||||
"pub use self::identity::MAX_JOB_KIND_CODE_BYTES;",
|
||||
"pub use self::lifecycle::JobCompletion;",
|
||||
"pub use self::lifecycle::JobLifecycle;",
|
||||
"pub use self::lifecycle::JobState;",
|
||||
"pub use ksp_core_lib::Error;",
|
||||
"pub use ksp_core_lib::ErrorCode;",
|
||||
"pub use ksp_core_lib::ErrorContext;",
|
||||
"pub use ksp_core_lib::Result;",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
assert_eq!(actual, expected);
|
||||
assert!(!crate_root.contains("pub mod "));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_production_module_inventory_is_exact() -> std::io::Result<()> {
|
||||
let source_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let entries = match std::fs::read_dir(source_root) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut names = std::vec::Vec::new();
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let file_type = match entry.file_type() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = match entry.file_name().into_string() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => continue,
|
||||
};
|
||||
if name.ends_with(".rs") {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
assert_eq!(names, std::vec!["cancellation.rs", "error.rs", "identity.rs", "lib.rs", "lifecycle.rs"]);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_surface_does_not_open_notifications_backfill_or_worker_contracts() {
|
||||
let sources = [
|
||||
include_str!("../src/cancellation.rs"),
|
||||
include_str!("../src/identity.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/lifecycle.rs"),
|
||||
];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"JobNotification",
|
||||
"JobNotificationSequence",
|
||||
"JobSnapshot",
|
||||
"JobSnapshotSource",
|
||||
"BackfillRequest",
|
||||
"WorkerControl",
|
||||
"WorkerState",
|
||||
"spawn(",
|
||||
"RawTransaction",
|
||||
"provider",
|
||||
"endpoint",
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "future or domain-specific Job contract leaked early: {forbidden}");
|
||||
}
|
||||
}
|
||||
let lifecycle_source = include_str!("../src/lifecycle.rs");
|
||||
assert!(lifecycle_source.contains("#[derive(Eq, PartialEq)]\npub struct JobLifecycle"));
|
||||
assert!(!lifecycle_source.contains("#[derive(Clone, Eq, PartialEq)]\npub struct JobLifecycle"));
|
||||
return;
|
||||
}
|
||||
72
crates/ksp-job-api/tests/security_hardening.rs
Normal file
72
crates/ksp-job-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// file: crates/ksp-job-api/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
//! Adversarial lifecycle, cancellation and redaction canaries.
|
||||
|
||||
const HOSTILE_MARKER: &str = "JOB-IDENTITY-SECRET-CANARY";
|
||||
|
||||
#[test]
|
||||
fn pre_002_job_id_and_lifecycle_debug_redact_caller_identity() {
|
||||
let id = ksp_job_api::JobId::new(HOSTILE_MARKER);
|
||||
let kind = ksp_job_api::JobKindCode::new("backfill_raw");
|
||||
assert!(id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let id = match id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(std::format!("{id:?}"), "JobId(..)");
|
||||
let lifecycle = ksp_job_api::JobLifecycle::new(id, kind);
|
||||
let debug = std::format!("{lifecycle:?}");
|
||||
assert!(debug.contains("JobLifecycle"));
|
||||
assert!(debug.contains("backfill_raw"));
|
||||
assert!(debug.contains("Created"));
|
||||
assert!(!debug.contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_terminal_states_are_immutable_under_all_public_mutators() {
|
||||
let id = ksp_job_api::JobId::new("terminal-job");
|
||||
let kind = ksp_job_api::JobKindCode::new("test_job");
|
||||
assert!(id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let id = match id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut lifecycle = ksp_job_api::JobLifecycle::new(id, kind);
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.complete(ksp_job_api::JobCompletion::Complete).is_ok());
|
||||
assert!(lifecycle.start().is_err());
|
||||
assert!(lifecycle.mark_cancelling().is_err());
|
||||
assert!(lifecycle.complete(ksp_job_api::JobCompletion::Partial).is_err());
|
||||
assert!(lifecycle.mark_cancelled().is_err());
|
||||
assert!(lifecycle.fail().is_err());
|
||||
assert_eq!(lifecycle.state(), ksp_job_api::JobState::Completed(ksp_job_api::JobCompletion::Complete));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_cancellation_token_is_send_sync_and_cross_thread_visible() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<ksp_job_api::JobCancellationToken>();
|
||||
let token = ksp_job_api::JobCancellationToken::new();
|
||||
let worker_token = token.clone();
|
||||
let thread = std::thread::spawn(move || return worker_token.cancel());
|
||||
let request = match thread.join() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(request);
|
||||
assert!(token.is_cancellation_requested());
|
||||
return;
|
||||
}
|
||||
22
crates/ksp-job-api/unit_tests/cancellation.rs
Normal file
22
crates/ksp-job-api/unit_tests/cancellation.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
// file: crates/ksp-job-api/unit_tests/cancellation.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pre_002_cancellation_is_shared_and_idempotent() {
|
||||
let token = crate::JobCancellationToken::new();
|
||||
let clone = token.clone();
|
||||
assert!(!token.is_cancellation_requested());
|
||||
assert!(clone.cancel());
|
||||
assert!(token.is_cancellation_requested());
|
||||
assert!(!token.cancel());
|
||||
assert!(!clone.cancel());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_default_token_starts_without_cancellation() {
|
||||
let token = crate::JobCancellationToken::default();
|
||||
assert!(!token.is_cancellation_requested());
|
||||
assert_eq!(std::format!("{token:?}"), "JobCancellationToken { cancellation_requested: false }");
|
||||
return;
|
||||
}
|
||||
57
crates/ksp-job-api/unit_tests/identity.rs
Normal file
57
crates/ksp-job-api/unit_tests/identity.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
// file: crates/ksp-job-api/unit_tests/identity.rs
|
||||
// version: 1
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_accepts_safe_codes_at_exact_bounds() {
|
||||
let job_id = crate::JobId::new("a".repeat(crate::MAX_JOB_ID_BYTES));
|
||||
let kind = crate::JobKindCode::new("backfill.raw:solana-1");
|
||||
assert!(job_id.is_ok());
|
||||
assert!(kind.is_ok());
|
||||
let job_id = match job_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match kind {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(job_id.as_str().len(), crate::MAX_JOB_ID_BYTES);
|
||||
assert_eq!(kind.as_str(), "backfill.raw:solana-1");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_identity_rejects_empty_oversized_and_unsafe_values() {
|
||||
for value in [std::string::String::new(), "a".repeat(crate::MAX_JOB_ID_BYTES + 1), "unsafe/value".to_string(), "unicode-é".to_string()] {
|
||||
let rejected = crate::JobId::new(value);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => continue,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JOB_ID_INVALID);
|
||||
assert_eq!(error.context().len(), 1);
|
||||
assert_eq!(error.context()[0].key(), "field");
|
||||
assert_eq!(error.context()[0].value(), "job_id");
|
||||
}
|
||||
for value in [std::string::String::new(), "b".repeat(crate::MAX_JOB_KIND_CODE_BYTES + 1), "backfill raw".to_string()] {
|
||||
let rejected = crate::JobKindCode::new(value);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => continue,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JOB_KIND_INVALID);
|
||||
assert_eq!(error.context()[0].value(), "job_kind");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_private_validator_uses_exact_safe_code_alphabet() {
|
||||
assert!(super::valid_job_code("AZaz09_.:-", 10));
|
||||
for rejected in ["slash/value", "space value", "line\nbreak", "é"] {
|
||||
assert!(!super::valid_job_code(rejected, crate::MAX_JOB_ID_BYTES));
|
||||
}
|
||||
return;
|
||||
}
|
||||
153
crates/ksp-job-api/unit_tests/lifecycle.rs
Normal file
153
crates/ksp-job-api/unit_tests/lifecycle.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
// file: crates/ksp-job-api/unit_tests/lifecycle.rs
|
||||
// version: 1
|
||||
|
||||
fn lifecycle() -> std::option::Option<crate::JobLifecycle> {
|
||||
let id = match crate::JobId::new("job-001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match crate::JobKindCode::new("backfill_raw") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(crate::JobLifecycle::new(id, kind));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_lifecycle_accepts_every_planned_terminal_path() {
|
||||
for completion in [crate::JobCompletion::Complete, crate::JobCompletion::Partial] {
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.complete(completion).is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::JobState::Completed(completion));
|
||||
assert!(lifecycle.state().is_terminal());
|
||||
assert_eq!(lifecycle.state().completion(), std::option::Option::Some(completion));
|
||||
}
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.fail().is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::JobState::Failed);
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.mark_cancelled().is_ok());
|
||||
assert_eq!(lifecycle.state(), crate::JobState::Cancelled);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_cancelling_allows_cancel_complete_or_fail() {
|
||||
for terminal in [crate::JobState::Cancelled, crate::JobState::Completed(crate::JobCompletion::Complete), crate::JobState::Failed] {
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
assert!(lifecycle.start().is_ok());
|
||||
assert!(lifecycle.mark_cancelling().is_ok());
|
||||
let result = match terminal {
|
||||
crate::JobState::Cancelled => lifecycle.mark_cancelled(),
|
||||
crate::JobState::Completed(completion) => lifecycle.complete(completion),
|
||||
crate::JobState::Failed => lifecycle.fail(),
|
||||
_ => return,
|
||||
};
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(lifecycle.state(), terminal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_invalid_transition_preserves_source_state_and_reports_safe_context() {
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let rejected = lifecycle.complete(crate::JobCompletion::Complete);
|
||||
assert!(rejected.is_err());
|
||||
assert_eq!(lifecycle.state(), crate::JobState::Created);
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_JOB_TRANSITION_INVALID);
|
||||
assert_eq!(error.context().len(), 2);
|
||||
assert_eq!(error.context()[0].key(), "source_state");
|
||||
assert_eq!(error.context()[0].value(), "created");
|
||||
assert_eq!(error.context()[1].key(), "target_state");
|
||||
assert_eq!(error.context()[1].value(), "completed");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_every_terminal_state_rejects_later_mutation() {
|
||||
let terminals = [crate::JobState::Completed(crate::JobCompletion::Complete), crate::JobState::Cancelled, crate::JobState::Failed];
|
||||
for terminal in terminals {
|
||||
let lifecycle = lifecycle();
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => continue,
|
||||
};
|
||||
if terminal == crate::JobState::Cancelled {
|
||||
assert!(lifecycle.mark_cancelled().is_ok());
|
||||
} else {
|
||||
assert!(lifecycle.start().is_ok());
|
||||
let terminal_result = match terminal {
|
||||
crate::JobState::Completed(completion) => lifecycle.complete(completion),
|
||||
crate::JobState::Failed => lifecycle.fail(),
|
||||
_ => return,
|
||||
};
|
||||
assert!(terminal_result.is_ok());
|
||||
}
|
||||
let rejected = lifecycle.start();
|
||||
assert!(rejected.is_err());
|
||||
assert_eq!(lifecycle.state(), terminal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_002_transition_matrix_is_exact() {
|
||||
let states = [
|
||||
crate::JobState::Created,
|
||||
crate::JobState::Running,
|
||||
crate::JobState::Cancelling,
|
||||
crate::JobState::Completed(crate::JobCompletion::Complete),
|
||||
crate::JobState::Cancelled,
|
||||
crate::JobState::Failed,
|
||||
];
|
||||
for source in states {
|
||||
for target in states {
|
||||
let expected = matches!(
|
||||
(source, target),
|
||||
(crate::JobState::Created, crate::JobState::Running)
|
||||
| (crate::JobState::Created, crate::JobState::Cancelled)
|
||||
| (crate::JobState::Running, crate::JobState::Cancelling)
|
||||
| (crate::JobState::Running, crate::JobState::Completed(_))
|
||||
| (crate::JobState::Running, crate::JobState::Failed)
|
||||
| (crate::JobState::Cancelling, crate::JobState::Completed(_))
|
||||
| (crate::JobState::Cancelling, crate::JobState::Cancelled)
|
||||
| (crate::JobState::Cancelling, crate::JobState::Failed)
|
||||
);
|
||||
assert_eq!(super::allowed_transition(source, target), expected, "unexpected transition matrix result for {source:?} -> {target:?}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Reference in New Issue
Block a user