//! Admin endpoint for listing WAL records permanently rejected by the IngestWorker. //! //! These records passed API-level validation but were skipped during WAL replay //! due to permanent failures (invalid signatures, corrupt serialization, etc.). //! With the API-side signature verification fix, new rejected records should be rare. use axum::{extract::State, Json}; use serde::{Deserialize, Serialize}; use stemedb_storage::{key_codec, KVStore}; use tracing::instrument; use utoipa::ToSchema; use crate::{dto::ErrorResponse, state::AppState}; /// Query parameters for listing rejected records. #[derive(Debug, Deserialize)] pub struct RejectedParams { /// Maximum number of records to return (default: 100). pub limit: Option, } /// A WAL record that was permanently skipped by the IngestWorker. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RejectedRecordDto { /// WAL offset where the record was found. pub offset: u64, /// The record type (Assertion, Vote, Epoch). pub record_type: String, /// Why the record was rejected. pub reason: String, /// When the record was skipped (Unix timestamp). pub timestamp: u64, } /// Response listing rejected WAL records. #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RejectedRecordsResponse { /// List of rejected records. pub rejected: Vec, /// Total number of rejected records found. pub count: usize, } /// GET /v1/admin/rejected /// /// List WAL records that were permanently rejected by the IngestWorker. #[utoipa::path( get, path = "/v1/admin/rejected", params( ("limit" = Option, Query, description = "Maximum records to return (default: 100)") ), responses( (status = 200, description = "Rejected records listed", body = RejectedRecordsResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "admin" )] #[instrument(skip(state))] pub async fn list_rejected( State(state): State, axum::extract::Query(params): axum::extract::Query, ) -> std::result::Result, (axum::http::StatusCode, Json)> { let limit = params.limit.unwrap_or(100); let prefix = key_codec::rejected_records_scan_prefix(); let entries = state.store.scan_prefix(&prefix).await.map_err(|e| { tracing::error!(error = %e, "Failed to scan rejected records"); ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: "Failed to retrieve rejected records".to_string(), code: "REJECTED_SCAN_ERROR".to_string(), }), ) })?; let mut rejected = Vec::new(); for (_key, value) in entries.into_iter().take(limit) { let json_str = String::from_utf8_lossy(&value); if let Ok(dto) = serde_json::from_str::(&json_str) { rejected.push(dto); } } let count = rejected.len(); Ok(Json(RejectedRecordsResponse { rejected, count })) }