1use crate::errors::{MCSError, Result};
3use crate::events::{Lease, lease_until, parse_uuid, sha256, sql_error};
4use crate::graph::TxGuard;
5use rusqlite::{Connection, OptionalExtension, params};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9pub(crate) fn serving_profile_ids(conn: &Connection) -> Result<Vec<Uuid>> {
12 let mut stmt = conn.prepare("SELECT serving_profile FROM index_profile_registry WHERE serving_profile IS NOT NULL UNION SELECT candidate_profile FROM index_profile_registry WHERE state='Rebuilding' AND candidate_profile IS NOT NULL").map_err(sql_error)?;
13 let mut profiles = stmt
14 .query_map([], |r| r.get::<_, String>(0))
15 .map_err(sql_error)?
16 .collect::<rusqlite::Result<Vec<_>>>()
17 .map_err(sql_error)?;
18 if profiles.is_empty() {
19 profiles.push(uuid::Uuid::nil().to_string());
20 }
21 profiles.iter().map(|profile| parse_uuid(profile)).collect()
22}
23
24pub(crate) fn enqueue_chunk_change(
29 conn: &Connection,
30 owner_kind: OwnerKind,
31 owner_id: i64,
32 revision: i64,
33 deleted: bool,
34) -> Result<()> {
35 let profiles = serving_profile_ids(conn)?;
36 for profile in profiles {
37 let state = if profile.is_nil() { "held" } else { "pending" };
38 conn.execute(
39 "INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation,state)
40 VALUES(?1,?2,?3,?4,?5,?6)
41 ON CONFLICT(profile_id,owner_kind,owner_id) DO UPDATE SET
42 owner_revision=excluded.owner_revision,operation=excluded.operation,state=excluded.state,
43 lease_token=NULL,lease_epoch=lease_epoch+1,lease_until_us=0,attempts=0,
44 next_attempt_us=0,last_error=NULL",
45 params![
46 profile.to_string(),
47 owner_kind.as_str(),
48 owner_id,
49 revision,
50 if deleted { "delete" } else { "upsert" },
51 state
52 ],
53 )
54 .map_err(sql_error)?;
55 conn.execute(
56 "UPDATE ann_generation SET full_scan_generation=NULL WHERE profile_id=?1",
57 [profile.to_string()],
58 )
59 .map_err(sql_error)?;
60 }
61 Ok(())
62}
63
64pub(crate) fn enqueue_change(
68 conn: &Connection,
69 entity_id: i64,
70 revision: i64,
71 deleted: bool,
72) -> Result<()> {
73 enqueue_chunk_change(conn, OwnerKind::Entity, entity_id, revision, deleted)
74}
75
76#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
77pub enum Normalization {
78 None,
79 L2,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
83pub enum ChunkKind {
84 Identity,
85 Observation,
86 Relation,
87}
88
89impl ChunkKind {
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 ChunkKind::Identity => "identity",
93 ChunkKind::Observation => "observation",
94 ChunkKind::Relation => "relation",
95 }
96 }
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
100pub enum OwnerKind {
101 Entity,
102 Relation,
103}
104
105impl OwnerKind {
106 pub const fn as_str(self) -> &'static str {
107 match self {
108 OwnerKind::Entity => "entity",
109 OwnerKind::Relation => "relation",
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
115pub enum DistanceMetric {
116 Cosine,
117 InnerProduct,
118 L2Squared,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct IndexProfile {
124 pub id: Uuid,
125 pub store_key: String,
126 pub provider_kind: String,
127 pub model: String,
128 pub dimensions: u32,
129 pub representation_version: String,
130 pub normalization: Normalization,
131 pub distance_metric: DistanceMetric,
132 pub vector_encoding_version: String,
133}
134
135impl IndexProfile {
136 pub fn validate(&self) -> Result<()> {
137 if self.id.is_nil()
138 || self.store_key != "default"
139 || self.dimensions == 0
140 || self.dimensions > 65_536
141 || self.vector_encoding_version != "f32le-v1"
142 || [
143 &self.provider_kind,
144 &self.model,
145 &self.representation_version,
146 ]
147 .iter()
148 .any(|s| s.trim().is_empty() || s.len() > 256 || s.chars().any(char::is_control))
149 {
150 return Err(MCSError::InvalidParams("invalid index profile".into()));
151 }
152 Ok(())
153 }
154
155 pub fn fingerprint(&self) -> Result<String> {
157 self.validate()?;
158 let mut value = serde_json::to_value(self)?;
159 value
160 .as_object_mut()
161 .ok_or_else(|| MCSError::MemoryError("profile must be an object".into()))?
162 .remove("id");
163 Ok(sha256(&serde_json::to_vec(&value)?))
164 }
165
166 pub fn validate_vector(&self, vector: &[f32]) -> Result<()> {
167 if vector.len() != self.dimensions as usize || vector.iter().any(|x| !x.is_finite()) {
168 return Err(MCSError::InvalidParams(
169 "vector dimensions or finite-value validation failed".into(),
170 ));
171 }
172 if self.normalization == Normalization::L2 {
173 let norm: f64 = vector.iter().map(|x| f64::from(*x).powi(2)).sum();
174 if (norm - 1.0).abs() > 1e-4 {
175 return Err(MCSError::InvalidParams(
176 "vector is not L2 normalized".into(),
177 ));
178 }
179 }
180 Ok(())
181 }
182}
183
184#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub enum StoreState {
186 LegacyCompat,
187 Active(Uuid),
188 Rebuilding {
189 serving: Option<Uuid>,
190 candidate: Uuid,
191 },
192 Failed {
193 serving: Option<Uuid>,
194 candidate: Uuid,
195 reason: String,
196 },
197}
198
199pub struct IndexProfileRegistry<'a> {
200 conn: &'a Connection,
201}
202
203impl<'a> IndexProfileRegistry<'a> {
204 pub const fn new(conn: &'a Connection) -> Self {
205 Self { conn }
206 }
207
208 pub const fn connection(&self) -> &Connection {
212 self.conn
213 }
214
215 pub fn get(&self, id: Uuid) -> Result<IndexProfile> {
216 let text: String = self
217 .conn
218 .query_row(
219 "SELECT definition FROM index_profile WHERE id=?1",
220 [id.to_string()],
221 |r| r.get(0),
222 )
223 .map_err(sql_error)?;
224 Ok(serde_json::from_str(&text)?)
225 }
226
227 pub fn state(&self, store_key: &str) -> Result<StoreState> {
228 let (state,serving,candidate,reason): (String,Option<String>,Option<String>,Option<String>) = self.conn.query_row("SELECT state,serving_profile,candidate_profile,failure_reason FROM index_profile_registry WHERE store_key=?1", [store_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).map_err(sql_error)?;
229 let serving = serving.as_deref().map(parse_uuid).transpose()?;
230 let candidate = candidate.as_deref().map(parse_uuid).transpose()?;
231 match (state.as_str(), serving, candidate, reason) {
232 ("LegacyCompat", None, None, None) => Ok(StoreState::LegacyCompat),
233 ("Active", Some(profile), None, None) => Ok(StoreState::Active(profile)),
234 ("Rebuilding", serving, Some(candidate), None) => {
235 Ok(StoreState::Rebuilding { serving, candidate })
236 }
237 ("Failed", serving, Some(candidate), Some(reason)) => Ok(StoreState::Failed {
238 serving,
239 candidate,
240 reason,
241 }),
242 _ => Err(MCSError::MemoryError(
243 "invalid persisted profile registry state".into(),
244 )),
245 }
246 }
247
248 pub fn serving_profile(&self, store_key: &str) -> Result<Option<Uuid>> {
249 Ok(match self.state(store_key)? {
250 StoreState::LegacyCompat => None,
251 StoreState::Active(profile) => Some(profile),
252 StoreState::Rebuilding { serving, .. } | StoreState::Failed { serving, .. } => serving,
253 })
254 }
255
256 pub fn begin_rebuild(&self, profile: &IndexProfile) -> Result<()> {
257 let fingerprint = profile.fingerprint()?;
258 let tx = TxGuard::begin(self.conn)?;
259 if matches!(
260 self.state(&profile.store_key)?,
261 StoreState::Rebuilding { .. }
262 ) {
263 return Err(MCSError::InvalidParams(
264 "profile rebuild already in progress".into(),
265 ));
266 }
267 if self.serving_profile(&profile.store_key)? == Some(profile.id) {
268 return Err(MCSError::InvalidParams(
269 "cannot rebuild into serving profile".into(),
270 ));
271 }
272 self.conn
275 .execute(
276 "INSERT INTO index_profile VALUES(?1,?2,?3,?4,'Rebuilding')",
277 params![
278 profile.id.to_string(),
279 profile.store_key,
280 fingerprint,
281 serde_json::to_string(profile)?
282 ],
283 )
284 .map_err(sql_error)?;
285 self.conn.execute("UPDATE index_profile SET state='Retired' WHERE id=(SELECT candidate_profile FROM index_profile_registry WHERE store_key=?1)", [&profile.store_key]).map_err(sql_error)?;
286 self.conn.execute("UPDATE index_profile_registry SET state='Rebuilding',candidate_profile=?2,failure_reason=NULL WHERE store_key=?1", params![profile.store_key,profile.id.to_string()]).map_err(sql_error)?;
287 self.conn
288 .execute(
289 "INSERT INTO ann_generation(profile_id) VALUES(?1)",
290 [profile.id.to_string()],
291 )
292 .map_err(sql_error)?;
293 self.conn.execute("INSERT INTO entity_revision SELECT id,1,0 FROM entity WHERE flags=0 ON CONFLICT(entity_id) DO NOTHING", []).map_err(sql_error)?;
294 self.conn.execute("INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation) SELECT ?1,'entity',e.id,r.revision,'upsert' FROM entity e JOIN entity_revision r ON r.entity_id=e.id WHERE e.flags=0", [profile.id.to_string()]).map_err(sql_error)?;
295 self.conn.execute("INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation) SELECT ?1,'relation',m.id,m.revision,CASE WHEN m.deleted=0 THEN 'upsert' ELSE 'delete' END FROM taxonomy_relation m", [profile.id.to_string()]).map_err(sql_error)?;
299 tx.commit()
300 }
301
302 pub fn fail_rebuild(&self, candidate: Uuid, reason: &str) -> Result<()> {
303 let tx = TxGuard::begin(self.conn)?;
304 let changed = self.conn.execute("UPDATE index_profile_registry SET state='Failed',failure_reason=?2 WHERE state='Rebuilding' AND candidate_profile=?1", params![candidate.to_string(),reason.chars().take(2048).collect::<String>()]).map_err(sql_error)?;
305 if changed != 1 {
306 return Err(MCSError::InvalidParams(
307 "candidate is not rebuilding".into(),
308 ));
309 }
310 self.conn.execute("UPDATE chunk_index_job SET state='held',lease_token=NULL,lease_epoch=lease_epoch+1 WHERE profile_id=?1 AND state!='done'", [candidate.to_string()]).map_err(sql_error)?;
311 tx.commit()
312 }
313
314 pub fn activate(&self, candidate: Uuid) -> Result<()> {
315 let tx = TxGuard::begin(self.conn)?;
316 let profile = self.get(candidate)?;
317 if !matches!(self.state(&profile.store_key)?, StoreState::Rebuilding {candidate: c,..} if c==candidate)
318 {
319 return Err(MCSError::InvalidParams(
320 "candidate is not rebuilding".into(),
321 ));
322 }
323 verify_vectors_current(self.conn, candidate)?;
324 let generation = AnnGenerationRepository::new(self.conn).get(candidate)?;
325 if generation.full_scan_generation != Some(generation.durable_generation)
326 || generation.published_generation != generation.durable_generation
327 {
328 return Err(MCSError::InvalidParams(
329 "candidate requires verified Full scan and matching published ANN generation"
330 .into(),
331 ));
332 }
333 self.conn
334 .execute(
335 "UPDATE index_profile SET state='Retired' WHERE store_key=?1 AND state='Active'",
336 [&profile.store_key],
337 )
338 .map_err(sql_error)?;
339 self.conn
340 .execute(
341 "UPDATE index_profile SET state='Active' WHERE id=?1",
342 [candidate.to_string()],
343 )
344 .map_err(sql_error)?;
345 self.conn.execute("UPDATE index_profile_registry SET state='Active',serving_profile=?2,candidate_profile=NULL,failure_reason=NULL WHERE store_key=?1", params![profile.store_key,candidate.to_string()]).map_err(sql_error)?;
346 self.conn.execute("UPDATE chunk_index_job SET state='held',lease_token=NULL,lease_epoch=lease_epoch+1 WHERE profile_id!=?1 AND state!='done'", [candidate.to_string()]).map_err(sql_error)?;
347 tx.commit()
348 }
349}
350
351#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
352pub enum IndexOperation {
353 Upsert,
354 Delete,
355}
356
357#[derive(Clone, Debug, Serialize, Deserialize)]
358pub struct IndexJob {
359 pub profile_id: Uuid,
360 pub operation: IndexOperation,
361 pub owner_kind: OwnerKind,
362 pub owner_id: i64,
363 pub owner_revision: i64,
364 pub lease: Lease,
365 pub attempts: i64,
366}
367
368pub struct IndexJobRepository<'a> {
369 conn: &'a Connection,
370}
371
372impl<'a> IndexJobRepository<'a> {
373 pub const fn new(conn: &'a Connection) -> Self {
374 Self { conn }
375 }
376
377 pub fn claim_due(&self, now: i64, duration_us: i64) -> Result<Option<IndexJob>> {
378 let until = lease_until(now, duration_us)?;
379 let tx = TxGuard::begin(self.conn)?;
380 let row: Option<(String,i64,String,i64,String,i64,i64)> = self.conn.query_row("SELECT owner_kind,owner_id,profile_id,owner_revision,operation,lease_epoch,attempts FROM chunk_index_job j WHERE ((state='pending' AND next_attempt_us<=?1) OR (state='leased' AND lease_until_us<=?1)) AND EXISTS(SELECT 1 FROM index_profile_registry r WHERE r.serving_profile=j.profile_id OR (r.state='Rebuilding' AND r.candidate_profile=j.profile_id)) ORDER BY next_attempt_us,owner_kind,owner_id,profile_id LIMIT 1", [now], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).optional().map_err(sql_error)?;
381 let job = row.map(|(owner_kind,owner_id,profile,revision,operation,epoch,attempts)| -> Result<IndexJob> {
382 let token = Uuid::new_v4();
383 self.conn.execute("UPDATE chunk_index_job SET state='leased',lease_token=?4,lease_epoch=lease_epoch+1,lease_until_us=?5,attempts=attempts+1 WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3", params![profile,owner_kind,owner_id,token.to_string(),until]).map_err(sql_error)?;
384 Ok(IndexJob { profile_id:parse_uuid(&profile)?,operation:match operation.as_str() { "upsert"=>IndexOperation::Upsert,"delete"=>IndexOperation::Delete,_=>return Err(MCSError::MemoryError("invalid index operation".into())) },owner_kind:match owner_kind.as_str() { "entity"=>OwnerKind::Entity,"relation"=>OwnerKind::Relation,_=>return Err(MCSError::MemoryError("invalid owner kind".into())) },owner_id,owner_revision:revision,lease:Lease {token,epoch:epoch+1,until_us:until},attempts:attempts+1 })
385 }).transpose()?;
386 tx.commit()?;
387 Ok(job)
388 }
389
390 pub fn renew(&self, job: &IndexJob, now: i64, duration_us: i64) -> Result<bool> {
391 let until = lease_until(now, duration_us)?;
392 let tx = TxGuard::begin(self.conn)?;
393 let changed = self.conn.execute("UPDATE chunk_index_job SET lease_until_us=?7 WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3 AND lease_token=?4 AND lease_epoch=?5 AND state='leased' AND lease_until_us>?6", params![job.profile_id.to_string(),job.owner_kind.as_str(),job.owner_id,job.lease.token.to_string(),job.lease.epoch,now,until]).map_err(sql_error)?;
394 tx.commit()?;
395 Ok(changed == 1)
396 }
397
398 pub fn retry(
399 &self,
400 job: &IndexJob,
401 now: i64,
402 next_attempt_us: i64,
403 error: &str,
404 dead: bool,
405 ) -> Result<bool> {
406 let tx = TxGuard::begin(self.conn)?;
407 let changed = self.conn.execute("UPDATE chunk_index_job SET state=?7,next_attempt_us=?8,last_error=?9 WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3 AND lease_token=?4 AND lease_epoch=?5 AND state='leased' AND lease_until_us>?6", params![job.profile_id.to_string(),job.owner_kind.as_str(),job.owner_id,job.lease.token.to_string(),job.lease.epoch,now,if dead {"dead"} else {"pending"},next_attempt_us,error.chars().take(2048).collect::<String>()]).map_err(sql_error)?;
408 if changed == 1 && dead {
413 self.conn
414 .execute(
415 "DELETE FROM chunk_vector WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3",
416 params![job.profile_id.to_string(), job.owner_kind.as_str(), job.owner_id],
417 )
418 .map_err(sql_error)?;
419 }
420 tx.commit()?;
421 Ok(changed == 1)
422 }
423
424 pub fn commit_chunks(
431 &self,
432 job: &IndexJob,
433 now_us: i64,
434 chunks: Option<&[&(ChunkKind, &[f32])]>,
435 source: &str,
436 ) -> Result<bool> {
437 let tx = TxGuard::begin(self.conn)?;
438 let current = self
439 .conn
440 .query_row(
441 "SELECT owner_revision, state, lease_token, lease_epoch, lease_until_us
442 FROM chunk_index_job WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3",
443 params![
444 job.profile_id.to_string(),
445 job.owner_kind.as_str(),
446 job.owner_id
447 ],
448 |r| {
449 Ok((
450 r.get::<_, i64>(0)?,
451 r.get::<_, String>(1)?,
452 r.get::<_, Option<String>>(2)?,
453 r.get::<_, i64>(3)?,
454 r.get::<_, i64>(4)?,
455 ))
456 },
457 )
458 .optional()
459 .map_err(sql_error)?;
460 let Some((revision, state, token, epoch, until)) = current else {
461 return Ok(false);
462 };
463 if revision != job.owner_revision
464 || state != "leased"
465 || token != Some(job.lease.token.to_string())
466 || epoch != job.lease.epoch
467 || until <= now_us
468 {
469 return Ok(false);
470 }
471 let Some((live_revision, live_deleted)) =
474 self.owner_revision(self.conn, job.owner_kind, job.owner_id)?
475 else {
476 return Ok(false);
477 };
478 if live_revision != job.owner_revision {
479 return Ok(false);
480 }
481 match job.operation {
482 IndexOperation::Delete => {
483 if !live_deleted {
484 return Ok(false);
485 }
486 }
487 IndexOperation::Upsert => {
488 if live_deleted {
489 return Ok(false);
490 }
491 }
492 }
493 match (job.operation, chunks.is_some()) {
497 (IndexOperation::Upsert, true) | (IndexOperation::Delete, false) => {}
498 _ => {
499 return Err(MCSError::InvalidParams(
500 "chunk payload does not match job operation".into(),
501 ));
502 }
503 }
504 self.conn
505 .execute(
506 "DELETE FROM chunk_vector WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3",
507 params![
508 job.profile_id.to_string(),
509 job.owner_kind.as_str(),
510 job.owner_id
511 ],
512 )
513 .map_err(sql_error)?;
514 if let Some(chunk_list) = chunks {
515 let profile = IndexProfileRegistry::new(self.conn).get(job.profile_id)?;
516 let type_id = owner_type_id(self.conn, job.owner_kind, job.owner_id)?;
517 for (idx, (chunk_kind, vector)) in chunk_list.iter().enumerate() {
518 profile.validate_vector(vector)?;
519 self.conn
520 .execute(
521 "INSERT INTO chunk_vector(profile_id,kind,owner_kind,owner_id,chunk_index,type_id,owner_revision,blob,created_at_us,source)
522 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
523 params![
524 job.profile_id.to_string(),
525 chunk_kind.as_str(),
526 job.owner_kind.as_str(),
527 job.owner_id,
528 idx as i64,
529 type_id,
530 job.owner_revision,
531 vector.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>(),
532 now_us,
533 source,
534 ],
535 )
536 .map_err(sql_error)?;
537 }
538 }
539 self.conn
540 .execute(
541 "UPDATE chunk_index_job SET state='done' WHERE profile_id=?1 AND owner_kind=?2 AND owner_id=?3",
542 params![
543 job.profile_id.to_string(),
544 job.owner_kind.as_str(),
545 job.owner_id
546 ],
547 )
548 .map_err(sql_error)?;
549 self.conn
550 .execute(
551 "UPDATE ann_generation SET durable_generation=durable_generation+1,full_scan_generation=NULL WHERE profile_id=?1",
552 [job.profile_id.to_string()],
553 )
554 .map_err(sql_error)?;
555 if job.owner_kind == OwnerKind::Relation {
556 self.conn
562 .execute(
563 "INSERT INTO taxonomy_ann_generation(profile_id,subject_kind) VALUES(?1,2)
564 ON CONFLICT(profile_id,subject_kind) DO NOTHING",
565 [job.profile_id.to_string()],
566 )
567 .map_err(sql_error)?;
568 self.conn
569 .execute(
570 "UPDATE taxonomy_ann_generation SET durable_generation=durable_generation+1,full_scan_generation=NULL
571 WHERE profile_id=?1 AND subject_kind=2",
572 [job.profile_id.to_string()],
573 )
574 .map_err(sql_error)?;
575 }
576 tx.commit()?;
577 Ok(true)
578 }
579
580 pub fn owner_revision(
581 &self,
582 conn: &Connection,
583 owner_kind: OwnerKind,
584 owner_id: i64,
585 ) -> Result<Option<(i64, bool)>> {
586 match owner_kind {
587 OwnerKind::Entity => conn
588 .query_row(
589 "SELECT revision, deleted FROM entity_revision WHERE entity_id=?1",
590 [owner_id],
591 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, bool>(1)?)),
592 )
593 .optional()
594 .map_err(sql_error),
595 OwnerKind::Relation => conn
596 .query_row(
597 "SELECT revision, deleted FROM taxonomy_relation WHERE id=?1",
598 [owner_id],
599 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, bool>(1)?)),
600 )
601 .optional()
602 .map_err(sql_error),
603 }
604 }
605}
606
607fn owner_type_id(conn: &Connection, owner_kind: OwnerKind, owner_id: i64) -> Result<i64> {
610 match owner_kind {
611 OwnerKind::Entity => conn
612 .query_row("SELECT type_id FROM entity WHERE id=?1", [owner_id], |r| {
613 r.get(0)
614 })
615 .map_err(sql_error),
616 OwnerKind::Relation => conn
617 .query_row(
618 "SELECT type_id FROM taxonomy_relation WHERE id=?1",
619 [owner_id],
620 |r| r.get(0),
621 )
622 .map_err(sql_error),
623 }
624}
625
626fn verify_vectors_current(conn: &Connection, profile: Uuid) -> Result<()> {
627 let invalid: bool = conn
636 .query_row(
637 "SELECT EXISTS(
638 SELECT 1 FROM entity e
639 JOIN entity_revision r ON r.entity_id = e.id
640 LEFT JOIN chunk_vector v ON v.profile_id=?1 AND v.owner_kind='entity'
641 AND v.owner_id=e.id AND v.kind='identity'
642 WHERE e.flags=0
643 AND NOT EXISTS(SELECT 1 FROM chunk_index_job d
644 WHERE d.profile_id=?1 AND d.owner_kind='entity' AND d.owner_id=e.id
645 AND d.state='dead')
646 AND (v.owner_id IS NULL OR v.owner_revision != r.revision)
647)
648OR EXISTS(
649 SELECT 1 FROM chunk_vector v
650 LEFT JOIN entity e ON e.id=v.owner_id
651 WHERE v.profile_id=?1 AND v.owner_kind='entity'
652 AND (e.id IS NULL OR e.flags!=0)
653)
654OR EXISTS(
655 SELECT 1 FROM taxonomy_relation m
656 LEFT JOIN chunk_vector v ON v.profile_id=?1 AND v.owner_kind='relation'
657 AND v.owner_id=m.id AND v.kind='relation'
658 WHERE m.deleted=0
659 AND NOT EXISTS(SELECT 1 FROM chunk_index_job d
660 WHERE d.profile_id=?1 AND d.owner_kind='relation' AND d.owner_id=m.id
661 AND d.state='dead')
662 AND (v.owner_id IS NULL OR v.owner_revision != m.revision)
663)
664OR EXISTS(
665 SELECT 1 FROM chunk_vector v
666 LEFT JOIN taxonomy_relation m ON m.id=v.owner_id
667 WHERE v.profile_id=?1 AND v.owner_kind='relation'
668 AND (m.id IS NULL OR m.deleted!=0)
669)
670OR EXISTS(
671 SELECT 1 FROM chunk_index_job WHERE profile_id=?1 AND state NOT IN ('done','dead')
672)",
673 [profile.to_string()],
674 |r| r.get(0),
675 )
676 .map_err(sql_error)?;
677 if invalid {
678 return Err(MCSError::InvalidParams(
679 "candidate Full scan has missing or stale vectors/jobs".into(),
680 ));
681 }
682 Ok(())
683}
684
685#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
686pub struct AnnGeneration {
687 pub profile_id: Uuid,
688 pub durable_generation: i64,
689 pub published_generation: i64,
690 pub full_scan_generation: Option<i64>,
691}
692
693pub struct AnnGenerationRepository<'a> {
694 conn: &'a Connection,
695}
696
697impl<'a> AnnGenerationRepository<'a> {
698 pub const fn new(conn: &'a Connection) -> Self {
699 Self { conn }
700 }
701
702 pub fn get(&self, profile: Uuid) -> Result<AnnGeneration> {
703 self.conn.query_row("SELECT durable_generation,published_generation,full_scan_generation FROM ann_generation WHERE profile_id=?1", [profile.to_string()], |r| Ok(AnnGeneration {profile_id:profile,durable_generation:r.get(0)?,published_generation:r.get(1)?,full_scan_generation:r.get(2)?})).map_err(sql_error)
704 }
705
706 pub fn verify_full_scan(&self, profile: Uuid) -> Result<()> {
707 let tx = TxGuard::begin(self.conn)?;
708 verify_vectors_current(self.conn, profile)?;
709 let changed = self.conn.execute("UPDATE ann_generation SET full_scan_generation=durable_generation WHERE profile_id=?1", [profile.to_string()]).map_err(sql_error)?;
710 if changed != 1 {
711 return Err(MCSError::InvalidParams("unknown ANN profile".into()));
712 }
713 tx.commit()
714 }
715
716 pub fn mark_published(&self, profile: Uuid, generation: i64) -> Result<bool> {
719 let tx = TxGuard::begin(self.conn)?;
720 let changed = self.conn.execute("UPDATE ann_generation SET published_generation=?2 WHERE profile_id=?1 AND durable_generation=?2", params![profile.to_string(),generation]).map_err(sql_error)?;
721 tx.commit()?;
722 Ok(changed == 1)
723 }
724}
725
726pub(crate) fn enqueue_taxonomy(
731 conn: &Connection,
732 kind: i64,
733 id: i64,
734 revision: i64,
735 operation: IndexOperation,
736 profile_id: Uuid,
737) -> Result<()> {
738 let state = if profile_id.is_nil() {
741 "held"
742 } else {
743 "pending"
744 };
745 conn.execute("INSERT INTO taxonomy_job(subject_kind,subject_id,profile_id,subject_revision,operation,state) VALUES(?1,?2,?3,?4,?5,?6) ON CONFLICT(subject_kind,subject_id,profile_id) DO UPDATE SET subject_revision=excluded.subject_revision,operation=excluded.operation,state=excluded.state,lease_token=NULL,lease_epoch=lease_epoch+1,lease_until_us=0,attempts=0,next_attempt_us=0,last_error=NULL", params![kind,id,profile_id.to_string(),revision,match operation { IndexOperation::Upsert => "upsert", IndexOperation::Delete => "delete" },state]).map_err(sql_error)?;
746 if !profile_id.is_nil() {
751 conn.execute(
752 "INSERT INTO taxonomy_ann_generation(profile_id,subject_kind) VALUES(?1,?2) ON CONFLICT(profile_id,subject_kind) DO NOTHING",
753 params![profile_id.to_string(), kind],
754 )
755 .map_err(sql_error)?;
756 }
757 conn.execute(
758 "UPDATE taxonomy_ann_generation SET full_scan_generation=NULL WHERE profile_id=?1 AND subject_kind=?2",
759 params![profile_id.to_string(), kind],
760 )
761 .map_err(sql_error)?;
762 Ok(())
763}
764
765#[derive(Clone, Debug, Serialize, Deserialize)]
766pub struct TaxonomyJob {
767 pub subject_kind: i64,
768 pub subject_id: i64,
769 pub subject_revision: i64,
770 pub profile_id: Uuid,
771 pub operation: IndexOperation,
772 pub lease: Lease,
773 pub attempts: i64,
774}
775
776pub struct TaxonomyJobRepository<'a> {
777 conn: &'a Connection,
778}
779
780impl<'a> TaxonomyJobRepository<'a> {
781 pub const fn new(conn: &'a Connection) -> Self {
782 Self { conn }
783 }
784
785 pub fn claim_due(&self, now: i64, duration_us: i64) -> Result<Option<TaxonomyJob>> {
786 let until = lease_until(now, duration_us)?;
787 let tx = TxGuard::begin(self.conn)?;
788 let row: Option<(i64,i64,String,i64,String,i64,i64)> = self.conn.query_row("SELECT subject_kind,subject_id,profile_id,subject_revision,operation,lease_epoch,attempts FROM taxonomy_job j WHERE ((state='pending' AND next_attempt_us<=?1) OR (state='leased' AND lease_until_us<=?1)) AND subject_kind != 2 AND EXISTS(SELECT 1 FROM index_profile_registry r WHERE r.serving_profile=j.profile_id OR (r.state='Rebuilding' AND r.candidate_profile=j.profile_id)) ORDER BY next_attempt_us,subject_kind,subject_id,profile_id LIMIT 1", [now], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?,r.get(6)?))).optional().map_err(sql_error)?;
790 let job = row.map(|(kind,id,profile,revision,operation,epoch,attempts)| -> Result<TaxonomyJob> {
791 let token = Uuid::new_v4();
792 self.conn.execute("UPDATE taxonomy_job SET state='leased',lease_token=?4,lease_epoch=lease_epoch+1,lease_until_us=?5,attempts=attempts+1 WHERE subject_kind=?1 AND subject_id=?2 AND profile_id=?3", params![kind,id,profile,token.to_string(),until]).map_err(sql_error)?;
793 Ok(TaxonomyJob { subject_kind:kind,subject_id:id,subject_revision:revision,profile_id:parse_uuid(&profile)?,operation:match operation.as_str() { "upsert"=>IndexOperation::Upsert,"delete"=>IndexOperation::Delete,_=>return Err(MCSError::MemoryError("invalid taxonomy operation".into())) },lease:Lease {token,epoch:epoch+1,until_us:until},attempts:attempts+1 })
794 }).transpose()?;
795 tx.commit()?;
796 Ok(job)
797 }
798
799 pub fn renew(&self, job: &TaxonomyJob, now: i64, duration_us: i64) -> Result<bool> {
800 let until = lease_until(now, duration_us)?;
801 let tx = TxGuard::begin(self.conn)?;
802 let changed = self.conn.execute("UPDATE taxonomy_job SET lease_until_us=?7 WHERE subject_kind=?1 AND subject_id=?2 AND profile_id=?3 AND lease_token=?4 AND lease_epoch=?5 AND state='leased' AND lease_until_us>?6", params![job.subject_kind,job.subject_id,job.profile_id.to_string(),job.lease.token.to_string(),job.lease.epoch,now,until]).map_err(sql_error)?;
803 tx.commit()?;
804 Ok(changed == 1)
805 }
806
807 pub fn retry(
808 &self,
809 job: &TaxonomyJob,
810 now: i64,
811 next_attempt_us: i64,
812 error: &str,
813 dead: bool,
814 ) -> Result<bool> {
815 let tx = TxGuard::begin(self.conn)?;
816 let changed = self.conn.execute("UPDATE taxonomy_job SET state=?7,next_attempt_us=?8,last_error=?9 WHERE subject_kind=?1 AND subject_id=?2 AND profile_id=?3 AND lease_token=?4 AND lease_epoch=?5 AND state='leased' AND lease_until_us>?6", params![job.subject_kind,job.subject_id,job.profile_id.to_string(),job.lease.token.to_string(),job.lease.epoch,now,if dead {"dead"} else {"pending"},next_attempt_us,error.chars().take(2048).collect::<String>()]).map_err(sql_error)?;
817 if changed == 1 && dead {
822 self.conn
823 .execute(
824 "DELETE FROM taxonomy_vector WHERE profile_id=?1 AND subject_kind=?2 AND subject_id=?3",
825 params![job.profile_id.to_string(), job.subject_kind, job.subject_id],
826 )
827 .map_err(sql_error)?;
828 }
829 tx.commit()?;
830 Ok(changed == 1)
831 }
832
833 pub fn commit_vector(
836 &self,
837 job: &TaxonomyJob,
838 now: i64,
839 vector: Option<&[f32]>,
840 source: &str,
841 ) -> Result<bool> {
842 let tx = TxGuard::begin(self.conn)?;
843 let state: Option<(String,i64)> = self.conn.query_row("SELECT state,lease_until_us FROM taxonomy_job WHERE subject_kind=?1 AND subject_id=?2 AND profile_id=?3 AND subject_revision=?4 AND lease_token=?5 AND lease_epoch=?6", params![job.subject_kind,job.subject_id,job.profile_id.to_string(),job.subject_revision,job.lease.token.to_string(),job.lease.epoch], |r| Ok((r.get(0)?,r.get(1)?))).optional().map_err(sql_error)?;
844 if matches!(&state,Some((state,_)) if state=="done") {
845 tx.commit()?;
846 return Ok(true);
847 }
848 if !matches!(state,Some((state,until)) if state=="leased" && until>now) {
849 return Ok(false);
850 }
851 let source_revision: i64 = self
852 .conn
853 .query_row(
854 "SELECT revision FROM type_dict WHERE id=?1 AND kind=?2",
855 params![job.subject_id, job.subject_kind],
856 |r| r.get(0),
857 )
858 .optional()
859 .map_err(sql_error)?
860 .unwrap_or(i64::MAX);
861 if source_revision != job.subject_revision {
862 return Ok(false);
863 }
864 match (job.operation, vector) {
865 (IndexOperation::Upsert, Some(vector)) => {
866 let bytes: Vec<u8> = vector.iter().flat_map(|x| x.to_le_bytes()).collect();
867 self.conn.execute("INSERT INTO taxonomy_vector VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(profile_id,subject_kind,subject_id) DO UPDATE SET subject_revision=excluded.subject_revision,blob=excluded.blob,created_at_us=excluded.created_at_us,source=excluded.source", params![job.profile_id.to_string(),job.subject_kind,job.subject_id,job.subject_revision,bytes,now,source]).map_err(sql_error)?;
868 }
869 (IndexOperation::Delete, None) => {
870 }
874 _ => {
875 return Err(MCSError::InvalidParams(
876 "vector payload does not match job operation".into(),
877 ));
878 }
879 }
880 self.conn
881 .execute(
882 "UPDATE taxonomy_job SET state='done' WHERE subject_kind=?1 AND subject_id=?2 AND profile_id=?3 AND lease_token=?4 AND lease_epoch=?5",
883 params![job.subject_kind, job.subject_id, job.profile_id.to_string(), job.lease.token.to_string(), job.lease.epoch],
884 )
885 .map_err(sql_error)?;
886 self.conn
890 .execute(
891 "UPDATE taxonomy_ann_generation SET durable_generation=durable_generation+1,full_scan_generation=NULL WHERE profile_id=?1 AND subject_kind=?2",
892 params![job.profile_id.to_string(), job.subject_kind],
893 )
894 .map_err(sql_error)?;
895 tx.commit()?;
896 Ok(true)
897 }
898}
899
900pub fn taxonomy_scan_invalid(conn: &Connection, profile_id: Uuid, kind: i64) -> Result<bool> {
905 let profile = profile_id.to_string();
906 let invalid: bool = match kind {
907 0 | 1 => conn.query_row("SELECT EXISTS(SELECT 1 FROM type_dict s WHERE s.kind=?2 AND s.count>0 AND NOT EXISTS(SELECT 1 FROM taxonomy_job j WHERE j.subject_kind=?2 AND j.subject_id=s.id AND j.profile_id=?1 AND j.state!='dead')) OR EXISTS(SELECT 1 FROM taxonomy_vector v JOIN type_dict s ON s.id=v.subject_id WHERE v.profile_id=?1 AND v.subject_kind=?2 AND s.kind=?2 AND s.count>0 AND v.subject_revision!=s.revision)", params![profile,kind], |r| r.get(0)).map_err(sql_error)?,
909 2 => conn.query_row("SELECT EXISTS(SELECT 1 FROM taxonomy_relation s WHERE s.deleted=0 AND NOT EXISTS(SELECT 1 FROM chunk_index_job j WHERE j.owner_kind='relation' AND j.owner_id=s.id AND j.profile_id=?1 AND j.state!='dead')) OR EXISTS(SELECT 1 FROM chunk_vector v JOIN taxonomy_relation s ON s.id=v.owner_id WHERE v.profile_id=?1 AND v.kind='relation' AND s.deleted=0 AND v.owner_revision!=s.revision)", [profile], |r| r.get(0)).map_err(sql_error)?,
914 _ => return Err(MCSError::MemoryError("invalid taxonomy subject kind".into())),
915 };
916 Ok(invalid)
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use crate::schema::initialize_database;
923
924 fn fixture() -> (Connection, Uuid) {
926 let conn = Connection::open_in_memory().unwrap();
927 initialize_database(&conn).unwrap();
928 let profile = Uuid::new_v4();
929 conn.execute(
930 "UPDATE index_profile_registry SET state='Active',serving_profile=?1 WHERE store_key='default'",
931 [profile.to_string()],
932 )
933 .unwrap();
934 (conn, profile)
935 }
936
937 fn count(conn: &Connection, table: &str) -> i64 {
938 conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
939 .unwrap()
940 }
941
942 fn seed_type(conn: &Connection, id: i64, kind: i64, revision: i64) {
943 conn.execute(
944 "INSERT INTO type_dict(id,kind,name,count,revision) VALUES(?1,?2,?3,?4,?5)",
945 params![id, kind, format!("type{kind}-{id}"), 1, revision],
946 )
947 .unwrap();
948 }
949
950 fn seed_relation(conn: &Connection, id: i64, revision: i64, deleted: i64) {
951 conn.execute(
952 "INSERT INTO taxonomy_relation(id,from_id,to_id,type_id,revision,deleted) VALUES(?1,?2,?3,?4,?5,?6)",
953 params![id, id * 100, id * 100 + 1, 2, revision, deleted],
954 )
955 .unwrap();
956 }
957
958 #[test]
959 fn enqueue_after_enqueue_upserts_and_bumps_lease_epoch() {
960 let (conn, profile) = fixture();
961 conn.execute(
962 "INSERT INTO taxonomy_ann_generation(profile_id,subject_kind,durable_generation,full_scan_generation) VALUES(?1,0,5,5)",
963 [profile.to_string()],
964 )
965 .unwrap();
966 enqueue_taxonomy(&conn, 0, 7, 3, IndexOperation::Upsert, profile).unwrap();
967 let repo = TaxonomyJobRepository::new(&conn);
968 let first = repo.claim_due(100, 100).unwrap().unwrap();
969 assert_eq!(
970 (first.subject_kind, first.subject_id, first.subject_revision),
971 (0, 7, 3)
972 );
973 enqueue_taxonomy(&conn, 0, 7, 4, IndexOperation::Upsert, profile).unwrap();
974 let (revision, epoch, token, state): (i64, i64, Option<String>, String) = conn
975 .query_row(
976 "SELECT subject_revision,lease_epoch,lease_token,state FROM taxonomy_job WHERE subject_kind=0 AND subject_id=7 AND profile_id=?1",
977 [profile.to_string()],
978 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
979 )
980 .unwrap();
981 assert_eq!(revision, 4);
982 assert_eq!(epoch, first.lease.epoch + 1);
983 assert!(token.is_none());
984 assert_eq!(state, "pending");
985 assert!(
987 !repo
988 .commit_vector(&first, 101, Some(&[1.0]), "worker")
989 .unwrap()
990 );
991 assert_eq!(count(&conn, "taxonomy_vector"), 0);
992 let generation: Option<i64> = conn
993 .query_row(
994 "SELECT full_scan_generation FROM taxonomy_ann_generation WHERE profile_id=?1 AND subject_kind=0",
995 [profile.to_string()],
996 |r| r.get(0),
997 )
998 .unwrap();
999 assert_eq!(generation, None);
1000 }
1001
1002 #[test]
1003 fn claim_picks_the_oldest_due_job() {
1004 let (conn, profile) = fixture();
1005 enqueue_taxonomy(&conn, 1, 3, 1, IndexOperation::Upsert, Uuid::nil()).unwrap();
1006 seed_type(&conn, 1, 0, 1);
1007 enqueue_taxonomy(&conn, 0, 1, 1, IndexOperation::Upsert, profile).unwrap();
1008 seed_type(&conn, 3, 1, 9);
1009 enqueue_taxonomy(&conn, 1, 3, 9, IndexOperation::Upsert, profile).unwrap();
1010 conn.execute(
1011 "UPDATE taxonomy_job SET next_attempt_us=500 WHERE subject_kind=1 AND subject_id=3",
1012 [],
1013 )
1014 .unwrap();
1015 let repo = TaxonomyJobRepository::new(&conn);
1016 let first = repo.claim_due(100, 10).unwrap().unwrap();
1017 assert_eq!((first.subject_kind, first.subject_id), (0, 1));
1018 assert_eq!(first.operation, IndexOperation::Upsert);
1019 assert_eq!((first.lease.epoch, first.lease.until_us), (1, 110));
1020 let (state, attempts): (String, i64) = conn
1021 .query_row(
1022 "SELECT state,attempts FROM taxonomy_job WHERE subject_kind=0 AND subject_id=1 AND profile_id=?1",
1023 [profile.to_string()],
1024 |r| Ok((r.get(0)?, r.get(1)?)),
1025 )
1026 .unwrap();
1027 assert_eq!((state.as_str(), attempts), ("leased", 1));
1028 assert!(repo.claim_due(100, 10).unwrap().is_none());
1030 assert!(
1032 repo.commit_vector(&first, 101, Some(&[1.0, 0.0]), "worker")
1033 .unwrap()
1034 );
1035 let second = repo.claim_due(500, 10).unwrap().unwrap();
1036 assert_eq!(
1037 (
1038 second.subject_kind,
1039 second.subject_id,
1040 second.subject_revision
1041 ),
1042 (1, 3, 9)
1043 );
1044 assert_eq!(second.lease.until_us, 510);
1045 assert_eq!(count(&conn, "taxonomy_job"), 3);
1046 }
1047
1048 #[test]
1049 fn commit_refuses_after_lease_expiry() {
1050 let (conn, profile) = fixture();
1051 seed_type(&conn, 1, 0, 7);
1052 enqueue_taxonomy(&conn, 0, 1, 7, IndexOperation::Upsert, profile).unwrap();
1053 let repo = TaxonomyJobRepository::new(&conn);
1054 let job = repo.claim_due(100, 10).unwrap().unwrap();
1055 assert!(
1056 !repo
1057 .commit_vector(&job, 111, Some(&[1.0, 0.0]), "worker")
1058 .unwrap()
1059 );
1060 assert_eq!(count(&conn, "taxonomy_vector"), 0);
1061 let state: String = conn
1062 .query_row(
1063 "SELECT state FROM taxonomy_job WHERE subject_kind=0 AND subject_id=1 AND profile_id=?1",
1064 [profile.to_string()],
1065 |r| r.get(0),
1066 )
1067 .unwrap();
1068 assert_eq!(state, "leased");
1069 }
1070
1071 #[test]
1072 fn commit_refuses_on_revision_mismatch_for_claimable_kinds() {
1073 let (conn, profile) = fixture();
1076 seed_type(&conn, 1, 0, 7);
1077 seed_type(&conn, 2, 1, 4);
1078 let repo = TaxonomyJobRepository::new(&conn);
1079 for (kind, id, revision) in [(0, 1, 6), (1, 2, 3)] {
1080 enqueue_taxonomy(&conn, kind, id, revision, IndexOperation::Upsert, profile).unwrap();
1081 let job = repo.claim_due(100 + id, 10).unwrap().unwrap();
1082 assert_eq!((job.subject_kind, job.subject_id), (kind, id));
1083 assert!(
1084 !repo
1085 .commit_vector(&job, 101 + id, Some(&[1.0, 0.0]), "worker")
1086 .unwrap()
1087 );
1088 }
1089 assert_eq!(count(&conn, "taxonomy_vector"), 0);
1090 assert_eq!(count(&conn, "taxonomy_job"), 2);
1091 }
1092
1093 #[test]
1094 fn the_valid_path_succeeds_and_writes_the_vector_row() {
1095 let (conn, profile) = fixture();
1096 seed_type(&conn, 1, 0, 7);
1097 enqueue_taxonomy(&conn, 0, 1, 7, IndexOperation::Upsert, profile).unwrap();
1098 let repo = TaxonomyJobRepository::new(&conn);
1099 let job = repo.claim_due(100, 10).unwrap().unwrap();
1100 assert!(
1101 repo.commit_vector(&job, 105, Some(&[1.0, 0.0]), "worker")
1102 .unwrap()
1103 );
1104 let (kind, revision, blob, created_at, source): (i64, i64, Vec<u8>, i64, String) = conn
1105 .query_row(
1106 "SELECT subject_kind,subject_revision,blob,created_at_us,source FROM taxonomy_vector WHERE profile_id=?1 AND subject_kind=0 AND subject_id=1",
1107 [profile.to_string()],
1108 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
1109 )
1110 .unwrap();
1111 assert_eq!(kind, 0);
1112 assert_eq!(revision, 7);
1113 assert_eq!(blob, [0, 0, 128, 63, 0, 0, 0, 0]);
1114 assert_eq!(created_at, 105);
1115 assert_eq!(source, "worker");
1116 let state: String = conn
1117 .query_row(
1118 "SELECT state FROM taxonomy_job WHERE subject_kind=0 AND subject_id=1 AND profile_id=?1",
1119 [profile.to_string()],
1120 |r| r.get(0),
1121 )
1122 .unwrap();
1123 assert_eq!(state, "done");
1124 assert!(repo.commit_vector(&job, 106, None, "worker").unwrap());
1126 assert_eq!(count(&conn, "taxonomy_vector"), 1);
1127 }
1128
1129 #[test]
1130 fn commit_bumps_the_kind_generation_on_success_only() {
1131 let (conn, profile) = fixture();
1132 conn.execute(
1133 "INSERT INTO taxonomy_ann_generation(profile_id,subject_kind,durable_generation,published_generation,full_scan_generation) VALUES(?1,0,4,-1,4)",
1134 [profile.to_string()],
1135 )
1136 .unwrap();
1137 seed_type(&conn, 1, 0, 7);
1138 enqueue_taxonomy(&conn, 0, 1, 7, IndexOperation::Upsert, profile).unwrap();
1139 let repo = TaxonomyJobRepository::new(&conn);
1140 let job = repo.claim_due(100, 10).unwrap().unwrap();
1141 assert!(
1144 !repo
1145 .commit_vector(&job, 111, Some(&[1.0, 0.0]), "worker")
1146 .unwrap()
1147 );
1148 let (durable, full_scan): (i64, Option<i64>) = conn
1149 .query_row(
1150 "SELECT durable_generation,full_scan_generation FROM taxonomy_ann_generation WHERE profile_id=?1 AND subject_kind=0",
1151 [profile.to_string()],
1152 |r| Ok((r.get(0)?, r.get(1)?)),
1153 )
1154 .unwrap();
1155 assert_eq!((durable, full_scan), (4, None));
1156 assert!(
1158 repo.commit_vector(&job, 105, Some(&[1.0, 0.0]), "worker")
1159 .unwrap()
1160 );
1161 let (durable, full_scan): (i64, Option<i64>) = conn
1162 .query_row(
1163 "SELECT durable_generation,full_scan_generation FROM taxonomy_ann_generation WHERE profile_id=?1 AND subject_kind=0",
1164 [profile.to_string()],
1165 |r| Ok((r.get(0)?, r.get(1)?)),
1166 )
1167 .unwrap();
1168 assert_eq!((durable, full_scan), (5, None));
1169 assert!(repo.commit_vector(&job, 106, None, "worker").unwrap());
1171 let durable: i64 = conn
1172 .query_row(
1173 "SELECT durable_generation FROM taxonomy_ann_generation WHERE profile_id=?1 AND subject_kind=0",
1174 [profile.to_string()],
1175 |r| r.get(0),
1176 )
1177 .unwrap();
1178 assert_eq!(durable, 5);
1179 }
1180
1181 #[test]
1182 fn taxonomy_scan_invalid_reports_stale_or_missing_work() {
1183 let (conn, profile) = fixture();
1184 seed_type(&conn, 1, 0, 7);
1185 seed_relation(&conn, 10, 5, 0);
1186 let repo = TaxonomyJobRepository::new(&conn);
1187 enqueue_taxonomy(&conn, 0, 1, 7, IndexOperation::Upsert, profile).unwrap();
1189 let job = repo.claim_due(100, 10).unwrap().unwrap();
1190 assert!(
1191 repo.commit_vector(&job, 101, Some(&[1.0, 0.0]), "worker")
1192 .unwrap()
1193 );
1194 assert!(!taxonomy_scan_invalid(&conn, profile, 0).unwrap());
1195 conn.execute(
1199 "INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation,state) VALUES(?1,'relation',10,5,'upsert','done')",
1200 [profile.to_string()],
1201 )
1202 .unwrap();
1203 conn.execute(
1204 "INSERT INTO chunk_vector(profile_id,kind,owner_kind,owner_id,chunk_index,type_id,owner_revision,blob,created_at_us,source) VALUES(?1,'relation','relation',10,0,1,5,X'000000000000803F000000000000803F',1,'old')",
1205 [profile.to_string()],
1206 )
1207 .unwrap();
1208 assert!(!taxonomy_scan_invalid(&conn, profile, 2).unwrap());
1209 conn.execute("UPDATE type_dict SET revision=8 WHERE id=1", [])
1211 .unwrap();
1212 assert!(taxonomy_scan_invalid(&conn, profile, 0).unwrap());
1213 conn.execute("UPDATE type_dict SET revision=7 WHERE id=1", [])
1214 .unwrap();
1215 conn.execute(
1217 "DELETE FROM taxonomy_job WHERE subject_kind=0 AND subject_id=1",
1218 [],
1219 )
1220 .unwrap();
1221 assert!(taxonomy_scan_invalid(&conn, profile, 0).unwrap());
1222 assert!(!taxonomy_scan_invalid(&conn, profile, 2).unwrap());
1223 seed_relation(&conn, 12, 2, 0);
1225 conn.execute(
1226 "INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation,state) VALUES(?1,'relation',12,2,'upsert','pending')",
1227 [profile.to_string()],
1228 )
1229 .unwrap();
1230 assert!(!taxonomy_scan_invalid(&conn, profile, 2).unwrap());
1231 seed_relation(&conn, 11, 3, 0);
1233 conn.execute(
1234 "INSERT INTO chunk_index_job(profile_id,owner_kind,owner_id,owner_revision,operation,state) VALUES(?1,'relation',11,3,'upsert','dead')",
1235 [profile.to_string()],
1236 )
1237 .unwrap();
1238 assert!(taxonomy_scan_invalid(&conn, profile, 2).unwrap());
1239 conn.execute("UPDATE type_dict SET count=0 WHERE id=1", [])
1241 .unwrap();
1242 assert!(!taxonomy_scan_invalid(&conn, profile, 0).unwrap());
1243 }
1244}