diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index 87a791ef..42f1bf48 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -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 = 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), diff --git a/v2/crates/homecore-recorder/src/db.rs b/v2/crates/homecore-recorder/src/db.rs index 741b33e5..3ca686fc 100644 --- a/v2/crates/homecore-recorder/src/db.rs +++ b/v2/crates/homecore-recorder/src/db.rs @@ -429,6 +429,24 @@ impl Recorder { since: DateTime, until: DateTime, ) -> Result, 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, + until: DateTime, + requested_limit: usize, + ) -> Result, 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]