fix(homecore-api): bound multi-entity history responses

This commit is contained in:
ruv
2026-07-27 14:47:04 -04:00
parent e47d40c5c4
commit b41b8c8a82
2 changed files with 56 additions and 3 deletions
+7 -2
View File
@@ -105,6 +105,7 @@ pub struct HistoryQuery {
}
const MAX_HISTORY_ENTITIES: usize = 32;
const MAX_API_HISTORY_ROWS: usize = 100_000;
pub async fn get_history(
headers: HeaderMap,
@@ -173,11 +174,13 @@ async fn history_response(
}
let mut result = Vec::with_capacity(entity_ids.len());
let mut remaining = MAX_API_HISTORY_ROWS;
for entity_id in entity_ids {
let rows = recorder
.get_state_history(&entity_id, start, end)
.get_state_history_limited(&entity_id, start, end, remaining)
.await
.map_err(|error| ApiError::Internal(format!("history query failed: {error}")))?;
remaining = remaining.saturating_sub(rows.len());
let mut previous_state: Option<String> = None;
let states = rows
.into_iter()
@@ -298,11 +301,13 @@ async fn logbook_response(
)));
}
let mut entries = Vec::new();
let mut remaining = MAX_API_HISTORY_ROWS;
for entity_id in entity_ids {
let rows = recorder
.get_state_history(&entity_id, start, end)
.get_state_history_limited(&entity_id, start, end, remaining)
.await
.map_err(|error| ApiError::Internal(format!("logbook query failed: {error}")))?;
remaining = remaining.saturating_sub(rows.len());
for row in rows {
entries.push(serde_json::json!({
"when": history_timestamp(row.last_updated_ts),
+49 -1
View File
@@ -429,6 +429,24 @@ impl Recorder {
since: DateTime<Utc>,
until: DateTime<Utc>,
) -> Result<Vec<StateRow>, RecorderError> {
self.get_state_history_limited(entity_id, since, until, MAX_HISTORY_ROWS as usize)
.await
}
/// Query state history with a caller-selected limit capped by
/// [`MAX_HISTORY_ROWS`]. The bound is applied in SQL, before rows are
/// materialized.
pub async fn get_state_history_limited(
&self,
entity_id: &EntityId,
since: DateTime<Utc>,
until: DateTime<Utc>,
requested_limit: usize,
) -> Result<Vec<StateRow>, RecorderError> {
let limit = requested_limit.min(MAX_HISTORY_ROWS as usize);
if limit == 0 {
return Ok(Vec::new());
}
let since_ts = since.timestamp_micros() as f64 / 1_000_000.0;
let until_ts = until.timestamp_micros() as f64 / 1_000_000.0;
@@ -446,7 +464,7 @@ impl Recorder {
.bind(entity_id.as_str())
.bind(since_ts)
.bind(until_ts)
.bind(MAX_HISTORY_ROWS)
.bind(limit as i64)
.fetch_all(&self.pool)
.await?;
@@ -957,6 +975,36 @@ mod tests {
assert_eq!(rows[2].state, "22.0");
}
#[tokio::test]
async fn history_caller_limit_is_applied_before_materialization() {
let recorder = open_memory().await;
let eid = entity("sensor.bounded");
for value in ["1", "2", "3"] {
recorder
.record_state(&make_state_event(
"sensor.bounded",
value,
serde_json::json!({}),
))
.await
.unwrap();
}
let since = Utc::now() - chrono::Duration::seconds(10);
let until = Utc::now() + chrono::Duration::seconds(10);
let rows = recorder
.get_state_history_limited(&eid, since, until, 2)
.await
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].state, "1");
assert_eq!(rows[1].state, "2");
assert!(recorder
.get_state_history_limited(&eid, since, until, 0)
.await
.unwrap()
.is_empty());
}
// ── record_event ──────────────────────────────────────────────────────────
#[tokio::test]