use a2a_protocol_types::error::{A2aError, A2aResult};
use a2a_protocol_types::events::StreamResponse;
use a2a_protocol_types::task::TaskId;
use super::task_store::RecordedEvent;
use crate::metrics::{self, Metrics};
pub(super) fn seq_to_i64(seq: u64) -> A2aResult<i64> {
i64::try_from(seq).map_err(|_| {
A2aError::internal(format!(
"event log: sequence {seq} exceeds the storable range"
))
})
}
pub(super) fn seq_from_i64(seq: i64) -> A2aResult<u64> {
u64::try_from(seq).map_err(|_| {
A2aError::internal(format!(
"event log: stored sequence {seq} is negative; positions start at 1"
))
})
}
pub(super) fn limit_to_i64(limit: usize) -> i64 {
i64::try_from(limit).unwrap_or(i64::MAX)
}
#[cfg(feature = "sqlite")]
pub(super) fn encode_text(event: &StreamResponse) -> A2aResult<String> {
serde_json::to_string(event)
.map_err(|e| A2aError::internal(format!("event log: could not serialize event: {e}")))
}
#[cfg(feature = "postgres")]
pub(super) fn encode_json(event: &StreamResponse) -> A2aResult<serde_json::Value> {
serde_json::to_value(event)
.map_err(|e| A2aError::internal(format!("event log: could not serialize event: {e}")))
}
#[cfg(feature = "sqlite")]
pub(super) fn decode_text_row((seq, payload): (i64, String)) -> A2aResult<RecordedEvent> {
build(seq, serde_json::from_str(&payload))
}
#[cfg(feature = "postgres")]
pub(super) fn decode_json_row(
(seq, payload): (i64, serde_json::Value),
) -> A2aResult<RecordedEvent> {
build(seq, serde_json::from_value(payload))
}
fn build(seq: i64, parsed: serde_json::Result<StreamResponse>) -> A2aResult<RecordedEvent> {
let event = parsed.map_err(|e| {
A2aError::internal(format!(
"event log: row {seq} could not be deserialized: {e}"
))
})?;
Ok(RecordedEvent {
seq: seq_from_i64(seq)?,
event,
})
}
pub(super) fn report_no_op_append(
metrics: &dyn Metrics,
_task_id: &TaskId,
_seq: u64,
same_event: bool,
) {
if same_event {
return;
}
trace_warn!(
task_id = %_task_id,
seq = _seq,
"event log: append changed no row and the stored event differs; \
another writer holds this position"
);
metrics.on_persistence_error(
metrics::persistence_operation::EVENT_APPEND,
metrics::event_append_error::POSITION_CONFLICT,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_position_beyond_the_storable_range_is_refused_rather_than_wrapped() {
let err = seq_to_i64(u64::MAX).expect_err("u64::MAX does not fit in an i64");
assert!(
format!("{err}").contains("exceeds the storable range"),
"the error must name the cause: {err}"
);
assert_eq!(seq_to_i64(0).expect("0 fits"), 0);
assert_eq!(
seq_to_i64(u64::try_from(i64::MAX).expect("i64::MAX is non-negative"))
.expect("i64::MAX fits"),
i64::MAX,
);
}
#[test]
fn an_oversized_limit_clamps_rather_than_failing() {
assert_eq!(limit_to_i64(10), 10);
assert_eq!(limit_to_i64(usize::MAX), i64::MAX);
}
#[test]
fn a_negative_stored_position_is_refused_rather_than_made_positive() {
let err = seq_from_i64(-1).expect_err("a negative position is not a position");
assert!(
format!("{err}").contains("negative"),
"the error must name the cause: {err}"
);
assert_eq!(seq_from_i64(0).expect("0 fits"), 0);
assert_eq!(
seq_from_i64(i64::MAX).expect("i64::MAX fits"),
u64::try_from(i64::MAX).expect("i64::MAX is non-negative"),
);
}
#[cfg(feature = "sqlite")]
#[test]
fn an_unreadable_row_names_its_position() {
let err = decode_text_row((7, "not json".to_string()))
.expect_err("a payload that is not JSON must not decode");
let msg = format!("{err}");
assert!(msg.contains("row 7"), "the error must name the row: {msg}");
}
}