Files
foxhunt/services/data_acquisition_service/src/service.rs
jgrusewski 5fe3608d92 fix(fxt,infra): production hardening — OTLP telemetry, TUI fixes, K8s infra
- Remove opentelemetry-otlp internal-logs feature (OTLP feedback loop)
- Switch trace sampling from AlwaysOn to 10% ratio-based
- Add RUST_LOG filtering (opentelemetry/h2/tonic/hyper=warn) to all 8 services
- Wire per-service latency measurement via health check → proto metadata → TUI
- Replace Vec::remove(0) with VecDeque ring buffers (O(1) vs O(n))
- Add Arc<AtomicBool> connected_sent for first-connected detection across 12 streams
- Add MAX_RECONNECT_ATTEMPTS (10) uniformly to all stream spawners
- Change kill switch/circuit breaker fields to Option types with N/A display
- Wire data_cache to real download status stream, remove dead cluster_events
- Remove ServiceData::new() hardcoded stubs, add honest placeholders
- Fix nanos_to_hms zero/negative guard, total_records semantic fix
- Fix RwLock held across yield in broker_gateway stream_account_state
- Add break after yield Err in broker/trading stream generators
- Fix connected_at advancing per tick in stream_session_status
- Tempo: replace emptyDir with 10Gi PVC, bump memory to 512Mi/2Gi
- Remove Prometheus gitlab-annotated-pods duplicate scrape job
- Wire 6 new gRPC streaming adapters (risk, trading, ml, data-acquisition)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:39:56 +01:00

374 lines
14 KiB
Rust

