Skip to main content

kcode_k1_audio_classification_projection/
lib.rs

1use kcode_k1_audio_classification_format::decode_event;
2pub use kcode_k1_audio_classification_projection_reducer::ProjectionEffect;
3use kcode_k1_audio_classification_projection_reducer::{event_fragment_id, reduce};
4#[cfg(feature = "testkit")]
5use kcode_k1_audio_classification_projection_state::append_error;
6pub use kcode_k1_audio_classification_projection_state::{
7    ExecutedAnalysis, FragmentId, FragmentStageV1, FragmentStatus, LlmJobState, LlmJobStatus,
8    OverallState, SpeakerLabelV1, StageState, StageStatus,
9};
10use kcode_k1_audio_classification_projection_state::{
11    actionable_state, interrupted_stage, validate_status,
12};
13use kcode_k1_transaction::Transaction;
14use kcode_k1_txn_ordering::{K1TxnOrdering, TxId};
15use rusqlite::{Connection, Error as SqlError, ErrorCode, OptionalExtension, params};
16use std::ffi::OsString;
17use std::fmt::Display;
18use std::fs;
19use std::ops::Deref;
20use std::path::{Path, PathBuf};
21use std::sync::{Mutex, MutexGuard};
22
23const DATABASE_NAME: &str = "audio-classification.sqlite3";
24const SUBSYSTEM: &str = "audio-classification";
25const SCHEMA_VERSION: i64 = 2;
26const METADATA_SQL: &str = "CREATE TABLE metadata(singleton INTEGER PRIMARY KEY CHECK(singleton = 1), schema_version INTEGER NOT NULL, last_applied_txid BLOB)";
27const FRAGMENTS_SQL: &str = "CREATE TABLE fragments(fragment_id BLOB PRIMARY KEY, actionable_state INTEGER NOT NULL, encoded_status BLOB NOT NULL)";
28const INDEX_SQL: &str = "CREATE INDEX fragments_actionable_state ON fragments(actionable_state)";
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct AppliedEvent {
32    pub fragment_id: FragmentId,
33    pub effect: ProjectionEffect,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct InterruptedFragment {
38    pub fragment_id: FragmentId,
39    pub stage: FragmentStageV1,
40}
41
42pub struct Projection {
43    connection: Mutex<Connection>,
44}
45
46impl Projection {
47    pub fn open(root: &Path, ordering: &K1TxnOrdering) -> Result<(Self, Option<TxId>), String> {
48        fs::create_dir_all(root).map_err(text)?;
49        let path = root.join(DATABASE_NAME);
50        let exists = path.try_exists().map_err(text)?;
51        let (connection, cursor) = if exists {
52            match open_existing(&path) {
53                Ok(value) => value,
54                Err(OpenIssue::Recoverable) => recreate(&path)?,
55                Err(OpenIssue::Fatal(error)) => return Err(error),
56            }
57        } else {
58            remove_sidecars(&path)?;
59            create_database(&path)?
60        };
61        if let Some(cursor) = cursor {
62            let bytes = ordering.get_txn(cursor).map_err(text)?;
63            let valid = bytes.as_deref().is_some_and(|bytes| {
64                Transaction::parse(bytes).is_ok_and(|value| value.subsystem().as_str() == SUBSYSTEM)
65            });
66            if !valid {
67                drop(connection);
68                let (connection, cursor) = recreate(&path)?;
69                return Ok((
70                    Self {
71                        connection: Mutex::new(connection),
72                    },
73                    cursor,
74                ));
75            }
76        }
77        Ok((
78            Self {
79                connection: Mutex::new(connection),
80            },
81            cursor,
82        ))
83    }
84
85    pub fn apply(&self, callback_txid: TxId, payload: &[u8]) -> Result<AppliedEvent, String> {
86        let event = decode_event(payload).map_err(text)?;
87        let fragment_id = event_fragment_id(&event);
88        let mut connection = self.lock()?;
89        let transaction = connection.transaction().map_err(text)?;
90        let reduction = reduce(
91            load_status(&transaction, fragment_id)?,
92            &event,
93            callback_txid,
94        )?;
95        write_status(&transaction, fragment_id, &reduction.status)?;
96        let updated = transaction
97            .execute(
98                "UPDATE metadata SET last_applied_txid = ?1 WHERE singleton = 1",
99                params![&callback_txid.as_bytes()[..]],
100            )
101            .map_err(text)?;
102        if updated != 1 {
103            return Err("projection metadata row is missing".to_string());
104        }
105        transaction.commit().map_err(text)?;
106        Ok(AppliedEvent {
107            fragment_id,
108            effect: reduction.effect,
109        })
110    }
111
112    pub fn status(&self, fragment_id: FragmentId) -> Result<Option<FragmentStatus>, String> {
113        load_status(&self.lock()?, fragment_id)
114    }
115
116    pub fn queued(&self) -> Result<Vec<FragmentId>, String> {
117        Ok(actionable_statuses(&self.lock()?, 1)?
118            .into_iter()
119            .map(|value| value.0)
120            .collect())
121    }
122
123    pub fn running(&self) -> Result<Vec<InterruptedFragment>, String> {
124        Ok(actionable_statuses(&self.lock()?, 2)?
125            .into_iter()
126            .map(|(fragment_id, status)| InterruptedFragment {
127                fragment_id,
128                stage: interrupted_stage(&status),
129            })
130            .collect())
131    }
132
133    pub fn validate_labels(
134        &self,
135        fragment_id: FragmentId,
136        labels: &[SpeakerLabelV1],
137    ) -> Result<TxId, String> {
138        let status = self.status(fragment_id)?.ok_or("unknown fragment")?;
139        kcode_k1_audio_classification_projection_state::validate_labels(&status, labels)
140    }
141
142    pub fn clear(&self) -> Result<(), String> {
143        let mut connection = self.lock()?;
144        let transaction = connection.transaction().map_err(text)?;
145        transaction
146            .execute("DELETE FROM fragments", [])
147            .map_err(text)?;
148        transaction
149            .execute(
150                "UPDATE metadata SET last_applied_txid = NULL WHERE singleton = 1",
151                [],
152            )
153            .map_err(text)?;
154        transaction.commit().map_err(text)
155    }
156
157    #[cfg(feature = "testkit")]
158    pub fn inject_errors(
159        &self,
160        fragment_id: FragmentId,
161        errors: Vec<String>,
162    ) -> Result<(), String> {
163        let mut connection = self.lock()?;
164        let transaction = connection.transaction().map_err(text)?;
165        let mut status = load_status(&transaction, fragment_id)?.ok_or("unknown fragment")?;
166        for error in errors {
167            append_error(&mut status, error);
168        }
169        write_status(&transaction, fragment_id, &status)?;
170        transaction.commit().map_err(text)
171    }
172
173    fn lock(&self) -> Result<MutexGuard<'_, Connection>, String> {
174        self.connection.lock().map_err(text)
175    }
176}
177
178fn load_status<C: Deref<Target = Connection>>(
179    connection: &C,
180    fragment_id: FragmentId,
181) -> Result<Option<FragmentStatus>, String> {
182    let stored = connection
183        .query_row(
184            "SELECT actionable_state, encoded_status FROM fragments WHERE fragment_id = ?1",
185            params![&fragment_id.as_bytes()[..]],
186            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
187        )
188        .optional()
189        .map_err(text)?;
190    stored
191        .map(|(actionable, bytes)| decode_status(&bytes, actionable))
192        .transpose()
193}
194
195fn write_status(
196    connection: &Connection,
197    fragment_id: FragmentId,
198    status: &FragmentStatus,
199) -> Result<(), String> {
200    let encoded = postcard::to_allocvec(status).map_err(text)?;
201    connection.execute(
202        "INSERT INTO fragments(fragment_id, actionable_state, encoded_status) VALUES(?1, ?2, ?3) ON CONFLICT(fragment_id) DO UPDATE SET actionable_state = excluded.actionable_state, encoded_status = excluded.encoded_status",
203        params![&fragment_id.as_bytes()[..], actionable_state(status.state), encoded],
204    ).map_err(text)?;
205    Ok(())
206}
207
208fn actionable_statuses<C: Deref<Target = Connection>>(
209    connection: &C,
210    actionable: i64,
211) -> Result<Vec<(FragmentId, FragmentStatus)>, String> {
212    let mut statement = connection
213        .prepare("SELECT fragment_id, encoded_status FROM fragments WHERE actionable_state = ?1")
214        .map_err(text)?;
215    let rows = statement
216        .query_map(params![actionable], |row| {
217            Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?))
218        })
219        .map_err(text)?;
220    rows.map(|row| {
221        let (id, encoded) = row.map_err(text)?;
222        Ok((fragment_id(&id)?, decode_status(&encoded, actionable)?))
223    })
224    .collect()
225}
226
227fn fragment_id(bytes: &[u8]) -> Result<FragmentId, String> {
228    let bytes: [u8; 12] = bytes.try_into().map_err(text)?;
229    Ok(FragmentId::from_bytes(bytes))
230}
231
232fn decode_status(bytes: &[u8], actionable: i64) -> Result<FragmentStatus, String> {
233    let status: FragmentStatus = postcard::from_bytes(bytes).map_err(text)?;
234    if postcard::to_allocvec(&status).map_err(text)? != bytes {
235        return Err("stored fragment status is noncanonical".to_string());
236    }
237    validate_status(&status, actionable)?;
238    Ok(status)
239}
240
241fn create_database(path: &Path) -> Result<(Connection, Option<TxId>), String> {
242    let connection = Connection::open(path).map_err(text)?;
243    configure(&connection).map_err(text)?;
244    connection.execute_batch(&format!(
245        "{METADATA_SQL};{FRAGMENTS_SQL};{INDEX_SQL};INSERT INTO metadata(singleton, schema_version, last_applied_txid) VALUES(1, {SCHEMA_VERSION}, NULL);"
246    )).map_err(text)?;
247    Ok((connection, None))
248}
249
250fn open_existing(path: &Path) -> Result<(Connection, Option<TxId>), OpenIssue> {
251    let connection = Connection::open(path).map_err(classify)?;
252    configure(&connection).map_err(classify)?;
253    let check: String = connection
254        .query_row("PRAGMA quick_check", [], |row| row.get(0))
255        .map_err(classify)?;
256    if check != "ok" {
257        return Err(OpenIssue::Recoverable);
258    }
259    validate_schema(&connection)?;
260    let cursor = validate_rows(&connection)?;
261    Ok((connection, cursor))
262}
263
264fn configure(connection: &Connection) -> Result<(), SqlError> {
265    connection.pragma_update(None, "journal_mode", "WAL")?;
266    connection.pragma_update(None, "synchronous", "FULL")
267}
268
269fn validate_schema(connection: &Connection) -> Result<(), OpenIssue> {
270    let mut statement = connection.prepare(
271        "SELECT type, name, sql FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
272    ).map_err(classify)?;
273    let rows = statement
274        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
275        .map_err(classify)?;
276    let schema = rows
277        .collect::<Result<Vec<(String, String, String)>, _>>()
278        .map_err(classify)?;
279    let expected = vec![
280        (
281            "index".into(),
282            "fragments_actionable_state".into(),
283            INDEX_SQL.into(),
284        ),
285        ("table".into(), "fragments".into(), FRAGMENTS_SQL.into()),
286        ("table".into(), "metadata".into(), METADATA_SQL.into()),
287    ];
288    if schema != expected {
289        return Err(OpenIssue::Recoverable);
290    }
291    Ok(())
292}
293
294fn validate_rows(connection: &Connection) -> Result<Option<TxId>, OpenIssue> {
295    let mut metadata = connection
296        .prepare("SELECT singleton, schema_version, last_applied_txid FROM metadata")
297        .map_err(classify)?;
298    let rows = metadata
299        .query_map([], |row| {
300            Ok((
301                row.get::<_, i64>(0)?,
302                row.get::<_, i64>(1)?,
303                row.get::<_, Option<Vec<u8>>>(2)?,
304            ))
305        })
306        .map_err(classify)?;
307    let rows = rows.collect::<Result<Vec<_>, _>>().map_err(classify)?;
308    if rows.len() != 1 || rows[0].0 != 1 || rows[0].1 != SCHEMA_VERSION {
309        return Err(OpenIssue::Recoverable);
310    }
311    let mut fragments = connection
312        .prepare("SELECT fragment_id, actionable_state, encoded_status FROM fragments")
313        .map_err(classify)?;
314    let values = fragments
315        .query_map([], |row| {
316            Ok((
317                row.get::<_, Vec<u8>>(0)?,
318                row.get::<_, i64>(1)?,
319                row.get::<_, Vec<u8>>(2)?,
320            ))
321        })
322        .map_err(classify)?;
323    for value in values {
324        let (id, actionable, encoded) = value.map_err(classify)?;
325        fragment_id(&id).map_err(|_| OpenIssue::Recoverable)?;
326        decode_status(&encoded, actionable).map_err(|_| OpenIssue::Recoverable)?;
327    }
328    rows[0]
329        .2
330        .as_deref()
331        .map(|bytes| {
332            let bytes: [u8; 12] = bytes.try_into().map_err(|_| OpenIssue::Recoverable)?;
333            Ok(TxId::from_bytes(bytes))
334        })
335        .transpose()
336}
337
338fn classify(error: SqlError) -> OpenIssue {
339    if matches!(
340        error.sqlite_error_code(),
341        Some(ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
342    ) {
343        OpenIssue::Recoverable
344    } else {
345        OpenIssue::Fatal(error.to_string())
346    }
347}
348
349fn recreate(path: &Path) -> Result<(Connection, Option<TxId>), String> {
350    remove_sidecars(path)?;
351    remove_if_present(path)?;
352    create_database(path)
353}
354
355fn remove_sidecars(path: &Path) -> Result<(), String> {
356    for suffix in ["-wal", "-shm"] {
357        remove_if_present(&path_with_suffix(path, suffix))?;
358    }
359    Ok(())
360}
361
362fn remove_if_present(path: &Path) -> Result<(), String> {
363    match fs::remove_file(path) {
364        Ok(()) => Ok(()),
365        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
366        Err(error) => Err(error.to_string()),
367    }
368}
369
370fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf {
371    let mut value = OsString::from(path.as_os_str());
372    value.push(suffix);
373    PathBuf::from(value)
374}
375
376fn text(error: impl Display) -> String {
377    error.to_string()
378}
379
380enum OpenIssue {
381    Recoverable,
382    Fatal(String),
383}
384
385#[cfg(test)]
386mod tests;