v0.3.6-pre.009
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# file: crates/ksp-job-backfill-lib/Cargo.toml
|
||||
# version: 3
|
||||
# version: 4
|
||||
|
||||
[package]
|
||||
name = "ksp-job-backfill-lib"
|
||||
@@ -16,6 +16,7 @@ ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" }
|
||||
ksp-store-lib = { path = "../ksp-store-lib", default-features = false }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/discovery.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Network-scoped identity of one discovered transaction candidate before canonical signature decoding.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -217,7 +217,7 @@ pub async fn discover_backfill_candidates(
|
||||
max_candidates = request.max_candidates(),
|
||||
"starting bounded Backfill candidate discovery"
|
||||
);
|
||||
let result = discover_with_source(transport, request).await;
|
||||
let result = discover_with_source(transport, request, std::option::Option::None).await;
|
||||
if let std::result::Result::Ok(discovery) = &result {
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
@@ -234,14 +234,30 @@ pub async fn discover_backfill_candidates(
|
||||
return result;
|
||||
}
|
||||
|
||||
async fn discover_with_source<S>(source: &S, request: &crate::BackfillRequest) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
/// Discovers one bounded candidate set while allowing cooperative cancellation of in-flight RPC futures.
|
||||
pub(crate) async fn discover_backfill_candidates_cancellable(
|
||||
transport: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
request: &crate::BackfillRequest,
|
||||
cancellation: &crate::BackfillCancellationSignal,
|
||||
) -> ksp_core_lib::Result<BackfillDiscovery> {
|
||||
return discover_with_source(transport, request, std::option::Option::Some(cancellation)).await;
|
||||
}
|
||||
|
||||
async fn discover_with_source<S>(
|
||||
source: &S,
|
||||
request: &crate::BackfillRequest,
|
||||
cancellation: std::option::Option<&crate::BackfillCancellationSignal>,
|
||||
) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
where
|
||||
S: SignaturePageSource,
|
||||
{
|
||||
if cancellation.is_some_and(crate::BackfillCancellationSignal::is_requested) {
|
||||
return std::result::Result::Err(cancelled_error());
|
||||
}
|
||||
return match request.scope().kind() {
|
||||
crate::BackfillScopeKind::ExplicitSignatures => discover_explicit(request),
|
||||
crate::BackfillScopeKind::LatestAddress | crate::BackfillScopeKind::BeforeAddress => discover_older(source, request).await,
|
||||
crate::BackfillScopeKind::AfterAddress => discover_after(source, request).await,
|
||||
crate::BackfillScopeKind::LatestAddress | crate::BackfillScopeKind::BeforeAddress => discover_older(source, request, cancellation).await,
|
||||
crate::BackfillScopeKind::AfterAddress => discover_after(source, request, cancellation).await,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,7 +280,11 @@ fn discover_explicit(request: &crate::BackfillRequest) -> ksp_core_lib::Result<B
|
||||
));
|
||||
}
|
||||
|
||||
async fn discover_older<S>(source: &S, request: &crate::BackfillRequest) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
async fn discover_older<S>(
|
||||
source: &S,
|
||||
request: &crate::BackfillRequest,
|
||||
cancellation: std::option::Option<&crate::BackfillCancellationSignal>,
|
||||
) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
where
|
||||
S: SignaturePageSource,
|
||||
{
|
||||
@@ -298,7 +318,7 @@ where
|
||||
std::option::Option::Some(request.commitment().transport()),
|
||||
request.min_context_slot(),
|
||||
);
|
||||
let page_result = source.fetch_signature_page(request.role(), address, config).await;
|
||||
let page_result = fetch_signature_page(source, request.role(), address, config, cancellation).await;
|
||||
let page = match page_result {
|
||||
std::result::Result::Ok(page) => page,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -347,7 +367,11 @@ where
|
||||
return std::result::Result::Ok(BackfillDiscovery::new(request.network().clone(), request.scope_fingerprint(), candidates, pages_fetched, boundary));
|
||||
}
|
||||
|
||||
async fn discover_after<S>(source: &S, request: &crate::BackfillRequest) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
async fn discover_after<S>(
|
||||
source: &S,
|
||||
request: &crate::BackfillRequest,
|
||||
cancellation: std::option::Option<&crate::BackfillCancellationSignal>,
|
||||
) -> ksp_core_lib::Result<BackfillDiscovery>
|
||||
where
|
||||
S: SignaturePageSource,
|
||||
{
|
||||
@@ -375,7 +399,7 @@ where
|
||||
std::option::Option::Some(request.commitment().transport()),
|
||||
request.min_context_slot(),
|
||||
);
|
||||
let page_result = source.fetch_signature_page(request.role(), address, config).await;
|
||||
let page_result = fetch_signature_page(source, request.role(), address, config, cancellation).await;
|
||||
let page = match page_result {
|
||||
std::result::Result::Ok(page) => page,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
@@ -430,6 +454,27 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
async fn fetch_signature_page<S>(
|
||||
source: &S,
|
||||
role: &ksp_onchain_transport_lib::HttpRoleName,
|
||||
address: &ksp_core_lib::Pubkey,
|
||||
config: ksp_onchain_transport_lib::SolanaSignaturesForAddressConfig,
|
||||
cancellation: std::option::Option<&crate::BackfillCancellationSignal>,
|
||||
) -> ksp_core_lib::Result<std::vec::Vec<SignaturePageEntry>>
|
||||
where
|
||||
S: SignaturePageSource,
|
||||
{
|
||||
let future = source.fetch_signature_page(role, address, config);
|
||||
return match cancellation {
|
||||
std::option::Option::Some(signal) => signal.run_cancellable(future).await,
|
||||
std::option::Option::None => future.await,
|
||||
};
|
||||
}
|
||||
|
||||
fn cancelled_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_CANCELLED, "Backfill discovery cancelled before RPC completion");
|
||||
}
|
||||
|
||||
fn validated_signature(value: &str) -> ksp_core_lib::Result<crate::BackfillSignature> {
|
||||
return crate::BackfillSignature::new(value.to_owned());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/error.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
/// Error code used when one Backfill checkpoint/frontier is incompatible with the current Job or semantic scope.
|
||||
pub const ERROR_CODE_BACKFILL_CHECKPOINT_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "checkpoint_invalid");
|
||||
@@ -15,5 +15,10 @@ pub const ERROR_CODE_BACKFILL_PERSISTENCE_INVALID: ksp_core_lib::ErrorCode = ksp
|
||||
pub const ERROR_CODE_BACKFILL_RAW_CONVERSION_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "raw_conversion_invalid");
|
||||
/// Error code used when one Backfill request violates its bounded admission contract.
|
||||
pub const ERROR_CODE_BACKFILL_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "request_invalid");
|
||||
/// Error code used when the concrete Backfill runtime reaches an impossible lifecycle or notification state.
|
||||
pub const ERROR_CODE_BACKFILL_RUNTIME_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "runtime_invalid");
|
||||
/// Error code used when one transaction signature text violates the bounded Base58-shape contract.
|
||||
pub const ERROR_CODE_BACKFILL_SIGNATURE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "signature_invalid");
|
||||
|
||||
/// Error code used internally when cooperative cancellation wins before durable Store submission.
|
||||
pub(crate) const ERROR_CODE_BACKFILL_CANCELLED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("job_backfill", "cancelled");
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/execution.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
|
||||
/// Bounded result of one concurrent Backfill candidate execution pass.
|
||||
///
|
||||
/// This is intentionally smaller than the concrete latest-value snapshot planned for `pre.009`.
|
||||
/// It exposes only the admission/frontier/checkpoint facts required to prove safe resumption.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BackfillExecutionBatch {
|
||||
candidate_count: usize,
|
||||
admitted_count: usize,
|
||||
finished_count: usize,
|
||||
durable_count: usize,
|
||||
inserted_count: usize,
|
||||
already_present_count: usize,
|
||||
purged_count: usize,
|
||||
missing_count: usize,
|
||||
conflict_count: usize,
|
||||
observation_inserted_count: usize,
|
||||
observation_already_present_count: usize,
|
||||
cancelled_count: usize,
|
||||
hole_count: usize,
|
||||
maximum_in_flight: usize,
|
||||
local_contiguous_completed: usize,
|
||||
discovery_partial: bool,
|
||||
cancelled: bool,
|
||||
checkpoint: crate::BackfillCheckpoint,
|
||||
failure_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
}
|
||||
@@ -46,6 +52,54 @@ impl BackfillExecutionBatch {
|
||||
return self.durable_count;
|
||||
}
|
||||
|
||||
/// Returns the number of newly inserted canonical RAW entities.
|
||||
#[must_use]
|
||||
pub const fn inserted_count(&self) -> usize {
|
||||
return self.inserted_count;
|
||||
}
|
||||
|
||||
/// Returns the number of canonical RAW entities already durable.
|
||||
#[must_use]
|
||||
pub const fn already_present_count(&self) -> usize {
|
||||
return self.already_present_count;
|
||||
}
|
||||
|
||||
/// Returns the number of purge tombstones respected by normal persistence.
|
||||
#[must_use]
|
||||
pub const fn purged_count(&self) -> usize {
|
||||
return self.purged_count;
|
||||
}
|
||||
|
||||
/// Returns the number of candidates whose `getTransaction` result was missing.
|
||||
#[must_use]
|
||||
pub const fn missing_count(&self) -> usize {
|
||||
return self.missing_count;
|
||||
}
|
||||
|
||||
/// Returns the number of Store content conflicts.
|
||||
#[must_use]
|
||||
pub const fn conflict_count(&self) -> usize {
|
||||
return self.conflict_count;
|
||||
}
|
||||
|
||||
/// Returns the number of newly inserted acquisition observations.
|
||||
#[must_use]
|
||||
pub const fn observation_inserted_count(&self) -> usize {
|
||||
return self.observation_inserted_count;
|
||||
}
|
||||
|
||||
/// Returns the number of acquisition observations already durable.
|
||||
#[must_use]
|
||||
pub const fn observation_already_present_count(&self) -> usize {
|
||||
return self.observation_already_present_count;
|
||||
}
|
||||
|
||||
/// Returns the number of admitted candidates cancelled before Store submission.
|
||||
#[must_use]
|
||||
pub const fn cancelled_count(&self) -> usize {
|
||||
return self.cancelled_count;
|
||||
}
|
||||
|
||||
/// Returns the number of known candidate outcomes that intentionally block the contiguous frontier.
|
||||
#[must_use]
|
||||
pub const fn hole_count(&self) -> usize {
|
||||
@@ -76,20 +130,114 @@ impl BackfillExecutionBatch {
|
||||
return self.failure_code;
|
||||
}
|
||||
|
||||
/// Returns whether cooperative cancellation stopped admissions or cancelled an admitted pre-Store operation.
|
||||
#[must_use]
|
||||
pub const fn was_cancelled(&self) -> bool {
|
||||
return self.cancelled;
|
||||
}
|
||||
|
||||
/// Returns whether discovery or candidate outcomes left the pass incomplete.
|
||||
#[must_use]
|
||||
pub const fn is_partial(&self) -> bool {
|
||||
return self.discovery_partial || self.hole_count != 0 || self.failure_code.is_some();
|
||||
return self.discovery_partial || self.hole_count != 0 || self.failure_code.is_some() || self.cancelled;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal latest-value execution facts published after each known candidate completion.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct BackfillExecutionProgress {
|
||||
admitted_count: usize,
|
||||
finished_count: usize,
|
||||
inserted_count: usize,
|
||||
already_present_count: usize,
|
||||
purged_count: usize,
|
||||
missing_count: usize,
|
||||
conflict_count: usize,
|
||||
observation_inserted_count: usize,
|
||||
observation_already_present_count: usize,
|
||||
cancelled_count: usize,
|
||||
hole_count: usize,
|
||||
maximum_in_flight: usize,
|
||||
contiguous_completed: usize,
|
||||
checkpoint: crate::BackfillCheckpoint,
|
||||
}
|
||||
|
||||
impl BackfillExecutionProgress {
|
||||
/// Returns the cumulative admission count.
|
||||
pub(crate) const fn admitted_count(&self) -> usize {
|
||||
return self.admitted_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative finished count.
|
||||
pub(crate) const fn finished_count(&self) -> usize {
|
||||
return self.finished_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative inserted entity count.
|
||||
pub(crate) const fn inserted_count(&self) -> usize {
|
||||
return self.inserted_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative already-present entity count.
|
||||
pub(crate) const fn already_present_count(&self) -> usize {
|
||||
return self.already_present_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative purged entity count.
|
||||
pub(crate) const fn purged_count(&self) -> usize {
|
||||
return self.purged_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative missing count.
|
||||
pub(crate) const fn missing_count(&self) -> usize {
|
||||
return self.missing_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative conflict count.
|
||||
pub(crate) const fn conflict_count(&self) -> usize {
|
||||
return self.conflict_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative inserted observation count.
|
||||
pub(crate) const fn observation_inserted_count(&self) -> usize {
|
||||
return self.observation_inserted_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative already-present observation count.
|
||||
pub(crate) const fn observation_already_present_count(&self) -> usize {
|
||||
return self.observation_already_present_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative cancelled candidate count.
|
||||
pub(crate) const fn cancelled_count(&self) -> usize {
|
||||
return self.cancelled_count;
|
||||
}
|
||||
|
||||
/// Returns the cumulative hole count.
|
||||
pub(crate) const fn hole_count(&self) -> usize {
|
||||
return self.hole_count;
|
||||
}
|
||||
|
||||
/// Returns the maximum observed in-flight count.
|
||||
pub(crate) const fn maximum_in_flight(&self) -> usize {
|
||||
return self.maximum_in_flight;
|
||||
}
|
||||
|
||||
/// Returns the cumulative safe contiguous prefix represented by the progress checkpoint.
|
||||
pub(crate) const fn contiguous_completed(&self) -> usize {
|
||||
return self.contiguous_completed;
|
||||
}
|
||||
|
||||
/// Returns the safe checkpoint proven at this progress position.
|
||||
pub(crate) const fn checkpoint(&self) -> &crate::BackfillCheckpoint {
|
||||
return &self.checkpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes one bounded discovered candidate set with request-owned hydration concurrency.
|
||||
///
|
||||
/// At most `request.hydration_concurrency()` candidate futures are kept in flight. Candidate
|
||||
/// completions may arrive out of order, but the checkpoint advances only across the contiguous
|
||||
/// durable prefix. `Missing` remains a non-fatal hole. Conflict or any Transport/conversion/Store
|
||||
/// error stops new admissions immediately; work already admitted is drained before this function
|
||||
/// returns so a submitted Store operation is never silently abandoned by the batch coordinator.
|
||||
/// This compatibility entry point has no external cancellation handle. Transport owns retries and
|
||||
/// endpoint selection; Store submissions are always awaited to completion.
|
||||
pub async fn execute_backfill_discovery(
|
||||
transport: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
store: &ksp_store_lib::Store,
|
||||
@@ -100,8 +248,21 @@ pub async fn execute_backfill_discovery(
|
||||
if let std::result::Result::Err(error) = validation {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let processor = RuntimeCandidateProcessor { transport, store, request };
|
||||
return execute_with_processor(&processor, request, discovery).await;
|
||||
let processor = RuntimeCandidateProcessor { transport, store, request, cancellation: std::option::Option::None };
|
||||
return execute_with_processor(&processor, request, discovery, std::option::Option::None, std::option::Option::None).await;
|
||||
}
|
||||
|
||||
/// Executes one bounded discovery with cooperative cancellation and concrete progress publication.
|
||||
pub(crate) async fn execute_backfill_discovery_cancellable(
|
||||
transport: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
store: &ksp_store_lib::Store,
|
||||
request: &crate::BackfillRequest,
|
||||
discovery: &crate::BackfillDiscovery,
|
||||
cancellation: &crate::BackfillCancellationSignal,
|
||||
publisher: &crate::BackfillRuntimePublisher,
|
||||
) -> ksp_core_lib::Result<BackfillExecutionBatch> {
|
||||
let processor = RuntimeCandidateProcessor { transport, store, request, cancellation: std::option::Option::Some(cancellation) };
|
||||
return execute_with_processor(&processor, request, discovery, std::option::Option::Some(cancellation), std::option::Option::Some(publisher)).await;
|
||||
}
|
||||
|
||||
type CandidateProcessFuture<'a> =
|
||||
@@ -115,6 +276,7 @@ struct RuntimeCandidateProcessor<'a> {
|
||||
transport: &'a ksp_onchain_transport_lib::HttpTransportPool,
|
||||
store: &'a ksp_store_lib::Store,
|
||||
request: &'a crate::BackfillRequest,
|
||||
cancellation: std::option::Option<&'a crate::BackfillCancellationSignal>,
|
||||
}
|
||||
|
||||
impl CandidateProcessor for RuntimeCandidateProcessor<'_> {
|
||||
@@ -124,20 +286,31 @@ impl CandidateProcessor for RuntimeCandidateProcessor<'_> {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let hydration = crate::hydrate_backfill_candidate(self.transport, self.request, candidate, received_at).await;
|
||||
let hydration_future = crate::hydrate_backfill_candidate(self.transport, self.request, candidate, received_at);
|
||||
let hydration = match self.cancellation {
|
||||
std::option::Option::Some(signal) => signal.run_cancellable(hydration_future).await,
|
||||
std::option::Option::None => hydration_future.await,
|
||||
};
|
||||
let hydration = match hydration {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
if self.cancellation.is_some_and(crate::BackfillCancellationSignal::is_requested) {
|
||||
return std::result::Result::Err(cancelled_error());
|
||||
}
|
||||
// Deliberately not cancellable once Store submission begins: a submitted durable write is drained.
|
||||
return crate::persist_backfill_hydration(self.store, hydration).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn execute_with_processor<P>(
|
||||
processor: &P,
|
||||
request: &crate::BackfillRequest,
|
||||
discovery: &crate::BackfillDiscovery,
|
||||
cancellation: std::option::Option<&crate::BackfillCancellationSignal>,
|
||||
publisher: std::option::Option<&crate::BackfillRuntimePublisher>,
|
||||
) -> ksp_core_lib::Result<BackfillExecutionBatch>
|
||||
where
|
||||
P: CandidateProcessor,
|
||||
@@ -161,12 +334,53 @@ where
|
||||
let mut admitted_count = 0_usize;
|
||||
let mut finished_count = 0_usize;
|
||||
let mut durable_count = 0_usize;
|
||||
let mut inserted_count = 0_usize;
|
||||
let mut already_present_count = 0_usize;
|
||||
let mut purged_count = 0_usize;
|
||||
let mut missing_count = 0_usize;
|
||||
let mut conflict_count = 0_usize;
|
||||
let mut observation_inserted_count = 0_usize;
|
||||
let mut observation_already_present_count = 0_usize;
|
||||
let mut cancelled_count = 0_usize;
|
||||
let mut hole_count = 0_usize;
|
||||
let mut maximum_in_flight = 0_usize;
|
||||
let mut failure_code = std::option::Option::<ksp_core_lib::ErrorCode>::None;
|
||||
let mut cancellation_requested = false;
|
||||
let mut in_flight = futures_util::stream::FuturesUnordered::new();
|
||||
loop {
|
||||
while failure_code.is_none() && next_index < discovery.candidates().len() && in_flight.len() < request.hydration_concurrency() {
|
||||
if !cancellation_requested && cancellation.is_some_and(crate::BackfillCancellationSignal::is_requested) {
|
||||
cancellation_requested = true;
|
||||
let progress = progress_from_state(
|
||||
request,
|
||||
discovery,
|
||||
&frontier,
|
||||
admitted_count,
|
||||
finished_count,
|
||||
inserted_count,
|
||||
already_present_count,
|
||||
purged_count,
|
||||
missing_count,
|
||||
conflict_count,
|
||||
observation_inserted_count,
|
||||
observation_already_present_count,
|
||||
cancelled_count,
|
||||
hole_count,
|
||||
maximum_in_flight,
|
||||
);
|
||||
let progress = match progress {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let published = publish_progress(publisher, &progress, true, true);
|
||||
if let std::result::Result::Err(error) = published {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
while failure_code.is_none()
|
||||
&& !cancellation_requested
|
||||
&& next_index < discovery.candidates().len()
|
||||
&& in_flight.len() < request.hydration_concurrency()
|
||||
{
|
||||
let index = next_index;
|
||||
let candidate = &discovery.candidates()[index];
|
||||
let future = processor.process(candidate);
|
||||
@@ -191,6 +405,19 @@ where
|
||||
};
|
||||
match result {
|
||||
std::result::Result::Ok(outcome) => {
|
||||
let classified = classify_persistence(
|
||||
&outcome,
|
||||
&mut inserted_count,
|
||||
&mut already_present_count,
|
||||
&mut purged_count,
|
||||
&mut missing_count,
|
||||
&mut conflict_count,
|
||||
&mut observation_inserted_count,
|
||||
&mut observation_already_present_count,
|
||||
);
|
||||
if let std::result::Result::Err(error) = classified {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
if persistence_advances_frontier(&outcome) {
|
||||
let marked = frontier.mark_durable(index);
|
||||
if let std::result::Result::Err(error) = marked {
|
||||
@@ -215,11 +442,43 @@ where
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(increment_error) => return std::result::Result::Err(increment_error),
|
||||
};
|
||||
if failure_code.is_none() {
|
||||
if error.code() == crate::ERROR_CODE_BACKFILL_CANCELLED {
|
||||
cancelled_count = match checked_increment(cancelled_count, "cancelled_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(increment_error) => return std::result::Result::Err(increment_error),
|
||||
};
|
||||
cancellation_requested = true;
|
||||
} else if failure_code.is_none() {
|
||||
failure_code = std::option::Option::Some(error.code());
|
||||
}
|
||||
},
|
||||
}
|
||||
let progress = progress_from_state(
|
||||
request,
|
||||
discovery,
|
||||
&frontier,
|
||||
admitted_count,
|
||||
finished_count,
|
||||
inserted_count,
|
||||
already_present_count,
|
||||
purged_count,
|
||||
missing_count,
|
||||
conflict_count,
|
||||
observation_inserted_count,
|
||||
observation_already_present_count,
|
||||
cancelled_count,
|
||||
hole_count,
|
||||
maximum_in_flight,
|
||||
);
|
||||
let progress = match progress {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let draining = cancellation_requested || failure_code.is_some();
|
||||
let published = publish_progress(publisher, &progress, cancellation_requested, draining);
|
||||
if let std::result::Result::Err(error) = published {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
let checkpoint = match crate::checkpoint_from_frontier(request, discovery, &frontier) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -230,15 +489,140 @@ where
|
||||
admitted_count,
|
||||
finished_count,
|
||||
durable_count,
|
||||
inserted_count,
|
||||
already_present_count,
|
||||
purged_count,
|
||||
missing_count,
|
||||
conflict_count,
|
||||
observation_inserted_count,
|
||||
observation_already_present_count,
|
||||
cancelled_count,
|
||||
hole_count,
|
||||
maximum_in_flight,
|
||||
local_contiguous_completed: frontier.contiguous_completed(),
|
||||
discovery_partial: discovery.is_partial(),
|
||||
cancelled: cancellation_requested,
|
||||
checkpoint,
|
||||
failure_code,
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn progress_from_state(
|
||||
request: &crate::BackfillRequest,
|
||||
discovery: &crate::BackfillDiscovery,
|
||||
frontier: &crate::CompletionFrontier,
|
||||
admitted_count: usize,
|
||||
finished_count: usize,
|
||||
inserted_count: usize,
|
||||
already_present_count: usize,
|
||||
purged_count: usize,
|
||||
missing_count: usize,
|
||||
conflict_count: usize,
|
||||
observation_inserted_count: usize,
|
||||
observation_already_present_count: usize,
|
||||
cancelled_count: usize,
|
||||
hole_count: usize,
|
||||
maximum_in_flight: usize,
|
||||
) -> ksp_core_lib::Result<BackfillExecutionProgress> {
|
||||
let checkpoint = match crate::checkpoint_from_frontier(request, discovery, frontier) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(BackfillExecutionProgress {
|
||||
admitted_count,
|
||||
finished_count,
|
||||
inserted_count,
|
||||
already_present_count,
|
||||
purged_count,
|
||||
missing_count,
|
||||
conflict_count,
|
||||
observation_inserted_count,
|
||||
observation_already_present_count,
|
||||
cancelled_count,
|
||||
hole_count,
|
||||
maximum_in_flight,
|
||||
contiguous_completed: checkpoint.completed_prefix(),
|
||||
checkpoint,
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_progress(
|
||||
publisher: std::option::Option<&crate::BackfillRuntimePublisher>,
|
||||
progress: &BackfillExecutionProgress,
|
||||
cancelling: bool,
|
||||
draining: bool,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
if let std::option::Option::Some(publisher) = publisher {
|
||||
let published = publisher.publish_execution_progress(progress, cancelling, draining);
|
||||
if let std::result::Result::Err(error) = published {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn classify_persistence(
|
||||
outcome: &crate::BackfillPersistenceOutcome,
|
||||
inserted_count: &mut usize,
|
||||
already_present_count: &mut usize,
|
||||
purged_count: &mut usize,
|
||||
missing_count: &mut usize,
|
||||
conflict_count: &mut usize,
|
||||
observation_inserted_count: &mut usize,
|
||||
observation_already_present_count: &mut usize,
|
||||
) -> ksp_core_lib::Result<()> {
|
||||
match outcome.entity() {
|
||||
crate::BackfillEntityPersistence::Inserted => {
|
||||
*inserted_count = match checked_increment(*inserted_count, "inserted_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillEntityPersistence::AlreadyPresent => {
|
||||
*already_present_count = match checked_increment(*already_present_count, "already_present_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillEntityPersistence::SkippedPurged => {
|
||||
*purged_count = match checked_increment(*purged_count, "purged_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillEntityPersistence::Missing => {
|
||||
*missing_count = match checked_increment(*missing_count, "missing_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillEntityPersistence::Conflict => {
|
||||
*conflict_count = match checked_increment(*conflict_count, "conflict_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
}
|
||||
match outcome.observation() {
|
||||
crate::BackfillObservationPersistence::Inserted => {
|
||||
*observation_inserted_count = match checked_increment(*observation_inserted_count, "observation_inserted_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillObservationPersistence::AlreadyPresent => {
|
||||
*observation_already_present_count = match checked_increment(*observation_already_present_count, "observation_already_present_count") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
},
|
||||
crate::BackfillObservationPersistence::NotRecorded | crate::BackfillObservationPersistence::NotApplicable => {},
|
||||
}
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn persistence_advances_frontier(outcome: &crate::BackfillPersistenceOutcome) -> bool {
|
||||
return matches!(
|
||||
outcome.entity(),
|
||||
@@ -263,6 +647,10 @@ fn current_raw_timestamp() -> ksp_core_lib::Result<ksp_store_lib::RawTimestamp>
|
||||
return ksp_store_lib::RawTimestamp::from_unix_millis(millis);
|
||||
}
|
||||
|
||||
fn cancelled_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_CANCELLED, "Backfill candidate cancelled before durable Store submission");
|
||||
}
|
||||
|
||||
fn execution_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_EXECUTION_INVALID, "invalid bounded Backfill execution state").with_context("field", field);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/lib.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
#![warn(missing_docs)]
|
||||
#![deny(unreachable_pub)]
|
||||
@@ -11,8 +11,8 @@
|
||||
//! `getSignaturesForAddress` pagination and canonical RAW v1 conversion through observed
|
||||
//! `getTransaction`. Transport retains provider/endpoint selection and retry; Store retains
|
||||
//! durable idempotence through the atomic Store facade. This tranche also owns bounded concurrent
|
||||
//! candidate execution and caller-owned contiguous checkpoints. Cancellation and concrete latest-value
|
||||
//! snapshots are added by later v0.3.6 tranches.
|
||||
//! candidate execution, caller-owned contiguous checkpoints, cooperative cancellation and concrete
|
||||
//! latest-value snapshots for external listeners.
|
||||
|
||||
mod checkpoint;
|
||||
mod constants;
|
||||
@@ -22,6 +22,7 @@ mod error;
|
||||
mod execution;
|
||||
mod persistence;
|
||||
mod request;
|
||||
mod runtime;
|
||||
|
||||
/// Opaque caller-owned checkpoint for one controlled Backfill resumption.
|
||||
pub use self::checkpoint::BackfillCheckpoint;
|
||||
@@ -59,6 +60,8 @@ pub use self::error::ERROR_CODE_BACKFILL_PERSISTENCE_INVALID;
|
||||
pub use self::error::ERROR_CODE_BACKFILL_RAW_CONVERSION_INVALID;
|
||||
/// Error code used when one Backfill request violates its bounded admission contract.
|
||||
pub use self::error::ERROR_CODE_BACKFILL_REQUEST_INVALID;
|
||||
/// Error code used when the concrete Backfill runtime reaches an impossible lifecycle or notification state.
|
||||
pub use self::error::ERROR_CODE_BACKFILL_RUNTIME_INVALID;
|
||||
/// Error code used when one transaction signature text violates the bounded Base58-shape contract.
|
||||
pub use self::error::ERROR_CODE_BACKFILL_SIGNATURE_INVALID;
|
||||
/// Bounded result of one concurrent Backfill candidate execution pass.
|
||||
@@ -97,6 +100,18 @@ pub use self::request::MAX_BACKFILL_PAGES;
|
||||
pub use self::request::MAX_BACKFILL_SIGNATURE_TEXT_BYTES;
|
||||
/// Minimum Base58 text length possible for one canonical 64-byte Solana signature.
|
||||
pub use self::request::MIN_BACKFILL_SIGNATURE_TEXT_BYTES;
|
||||
/// Stable Job kind code used by the concrete historical RAW transaction Backfill runtime.
|
||||
pub use self::runtime::BACKFILL_JOB_KIND_CODE;
|
||||
/// Cloneable external control handle for one concrete Backfill runtime.
|
||||
pub use self::runtime::BackfillJobHandle;
|
||||
/// Current concrete phase of one historical RAW transaction Backfill Job.
|
||||
pub use self::runtime::BackfillJobPhase;
|
||||
/// Concrete single-run Backfill coordinator paired with a cloneable control/snapshot handle.
|
||||
pub use self::runtime::BackfillJobRuntime;
|
||||
/// Complete safe latest-value snapshot of one concrete historical RAW transaction Backfill Job.
|
||||
pub use self::runtime::BackfillJobSnapshot;
|
||||
/// Cloneable runtime-neutral-facing latest-value source for concrete Backfill snapshots.
|
||||
pub use self::runtime::BackfillSnapshotSource;
|
||||
|
||||
/// Internal contiguous completion frontier used by bounded execution.
|
||||
pub(crate) use self::checkpoint::CompletionFrontier;
|
||||
@@ -114,3 +129,19 @@ pub(crate) use self::checkpoint::validate_request_checkpoint;
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
/// Exact private Base58 decoder shared by the public signature wrapper and hydration path.
|
||||
pub(crate) use self::conversion::decode_backfill_signature;
|
||||
/// Internal cancellable discovery path used by the concrete runtime.
|
||||
pub(crate) use self::discovery::discover_backfill_candidates_cancellable;
|
||||
/// Internal cooperative cancellation code used to distinguish cancellation from failure.
|
||||
pub(crate) use self::error::ERROR_CODE_BACKFILL_CANCELLED;
|
||||
/// Internal execution progress facts fed into the concrete latest-value source.
|
||||
pub(crate) use self::execution::BackfillExecutionProgress;
|
||||
/// Internal cancellable execution path used by the concrete runtime.
|
||||
pub(crate) use self::execution::execute_backfill_discovery_cancellable;
|
||||
/// Internal concrete cancellation signal shared by discovery and execution.
|
||||
pub(crate) use self::runtime::BackfillCancellationSignal;
|
||||
/// Internal atomic terminal/cancellation arbitration shared by runtime tests and coordinator.
|
||||
pub(crate) use self::runtime::BackfillRuntimeControl;
|
||||
/// Internal execution progress publisher feeding the latest-value snapshot source.
|
||||
pub(crate) use self::runtime::BackfillRuntimePublisher;
|
||||
/// Internal terminal race result used by coordinator canaries.
|
||||
pub(crate) use self::runtime::TerminalClaim;
|
||||
|
||||
715
crates/ksp-job-backfill-lib/src/runtime.rs
Normal file
715
crates/ksp-job-backfill-lib/src/runtime.rs
Normal file
@@ -0,0 +1,715 @@
|
||||
// file: crates/ksp-job-backfill-lib/src/runtime.rs
|
||||
// version: 2
|
||||
|
||||
/// Stable Job kind code used by the concrete historical RAW transaction Backfill runtime.
|
||||
pub const BACKFILL_JOB_KIND_CODE: &str = "solana.raw_transaction.backfill";
|
||||
|
||||
const CONTROL_ACTIVE: u8 = 0;
|
||||
const CONTROL_CANCELLATION_REQUESTED: u8 = 1;
|
||||
const CONTROL_CANCELLED: u8 = 3;
|
||||
const CONTROL_COMPLETED: u8 = 2;
|
||||
const CONTROL_FAILED: u8 = 4;
|
||||
|
||||
/// Current concrete phase of one historical RAW transaction Backfill Job.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum BackfillJobPhase {
|
||||
/// Runtime was created but execution has not started.
|
||||
Created,
|
||||
/// Candidate discovery is in progress.
|
||||
Discovering,
|
||||
/// Candidate hydration and persistence are being admitted and completed.
|
||||
Executing,
|
||||
/// Cancellation or a fatal result stopped admission while already submitted work is draining.
|
||||
Draining,
|
||||
/// No more work can be admitted and the Job is terminal.
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl BackfillJobPhase {
|
||||
/// Returns the stable safe code for this concrete runtime phase.
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Created => "created",
|
||||
Self::Discovering => "discovering",
|
||||
Self::Executing => "executing",
|
||||
Self::Draining => "draining",
|
||||
Self::Finished => "finished",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete safe latest-value snapshot of one concrete historical RAW transaction Backfill Job.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BackfillJobSnapshot {
|
||||
phase: BackfillJobPhase,
|
||||
scope_kind: crate::BackfillScopeKind,
|
||||
discovery_boundary: std::option::Option<crate::BackfillDiscoveryBoundary>,
|
||||
candidates_selected: usize,
|
||||
candidates_admitted: usize,
|
||||
candidates_finished: usize,
|
||||
entities_inserted: usize,
|
||||
entities_existing: usize,
|
||||
entities_purged: usize,
|
||||
missing: usize,
|
||||
conflicts: usize,
|
||||
observations_inserted: usize,
|
||||
observations_existing: usize,
|
||||
cancelled_candidates: usize,
|
||||
holes: usize,
|
||||
maximum_in_flight: usize,
|
||||
contiguous_completed: usize,
|
||||
checkpoint: std::option::Option<crate::BackfillCheckpoint>,
|
||||
failure_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
}
|
||||
|
||||
impl BackfillJobSnapshot {
|
||||
fn initial(request: &crate::BackfillRequest) -> Self {
|
||||
return Self {
|
||||
phase: BackfillJobPhase::Created,
|
||||
scope_kind: request.scope().kind(),
|
||||
discovery_boundary: std::option::Option::None,
|
||||
candidates_selected: 0,
|
||||
candidates_admitted: 0,
|
||||
candidates_finished: 0,
|
||||
entities_inserted: 0,
|
||||
entities_existing: 0,
|
||||
entities_purged: 0,
|
||||
missing: 0,
|
||||
conflicts: 0,
|
||||
observations_inserted: 0,
|
||||
observations_existing: 0,
|
||||
cancelled_candidates: 0,
|
||||
holes: 0,
|
||||
maximum_in_flight: 0,
|
||||
contiguous_completed: request.checkpoint().map_or(0, crate::BackfillCheckpoint::completed_prefix),
|
||||
checkpoint: request.checkpoint().cloned(),
|
||||
failure_code: std::option::Option::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the concrete execution phase represented by this snapshot.
|
||||
#[must_use]
|
||||
pub const fn phase(&self) -> BackfillJobPhase {
|
||||
return self.phase;
|
||||
}
|
||||
|
||||
/// Returns the safe scope category without rendering address or signature payloads.
|
||||
#[must_use]
|
||||
pub const fn scope_kind(&self) -> crate::BackfillScopeKind {
|
||||
return self.scope_kind;
|
||||
}
|
||||
|
||||
/// Returns the discovery boundary once candidate selection has completed.
|
||||
#[must_use]
|
||||
pub const fn discovery_boundary(&self) -> std::option::Option<crate::BackfillDiscoveryBoundary> {
|
||||
return self.discovery_boundary;
|
||||
}
|
||||
|
||||
/// Returns the bounded candidate count selected by discovery.
|
||||
#[must_use]
|
||||
pub const fn candidates_selected(&self) -> usize {
|
||||
return self.candidates_selected;
|
||||
}
|
||||
|
||||
/// Returns the number of candidates admitted into hydration.
|
||||
#[must_use]
|
||||
pub const fn candidates_admitted(&self) -> usize {
|
||||
return self.candidates_admitted;
|
||||
}
|
||||
|
||||
/// Returns the number of admitted candidates that reached a known coordinator result.
|
||||
#[must_use]
|
||||
pub const fn candidates_finished(&self) -> usize {
|
||||
return self.candidates_finished;
|
||||
}
|
||||
|
||||
/// Returns the number of newly inserted canonical RAW transactions.
|
||||
#[must_use]
|
||||
pub const fn entities_inserted(&self) -> usize {
|
||||
return self.entities_inserted;
|
||||
}
|
||||
|
||||
/// Returns the number of identical canonical RAW transactions already present.
|
||||
#[must_use]
|
||||
pub const fn entities_existing(&self) -> usize {
|
||||
return self.entities_existing;
|
||||
}
|
||||
|
||||
/// Returns the number of durable purge tombstones respected by normal Backfill persistence.
|
||||
#[must_use]
|
||||
pub const fn entities_purged(&self) -> usize {
|
||||
return self.entities_purged;
|
||||
}
|
||||
|
||||
/// Returns the number of `getTransaction = null` candidates.
|
||||
#[must_use]
|
||||
pub const fn missing(&self) -> usize {
|
||||
return self.missing;
|
||||
}
|
||||
|
||||
/// Returns the number of Store content conflicts observed by this run.
|
||||
#[must_use]
|
||||
pub const fn conflicts(&self) -> usize {
|
||||
return self.conflicts;
|
||||
}
|
||||
|
||||
/// Returns the number of newly inserted acquisition observations.
|
||||
#[must_use]
|
||||
pub const fn observations_inserted(&self) -> usize {
|
||||
return self.observations_inserted;
|
||||
}
|
||||
|
||||
/// Returns the number of acquisition observations already durable.
|
||||
#[must_use]
|
||||
pub const fn observations_existing(&self) -> usize {
|
||||
return self.observations_existing;
|
||||
}
|
||||
|
||||
/// Returns the number of admitted candidates cancelled before Store submission.
|
||||
#[must_use]
|
||||
pub const fn cancelled_candidates(&self) -> usize {
|
||||
return self.cancelled_candidates;
|
||||
}
|
||||
|
||||
/// Returns the number of known candidate outcomes blocking the contiguous frontier.
|
||||
#[must_use]
|
||||
pub const fn holes(&self) -> usize {
|
||||
return self.holes;
|
||||
}
|
||||
|
||||
/// Returns the greatest observed concurrent candidate count.
|
||||
#[must_use]
|
||||
pub const fn maximum_in_flight(&self) -> usize {
|
||||
return self.maximum_in_flight;
|
||||
}
|
||||
|
||||
/// Returns the cumulative safe contiguous completion prefix represented by the current checkpoint.
|
||||
#[must_use]
|
||||
pub const fn contiguous_completed(&self) -> usize {
|
||||
return self.contiguous_completed;
|
||||
}
|
||||
|
||||
/// Returns the latest caller-owned safe checkpoint, when one exists.
|
||||
#[must_use]
|
||||
pub const fn checkpoint(&self) -> std::option::Option<&crate::BackfillCheckpoint> {
|
||||
return self.checkpoint.as_ref();
|
||||
}
|
||||
|
||||
/// Returns the stable fatal error code retained by a failed Job, when one exists.
|
||||
#[must_use]
|
||||
pub const fn failure_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
|
||||
return self.failure_code;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable runtime-neutral-facing latest-value source for concrete Backfill snapshots.
|
||||
#[derive(Clone)]
|
||||
pub struct BackfillSnapshotSource {
|
||||
receiver: tokio::sync::watch::Receiver<ksp_job_api::JobNotification<BackfillJobSnapshot>>,
|
||||
}
|
||||
|
||||
impl ksp_job_api::JobSnapshotSource for BackfillSnapshotSource {
|
||||
type Snapshot = BackfillJobSnapshot;
|
||||
|
||||
fn current(&self) -> ksp_job_api::JobNotification<Self::Snapshot> {
|
||||
return self.receiver.borrow().clone();
|
||||
}
|
||||
|
||||
fn wait_for_change(&self, observed: ksp_job_api::JobNotificationSequence) -> ksp_job_api::JobSnapshotFuture<'_, Self::Snapshot> {
|
||||
let mut receiver = self.receiver.clone();
|
||||
return std::boxed::Box::pin(async move {
|
||||
loop {
|
||||
let current = receiver.borrow().clone();
|
||||
if current.sequence().is_after(observed) || current.state().is_terminal() {
|
||||
return current;
|
||||
}
|
||||
let changed = receiver.changed().await;
|
||||
if changed.is_err() {
|
||||
return receiver.borrow().clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BackfillSnapshotSource {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let current = self.receiver.borrow();
|
||||
return formatter.debug_struct("BackfillSnapshotSource").field("sequence", ¤t.sequence()).field("state", ¤t.state()).finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable external control handle for one concrete Backfill runtime.
|
||||
#[derive(Clone)]
|
||||
pub struct BackfillJobHandle {
|
||||
control: BackfillRuntimeControl,
|
||||
snapshots: BackfillSnapshotSource,
|
||||
}
|
||||
|
||||
impl BackfillJobHandle {
|
||||
/// Requests cooperative cancellation and returns `true` only when accepted before terminal publication.
|
||||
#[must_use]
|
||||
pub fn cancel(&self) -> bool {
|
||||
return self.control.request_cancellation();
|
||||
}
|
||||
|
||||
/// Returns an independent latest-value snapshot source for one listener.
|
||||
#[must_use]
|
||||
pub fn snapshots(&self) -> BackfillSnapshotSource {
|
||||
return self.snapshots.clone();
|
||||
}
|
||||
|
||||
/// Returns whether cooperative cancellation has been accepted for this non-terminal Job.
|
||||
#[must_use]
|
||||
pub fn is_cancellation_requested(&self) -> bool {
|
||||
return self.control.is_cancellation_requested();
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BackfillJobHandle {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("BackfillJobHandle")
|
||||
.field("cancellation_requested", &self.is_cancellation_requested())
|
||||
.field("snapshot", &<BackfillSnapshotSource as ksp_job_api::JobSnapshotSource>::current(&self.snapshots))
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete single-run Backfill coordinator paired with a cloneable control/snapshot handle.
|
||||
pub struct BackfillJobRuntime {
|
||||
request: crate::BackfillRequest,
|
||||
control: BackfillRuntimeControl,
|
||||
cancellation: BackfillCancellationSignal,
|
||||
publisher: BackfillRuntimePublisher,
|
||||
handle: BackfillJobHandle,
|
||||
}
|
||||
|
||||
impl BackfillJobRuntime {
|
||||
/// Creates one runtime in `Created` state and its stable latest-value channel.
|
||||
pub fn new(request: crate::BackfillRequest) -> ksp_core_lib::Result<Self> {
|
||||
let kind = match ksp_job_api::JobKindCode::new(BACKFILL_JOB_KIND_CODE) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let initial_snapshot = BackfillJobSnapshot::initial(&request);
|
||||
let initial = ksp_job_api::JobNotification::new(
|
||||
request.job_id().clone(),
|
||||
kind.clone(),
|
||||
ksp_job_api::JobNotificationSequence::initial(),
|
||||
ksp_job_api::JobState::Created,
|
||||
initial_snapshot,
|
||||
);
|
||||
let (snapshot_sender, snapshot_receiver) = tokio::sync::watch::channel(initial);
|
||||
let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false);
|
||||
let control = BackfillRuntimeControl::new(cancel_sender);
|
||||
let cancellation = BackfillCancellationSignal::new(control.token(), cancel_receiver);
|
||||
let snapshots = BackfillSnapshotSource { receiver: snapshot_receiver };
|
||||
let handle = BackfillJobHandle { control: control.clone(), snapshots: snapshots.clone() };
|
||||
let publisher = BackfillRuntimePublisher { sender: snapshot_sender, id: request.job_id().clone(), kind };
|
||||
return std::result::Result::Ok(Self { request, control, cancellation, publisher, handle });
|
||||
}
|
||||
|
||||
/// Returns a cloneable control and latest-value observation handle before the runtime is moved into execution.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> BackfillJobHandle {
|
||||
return self.handle.clone();
|
||||
}
|
||||
|
||||
/// Runs discovery, bounded execution, cooperative cancellation and terminal snapshot publication.
|
||||
pub async fn run(
|
||||
self,
|
||||
transport: &ksp_onchain_transport_lib::HttpTransportPool,
|
||||
store: &ksp_store_lib::Store,
|
||||
) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
if self.cancellation.is_requested() {
|
||||
let claimed = self.control.claim_normal_terminal();
|
||||
if claimed != TerminalClaim::Cancelled {
|
||||
return std::result::Result::Err(runtime_error("terminal.pre_start"));
|
||||
}
|
||||
return self.publisher.publish_cancelled_from_created();
|
||||
}
|
||||
let started = self.publisher.publish_running(BackfillJobPhase::Discovering);
|
||||
if let std::result::Result::Err(error) = started {
|
||||
self.control.claim_failed();
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let discovery = crate::discover_backfill_candidates_cancellable(transport, &self.request, &self.cancellation).await;
|
||||
let discovery = match discovery {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
if error.code() == crate::ERROR_CODE_BACKFILL_CANCELLED {
|
||||
return self.finish_cancelled();
|
||||
}
|
||||
self.control.claim_failed();
|
||||
let published = self.publisher.publish_failed(error.code());
|
||||
if let std::result::Result::Err(publish_error) = published {
|
||||
return std::result::Result::Err(publish_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
let published = self.publisher.publish_discovery(&discovery);
|
||||
if let std::result::Result::Err(error) = published {
|
||||
self.control.claim_failed();
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
let batch = crate::execute_backfill_discovery_cancellable(transport, store, &self.request, &discovery, &self.cancellation, &self.publisher).await;
|
||||
let batch = match batch {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => {
|
||||
self.control.claim_failed();
|
||||
let published = self.publisher.publish_failed(error.code());
|
||||
if let std::result::Result::Err(publish_error) = published {
|
||||
return std::result::Result::Err(publish_error);
|
||||
}
|
||||
return std::result::Result::Err(error);
|
||||
},
|
||||
};
|
||||
if let std::option::Option::Some(code) = batch.failure_code() {
|
||||
self.control.claim_failed();
|
||||
let published = self.publisher.publish_batch_terminal(&batch, ksp_job_api::JobState::Failed, std::option::Option::Some(code));
|
||||
if let std::result::Result::Err(error) = published {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return std::result::Result::Err(ksp_core_lib::Error::new(code, "Backfill execution reached a fatal candidate result"));
|
||||
}
|
||||
let terminal = self.control.claim_normal_terminal();
|
||||
return match terminal {
|
||||
TerminalClaim::Cancelled => {
|
||||
let cancelling = self.publisher.publish_batch_cancelling(&batch);
|
||||
if let std::result::Result::Err(error) = cancelling {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
self.publisher.publish_batch_terminal(&batch, ksp_job_api::JobState::Cancelled, std::option::Option::None)
|
||||
},
|
||||
TerminalClaim::Completed => {
|
||||
let completion = if batch.is_partial() { ksp_job_api::JobCompletion::Partial } else { ksp_job_api::JobCompletion::Complete };
|
||||
self.publisher.publish_batch_terminal(&batch, ksp_job_api::JobState::Completed(completion), std::option::Option::None)
|
||||
},
|
||||
TerminalClaim::Failed => std::result::Result::Err(runtime_error("terminal.failed")),
|
||||
};
|
||||
}
|
||||
|
||||
fn finish_cancelled(&self) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
let terminal = self.control.claim_normal_terminal();
|
||||
if terminal != TerminalClaim::Cancelled {
|
||||
return std::result::Result::Err(runtime_error("terminal.cancelled"));
|
||||
}
|
||||
let cancelling = self.publisher.publish_cancelling();
|
||||
if let std::result::Result::Err(error) = cancelling {
|
||||
return std::result::Result::Err(error);
|
||||
}
|
||||
return self.publisher.publish_terminal(ksp_job_api::JobState::Cancelled, std::option::Option::None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal atomic terminal/cancellation arbitration shared by runtime and external handle.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BackfillRuntimeControl {
|
||||
state: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
token: ksp_job_api::JobCancellationToken,
|
||||
cancel_sender: tokio::sync::watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl BackfillRuntimeControl {
|
||||
/// Creates one active control state paired with the cancellation wake channel.
|
||||
pub(crate) fn new(cancel_sender: tokio::sync::watch::Sender<bool>) -> Self {
|
||||
return Self {
|
||||
state: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(CONTROL_ACTIVE)),
|
||||
token: ksp_job_api::JobCancellationToken::new(),
|
||||
cancel_sender,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the runtime-neutral cancellation token mirrored by this control.
|
||||
pub(crate) fn token(&self) -> ksp_job_api::JobCancellationToken {
|
||||
return self.token.clone();
|
||||
}
|
||||
|
||||
/// Atomically accepts the first pre-terminal cancellation request.
|
||||
pub(crate) fn request_cancellation(&self) -> bool {
|
||||
let accepted = self
|
||||
.state
|
||||
.compare_exchange(CONTROL_ACTIVE, CONTROL_CANCELLATION_REQUESTED, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire)
|
||||
.is_ok();
|
||||
if accepted && self.token.cancel() {
|
||||
let _previous = self.cancel_sender.send_replace(true);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
/// Returns whether cooperative cancellation was accepted.
|
||||
pub(crate) fn is_cancellation_requested(&self) -> bool {
|
||||
return self.token.is_cancellation_requested();
|
||||
}
|
||||
|
||||
/// Atomically resolves the completion-versus-cancellation terminal race.
|
||||
pub(crate) fn claim_normal_terminal(&self) -> TerminalClaim {
|
||||
let completed =
|
||||
self.state.compare_exchange(CONTROL_ACTIVE, CONTROL_COMPLETED, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire);
|
||||
if completed.is_ok() {
|
||||
return TerminalClaim::Completed;
|
||||
}
|
||||
let cancelled = self.state.compare_exchange(
|
||||
CONTROL_CANCELLATION_REQUESTED,
|
||||
CONTROL_CANCELLED,
|
||||
std::sync::atomic::Ordering::AcqRel,
|
||||
std::sync::atomic::Ordering::Acquire,
|
||||
);
|
||||
if cancelled.is_ok() {
|
||||
return TerminalClaim::Cancelled;
|
||||
}
|
||||
return TerminalClaim::Failed;
|
||||
}
|
||||
|
||||
/// Marks a non-terminal control as failed, overriding a pending cancellation request.
|
||||
pub(crate) fn claim_failed(&self) {
|
||||
loop {
|
||||
let state = self.state.load(std::sync::atomic::Ordering::Acquire);
|
||||
if matches!(state, CONTROL_COMPLETED | CONTROL_CANCELLED | CONTROL_FAILED) {
|
||||
return;
|
||||
}
|
||||
let changed = self.state.compare_exchange(state, CONTROL_FAILED, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire);
|
||||
if changed.is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal wakeable cancellation signal used only around abandonable pre-Store futures.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BackfillCancellationSignal {
|
||||
token: ksp_job_api::JobCancellationToken,
|
||||
receiver: tokio::sync::watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl BackfillCancellationSignal {
|
||||
/// Creates one signal from the runtime-neutral token and Tokio wake receiver.
|
||||
pub(crate) fn new(token: ksp_job_api::JobCancellationToken, receiver: tokio::sync::watch::Receiver<bool>) -> Self {
|
||||
return Self { token, receiver };
|
||||
}
|
||||
|
||||
/// Returns whether cancellation has been requested.
|
||||
pub(crate) fn is_requested(&self) -> bool {
|
||||
return self.token.is_cancellation_requested();
|
||||
}
|
||||
|
||||
/// Runs one abandonable operation until it completes or cancellation wins.
|
||||
pub(crate) async fn run_cancellable<F, T>(&self, operation: F) -> ksp_core_lib::Result<T>
|
||||
where
|
||||
F: std::future::Future<Output = ksp_core_lib::Result<T>>,
|
||||
{
|
||||
if self.is_requested() {
|
||||
return std::result::Result::Err(cancelled_error());
|
||||
}
|
||||
let mut receiver = self.receiver.clone();
|
||||
return tokio::select! {
|
||||
biased;
|
||||
_ = wait_for_cancellation(&self.token, &mut receiver) => std::result::Result::Err(cancelled_error()),
|
||||
result = operation => result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_cancellation(token: &ksp_job_api::JobCancellationToken, receiver: &mut tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
if token.is_cancellation_requested() || *receiver.borrow() {
|
||||
return;
|
||||
}
|
||||
let changed = receiver.changed().await;
|
||||
if changed.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal result of atomically claiming a normal terminal state.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum TerminalClaim {
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Internal latest-value publisher owning the concrete Backfill notification stream.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BackfillRuntimePublisher {
|
||||
sender: tokio::sync::watch::Sender<ksp_job_api::JobNotification<BackfillJobSnapshot>>,
|
||||
id: ksp_job_api::JobId,
|
||||
kind: ksp_job_api::JobKindCode,
|
||||
}
|
||||
|
||||
impl BackfillRuntimePublisher {
|
||||
/// Publishes one non-terminal running phase.
|
||||
pub(crate) fn publish_running(&self, phase: BackfillJobPhase) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(ksp_job_api::JobState::Running, |snapshot| {
|
||||
snapshot.phase = phase;
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes the complete bounded discovery result as the current execution snapshot.
|
||||
pub(crate) fn publish_discovery(&self, discovery: &crate::BackfillDiscovery) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(ksp_job_api::JobState::Running, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Executing;
|
||||
snapshot.discovery_boundary = std::option::Option::Some(discovery.boundary());
|
||||
snapshot.candidates_selected = discovery.candidates().len();
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes one coalescable execution progress value.
|
||||
pub(crate) fn publish_execution_progress(
|
||||
&self,
|
||||
progress: &crate::BackfillExecutionProgress,
|
||||
cancelling: bool,
|
||||
draining: bool,
|
||||
) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
let state = if cancelling { ksp_job_api::JobState::Cancelling } else { ksp_job_api::JobState::Running };
|
||||
return self.publish_with(state, |snapshot| {
|
||||
snapshot.phase = if draining { BackfillJobPhase::Draining } else { BackfillJobPhase::Executing };
|
||||
apply_progress(snapshot, progress);
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes cancellation observation before terminal cancellation.
|
||||
pub(crate) fn publish_cancelling(&self) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(ksp_job_api::JobState::Cancelling, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Draining;
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes the drained batch state while cancellation is terminalizing.
|
||||
pub(crate) fn publish_batch_cancelling(&self, batch: &crate::BackfillExecutionBatch) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(ksp_job_api::JobState::Cancelling, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Draining;
|
||||
apply_batch(snapshot, batch);
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes one terminal state together with the fully drained execution batch.
|
||||
pub(crate) fn publish_batch_terminal(
|
||||
&self,
|
||||
batch: &crate::BackfillExecutionBatch,
|
||||
state: ksp_job_api::JobState,
|
||||
failure_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(state, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Finished;
|
||||
snapshot.failure_code = failure_code;
|
||||
apply_batch(snapshot, batch);
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes a terminal failure before a batch exists.
|
||||
pub(crate) fn publish_failed(&self, code: ksp_core_lib::ErrorCode) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_terminal(ksp_job_api::JobState::Failed, std::option::Option::Some(code));
|
||||
}
|
||||
|
||||
/// Publishes a terminal state without a completed execution batch.
|
||||
pub(crate) fn publish_terminal(
|
||||
&self,
|
||||
state: ksp_job_api::JobState,
|
||||
failure_code: std::option::Option<ksp_core_lib::ErrorCode>,
|
||||
) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(state, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Finished;
|
||||
snapshot.failure_code = failure_code;
|
||||
});
|
||||
}
|
||||
|
||||
/// Publishes direct Created-to-Cancelled termination before execution starts.
|
||||
pub(crate) fn publish_cancelled_from_created(&self) -> ksp_core_lib::Result<BackfillJobSnapshot> {
|
||||
return self.publish_with(ksp_job_api::JobState::Cancelled, |snapshot| {
|
||||
snapshot.phase = BackfillJobPhase::Finished;
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_with<F>(&self, state: ksp_job_api::JobState, update: F) -> ksp_core_lib::Result<BackfillJobSnapshot>
|
||||
where
|
||||
F: FnOnce(&mut BackfillJobSnapshot),
|
||||
{
|
||||
let current = self.sender.borrow().clone();
|
||||
if current.state().is_terminal() {
|
||||
return std::result::Result::Err(runtime_error("notification.terminal"));
|
||||
}
|
||||
if !valid_snapshot_transition(current.state(), state) {
|
||||
return std::result::Result::Err(runtime_error("notification.transition"));
|
||||
}
|
||||
let sequence = match current.sequence().next() {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let mut snapshot = current.snapshot().clone();
|
||||
update(&mut snapshot);
|
||||
let notification = ksp_job_api::JobNotification::new(self.id.clone(), self.kind.clone(), sequence, state, snapshot.clone());
|
||||
let _previous = self.sender.send_replace(notification);
|
||||
return std::result::Result::Ok(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_snapshot_transition(source: ksp_job_api::JobState, target: ksp_job_api::JobState) -> bool {
|
||||
if source.is_terminal() {
|
||||
return false;
|
||||
}
|
||||
return matches!(
|
||||
(source, target),
|
||||
(ksp_job_api::JobState::Created, ksp_job_api::JobState::Running)
|
||||
| (ksp_job_api::JobState::Created, ksp_job_api::JobState::Cancelled)
|
||||
| (ksp_job_api::JobState::Running, ksp_job_api::JobState::Running)
|
||||
| (ksp_job_api::JobState::Running, ksp_job_api::JobState::Cancelling)
|
||||
| (ksp_job_api::JobState::Running, ksp_job_api::JobState::Completed(_))
|
||||
| (ksp_job_api::JobState::Running, ksp_job_api::JobState::Failed)
|
||||
| (ksp_job_api::JobState::Cancelling, ksp_job_api::JobState::Cancelling)
|
||||
| (ksp_job_api::JobState::Cancelling, ksp_job_api::JobState::Cancelled)
|
||||
| (ksp_job_api::JobState::Cancelling, ksp_job_api::JobState::Failed)
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_progress(snapshot: &mut BackfillJobSnapshot, progress: &crate::BackfillExecutionProgress) {
|
||||
snapshot.candidates_admitted = progress.admitted_count();
|
||||
snapshot.candidates_finished = progress.finished_count();
|
||||
snapshot.entities_inserted = progress.inserted_count();
|
||||
snapshot.entities_existing = progress.already_present_count();
|
||||
snapshot.entities_purged = progress.purged_count();
|
||||
snapshot.missing = progress.missing_count();
|
||||
snapshot.conflicts = progress.conflict_count();
|
||||
snapshot.observations_inserted = progress.observation_inserted_count();
|
||||
snapshot.observations_existing = progress.observation_already_present_count();
|
||||
snapshot.cancelled_candidates = progress.cancelled_count();
|
||||
snapshot.holes = progress.hole_count();
|
||||
snapshot.maximum_in_flight = progress.maximum_in_flight();
|
||||
snapshot.contiguous_completed = progress.contiguous_completed();
|
||||
snapshot.checkpoint = std::option::Option::Some(progress.checkpoint().clone());
|
||||
}
|
||||
|
||||
fn apply_batch(snapshot: &mut BackfillJobSnapshot, batch: &crate::BackfillExecutionBatch) {
|
||||
snapshot.candidates_admitted = batch.admitted_count();
|
||||
snapshot.candidates_finished = batch.finished_count();
|
||||
snapshot.entities_inserted = batch.inserted_count();
|
||||
snapshot.entities_existing = batch.already_present_count();
|
||||
snapshot.entities_purged = batch.purged_count();
|
||||
snapshot.missing = batch.missing_count();
|
||||
snapshot.conflicts = batch.conflict_count();
|
||||
snapshot.observations_inserted = batch.observation_inserted_count();
|
||||
snapshot.observations_existing = batch.observation_already_present_count();
|
||||
snapshot.cancelled_candidates = batch.cancelled_count();
|
||||
snapshot.holes = batch.hole_count();
|
||||
snapshot.maximum_in_flight = batch.maximum_in_flight();
|
||||
snapshot.contiguous_completed = batch.checkpoint().completed_prefix();
|
||||
snapshot.checkpoint = std::option::Option::Some(batch.checkpoint().clone());
|
||||
}
|
||||
|
||||
fn cancelled_error() -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_CANCELLED, "Backfill operation cancelled before durable Store submission");
|
||||
}
|
||||
|
||||
fn runtime_error(field: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_RUNTIME_INVALID, "invalid concrete Backfill runtime state").with_context("field", field);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/runtime.rs"]
|
||||
mod tests;
|
||||
@@ -1,10 +1,10 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/dependency_boundary.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
//! Dependency firewall canaries through bounded Backfill execution/checkpointing.
|
||||
//! Dependency firewall canaries through the concrete cancellation and latest-value runtime tranche.
|
||||
|
||||
#[test]
|
||||
fn pre_008_manifest_uses_only_planned_ksp_edges_and_private_futures_runtime() {
|
||||
fn pre_009_manifest_uses_only_planned_ksp_edges_and_private_tokio_runtime() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
for required in [
|
||||
"futures-util = { workspace = true, features = [\"std\"] }",
|
||||
@@ -15,6 +15,7 @@ fn pre_008_manifest_uses_only_planned_ksp_edges_and_private_futures_runtime() {
|
||||
"ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }",
|
||||
"serde_json.workspace = true",
|
||||
"sha2.workspace = true",
|
||||
"tokio = { workspace = true, features = [\"macros\", \"sync\"] }",
|
||||
] {
|
||||
assert!(manifest.contains(required), "required Backfill dependency missing: {required}");
|
||||
}
|
||||
@@ -33,16 +34,13 @@ fn pre_008_manifest_uses_only_planned_ksp_edges_and_private_futures_runtime() {
|
||||
] {
|
||||
assert!(!manifest.contains(forbidden), "forbidden Backfill dependency present: {forbidden}");
|
||||
}
|
||||
let normal_dependencies = match manifest.split("[dev-dependencies]").next() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => manifest,
|
||||
};
|
||||
assert!(!normal_dependencies.contains("tokio ="), "Tokio must not become a public/normal Backfill dependency in pre.008");
|
||||
let root = include_str!("../src/lib.rs");
|
||||
assert!(!root.contains("tokio::"), "Tokio implementation types must not leak through the public crate root");
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_008_production_sources_keep_transport_store_and_scheduler_ownership_separate() {
|
||||
fn pre_009_production_sources_keep_transport_store_and_scheduler_ownership_separate() {
|
||||
let neutral_sources = [
|
||||
include_str!("../src/checkpoint.rs"),
|
||||
include_str!("../src/constants.rs"),
|
||||
@@ -50,6 +48,7 @@ fn pre_008_production_sources_keep_transport_store_and_scheduler_ownership_separ
|
||||
include_str!("../src/error.rs"),
|
||||
include_str!("../src/lib.rs"),
|
||||
include_str!("../src/request.rs"),
|
||||
include_str!("../src/runtime.rs"),
|
||||
];
|
||||
for source in neutral_sources {
|
||||
for forbidden in ["ksp_config_lib::", "ksp_interface_lib::", "ksp_store_api::", "ksp_store_postgres_lib::", "reqwest::", "std::env", "tonic::"] {
|
||||
@@ -83,5 +82,12 @@ fn pre_008_production_sources_keep_transport_store_and_scheduler_ownership_separ
|
||||
for forbidden in ["tokio::spawn", "tokio::time", "retry", "endpoint_name", "provider()", "ForceRehydrate", "get_raw_transaction"] {
|
||||
assert!(!execution.contains(forbidden), "forbidden scheduler/policy ownership detected: {forbidden}");
|
||||
}
|
||||
let runtime = include_str!("../src/runtime.rs");
|
||||
assert!(runtime.contains("tokio::sync::watch"));
|
||||
assert!(runtime.contains("JobSnapshotSource"));
|
||||
assert!(runtime.contains("JobCancellationToken"));
|
||||
assert!(!runtime.contains("tokio::spawn"));
|
||||
assert!(!runtime.contains("tokio::time"));
|
||||
assert!(!runtime.contains("reqwest::"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/public_api.rs
|
||||
// version: 4
|
||||
// version: 6
|
||||
|
||||
//! Public API canaries through bounded Backfill execution and caller-owned checkpoints.
|
||||
//! Public API canaries through the concrete Backfill cancellation and latest-value runtime tranche.
|
||||
|
||||
#[test]
|
||||
fn pre_005_request_scope_and_discovery_contracts_are_available_from_crate_root() {
|
||||
@@ -106,3 +106,20 @@ fn pre_008_checkpoint_and_bounded_execution_contracts_are_available_from_crate_r
|
||||
assert_eq!(ksp_job_backfill_lib::ERROR_CODE_BACKFILL_EXECUTION_INVALID, ksp_core_lib::ErrorCode::new("job_backfill", "execution_invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_concrete_runtime_snapshot_and_control_contracts_are_available_from_crate_root() {
|
||||
fn assert_source<T>()
|
||||
where
|
||||
T: ksp_job_api::JobSnapshotSource<Snapshot = ksp_job_backfill_lib::BackfillJobSnapshot>,
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert_source::<ksp_job_backfill_lib::BackfillSnapshotSource>();
|
||||
let _runtime_new = ksp_job_backfill_lib::BackfillJobRuntime::new;
|
||||
let _handle: std::option::Option<ksp_job_backfill_lib::BackfillJobHandle> = std::option::Option::None;
|
||||
let _phase = ksp_job_backfill_lib::BackfillJobPhase::Discovering;
|
||||
assert_eq!(ksp_job_backfill_lib::BACKFILL_JOB_KIND_CODE, "solana.raw_transaction.backfill");
|
||||
assert_eq!(ksp_job_backfill_lib::ERROR_CODE_BACKFILL_RUNTIME_INVALID, ksp_core_lib::ErrorCode::new("job_backfill", "runtime_invalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// file: crates/ksp-job-backfill-lib/tests/release_completeness.rs
|
||||
// version: 4
|
||||
// version: 7
|
||||
|
||||
//! Completeness canaries through the `pre.008` bounded execution/checkpoint tranche.
|
||||
//! Completeness canaries through the `pre.009` concrete cancellation and latest-value runtime tranche.
|
||||
|
||||
#[test]
|
||||
fn pre_008_production_module_inventory_is_exact() -> std::io::Result<()> {
|
||||
fn pre_009_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,
|
||||
@@ -34,13 +34,24 @@ fn pre_008_production_module_inventory_is_exact() -> std::io::Result<()> {
|
||||
names.sort_unstable();
|
||||
assert_eq!(
|
||||
names,
|
||||
std::vec!["checkpoint.rs", "constants.rs", "conversion.rs", "discovery.rs", "error.rs", "execution.rs", "lib.rs", "persistence.rs", "request.rs"]
|
||||
std::vec![
|
||||
"checkpoint.rs",
|
||||
"constants.rs",
|
||||
"conversion.rs",
|
||||
"discovery.rs",
|
||||
"error.rs",
|
||||
"execution.rs",
|
||||
"lib.rs",
|
||||
"persistence.rs",
|
||||
"request.rs",
|
||||
"runtime.rs",
|
||||
]
|
||||
);
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_008_surface_adds_bounded_execution_and_checkpoint_without_pre_009_runtime() {
|
||||
fn pre_009_surface_closes_concrete_cancellation_and_latest_value_runtime() {
|
||||
let root = include_str!("../src/lib.rs");
|
||||
for required in [
|
||||
"BackfillCandidate",
|
||||
@@ -65,11 +76,18 @@ fn pre_008_surface_adds_bounded_execution_and_checkpoint_without_pre_009_runtime
|
||||
"execute_backfill_discovery",
|
||||
"ERROR_CODE_BACKFILL_CHECKPOINT_INVALID",
|
||||
"ERROR_CODE_BACKFILL_EXECUTION_INVALID",
|
||||
"BackfillJobHandle",
|
||||
"BackfillJobPhase",
|
||||
"BackfillJobRuntime",
|
||||
"BackfillJobSnapshot",
|
||||
"BackfillSnapshotSource",
|
||||
"BACKFILL_JOB_KIND_CODE",
|
||||
"ERROR_CODE_BACKFILL_RUNTIME_INVALID",
|
||||
] {
|
||||
assert!(root.contains(required), "required pre.008 public contract missing: {required}");
|
||||
assert!(root.contains(required), "required pre.009 public contract missing: {required}");
|
||||
}
|
||||
for forbidden in ["BackfillJobHandle", "BackfillSnapshot", "JobSnapshotSource", "tokio::", "FuturesUnordered"] {
|
||||
assert!(!root.contains(forbidden), "pre.009 or runtime implementation detail leaked into public root: {forbidden}");
|
||||
for forbidden in ["tokio::", "FuturesUnordered", "watch::Receiver", "watch::Sender"] {
|
||||
assert!(!root.contains(forbidden), "runtime implementation detail leaked into public root: {forbidden}");
|
||||
}
|
||||
assert!(!root.contains("pub mod "));
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/unit_tests/discovery.rs
|
||||
// version: 2
|
||||
// version: 5
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PageCall {
|
||||
@@ -15,6 +15,34 @@ struct FakeSource {
|
||||
calls: std::sync::Mutex<std::vec::Vec<PageCall>>,
|
||||
}
|
||||
|
||||
struct PendingSource {
|
||||
calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl PendingSource {
|
||||
fn new() -> Self {
|
||||
return Self { calls: std::sync::atomic::AtomicUsize::new(0) };
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
return self.calls.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
}
|
||||
|
||||
impl super::SignaturePageSource for PendingSource {
|
||||
fn fetch_signature_page<'a>(
|
||||
&'a self,
|
||||
_role: &'a ksp_onchain_transport_lib::HttpRoleName,
|
||||
_address: &'a ksp_core_lib::Pubkey,
|
||||
_config: ksp_onchain_transport_lib::SolanaSignaturesForAddressConfig,
|
||||
) -> super::SignaturePageFuture<'a> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
return std::boxed::Box::pin(async {
|
||||
return std::future::pending::<ksp_core_lib::Result<std::vec::Vec<super::SignaturePageEntry>>>().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeSource {
|
||||
fn new(pages: std::vec::Vec<std::vec::Vec<super::SignaturePageEntry>>) -> Self {
|
||||
return Self { pages: std::sync::Mutex::new(pages.into()), calls: std::sync::Mutex::new(std::vec::Vec::new()) };
|
||||
@@ -130,7 +158,7 @@ async fn pre_005_latest_paginates_newest_first_and_deduplicates_pages_stably() {
|
||||
std::vec![page_entry('6', 60), page_entry('5', 50), page_entry('5', 50)],
|
||||
std::vec![page_entry('4', 40), page_entry('3', 30)],
|
||||
]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
assert!(result.is_ok());
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
@@ -164,7 +192,7 @@ async fn pre_005_before_uses_exclusive_anchor_then_advances_rpc_cursor() {
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec![std::vec![page_entry('6', 60), page_entry('5', 50)], std::vec![page_entry('4', 40)]]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -194,7 +222,7 @@ async fn pre_005_after_keeps_only_nearest_newer_window_and_preserves_rpc_order()
|
||||
std::vec![page_entry('7', 70), page_entry('6', 60), page_entry('5', 50)],
|
||||
std::vec![page_entry('4', 40), page_entry('3', 30)],
|
||||
]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -223,7 +251,7 @@ async fn pre_005_after_page_bound_is_partial_and_does_not_claim_anchor_completio
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec![std::vec![page_entry('7', 70), page_entry('6', 60)], std::vec![page_entry('5', 50), page_entry('4', 40)],]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -243,7 +271,7 @@ async fn pre_005_latest_page_bound_is_partial_when_full_pages_leave_more_history
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec![std::vec![page_entry('7', 70), page_entry('6', 60)]]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -293,7 +321,7 @@ async fn pre_005_explicit_scope_never_calls_transport_and_preserves_network_scop
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec::Vec::new());
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
let discovery = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -327,7 +355,7 @@ async fn pre_008_before_resume_uses_checkpoint_cursor_instead_of_original_anchor
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec![std::vec![page_entry('4', 40)]]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
assert!(result.is_ok());
|
||||
let calls = source.calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
@@ -348,10 +376,36 @@ async fn pre_008_latest_resume_restarts_from_current_latest_without_rpc_cursor()
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let source = FakeSource::new(std::vec![std::vec![page_entry('7', 70)]]);
|
||||
let result = super::discover_with_source(&source, &request).await;
|
||||
let result = super::discover_with_source(&source, &request, std::option::Option::None).await;
|
||||
assert!(result.is_ok());
|
||||
let calls = source.calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].before, std::option::Option::None);
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_009_discovery_rpc_wait_is_cancelled_cooperatively() {
|
||||
let scope = crate::BackfillScope::latest_address(ksp_core_lib::Pubkey::new_from_array([9_u8; 32]));
|
||||
let request = match request(scope, 2, 2, 5) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let source = PendingSource::new();
|
||||
let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false);
|
||||
let control = crate::BackfillRuntimeControl::new(cancel_sender);
|
||||
let cancellation = crate::BackfillCancellationSignal::new(control.token(), cancel_receiver);
|
||||
let discovery = super::discover_with_source(&source, &request, std::option::Option::Some(&cancellation));
|
||||
let cancel = async {
|
||||
tokio::task::yield_now().await;
|
||||
assert!(control.request_cancellation());
|
||||
};
|
||||
let (result, ()) = tokio::join!(discovery, cancel);
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_CANCELLED);
|
||||
assert_eq!(source.calls(), 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-job-backfill-lib/unit_tests/execution.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FakeDisposition {
|
||||
@@ -164,7 +164,7 @@ async fn pre_008_execution_is_bounded_and_reconciles_out_of_order_durable_comple
|
||||
FakePlan { pending_polls: 2, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
]);
|
||||
let result = super::execute_with_processor(&processor, &request, &discovery).await;
|
||||
let result = super::execute_with_processor(&processor, &request, &discovery, std::option::Option::None, std::option::Option::None).await;
|
||||
let batch = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
@@ -196,7 +196,7 @@ async fn pre_008_missing_is_non_fatal_but_blocks_frontier_while_later_candidates
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Missing },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
]);
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery).await {
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery, std::option::Option::None, std::option::Option::None).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -227,7 +227,7 @@ async fn pre_008_conflict_stops_new_admissions_and_drains_already_in_flight_work
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
]);
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery).await {
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery, std::option::Option::None, std::option::Option::None).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -263,7 +263,7 @@ async fn pre_008_explicit_resume_skips_only_the_checkpointed_contiguous_prefix()
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
]);
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery).await {
|
||||
let batch = match super::execute_with_processor(&processor, &request, &discovery, std::option::Option::None, std::option::Option::None).await {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
@@ -274,3 +274,47 @@ async fn pre_008_explicit_resume_skips_only_the_checkpointed_contiguous_prefix()
|
||||
assert_eq!(batch.failure_code(), std::option::Option::None);
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_009_cancellation_stops_admission_and_drains_already_admitted_candidate_work() {
|
||||
let request = match explicit_request(&['1', '2', '3', '4'], 2) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let discovery = match discovery(&request, &['1', '2', '3', '4']) {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let processor = FakeProcessor::new(std::vec![
|
||||
FakePlan { pending_polls: 12, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 12, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
FakePlan { pending_polls: 0, disposition: FakeDisposition::Durable },
|
||||
]);
|
||||
let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false);
|
||||
let control = crate::runtime::BackfillRuntimeControl::new(cancel_sender);
|
||||
let signal = crate::runtime::BackfillCancellationSignal::new(control.token(), cancel_receiver);
|
||||
let execution = super::execute_with_processor(&processor, &request, &discovery, std::option::Option::Some(&signal), std::option::Option::None);
|
||||
let cancellation = async {
|
||||
loop {
|
||||
if processor.calls() >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert!(control.request_cancellation());
|
||||
};
|
||||
let (result, ()) = tokio::join!(execution, cancellation);
|
||||
let batch = match result {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
assert_eq!(processor.calls(), 2);
|
||||
assert_eq!(batch.admitted_count(), 2);
|
||||
assert_eq!(batch.finished_count(), 2);
|
||||
assert_eq!(batch.durable_count(), 2);
|
||||
assert_eq!(batch.cancelled_count(), 0);
|
||||
assert!(batch.was_cancelled());
|
||||
assert_eq!(batch.checkpoint().completed_prefix(), 2);
|
||||
return;
|
||||
}
|
||||
|
||||
158
crates/ksp-job-backfill-lib/unit_tests/runtime.rs
Normal file
158
crates/ksp-job-backfill-lib/unit_tests/runtime.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// file: crates/ksp-job-backfill-lib/unit_tests/runtime.rs
|
||||
// version: 2
|
||||
|
||||
use ksp_job_api::JobSnapshotSource; // rust-rules: trait-import
|
||||
|
||||
fn request() -> std::option::Option<crate::BackfillRequest> {
|
||||
let signature = match crate::BackfillSignature::new("1".repeat(crate::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let scope = match crate::BackfillScope::explicit_signatures(std::vec![signature]) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let job_id = match ksp_job_api::JobId::new("backfill:runtime-test") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
let network = match ksp_store_lib::RawNetworkId::new("devnet") {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return std::option::Option::None,
|
||||
};
|
||||
return crate::BackfillRequest::new(
|
||||
job_id,
|
||||
network,
|
||||
ksp_onchain_transport_lib::HttpRoleName::new("history"),
|
||||
crate::BackfillCommitment::Confirmed,
|
||||
scope,
|
||||
100,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
std::option::Option::None,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_009_latest_value_source_coalesces_progress_for_slow_independent_listeners() {
|
||||
let request = match request() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let runtime = match crate::BackfillJobRuntime::new(request) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let handle = runtime.handle();
|
||||
let listener_a = handle.snapshots();
|
||||
let listener_b = handle.snapshots();
|
||||
let initial = listener_a.current();
|
||||
assert_eq!(initial.sequence().value(), 0);
|
||||
assert_eq!(initial.state(), ksp_job_api::JobState::Created);
|
||||
let first = runtime.publisher.publish_running(crate::BackfillJobPhase::Discovering);
|
||||
assert!(first.is_ok());
|
||||
let second = runtime.publisher.publish_running(crate::BackfillJobPhase::Executing);
|
||||
assert!(second.is_ok());
|
||||
let coalesced = listener_a.wait_for_change(initial.sequence()).await;
|
||||
assert_eq!(coalesced.sequence().value(), 2);
|
||||
assert_eq!(coalesced.snapshot().phase(), crate::BackfillJobPhase::Executing);
|
||||
let listener_b_current = listener_b.current();
|
||||
assert_eq!(listener_b_current.sequence().value(), 2);
|
||||
assert_eq!(listener_b_current.snapshot().phase(), crate::BackfillJobPhase::Executing);
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_009_terminal_snapshot_is_retained_and_late_cancellation_is_rejected() {
|
||||
let request = match request() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let runtime = match crate::BackfillJobRuntime::new(request) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let handle = runtime.handle();
|
||||
let listener = handle.snapshots();
|
||||
let started = runtime.publisher.publish_running(crate::BackfillJobPhase::Discovering);
|
||||
assert!(started.is_ok());
|
||||
assert_eq!(runtime.control.claim_normal_terminal(), crate::TerminalClaim::Completed);
|
||||
let terminal = runtime.publisher.publish_terminal(ksp_job_api::JobState::Completed(ksp_job_api::JobCompletion::Complete), std::option::Option::None);
|
||||
assert!(terminal.is_ok());
|
||||
assert!(!handle.cancel());
|
||||
let current = listener.current();
|
||||
assert!(current.state().is_terminal());
|
||||
assert_eq!(current.snapshot().phase(), crate::BackfillJobPhase::Finished);
|
||||
let retained = listener.wait_for_change(current.sequence()).await;
|
||||
assert_eq!(retained.sequence(), current.sequence());
|
||||
assert_eq!(retained.state(), current.state());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_terminal_race_is_first_decision_wins_for_cancellation_vs_completion() {
|
||||
let (cancel_sender, _) = tokio::sync::watch::channel(false);
|
||||
let cancellation_first = crate::BackfillRuntimeControl::new(cancel_sender);
|
||||
assert!(cancellation_first.request_cancellation());
|
||||
assert_eq!(cancellation_first.claim_normal_terminal(), crate::TerminalClaim::Cancelled);
|
||||
assert!(!cancellation_first.request_cancellation());
|
||||
let (cancel_sender, _) = tokio::sync::watch::channel(false);
|
||||
let completion_first = crate::BackfillRuntimeControl::new(cancel_sender);
|
||||
assert_eq!(completion_first.claim_normal_terminal(), crate::TerminalClaim::Completed);
|
||||
assert!(!completion_first.request_cancellation());
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_fatal_failure_overrides_pending_cancellation_before_terminal_publication() {
|
||||
let (cancel_sender, _) = tokio::sync::watch::channel(false);
|
||||
let control = crate::BackfillRuntimeControl::new(cancel_sender);
|
||||
assert!(control.request_cancellation());
|
||||
control.claim_failed();
|
||||
assert_eq!(control.claim_normal_terminal(), crate::TerminalClaim::Failed);
|
||||
assert!(!control.request_cancellation());
|
||||
return;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_009_long_running_pre_store_future_is_cancelled_cooperatively() {
|
||||
let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false);
|
||||
let control = crate::BackfillRuntimeControl::new(cancel_sender);
|
||||
let signal = crate::BackfillCancellationSignal::new(control.token(), cancel_receiver);
|
||||
let operation = std::future::pending::<ksp_core_lib::Result<usize>>();
|
||||
let wait = signal.run_cancellable(operation);
|
||||
let cancel = async {
|
||||
tokio::task::yield_now().await;
|
||||
assert!(control.request_cancellation());
|
||||
};
|
||||
let (result, ()) = tokio::join!(wait, cancel);
|
||||
let error = match result {
|
||||
std::result::Result::Ok(_) => return,
|
||||
std::result::Result::Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_BACKFILL_CANCELLED);
|
||||
return;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_009_snapshot_debug_and_public_shape_do_not_include_transport_or_raw_payloads() {
|
||||
let request = match request() {
|
||||
std::option::Option::Some(value) => value,
|
||||
std::option::Option::None => return,
|
||||
};
|
||||
let runtime = match crate::BackfillJobRuntime::new(request) {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(_) => return,
|
||||
};
|
||||
let snapshot = runtime.handle().snapshots().current();
|
||||
let debug = format!("{:?}", snapshot.snapshot());
|
||||
for forbidden in ["http://", "https://", "endpoint", "provider", "raw_payload", "transaction_data"] {
|
||||
assert!(!debug.contains(forbidden), "unsafe snapshot diagnostic leaked: {forbidden}");
|
||||
}
|
||||
assert_eq!(snapshot.snapshot().scope_kind(), crate::BackfillScopeKind::ExplicitSignatures);
|
||||
assert_eq!(snapshot.snapshot().candidates_selected(), 0);
|
||||
assert!(snapshot.snapshot().failure_code().is_none());
|
||||
return;
|
||||
}
|
||||
Reference in New Issue
Block a user