Skip to main content

burn_dataset/dataset/sqlite/
mod.rs

1use std::{
2    collections::HashSet,
3    ffi::OsString,
4    fs, io,
5    marker::PhantomData,
6    path::{Path, PathBuf},
7    sync::{Arc, Mutex, PoisonError, RwLock},
8};
9
10use crate::Dataset;
11
12// Turso exposes an async API, but for a local database file every `poll` runs its IO inline
13// before returning, so no reactor is involved and a parking executor is enough to drive it.
14// `Dataset` is a synchronous trait, and this is where the two meet.
15use futures_lite::future::block_on;
16use gix_tempfile::{
17    AutoRemove, ContainingDirectory, Handle,
18    handle::{Writable, persist},
19};
20use r2d2::Pool;
21use sanitize_filename::sanitize;
22use serde::{Serialize, de::DeserializeOwned};
23use turso::{Builder, Connection, Database, Value};
24
25mod de;
26
27use de::from_row_with_columns;
28// Re-exported because `SqliteDatasetError::Row` carries it: a payload users cannot name is one
29// they cannot match on or wrap.
30pub use de::RowError;
31
32/// Result type for the sqlite dataset.
33pub type Result<T> = core::result::Result<T, SqliteDatasetError>;
34
35/// Sqlite dataset error.
36#[derive(thiserror::Error, Debug)]
37pub enum SqliteDatasetError {
38    /// IO related error.
39    #[error("IO error: {0}")]
40    Io(#[from] io::Error),
41
42    /// Sql related error.
43    #[error("Sql error: {0}")]
44    Sql(#[from] turso::Error),
45
46    /// Serde related error.
47    #[error("Serde error: {0}")]
48    Serde(#[from] rmp_serde::encode::Error),
49
50    /// Deserialization error when reading a row.
51    #[error("Deserialize error: {0}")]
52    Deserialize(#[from] rmp_serde::decode::Error),
53
54    /// Row deserialization error.
55    #[error("Row error: {0}")]
56    Row(#[from] RowError),
57
58    /// The database file already exists error.
59    #[error("Overwrite flag is set to false and the database file already exists: {0}")]
60    FileExists(PathBuf),
61
62    /// Error when creating the connection pool.
63    #[error("Failed to create connection pool: {0}")]
64    ConnectionPool(#[from] r2d2::Error),
65
66    /// Error when persisting the temporary database file.
67    #[error("Could not persist the temporary database file: {0}")]
68    PersistDbFile(#[from] persist::Error<Writable>),
69
70    /// Any other error.
71    #[error("{0}")]
72    Other(&'static str),
73}
74
75impl From<&'static str> for SqliteDatasetError {
76    fn from(s: &'static str) -> Self {
77        SqliteDatasetError::Other(s)
78    }
79}
80
81/// This struct represents a dataset where all items are stored in an SQLite database.
82/// Each instance of this struct corresponds to a specific table within the SQLite database,
83/// and allows for interaction with the data stored in the table in a structured and typed manner.
84///
85/// The database is read and written with [Turso](https://turso.tech/), which implements the engine
86/// in Rust rather than linking libsqlite3, so the files stay interchangeable with any other SQLite
87/// tooling. Note that this does not make the build free of C: turso pulls `simsimd` for its SIMD
88/// kernels, which compiles C unconditionally.
89///
90/// The SQLite database must contain a table with the same name as the `split` field. This table should
91/// have a primary key column named `row_id`, which is used to index the rows in the table. The `row_id`
92/// should start at 1, while the corresponding dataset `index` should start at 0, i.e., `row_id` = `index` + 1.
93///
94/// Table columns can be represented in two ways:
95///
96/// 1. The table can have a column for each field in the `I` struct. In this case, the column names in the table
97///    should match the field names of the `I` struct. The field names can be a subset of column names and
98///    can be in any order.
99///
100/// For the supported field types, refer to
101/// [SQLite data types](https://www.sqlite.org/datatype3.html). Each column is handed to serde as
102/// the value its storage class implies: `INTEGER` as `i64`, `REAL` as `f64`, `TEXT` as `String`,
103/// and `BLOB` as a byte sequence. Booleans are read back from integers, and a nullable column maps
104/// to an `Option` field. Reading a NULL into a bare `f32` or `f64` field yields `NaN`.
105///
106/// A `Vec<u8>` field derives as a sequence, so a large `BLOB` column is walked one byte at a time.
107/// Tagging the field `#[serde(with = "serde_bytes")]` moves the blob across instead, which is
108/// substantially cheaper for columns of any size.
109///
110/// 2. The fields in the `I` struct can be serialized into a single column `item` in the table. In this case, the table
111///    should have a single column named `item` of type `BLOB`. This is useful when the `I` struct contains complex fields
112///    that cannot be mapped to a SQLite type, such as nested structs, vectors, etc. The serialization is done using
113///    [MessagePack](https://msgpack.org/).
114///
115/// Note: The code automatically figures out which of the above two cases is applicable, and uses the appropriate
116/// method to read the data from the table.
117#[derive(Debug)]
118pub struct SqliteDataset<I> {
119    db_file: PathBuf,
120    split: String,
121    conn_pool: Pool<TursoConnectionManager>,
122    columns: Vec<String>,
123    len: usize,
124    select_statement: String,
125    row_serialized: bool,
126    phantom: PhantomData<I>,
127}
128
129impl<I> SqliteDataset<I> {
130    /// Initializes a `SqliteDataset` from a SQLite database file and a split name.
131    pub fn from_db_file<P: AsRef<Path>>(db_file: P, split: &str) -> Result<Self> {
132        // Create a connection pool
133        let database = open_database(&db_file, false)?;
134        let conn_pool = Pool::new(TursoConnectionManager { database })?;
135
136        // Determine how the table is stored
137        let row_serialized = Self::check_if_row_serialized(&conn_pool, split)?;
138
139        // Create a select statement and save it
140        let select_statement = if row_serialized {
141            format!("select item from {split} where row_id = ?")
142        } else {
143            format!("select * from {split} where row_id = ?")
144        };
145
146        // Save the column names and the number of rows
147        let (columns, len) = fetch_columns_and_len(&conn_pool, &select_statement, split)?;
148
149        Ok(SqliteDataset {
150            db_file: db_file.as_ref().to_path_buf(),
151            split: split.to_string(),
152            conn_pool,
153            columns,
154            len,
155            select_statement,
156            row_serialized,
157            phantom: PhantomData,
158        })
159    }
160
161    /// Returns true if table has two columns: row_id (integer) and item (blob).
162    ///
163    /// This is used to determine if the table is row serialized or not.
164    fn check_if_row_serialized(
165        conn_pool: &Pool<TursoConnectionManager>,
166        split: &str,
167    ) -> Result<bool> {
168        let conn = conn_pool.get()?;
169
170        // A prepared statement already reports each column's name and declared type, so the table
171        // does not need to be described separately.
172        let columns = block_on(conn.prepare(&format!("select * from {split}")))?.columns();
173
174        let matches = |index: usize, name: &str, ty: &str| {
175            columns[index].name().eq_ignore_ascii_case(name)
176                && columns[index]
177                    .decl_type()
178                    .is_some_and(|declared| declared.eq_ignore_ascii_case(ty))
179        };
180
181        Ok(columns.len() == 2 && matches(0, "row_id", "integer") && matches(1, "item", "blob"))
182    }
183
184    /// Get the database file name.
185    pub fn db_file(&self) -> PathBuf {
186        self.db_file.clone()
187    }
188
189    /// Get the split name.
190    pub fn split(&self) -> &str {
191        self.split.as_str()
192    }
193}
194
195impl<I: DeserializeOwned> SqliteDataset<I> {
196    /// Decodes one row, in whichever of the two table layouts this dataset uses.
197    fn item_from_row(&self, row: &turso::Row) -> Result<I> {
198        if self.row_serialized {
199            // A single column `item`, serialized with MessagePack
200            match row.get_value(0)? {
201                Value::Blob(blob) => Ok(rmp_serde::from_slice::<I>(&blob)?),
202                _ => Err(SqliteDatasetError::Other("expected a blob column")),
203            }
204        } else {
205            // A column per field, decoded column by column
206            Ok(from_row_with_columns::<I>(row, &self.columns)?)
207        }
208    }
209}
210
211impl<I> Dataset<I, SqliteDatasetError> for SqliteDataset<I>
212where
213    I: Clone + Send + Sync + DeserializeOwned,
214{
215    /// Get an item from the dataset.
216    ///
217    /// # Panics
218    ///
219    /// Panics if `index >= len()`.
220    fn get(&self, index: usize) -> Result<I> {
221        assert!(
222            index < self.len,
223            "Index out of bounds for SqliteDataset: {} >= {}",
224            index,
225            self.len,
226        );
227
228        // Row ids start with 1 (one) and index starts with 0 (zero).
229        // Turso binds integers as `i64`, so `usize` is not a valid parameter type.
230        let row_id = (index + 1) as i64;
231
232        // Get a connection from the pool
233        let connection = self.conn_pool.get()?;
234
235        let row = block_on(async {
236            // `prepare_cached` keeps the compiled program on the pooled connection, which is
237            // what makes repeated single-row reads cheap. `query_row` runs the statement to
238            // completion, which is what keeps dropping it from rolling back an open transaction.
239            let mut statement = connection.prepare_cached(&self.select_statement).await?;
240            statement.query_row([row_id]).await
241        })
242        .map_err(|error| match error {
243            // `len` comes from `max(row_id)` and assumes ids are contiguous, so a gap shows up
244            // here rather than at construction. Say that instead of reporting a database fault.
245            turso::Error::QueryReturnedNoRows => SqliteDatasetError::Other(
246                "no row for this index; row_id values must be contiguous and start at 1",
247            ),
248            error => error.into(),
249        })?;
250
251        self.item_from_row(&row)
252    }
253
254    /// Get multiple items from the dataset in a single query.
255    ///
256    /// # Panics
257    ///
258    /// Panics if `indexes[i] >= len()`.
259    fn get_many(&self, indexes: Vec<usize>) -> Result<Vec<I>> {
260        if indexes.is_empty() {
261            return Ok(Vec::new());
262        }
263
264        for &index in &indexes {
265            assert!(
266                index < self.len,
267                "Index out of bounds for SqliteDataset: {} >= {}",
268                index,
269                self.len,
270            );
271        }
272
273        // Bind (row_id, ord) pairs through a CTE so the engine returns the rows already ordered
274        // (and duplicated) to match the requested `indexes`, instead of reordering in Rust.
275        let values_clause = vec!["(?, ?)"; indexes.len()].join(", ");
276        let params: Vec<i64> = indexes
277            .iter()
278            .enumerate()
279            .flat_map(|(ord, &index)| [(index + 1) as i64, ord as i64])
280            .collect();
281
282        let split = &self.split;
283        let connection = self.conn_pool.get()?;
284
285        let selection = if self.row_serialized { "t.item" } else { "t.*" };
286        let query = format!(
287            "WITH req(row_id, ord) AS (VALUES {values_clause}) \
288             SELECT {selection} FROM req JOIN {split} t ON t.row_id = req.row_id ORDER BY req.ord"
289        );
290
291        // Deserialize as rows arrive rather than collecting them first, so a batch never holds
292        // both the raw rows and the decoded items at once.
293        //
294        // `prepare` rather than `prepare_cached`: the statement carries one `(?, ?)` pair per
295        // requested index, so every distinct batch size is a distinct cache key, and turso's
296        // per-connection cache never evicts. A caller varying its batch size would pin a compiled
297        // program and its SQL text for every size it ever used, on every pooled connection.
298        block_on(async {
299            let mut statement = connection.prepare(&query).await?;
300            let mut rows = statement.query(params).await?;
301
302            let mut items = Vec::with_capacity(indexes.len());
303            while let Some(row) = rows.next().await? {
304                items.push(self.item_from_row(&row)?);
305            }
306
307            // The join drops requested ids that do not exist, so a gapped table would otherwise
308            // hand back a short batch as success, where `get` on the same index errors.
309            if items.len() != indexes.len() {
310                return Err(SqliteDatasetError::Other(
311                    "fewer rows than indexes requested; row_id values must be contiguous and \
312                     start at 1",
313                ));
314            }
315
316            Ok(items)
317        })
318    }
319
320    /// Return the number of rows in the dataset.
321    fn len(&self) -> usize {
322        self.len
323    }
324}
325
326/// Fetch the column names and the number of rows from the database.
327fn fetch_columns_and_len(
328    conn_pool: &Pool<TursoConnectionManager>,
329    select_statement: &str,
330    split: &str,
331) -> Result<(Vec<String>, usize)> {
332    let connection = conn_pool.get()?;
333
334    let (columns, max_row_id) = block_on(async {
335        // Save the column names
336        let statement = connection.prepare(select_statement).await?;
337        let columns = statement.column_names();
338
339        // Count the number of rows and save it as len
340        //
341        // NOTE: Using max(row_id) instead of count(*) because count(*) walks the table where
342        // max(row_id) is a single backward seek. The max row_id corresponds to the number of rows
343        // in the table.
344        // The main assumption, which always holds true, is that the row_id is always increasing and there are no gaps.
345        // This is true for all the datasets that we are using, otherwise row_id will not correspond to the index.
346        //
347        // The aggregate has to stand alone for turso to take that fast path, so an empty table's
348        // NULL is turned into 0 here rather than by wrapping the call in `coalesce`. Note that
349        // `EXPLAIN QUERY PLAN` reports a scan for both forms, so the difference only shows up in
350        // the emitted bytecode, or on a clock.
351        let mut statement = connection
352            .prepare(format!("select max(row_id) from {split}").as_str())
353            .await?;
354        let max_row_id = statement.query_row(()).await?.get_value(0)?;
355
356        Ok::<_, turso::Error>((columns, max_row_id))
357    })?;
358
359    // Only NULL means "empty table". SQLite's dynamic typing lets a `row_id` column hold text or
360    // a float, and a dataset indexed by something that is not an integer is not one this can read
361    // by row id, so say so rather than reporting it as empty.
362    let len = match max_row_id {
363        Value::Null => 0,
364        Value::Integer(max_row_id) => usize::try_from(max_row_id).map_err(|_| {
365            SqliteDatasetError::Other("row_id is negative, so it cannot index the dataset")
366        })?,
367        _ => {
368            return Err(SqliteDatasetError::Other(
369                "row_id is not an integer, so it cannot index the dataset",
370            ));
371        }
372    };
373
374    Ok((columns, len))
375}
376
377/// Hands out connections to a single turso database.
378///
379/// Pooling is not just an optimization here: a turso [`Connection`] rejects concurrent use from
380/// more than one thread at a time, so a multi-threaded reader needs one connection per caller.
381#[derive(Debug)]
382struct TursoConnectionManager {
383    database: Database,
384}
385
386impl r2d2::ManageConnection for TursoConnectionManager {
387    type Connection = Connection;
388    type Error = turso::Error;
389
390    fn connect(&self) -> core::result::Result<Connection, turso::Error> {
391        self.database.connect()
392    }
393
394    fn is_valid(&self, _conn: &mut Connection) -> core::result::Result<(), turso::Error> {
395        Ok(())
396    }
397
398    fn has_broken(&self, _conn: &mut Connection) -> bool {
399        false
400    }
401}
402
403/// Opens a database file. Reads and writes go through different shapes, so `write` picks which:
404/// the reader fans a pool out over one handle, the writer keeps a single connection.
405fn open_database<P: AsRef<Path>>(db_file: P, write: bool) -> Result<Database> {
406    // Turso takes the path as `&str`, and `to_string_lossy` would substitute U+FFFD without
407    // saying so, pointing the engine at a different file than the caller named. In the writer
408    // that is silent total loss: rows land in the mangled file while the untouched original is
409    // the one published.
410    let db_file = db_file.as_ref().to_str().ok_or(SqliteDatasetError::Other(
411        "database path is not valid UTF-8, which the turso engine requires",
412    ))?;
413
414    // The exact version pin on turso is load-bearing here beyond the public error type: read-only
415    // enforcement is behaviour of a prerelease, covered by no stability promise.
416    Ok(block_on(
417        Builder::new_local(db_file).read_only(!write).build(),
418    )?)
419}
420
421/// The `SqliteDatasetStorage` struct represents a SQLite database for storing datasets.
422/// It consists of an optional name, a database file path, and a base directory for storage.
423#[derive(Clone, Debug)]
424pub struct SqliteDatasetStorage {
425    name: Option<String>,
426    db_file: Option<PathBuf>,
427    base_dir: Option<PathBuf>,
428}
429
430impl SqliteDatasetStorage {
431    /// Creates a new instance of `SqliteDatasetStorage` using a dataset name.
432    ///
433    /// # Arguments
434    ///
435    /// * `name` - A string slice that holds the name of the dataset.
436    pub fn from_name(name: &str) -> Self {
437        SqliteDatasetStorage {
438            name: Some(name.to_string()),
439            db_file: None,
440            base_dir: None,
441        }
442    }
443
444    /// Creates a new instance of `SqliteDatasetStorage` using a database file path.
445    ///
446    /// # Arguments
447    ///
448    /// * `db_file` - A reference to the Path that represents the database file path.
449    pub fn from_file<P: AsRef<Path>>(db_file: P) -> Self {
450        SqliteDatasetStorage {
451            name: None,
452            db_file: Some(db_file.as_ref().to_path_buf()),
453            base_dir: None,
454        }
455    }
456
457    /// Sets the base directory for storing the dataset.
458    ///
459    /// # Arguments
460    ///
461    /// * `base_dir` - A string slice that represents the base directory.
462    pub fn with_base_dir<P: AsRef<Path>>(mut self, base_dir: P) -> Self {
463        self.base_dir = Some(base_dir.as_ref().to_path_buf());
464        self
465    }
466
467    /// Checks if the database file exists in the given path.
468    ///
469    /// # Returns
470    ///
471    /// * A boolean value indicating whether the file exists or not.
472    pub fn exists(&self) -> bool {
473        self.db_file().exists()
474    }
475
476    /// Fetches the database file path.
477    ///
478    /// # Returns
479    ///
480    /// * A `PathBuf` instance representing the file path.
481    pub fn db_file(&self) -> PathBuf {
482        match &self.db_file {
483            Some(db_file) => db_file.clone(),
484            None => {
485                let name = sanitize(self.name.as_ref().expect("Name is not set"));
486                Self::base_dir(self.base_dir.to_owned()).join(format!("{name}.db"))
487            }
488        }
489    }
490
491    /// Determines the base directory for storing the dataset.
492    ///
493    /// # Arguments
494    ///
495    /// * `base_dir` - An `Option` that may contain a `PathBuf` instance representing the base directory.
496    ///
497    /// # Returns
498    ///
499    /// * A `PathBuf` instance representing the base directory.
500    pub fn base_dir(base_dir: Option<PathBuf>) -> PathBuf {
501        match base_dir {
502            Some(base_dir) => base_dir,
503            None => dirs::cache_dir()
504                .expect("Could not get cache directory")
505                .join("burn-dataset"),
506        }
507    }
508
509    /// Provides a writer instance for the SQLite dataset.
510    ///
511    /// # Arguments
512    ///
513    /// * `overwrite` - A boolean indicating if the existing database file should be overwritten.
514    ///
515    /// # Returns
516    ///
517    /// * A `Result` which is `Ok` if the writer could be created, `Err` otherwise.
518    pub fn writer<I>(&self, overwrite: bool) -> Result<SqliteDatasetWriter<I>>
519    where
520        I: Clone + Send + Sync + Serialize + DeserializeOwned,
521    {
522        SqliteDatasetWriter::new(self.db_file(), overwrite)
523    }
524
525    /// Provides a reader instance for the SQLite dataset.
526    ///
527    /// # Arguments
528    ///
529    /// * `split` - A string slice that defines the data split for reading (e.g., "train", "test").
530    ///
531    /// # Returns
532    ///
533    /// * A `Result` which is `Ok` if the reader could be created, `Err` otherwise.
534    pub fn reader<I>(&self, split: &str) -> Result<SqliteDataset<I>>
535    where
536        I: Clone + Send + Sync + Serialize + DeserializeOwned,
537    {
538        if !self.exists() {
539            panic!("The database file does not exist");
540        }
541
542        SqliteDataset::from_db_file(self.db_file(), split)
543    }
544}
545
546/// This `SqliteDatasetWriter` struct is a SQLite database writer dedicated to storing datasets.
547/// It retains the current writer's state and its database connection.
548///
549/// Being thread-safe, this writer can be concurrently used across multiple threads.
550///
551/// Typical applications include:
552///
553/// - Generation of a new dataset
554/// - Storage of preprocessed data or metadata
555/// - Enlargement of a dataset's item count post preprocessing
556#[derive(Debug)]
557pub struct SqliteDatasetWriter<I> {
558    db_file: PathBuf,
559    db_file_tmp: Option<Handle<Writable>>,
560    overwrite: bool,
561    state: Option<Mutex<WriteState>>,
562    is_completed: Arc<RwLock<bool>>,
563    phantom: PhantomData<I>,
564}
565
566/// Number of rows batched into a single transaction.
567///
568/// Turso is WAL-only and ignores `journal_mode = OFF`, so an autocommit insert pays for a WAL
569/// commit per row. Batching amortizes that away.
570///
571/// A batch that never commits costs more than the rows in it. [`SqliteDatasetWriter::write`] has
572/// already handed their indexes to the caller, and turso reuses the rowids once the transaction is
573/// discarded, so those indexes would go on to name different items. That is why a rolled-back
574/// batch makes the whole dataset unpublishable rather than merely shorter: the only safe reading
575/// of an index is one from a run that reached [`SqliteDatasetWriter::set_completed`] successfully.
576const WRITE_BATCH_SIZE: usize = 256;
577
578/// The writer's single connection and the batch currently in flight on it.
579///
580/// One connection rather than a pool, because a database admits one writer at a time anyway and a
581/// turso [`Connection`] forbids concurrent use. Callers serialize on the enclosing mutex, which is
582/// held only for the insert itself: item serialization happens before it is taken.
583#[derive(Debug)]
584struct WriteState {
585    connection: Connection,
586    /// Zero means no transaction is open. Bumped as soon as `BEGIN` succeeds rather than after the
587    /// insert, so that a failing insert cannot strand a transaction nothing would ever close.
588    pending: usize,
589    /// Set once a batch has been rolled back. The rows it held are gone, so the dataset can no
590    /// longer be published: without this, the commit that failed would be a no-op on the retry and
591    /// `set_completed` would report success over a dataset missing up to a full batch.
592    lost_a_batch: bool,
593    /// Splits whose table has already been created on this connection.
594    splits: HashSet<String>,
595}
596
597impl WriteState {
598    /// Inserts one serialized item, opening a batch transaction if none is in flight, and returns
599    /// the index of the new row.
600    fn insert(&mut self, split: &str, item: Vec<u8>) -> Result<usize> {
601        block_on(async {
602            if self.pending == 0 {
603                self.connection.execute("BEGIN", ()).await?;
604            }
605            self.pending += 1;
606
607            let insert_statement = format!("insert into {split} (item) values (?)");
608            let mut statement = self.connection.prepare_cached(&insert_statement).await?;
609            statement.execute([item]).await
610        })?;
611
612        // Get the primary key of the last inserted row and convert to index (row_id-1)
613        let index = (self.connection.last_insert_rowid() - 1) as usize;
614
615        if self.pending >= WRITE_BATCH_SIZE {
616            self.commit()?;
617        }
618
619        Ok(index)
620    }
621
622    /// Commits the batch in flight, if there is one.
623    fn commit(&mut self) -> Result<()> {
624        if self.pending > 0 {
625            let committed = block_on(self.connection.execute("COMMIT", ()));
626
627            if committed.is_err() {
628                // A refused `COMMIT` can leave the transaction open. Roll it back so that
629                // `pending == 0` keeps meaning "nothing in flight": left as it was, the next
630                // insert would skip its `BEGIN` and run against a transaction it does not know
631                // about. The rows in it are gone, which is what `lost_a_batch` records.
632                let _ = block_on(self.connection.execute("ROLLBACK", ()));
633                self.lost_a_batch = true;
634            }
635
636            self.pending = 0;
637            committed?;
638        }
639        Ok(())
640    }
641
642    /// Folds the write-ahead log back into the database file.
643    ///
644    /// The `turso` crate exposes no `close()`, and dropping a connection does not checkpoint, so
645    /// the tail of the dataset lives in a `-wal` sidecar until this runs. Publishing the database
646    /// file without it would hand over a dataset missing everything not yet backfilled.
647    ///
648    /// Failure arrives as a result row rather than an error: column 0 is non-zero when the
649    /// checkpoint did not complete. Turso funnels every cause into that one flag - a reader
650    /// holding an open transaction, a full disk, an IO error, a corrupt header - and keeps the
651    /// real reason to a `tracing::debug!` of its own, which burn-dataset's `tracing` feature
652    /// surfaces. Taking the pragma's `Ok` at face value would publish a truncated dataset.
653    ///
654    /// The remaining columns carry nothing to check: a TRUNCATE checkpoint zeroes them on the way
655    /// out, so it reports `(0, 0, 0)` however many frames it folded in.
656    fn checkpoint(&self) -> Result<()> {
657        if self.lost_a_batch {
658            return Err(SqliteDatasetError::Other(
659                "A batch was rolled back earlier, so this dataset is missing rows and will not be \
660                 published. Rebuild it from scratch",
661            ));
662        }
663
664        let mut failed = false;
665        block_on(
666            self.connection
667                .pragma_query("wal_checkpoint(TRUNCATE)", |row| {
668                    failed |= !matches!(row.get_value(0), Ok(Value::Integer(0)));
669                    Ok(())
670                }),
671        )?;
672
673        if failed {
674            return Err(SqliteDatasetError::Other(
675                "Could not checkpoint the write-ahead log, so the database was left unpublished \
676                 rather than published incomplete. Turso does not report why; enable the \
677                 `tracing` feature and log at debug level to see the underlying cause",
678            ));
679        }
680
681        Ok(())
682    }
683
684    /// Creates the table backing a split, once per split.
685    ///
686    /// The batch in flight is committed first so that the schema change never rides along inside a
687    /// transaction that is still accumulating rows.
688    fn create_table(&mut self, split: &str) -> Result<()> {
689        if self.splits.contains(split) {
690            return Ok(());
691        }
692
693        self.commit()?;
694        let create_table_statement = format!(
695            "create table if not exists  {split} (row_id integer primary key autoincrement not \
696             null, item blob not null)"
697        );
698        block_on(self.connection.execute(&create_table_statement, ()))?;
699        self.splits.insert(split.to_string());
700        Ok(())
701    }
702}
703
704impl<I> SqliteDatasetWriter<I>
705where
706    I: Clone + Send + Sync + Serialize + DeserializeOwned,
707{
708    /// Creates a new instance of `SqliteDatasetWriter`.
709    ///
710    /// # Arguments
711    ///
712    /// * `db_file` - A reference to the Path that represents the database file path.
713    /// * `overwrite` - A boolean indicating if the existing database file should be overwritten.
714    ///
715    /// # Returns
716    ///
717    /// * A `Result` which is `Ok` if the writer could be created, `Err` otherwise.
718    pub fn new<P: AsRef<Path>>(db_file: P, overwrite: bool) -> Result<Self> {
719        let writer = Self {
720            db_file: db_file.as_ref().to_path_buf(),
721            db_file_tmp: None,
722            overwrite,
723            state: None,
724            is_completed: Arc::new(RwLock::new(false)),
725            phantom: PhantomData,
726        };
727
728        writer.init()
729    }
730
731    /// Initializes the dataset writer by creating the database file, tables, and connection.
732    ///
733    /// # Returns
734    ///
735    /// * A `Result` which is `Ok` if the writer could be initialized, `Err` otherwise.
736    fn init(mut self) -> Result<Self> {
737        // Remove the db file if it already exists
738        if self.db_file.exists() {
739            if self.overwrite {
740                fs::remove_file(&self.db_file)?;
741            } else {
742                return Err(SqliteDatasetError::FileExists(self.db_file.clone()));
743            }
744        }
745
746        // An earlier database may have left a `-wal` sidecar beside this path, with or without the
747        // database itself still there. Whoever opens the file this writer publishes would apply
748        // that sidecar as if it belonged to it.
749        remove_wal_file(&self.db_file)?;
750
751        // Create the database file directory if it does not exist
752        let db_file_dir = self
753            .db_file
754            .parent()
755            .ok_or("Unable to get parent directory")?;
756
757        if !db_file_dir.exists() {
758            fs::create_dir_all(db_file_dir)?;
759        }
760
761        // Create a temp database file name as {base_dir}/{name}.db.tmp
762        let db_file_tmp = tmp_db_file(&self.db_file);
763        if db_file_tmp.exists() {
764            fs::remove_file(&db_file_tmp)?;
765        }
766
767        // Turso writes through a `-wal` sidecar, which a previous aborted run may have left
768        // behind. Starting from a stale one would corrupt the fresh database.
769        remove_wal_file(&db_file_tmp)?;
770
771        // Create the temp database file and wrap it with a gix_tempfile::Handle
772        // This will ensure that the temp file is deleted when the writer is dropped
773        // or when process exits with SIGINT or SIGTERM (tempfile crate does not do this)
774        gix_tempfile::signal::setup(Default::default());
775        self.db_file_tmp = Some(gix_tempfile::writable_at(
776            &db_file_tmp,
777            ContainingDirectory::Exists,
778            AutoRemove::Tempfile,
779        )?);
780
781        // The connection keeps the database alive, so the handle does not need to be held on to.
782        let connection = open_database(&db_file_tmp, true)?.connect()?;
783        self.state = Some(Mutex::new(WriteState {
784            connection,
785            pending: 0,
786            lost_a_batch: false,
787            splits: HashSet::new(),
788        }));
789
790        Ok(self)
791    }
792
793    /// Serializes and writes an item to the database. The item is written to the table for the
794    /// specified split. If the table does not exist, it is created. If the table exists, the item
795    /// is appended to the table. The serialization is done using the [MessagePack](https://msgpack.org/)
796    ///
797    /// # Arguments
798    ///
799    /// * `split` - A string slice that defines the data split for writing (e.g., "train", "test").
800    /// * `item` - A reference to the item to be written to the database.
801    ///
802    /// # Returns
803    ///
804    /// * A `Result` containing the index of the inserted row if successful, an error otherwise.
805    ///
806    /// The index is provisional until [`Self::set_completed`] returns `Ok`. It belongs to a row in
807    /// a transaction that has not committed yet, and a batch that is later rolled back gives its
808    /// rowids back to the engine. A run whose `set_completed` fails publishes nothing, so indexes
809    /// recorded during it must be discarded along with it.
810    pub fn write(&self, split: &str, item: &I) -> Result<usize> {
811        // Acquire the read lock (wont't block other reads)
812        let is_completed = self
813            .is_completed
814            .read()
815            .unwrap_or_else(PoisonError::into_inner);
816
817        // If the writer is completed, return an error
818        if *is_completed {
819            return Err(SqliteDatasetError::Other(
820                "Cannot save to a completed dataset writer",
821            ));
822        }
823
824        // Serialize the item using MessagePack, before taking the connection mutex so that this
825        // part stays parallel across threads
826        let serialized_item = rmp_serde::to_vec(item)?;
827
828        let state = self.state.as_ref().ok_or(SqliteDatasetError::Other(
829            "Cannot save to a completed dataset writer",
830        ))?;
831        let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
832
833        // create the table for the split if it does not exist
834        state.create_table(split)?;
835        state.insert(split, serialized_item)
836    }
837
838    /// Marks the dataset as completed and persists the temporary database file.
839    pub fn set_completed(&mut self) -> Result<()> {
840        let mut is_completed = self
841            .is_completed
842            .write()
843            .unwrap_or_else(PoisonError::into_inner);
844
845        // Commit the batch still in flight and fold the write-ahead log back into the database
846        // file, so that the file persisted below actually holds the dataset.
847        //
848        // The state stays in place until both succeed. Taking it up front would make a failure
849        // unrecoverable in the worst way: the retry would find nothing left to commit, fall
850        // through to the rename below, and publish the very uncheckpointed database that
851        // `checkpoint` had just refused to publish.
852        {
853            let state = self
854                .state
855                .as_mut()
856                .ok_or(SqliteDatasetError::Other(
857                    "Cannot complete a dataset writer twice",
858                ))?
859                .get_mut()
860                .unwrap_or_else(PoisonError::into_inner);
861            state.commit()?;
862            state.checkpoint()?;
863        }
864
865        // Dropping the connection is required on the Windows platform, where an open connection
866        // prevents persisting the db by renaming the temp file.
867        self.state = None;
868
869        // Rename the database file from tmp to db
870        let _file_result = self
871            .db_file_tmp
872            .take() // take ownership of the temporary file and set to None
873            .unwrap() // unwrap the temporary file
874            .persist(&self.db_file)?
875            .ok_or("Unable to persist the database file")?;
876
877        // The checkpoint above emptied the sidecar, but turso leaves the file itself behind.
878        // Removing it is best effort: the dataset is already published at this point, so failing
879        // to clean up a zero-length leftover must not fail the write.
880        let _ = fs::remove_file(wal_file(&tmp_db_file(&self.db_file)));
881
882        *is_completed = true;
883        Ok(())
884    }
885}
886
887impl<I> Drop for SqliteDatasetWriter<I> {
888    /// Removes the write-ahead log beside the temporary database.
889    ///
890    /// `gix_tempfile` owns `{name}.db.tmp`, but it knows nothing about the `-wal` sidecar turso
891    /// writes next to it. That sidecar holds the tail of the dataset, so a writer abandoned before
892    /// `set_completed` would otherwise leave the bulk of what it wrote on disk with nothing left
893    /// to reference it.
894    ///
895    /// This covers an ordinary drop only. `gix_tempfile::signal::setup` still takes the `.db.tmp`
896    /// away on SIGINT or SIGTERM, but `Drop` does not run there, so a signalled process leaves the
897    /// sidecar behind; `init` clears it on the next run.
898    fn drop(&mut self) {
899        // The connection goes first: it holds the sidecar open until it does.
900        self.state = None;
901        let _ = fs::remove_file(wal_file(&tmp_db_file(&self.db_file)));
902    }
903}
904
905/// Path of the temporary database the writer fills in before publishing it as `db_file`.
906fn tmp_db_file(db_file: &Path) -> PathBuf {
907    let mut db_file_tmp = db_file.to_path_buf();
908    db_file_tmp.set_extension("db.tmp");
909    db_file_tmp
910}
911
912/// Path of the write-ahead log that turso keeps beside `db_file`.
913fn wal_file(db_file: &Path) -> PathBuf {
914    let mut wal_file = OsString::from(db_file);
915    wal_file.push("-wal");
916    PathBuf::from(wal_file)
917}
918
919/// Removes the write-ahead log beside `db_file`, if there is one.
920fn remove_wal_file(db_file: &Path) -> Result<()> {
921    let wal_file = wal_file(db_file);
922    if wal_file.exists() {
923        fs::remove_file(wal_file)?;
924    }
925    Ok(())
926}
927
928#[cfg(test)]
929mod tests {
930    use rayon::prelude::*;
931    use rstest::{fixture, rstest};
932    use serde::{Deserialize, Serialize};
933    use tempfile::{NamedTempFile, TempDir, tempdir};
934
935    use super::*;
936
937    type SqlDs = SqliteDataset<Sample>;
938
939    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
940    pub struct Sample {
941        column_str: String,
942        column_bytes: Vec<u8>,
943        column_int: i64,
944        column_bool: bool,
945        column_float: f64,
946    }
947
948    #[fixture]
949    fn train_dataset() -> SqlDs {
950        SqliteDataset::<Sample>::from_db_file("tests/data/sqlite-dataset.db", "train").unwrap()
951    }
952
953    #[rstest]
954    pub fn len(train_dataset: SqlDs) {
955        assert_eq!(train_dataset.len(), 2);
956    }
957
958    #[rstest]
959    pub fn get_some(train_dataset: SqlDs) {
960        let item = train_dataset.get(0).unwrap();
961        assert_eq!(item.column_str, "HI1");
962        assert_eq!(item.column_bytes, vec![55, 231, 159]);
963        assert_eq!(item.column_int, 1);
964        assert!(item.column_bool);
965        assert_eq!(item.column_float, 1.0);
966    }
967
968    #[rstest]
969    #[should_panic(expected = "Index out of bounds")]
970    pub fn get_none(train_dataset: SqlDs) {
971        train_dataset.get(10).unwrap();
972    }
973
974    #[rstest]
975    pub fn get_many_out_of_order_with_duplicates(train_dataset: SqlDs) {
976        // Requested out of order and with a duplicate; result must follow the requested order.
977        let items = train_dataset.get_many(vec![1, 0, 1]).unwrap();
978
979        assert_eq!(items.len(), 3);
980        assert_eq!(items[0], train_dataset.get(1).unwrap());
981        assert_eq!(items[1], train_dataset.get(0).unwrap());
982        assert_eq!(items[2], train_dataset.get(1).unwrap());
983    }
984
985    #[rstest]
986    pub fn get_many_empty(train_dataset: SqlDs) {
987        assert_eq!(train_dataset.get_many(vec![]).unwrap(), Vec::new());
988    }
989
990    #[rstest]
991    #[should_panic(expected = "Index out of bounds")]
992    pub fn get_many_out_of_bounds(train_dataset: SqlDs) {
993        train_dataset.get_many(vec![0, 10]).unwrap();
994    }
995
996    #[rstest]
997    pub fn multi_thread(train_dataset: SqlDs) {
998        let dataset_len = train_dataset.len();
999        let indices: Vec<usize> = vec![0, 1, 1, 3, 4, 5, 6, 0, 8, 1];
1000        let valid_indices: Vec<usize> = indices.into_iter().filter(|&i| i < dataset_len).collect();
1001
1002        let results: Vec<Sample> = valid_indices
1003            .par_iter()
1004            .map(|&i| train_dataset.get(i).unwrap())
1005            .collect();
1006
1007        assert_eq!(results.len(), 5);
1008    }
1009
1010    /// Reading must leave the database file exactly as it was found.
1011    ///
1012    /// Turso writes through a `-wal` sidecar and rewrites the file header into WAL mode as soon as
1013    /// it is opened for writing, so a reader that is not genuinely read-only would silently modify
1014    /// the dataset it was handed and litter the directory next to it.
1015    #[rstest]
1016    pub fn reading_leaves_the_database_file_untouched(tmp_dir: TempDir) {
1017        let db_file = tmp_dir.path().join("sqlite-dataset.db");
1018        fs::copy("tests/data/sqlite-dataset.db", &db_file).unwrap();
1019        let before = fs::read(&db_file).unwrap();
1020
1021        let dataset = SqliteDataset::<Sample>::from_db_file(&db_file, "train").unwrap();
1022        assert_eq!(dataset.len(), 2);
1023        dataset.get(0).unwrap();
1024        dataset.get_many(vec![0, 1]).unwrap();
1025        drop(dataset);
1026
1027        assert_eq!(fs::read(&db_file).unwrap(), before);
1028        assert!(!wal_file(&db_file).exists());
1029    }
1030
1031    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1032    pub struct Typed {
1033        #[serde(rename = "MedInc")]
1034        median_income: f32,
1035        count: usize,
1036        ratio: f64,
1037        label: Option<String>,
1038        missing: Option<f64>,
1039    }
1040
1041    #[derive(Debug, Clone, Deserialize, PartialEq)]
1042    enum Status {
1043        Ready,
1044        Pending,
1045    }
1046
1047    #[derive(Debug, Clone, Deserialize, PartialEq)]
1048    struct UserId(i64);
1049
1050    #[derive(Debug, Clone, Deserialize, PartialEq)]
1051    struct Marker;
1052
1053    #[derive(Debug, Clone, Deserialize, PartialEq)]
1054    struct SerdeShapes {
1055        status: Status,
1056        user_id: UserId,
1057        marker: Marker,
1058    }
1059
1060    /// Keep scalar Serde shapes working both as mapped fields and as the complete row type, as
1061    /// they did through `serde_rusqlite`.
1062    #[rstest]
1063    fn get_maps_columns_onto_serde_shapes(tmp_dir: TempDir) {
1064        let db_file = tmp_dir.path().join("serde-shapes.db");
1065
1066        {
1067            let connection = open_database(&db_file, true).unwrap().connect().unwrap();
1068            block_on(async {
1069                connection
1070                    .execute(
1071                        "create table train (status TEXT, user_id INTEGER, marker TEXT, \
1072                         row_id INTEGER NOT NULL, PRIMARY KEY (row_id))",
1073                        (),
1074                    )
1075                    .await?;
1076                connection
1077                    .execute("insert into train values ('Ready', 42, 'Marker', 1)", ())
1078                    .await?;
1079                connection
1080                    .execute(
1081                        "create table statuses (status TEXT, row_id INTEGER NOT NULL, \
1082                         PRIMARY KEY (row_id))",
1083                        (),
1084                    )
1085                    .await?;
1086                connection
1087                    .execute("insert into statuses values ('Pending', 1)", ())
1088                    .await?;
1089                connection
1090                    .execute(
1091                        "create table users (user_id INTEGER, row_id INTEGER NOT NULL, \
1092                         PRIMARY KEY (row_id))",
1093                        (),
1094                    )
1095                    .await?;
1096                connection
1097                    .execute("insert into users values (7, 1)", ())
1098                    .await?;
1099                connection
1100                    .pragma_query("wal_checkpoint(TRUNCATE)", |_| Ok(()))
1101                    .await
1102            })
1103            .unwrap();
1104        }
1105        fs::remove_file(wal_file(&db_file)).unwrap();
1106
1107        let dataset = SqliteDataset::<SerdeShapes>::from_db_file(&db_file, "train").unwrap();
1108        assert_eq!(
1109            dataset.get(0).unwrap(),
1110            SerdeShapes {
1111                status: Status::Ready,
1112                user_id: UserId(42),
1113                marker: Marker,
1114            }
1115        );
1116
1117        let statuses = SqliteDataset::<Status>::from_db_file(&db_file, "statuses").unwrap();
1118        assert_eq!(statuses.get(0).unwrap(), Status::Pending);
1119
1120        let users = SqliteDataset::<UserId>::from_db_file(&db_file, "users").unwrap();
1121        assert_eq!(users.get(0).unwrap(), UserId(7));
1122    }
1123
1124    /// Covers the column shapes a HuggingFace import produces, which the row-serialized path never
1125    /// reaches: renamed fields, floats read from both `REAL` and `INTEGER` storage, and `NULL`
1126    /// columns read into `Option`.
1127    #[rstest]
1128    pub fn get_maps_columns_onto_typed_fields(tmp_dir: TempDir) {
1129        let db_file = tmp_dir.path().join("typed.db");
1130
1131        {
1132            let connection = open_database(&db_file, true).unwrap().connect().unwrap();
1133            block_on(async {
1134                connection
1135                    .execute(
1136                        "create table train (\"MedInc\" REAL, count INTEGER, ratio REAL, \
1137                         label TEXT, missing REAL, row_id INTEGER NOT NULL, PRIMARY KEY (row_id))",
1138                        (),
1139                    )
1140                    .await?;
1141                // `ratio` holds an integer in the first row even though the column is declared
1142                // REAL, which SQLite's dynamic typing allows and real imports do produce.
1143                connection
1144                    .execute(
1145                        "insert into train values (8.3252, 41, 2, 'first', NULL, 1)",
1146                        (),
1147                    )
1148                    .await?;
1149                connection
1150                    .execute(
1151                        "insert into train values (8.3014, 21, 0.5, NULL, 1.5, 2)",
1152                        (),
1153                    )
1154                    .await?;
1155                // A NULL read into a bare float, which the public docs promise arrives as NaN
1156                // rather than as an error.
1157                connection
1158                    .execute(
1159                        "insert into train values (NULL, 7, 1.0, 'third', NULL, 3)",
1160                        (),
1161                    )
1162                    .await?;
1163                connection
1164                    .pragma_query("wal_checkpoint(TRUNCATE)", |_| Ok(()))
1165                    .await
1166            })
1167            .unwrap();
1168        }
1169        fs::remove_file(wal_file(&db_file)).unwrap();
1170
1171        let dataset = SqliteDataset::<Typed>::from_db_file(&db_file, "train").unwrap();
1172        assert_eq!(dataset.len(), 3);
1173        assert_eq!(
1174            dataset.get(0).unwrap(),
1175            Typed {
1176                median_income: 8.3252,
1177                count: 41,
1178                ratio: 2.0,
1179                label: Some("first".to_string()),
1180                missing: None,
1181            }
1182        );
1183        assert_eq!(
1184            dataset.get(1).unwrap(),
1185            Typed {
1186                median_income: 8.3014,
1187                count: 21,
1188                ratio: 0.5,
1189                label: None,
1190                missing: Some(1.5),
1191            }
1192        );
1193
1194        // NULL into a bare `f32` yields NaN, matching what serde_rusqlite did.
1195        let third = dataset.get(2).unwrap();
1196        assert!(third.median_income.is_nan());
1197        assert_eq!(third.count, 7);
1198        assert_eq!(third.label, Some("third".to_string()));
1199    }
1200
1201    #[test]
1202    fn sqlite_dataset_storage() {
1203        // Test with non-existing file
1204        let storage = SqliteDatasetStorage::from_file("non-existing.db");
1205        assert!(!storage.exists());
1206
1207        // Test with non-existing name
1208        let storage = SqliteDatasetStorage::from_name("non-existing.db");
1209        assert!(!storage.exists());
1210
1211        // Test with existing file
1212        let storage = SqliteDatasetStorage::from_file("tests/data/sqlite-dataset.db");
1213        assert!(storage.exists());
1214        let result = storage.reader::<Sample>("train");
1215        assert!(result.is_ok());
1216        let train = result.unwrap();
1217        assert_eq!(train.len(), 2);
1218
1219        // Test get writer
1220        let temp_file = NamedTempFile::new().unwrap();
1221        let storage = SqliteDatasetStorage::from_file(temp_file.path());
1222        assert!(storage.exists());
1223        let result = storage.writer::<Sample>(true);
1224        assert!(result.is_ok());
1225    }
1226
1227    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1228    pub struct Complex {
1229        column_str: String,
1230        column_bytes: Vec<u8>,
1231        column_int: i64,
1232        column_bool: bool,
1233        column_float: f64,
1234        column_complex: Vec<Vec<Vec<[u8; 3]>>>,
1235    }
1236
1237    /// Create a temporary directory.
1238    #[fixture]
1239    fn tmp_dir() -> TempDir {
1240        // Create a TempDir. This object will be automatically
1241        // deleted when it goes out of scope.
1242        tempdir().unwrap()
1243    }
1244    type Writer = SqliteDatasetWriter<Complex>;
1245
1246    /// Create a SqliteDatasetWriter with a temporary directory.
1247    /// Make sure to return the temporary directory so that it is not deleted.
1248    #[fixture]
1249    fn writer_fixture(tmp_dir: TempDir) -> (Writer, TempDir) {
1250        let temp_dir_str = tmp_dir.path();
1251        let storage = SqliteDatasetStorage::from_name("preprocessed").with_base_dir(temp_dir_str);
1252        let overwrite = true;
1253        let result = storage.writer::<Complex>(overwrite);
1254        assert!(result.is_ok());
1255        let writer = result.unwrap();
1256        (writer, tmp_dir)
1257    }
1258
1259    #[test]
1260    fn test_new() {
1261        // Test that the constructor works with overwrite = true
1262        let test_path = NamedTempFile::new().unwrap();
1263        let _writer = SqliteDatasetWriter::<Complex>::new(&test_path, true).unwrap();
1264        assert!(!test_path.path().exists());
1265
1266        // Test that the constructor works with overwrite = false
1267        let test_path = NamedTempFile::new().unwrap();
1268        let result = SqliteDatasetWriter::<Complex>::new(&test_path, false);
1269        assert!(result.is_err());
1270
1271        // Test that the constructor works with no existing file
1272        let temp = NamedTempFile::new().unwrap();
1273        let test_path = temp.path().to_path_buf();
1274        assert!(temp.close().is_ok());
1275        assert!(!test_path.exists());
1276        let _writer = SqliteDatasetWriter::<Complex>::new(&test_path, true).unwrap();
1277        assert!(!test_path.exists());
1278    }
1279
1280    #[rstest]
1281    pub fn sqlite_writer_write(writer_fixture: (Writer, TempDir)) {
1282        // Get the dataset_saver from the fixture and tmp_dir (will be deleted after scope)
1283        let (writer, _tmp_dir) = writer_fixture;
1284
1285        assert!(writer.overwrite);
1286        assert!(!writer.db_file.exists());
1287
1288        let new_item = Complex {
1289            column_str: "HI1".to_string(),
1290            column_bytes: vec![1_u8, 2, 3],
1291            column_int: 0,
1292            column_bool: true,
1293            column_float: 1.0,
1294            column_complex: vec![vec![vec![[1, 23_u8, 3]]]],
1295        };
1296
1297        let index = writer.write("train", &new_item).unwrap();
1298        assert_eq!(index, 0);
1299
1300        let mut writer = writer;
1301
1302        writer.set_completed().expect("Failed to set completed");
1303
1304        assert!(writer.db_file.exists());
1305        assert!(writer.db_file_tmp.is_none());
1306
1307        let result = writer.write("train", &new_item);
1308
1309        // Should fail because the writer is completed
1310        assert!(result.is_err());
1311
1312        let dataset = SqliteDataset::<Complex>::from_db_file(&writer.db_file, "train").unwrap();
1313
1314        let fetched_item = dataset.get(0).unwrap();
1315        assert_eq!(fetched_item, new_item);
1316        assert_eq!(dataset.len(), 1);
1317    }
1318
1319    #[rstest]
1320    pub fn sqlite_writer_write_multi_thread(writer_fixture: (Writer, TempDir)) {
1321        // Get the dataset_saver from the fixture and tmp_dir (will be deleted after scope)
1322        let (writer, _tmp_dir) = writer_fixture;
1323
1324        let writer = Arc::new(writer);
1325        let record_count = 20;
1326
1327        let splits = ["train", "test"];
1328
1329        (0..record_count).into_par_iter().for_each(|index: i64| {
1330            let thread_id: std::thread::ThreadId = std::thread::current().id();
1331            let sample = Complex {
1332                column_str: format!("test_{thread_id:?}_{index}"),
1333                column_bytes: vec![index as u8, 2, 3],
1334                column_int: index,
1335                column_bool: true,
1336                column_float: 1.0,
1337                column_complex: vec![vec![vec![[1, index as u8, 3]]]],
1338            };
1339
1340            // half for train and half for test
1341            let split = splits[index as usize % 2];
1342
1343            let _index = writer.write(split, &sample).unwrap();
1344        });
1345
1346        let mut writer = Arc::try_unwrap(writer).unwrap();
1347
1348        writer
1349            .set_completed()
1350            .expect("Should set completed successfully");
1351
1352        let train =
1353            SqliteDataset::<Complex>::from_db_file(writer.db_file.clone(), "train").unwrap();
1354        let test = SqliteDataset::<Complex>::from_db_file(&writer.db_file, "test").unwrap();
1355
1356        assert_eq!(train.len(), record_count as usize / 2);
1357        assert_eq!(test.len(), record_count as usize / 2);
1358    }
1359
1360    /// A rolled-back batch must make the dataset permanently unpublishable.
1361    ///
1362    /// The rows are gone and their indexes have already been handed to the caller, so publishing
1363    /// anything afterwards would hand over a dataset those indexes no longer describe.
1364    #[rstest]
1365    pub fn a_lost_batch_can_never_be_published(writer_fixture: (Writer, TempDir)) {
1366        let (writer, _tmp_dir) = writer_fixture;
1367        let item = Complex {
1368            column_str: "HI".to_string(),
1369            column_bytes: vec![1, 2, 3],
1370            column_int: 0,
1371            column_bool: true,
1372            column_float: 1.0,
1373            column_complex: vec![vec![vec![[1, 2, 3]]]],
1374        };
1375        writer.write("train", &item).unwrap();
1376
1377        // Stand in for a refused COMMIT, which `commit` records this way after rolling back.
1378        writer.state.as_ref().unwrap().lock().unwrap().lost_a_batch = true;
1379
1380        let mut writer = writer;
1381        assert!(writer.set_completed().is_err(), "published a lost batch");
1382        assert!(!writer.db_file.exists());
1383
1384        // Still refused on a retry, and after further successful writes.
1385        writer.write("train", &item).unwrap();
1386        assert!(writer.set_completed().is_err(), "published a lost batch");
1387        assert!(!writer.db_file.exists());
1388    }
1389
1390    /// `get_many` binds two parameters per index, so a large batch must not run into a
1391    /// bind-variable limit.
1392    ///
1393    /// Turso 0.8.0-pre.7 enforces none (verified well past SQLite's own 32766), but the engine is
1394    /// pinned at a prerelease and the dataloader lets callers pick any batch size, so this pins
1395    /// the behaviour rather than trusting it.
1396    #[rstest]
1397    pub fn get_many_handles_batches_past_the_sqlite_variable_limit(tmp_dir: TempDir) {
1398        let db_file = tmp_dir.path().join("wide.db");
1399        let item = |index: usize| Complex {
1400            column_str: format!("item_{index}"),
1401            column_bytes: vec![index as u8, 2, 3],
1402            column_int: index as i64,
1403            column_bool: true,
1404            column_float: 1.0,
1405            column_complex: vec![vec![vec![[1, index as u8, 3]]]],
1406        };
1407
1408        // 1200 indexes is 2400 bind parameters, comfortably past the historical 999.
1409        let count = 1200;
1410        let mut writer = SqliteDatasetWriter::<Complex>::new(&db_file, true).unwrap();
1411        for index in 0..count {
1412            writer.write("train", &item(index)).unwrap();
1413        }
1414        writer.set_completed().unwrap();
1415
1416        let dataset = SqliteDataset::<Complex>::from_db_file(&db_file, "train").unwrap();
1417        let items = dataset.get_many((0..count).collect()).unwrap();
1418        assert_eq!(items.len(), count);
1419        for (index, got) in items.iter().enumerate() {
1420            assert_eq!(got.column_int, index as i64);
1421        }
1422    }
1423
1424    /// A `row_id` that is not an integer cannot index the dataset, and must not read as empty.
1425    ///
1426    /// SQLite's dynamic typing allows it, and `max()` over such a column returns text. Reporting
1427    /// `len() == 0` would make a populated dataset look empty and silently train on nothing.
1428    #[rstest]
1429    pub fn non_integer_row_id_is_an_error(tmp_dir: TempDir) {
1430        let db_file = tmp_dir.path().join("textual.db");
1431
1432        {
1433            let connection = open_database(&db_file, true).unwrap().connect().unwrap();
1434            block_on(async {
1435                connection
1436                    .execute("create table train (row_id TEXT, name TEXT)", ())
1437                    .await?;
1438                connection
1439                    .execute("insert into train values ('a', 'first')", ())
1440                    .await?;
1441                connection
1442                    .pragma_query("wal_checkpoint(TRUNCATE)", |_| Ok(()))
1443                    .await
1444            })
1445            .unwrap();
1446        }
1447        fs::remove_file(wal_file(&db_file)).unwrap();
1448
1449        #[derive(Debug, Clone, Serialize, Deserialize)]
1450        struct Named {
1451            name: String,
1452        }
1453
1454        let result = SqliteDataset::<Named>::from_db_file(&db_file, "train");
1455        assert!(
1456            result.is_err(),
1457            "a textual row_id should be rejected, not reported as an empty dataset"
1458        );
1459    }
1460
1461    /// A writer abandoned without `set_completed` must not leave its write-ahead log behind.
1462    ///
1463    /// `gix_tempfile` removes the `.db.tmp` file itself, but the sidecar holds the tail of the
1464    /// dataset and is nobody else's responsibility.
1465    #[rstest]
1466    pub fn sqlite_writer_abandoned_leaves_nothing_behind(tmp_dir: TempDir) {
1467        let db_file = tmp_dir.path().join("abandoned.db");
1468        let item = Complex {
1469            column_str: "HI".to_string(),
1470            column_bytes: vec![1, 2, 3],
1471            column_int: 0,
1472            column_bool: true,
1473            column_float: 1.0,
1474            column_complex: vec![vec![vec![[1, 2, 3]]]],
1475        };
1476
1477        {
1478            let writer = SqliteDatasetWriter::<Complex>::new(&db_file, true).unwrap();
1479            for _ in 0..1000 {
1480                writer.write("train", &item).unwrap();
1481            }
1482            // Dropped without set_completed, as an interrupted preprocessing job would be.
1483        }
1484
1485        let db_file_tmp = tmp_db_file(&db_file);
1486        assert!(!db_file_tmp.exists(), "temporary database left behind");
1487        assert!(
1488            !wal_file(&db_file_tmp).exists(),
1489            "abandoned writer leaked its write-ahead log"
1490        );
1491        assert!(!db_file.exists(), "nothing should have been published");
1492    }
1493
1494    /// Reads have to come from a pool: a turso connection rejects concurrent use outright, so a
1495    /// shared one fails with `Misuse("concurrent use forbidden")` rather than merely serializing.
1496    /// The `multi_thread` test above is far too small to ever schedule two threads at once.
1497    #[rstest]
1498    pub fn concurrent_reads_scale_across_the_pool(tmp_dir: TempDir) {
1499        let db_file = tmp_dir.path().join("concurrent.db");
1500        let item = |index: usize| Complex {
1501            column_str: format!("item_{index}"),
1502            column_bytes: vec![index as u8, 2, 3],
1503            column_int: index as i64,
1504            column_bool: true,
1505            column_float: 1.0,
1506            column_complex: vec![vec![vec![[1, index as u8, 3]]]],
1507        };
1508
1509        let mut writer = SqliteDatasetWriter::<Complex>::new(&db_file, true).unwrap();
1510        for index in 0..1000 {
1511            writer.write("train", &item(index)).unwrap();
1512        }
1513        writer.set_completed().unwrap();
1514
1515        let dataset = SqliteDataset::<Complex>::from_db_file(&db_file, "train").unwrap();
1516
1517        let requested: Vec<usize> = (0..8000).map(|i| i % 1000).collect();
1518        let got: Vec<i64> = requested
1519            .par_iter()
1520            .map(|&index| dataset.get(index).unwrap().column_int)
1521            .collect();
1522        for (position, &index) in requested.iter().enumerate() {
1523            assert_eq!(got[position], index as i64);
1524        }
1525
1526        // `get_many` takes its own connection out of the pool, so exercise it concurrently too.
1527        (0..256usize).into_par_iter().for_each(|batch| {
1528            let indexes: Vec<usize> = (0..32).map(|offset| (batch * 32 + offset) % 1000).collect();
1529            let items = dataset.get_many(indexes.clone()).unwrap();
1530            assert_eq!(items.len(), indexes.len());
1531            for (item, index) in items.iter().zip(&indexes) {
1532                assert_eq!(item.column_int, *index as i64);
1533            }
1534        });
1535    }
1536
1537    /// A `set_completed` that fails must not let a retry publish an unfinished database.
1538    ///
1539    /// The checkpoint refuses to run while another connection holds a read transaction open. That
1540    /// refusal is what stops a half-written dataset from being published, so the retry after it
1541    /// has to refuse too rather than fall through to the rename.
1542    #[rstest]
1543    pub fn sqlite_writer_set_completed_is_retryable(writer_fixture: (Writer, TempDir)) {
1544        let (writer, _tmp_dir) = writer_fixture;
1545        let item = Complex {
1546            column_str: "HI".to_string(),
1547            column_bytes: vec![1, 2, 3],
1548            column_int: 0,
1549            column_bool: true,
1550            column_float: 1.0,
1551            column_complex: vec![vec![vec![[1, 2, 3]]]],
1552        };
1553        for _ in 0..500 {
1554            writer.write("train", &item).unwrap();
1555        }
1556
1557        let mut writer = writer;
1558
1559        let blocker = open_database(tmp_db_file(&writer.db_file), true)
1560            .unwrap()
1561            .connect()
1562            .unwrap();
1563        block_on(async {
1564            blocker.execute("BEGIN", ()).await?;
1565            blocker
1566                .prepare("select count(*) from train")
1567                .await?
1568                .query_row(())
1569                .await
1570        })
1571        .unwrap();
1572
1573        let first = writer.set_completed();
1574        assert!(first.is_err(), "checkpoint should have been refused");
1575        assert!(!writer.db_file.exists(), "nothing may be published yet");
1576
1577        // The retry must refuse as well, rather than publishing a truncated database.
1578        assert!(
1579            writer.set_completed().is_err(),
1580            "retry published while the checkpoint was still refused"
1581        );
1582        assert!(!writer.db_file.exists(), "nothing may be published yet");
1583
1584        // The writer is still usable, and still reports errors rather than panicking.
1585        writer.write("train", &item).unwrap();
1586
1587        // Once nothing holds the database, completing succeeds with every row intact.
1588        drop(blocker);
1589        writer
1590            .set_completed()
1591            .expect("should complete once the database is no longer busy");
1592
1593        let dataset = SqliteDataset::<Complex>::from_db_file(&writer.db_file, "train").unwrap();
1594        assert_eq!(dataset.len(), 501);
1595        assert!(writer.write("train", &item).is_err());
1596    }
1597
1598    /// Writes enough rows to span several transactions, then reads every one of them back.
1599    ///
1600    /// Rows live in the write-ahead log until `set_completed` checkpoints them into the database
1601    /// file, and only a run longer than [`WRITE_BATCH_SIZE`] exercises both the batch boundary and
1602    /// a checkpoint with more than one batch to fold in.
1603    #[rstest]
1604    pub fn sqlite_writer_write_across_batches(writer_fixture: (Writer, TempDir)) {
1605        let (writer, _tmp_dir) = writer_fixture;
1606        let record_count = WRITE_BATCH_SIZE * 2 + 5;
1607
1608        let item = |index: usize| Complex {
1609            column_str: format!("item_{index}"),
1610            column_bytes: vec![index as u8, 2, 3],
1611            column_int: index as i64,
1612            column_bool: true,
1613            column_float: 1.0,
1614            column_complex: vec![vec![vec![[1, index as u8, 3]]]],
1615        };
1616
1617        for index in 0..record_count {
1618            assert_eq!(writer.write("train", &item(index)).unwrap(), index);
1619        }
1620
1621        let mut writer = writer;
1622        writer.set_completed().expect("Failed to set completed");
1623
1624        let train = SqliteDataset::<Complex>::from_db_file(&writer.db_file, "train").unwrap();
1625        assert_eq!(train.len(), record_count);
1626        assert_eq!(train.get(0).unwrap(), item(0));
1627        assert_eq!(train.get(WRITE_BATCH_SIZE).unwrap(), item(WRITE_BATCH_SIZE));
1628        assert_eq!(train.get(record_count - 1).unwrap(), item(record_count - 1));
1629
1630        // The write-ahead log must not outlive the completed dataset.
1631        assert!(!wal_file(&tmp_db_file(&writer.db_file)).exists());
1632    }
1633}