Skip to content

Commit c6150f7

Browse files
committed
fix(install): fix force reinstall cleanup and resume file corruption
1 parent 0b7deb6 commit c6150f7

7 files changed

Lines changed: 163 additions & 31 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 #v5.5.1
6666
with:
6767
files: lcov.info
68-
fail_ci_if_error: true
68+
fail_ci_if_error: false
6969
token: ${{ secrets.CODECOV_TOKEN }}
7070

7171
cargo-machete:

crates/soar-cli/src/install.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use soar_core::{
2020
package::{
2121
install::{InstallMarker, InstallTarget, PackageInstaller},
2222
query::PackageQuery,
23+
update::remove_old_versions,
2324
url::UrlPackage,
2425
},
2526
SoarResult,
@@ -260,7 +261,9 @@ fn resolve_packages(
260261
}
261262
}
262263

263-
let existing_install = installed_packages.into_iter().next();
264+
let existing_install = installed_pkg
265+
.cloned()
266+
.or_else(|| installed_packages.into_iter().next());
264267

265268
install_targets.push(InstallTarget {
266269
package: url_pkg.to_package(),
@@ -977,7 +980,8 @@ async fn spawn_installation_task(
977980
let ctx = ctx.clone();
978981

979982
tokio::spawn(async move {
980-
let result = install_single_package(&ctx, &target, progress_callback, core_db).await;
983+
let result =
984+
install_single_package(&ctx, &target, progress_callback, core_db.clone()).await;
981985

982986
match result {
983987
Ok((install_dir, symlinks)) => {
@@ -987,12 +991,16 @@ async fn spawn_installation_task(
987991
.insert(idx, (install_dir, symlinks));
988992
installed_count.fetch_add(1, Ordering::Relaxed);
989993
total_pb.inc(1);
994+
995+
let _ = remove_old_versions(&target.package, &core_db, false);
990996
}
991997
Err(err) => {
992998
match err {
993999
SoarError::Warning(err) => {
9941000
let mut warnings = ctx.warnings.lock().unwrap();
9951001
warnings.push(err);
1002+
1003+
let _ = remove_old_versions(&target.package, &core_db, false);
9961004
}
9971005
_ => {
9981006
let mut errors = ctx.errors.lock().unwrap();

crates/soar-cli/src/update.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ async fn spawn_update_task(
360360
warnings.push(err);
361361

362362
if !keep {
363-
let _ = remove_old_versions(&target.package, &diesel_db);
363+
let _ = remove_old_versions(&target.package, &diesel_db, false);
364364
}
365365
}
366366
_ => {
@@ -373,7 +373,7 @@ async fn spawn_update_task(
373373
total_pb.inc(1);
374374

375375
if !keep {
376-
let _ = remove_old_versions(&target.package, &diesel_db);
376+
let _ = remove_old_versions(&target.package, &diesel_db, false);
377377
}
378378
}
379379

crates/soar-core/src/package/install.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ pub struct PackageInstaller {
6767
globs: Vec<String>,
6868
}
6969

70-
#[derive(Clone, Default)]
70+
#[derive(Clone, Default, Debug)]
7171
pub struct InstallTarget {
7272
pub package: Package,
7373
pub existing_install: Option<crate::database::models::InstalledPackage>,
@@ -100,9 +100,34 @@ impl PackageInstaller {
100100
);
101101
let profile = get_config().default_profile.clone();
102102

103-
let needs_new_record = match &target.existing_install {
104-
None => true,
105-
Some(existing) => existing.version != package.version,
103+
// Check if there's a pending install for this exact version we can resume
104+
let has_pending = db.with_conn(|conn| {
105+
CoreRepository::has_pending_install(
106+
conn,
107+
&package.pkg_id,
108+
&package.pkg_name,
109+
&package.repo_name,
110+
&package.version,
111+
)
112+
})?;
113+
114+
trace!(
115+
pkg_id = package.pkg_id,
116+
pkg_name = package.pkg_name,
117+
repo_name = package.repo_name,
118+
version = package.version,
119+
has_pending = has_pending,
120+
"checking for pending install"
121+
);
122+
123+
let needs_new_record = if has_pending {
124+
trace!("resuming existing pending install");
125+
false
126+
} else {
127+
match &target.existing_install {
128+
None => true,
129+
Some(existing) => existing.version != package.version || existing.is_installed,
130+
}
106131
};
107132

108133
if needs_new_record {
@@ -119,6 +144,17 @@ impl PackageInstaller {
119144
let installed_path = install_dir.to_string_lossy();
120145
let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
121146

147+
// Clean up any orphaned pending installs (different versions) before creating new record
148+
let orphaned_paths = db.with_conn(|conn| {
149+
CoreRepository::delete_pending_installs(conn, pkg_id, pkg_name, repo_name)
150+
})?;
151+
for path in orphaned_paths {
152+
let path = std::path::Path::new(&path);
153+
if path.exists() {
154+
fs::remove_dir_all(path).ok();
155+
}
156+
}
157+
122158
let new_package = NewInstalledPackage {
123159
repo_name,
124160
pkg_id,
@@ -351,6 +387,7 @@ impl PackageInstaller {
351387
let with_pkg_id = self.with_pkg_id;
352388
let installed_date = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
353389

390+
let installed_path = self.install_dir.to_string_lossy();
354391
let record_id: Option<i32> = self.db.with_conn(|conn| {
355392
CoreRepository::record_installation(
356393
conn,
@@ -363,6 +400,7 @@ impl PackageInstaller {
363400
with_pkg_id,
364401
checksum,
365402
&installed_date,
403+
&installed_path,
366404
)
367405
})?;
368406

crates/soar-core/src/package/update.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use crate::{
1212
///
1313
/// This function finds all installed versions of the package (by pkg_id, pkg_name, repo_name)
1414
/// that are older than the current version and removes them from disk and database.
15-
pub fn remove_old_versions(package: &Package, db: &DieselDatabase) -> SoarResult<()> {
15+
/// If `force` is true, removes pinned packages too. Otherwise only unpinned packages.
16+
pub fn remove_old_versions(package: &Package, db: &DieselDatabase, force: bool) -> SoarResult<()> {
1617
let Package {
1718
pkg_id,
1819
pkg_name,
@@ -21,7 +22,7 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase) -> SoarResult
2122
} = package;
2223

2324
let old_packages = db.with_conn(|conn| {
24-
CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name)
25+
CoreRepository::get_old_package_paths(conn, pkg_id, pkg_name, repo_name, force)
2526
})?;
2627

2728
for (_id, installed_path) in &old_packages {
@@ -32,7 +33,9 @@ pub fn remove_old_versions(package: &Package, db: &DieselDatabase) -> SoarResult
3233
}
3334
}
3435

35-
db.with_conn(|conn| CoreRepository::delete_old_packages(conn, pkg_id, pkg_name, repo_name))?;
36+
db.with_conn(|conn| {
37+
CoreRepository::delete_old_packages(conn, pkg_id, pkg_name, repo_name, force)
38+
})?;
3639

3740
Ok(())
3841
}

crates/soar-db/src/repository/core.rs

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Core database repository for installed packages.
22
3-
use diesel::prelude::*;
3+
use diesel::{prelude::*, sql_types::Bool, sqlite::Sqlite};
44

55
use crate::{
66
models::{
@@ -366,6 +366,7 @@ impl CoreRepository {
366366
}
367367

368368
/// Updates an installed package after successful installation.
369+
/// Only updates the record with is_installed=false (the newly created one).
369370
#[allow(clippy::too_many_arguments)]
370371
pub fn record_installation(
371372
conn: &mut SqliteConnection,
@@ -378,14 +379,16 @@ impl CoreRepository {
378379
with_pkg_id: bool,
379380
checksum: Option<&str>,
380381
installed_date: &str,
382+
installed_path: &str,
381383
) -> QueryResult<Option<i32>> {
382384
let provides = provides.map(|v| serde_json::to_value(v).unwrap_or_default());
383385
diesel::update(
384386
packages::table
385387
.filter(packages::repo_name.eq(repo_name))
386388
.filter(packages::pkg_name.eq(pkg_name))
387389
.filter(packages::pkg_id.eq(pkg_id))
388-
.filter(packages::version.eq(version)),
390+
.filter(packages::version.eq(version))
391+
.filter(packages::is_installed.eq(false)),
389392
)
390393
.set((
391394
packages::size.eq(size),
@@ -394,6 +397,7 @@ impl CoreRepository {
394397
packages::provides.eq(provides),
395398
packages::with_pkg_id.eq(with_pkg_id),
396399
packages::checksum.eq(checksum),
400+
packages::installed_path.eq(installed_path),
397401
))
398402
.returning(packages::id)
399403
.get_result(conn)
@@ -459,6 +463,54 @@ impl CoreRepository {
459463
diesel::delete(packages::table.filter(packages::id.eq(id))).execute(conn)
460464
}
461465

466+
/// Checks if a pending install (is_installed=false) exists for a specific package version.
467+
/// Used to check if we can resume a partial install.
468+
pub fn has_pending_install(
469+
conn: &mut SqliteConnection,
470+
pkg_id: &str,
471+
pkg_name: &str,
472+
repo_name: &str,
473+
version: &str,
474+
) -> QueryResult<bool> {
475+
let count: i64 = packages::table
476+
.filter(packages::pkg_id.eq(pkg_id))
477+
.filter(packages::pkg_name.eq(pkg_name))
478+
.filter(packages::repo_name.eq(repo_name))
479+
.filter(packages::version.eq(version))
480+
.filter(packages::is_installed.eq(false))
481+
.count()
482+
.get_result(conn)?;
483+
Ok(count > 0)
484+
}
485+
486+
/// Deletes pending (is_installed=false) records for a package and returns their paths.
487+
/// Used to clean up orphaned partial installs before starting a new install.
488+
pub fn delete_pending_installs(
489+
conn: &mut SqliteConnection,
490+
pkg_id: &str,
491+
pkg_name: &str,
492+
repo_name: &str,
493+
) -> QueryResult<Vec<String>> {
494+
let paths: Vec<String> = packages::table
495+
.filter(packages::pkg_id.eq(pkg_id))
496+
.filter(packages::pkg_name.eq(pkg_name))
497+
.filter(packages::repo_name.eq(repo_name))
498+
.filter(packages::is_installed.eq(false))
499+
.select(packages::installed_path)
500+
.load(conn)?;
501+
502+
diesel::delete(
503+
packages::table
504+
.filter(packages::pkg_id.eq(pkg_id))
505+
.filter(packages::pkg_name.eq(pkg_name))
506+
.filter(packages::repo_name.eq(repo_name))
507+
.filter(packages::is_installed.eq(false)),
508+
)
509+
.execute(conn)?;
510+
511+
Ok(paths)
512+
}
513+
462514
/// Gets the portable package configuration for a package.
463515
pub fn get_portable(
464516
conn: &mut SqliteConnection,
@@ -525,13 +577,15 @@ impl CoreRepository {
525577
.execute(conn)
526578
}
527579

528-
/// Gets old package versions (all except the newest unpinned one) for cleanup.
580+
/// Gets old package versions (all except the newest one) for cleanup.
529581
/// Returns the installed paths of packages to remove.
582+
/// If `force` is true, includes pinned packages. Otherwise only unpinned packages.
530583
pub fn get_old_package_paths(
531584
conn: &mut SqliteConnection,
532585
pkg_id: &str,
533586
pkg_name: &str,
534587
repo_name: &str,
588+
force: bool,
535589
) -> QueryResult<Vec<(i32, String)>> {
536590
let latest: Option<(i32, String)> = packages::table
537591
.filter(packages::pkg_id.eq(pkg_id))
@@ -546,23 +600,33 @@ impl CoreRepository {
546600
return Ok(Vec::new());
547601
};
548602

549-
packages::table
603+
let query = packages::table
550604
.filter(packages::pkg_id.eq(pkg_id))
551605
.filter(packages::pkg_name.eq(pkg_name))
552606
.filter(packages::repo_name.eq(repo_name))
553-
.filter(packages::pinned.eq(false))
554607
.filter(packages::id.ne(latest_id))
555608
.filter(packages::installed_path.ne(&latest_path))
609+
.into_boxed();
610+
611+
let query = if force {
612+
query
613+
} else {
614+
query.filter(packages::pinned.eq(false))
615+
};
616+
617+
query
556618
.select((packages::id, packages::installed_path))
557619
.load(conn)
558620
}
559621

560-
/// Deletes old package versions (all except the newest unpinned one).
622+
/// Deletes old package versions (all except the newest one).
623+
/// If `force` is true, deletes pinned packages too. Otherwise only unpinned packages.
561624
pub fn delete_old_packages(
562625
conn: &mut SqliteConnection,
563626
pkg_id: &str,
564627
pkg_name: &str,
565628
repo_name: &str,
629+
force: bool,
566630
) -> QueryResult<usize> {
567631
let latest_id: Option<i32> = packages::table
568632
.filter(packages::pkg_id.eq(pkg_id))
@@ -577,15 +641,21 @@ impl CoreRepository {
577641
return Ok(0);
578642
};
579643

580-
diesel::delete(
581-
packages::table
582-
.filter(packages::pkg_id.eq(pkg_id))
583-
.filter(packages::pkg_name.eq(pkg_name))
584-
.filter(packages::repo_name.eq(repo_name))
585-
.filter(packages::pinned.eq(false))
586-
.filter(packages::id.ne(latest_id)),
587-
)
588-
.execute(conn)
644+
let pinned_filter: Box<dyn BoxableExpression<packages::table, Sqlite, SqlType = Bool>> =
645+
if force {
646+
Box::new(diesel::dsl::sql::<Bool>("TRUE"))
647+
} else {
648+
Box::new(packages::pinned.eq(false))
649+
};
650+
651+
let query = packages::table
652+
.filter(packages::pkg_id.eq(pkg_id))
653+
.filter(packages::pkg_name.eq(pkg_name))
654+
.filter(packages::repo_name.eq(repo_name))
655+
.filter(packages::id.ne(latest_id))
656+
.filter(pinned_filter);
657+
658+
diesel::delete(query).execute(conn)
589659
}
590660

591661
/// Unlinks all packages with a given name except those matching pkg_id and checksum.

0 commit comments

Comments
 (0)