Skip to main content

relay_knowledge/storage/contracts/
index.rs

1use serde::{Deserialize, Serialize};
2
3use crate::domain::{
4    AuditEventRecord, GraphVersion, IndexCursor, IndexKind, IndexModality, IndexRefreshDiagnostics,
5    IndexStatus, ProposalConflictRecord, ProposalRecord, ProposalState, ServiceOperatorState,
6    ServiceOperatorStatus, WorkerStatus, WorkerTaskRecord,
7};
8
9use super::{
10    AuditQueryRequest, FileContentSearchHit, FileContentSearchRequest, FileIndexDiagnostics,
11    FileIndexRoot, FileIndexRootStatus, FileIndexRootUpdate, FileSearchHit, FileSearchRequest,
12    NewAuditEvent, NewProposal, ProposalDecision, ProposalListRequest, ServiceOperatorUpdate,
13    StorageError, StorageFuture, WorkerTaskClaimRequest, WorkerTaskCompletion, WorkerTaskFailure,
14    WorkerTaskSeed,
15};
16
17/// Synthetic scope used for graph-wide index work that is not tied to evidence.
18pub const DEFAULT_INDEX_SOURCE_SCOPE: &str = "graph";
19
20/// Derived index metadata and operational persistence contract.
21pub trait IndexStore: Send + Sync {
22    fn index_statuses(&self) -> StorageFuture<'_, Vec<IndexStatus>>;
23
24    fn mark_refresh_complete(
25        &self,
26        kind: IndexKind,
27        graph_version: GraphVersion,
28    ) -> StorageFuture<'_, IndexStatus>;
29
30    fn index_cursors(&self) -> StorageFuture<'_, Vec<IndexCursor>> {
31        Box::pin(async {
32            Err(StorageError::InvalidInput(
33                "index cursor storage is unavailable".to_owned(),
34            ))
35        })
36    }
37
38    fn queue_index_refreshes(
39        &self,
40        _request: IndexRefreshQueueRequest,
41    ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
42        Box::pin(async {
43            Err(StorageError::InvalidInput(
44                "index refresh task storage is unavailable".to_owned(),
45            ))
46        })
47    }
48
49    fn claim_index_refresh_task(
50        &self,
51        _request: IndexRefreshClaimRequest,
52    ) -> StorageFuture<'_, Option<IndexRefreshTask>> {
53        Box::pin(async {
54            Err(StorageError::InvalidInput(
55                "index refresh task storage is unavailable".to_owned(),
56            ))
57        })
58    }
59
60    fn complete_index_refresh_task(
61        &self,
62        _request: IndexRefreshCompletion,
63    ) -> StorageFuture<'_, IndexRefreshTask> {
64        Box::pin(async {
65            Err(StorageError::InvalidInput(
66                "index refresh task storage is unavailable".to_owned(),
67            ))
68        })
69    }
70
71    fn fail_index_refresh_task(
72        &self,
73        _request: IndexRefreshFailure,
74    ) -> StorageFuture<'_, IndexRefreshTask> {
75        Box::pin(async {
76            Err(StorageError::InvalidInput(
77                "index refresh task storage is unavailable".to_owned(),
78            ))
79        })
80    }
81
82    fn index_refresh_diagnostics(
83        &self,
84        _now_ms: u64,
85    ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
86        Box::pin(async {
87            Err(StorageError::InvalidInput(
88                "index refresh diagnostics are unavailable".to_owned(),
89            ))
90        })
91    }
92
93    fn queue_worker_tasks(
94        &self,
95        _tasks: Vec<WorkerTaskSeed>,
96    ) -> StorageFuture<'_, Vec<WorkerTaskRecord>> {
97        Box::pin(async { Ok(Vec::new()) })
98    }
99
100    fn worker_statuses(&self) -> StorageFuture<'_, Vec<WorkerStatus>> {
101        Box::pin(async { Ok(Vec::new()) })
102    }
103
104    fn claim_worker_task(
105        &self,
106        _request: WorkerTaskClaimRequest,
107    ) -> StorageFuture<'_, Option<WorkerTaskRecord>> {
108        Box::pin(async { Ok(None) })
109    }
110
111    fn complete_worker_task(
112        &self,
113        _request: WorkerTaskCompletion,
114    ) -> StorageFuture<'_, WorkerTaskRecord> {
115        Box::pin(async {
116            Err(StorageError::InvalidInput(
117                "worker task storage is unavailable".to_owned(),
118            ))
119        })
120    }
121
122    fn fail_worker_task(&self, _request: WorkerTaskFailure) -> StorageFuture<'_, WorkerTaskRecord> {
123        Box::pin(async {
124            Err(StorageError::InvalidInput(
125                "worker task storage is unavailable".to_owned(),
126            ))
127        })
128    }
129
130    fn insert_proposal(&self, _proposal: NewProposal) -> StorageFuture<'_, ProposalRecord> {
131        Box::pin(async {
132            Err(StorageError::InvalidInput(
133                "proposal storage is unavailable".to_owned(),
134            ))
135        })
136    }
137
138    fn list_proposals(
139        &self,
140        _request: ProposalListRequest,
141    ) -> StorageFuture<'_, Vec<ProposalRecord>> {
142        Box::pin(async { Ok(Vec::new()) })
143    }
144
145    fn proposal_count(&self, _state: Option<ProposalState>) -> StorageFuture<'_, usize> {
146        Box::pin(async { Ok(0) })
147    }
148
149    fn proposal_by_id(&self, _proposal_id: String) -> StorageFuture<'_, Option<ProposalRecord>> {
150        Box::pin(async { Ok(None) })
151    }
152
153    fn proposal_conflicts(
154        &self,
155        _proposal_id: String,
156    ) -> StorageFuture<'_, Vec<ProposalConflictRecord>> {
157        Box::pin(async { Ok(Vec::new()) })
158    }
159
160    fn decide_proposal(&self, _request: ProposalDecision) -> StorageFuture<'_, ProposalRecord> {
161        Box::pin(async {
162            Err(StorageError::InvalidInput(
163                "proposal storage is unavailable".to_owned(),
164            ))
165        })
166    }
167
168    fn insert_audit_event(&self, _event: NewAuditEvent) -> StorageFuture<'_, AuditEventRecord> {
169        Box::pin(async {
170            Err(StorageError::InvalidInput(
171                "audit storage is unavailable".to_owned(),
172            ))
173        })
174    }
175
176    fn query_audit_events(
177        &self,
178        _request: AuditQueryRequest,
179    ) -> StorageFuture<'_, Vec<AuditEventRecord>> {
180        Box::pin(async { Ok(Vec::new()) })
181    }
182
183    fn audit_event_count(&self) -> StorageFuture<'_, usize> {
184        Box::pin(async { Ok(0) })
185    }
186
187    fn service_operator_status(&self) -> StorageFuture<'_, ServiceOperatorStatus> {
188        Box::pin(async {
189            Ok(ServiceOperatorStatus {
190                state: ServiceOperatorState::Disabled,
191                silent_updates_enabled: false,
192                allowed_scopes: Vec::new(),
193                last_run_at_ms: None,
194                next_retry_at_ms: None,
195                last_error: None,
196                updated_at_ms: 0,
197            })
198        })
199    }
200
201    fn update_service_operator(
202        &self,
203        _request: ServiceOperatorUpdate,
204    ) -> StorageFuture<'_, ServiceOperatorStatus> {
205        Box::pin(async {
206            Err(StorageError::InvalidInput(
207                "service operator storage is unavailable".to_owned(),
208            ))
209        })
210    }
211
212    fn replace_file_index_root(
213        &self,
214        _update: FileIndexRootUpdate,
215    ) -> StorageFuture<'_, FileIndexRootStatus> {
216        unavailable_file_index_storage()
217    }
218
219    fn mark_file_index_roots_unconfigured(
220        &self,
221        _active_roots: Vec<FileIndexRoot>,
222        _now_ms: u64,
223    ) -> StorageFuture<'_, FileIndexDiagnostics> {
224        unavailable_file_index_storage()
225    }
226
227    fn search_files(&self, _request: FileSearchRequest) -> StorageFuture<'_, Vec<FileSearchHit>> {
228        unavailable_file_index_storage()
229    }
230
231    fn search_file_content(
232        &self,
233        _request: FileContentSearchRequest,
234    ) -> StorageFuture<'_, Vec<FileContentSearchHit>> {
235        unavailable_file_index_storage()
236    }
237
238    fn file_index_diagnostics(&self) -> StorageFuture<'_, FileIndexDiagnostics> {
239        unavailable_file_index_storage()
240    }
241}
242
243fn unavailable_file_index_storage<T>() -> StorageFuture<'static, T> {
244    Box::pin(async {
245        Err(StorageError::InvalidInput(
246            "file index storage is unavailable".to_owned(),
247        ))
248    })
249}
250
251/// Persistent index refresh task lifecycle state.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case")]
254pub enum IndexRefreshTaskState {
255    Queued,
256    Running,
257    Succeeded,
258    Retrying,
259    Failed,
260    DeadLetter,
261}
262
263impl IndexRefreshTaskState {
264    /// Stable storage and API representation.
265    pub const fn as_str(self) -> &'static str {
266        match self {
267            Self::Queued => "queued",
268            Self::Running => "running",
269            Self::Succeeded => "succeeded",
270            Self::Retrying => "retrying",
271            Self::Failed => "failed",
272            Self::DeadLetter => "dead_letter",
273        }
274    }
275}
276
277/// Persistent task used by foreground refresh and startup recovery.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct IndexRefreshTask {
280    pub task_id: String,
281    pub kind: IndexKind,
282    pub source_scope: String,
283    pub modality: IndexModality,
284    pub target_graph_version: GraphVersion,
285    pub state: IndexRefreshTaskState,
286    pub lease_owner: Option<String>,
287    pub lease_expires_at_ms: Option<u64>,
288    pub attempt_count: u32,
289    pub next_retry_at_ms: u64,
290    pub input_fingerprint: String,
291    pub cursor_before: GraphVersion,
292    pub cursor_after: Option<GraphVersion>,
293    pub last_error_kind: Option<String>,
294    pub last_error_message: Option<String>,
295    pub created_at_ms: u64,
296    pub updated_at_ms: u64,
297}
298
299/// Queue request created by refresh APIs or the reconciler.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct IndexRefreshQueueRequest {
302    pub kinds: Vec<IndexKind>,
303    pub target_graph_version: GraphVersion,
304    pub max_queue_depth: usize,
305    pub reset_dead_letter_tasks: bool,
306    pub now_ms: u64,
307}
308
309/// Lease acquisition request for bounded foreground workers.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct IndexRefreshClaimRequest {
312    pub lease_owner: String,
313    pub lease_duration_ms: u64,
314    pub max_attempts: u32,
315    pub now_ms: u64,
316}
317
318/// Completion report guarded by the active task lease and attempt token.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct IndexRefreshCompletion {
321    pub task_id: String,
322    pub lease_owner: String,
323    pub attempt_count: u32,
324    pub indexed_graph_version: GraphVersion,
325    pub model_name: Option<String>,
326    pub model_dimension: Option<u32>,
327    pub now_ms: u64,
328}
329
330/// Failure report for retry backoff and dead-letter isolation.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub struct IndexRefreshFailure {
333    pub task_id: String,
334    pub lease_owner: String,
335    pub attempt_count: u32,
336    pub error_kind: String,
337    pub error_message: String,
338    pub retry_backoff_ms: u64,
339    pub max_attempts: u32,
340    pub now_ms: u64,
341}
342
343#[cfg(test)]
344#[path = "index_tests.rs"]
345mod tests;