- Add `content: Option<String>` to SourceRecord with rkyv schema evolution (LegacySourceRecord compat deserializer for backward compatibility) - Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation - Strip content from list responses, include in single-source GET - Update Go SDK RegisterSourceRequest with Content field - FCM pipeline extracts PDF text via pdftotext and passes to registration - Dashboard impact panel fetches and displays source content with expand/collapse - Add feed endpoint, dashboard feed panel, and signed assertion support - Update data-structures.md, API docs, and storage docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
90 lines
3.1 KiB
Rust
90 lines
3.1 KiB
Rust
//! 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<usize>,
|
|
}
|
|
|
|
/// 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<RejectedRecordDto>,
|
|
/// 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<usize>, 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<AppState>,
|
|
axum::extract::Query(params): axum::extract::Query<RejectedParams>,
|
|
) -> std::result::Result<Json<RejectedRecordsResponse>, (axum::http::StatusCode, Json<ErrorResponse>)>
|
|
{
|
|
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::<RejectedRecordDto>(&json_str) {
|
|
rejected.push(dto);
|
|
}
|
|
}
|
|
|
|
let count = rejected.len();
|
|
Ok(Json(RejectedRecordsResponse { rejected, count }))
|
|
}
|