Add //! module-level doc comment to training_uploader/src/main.rs. The other 6 crates (backtesting, ctrader-openapi, data, ml, risk, trading_engine) already had doc comments and were skipped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
274 lines
8.8 KiB
Rust
274 lines
8.8 KiB
Rust
#![allow(unused_crate_dependencies)]
|
|
|
|
//! K8s sidecar that uploads training artifacts to S3 and reports completion via gRPC.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, Result};
|
|
use bytes::Bytes;
|
|
use clap::Parser;
|
|
use object_store::aws::AmazonS3Builder;
|
|
use object_store::{Certificate, ClientOptions, ObjectStore, PutPayload};
|
|
use tracing::{error, info, warn};
|
|
|
|
mod proto {
|
|
#![allow(clippy::enum_variant_names, clippy::mixed_attributes_style)]
|
|
pub mod ml_training {
|
|
tonic::include_proto!("ml_training");
|
|
}
|
|
pub use ml_training::*;
|
|
}
|
|
|
|
/// Training uploader sidecar: watches for training completion, uploads
|
|
/// artifacts to S3, and reports results back to ml_training_service via gRPC.
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "training-uploader")]
|
|
struct Args {
|
|
/// Directory where training job writes its output artifacts
|
|
#[arg(long, default_value = "/output")]
|
|
output_dir: PathBuf,
|
|
|
|
/// S3 bucket name for artifact storage
|
|
#[arg(long, env = "S3_BUCKET")]
|
|
s3_bucket: String,
|
|
|
|
/// S3-compatible endpoint URL
|
|
#[arg(long, env = "S3_ENDPOINT")]
|
|
s3_endpoint: String,
|
|
|
|
/// S3 region
|
|
#[arg(long, env = "S3_REGION", default_value = "fr-par")]
|
|
s3_region: String,
|
|
|
|
/// S3 key prefix for this job's artifacts
|
|
#[arg(long, env = "S3_PREFIX")]
|
|
s3_prefix: String,
|
|
|
|
/// gRPC endpoint of ml_training_service for completion callback
|
|
#[arg(long, env = "CALLBACK_ENDPOINT")]
|
|
callback_endpoint: String,
|
|
|
|
/// Unique identifier for this training job
|
|
#[arg(long, env = "JOB_ID")]
|
|
job_id: String,
|
|
|
|
/// Seconds between polls for the DONE/FAILED marker file
|
|
#[arg(long, default_value = "10")]
|
|
poll_interval: u64,
|
|
|
|
/// Maximum seconds to wait before giving up
|
|
#[arg(long, default_value = "3600")]
|
|
max_wait: u64,
|
|
}
|
|
|
|
/// Poll the output directory for a `DONE` or `FAILED` marker file.
|
|
///
|
|
/// Returns `(success, status_message)`.
|
|
fn wait_for_completion(dir: &Path, poll_secs: u64, max_wait: u64) -> (bool, String) {
|
|
let start = Instant::now();
|
|
let poll = Duration::from_secs(poll_secs);
|
|
let deadline = Duration::from_secs(max_wait);
|
|
|
|
loop {
|
|
let done_path = dir.join("DONE");
|
|
let failed_path = dir.join("FAILED");
|
|
|
|
if done_path.exists() {
|
|
let msg = std::fs::read_to_string(&done_path).unwrap_or_default();
|
|
return (true, msg);
|
|
}
|
|
if failed_path.exists() {
|
|
let msg = std::fs::read_to_string(&failed_path).unwrap_or_default();
|
|
return (false, msg);
|
|
}
|
|
|
|
if start.elapsed() >= deadline {
|
|
return (
|
|
false,
|
|
format!("Timed out after {}s waiting for marker file", max_wait),
|
|
);
|
|
}
|
|
|
|
std::thread::sleep(poll);
|
|
}
|
|
}
|
|
|
|
/// Read `metrics.json` from the output directory.
|
|
///
|
|
/// Returns an empty map if the file is missing or cannot be parsed.
|
|
fn read_metrics(dir: &Path) -> HashMap<String, f64> {
|
|
let path = dir.join("metrics.json");
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
|
Err(_) => {
|
|
warn!("No metrics.json found at {}", path.display());
|
|
HashMap::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Upload all files from the output directory to S3, preserving the
|
|
/// subdirectory structure under the configured prefix.
|
|
///
|
|
/// Uses an iterative stack-based directory walk to avoid async-recursion.
|
|
async fn upload_artifacts(args: &Args) -> Result<String> {
|
|
let mut client_options = ClientOptions::new();
|
|
|
|
// Load custom CA certificate for self-signed MinIO TLS
|
|
if let Ok(ca_path) = std::env::var("MINIO_CA_CERT_PATH") {
|
|
let pem = std::fs::read(&ca_path)
|
|
.with_context(|| format!("Failed to read CA cert from {ca_path}"))?;
|
|
let cert = Certificate::from_pem(&pem)
|
|
.with_context(|| format!("Failed to parse CA cert from {ca_path}"))?;
|
|
client_options = client_options.with_root_certificate(cert);
|
|
info!("Loaded MinIO CA certificate from {}", ca_path);
|
|
}
|
|
|
|
let mut builder = AmazonS3Builder::new()
|
|
.with_bucket_name(&args.s3_bucket)
|
|
.with_endpoint(&args.s3_endpoint)
|
|
.with_region(&args.s3_region)
|
|
.with_virtual_hosted_style_request(false)
|
|
.with_allow_http(false)
|
|
.with_client_options(client_options);
|
|
|
|
// Explicitly pass credentials from env vars (MinIO has no instance metadata)
|
|
if let (Ok(key_id), Ok(secret)) = (
|
|
std::env::var("AWS_ACCESS_KEY_ID"),
|
|
std::env::var("AWS_SECRET_ACCESS_KEY"),
|
|
) {
|
|
builder = builder
|
|
.with_access_key_id(key_id)
|
|
.with_secret_access_key(secret);
|
|
} else {
|
|
warn!("AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY not set — S3 upload may fail");
|
|
}
|
|
|
|
let store = builder
|
|
.build()
|
|
.context("Failed to build S3 client")?;
|
|
|
|
let mut uploaded = 0usize;
|
|
let mut stack: Vec<(PathBuf, String)> =
|
|
vec![(args.output_dir.clone(), args.s3_prefix.clone())];
|
|
|
|
while let Some((local_dir, prefix)) = stack.pop() {
|
|
let entries = std::fs::read_dir(&local_dir)
|
|
.with_context(|| format!("Failed to read dir {}", local_dir.display()))?;
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
|
|
if path.is_dir() {
|
|
stack.push((path, format!("{}/{}", prefix, name)));
|
|
} else {
|
|
let s3_key = format!("{}/{}", prefix, name);
|
|
let data = std::fs::read(&path)
|
|
.with_context(|| format!("Failed to read {}", path.display()))?;
|
|
let s3_path = object_store::path::Path::from(s3_key.as_str());
|
|
store
|
|
.put(&s3_path, PutPayload::from(Bytes::from(data)))
|
|
.await
|
|
.with_context(|| format!("Failed to upload {}", s3_key))?;
|
|
uploaded += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("Uploaded {} files to S3", uploaded);
|
|
Ok(args.s3_prefix.clone())
|
|
}
|
|
|
|
/// Send a `JobCompletionReport` to the ml_training_service via gRPC.
|
|
async fn report_completion(
|
|
args: &Args,
|
|
success: bool,
|
|
s3_path: &str,
|
|
error_message: &str,
|
|
metrics: HashMap<String, f64>,
|
|
) -> Result<()> {
|
|
let mut client =
|
|
proto::ml_training_service_client::MlTrainingServiceClient::connect(
|
|
args.callback_endpoint.clone(),
|
|
)
|
|
.await
|
|
.context("Failed to connect to ml_training_service")?;
|
|
|
|
let report = proto::JobCompletionReport {
|
|
job_id: args.job_id.clone(),
|
|
s3_path: s3_path.to_owned(),
|
|
success,
|
|
error_message: error_message.to_owned(),
|
|
metrics,
|
|
};
|
|
|
|
let resp = client
|
|
.report_job_completion(report)
|
|
.await
|
|
.context("ReportJobCompletion RPC failed")?;
|
|
|
|
let ack = resp.into_inner();
|
|
info!(
|
|
accepted = ack.accepted,
|
|
promotion_status = %ack.promotion_status,
|
|
"Completion report acknowledged"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
)
|
|
.json()
|
|
.init();
|
|
|
|
let args = Args::parse();
|
|
info!(job_id = %args.job_id, output_dir = %args.output_dir.display(), "Training uploader started");
|
|
|
|
// Phase 1: Wait for training container to finish
|
|
info!("Waiting for DONE or FAILED marker in {}", args.output_dir.display());
|
|
let (success, status_msg) =
|
|
wait_for_completion(&args.output_dir, args.poll_interval, args.max_wait);
|
|
|
|
if success {
|
|
info!("Training completed successfully: {}", status_msg);
|
|
} else {
|
|
error!("Training failed or timed out: {}", status_msg);
|
|
}
|
|
|
|
// Phase 2: Read metrics
|
|
let metrics = read_metrics(&args.output_dir);
|
|
if !metrics.is_empty() {
|
|
info!(count = metrics.len(), "Read training metrics");
|
|
}
|
|
|
|
// Phase 3: Upload artifacts to S3
|
|
let (s3_path, error_message) = match upload_artifacts(&args).await {
|
|
Ok(path) => (path, if success { String::new() } else { status_msg }),
|
|
Err(e) => {
|
|
error!("S3 upload failed: {:#}", e);
|
|
(
|
|
String::new(),
|
|
format!("S3 upload failed: {e:#}; original status: {status_msg}"),
|
|
)
|
|
}
|
|
};
|
|
|
|
// Phase 4: Report completion via gRPC
|
|
if let Err(e) = report_completion(&args, success, &s3_path, &error_message, metrics).await {
|
|
error!("Failed to report completion: {:#}", e);
|
|
return Err(e);
|
|
}
|
|
|
|
info!(job_id = %args.job_id, "Uploader finished, exiting");
|
|
Ok(())
|
|
}
|