v0.3.9-pre.003
This commit is contained in:
67
crates/ksp-worker-api/README.md
Normal file
67
crates/ksp-worker-api/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
<!-- file: crates/ksp-worker-api/README.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# ksp-worker-api
|
||||
|
||||
`ksp-worker-api` fournit les contrats passifs et runtime-neutral communs aux services continus KSP.
|
||||
|
||||
La crate possède l'identité logique d'un Worker, son lifecycle continu, une classification minimale de health/activity, l'intention de stop coopératif et un contrat latest-value fixe pour l'observation. Elle ne possède aucun runtime concret, aucune politique de restart, aucun Job, aucun Transport, aucun Store et aucun contrat Solana.
|
||||
|
||||
## Responsabilités
|
||||
|
||||
La façade crate-root expose :
|
||||
|
||||
- `WorkerId` et `WorkerKindCode`, bornés et validés ;
|
||||
- `WorkerState`, `WorkerHealth` et `WorkerActivity` ;
|
||||
- `WorkerLifecycle`, propriétaire des transitions admises ;
|
||||
- `WorkerStopToken`, cloneable et idempotent ;
|
||||
- `WorkerSnapshotSequence`, strictement monotone et sans wrap silencieux ;
|
||||
- `WorkerSnapshot`, forme commune fixe sans payload métier ;
|
||||
- `WorkerSnapshotSource`, contrat object-safe de lecture courante et attente d'une valeur plus récente ;
|
||||
- les codes d'erreur Worker et les types `Error`/`Result` communs de Core.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Le lifecycle admis reste explicitement borné :
|
||||
|
||||
```text
|
||||
Created -> Starting | Stopped
|
||||
Starting -> Running | Stopping | Faulted(ErrorCode)
|
||||
Running -> Stopping | Faulted(ErrorCode)
|
||||
Stopping -> Stopped | Faulted(ErrorCode)
|
||||
Stopped -> terminal
|
||||
Faulted -> terminal
|
||||
```
|
||||
|
||||
`Stopped` et `Faulted` sont terminaux et immuables. Une transition invalide retourne `ERROR_CODE_WORKER_TRANSITION_INVALID` sans modifier l'état source.
|
||||
|
||||
## Observation latest-value
|
||||
|
||||
`WorkerSnapshotSource` n'impose ni callback, ni queue d'événements, ni runtime async particulier. Un listener lit d'abord `current()`, mémorise la `WorkerSnapshotSequence`, puis appelle `wait_for_change()` s'il doit attendre une valeur plus récente.
|
||||
|
||||
Les mises à jour intermédiaires peuvent être coalescées : le contrat porte sur la dernière valeur complète, pas sur la livraison de chaque événement. Le snapshot commun ne contient que l'identité, la séquence, le lifecycle, la health et l'activity.
|
||||
|
||||
## Stop
|
||||
|
||||
`WorkerStopToken` représente uniquement une intention coopérative partagée. Il ne tue pas une tâche, ne ferme pas un socket et ne décide pas du résultat terminal. Le runtime concret observe cette intention puis pilote `WorkerLifecycle` selon sa politique de shutdown.
|
||||
|
||||
## Restart et contrôle
|
||||
|
||||
La crate ne possède aucun `restart()`, scheduler, retry/backoff, process manager ou handle runtime générique. Un lifecycle/source terminal n'est jamais réanimé ni rebinding vers une nouvelle exécution. La recréation et la supervision appartiennent au caller ou à une couche de contrôle supérieure.
|
||||
|
||||
## Firewall
|
||||
|
||||
La dépendance normale est volontairement minimale :
|
||||
|
||||
```text
|
||||
ksp-worker-api
|
||||
-> ksp-core-lib
|
||||
```
|
||||
|
||||
La crate ne dépend pas de `ksp-job-api`, Tokio, Futures, serde, Logging, Config, Interface, Transport, Store, Tauri ou d'un SDK provider.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`USAGE.md`](USAGE.md) — utilisation durable des contrats Worker ;
|
||||
- [`../../docs/architecture/003-COMPONENT_CONTRACTS.md`](../../docs/architecture/003-COMPONENT_CONTRACTS.md) — contrats de composants ;
|
||||
- [`../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md`](../../docs/architecture/009-ACQUISITION_WORKERS_AND_JOBS.md) — séparation Worker/Job et ownership d'acquisition.
|
||||
141
crates/ksp-worker-api/USAGE.md
Normal file
141
crates/ksp-worker-api/USAGE.md
Normal file
@@ -0,0 +1,141 @@
|
||||
<!-- file: crates/ksp-worker-api/USAGE.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Utilisation de ksp-worker-api
|
||||
|
||||
Cette page décrit la façade publique durable de `ksp-worker-api`. Les consumers utilisent uniquement les exports du crate-root.
|
||||
|
||||
## Construire une identité Worker
|
||||
|
||||
```rust
|
||||
fn worker_identity() -> ksp_worker_api::Result<(ksp_worker_api::WorkerId, ksp_worker_api::WorkerKindCode)> {
|
||||
let id = match ksp_worker_api::WorkerId::new("raw-ingest-mainnet-0001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("raw_transaction_ingest") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok((id, kind));
|
||||
}
|
||||
```
|
||||
|
||||
`WorkerId` identifie une instance logique observée par un lifecycle/source donné. `WorkerKindCode` identifie une famille de Workers. Les deux sont bornés et utilisent un alphabet sûr. Le `Debug` de `WorkerId` masque sa valeur.
|
||||
|
||||
## Piloter un lifecycle passif
|
||||
|
||||
```rust
|
||||
fn start_worker(id: ksp_worker_api::WorkerId, kind: ksp_worker_api::WorkerKindCode) -> ksp_worker_api::Result<ksp_worker_api::WorkerLifecycle> {
|
||||
let mut lifecycle = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
if let std::result::Result::Err(error) = lifecycle.start() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_running() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Ok(lifecycle);
|
||||
}
|
||||
```
|
||||
|
||||
Le producer possède l'autorité de transition. Il ne force jamais un état directement. Une transition invalide retourne une erreur stable et conserve l'état courant.
|
||||
|
||||
Pour un shutdown coopératif après observation du token :
|
||||
|
||||
```rust
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_stopping() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if let std::result::Result::Err(error) = lifecycle.mark_stopped() {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
Pour un fault terminal :
|
||||
|
||||
```rust
|
||||
const IO_FAULT: ksp_worker_api::ErrorCode = ksp_worker_api::ErrorCode::new("example_worker", "io_fault");
|
||||
if let std::result::Result::Err(error) = lifecycle.fault(IO_FAULT) {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
```
|
||||
|
||||
`Stopped` et `Faulted(ErrorCode)` sont terminaux. Un ancien lifecycle terminal ne représente jamais une nouvelle exécution.
|
||||
|
||||
## Distinguer lifecycle, health et activity
|
||||
|
||||
`WorkerState` décrit la phase du service. `WorkerHealth` décrit sa qualité opérationnelle. `WorkerActivity` indique seulement `Unknown`, `Idle` ou `Active`.
|
||||
|
||||
```rust
|
||||
let snapshot = ksp_worker_api::WorkerSnapshot::new(
|
||||
lifecycle.id().clone(),
|
||||
lifecycle.kind().clone(),
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
lifecycle.state(),
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Idle,
|
||||
);
|
||||
```
|
||||
|
||||
Le snapshot commun ne porte ni pourcentage, ni total, ni backlog, ni slot, ni transaction, ni métrique métier. Une API Worker concrète peut exposer séparément ses propres métriques.
|
||||
|
||||
## Partager une intention de stop
|
||||
|
||||
```rust
|
||||
let token = ksp_worker_api::WorkerStopToken::new();
|
||||
let listener = token.clone();
|
||||
|
||||
assert!(!listener.is_stop_requested());
|
||||
assert!(token.request_stop());
|
||||
assert!(listener.is_stop_requested());
|
||||
assert!(!token.request_stop());
|
||||
```
|
||||
|
||||
Le premier appel qui change l'intention retourne `true`. Les demandes suivantes sont idempotentes et retournent `false`.
|
||||
|
||||
Le token n'est pas une primitive de kill et ne garantit aucun délai de shutdown. Timeout, drain, join et retry appartiennent au runtime/caller.
|
||||
|
||||
## Observer un snapshot latest-value
|
||||
|
||||
Un consumer portable peut travailler directement avec le trait object-safe :
|
||||
|
||||
```rust
|
||||
async fn observe(source: &dyn ksp_worker_api::WorkerSnapshotSource) {
|
||||
let current = source.current();
|
||||
let observed = current.sequence();
|
||||
let newer = source.wait_for_change(observed).await;
|
||||
assert!(newer.sequence().is_after(observed));
|
||||
}
|
||||
```
|
||||
|
||||
`wait_for_change` retourne la dernière valeur complète disponible après coalescence éventuelle. Un consumer ne doit pas supposer qu'il recevra chaque mise à jour intermédiaire.
|
||||
|
||||
Un listener tardif commence par `current()`. Tant que la source existe, son snapshot terminal courant reste lisible.
|
||||
|
||||
## Faire avancer une séquence
|
||||
|
||||
```rust
|
||||
let first = ksp_worker_api::WorkerSnapshotSequence::initial();
|
||||
let second = match first.next() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
assert!(second.is_after(first));
|
||||
```
|
||||
|
||||
L'épuisement de `u64` est une erreur explicite ; la séquence ne wrappe jamais silencieusement.
|
||||
|
||||
## Frontières à respecter
|
||||
|
||||
Ne pas ajouter à `ksp-worker-api` :
|
||||
|
||||
```text
|
||||
runtime Tokio/Futures concret
|
||||
Job lifecycle ou checkpoint/backfill
|
||||
Transport, Store, Config ou Logging
|
||||
DTO Solana/provider
|
||||
restart/retry/scheduler/process manager
|
||||
payload métier dans WorkerSnapshot
|
||||
```
|
||||
|
||||
Ces responsabilités appartiennent aux Workers concrets et aux couches de composition/contrôle supérieures.
|
||||
161
crates/ksp-worker-api/tests/release_completeness.rs
Normal file
161
crates/ksp-worker-api/tests/release_completeness.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
// file: crates/ksp-worker-api/tests/release_completeness.rs
|
||||
// version: 1
|
||||
|
||||
//! Exact frozen-surface and domain-firewall canaries for `ksp-worker-api`.
|
||||
|
||||
#[test]
|
||||
fn pre_003_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::error::ERROR_CODE_WORKER_ID_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_KIND_INVALID;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_SNAPSHOT_SEQUENCE_EXHAUSTED;",
|
||||
"pub use self::error::ERROR_CODE_WORKER_TRANSITION_INVALID;",
|
||||
"pub use self::identity::MAX_WORKER_ID_BYTES;",
|
||||
"pub use self::identity::MAX_WORKER_KIND_CODE_BYTES;",
|
||||
"pub use self::identity::WorkerId;",
|
||||
"pub use self::identity::WorkerKindCode;",
|
||||
"pub use self::lifecycle::WorkerActivity;",
|
||||
"pub use self::lifecycle::WorkerHealth;",
|
||||
"pub use self::lifecycle::WorkerLifecycle;",
|
||||
"pub use self::lifecycle::WorkerState;",
|
||||
"pub use self::snapshot::WorkerSnapshot;",
|
||||
"pub use self::snapshot::WorkerSnapshotFuture;",
|
||||
"pub use self::snapshot::WorkerSnapshotSequence;",
|
||||
"pub use self::snapshot::WorkerSnapshotSource;",
|
||||
"pub use self::stop::WorkerStopToken;",
|
||||
"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_003_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!["error.rs", "identity.rs", "lib.rs", "lifecycle.rs", "snapshot.rs", "stop.rs"]);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_fixed_snapshot_shape_contains_only_common_worker_dimensions() {
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
let struct_start = match snapshot.find("pub struct WorkerSnapshot {") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let after_start = &snapshot[struct_start..];
|
||||
let struct_end = match after_start.find("\n}") {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let definition = &after_start[..struct_end];
|
||||
for required in ["id:", "kind:", "sequence:", "state:", "health:", "activity:"] {
|
||||
assert!(definition.contains(required), "missing frozen Worker snapshot field: {required}");
|
||||
}
|
||||
for forbidden in ["String", "Vec<", "payload", "slot", "signature", "provider", "endpoint", "checkpoint", "backfill", "transaction"] {
|
||||
assert!(!definition.contains(forbidden), "domain or arbitrary payload leaked into frozen Worker snapshot: {forbidden}");
|
||||
}
|
||||
let fields = definition.lines().filter(|line| {
|
||||
let line = line.trim_start();
|
||||
return line.starts_with("id:")
|
||||
|| line.starts_with("kind:")
|
||||
|| line.starts_with("sequence:")
|
||||
|| line.starts_with("state:")
|
||||
|| line.starts_with("health:")
|
||||
|| line.starts_with("activity:");
|
||||
});
|
||||
assert_eq!(fields.count(), 6);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_surface_remains_runtime_neutral_job_independent_and_domain_free() {
|
||||
let sources = [
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/identity.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/lifecycle.rs"),
|
||||
include_str!("../src/snapshot.rs"),
|
||||
include_str!("../src/stop.rs"),
|
||||
];
|
||||
for source in sources {
|
||||
for forbidden in [
|
||||
"Backfill",
|
||||
"RawTransaction",
|
||||
"WorkerHandle",
|
||||
"checkpoint",
|
||||
"endpoint",
|
||||
"futures::",
|
||||
"futures_util::",
|
||||
"ksp_config_lib::",
|
||||
"ksp_interface_lib::",
|
||||
"ksp_job_api::",
|
||||
"ksp_logging_lib::",
|
||||
"ksp_offchain_transport_lib::",
|
||||
"ksp_onchain_transport_lib::",
|
||||
"ksp_store_api::",
|
||||
"ksp_store_lib::",
|
||||
"provider",
|
||||
"reqwest::",
|
||||
"serde::",
|
||||
"serde_json::",
|
||||
"slot",
|
||||
"solana_",
|
||||
"tauri::",
|
||||
"tokio::",
|
||||
"tonic::",
|
||||
concat!("tracing", "::"),
|
||||
] {
|
||||
assert!(!source.contains(forbidden), "forbidden frozen Worker API concern detected: {forbidden}");
|
||||
}
|
||||
}
|
||||
let snapshot = include_str!("../src/snapshot.rs");
|
||||
assert!(snapshot.contains("pub trait WorkerSnapshotSource: std::marker::Send + std::marker::Sync"));
|
||||
assert!(snapshot.contains("std::future::Future"));
|
||||
assert!(!snapshot.contains("std::sync::mpsc"));
|
||||
assert!(!snapshot.contains("VecDeque"));
|
||||
let lifecycle = include_str!("../src/lifecycle.rs");
|
||||
assert!(lifecycle.contains("#[derive(Eq, PartialEq)]\npub struct WorkerLifecycle"));
|
||||
assert!(!lifecycle.contains("#[derive(Clone, Eq, PartialEq)]\npub struct WorkerLifecycle"));
|
||||
return;
|
||||
}
|
||||
185
crates/ksp-worker-api/tests/security_hardening.rs
Normal file
185
crates/ksp-worker-api/tests/security_hardening.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
// file: crates/ksp-worker-api/tests/security_hardening.rs
|
||||
// version: 1
|
||||
|
||||
//! Adversarial lifecycle, stop and redaction canaries for the frozen Worker API.
|
||||
|
||||
const HOSTILE_MARKER: &str = "WORKER-IDENTITY-SECRET-CANARY";
|
||||
const TEST_FAULT: ksp_worker_api::ErrorCode = ksp_worker_api::ErrorCode::new("worker_test", "fault");
|
||||
|
||||
fn running_lifecycle(id_value: &str) -> std::option::Option<ksp_worker_api::WorkerLifecycle> {
|
||||
let id = match ksp_worker_api::WorkerId::new(id_value) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("continuous_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let mut lifecycle = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
if lifecycle.start().is_err() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
if lifecycle.mark_running().is_err() {
|
||||
return std::option::Option::None;
|
||||
}
|
||||
return std::option::Option::Some(lifecycle);
|
||||
}
|
||||
|
||||
fn assert_all_mutators_reject(lifecycle: &mut ksp_worker_api::WorkerLifecycle, terminal: ksp_worker_api::WorkerState) {
|
||||
assert!(lifecycle.start().is_err());
|
||||
assert!(lifecycle.mark_running().is_err());
|
||||
assert!(lifecycle.mark_stopping().is_err());
|
||||
assert!(lifecycle.mark_stopped().is_err());
|
||||
assert!(lifecycle.fault(TEST_FAULT).is_err());
|
||||
assert_eq!(lifecycle.state(), terminal);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_worker_identity_lifecycle_and_snapshot_debug_redact_hostile_identity() {
|
||||
let lifecycle = running_lifecycle(HOSTILE_MARKER);
|
||||
assert!(lifecycle.is_some());
|
||||
let lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let id = lifecycle.id();
|
||||
assert_eq!(std::format!("{id:?}"), "WorkerId(..)");
|
||||
let lifecycle_debug = std::format!("{lifecycle:?}");
|
||||
assert!(lifecycle_debug.contains("WorkerLifecycle"));
|
||||
assert!(lifecycle_debug.contains("continuous_worker"));
|
||||
assert!(lifecycle_debug.contains("Running"));
|
||||
assert!(!lifecycle_debug.contains(HOSTILE_MARKER));
|
||||
let snapshot = ksp_worker_api::WorkerSnapshot::new(
|
||||
lifecycle.id().clone(),
|
||||
lifecycle.kind().clone(),
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
lifecycle.state(),
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Active,
|
||||
);
|
||||
let snapshot_debug = std::format!("{snapshot:?}");
|
||||
assert!(snapshot_debug.contains("WorkerSnapshot"));
|
||||
assert!(snapshot_debug.contains("continuous_worker"));
|
||||
assert!(snapshot_debug.contains("Healthy"));
|
||||
assert!(snapshot_debug.contains("Active"));
|
||||
assert!(!snapshot_debug.contains(HOSTILE_MARKER));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_identity_errors_never_echo_hostile_values() {
|
||||
let hostile = "WORKER-SECRET/CANARY";
|
||||
let rejected = ksp_worker_api::WorkerId::new(hostile);
|
||||
assert!(rejected.is_err());
|
||||
let error = match rejected {
|
||||
std::result::Result::Err(value) => value,
|
||||
std::result::Result::Ok(_) => return,
|
||||
};
|
||||
assert_eq!(error.code(), ksp_worker_api::ERROR_CODE_WORKER_ID_INVALID);
|
||||
assert!(!std::format!("{error}").contains(hostile));
|
||||
assert!(!std::format!("{error:?}").contains(hostile));
|
||||
for context in error.context() {
|
||||
assert!(!context.value().contains(hostile));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stopped_and_faulted_are_immutable_under_all_public_mutators() {
|
||||
let id = match ksp_worker_api::WorkerId::new("terminal-created") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("terminal_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let mut stopped = ksp_worker_api::WorkerLifecycle::new(id, kind);
|
||||
assert!(stopped.mark_stopped().is_ok());
|
||||
assert_all_mutators_reject(&mut stopped, ksp_worker_api::WorkerState::Stopped);
|
||||
let faulted = running_lifecycle("terminal-faulted");
|
||||
assert!(faulted.is_some());
|
||||
let mut faulted = match faulted {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(faulted.fault(TEST_FAULT).is_ok());
|
||||
assert_all_mutators_reject(&mut faulted, ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stop_fault_terminal_orders_are_first_valid_terminal_wins() {
|
||||
let direct_fault = running_lifecycle("race-direct-fault");
|
||||
assert!(direct_fault.is_some());
|
||||
let mut direct_fault = match direct_fault {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(direct_fault.fault(TEST_FAULT).is_ok());
|
||||
assert!(direct_fault.mark_stopping().is_err());
|
||||
assert_eq!(direct_fault.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
let stopping_fault = running_lifecycle("race-stopping-fault");
|
||||
assert!(stopping_fault.is_some());
|
||||
let mut stopping_fault = match stopping_fault {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(stopping_fault.mark_stopping().is_ok());
|
||||
assert!(stopping_fault.fault(TEST_FAULT).is_ok());
|
||||
assert!(stopping_fault.mark_stopped().is_err());
|
||||
assert_eq!(stopping_fault.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
let stopping_stopped = running_lifecycle("race-stopping-stopped");
|
||||
assert!(stopping_stopped.is_some());
|
||||
let mut stopping_stopped = match stopping_stopped {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(stopping_stopped.mark_stopping().is_ok());
|
||||
assert!(stopping_stopped.mark_stopped().is_ok());
|
||||
assert!(stopping_stopped.fault(TEST_FAULT).is_err());
|
||||
assert_eq!(stopping_stopped.state(), ksp_worker_api::WorkerState::Stopped);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_stop_token_is_send_sync_cross_thread_and_cannot_reanimate_terminal_lifecycle() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<ksp_worker_api::WorkerStopToken>();
|
||||
let lifecycle = running_lifecycle("cross-thread-stop");
|
||||
assert!(lifecycle.is_some());
|
||||
let mut lifecycle = match lifecycle {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(lifecycle.fault(TEST_FAULT).is_ok());
|
||||
let token = ksp_worker_api::WorkerStopToken::new();
|
||||
let worker_token = token.clone();
|
||||
let thread = std::thread::spawn(move || return worker_token.request_stop());
|
||||
let first = match thread.join() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert!(first);
|
||||
assert!(token.is_stop_requested());
|
||||
assert!(!token.request_stop());
|
||||
assert!(lifecycle.mark_stopping().is_err());
|
||||
assert_eq!(lifecycle.state(), ksp_worker_api::WorkerState::Faulted(TEST_FAULT));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_public_worker_primitives_keep_promised_send_sync_contracts() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<ksp_worker_api::WorkerId>();
|
||||
require_send_sync::<ksp_worker_api::WorkerKindCode>();
|
||||
require_send_sync::<ksp_worker_api::WorkerState>();
|
||||
require_send_sync::<ksp_worker_api::WorkerHealth>();
|
||||
require_send_sync::<ksp_worker_api::WorkerActivity>();
|
||||
require_send_sync::<ksp_worker_api::WorkerLifecycle>();
|
||||
require_send_sync::<ksp_worker_api::WorkerSnapshotSequence>();
|
||||
require_send_sync::<ksp_worker_api::WorkerSnapshot>();
|
||||
require_send_sync::<ksp_worker_api::WorkerStopToken>();
|
||||
return;
|
||||
}
|
||||
219
crates/ksp-worker-api/tests/snapshot_source.rs
Normal file
219
crates/ksp-worker-api/tests/snapshot_source.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
// file: crates/ksp-worker-api/tests/snapshot_source.rs
|
||||
// version: 1
|
||||
|
||||
//! External std-only latest-value source, object-safety and resynchronization canaries.
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestSnapshotSource {
|
||||
state: std::sync::Arc<TestState>,
|
||||
}
|
||||
|
||||
struct TestState {
|
||||
current: std::sync::Mutex<TestInner>,
|
||||
}
|
||||
|
||||
struct TestInner {
|
||||
snapshot: ksp_worker_api::WorkerSnapshot,
|
||||
waiters: std::vec::Vec<std::task::Waker>,
|
||||
}
|
||||
|
||||
impl TestSnapshotSource {
|
||||
fn new(snapshot: ksp_worker_api::WorkerSnapshot) -> Self {
|
||||
return Self {
|
||||
state: std::sync::Arc::new(TestState { current: std::sync::Mutex::new(TestInner { snapshot, waiters: std::vec::Vec::new() }) }),
|
||||
};
|
||||
}
|
||||
|
||||
fn publish(&self, state: ksp_worker_api::WorkerState, health: ksp_worker_api::WorkerHealth, activity: ksp_worker_api::WorkerActivity) -> bool {
|
||||
let mut current = match self.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
let sequence = match current.snapshot.sequence().next() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return false,
|
||||
};
|
||||
let id = current.snapshot.id().clone();
|
||||
let kind = current.snapshot.kind().clone();
|
||||
current.snapshot = ksp_worker_api::WorkerSnapshot::new(id, kind, sequence, state, health, activity);
|
||||
let waiters = std::mem::take(&mut current.waiters);
|
||||
std::mem::drop(current);
|
||||
for waiter in waiters {
|
||||
waiter.wake();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
struct TestWaitFuture<'a> {
|
||||
source: &'a TestSnapshotSource,
|
||||
observed: ksp_worker_api::WorkerSnapshotSequence,
|
||||
}
|
||||
|
||||
impl std::future::Future for TestWaitFuture<'_> {
|
||||
type Output = ksp_worker_api::WorkerSnapshot;
|
||||
|
||||
fn poll(self: std::pin::Pin<&mut Self>, context: &mut std::task::Context<'_>) -> std::task::Poll<Self::Output> {
|
||||
let mut current = match self.source.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if current.snapshot.sequence().is_after(self.observed) {
|
||||
return std::task::Poll::Ready(current.snapshot.clone());
|
||||
}
|
||||
if !current.waiters.iter().any(|registered| return registered.will_wake(context.waker())) {
|
||||
current.waiters.push(context.waker().clone());
|
||||
}
|
||||
return std::task::Poll::Pending;
|
||||
}
|
||||
}
|
||||
|
||||
impl ksp_worker_api::WorkerSnapshotSource for TestSnapshotSource {
|
||||
fn current(&self) -> ksp_worker_api::WorkerSnapshot {
|
||||
let current = match self.state.current.lock() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
return current.snapshot.clone();
|
||||
}
|
||||
|
||||
fn wait_for_change(&self, observed: ksp_worker_api::WorkerSnapshotSequence) -> ksp_worker_api::WorkerSnapshotFuture<'_> {
|
||||
return std::boxed::Box::pin(TestWaitFuture { source: self, observed });
|
||||
}
|
||||
}
|
||||
|
||||
struct WakeProbe {
|
||||
woken: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl WakeProbe {
|
||||
fn new() -> Self {
|
||||
return Self { woken: std::sync::atomic::AtomicBool::new(false) };
|
||||
}
|
||||
|
||||
fn is_woken(&self) -> bool {
|
||||
return self.woken.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::task::Wake for WakeProbe {
|
||||
fn wake(self: std::sync::Arc<Self>) {
|
||||
self.woken.store(true, std::sync::atomic::Ordering::Release);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_snapshot(future: &mut ksp_worker_api::WorkerSnapshotFuture<'_>, wake: &std::sync::Arc<WakeProbe>) -> std::task::Poll<ksp_worker_api::WorkerSnapshot> {
|
||||
let waker = std::task::Waker::from(wake.clone());
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
return std::future::Future::poll(future.as_mut(), &mut context);
|
||||
}
|
||||
|
||||
fn initial_snapshot() -> std::option::Option<ksp_worker_api::WorkerSnapshot> {
|
||||
let id = match ksp_worker_api::WorkerId::new("external-source-001") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let kind = match ksp_worker_api::WorkerKindCode::new("external_test_worker") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return std::option::Option::Some(ksp_worker_api::WorkerSnapshot::new(
|
||||
id,
|
||||
kind,
|
||||
ksp_worker_api::WorkerSnapshotSequence::initial(),
|
||||
ksp_worker_api::WorkerState::Running,
|
||||
ksp_worker_api::WorkerHealth::Healthy,
|
||||
ksp_worker_api::WorkerActivity::Idle,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_snapshot_source_is_object_safe_send_sync_and_externally_implementable() {
|
||||
fn require_send_sync<T: std::marker::Send + std::marker::Sync>() {}
|
||||
require_send_sync::<TestSnapshotSource>();
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let object: &dyn ksp_worker_api::WorkerSnapshotSource = &source;
|
||||
let current = object.current();
|
||||
assert_eq!(current.sequence().value(), 0);
|
||||
let mut wait = object.wait_for_change(current.sequence());
|
||||
fn require_send<T: std::marker::Send>(_: &T) {}
|
||||
require_send(&wait);
|
||||
let wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut wait, &wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(wake.is_woken());
|
||||
let changed = poll_snapshot(&mut wait, &wake);
|
||||
assert!(matches!(changed, std::task::Poll::Ready(_)));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_slow_and_independent_listeners_coalesce_to_latest_value() {
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let observed = ksp_worker_api::WorkerSnapshotSource::current(&source).sequence();
|
||||
let mut slow = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, observed);
|
||||
let mut fast = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, observed);
|
||||
let slow_wake = std::sync::Arc::new(WakeProbe::new());
|
||||
let fast_wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut slow, &slow_wake), std::task::Poll::Pending));
|
||||
assert!(matches!(poll_snapshot(&mut fast, &fast_wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Degraded, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(slow_wake.is_woken());
|
||||
assert!(fast_wake.is_woken());
|
||||
let fast_value = match poll_snapshot(&mut fast, &fast_wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(fast_value.sequence().value(), 1);
|
||||
assert_eq!(fast_value.health(), ksp_worker_api::WorkerHealth::Degraded);
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
let slow_value = match poll_snapshot(&mut slow, &slow_wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(slow_value.sequence().value(), 2);
|
||||
assert_eq!(slow_value.health(), ksp_worker_api::WorkerHealth::Healthy);
|
||||
assert_eq!(slow_value.activity(), ksp_worker_api::WorkerActivity::Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_003_late_listener_resynchronizes_and_terminal_snapshot_remains_current() {
|
||||
let snapshot = initial_snapshot();
|
||||
assert!(snapshot.is_some());
|
||||
let source = match snapshot {
|
||||
std::option::Option::Some(value) => TestSnapshotSource::new(value),
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Running, ksp_worker_api::WorkerHealth::Degraded, ksp_worker_api::WorkerActivity::Active));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Stopping, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
let current = ksp_worker_api::WorkerSnapshotSource::current(&source);
|
||||
assert_eq!(current.sequence().value(), 2);
|
||||
assert_eq!(current.state(), ksp_worker_api::WorkerState::Stopping);
|
||||
let mut wait = ksp_worker_api::WorkerSnapshotSource::wait_for_change(&source, current.sequence());
|
||||
let wake = std::sync::Arc::new(WakeProbe::new());
|
||||
assert!(matches!(poll_snapshot(&mut wait, &wake), std::task::Poll::Pending));
|
||||
assert!(source.publish(ksp_worker_api::WorkerState::Stopped, ksp_worker_api::WorkerHealth::Healthy, ksp_worker_api::WorkerActivity::Idle));
|
||||
assert!(wake.is_woken());
|
||||
let terminal = match poll_snapshot(&mut wait, &wake) {
|
||||
std::task::Poll::Ready(value) => value,
|
||||
std::task::Poll::Pending => return,
|
||||
};
|
||||
assert_eq!(terminal.sequence().value(), 3);
|
||||
assert_eq!(terminal.state(), ksp_worker_api::WorkerState::Stopped);
|
||||
assert!(terminal.state().is_terminal());
|
||||
let retained = ksp_worker_api::WorkerSnapshotSource::current(&source);
|
||||
assert_eq!(retained, terminal);
|
||||
return;
|
||||
}
|
||||
Reference in New Issue
Block a user