use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Instant;
use bytes::Bytes;
use serde_json::json;
use tokio::sync::mpsc;
use helix_core::effect::{
FileUploadProgress, FileUploadProgressPolicy, FileUploadRequest, HttpRequest, StorageOp,
};
use helix_core::ports::{FileUploadProgressReporter, FileUploader, HttpRequester, Storage};
use helix_core::tick::{PortOutcome, ReplyBytes};
use helix_core::{Correlation, Tick};
use crate::http_envelope::run_http_envelope;
use crate::metrics::{AsyncMetricSink, NoopMetricSink};
use crate::ops::classify_port_error;
use crate::spawner::{BoundedSpawner, FeedbackSender, FeedbackSink, Overflow};
use crate::storage::{
with_storage_operation_context, with_storage_trace_context, StorageOperationContext,
StorageTraceContext,
};
use crate::{execute_storage_ops, execute_storage_ops_atomic, PERSIST_QUEUE_CAP};
use crate::HTTP_QUEUE_CAP;
fn safe_http_target(url: &str) -> &str {
url.split(['?', '#']).next().unwrap_or(url)
}
fn http_status(outcome: &PortOutcome) -> Option<u64> {
let PortOutcome::Ok(reply) = outcome else {
return None;
};
serde_json::from_slice::<serde_json::Value>(&reply.0)
.ok()
.and_then(|value| value.get("status").and_then(serde_json::Value::as_u64))
}
pub struct PersistJobPayload {
pub ops: Vec<StorageOp>,
pub atomic: bool,
pub trace: Option<StorageTraceContext>,
}
struct UploadProgressTickReporter {
corr: Correlation,
percent_step: u8,
last_emitted_percent: AtomicU8,
reply_tx: FeedbackSink,
}
impl UploadProgressTickReporter {
fn new(corr: Correlation, percent_step: u8, reply_tx: FeedbackSink) -> Option<Self> {
(1..=100).contains(&percent_step).then_some(Self {
corr,
percent_step,
last_emitted_percent: AtomicU8::new(0),
reply_tx,
})
}
}
impl FileUploadProgressReporter for UploadProgressTickReporter {
fn report(&self, progress: FileUploadProgress) {
if progress.total_bytes == 0 || progress.completed_bytes > progress.total_bytes {
return;
}
let raw_percent =
((u128::from(progress.completed_bytes) * 100) / u128::from(progress.total_bytes)) as u8;
let bucket = if raw_percent == 100 {
100
} else {
(raw_percent / self.percent_step) * self.percent_step
};
if bucket == 0 {
return;
}
let mut seen = self.last_emitted_percent.load(Ordering::Relaxed);
while bucket > seen {
match self.last_emitted_percent.compare_exchange_weak(
seen,
bucket,
Ordering::AcqRel,
Ordering::Relaxed,
) {
Ok(_) => {
self.reply_tx.send(Tick::PortProgress {
corr: self.corr,
progress,
});
return;
}
Err(actual) => seen = actual,
}
}
}
}
pub(crate) fn get_or_spawn_persist<S>(
workers: &mut HashMap<String, BoundedSpawner<PersistJobPayload>>,
table_key: String,
storage: Arc<S>,
reply_tx: FeedbackSink,
metrics: Arc<dyn AsyncMetricSink>,
) -> &BoundedSpawner<PersistJobPayload>
where
S: Storage + Send + Sync + 'static,
{
workers.entry(table_key).or_insert_with(|| {
let storage = Arc::clone(&storage);
BoundedSpawner::new_with_feedback(
1, PERSIST_QUEUE_CAP,
Overflow::Block,
reply_tx,
metrics,
"persist",
move |corr, payload: PersistJobPayload| {
let storage = Arc::clone(&storage);
async move {
let PersistJobPayload { ops, atomic, trace } = payload;
let execute = async {
if atomic {
execute_storage_ops_atomic(storage.as_ref(), &ops).await
} else {
execute_storage_ops(storage.as_ref(), &ops).await
}
};
let operation_context = StorageOperationContext::new(corr);
with_storage_operation_context(operation_context, async move {
match trace {
Some(trace) => with_storage_trace_context(trace, execute).await,
None => execute.await,
}
})
.await
}
},
)
})
}
pub fn spawn_http_pool<H>(
concurrency: usize,
http: Arc<H>,
reply_tx: mpsc::UnboundedSender<Tick>,
overflow: Overflow,
) -> BoundedSpawner<HttpRequest>
where
H: HttpRequester + Send + Sync + 'static,
{
spawn_http_pool_with_feedback(
concurrency,
http,
FeedbackSink::Raw(reply_tx),
overflow,
Arc::new(NoopMetricSink),
)
}
pub(crate) fn spawn_http_pool_observed<H>(
concurrency: usize,
http: Arc<H>,
reply_tx: FeedbackSender,
overflow: Overflow,
metrics: Arc<dyn AsyncMetricSink>,
) -> BoundedSpawner<HttpRequest>
where
H: HttpRequester + Send + Sync + 'static,
{
spawn_http_pool_with_feedback(
concurrency,
http,
FeedbackSink::Stamped(reply_tx),
overflow,
metrics,
)
}
fn spawn_http_pool_with_feedback<H>(
concurrency: usize,
http: Arc<H>,
feedback: FeedbackSink,
overflow: Overflow,
metrics: Arc<dyn AsyncMetricSink>,
) -> BoundedSpawner<HttpRequest>
where
H: HttpRequester + Send + Sync + 'static,
{
let pool = match overflow {
Overflow::Block => "http",
Overflow::DropNewest => "http_fire",
};
BoundedSpawner::new_with_feedback(
concurrency,
HTTP_QUEUE_CAP,
overflow,
feedback,
metrics,
pool,
move |corr, req: HttpRequest| {
let http = Arc::clone(&http);
async move {
let started_at = Instant::now();
let method = req.method.clone();
let target = safe_http_target(&req.url).to_string();
tracing::info!(
hop = "host.http.dispatch",
corr = corr.map(Correlation::raw),
method,
target,
"HTTP effect entered the host worker"
);
let outcome = run_http_envelope(http.as_ref(), req).await;
let outcome_class = if matches!(outcome, PortOutcome::Ok(_)) {
"ok"
} else {
"err"
};
tracing::info!(
hop = "host.http.complete",
corr = corr.map(Correlation::raw),
method,
target,
status = http_status(&outcome),
outcome = outcome_class,
elapsed_ms = started_at.elapsed().as_millis() as u64,
"HTTP effect completed without exposing headers or body"
);
outcome
}
},
)
}
pub fn spawn_upload_pool<U>(
concurrency: usize,
uploader: Arc<U>,
reply_tx: mpsc::UnboundedSender<Tick>,
overflow: Overflow,
) -> BoundedSpawner<FileUploadRequest>
where
U: FileUploader + Send + Sync + 'static,
{
spawn_upload_pool_with_feedback(
concurrency,
uploader,
FeedbackSink::Raw(reply_tx),
overflow,
Arc::new(NoopMetricSink),
)
}
pub(crate) fn spawn_upload_pool_observed<U>(
concurrency: usize,
uploader: Arc<U>,
reply_tx: FeedbackSender,
overflow: Overflow,
metrics: Arc<dyn AsyncMetricSink>,
) -> BoundedSpawner<FileUploadRequest>
where
U: FileUploader + Send + Sync + 'static,
{
spawn_upload_pool_with_feedback(
concurrency,
uploader,
FeedbackSink::Stamped(reply_tx.clone()),
overflow,
metrics,
)
}
fn spawn_upload_pool_with_feedback<U>(
concurrency: usize,
uploader: Arc<U>,
feedback: FeedbackSink,
overflow: Overflow,
metrics: Arc<dyn AsyncMetricSink>,
) -> BoundedSpawner<FileUploadRequest>
where
U: FileUploader + Send + Sync + 'static,
{
let progress_reply_tx = feedback.clone();
BoundedSpawner::new_with_feedback(
concurrency,
HTTP_QUEUE_CAP,
overflow,
feedback,
metrics,
"upload",
move |corr, req: FileUploadRequest| {
let uploader = Arc::clone(&uploader);
let progress_reply_tx = progress_reply_tx.clone();
async move {
let correlation = corr.map(Correlation::raw).unwrap_or_default();
tracing::info!(
hop = "host.upload.dispatch",
corr = correlation,
object_key = req.object_key.as_str(),
size = req.size.unwrap_or_default(),
"OSS upload dispatched without exposing its signed URL"
);
let progress = match (corr, req.progress_policy()) {
(Some(corr), FileUploadProgressPolicy::PercentStep(step)) => {
UploadProgressTickReporter::new(corr, step, progress_reply_tx).map(
|reporter| Arc::new(reporter) as Arc<dyn FileUploadProgressReporter>,
)
}
_ => None,
};
match uploader.upload_with_progress(req, progress).await {
Ok(resp) => {
tracing::info!(
hop = "host.upload.complete",
corr = correlation,
outcome = "ok",
object_key = resp.object_key.as_str(),
"OSS upload completed"
);
let bytes = serde_json::to_vec(&json!({
"objectKey": resp.object_key,
"url": resp.public_url,
"etag": resp.etag,
}))
.expect("upload response JSON serialization cannot fail");
PortOutcome::Ok(ReplyBytes(Bytes::from(bytes)))
}
Err(e) => {
tracing::warn!(
hop = "host.upload.complete",
corr = correlation,
outcome = "err",
"OSS upload failed without exposing its signed URL"
);
PortOutcome::Err(classify_port_error(e))
}
}
}
},
)
}