use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use crate::admission::{AdmissionDecision, AdmissionGuard};
use crate::effect::{
is_reserved_effect_capability, EventAppendHandle, EventReadHandle, HostControlHandle,
OperationEffectRow, ProjectionReadHandle, ReceiptEmitHandle,
};
use crate::effect_backend::EffectBackend;
use crate::error::{ReceiptSinkHandlerCause, RuntimeError};
use crate::operation_status::{OperationStatusFactV1, OperationStatusLifecycle};
use crate::operation_status_sink::OperationStatusSink;
use crate::receipt::{ReceiptHashPolicy, ReceiptMetadata, ReceiptOutcome, RecordedReceipt};
use crate::{handler, operation, receipt};
type HandlerResult = handler::HandlerResult;
type BoxedHandler = Box<dyn handler::Handler + 'static>;
type BoxedReceiptSink = Box<dyn receipt::ReceiptSink + 'static>;
type BoxedStatusSink = Arc<dyn OperationStatusSink + Send + Sync>;
type BoxedAdmissionGuard = Box<dyn AdmissionGuard + 'static>;
type BoxedEffectBackend = Box<dyn EffectBackend + 'static>;
pub struct Core {
pub(crate) descriptors: BTreeMap<String, operation::OperationDescriptor>,
pub(crate) handlers: BTreeMap<String, BoxedHandler>,
pub(crate) admission_guard: Option<BoxedAdmissionGuard>,
pub(crate) receipt_sink: Option<BoxedReceiptSink>,
pub(crate) status_sink: Option<BoxedStatusSink>,
pub(crate) receipt_hash_policy: ReceiptHashPolicy,
pub(crate) effect_backend: Option<BoxedEffectBackend>,
pub(crate) granted_capabilities: BTreeSet<String>,
}
impl Core {
#[must_use]
pub fn builder() -> crate::builder::CoreBuilder {
crate::builder::CoreBuilder::new()
}
#[must_use]
pub fn contains_operation(&self, name: impl AsRef<str>) -> bool {
self.descriptors.contains_key(name.as_ref())
}
#[must_use]
pub fn descriptor(&self, name: impl AsRef<str>) -> Option<&operation::OperationDescriptor> {
self.descriptors.get(name.as_ref())
}
pub fn invoke(
&mut self,
name: impl AsRef<str>,
input: operation::OperationInput,
) -> Result<CheckoutResult, RuntimeError> {
self.checkout_frame(CheckoutFrame::new(name.as_ref(), input))
}
pub fn checkout_frame(&mut self, frame: CheckoutFrame) -> Result<CheckoutResult, RuntimeError> {
let descriptor = self
.descriptors
.get(frame.name())
.cloned()
.ok_or_else(|| RuntimeError::unknown_operation(frame.name()))?;
self.checkout(Checkout::from_frame(descriptor, frame))
}
#[tracing::instrument(
name = "syncbat.checkout",
skip_all,
fields(
operation = %checkout.descriptor.name(),
input_bytes = checkout.input.len(),
output_bytes = tracing::field::Empty,
outcome = tracing::field::Empty,
),
)]
pub fn checkout(&mut self, checkout: Checkout) -> Result<CheckoutResult, RuntimeError> {
let descriptor = {
let name = checkout.descriptor.name();
self.descriptors.get(name).cloned().ok_or_else(|| {
tracing::warn!(operation = %name, outcome = "unknown_operation", "checkout rejected");
RuntimeError::unknown_operation(name)
})?
};
let name = descriptor.name();
let input = checkout.input;
let status_sink = self.status_sink.clone();
let receipt_hash_policy = self.receipt_hash_policy.clone();
self.enforce_granted_capabilities(
&descriptor,
&input,
status_sink.as_deref(),
&receipt_hash_policy,
)?;
let mut ctx = Ctx::new(&descriptor, self.effect_backend.as_deref_mut());
if let Some(guard) = self.admission_guard.as_deref() {
if let AdmissionDecision::Deny { code, message } =
guard.admit(&descriptor, &input, &mut ctx)
{
let metadata = ctx.into_metadata();
tracing::warn!(
operation = %name,
code = %code,
message = %message,
outcome = "denied",
"checkout denied by admission guard",
);
let outcome = ReceiptOutcome::denied(code.clone(), message.clone());
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy: &receipt_hash_policy,
descriptor: &descriptor,
lifecycle: OperationStatusLifecycle::Denied,
input: &input,
output: None,
code: Some(code.clone()),
message: Some(message.clone()),
handler_cause: None,
})?;
self.record_runtime_receipt(&descriptor, &input, None, outcome, None, metadata)?;
tracing::Span::current().record("outcome", "denied");
return Err(RuntimeError::denied(name, code, message));
}
}
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy: &receipt_hash_policy,
descriptor: &descriptor,
lifecycle: OperationStatusLifecycle::Started,
input: &input,
output: None,
code: None,
message: None,
handler_cause: None,
})?;
let Some(handler) = self.handlers.get_mut(name) else {
tracing::error!(operation = %name, outcome = "missing_handler", "checkout rejected");
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy: &receipt_hash_policy,
descriptor: &descriptor,
lifecycle: OperationStatusLifecycle::Failed,
input: &input,
output: None,
code: Some("missing_handler".to_owned()),
message: Some("operation descriptor has no registered handler".to_owned()),
handler_cause: None,
})?;
return Err(RuntimeError::missing_handler(name));
};
let handler_result = handler.handle(&input, &mut ctx);
let observed_effects = ctx.observed_effects().clone();
let metadata = ctx.into_metadata();
self.finish_handler_phase(HandlerPhase {
name,
descriptor: &descriptor,
input: &input,
handler_result,
observed_effects: &observed_effects,
metadata,
status_sink: &status_sink,
receipt_hash_policy: &receipt_hash_policy,
})
}
fn enforce_granted_capabilities(
&self,
descriptor: &operation::OperationDescriptor,
input: &[u8],
status_sink: Option<&(dyn OperationStatusSink + Send + Sync)>,
receipt_hash_policy: &ReceiptHashPolicy,
) -> Result<(), RuntimeError> {
let missing = descriptor
.effect_row()
.requires_capabilities()
.iter()
.map(String::as_str)
.find(|token| {
!is_reserved_effect_capability(token) && !self.granted_capabilities.contains(*token)
});
let Some(missing) = missing else {
return Ok(());
};
let name = descriptor.name();
let code = "capability.denied";
let message = format!("operation requires ungranted capability {missing:?}");
tracing::warn!(
operation = %name,
code = %code,
capability = %missing,
outcome = "capability_denied",
"checkout denied by ungranted capability",
);
let outcome = ReceiptOutcome::denied(code, message.clone());
record_runtime_status(RuntimeStatusRecord {
status_sink,
receipt_hash_policy,
descriptor,
lifecycle: OperationStatusLifecycle::Denied,
input,
output: None,
code: Some(code.to_owned()),
message: Some(message.clone()),
handler_cause: None,
})?;
self.record_runtime_receipt(
descriptor,
input,
None,
outcome,
None,
ReceiptMetadata::default(),
)?;
tracing::Span::current().record("outcome", "capability_denied");
Err(RuntimeError::denied(name, code, message))
}
fn finish_handler_phase(
&self,
phase: HandlerPhase<'_>,
) -> Result<CheckoutResult, RuntimeError> {
let HandlerPhase {
name,
descriptor,
input,
handler_result,
observed_effects,
metadata,
status_sink,
receipt_hash_policy,
} = phase;
if let Some(violation) = observed_effects.first_violation_against(descriptor.effect_row()) {
tracing::warn!(
operation = %name,
code = %violation.code(),
message = %violation.message(),
outcome = "effect_denied",
"checkout denied by observed effect row",
);
let outcome = ReceiptOutcome::denied(violation.code(), violation.message());
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy,
descriptor,
lifecycle: OperationStatusLifecycle::Denied,
input,
output: None,
code: Some(violation.code().to_owned()),
message: Some(violation.message().to_owned()),
handler_cause: None,
})?;
self.record_runtime_receipt(descriptor, input, None, outcome, None, metadata)?;
tracing::Span::current().record("outcome", "effect_denied");
return Err(RuntimeError::denied(
name,
violation.code(),
violation.message(),
));
}
let output = match handler_result {
Ok(output) => output,
Err(error) => {
let cause = ReceiptSinkHandlerCause::new(error.class(), error.message());
let outcome = ReceiptOutcome::failed(cause.code(), cause.message());
tracing::warn!(
operation = %name,
code = %cause.code(),
message = %cause.message(),
outcome = "handler_failed",
"checkout failed in handler",
);
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy,
descriptor,
lifecycle: OperationStatusLifecycle::Failed,
input,
output: None,
code: Some(cause.code().to_owned()),
message: Some(cause.message().to_owned()),
handler_cause: Some(cause.clone()),
})?;
self.record_runtime_receipt(
descriptor,
input,
None,
outcome,
Some(cause.clone()),
metadata,
)?;
return Err(RuntimeError::handler(name, cause.code(), cause.message()));
}
};
let recorded_receipt = self.record_runtime_receipt(
descriptor,
input,
Some(output.as_slice()),
ReceiptOutcome::Completed,
None,
metadata,
)?;
record_runtime_status(RuntimeStatusRecord {
status_sink: status_sink.as_deref(),
receipt_hash_policy,
descriptor,
lifecycle: OperationStatusLifecycle::Completed,
input,
output: Some(output.as_slice()),
code: None,
message: None,
handler_cause: None,
})?;
let span = tracing::Span::current();
span.record("output_bytes", output.len());
span.record("outcome", "completed");
Ok(CheckoutResult {
descriptor: descriptor.clone(),
output,
recorded_receipt,
})
}
fn record_runtime_receipt(
&self,
descriptor: &operation::OperationDescriptor,
input: &[u8],
output: Option<&[u8]>,
outcome: ReceiptOutcome,
handler_cause: Option<ReceiptSinkHandlerCause>,
metadata: ReceiptMetadata,
) -> Result<Option<RecordedReceipt>, RuntimeError> {
let Some(sink) = self.receipt_sink.as_deref() else {
return Ok(None);
};
let mut envelope = receipt::ReceiptEnvelope::new(descriptor, outcome);
if let Some(hash) = self.receipt_hash_policy.hash(input) {
envelope = envelope.with_input_hash(hash);
}
if let Some(output) = output {
if let Some(hash) = self.receipt_hash_policy.hash(output) {
envelope = envelope.with_output_hash(hash);
}
}
envelope.signed_extensions.extend(metadata.signed);
envelope.local_extensions.extend(metadata.local);
sink.record_receipt(&envelope).map(Some).map_err(|error| {
let message = error.to_string();
if let Some(cause) = handler_cause {
RuntimeError::receipt_sink_after_handler_failure(descriptor.name(), message, cause)
} else {
RuntimeError::receipt_sink(descriptor.name(), message)
}
})
}
}
struct HandlerPhase<'a> {
name: &'a str,
descriptor: &'a operation::OperationDescriptor,
input: &'a [u8],
handler_result: HandlerResult,
observed_effects: &'a OperationEffectRow,
metadata: ReceiptMetadata,
status_sink: &'a Option<BoxedStatusSink>,
receipt_hash_policy: &'a ReceiptHashPolicy,
}
struct RuntimeStatusRecord<'a> {
status_sink: Option<&'a (dyn OperationStatusSink + Send + Sync)>,
receipt_hash_policy: &'a ReceiptHashPolicy,
descriptor: &'a operation::OperationDescriptor,
lifecycle: OperationStatusLifecycle,
input: &'a [u8],
output: Option<&'a [u8]>,
code: Option<String>,
message: Option<String>,
handler_cause: Option<ReceiptSinkHandlerCause>,
}
fn record_runtime_status(record: RuntimeStatusRecord<'_>) -> Result<(), RuntimeError> {
let RuntimeStatusRecord {
status_sink,
receipt_hash_policy,
descriptor,
lifecycle,
input,
output,
code,
message,
handler_cause,
} = record;
let Some(sink) = status_sink else {
return Ok(());
};
let fact = if lifecycle == OperationStatusLifecycle::Started {
OperationStatusFactV1::started(descriptor.name(), descriptor.receipt_kind())
} else {
let input_hash = receipt_hash_policy.hash(input);
let output_hash = output.and_then(|bytes| receipt_hash_policy.hash(bytes));
OperationStatusFactV1::terminal(
descriptor.name(),
lifecycle,
descriptor.receipt_kind(),
code,
message,
input_hash,
output_hash,
)
};
sink.record_fact(&fact).map_err(|error| {
let message = error.to_string();
if let Some(cause) = handler_cause {
RuntimeError::status_sink_after_handler_failure(descriptor.name(), message, cause)
} else {
RuntimeError::status_sink(descriptor.name(), message)
}
})
}
pub struct CheckoutFrame {
name: String,
input: operation::OperationInput,
}
impl CheckoutFrame {
#[must_use]
pub fn new(name: impl Into<String>, input: operation::OperationInput) -> Self {
Self {
name: name.into(),
input,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn input(&self) -> &[u8] {
&self.input
}
#[must_use]
pub fn into_parts(self) -> (String, operation::OperationInput) {
(self.name, self.input)
}
}
pub struct Checkout {
descriptor: operation::OperationDescriptor,
input: operation::OperationInput,
}
impl Checkout {
#[must_use]
pub fn new(
descriptor: operation::OperationDescriptor,
input: operation::OperationInput,
) -> Self {
Self { descriptor, input }
}
fn from_frame(descriptor: operation::OperationDescriptor, frame: CheckoutFrame) -> Self {
Self {
descriptor,
input: frame.input,
}
}
#[must_use]
pub fn descriptor(&self) -> &operation::OperationDescriptor {
&self.descriptor
}
#[must_use]
pub fn input(&self) -> &[u8] {
&self.input
}
#[must_use]
pub fn into_parts(self) -> (operation::OperationDescriptor, operation::OperationInput) {
(self.descriptor, self.input)
}
}
pub struct Ctx<'a> {
descriptor: &'a operation::OperationDescriptor,
metadata: ReceiptMetadata,
observed_effects: OperationEffectRow,
effect_backend: Option<&'a mut (dyn EffectBackend + 'static)>,
}
impl<'a> Ctx<'a> {
pub(crate) fn new(
descriptor: &'a operation::OperationDescriptor,
effect_backend: Option<&'a mut (dyn EffectBackend + 'static)>,
) -> Self {
Self {
descriptor,
metadata: ReceiptMetadata::default(),
observed_effects: OperationEffectRow::empty(),
effect_backend,
}
}
#[must_use]
pub fn descriptor(&self) -> &'a operation::OperationDescriptor {
self.descriptor
}
pub fn attach_signed_extension(&mut self, key: impl Into<String>, value: impl Into<Vec<u8>>) {
self.metadata.signed.insert(key.into(), value.into());
}
pub fn attach_local_extension(&mut self, key: impl Into<String>, value: impl Into<Vec<u8>>) {
self.metadata.local.insert(key.into(), value.into());
}
pub fn event_read_handle(&mut self) -> EventReadHandle<'_> {
EventReadHandle::new(
&mut self.observed_effects,
self.effect_backend.as_deref_mut(),
)
}
pub fn event_append_handle(&mut self) -> EventAppendHandle<'_> {
EventAppendHandle::new(
&mut self.observed_effects,
self.effect_backend.as_deref_mut(),
)
}
pub fn projection_read_handle(&mut self) -> ProjectionReadHandle<'_> {
ProjectionReadHandle::new(
&mut self.observed_effects,
self.effect_backend.as_deref_mut(),
)
}
pub fn receipt_emit_handle(&mut self) -> ReceiptEmitHandle<'_> {
ReceiptEmitHandle::new(
&mut self.observed_effects,
self.effect_backend.as_deref_mut(),
&mut self.metadata,
)
}
pub fn host_control_handle(&mut self) -> HostControlHandle<'_> {
HostControlHandle::new(
&mut self.observed_effects,
self.effect_backend.as_deref_mut(),
)
}
#[must_use]
pub fn observed_effects(&self) -> &OperationEffectRow {
&self.observed_effects
}
pub(crate) fn into_metadata(self) -> ReceiptMetadata {
self.metadata
}
}
pub struct CheckoutResult {
descriptor: operation::OperationDescriptor,
output: operation::OperationOutput,
recorded_receipt: Option<RecordedReceipt>,
}
impl CheckoutResult {
#[must_use]
pub fn descriptor(&self) -> &operation::OperationDescriptor {
&self.descriptor
}
#[must_use]
pub fn output(&self) -> &operation::OperationOutput {
&self.output
}
#[must_use]
pub fn recorded_receipt(&self) -> Option<&RecordedReceipt> {
self.recorded_receipt.as_ref()
}
#[must_use]
pub fn into_output(self) -> operation::OperationOutput {
self.output
}
}
pub trait CoreFactory: Send {
fn open_core(&mut self) -> Core;
}
impl<F> CoreFactory for F
where
F: FnMut() -> Core + Send,
{
fn open_core(&mut self) -> Core {
self()
}
}
#[cfg(test)]
mod checkout_tests {
use super::Checkout;
use crate::operation::{EffectClass, OperationDescriptor};
#[test]
fn input_exposes_the_checkout_bytes() {
let descriptor = OperationDescriptor::new(
"echo",
EffectClass::Compute,
"schema.echo.input.v1",
"schema.echo.output.v1",
"receipt.echo.v1",
);
let payload = b"real-payload".to_vec();
let checkout = Checkout::new(descriptor, payload.clone());
assert_eq!(checkout.input(), payload.as_slice());
}
}