Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions crates/service/src/storage/storage_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -631,16 +631,20 @@ fn preflight_model_catalog_v2(db_path: &Path) -> Result<(), String> {
.query_row(
"SELECT COUNT(*) FROM (
SELECT lower(trim(slug)) FROM model_catalog_models
WHERE trim(slug)<>'' GROUP BY lower(trim(slug)) HAVING COUNT(*)>1
WHERE scope='default' AND trim(slug)<>''
GROUP BY lower(trim(slug)) HAVING COUNT(*)>1
)",
[],
|row| row.get(0),
)
.map_err(|err| format!("preflight duplicate model slugs failed: {err}"))?;
if duplicates > 0 {
return Err(format!(
"model catalog V2 preflight failed: {duplicates} duplicate case-insensitive slugs"
));
// The V2 cutover reads only the default scope and deterministically keeps
// the first row for a case-insensitive slug collision. Do not strand a
// database that the migration can still open and preserve.
log::warn!(
"model catalog V2 preflight found {duplicates} duplicate case-insensitive default-scope slugs; migration will keep the first legacy row"
);
}
}
if sqlite_table_exists(&conn, "model_price_rules")? {
Expand Down
94 changes: 92 additions & 2 deletions crates/service/src/storage/tests/storage_helpers_tests.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use super::{
clear_storage_cache_for_tests, clear_storage_open_count_for_tests,
model_catalog_v2_migration_needed, open_storage_at_path, storage_open_count_for_tests,
clear_storage_cache_for_tests, clear_storage_open_count_for_tests, initialize_storage,
model_catalog_v2_migration_needed, open_storage_at_path, preflight_model_catalog_v2,
storage_open_count_for_tests,
};
use rusqlite::Connection;
use std::ffi::OsString;
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -97,6 +99,94 @@ fn pending_model_catalog_data_migration_requires_backup() {
let _ = std::fs::remove_file(&db_path);
}

#[test]
fn model_catalog_preflight_allows_case_conflicting_legacy_slugs() {
let db_path = unique_db_path("codexmanager-model-catalog-duplicate-slugs");
let conn = Connection::open(&db_path).expect("open database");
conn.execute_batch(
"CREATE TABLE model_catalog_models (
scope TEXT NOT NULL,
slug TEXT NOT NULL
);
INSERT INTO model_catalog_models(scope,slug)
VALUES
('default','Legacy-Model'),
('default','legacy-model'),
('account_proxy','LEGACY-MODEL');",
)
.expect("create duplicate legacy catalog fixture");
drop(conn);

preflight_model_catalog_v2(Path::new(&db_path))
.expect("case-conflicting legacy slugs are migratable");

let _ = std::fs::remove_file(&db_path);
}

#[test]
fn initialize_storage_migrates_case_conflicting_legacy_slugs() {
let _env_lock = crate::test_env_guard();
let db_path = unique_db_path("codexmanager-model-catalog-duplicate-migration");
let conn = Connection::open(&db_path).expect("open database");
conn.execute_batch(
"CREATE TABLE model_catalog_models (
scope TEXT NOT NULL,
slug TEXT NOT NULL,
display_name TEXT NOT NULL,
source_kind TEXT NOT NULL DEFAULT 'remote',
user_edited INTEGER NOT NULL DEFAULT 0,
description TEXT,
default_reasoning_level TEXT,
visibility TEXT,
supported_in_api INTEGER,
context_window INTEGER,
extra_json TEXT NOT NULL DEFAULT '{}',
sort_index INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, slug)
);
INSERT INTO model_catalog_models(
scope,slug,display_name,source_kind,user_edited,description,
visibility,supported_in_api,extra_json,sort_index,updated_at
) VALUES
('default','Legacy-Model','First legacy row','custom',1,'first','list',1,'{}',0,100),
('default','legacy-model','Second legacy row','custom',1,'second','list',1,'{}',1,200);",
)
.expect("create duplicate legacy catalog fixture");
drop(conn);

clear_storage_cache_for_tests();
let _db_env = EnvGuard::set("CODEXMANAGER_DB_PATH", &db_path);
initialize_storage().expect("migrate duplicate legacy slugs");

let storage = open_storage_at_path(&db_path).expect("open migrated storage");
let migrated = storage
.list_managed_models_v2(true)
.expect("list migrated models");
let legacy_rows = migrated
.iter()
.filter(|model| model.slug.eq_ignore_ascii_case("legacy-model"))
.collect::<Vec<_>>();
assert_eq!(legacy_rows.len(), 1);
assert_eq!(legacy_rows[0].slug, "Legacy-Model");
assert_eq!(legacy_rows[0].display_name, "First legacy row");
drop(storage);

clear_storage_cache_for_tests();
let _ = std::fs::remove_file(&db_path);
if let Some(parent) = Path::new(&db_path).parent() {
let backup_prefix = format!("{db_path}.model-catalog-v2.");
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let path = entry.path();
if path.to_string_lossy().starts_with(&backup_prefix) {
let _ = std::fs::remove_file(path);
}
}
}
}
}

/// 函数 `open_storage_reuses_cached_connection_in_same_thread`
///
/// 作者: gaohongshun
Expand Down