feat(serving): wire list_pending_promotions to PromotionManager

Replace the stub list_pending_promotions gRPC handler with a real
implementation that queries PromotionManager::list_pending() and maps
each PendingModel to the proto PendingPromotion message. Adds a unit
test verifying the field mapping from domain type to proto type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-27 23:02:02 +01:00
parent 8324b98f72
commit 1b4bfb9d5b

View File

@@ -35,7 +35,7 @@ use proto::{
TrainingStatusUpdate as ProtoStatusUpdate,
// Job completion & model promotion (on-demand training pipeline)
JobCompletionReport, JobCompletionAck,
ListPendingPromotionsRequest, ListPendingPromotionsResponse,
ListPendingPromotionsRequest, ListPendingPromotionsResponse, PendingPromotion,
ApprovePromotionRequest, ApprovePromotionResponse,
RejectPromotionRequest, RejectPromotionResponse,
};
@@ -1234,10 +1234,24 @@ impl MlTrainingService for MLTrainingServiceImpl {
&self,
_request: Request<ListPendingPromotionsRequest>,
) -> Result<Response<ListPendingPromotionsResponse>, Status> {
// TODO(task-6): query promotion store
Ok(Response::new(ListPendingPromotionsResponse {
promotions: vec![],
}))
let pending = self.promotion_manager.list_pending().await;
info!(count = pending.len(), "listing pending promotions");
let promotions = pending
.into_iter()
.map(|m| PendingPromotion {
model_id: m.model_id,
model_type: m.model_type,
symbol: m.symbol,
s3_path: m.s3_path,
new_metrics: m.new_metrics.into_iter().collect(),
current_metrics: m.current_metrics.into_iter().collect(),
trained_at: m.trained_at.timestamp(),
job_id: m.job_id,
})
.collect();
Ok(Response::new(ListPendingPromotionsResponse { promotions }))
}
async fn approve_promotion(
@@ -1757,4 +1771,50 @@ mod tests {
crate::promotion_manager::PromotionStatus::Registered
);
}
#[test]
fn test_list_pending_promotions_maps_to_proto() {
// Verify that PendingModel fields map correctly to proto PendingPromotion
use crate::promotion_manager::PendingModel;
use chrono::Utc;
let now = Utc::now();
let model = PendingModel {
model_id: "m-123".to_string(),
model_type: "DQN".to_string(),
symbol: "ES.FUT".to_string(),
s3_path: "s3://bucket/better.bin".to_string(),
new_metrics: HashMap::from([
("best_val_loss".to_string(), 0.03),
("sharpe_ratio".to_string(), 2.5),
]),
current_metrics: HashMap::from([
("best_val_loss".to_string(), 0.10),
("sharpe_ratio".to_string(), 1.0),
]),
trained_at: now,
job_id: "job-99".to_string(),
};
// Map to proto the same way the handler does
let proto = super::proto::PendingPromotion {
model_id: model.model_id.clone(),
model_type: model.model_type.clone(),
symbol: model.symbol.clone(),
s3_path: model.s3_path.clone(),
new_metrics: model.new_metrics.clone().into_iter().collect(),
current_metrics: model.current_metrics.clone().into_iter().collect(),
trained_at: model.trained_at.timestamp(),
job_id: model.job_id.clone(),
};
assert_eq!(proto.model_id, "m-123");
assert_eq!(proto.model_type, "DQN");
assert_eq!(proto.symbol, "ES.FUT");
assert_eq!(proto.s3_path, "s3://bucket/better.bin");
assert_eq!(proto.job_id, "job-99");
assert_eq!(proto.trained_at, now.timestamp());
assert_eq!(proto.new_metrics.len(), 2);
assert_eq!(proto.current_metrics.len(), 2);
}
}