Skip to main content

heddle_core/
query.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Structured query over the operation log.
3
4use std::{collections::BTreeMap, path::Path};
5
6use chrono::TimeZone;
7use objects::{
8    error::Result,
9    object::{OperationId, StateId},
10};
11use oplog::{OpEntry, OpLog, OpLogBackend, OpRecord, RecordedHead};
12use refs::refs::{IndexedOperation, OperationLogIndex, OperationLogQuery};
13use schemars::JsonSchema;
14use serde::Serialize;
15
16use crate::{
17    ExecutionContext, HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract,
18    schema_for_report,
19};
20
21/// Query filters for the operation log facade.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct QueryRequest {
24    pub actor: String,
25    pub symbol: String,
26    pub signal_kind: String,
27    pub thread: String,
28    pub verbs: Vec<String>,
29    pub since_secs: i64,
30    pub until_secs: i64,
31    pub limit: u32,
32    pub include_checkpoints: bool,
33}
34
35#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
36pub struct QueryReport {
37    pub output_kind: &'static str,
38    pub hits: Vec<QueryHit>,
39}
40
41impl QueryReport {
42    pub const CONTRACT: ReportContract = ReportContract {
43        schema_name: "query",
44        machine_output_kind: MachineOutputKind::Json,
45        output_discriminator: Some(OutputDiscriminator {
46            field: "output_kind",
47            value: "query",
48        }),
49        schema: schema_for_report::<QueryReport>,
50    };
51}
52
53impl HeddleReport for QueryReport {
54    const CONTRACT: ReportContract = QueryReport::CONTRACT;
55}
56
57#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)]
58pub struct QueryHit {
59    pub seq: u64,
60    pub timestamp_secs: i64,
61    pub verb: String,
62    pub actor_email: String,
63    pub operation_id: Option<String>,
64    pub thread: Option<String>,
65    pub symbols: Vec<String>,
66    pub signal_kinds: Vec<String>,
67    pub state_id: Option<String>,
68}
69
70/// Query is an operator-facing inspection command, so it should answer from
71/// the live oplog even before the rebuildable index sidecar has been warmed.
72/// Keep the scan bounded; long-tail history can use the index once populated.
73const OPLOG_FALLBACK_SCAN_WINDOW: usize = 100_000;
74
75pub fn query(ctx: &ExecutionContext, req: QueryRequest) -> Result<QueryReport> {
76    let repo = ctx.require_repo()?;
77    let q = build_query(&req);
78    let hits = query_combined(repo.heddle_dir(), &q)?;
79    Ok(QueryReport {
80        output_kind: "query",
81        hits: hits.into_iter().map(hit_to_report).collect(),
82    })
83}
84
85fn build_query(req: &QueryRequest) -> OperationLogQuery {
86    let mut q = OperationLogQuery {
87        actor: (!req.actor.is_empty()).then(|| req.actor.clone()),
88        symbol: (!req.symbol.is_empty()).then(|| req.symbol.clone()),
89        signal_kind: (!req.signal_kind.is_empty()).then(|| req.signal_kind.clone()),
90        thread: (!req.thread.is_empty()).then(|| req.thread.clone()),
91        verbs: (!req.verbs.is_empty()).then(|| req.verbs.clone()),
92        since: parse_unix_secs(req.since_secs),
93        until: parse_unix_secs(req.until_secs),
94        limit: (req.limit > 0).then_some(req.limit as usize),
95    };
96    if !req.include_checkpoints && q.verbs.is_none() {
97        q.verbs = Some(
98            OpRecord::verbs(false)
99                .iter()
100                .map(|s| s.to_string())
101                .collect(),
102        );
103    }
104    q
105}
106
107fn parse_unix_secs(secs: i64) -> Option<chrono::DateTime<chrono::Utc>> {
108    if secs == 0 {
109        return None;
110    }
111    chrono::Utc.timestamp_opt(secs, 0).single()
112}
113
114fn query_combined(heddle_dir: &Path, query: &OperationLogQuery) -> Result<Vec<IndexedOperation>> {
115    let index = OperationLogIndex::new(heddle_dir);
116    let mut unbounded = query.clone();
117    unbounded.limit = None;
118
119    let mut by_seq = BTreeMap::new();
120    for hit in index.query(&unbounded)? {
121        by_seq.insert(hit.seq, hit);
122    }
123
124    if unbounded.symbol.is_none() && unbounded.signal_kind.is_none() {
125        for hit in query_oplog_fallback(heddle_dir, &unbounded)? {
126            by_seq.entry(hit.seq).or_insert(hit);
127        }
128    }
129
130    let mut hits: Vec<_> = by_seq.into_values().collect();
131    hits.sort_by_key(|hit| hit.seq);
132    if let Some(limit) = query.limit {
133        hits.truncate(limit);
134    }
135    Ok(hits)
136}
137
138fn query_oplog_fallback(
139    heddle_dir: &Path,
140    query: &OperationLogQuery,
141) -> Result<Vec<IndexedOperation>> {
142    let log = OpLog::new_unattributed(heddle_dir);
143    let mut entries = log.recent(OPLOG_FALLBACK_SCAN_WINDOW)?;
144    entries.reverse();
145    let mut hits = Vec::new();
146    for entry in entries {
147        let hit = indexed_from_oplog_entry(&entry);
148        if hit.matches(query) {
149            hits.push(hit);
150        }
151    }
152    Ok(hits)
153}
154
155fn indexed_from_oplog_entry(entry: &OpEntry) -> IndexedOperation {
156    IndexedOperation {
157        seq: entry.id,
158        timestamp_secs: entry.timestamp.timestamp(),
159        verb: entry.operation.verb().to_string(),
160        actor_email: entry.actor.email.clone(),
161        operation_id: entry.operation_id,
162        thread: thread_for(&entry.operation),
163        symbols: Vec::new(),
164        signal_kinds: Vec::new(),
165        state_id: primary_state_id(&entry.operation),
166    }
167}
168
169fn hit_to_report(hit: IndexedOperation) -> QueryHit {
170    QueryHit {
171        seq: hit.seq,
172        timestamp_secs: hit.timestamp_secs,
173        verb: hit.verb,
174        actor_email: hit.actor_email,
175        operation_id: hit.operation_id.map(operation_id_to_string),
176        thread: hit.thread,
177        symbols: hit.symbols,
178        signal_kinds: hit.signal_kinds,
179        state_id: hit.state_id.map(|id| id.to_string_full()),
180    }
181}
182
183fn operation_id_to_string(id: OperationId) -> String {
184    id.to_string()
185}
186
187fn thread_for(op: &OpRecord) -> Option<String> {
188    match op {
189        OpRecord::Snapshot { thread, .. } => thread.clone(),
190        OpRecord::ThreadCreate { name, .. } => Some(name.clone()),
191        OpRecord::ThreadDelete { name, .. } => Some(name.clone()),
192        OpRecord::ThreadUpdate { name, .. } => Some(name.clone()),
193        OpRecord::MarkerCreate { name, .. } => Some(name.clone()),
194        OpRecord::MarkerDelete { name, .. } => Some(name.clone()),
195        OpRecord::Checkpoint { thread, .. } => thread.clone(),
196        OpRecord::EphemeralThreadCollapse { thread, .. } => Some(thread.clone()),
197        OpRecord::FastForward { target_thread, .. } => Some(target_thread.clone()),
198        OpRecord::GitCheckpoint { branch, .. } => Some(branch.clone()),
199        OpRecord::RemoteThreadUpdate { thread, .. }
200        | OpRecord::RemoteThreadDelete { thread, .. } => Some(thread.clone()),
201        OpRecord::HeadUpdate {
202            new: RecordedHead::Attached { thread },
203            ..
204        } => Some(thread.clone()),
205        OpRecord::Goto { .. }
206        | OpRecord::Fork { .. }
207        | OpRecord::Collapse { .. }
208        | OpRecord::TransactionAbort { .. }
209        | OpRecord::TransactionCommit { .. }
210        | OpRecord::ConflictResolved { .. }
211        | OpRecord::Redact { .. }
212        | OpRecord::UndoRecoveryUpdate { .. }
213        | OpRecord::StateVisibilitySet { .. }
214        | OpRecord::StateVisibilityPromote { .. }
215        | OpRecord::HeadUpdate {
216            new: RecordedHead::Detached { .. },
217            ..
218        }
219        | OpRecord::Purge { .. } => None,
220    }
221}
222
223fn primary_state_id(op: &OpRecord) -> Option<StateId> {
224    match op {
225        OpRecord::Snapshot { new_state, .. } => Some(*new_state),
226        OpRecord::Goto { target, .. } => Some(*target),
227        OpRecord::ThreadCreate { state, .. } => Some(*state),
228        OpRecord::ThreadDelete { state, .. } => Some(*state),
229        OpRecord::ThreadUpdate { new_state, .. } => Some(*new_state),
230        OpRecord::Fork { new_state, .. } => Some(*new_state),
231        OpRecord::Collapse { result, .. } => Some(*result),
232        OpRecord::MarkerCreate { state, .. } => Some(*state),
233        OpRecord::MarkerDelete { state, .. } => Some(*state),
234        OpRecord::Checkpoint { state, .. } => Some(*state),
235        OpRecord::GitCheckpoint { state, .. } => Some(*state),
236        OpRecord::EphemeralThreadCollapse { final_state, .. } => Some(*final_state),
237        OpRecord::Redact { state, .. } => Some(*state),
238        OpRecord::StateVisibilitySet { state, .. }
239        | OpRecord::StateVisibilityPromote { state, .. } => Some(*state),
240        OpRecord::RemoteThreadUpdate { state, .. } | OpRecord::RemoteThreadDelete { state, .. } => {
241            Some(*state)
242        }
243        OpRecord::UndoRecoveryUpdate { state } => Some(*state),
244        OpRecord::HeadUpdate {
245            new: RecordedHead::Detached { state },
246            ..
247        } => Some(*state),
248        OpRecord::TransactionAbort { .. }
249        | OpRecord::TransactionCommit { .. }
250        | OpRecord::ConflictResolved { .. }
251        | OpRecord::Purge { .. }
252        | OpRecord::FastForward { .. }
253        | OpRecord::HeadUpdate {
254            new: RecordedHead::Attached { .. },
255            ..
256        } => None,
257    }
258}