//! gRPC service implementation for Data Acquisition Service
//!
//! Implements the DataAcquisitionService proto interface with:
//! - Download job scheduling and management
//! - Integration with Databento downloader
//! - Data validation pipeline
//! - Automatic MinIO upload
//! - Job persistence and status tracking
use crate::downloader::{DatabentoDownloader, DatabentoDownloaderConfig};
use crate::proto::data_acquisition_service_server::DataAcquisitionService;
use crate::proto::*;
use crate::uploader::{MinIOUploader, MinIOUploaderConfig};
use crate::validator::{DataValidator, ValidationConfig};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use futures::Stream;
use tokio::sync::RwLock;
use tonic::{Request, Response, Status};
use uuid::Uuid;
use tracing::instrument;
/// In-memory job store backed by a `HashMap<String, DownloadJobDetails>`.
///
/// Acceptable for the current MVP: the data-acquisition service manages a small
/// number of concurrent download jobs (typically <100) that are short-lived.
/// Jobs are created, run, and either complete or fail within minutes. In-memory
/// storage keeps the service dependency-free (no PostgreSQL / SQLite required)
/// and simplifies deployment. If durability across restarts becomes a requirement
/// (e.g., multi-hour downloads or job auditing), migrate to the shared PostgreSQL
/// instance used by other services.
type JobStore = Arc<RwLock<std::collections::HashMap<String, DownloadJobDetails>>>;
/// Data Acquisition Service implementation
pub struct DataAcquisitionServiceImpl {
downloader: Arc<DatabentoDownloader>,
_uploader: Arc<MinIOUploader>,
_validator: Arc<DataValidator>,
job_store: JobStore,
}
impl DataAcquisitionServiceImpl {
/// Create new service instance
pub fn new() -> Self {
Self {
downloader: Arc::new(DatabentoDownloader::new(
DatabentoDownloaderConfig::default(),
)),
_uploader: Arc::new(MinIOUploader::new(MinIOUploaderConfig::default())),
_validator: Arc::new(DataValidator::new(ValidationConfig::default())),
job_store: Arc::new(RwLock::new(std::collections::HashMap::new())),
}
}
/// Create service with custom configuration
pub fn with_config(
downloader_config: DatabentoDownloaderConfig,
uploader_config: MinIOUploaderConfig,
validator_config: ValidationConfig,
) -> Self {
Self {
downloader: Arc::new(DatabentoDownloader::new(downloader_config)),
_uploader: Arc::new(MinIOUploader::new(uploader_config)),
_validator: Arc::new(DataValidator::new(validator_config)),
job_store: Arc::new(RwLock::new(std::collections::HashMap::new())),
}
}
}
impl Default for DataAcquisitionServiceImpl {
fn default() -> Self {
Self::new()
}
}
#[tonic::async_trait]
impl DataAcquisitionService for DataAcquisitionServiceImpl {
#[instrument(skip_all)]
async fn schedule_download(
&self,
request: Request<ScheduleDownloadRequest>,
) -> Result<Response<ScheduleDownloadResponse>, Status> {
let req = request.into_inner();
let job_id = Uuid::new_v4();
// Estimate cost
let estimated_cost = self
.downloader
.estimate_cost(&req.dataset, &req.symbols, &req.start_date, &req.end_date)
.await
.map_err(|e| Status::internal(format!("Cost estimation failed: {}", e)))?;
// Create job details
let job_details = DownloadJobDetails {
job_id: job_id.to_string(),
status: DownloadStatus::Pending as i32,
dataset: req.dataset.clone(),
symbols: req.symbols.clone(),
start_date: req.start_date.clone(),
end_date: req.end_date.clone(),
schema: req.schema.clone(),
description: req.description.clone(),
tags: req.tags.clone(),
priority: req.priority,
progress_percentage: 0.0,
bytes_downloaded: 0,
total_bytes: 0,
estimated_cost_usd: estimated_cost,
actual_cost_usd: 0.0,
records_count: 0,
invalid_records: 0,
data_quality_score: 0.0,
local_path: String::new(),
minio_path: String::new(),
created_at: chrono::Utc::now().timestamp(),
started_at: 0,
completed_at: 0,
error_message: String::new(),
retry_count: 0,
created_by: "system".to_string(),
};
// Store job
{
let mut store = self.job_store.write().await;
store.insert(job_id.to_string(), job_details.clone());
}
// Spawn background download task
{
let job_store = Arc::clone(&self.job_store);
let downloader = Arc::clone(&self.downloader);
let job_id_str = job_id.to_string();
let download_job = crate::downloader::DownloadJob {
job_id,
dataset: req.dataset.clone(),
symbols: req.symbols.clone(),
start_date: req.start_date.clone(),
end_date: req.end_date.clone(),
schema: req.schema.clone(),
priority: req.priority as i32,
};
tokio::spawn(async move {
// Mark as downloading
{
let mut store = job_store.write().await;
if let Some(job) = store.get_mut(&job_id_str) {
job.status = DownloadStatus::Downloading as i32;
job.started_at = chrono::Utc::now().timestamp();
}
}
match downloader.download(&download_job).await {
Ok(path) => {
let mut store = job_store.write().await;
if let Some(job) = store.get_mut(&job_id_str) {
job.status = DownloadStatus::Completed as i32;
job.progress_percentage = 100.0;
job.completed_at = chrono::Utc::now().timestamp();
job.local_path = path.to_string_lossy().to_string();
}
}
Err(e) => {
let mut store = job_store.write().await;
if let Some(job) = store.get_mut(&job_id_str) {
job.status = DownloadStatus::Failed as i32;
job.completed_at = chrono::Utc::now().timestamp();
job.error_message = e.to_string();
job.retry_count += 1;
}
}
}
});
}
Ok(Response::new(ScheduleDownloadResponse {
job_id: job_id.to_string(),
status: DownloadStatus::Pending as i32,
estimated_cost_usd: estimated_cost,
message: format!("Download scheduled for {} symbols", req.symbols.len()),
}))
}
#[instrument(skip_all)]
async fn get_download_status(
&self,
request: Request<GetDownloadStatusRequest>,
) -> Result<Response<GetDownloadStatusResponse>, Status> {
let req = request.into_inner();
let store = self.job_store.read().await;
let job_details = store
.get(&req.job_id)
.ok_or_else(|| Status::not_found(format!("Job not found: {}", req.job_id)))?
.clone();
Ok(Response::new(GetDownloadStatusResponse {
job_details: Some(job_details),
}))
}
#[instrument(skip_all)]
async fn cancel_download(
&self,
request: Request<CancelDownloadRequest>,
) -> Result<Response<CancelDownloadResponse>, Status> {
let req = request.into_inner();
let job_id = Uuid::parse_str(&req.job_id)
.map_err(|e| Status::invalid_argument(format!("Invalid job ID: {}", e)))?;
self.downloader
.cancel(job_id)
.await
.map_err(|e| Status::internal(format!("Cancel failed: {}", e)))?;
// Update job status
{
let mut store = self.job_store.write().await;
if let Some(job) = store.get_mut(&req.job_id) {
job.status = DownloadStatus::Cancelled as i32;
job.error_message = req.reason.clone();
}
}
Ok(Response::new(CancelDownloadResponse {
success: true,
message: "Download cancelled successfully".to_string(),
}))
}
#[instrument(skip_all)]
async fn list_download_jobs(
&self,
request: Request<ListDownloadJobsRequest>,
) -> Result<Response<ListDownloadJobsResponse>, Status> {
let req = request.into_inner();
let store = self.job_store.read().await;
let mut jobs: Vec<_> = store.values().cloned().collect();
// Apply status filter
if req.status_filter != 0 {
jobs.retain(|j| j.status == req.status_filter);
}
// Apply time filter
if req.start_time > 0 {
jobs.retain(|j| j.created_at >= req.start_time);
}
if req.end_time > 0 {
jobs.retain(|j| j.created_at <= req.end_time);
}
// Pagination
let page = req.page.max(1) as usize;
let page_size = req.page_size.clamp(1, 100) as usize;
let start_idx = (page - 1) * page_size;
let _end_idx = start_idx + page_size;
let total_count = jobs.len() as u32;
let paginated_jobs = jobs.into_iter().skip(start_idx).take(page_size);
let job_summaries: Vec<DownloadJobSummary> = paginated_jobs
.map(|job| DownloadJobSummary {
job_id: job.job_id,
status: job.status,
dataset: job.dataset,
symbols: job.symbols,
start_date: job.start_date,
end_date: job.end_date,
progress_percentage: job.progress_percentage,
actual_cost_usd: job.actual_cost_usd,
created_at: job.created_at,
completed_at: job.completed_at,
})
.collect();
Ok(Response::new(ListDownloadJobsResponse {
jobs: job_summaries,
total_count,
page: req.page,
page_size: req.page_size,
}))
}
#[instrument(skip_all)]
async fn health_check(
&self,
_request: Request<HealthCheckRequest>,
) -> Result<Response<HealthCheckResponse>, Status> {
let mut details = std::collections::HashMap::new();
details.insert("service".to_string(), "data_acquisition".to_string());
details.insert("version".to_string(), env!("CARGO_PKG_VERSION").to_string());
// Check job store accessibility
let store = self.job_store.read().await;
let total_jobs = store.len();
let active_jobs = store
.values()
.filter(|j| j.status == DownloadStatus::Downloading as i32)
.count();
let failed_jobs = store
.values()
.filter(|j| j.status == DownloadStatus::Failed as i32)
.count();
drop(store);
// Check downloader active count
let active_downloads = self.downloader.active_download_count().await;
details.insert("total_jobs".to_string(), total_jobs.to_string());
details.insert("active_jobs".to_string(), active_jobs.to_string());
details.insert("failed_jobs".to_string(), failed_jobs.to_string());
details.insert(
"active_downloads".to_string(),
active_downloads.to_string(),
);
details.insert("status".to_string(), "operational".to_string());
Ok(Response::new(HealthCheckResponse {
healthy: true,
message: format!(
"Service is healthy: {} active downloads, {} total jobs",
active_downloads, total_jobs
),
details,
}))
}
type StreamDownloadStatusStream =
Pin<Box<dyn Stream<Item = Result<ListDownloadJobsResponse, Status>> + Send>>;
async fn stream_download_status(
&self,
request: Request<StreamDownloadStatusRequest>,
) -> Result<Response<Self::StreamDownloadStatusStream>, Status> {
let req = request.into_inner();
let interval_secs = if req.interval_seconds == 0 { 5 } else { req.interval_seconds };
let store = self.job_store.clone();
let stream = async_stream::stream! {
let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs)));
loop {
interval.tick().await;
let jobs: Vec<DownloadJobSummary> = store.read().await.values().map(|job| DownloadJobSummary {
job_id: job.job_id.clone(),
status: job.status,
dataset: job.dataset.clone(),
symbols: job.symbols.clone(),
start_date: job.start_date.clone(),
end_date: job.end_date.clone(),
progress_percentage: job.progress_percentage,
actual_cost_usd: job.actual_cost_usd,
created_at: job.created_at,
completed_at: job.completed_at,
}).collect();
#[allow(clippy::cast_possible_truncation)]
let total = jobs.len() as u32;
yield Ok(ListDownloadJobsResponse {
jobs,
total_count: total,
page: 1,
page_size: total,
});
}
};
Ok(Response::new(Box::pin(stream)))
}
}