Skip to content

Commit 8bf146c

Browse files
Handle invalid persisted migration namespace names without panicking
1 parent 1c8dd8d commit 8bf146c

3 files changed

Lines changed: 183 additions & 53 deletions

File tree

‎libsql-server/src/schema/db.rs‎

Lines changed: 169 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -185,27 +185,16 @@ pub(super) fn get_next_pending_migration_tasks_batch(
185185
limit: usize,
186186
) -> Result<Vec<MigrationTask>, Error> {
187187
let txn = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
188-
let tasks = txn
189-
.prepare(
190-
"SELECT task_id, target_namespace, status, job_id
191-
FROM pending_tasks
188+
let tasks = {
189+
let mut stmt = txn.prepare(
190+
"SELECT task_id, target_namespace, status, job_id
191+
FROM pending_tasks
192192
WHERE job_id = ? AND status = ? AND task_id NOT IN (select * from enqueued_tasks)
193193
LIMIT ?",
194-
)?
195-
.query_map((job_id, status as u64, limit), |row| {
196-
let task_id = row.get::<_, i64>(0)?;
197-
let namespace = NamespaceName::from_string(row.get::<_, String>(1)?).unwrap();
198-
let status = MigrationTaskStatus::from_int(row.get::<_, u64>(2)?);
199-
let job_id = row.get::<_, i64>(3)?;
200-
Ok(MigrationTask {
201-
namespace,
202-
status,
203-
job_id,
204-
task_id,
205-
})
206-
})?
207-
.map(|r| r.map_err(Into::into))
208-
.collect::<Result<Vec<_>, Error>>()?;
194+
)?;
195+
let mut rows = stmt.query((job_id, status as u64, limit))?;
196+
read_migration_tasks(&mut rows)?
197+
};
209198

210199
for task in tasks.iter() {
211200
txn.execute("INSERT INTO enqueued_tasks VALUES (?)", [task.task_id])?;
@@ -222,27 +211,16 @@ pub(super) fn get_unfinished_task_batch(
222211
limit: usize,
223212
) -> Result<Vec<MigrationTask>, Error> {
224213
let txn = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
225-
let tasks = txn
226-
.prepare(
227-
"SELECT task_id, target_namespace, status, job_id
228-
FROM pending_tasks
214+
let tasks = {
215+
let mut stmt = txn.prepare(
216+
"SELECT task_id, target_namespace, status, job_id
217+
FROM pending_tasks
229218
WHERE job_id = ? AND finished = false AND task_id NOT IN (select * from enqueued_tasks)
230219
LIMIT ?",
231-
)?
232-
.query_map((job_id, limit), |row| {
233-
let task_id = row.get::<_, i64>(0)?;
234-
let namespace = NamespaceName::from_string(row.get::<_, String>(1)?).unwrap();
235-
let status = MigrationTaskStatus::from_int(row.get::<_, u64>(2)?);
236-
let job_id = row.get::<_, i64>(3)?;
237-
Ok(MigrationTask {
238-
namespace,
239-
status,
240-
job_id,
241-
task_id,
242-
})
243-
})?
244-
.map(|r| r.map_err(Into::into))
245-
.collect::<Result<Vec<_>, Error>>()?;
220+
)?;
221+
let mut rows = stmt.query((job_id, limit))?;
222+
read_migration_tasks(&mut rows)?
223+
};
246224

247225
for task in tasks.iter() {
248226
txn.execute("INSERT INTO enqueued_tasks VALUES (?)", [task.task_id])?;
@@ -252,6 +230,30 @@ pub(super) fn get_unfinished_task_batch(
252230
Ok(tasks)
253231
}
254232

233+
// Reject corrupt persisted names before enqueuing any tasks from the batch. An error
234+
// rolls back the transaction, so the scheduler cannot silently lose unfinished work.
235+
fn read_migration_tasks(rows: &mut rusqlite::Rows<'_>) -> Result<Vec<MigrationTask>, Error> {
236+
let mut tasks = Vec::new();
237+
while let Some(row) = rows.next()? {
238+
let task_id = row.get::<_, i64>(0)?;
239+
let name = row.get::<_, String>(1)?;
240+
let namespace = NamespaceName::from_string(name.clone()).map_err(|_| {
241+
Error::InvalidPersistedNamespace {
242+
kind: "task",
243+
id: task_id,
244+
name,
245+
}
246+
})?;
247+
tasks.push(MigrationTask {
248+
namespace,
249+
status: MigrationTaskStatus::from_int(row.get::<_, u64>(2)?),
250+
job_id: row.get::<_, i64>(3)?,
251+
task_id,
252+
});
253+
}
254+
Ok(tasks)
255+
}
256+
255257
pub(super) fn update_meta_task_status(
256258
conn: &mut rusqlite::Connection,
257259
task: &MigrationTask,
@@ -317,7 +319,7 @@ pub(super) fn get_next_pending_migration_job(
317319
conn: &mut rusqlite::Connection,
318320
) -> Result<Option<MigrationJob>, Error> {
319321
let txn = conn.transaction()?;
320-
let mut job = txn
322+
let row = txn
321323
.query_row(
322324
"SELECT job_id, status, migration, schema
323325
FROM jobs
@@ -328,23 +330,37 @@ pub(super) fn get_next_pending_migration_job(
328330
MigrationJobStatus::RunFailure as u64,
329331
),
330332
|row| {
331-
let job_id = row.get::<_, i64>(0)?;
332-
let status = MigrationJobStatus::from_int(row.get::<_, u64>(1)?);
333-
let mut migration = serde_json::from_str(row.get_ref(2)?.as_str()?).unwrap();
334-
let schema = NamespaceName::from_string(row.get::<_, String>(3)?).unwrap();
335-
let disable_foreign_key = validate_migration(&mut migration).unwrap();
336-
Ok(MigrationJob {
337-
schema,
338-
job_id,
339-
status,
340-
progress: Default::default(),
341-
task_error: None,
342-
disable_foreign_key,
343-
migration: migration.into(),
344-
})
333+
Ok((
334+
row.get::<_, i64>(0)?,
335+
row.get::<_, u64>(1)?,
336+
row.get::<_, String>(2)?,
337+
row.get::<_, String>(3)?,
338+
))
345339
},
346340
)
347341
.optional()?;
342+
let mut job = if let Some((job_id, status, migration, name)) = row {
343+
let schema = NamespaceName::from_string(name.clone()).map_err(|_| {
344+
Error::InvalidPersistedNamespace {
345+
kind: "job",
346+
id: job_id,
347+
name,
348+
}
349+
})?;
350+
let mut migration = serde_json::from_str(&migration).unwrap();
351+
let disable_foreign_key = validate_migration(&mut migration).unwrap();
352+
Some(MigrationJob {
353+
schema,
354+
job_id,
355+
status: MigrationJobStatus::from_int(status),
356+
progress: Default::default(),
357+
task_error: None,
358+
disable_foreign_key,
359+
migration: migration.into(),
360+
})
361+
} else {
362+
None
363+
};
348364

349365
if let Some(ref mut job) = job {
350366
txn.prepare(
@@ -483,6 +499,106 @@ mod test {
483499

484500
use super::*;
485501

502+
#[test]
503+
fn invalid_persisted_job_schema_can_be_repaired() {
504+
let mut conn = rusqlite::Connection::open_in_memory().unwrap();
505+
setup_schema(&mut conn).unwrap();
506+
let migration = serde_json::to_string(&Program::seq(&["select 1"])).unwrap();
507+
conn.execute(
508+
"INSERT INTO jobs (schema, migration, status) VALUES (?1, ?2, ?3)",
509+
(
510+
"../schema",
511+
migration,
512+
MigrationJobStatus::WaitingDryRun as u64,
513+
),
514+
)
515+
.unwrap();
516+
let job_id = conn.last_insert_rowid();
517+
518+
assert!(matches!(
519+
get_next_pending_migration_job(&mut conn),
520+
Err(Error::InvalidPersistedNamespace { kind: "job", id, .. }) if id == job_id
521+
));
522+
conn.execute(
523+
"UPDATE jobs SET schema = 'schema' WHERE job_id = ?",
524+
[job_id],
525+
)
526+
.unwrap();
527+
assert_eq!(
528+
get_next_pending_migration_job(&mut conn)
529+
.unwrap()
530+
.unwrap()
531+
.job_id(),
532+
job_id
533+
);
534+
}
535+
536+
#[test]
537+
fn invalid_persisted_task_namespace_does_not_enqueue_partial_batch() {
538+
let mut conn = rusqlite::Connection::open_in_memory().unwrap();
539+
setup_schema(&mut conn).unwrap();
540+
let migration = serde_json::to_string(&Program::seq(&["select 1"])).unwrap();
541+
conn.execute(
542+
"INSERT INTO jobs (schema, migration, status) VALUES (?1, ?2, ?3)",
543+
(
544+
"schema",
545+
migration,
546+
MigrationJobStatus::WaitingDryRun as u64,
547+
),
548+
)
549+
.unwrap();
550+
let job_id = conn.last_insert_rowid();
551+
conn.execute(
552+
"INSERT INTO pending_tasks (job_id, target_namespace, status) VALUES (?1, 'valid', ?2)",
553+
(job_id, MigrationTaskStatus::Enqueued as u64),
554+
)
555+
.unwrap();
556+
conn.execute(
557+
"INSERT INTO pending_tasks (job_id, target_namespace, status) VALUES (?1, '../escape', ?2)",
558+
(job_id, MigrationTaskStatus::Enqueued as u64),
559+
)
560+
.unwrap();
561+
let bad_task_id = conn.last_insert_rowid();
562+
563+
for fetch in [false, true] {
564+
let result = if fetch {
565+
get_unfinished_task_batch(&mut conn, job_id, 10)
566+
} else {
567+
get_next_pending_migration_tasks_batch(
568+
&mut conn,
569+
job_id,
570+
MigrationTaskStatus::Enqueued,
571+
10,
572+
)
573+
};
574+
assert!(matches!(
575+
result,
576+
Err(Error::InvalidPersistedNamespace { kind: "task", id, .. }) if id == bad_task_id
577+
));
578+
let queued: i64 = conn
579+
.query_row("SELECT count(*) FROM enqueued_tasks", [], |row| row.get(0))
580+
.unwrap();
581+
assert_eq!(queued, 0);
582+
}
583+
584+
conn.execute(
585+
"UPDATE pending_tasks SET target_namespace = 'repaired' WHERE task_id = ?",
586+
[bad_task_id],
587+
)
588+
.unwrap();
589+
assert_eq!(
590+
get_next_pending_migration_tasks_batch(
591+
&mut conn,
592+
job_id,
593+
MigrationTaskStatus::Enqueued,
594+
10,
595+
)
596+
.unwrap()
597+
.len(),
598+
2
599+
);
600+
}
601+
486602
async fn register_schema(meta_store: &MetaStore, schema: &'static str) {
487603
meta_store
488604
.handle(schema.into())

‎libsql-server/src/schema/error.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ pub enum Error {
1515
CorruptedJobStatus(serde_json::Error),
1616
#[error("sqlite error: {0}")]
1717
Sqlite(#[from] rusqlite::Error),
18+
#[error("invalid persisted namespace in migration {kind} {id}: {name:?}; repair the metastore entry before restarting the scheduler")]
19+
InvalidPersistedNamespace {
20+
kind: &'static str,
21+
id: i64,
22+
name: String,
23+
},
1824
#[error("`{0}` is not a schema database")]
1925
NotASchema(NamespaceName),
2026
#[error("schema `{0}` doesn't exist")]

‎libsql-server/src/schema/scheduler.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ impl Scheduler {
8181
tracing::info!("all scheduler handles dropped: exiting.");
8282
break;
8383
}
84+
Err(e @ Error::InvalidPersistedNamespace { .. }) => {
85+
// Do not retry or skip corrupt work: it must be repaired before
86+
// resuming, and must remain unfinished in the metastore.
87+
tracing::error!(
88+
"migration scheduler stopped on invalid persisted namespace: {e}"
89+
);
90+
break;
91+
}
8492
Err(e) => {
8593
if tries >= MAX_ERROR_RETRIES {
8694
tracing::error!("scheduler could not make progress after {MAX_ERROR_RETRIES}, exiting: {e}");

0 commit comments

Comments
 (0)