use std::sync::Arc;
use aion_core::{ContentType, Payload, WorkflowId};
use beamr::native::ProcessContext;
use beamr::term::Term;
use beamr::term::binary_ref::BinaryRef;
use beamr::term::heap_borrow::HeapBorrow;
use chrono::Utc;
use crate::durability::{Command, CorrelationKey, Resolution, ResolveOutcome};
use crate::error::EngineError;
use crate::runtime::nif_child_engine::ChildNifBridge;
use crate::runtime::nif_context::{NifContext, NifContextError};
use crate::runtime::nif_result_term::{NifRefusal, error_result_term, ok_result_term};
use crate::runtime::nif_state::EngineNifState;
use crate::workloop::hatch::derive_identity;
pub(super) fn hatch_detached_impl(args: &[Term], ctx: &mut ProcessContext) -> Result<Term, Term> {
match run_hatch(args, ctx) {
Ok(term) => Ok(term),
Err(refusal) => refusal.into_nif_result(),
}
}
fn run_hatch(args: &[Term], ctx: &mut ProcessContext) -> Result<Term, NifRefusal> {
require_arity("hatch_detached", args, 3).map_err(|message| hatch_refusal(ctx, &message))?;
let workflow_type = decode_string_arg(args[0], ctx.borrow_terms())
.map_err(|error| format!("workflow_type:{error}"))
.map_err(|message| hatch_refusal(ctx, &message))?;
let input_text = decode_string_arg(args[1], ctx.borrow_terms())
.map_err(|error| format!("input:{error}"))
.map_err(|message| hatch_refusal(ctx, &message))?;
let hatch_key = decode_string_arg(args[2], ctx.borrow_terms())
.map_err(|error| format!("key:{error}"))
.map_err(|message| hatch_refusal(ctx, &message))?;
let input = Payload::new(ContentType::Json, input_text.into_bytes());
let bridge = hatch_bridge(ctx).map_err(|message| hatch_refusal(ctx, &message))?;
let pid = ctx
.pid()
.ok_or_else(|| "missing_caller_pid".to_owned())
.map_err(|message| hatch_refusal(ctx, &message))?;
let state = crate::runtime::nif_state::engine_nif_state(ctx)
.map_err(|message| hatch_refusal(ctx, &message))?;
crate::runtime::nif_query_pump::ensure_not_servicing_query(&state, pid, "hatch_detached")
.map_err(|message| hatch_refusal(ctx, &message))?;
let nif = new_context(&bridge, pid).map_err(|message| hatch_refusal(ctx, &message))?;
let key = CorrelationKey::Hatch(nif.next_hatch_ordinal());
let command = Command::HatchWorkflow {
key,
workflow_type: workflow_type.clone(),
hatch_key: hatch_key.clone(),
input: input.clone(),
};
resolve_hatch(ctx, &bridge, nif, workflow_type, hatch_key, input, command)
}
fn resolve_hatch(
ctx: &mut ProcessContext,
bridge: &Arc<ChildNifBridge>,
mut nif: NifContext,
workflow_type: String,
hatch_key: String,
input: Payload,
command: Command,
) -> Result<Term, NifRefusal> {
match nif
.resolve_command_observed(command)
.map_err(|error| hatch_refusal(ctx, &context_error(&error)))?
{
ResolveOutcome::Recorded(Resolution::Hatched(hatched_id)) => {
ok_result_term(ctx, hatched_id.to_string().as_bytes()).map_err(NifRefusal::Unbuildable)
}
ResolveOutcome::Recorded(other) => Err(hatch_refusal(
ctx,
&format!("unexpected_hatch_resolution:{other:?}"),
)),
ResolveOutcome::ResumeLive => {
let namespace = nif.workflow_handle().namespace().to_owned();
let package_version = bridge
.routed_package_version(&workflow_type)
.map_err(|error| hatch_refusal(ctx, &format!("hatch_version_resolution:{error}")))?
.ok_or_else(|| {
hatch_refusal(
ctx,
&format!("hatch_workflow_type_not_loaded:{workflow_type}"),
)
})?;
let hatch_id = derive_identity(&namespace, &workflow_type, &hatch_key)
.map_err(|error| hatch_refusal(ctx, &format!("identity_refused:{error}")))?;
live_hatch(
ctx,
bridge,
&mut nif,
LiveHatch {
namespace,
workflow_type,
hatch_key,
hatch_id,
input,
package_version,
},
)
}
}
}
struct LiveHatch {
namespace: String,
workflow_type: String,
hatch_key: String,
hatch_id: WorkflowId,
input: Payload,
package_version: aion_core::PackageVersion,
}
fn live_hatch(
ctx: &mut ProcessContext,
bridge: &Arc<ChildNifBridge>,
nif: &mut NifContext,
hatch: LiveHatch,
) -> Result<Term, NifRefusal> {
let LiveHatch {
namespace,
workflow_type,
hatch_key,
hatch_id,
input,
package_version,
} = hatch;
let already_hatched = !bridge
.tokio_handle()
.block_on(bridge.store().read_history(&hatch_id))
.map_err(|error| hatch_refusal(ctx, &format!("store:{error}")))?
.is_empty();
if !already_hatched {
match bridge
.tokio_handle()
.block_on(bridge.start_hatched_under_recorded_id(
&namespace,
&workflow_type,
hatch_id.clone(),
input,
package_version,
)) {
Ok(_handle) => {}
Err(EngineError::Store(aion_store::StoreError::SequenceConflict { .. })) => {
tracing::info!(
hatching_workflow_id = %nif.workflow_id(),
hatched_workflow_id = %hatch_id,
workflow_type = %workflow_type,
"hatch dedupe race: a concurrent hatch of this identity won the first \
append, so this call resolves to the existing workflow. This \
SequenceConflict is EXPECTED and absorbed here — it is the dedupe index \
doing its job, not the double-writer signal it means everywhere else"
);
}
Err(EngineError::WorkflowIdAlreadyLive { .. }) => {
tracing::info!(
hatching_workflow_id = %nif.workflow_id(),
hatched_workflow_id = %hatch_id,
workflow_type = %workflow_type,
"hatch dedupe race: a concurrent hatch of this identity is already live, so \
this call resolves to the existing workflow"
);
}
Err(error) => {
tracing::warn!(
hatching_workflow_id = %nif.workflow_id(),
hatched_workflow_id = %hatch_id,
workflow_type = %workflow_type,
error = %error,
"hatch failed to start; nothing was recorded, so the hatching run's \
history is unchanged and a retry re-attempts the start"
);
return Err(hatch_refusal(ctx, &format!("hatch_start_failed:{error}")));
}
}
}
let recorded_at = Utc::now();
let recorded_id = hatch_id.clone();
let recorded_key = hatch_key.clone();
nif.block_on_recorder(move |recorder| {
Box::pin(async move {
recorder
.record_workflow_hatched(recorded_at, recorded_id, recorded_key)
.await
})
})
.map_err(|error| hatch_refusal(ctx, &context_error(&error)))?;
nif.observe_recorded_at(recorded_at);
ok_result_term(ctx, hatch_id.to_string().as_bytes()).map_err(NifRefusal::Unbuildable)
}
fn hatch_refusal(ctx: &mut ProcessContext, message: &str) -> NifRefusal {
NifRefusal::reported(error_result_term(ctx, &format!("hatch_detached:{message}")))
}
fn hatch_bridge(ctx: &ProcessContext) -> Result<Arc<ChildNifBridge>, String> {
let state = crate::runtime::nif_state::engine_nif_state(ctx)?;
hatch_bridge_from_state(&state)
}
fn hatch_bridge_from_state(state: &EngineNifState) -> Result<Arc<ChildNifBridge>, String> {
let slot = match state.child_bridge.read() {
Ok(slot) => slot.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
};
slot.ok_or_else(|| "no_engine_nif_bridge_configured".to_owned())
}
fn new_context(bridge: &ChildNifBridge, pid: u64) -> Result<NifContext, String> {
NifContext::new_with_history_store(
pid,
bridge.registry(),
bridge.tokio_handle(),
Some(bridge.store()),
bridge.watch_backoff(),
)
.map_err(|error| context_error(&error))
}
fn context_error(error: &NifContextError) -> String {
error.error_reason()
}
fn decode_string_arg(term: Term, heap: HeapBorrow<'_>) -> Result<String, String> {
let bin = BinaryRef::new(term).ok_or_else(|| "argument is not a binary".to_owned())?;
String::from_utf8(bin.as_bytes(heap).to_vec())
.map_err(|_| "argument is not valid UTF-8".to_owned())
}
fn require_arity(name: &str, args: &[Term], expected: usize) -> Result<(), String> {
if args.len() == expected {
Ok(())
} else {
Err(format!(
"{name}: expected {expected} arguments, got {}",
args.len()
))
}
}