diff --git a/crates/manager-core/src/api.rs b/crates/manager-core/src/api.rs index b94d285..b29023d 100644 --- a/crates/manager-core/src/api.rs +++ b/crates/manager-core/src/api.rs @@ -375,8 +375,14 @@ async fn delete_script( pub struct LogsQuery { #[serde(default = "default_limit")] pub limit: i64, + /// F-P-005: keyset cursor (`_`). Replaces the OFFSET + /// path that scanned + discarded N rows per page. Absent for the + /// first page. The legacy `offset` query param is accepted but + /// silently ignored to keep older dashboards from 400'ing. #[serde(default)] - pub offset: i64, + pub cursor: Option, + #[serde(default, rename = "offset")] + pub _legacy_offset: Option, } const fn default_limit() -> i64 { @@ -399,8 +405,11 @@ async fn list_logs( // Cap to keep the dashboard responsive; the data plane writes are // unbounded over time so a paged read is the only sane default. let limit = q.limit.clamp(1, 200); - let offset = q.offset.max(0); - let logs = state.logs.list_for_script(id, limit, offset).await?; + let cursor = q + .cursor + .as_deref() + .and_then(crate::repo::ExecutionLogCursor::decode); + let logs = state.logs.list_for_script(id, limit, cursor).await?; Ok(Json(logs)) } diff --git a/crates/manager-core/src/repo.rs b/crates/manager-core/src/repo.rs index 5f577d9..c9a4bb2 100644 --- a/crates/manager-core/src/repo.rs +++ b/crates/manager-core/src/repo.rs @@ -485,13 +485,43 @@ impl ScriptResolver for RepoResolver { /// Read-side access to the `execution_logs` table. Writes go through /// `PostgresExecutionLogSink` so the read and write paths can diverge /// in cluster mode without disturbing this trait. +/// Opaque cursor encoding `(created_at, id)` for keyset pagination. +/// Format: `_` — readable in URLs and stable across +/// server restarts (no opaque internal IDs). +#[derive(Debug, Clone)] +pub struct ExecutionLogCursor { + pub created_at: chrono::DateTime, + pub id: uuid::Uuid, +} + +impl ExecutionLogCursor { + /// Encode as `_`. + #[must_use] + pub fn encode(&self) -> String { + format!("{}_{}", self.created_at.to_rfc3339(), self.id) + } + + /// Decode from the wire format. Returns `None` on any parse error; + /// the caller treats that as "no cursor" (start of the page). + #[must_use] + pub fn decode(s: &str) -> Option { + let (ts, id) = s.rsplit_once('_')?; + let created_at = chrono::DateTime::parse_from_rfc3339(ts).ok()?.to_utc(); + let id = uuid::Uuid::parse_str(id).ok()?; + Some(Self { created_at, id }) + } +} + #[async_trait] pub trait ExecutionLogRepository: Send + Sync { + /// F-P-005: keyset cursor on `(created_at, id)` — `cursor` is the + /// boundary of the previous page (or `None` for the first page). + /// Avoids the OFFSET tail-latency cliff on deep pagination. async fn list_for_script( &self, script_id: ScriptId, limit: i64, - offset: i64, + cursor: Option, ) -> Result, ScriptRepositoryError>; } @@ -512,23 +542,45 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository { &self, script_id: ScriptId, limit: i64, - offset: i64, + cursor: Option, ) -> Result, ScriptRepositoryError> { - let rows = sqlx::query_as::<_, ExecutionLogRow>( - "SELECT id, app_id, script_id, request_id, \ - request_path, request_headers, request_body, \ - response_code, response_body, \ - logs, duration_ms, status, created_at \ - FROM execution_logs \ - WHERE script_id = $1 \ - ORDER BY created_at DESC \ - LIMIT $2 OFFSET $3", - ) - .bind(script_id.into_inner()) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await?; + let rows = match cursor { + Some(c) => { + sqlx::query_as::<_, ExecutionLogRow>( + "SELECT id, app_id, script_id, request_id, \ + request_path, request_headers, request_body, \ + response_code, response_body, \ + logs, duration_ms, status, created_at \ + FROM execution_logs \ + WHERE script_id = $1 \ + AND (created_at, id) < ($2, $3) \ + ORDER BY created_at DESC, id DESC \ + LIMIT $4", + ) + .bind(script_id.into_inner()) + .bind(c.created_at) + .bind(c.id) + .bind(limit) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query_as::<_, ExecutionLogRow>( + "SELECT id, app_id, script_id, request_id, \ + request_path, request_headers, request_body, \ + response_code, response_body, \ + logs, duration_ms, status, created_at \ + FROM execution_logs \ + WHERE script_id = $1 \ + ORDER BY created_at DESC, id DESC \ + LIMIT $2", + ) + .bind(script_id.into_inner()) + .bind(limit) + .fetch_all(&self.pool) + .await? + } + }; Ok(rows.into_iter().map(Into::into).collect()) }