Skip to main content

mcpmem_core/
jobs.rs

1//! Provider-neutral index jobs and vector-space registry.
2use 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
9/// Profile ids that must receive an index-job update. When no managed
10/// profile serves the store, the list holds the nil id so the job is held.
11pub(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
24/// Queue one owner for every serving profile, keyed on `chunk_index_job`
25/// `(profile_id, owner_kind, owner_id)`. A nil profile is an explicitly held
26/// job, never a claimable provider profile; managed serving and candidate
27/// profiles receive pending rows. One row per owner and profile.
28pub(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
64/// The entity path of [`enqueue_chunk_change`], kept as the narrow wrapper so
65/// the change-event hook in `events.rs` keeps its entity-only signature. The
66/// legacy `index_job` table was dropped by migration 0009.
67pub(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    /// serde_json's default sorted object map is the canonical key order.
156    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    /// The connection this registry reads. The server crate serves taxonomy
209    /// snapshots through it: the generation read, the vector rows and the
210    /// publish mark then share one transaction view with the registry state.
211    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        // Profiles are immutable. Reusing a retired/candidate ID would also
273        // reuse its generation and vectors, defeating the rebuild boundary.
274        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        // Every relation mirror joins the rebuild: live relations embed as
296        // upserts and tombstoned mirrors re-run as deletes, so a rebuild
297        // also purges orphan chunk rows for relations that no longer exist.
298        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        // A dead-lettered owner must not keep a stale chunk set in the
409        // candidate: the verified full-scan gate would otherwise publish a
410        // snapshot serving an outdated embedding. Its next write re-enqueues
411        // the owner from scratch.
412        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    /// Fenced chunk commit: delete the owner's old chunk rows, insert the new
425    /// ones, and advance the durable generation in one transaction. A delete
426    /// operation passes `None` as the chunks and removes the rows. Every
427    /// vector is validated against the profile before it is stored, so a
428    /// wrong dimension, a NaN, or a non-unit L2 vector fails the job instead
429    /// of poisoning the snapshot.
430    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        // The fence compares the job revision against the LIVE owner revision
472        // (entity_revision or taxonomy_relation), so a stale job never lands.
473        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        // The payload must match the operation, before any row is touched:
494        // an upsert without chunks would otherwise wipe the owner's rows and
495        // a delete with chunks would write for a tombstoned owner.
496        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            // Relation commits also advance the taxonomy kind-2 generation so
557            // the derived snapshot refreshes (Task 5 reads it). The first
558            // relation commit for a profile creates the kind's generation
559            // marker, mirroring what enqueue_taxonomy did for kinds 0/1; the
560            // old kind-2 funnel that created the row is retired.
561            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
607/// The `type_id` of one owner, used to tag its chunk rows. Single-row
608/// lookups mirror the source tables the owner revision fence reads.
609fn 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    // The full-scan gate accepts only a fully current chunk set across both
628    // owner kinds. A dead-lettered job declares its owner unindexable: the
629    // worker deletes the owner's chunk rows when it dead-letters, so no
630    // stale chunk sneaks into the snapshot, and the gate must not block the
631    // whole store on it. Any unfinished job also fails the scan: a pending
632    // owner is exactly a chunk the worker has not written yet. Rebuild
633    // enqueues every relation mirror, so a relation without its chunk can
634    // only mean the worker has not caught up.
635    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    /// Call only after building the replacement reader from a consistent read
717    /// snapshot at this generation. A concurrent durable update rejects publish.
718    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
726/// Queue one taxonomy subject for one profile. A nil profile means an
727/// explicitly held job, in the same way the entity path holds its LegacyCompat
728/// fallback. The generation marker resets so a later full-scan verification
729/// re-checks this kind from scratch.
730pub(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    // A nil profile is an explicitly held job, mirroring the entity path's
739    // LegacyCompat fallback: the store has no managed profile to serve it.
740    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    // The first subject queued for a managed profile creates the kind's
747    // generation marker. A commit advances that row and reconciliation
748    // serves it; without the row, the commit bumps nothing and the kind
749    // never becomes serveable. A nil profile has nothing to serve.
750    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        // 0009 purges kind-2 rows in the same startup transaction and commit defends on missing rows; one must never claim.
789        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        // A dead-lettered subject must not keep a stale vector row: the
818        // full-scan gate would otherwise publish an outdated embedding. Its
819        // next write re-enqueues the subject from scratch, mirroring the
820        // entity dead-letter cleanup.
821        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    /// Fenced durable effect and completion are indivisible, mirroring the
834    /// entity commit. A repeated completion is a no-op.
835    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                // Kinds 0/1 carry no tombstone and never enqueue deletes; a
871                // delete against them is a no-op rather than an error, keeping
872                // the retried-claim path harmless.
873            }
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        // The kind generation is the freshness signal for the semantic tier:
887        // every committed vector retires the kind's snapshot, exactly like the
888        // entity path retires one per committed entity vector.
889        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
900/// Soft full-scan completeness check for one taxonomy kind. It reports whether
901/// the kind is missing queued work or carries a stale vector, without failing
902/// the caller. Public because the server crate's VectorStore runs it before
903/// serving a candidate taxonomy snapshot.
904pub 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        // Kinds 0 and 1 read type_dict members as their source.
908        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        // Kind 2 derives from the relation chunk rows, so its scan state
910        // reads the same chunk sources `verify_vectors_current` checks: the
911        // relation mirror must have a live chunk job and a current vector,
912        // and no orphaned relation chunk rows may linger.
913        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    /// In-memory store with every migration applied and one serving profile.
925    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        // The superseded lease cannot commit the old revision.
986        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        // The held job is never claimable and the second job is not due yet.
1029        assert!(repo.claim_due(100, 10).unwrap().is_none());
1030        // Complete the first job so its expired lease cannot be re-claimed.
1031        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        // Kind 2 is retired (Task 5): its rows never claim, so only kinds
1074        // 0 and 1 exercise the revision fence here.
1075        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        // A repeated completion is a no-op.
1125        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        // A fence-refused commit does not bump the generation. (The enqueue
1142        // already cleared the marker; the durable count is the signal.)
1143        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        // A successful commit bumps by one and clears the marker.
1157        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        // A repeated completion is a no-op and does not bump again.
1170        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        // A fully indexed kind is valid.
1188        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        // Kind 2 derives from the relation chunk rows: a live job and a
1196        // current relation chunk make the source current, mirroring the
1197        // entity source but against `chunk_index_job`/`chunk_vector`.
1198        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        // A stale vector is invalid.
1210        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        // A missing job is invalid.
1216        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        // A pending job counts as queued work even before the vector exists.
1224        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        // A dead job does not count as queued work.
1232        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        // A type without members is not a source.
1240        conn.execute("UPDATE type_dict SET count=0 WHERE id=1", [])
1241            .unwrap();
1242        assert!(!taxonomy_scan_invalid(&conn, profile, 0).unwrap());
1243    }
1244}