feat(usage): add configurable database size limit

This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent 1f347d142b
commit 911a20a91a
7 files changed
+608 -65

No files matched your search

+2
View File
@@ -2648,7 +2648,9 @@ fn main() {
usage::get_usage_analysis,
usage::get_usage_events,
usage::get_usage_pricing,
usage::get_usage_storage_settings,
usage::repair_usage_cache_records,
usage::save_usage_storage_settings,
usage::save_usage_model_price,
usage::delete_usage_model_price,
usage::sync_usage_model_prices,
+388 -42
View File
@@ -44,6 +44,7 @@ const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
const USAGE_EVENT_KEY_MIGRATION_KEY: &str = "event_key_v5";
const USAGE_DATABASE_MAX_MB_KEY: &str = "max_database_size_mb";
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
const USAGE_SCHEMA_VERSION: u8 = 1;
const USAGE_DATABASE_SCHEMA_VERSION: i64 = 5;
@@ -56,6 +57,7 @@ const USAGE_QUEUE_KEY: &str = "usage";
const LEGACY_USAGE_QUEUE_KEY: &str = "queue";
const HTTP_USAGE_QUEUE_SOURCE: &str = "http_pull:usage_queue";
const SQLITE_BUSY_TIMEOUT_SECONDS: u64 = 5;
const BYTES_PER_MB: u64 = 1024 * 1024;
const TOKENS_PER_PRICE_UNIT: f64 = 1_000_000.0;
const LONG_CONTEXT_INPUT_TOKEN_THRESHOLD: u64 = 272_000;
const BUNDLED_MODEL_PRICE_CATALOG: &str = include_str!("../resources/model_prices.json");
@@ -105,6 +107,19 @@ struct UsageInboxRow {
attempt_count: i64,
}
#[derive(Clone, Copy, Default)]
struct UsagePersistOutcome {
inserted: usize,
deleted: u64,
}
impl UsagePersistOutcome {
fn add(&mut self, other: Self) {
self.inserted = self.inserted.saturating_add(other.inserted);
self.deleted = self.deleted.saturating_add(other.deleted);
}
}
#[derive(Debug, Eq, PartialEq)]
struct UsageMigrationSnapshot {
records: i64,
@@ -305,6 +320,15 @@ pub(crate) struct UsageRepairResult {
backup_path: Option<String>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageStorageSettings {
max_database_size_mb: u64,
database_size_bytes: u64,
total_records: u64,
deleted_records: u64,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelPrice {
@@ -487,9 +511,13 @@ impl UsageCollectorState {
}
}
fn increment_total_records(&self, added: usize) {
fn apply_record_changes(&self, inserted: usize, deleted: u64) {
if let Ok(mut inner) = self.inner.lock() {
inner.status.total_records = inner.status.total_records.saturating_add(added as u64);
inner.status.total_records = inner
.status
.total_records
.saturating_add(inserted as u64)
.saturating_sub(deleted);
}
}
@@ -579,7 +607,8 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
.map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?;
migrate_usage_database(&mut connection, root)?;
migrate_legacy_json_storage(&mut connection, root)?;
cleanup_usage_inbox(&connection, Local::now())
cleanup_usage_inbox(&connection, Local::now())?;
enforce_usage_database_limit(&connection).map(|_| ())
}
#[tauri::command]
@@ -587,6 +616,37 @@ pub(crate) async fn repair_usage_cache_records() -> Result<UsageRepairResult, St
run_usage_task(|| repair_usage_cache_records_at(&usage_root_dir()?)).await
}
#[tauri::command]
pub(crate) async fn get_usage_storage_settings() -> Result<UsageStorageSettings, String> {
run_usage_task(|| {
let root = usage_root_dir()?;
let connection = open_usage_database_at(&root)?;
load_usage_storage_settings(&connection, &root, 0)
})
.await
}
#[tauri::command]
pub(crate) async fn save_usage_storage_settings(
app: tauri::AppHandle,
max_database_size_mb: u64,
) -> Result<UsageStorageSettings, String> {
let settings = run_usage_task(move || {
let root = usage_root_dir()?;
let connection = open_usage_database_at(&root)?;
save_usage_database_limit(&connection, max_database_size_mb)?;
let deleted = enforce_usage_database_limit(&connection)?;
load_usage_storage_settings(&connection, &root, deleted)
})
.await?;
app.state::<UsageCollectorState>()
.set_total_records(settings.total_records);
if settings.deleted_records > 0 {
let _ = app.emit(USAGE_UPDATED_EVENT, Local::now().to_rfc3339());
}
Ok(settings)
}
fn repair_usage_cache_records_at(root: &Path) -> Result<UsageRepairResult, String> {
let mut connection = open_usage_database_at(root)?;
let candidate_count: i64 = connection
@@ -1329,6 +1389,198 @@ fn open_usage_database_at(root: &Path) -> Result<Connection, String> {
Ok(connection)
}
fn load_usage_database_limit(connection: &Connection) -> Result<u64, String> {
let value = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![USAGE_DATABASE_MAX_MB_KEY],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(|error| format!("读取使用记录数据库大小限制失败: {error}"))?;
value
.map(|value| {
value
.parse::<u64>()
.map_err(|error| format!("使用记录数据库大小限制无效: {error}"))
})
.transpose()
.map(|value| value.unwrap_or(0))
}
fn save_usage_database_limit(
connection: &Connection,
max_database_size_mb: u64,
) -> Result<(), String> {
max_database_size_mb
.checked_mul(BYTES_PER_MB)
.ok_or_else(|| "使用记录数据库大小限制过大".to_string())?;
connection
.execute(
r#"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value"#,
params![USAGE_DATABASE_MAX_MB_KEY, max_database_size_mb.to_string()],
)
.map(|_| ())
.map_err(|error| format!("保存使用记录数据库大小限制失败: {error}"))
}
fn usage_database_active_bytes(connection: &Connection) -> Result<u64, String> {
let page_size = connection
.query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0))
.map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?;
let page_count = connection
.query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0))
.map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?;
let free_pages = connection
.query_row("PRAGMA freelist_count", [], |row| row.get::<_, u64>(0))
.map_err(|error| format!("读取使用记录数据库空闲页数失败: {error}"))?;
page_count
.saturating_sub(free_pages)
.checked_mul(page_size)
.ok_or_else(|| "使用记录数据库大小溢出".to_string())
}
fn usage_database_allocated_bytes(connection: &Connection) -> Result<u64, String> {
let page_size = connection
.query_row("PRAGMA page_size", [], |row| row.get::<_, u64>(0))
.map_err(|error| format!("读取使用记录数据库页大小失败: {error}"))?;
let page_count = connection
.query_row("PRAGMA page_count", [], |row| row.get::<_, u64>(0))
.map_err(|error| format!("读取使用记录数据库页数失败: {error}"))?;
page_count
.checked_mul(page_size)
.ok_or_else(|| "使用记录数据库大小溢出".to_string())
}
fn usage_database_file_bytes(database_path: &Path) -> Result<u64, String> {
let mut wal_path = database_path.as_os_str().to_os_string();
wal_path.push("-wal");
let paths = [database_path.to_path_buf(), PathBuf::from(wal_path)];
let mut total = 0_u64;
for path in paths {
match fs::metadata(&path) {
Ok(metadata) => total = total.saturating_add(metadata.len()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取使用记录数据库文件大小失败 {}: {error}",
path.display()
));
}
}
}
Ok(total)
}
fn usage_database_disk_bytes(root: &Path) -> Result<u64, String> {
usage_database_file_bytes(&root.join(USAGE_DATABASE_FILE))
}
fn usage_database_connection_disk_bytes(connection: &Connection) -> Result<u64, String> {
let path = connection
.path()
.ok_or_else(|| "无法读取使用记录数据库文件路径".to_string())?;
usage_database_file_bytes(Path::new(path))
}
fn truncate_usage_database_wal(connection: &Connection) {
let _ = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
))
});
}
fn load_usage_storage_settings(
connection: &Connection,
root: &Path,
deleted_records: u64,
) -> Result<UsageStorageSettings, String> {
let total_records = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, u64>(0)
})
.map_err(|error| format!("统计使用记录数量失败: {error}"))?;
Ok(UsageStorageSettings {
max_database_size_mb: load_usage_database_limit(connection)?,
database_size_bytes: usage_database_disk_bytes(root)?,
total_records,
deleted_records,
})
}
fn enforce_usage_database_limit(connection: &Connection) -> Result<u64, String> {
let max_database_size_mb = load_usage_database_limit(connection)?;
if max_database_size_mb == 0 {
return Ok(0);
}
let max_bytes = max_database_size_mb
.checked_mul(BYTES_PER_MB)
.ok_or_else(|| "使用记录数据库大小限制过大".to_string())?;
if usage_database_connection_disk_bytes(connection)? <= max_bytes {
return Ok(0);
}
truncate_usage_database_wal(connection);
let allocated_bytes = usage_database_allocated_bytes(connection)?;
if allocated_bytes <= max_bytes {
return Ok(0);
}
let inbox_deleted = connection
.execute(
"DELETE FROM usage_inbox WHERE status IN ('processed', 'decode_failed', 'discarded')",
[],
)
.map_err(|error| format!("清理使用记录临时数据失败: {error}"))?;
let mut active_bytes = usage_database_active_bytes(connection)?;
let mut deleted_records = 0_u64;
while active_bytes > max_bytes {
let total_records = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, u64>(0)
})
.map_err(|error| format!("统计待清理使用记录失败: {error}"))?;
if total_records == 0 {
break;
}
let excess = active_bytes.saturating_sub(max_bytes);
let estimated = ((total_records as u128 * excess as u128)
.saturating_add(active_bytes.saturating_sub(1) as u128)
/ active_bytes.max(1) as u128) as u64;
let batch = estimated.max(1).min(total_records).min(i64::MAX as u64) as i64;
let deleted = connection
.execute(
r#"DELETE FROM usage_events
WHERE id IN (
SELECT id FROM usage_events
ORDER BY timestamp_ms ASC, id ASC
LIMIT ?1
)"#,
params![batch],
)
.map_err(|error| format!("删除最旧使用记录失败: {error}"))?
as u64;
if deleted == 0 {
break;
}
deleted_records = deleted_records.saturating_add(deleted);
active_bytes = usage_database_active_bytes(connection)?;
}
if allocated_bytes > max_bytes || inbox_deleted > 0 || deleted_records > 0 {
connection
.execute_batch("VACUUM;")
.map_err(|error| format!("回收使用记录数据库空间失败: {error}"))?;
truncate_usage_database_wal(connection);
}
Ok(deleted_records)
}
fn migrate_legacy_json_storage(connection: &mut Connection, root: &Path) -> Result<(), String> {
let migrated = connection
.query_row(
@@ -1536,10 +1788,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
let recovered = open_usage_database_at(&root)
.and_then(|mut connection| process_usage_inbox(&mut connection, &config));
match recovered {
Ok(saved) if saved > 0 => {
Ok(outcome) if outcome.inserted > 0 || outcome.deleted > 0 => {
let saved = outcome.inserted;
let collected_at = Local::now().to_rfc3339();
app.state::<UsageCollectorState>()
.increment_total_records(saved);
.apply_record_changes(outcome.inserted, outcome.deleted);
set_collector_status(
&app,
"collecting",
@@ -1586,10 +1839,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
subscription_config = Some((config.port, config.management_secret_key.clone()));
set_collector_status(&app, "collecting", "已连接 CPA usage 实时订阅", None);
match backfill_usage_queue(&root, &config, &mut redis_queue).await {
Ok(saved) => {
Ok(outcome) => {
let saved = outcome.inserted;
publish_collected_records(
&app,
saved,
outcome,
&format!("实时订阅已连接,补录 {saved} 条队列记录"),
);
retry_seconds = 1;
@@ -1634,10 +1888,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
payload,
&config,
) {
Ok(saved) => {
Ok(outcome) => {
let saved = outcome.inserted;
publish_collected_records(
&app,
saved,
outcome,
&format!("实时订阅已保存 {saved} 条新记录"),
);
retry_seconds = 1;
@@ -1692,10 +1947,11 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
batch.messages,
&config,
) {
Ok(saved) => {
Ok(outcome) => {
let saved = outcome.inserted;
publish_collected_records(
&app,
saved,
outcome,
&format!("{source_label}已保存 {saved} 条新记录"),
);
retry_seconds = 1;
@@ -1720,27 +1976,27 @@ async fn backfill_usage_queue(
root: &Path,
config: &GuiConfigFile,
redis_queue: &mut RedisUsageQueueSource,
) -> Result<usize, String> {
let mut saved_total = 0_usize;
) -> Result<UsagePersistOutcome, String> {
let mut outcome = UsagePersistOutcome::default();
loop {
match redis_queue.pull(config).await {
Ok(batch) => {
let fetched = batch.messages.len();
if fetched == 0 {
return Ok(saved_total);
return Ok(outcome);
}
saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source(
outcome.add(persist_raw_usage_messages_from_source(
root,
&batch.source,
batch.messages,
config,
)?);
if fetched < USAGE_QUEUE_BATCH_SIZE {
return Ok(saved_total);
return Ok(outcome);
}
}
Err(redis_error) => {
return backfill_http_usage_queue(root, config, saved_total)
return backfill_http_usage_queue(root, config, outcome)
.await
.map_err(|http_error| {
format!("补录队列失败(Redis: {redis_error}; HTTP: {http_error})")
@@ -1753,31 +2009,32 @@ async fn backfill_usage_queue(
async fn backfill_http_usage_queue(
root: &Path,
config: &GuiConfigFile,
mut saved_total: usize,
) -> Result<usize, String> {
mut outcome: UsagePersistOutcome,
) -> Result<UsagePersistOutcome, String> {
loop {
let messages = fetch_usage_queue_raw(config).await?;
let fetched = messages.len();
if fetched == 0 {
return Ok(saved_total);
return Ok(outcome);
}
saved_total = saved_total.saturating_add(persist_raw_usage_messages_from_source(
outcome.add(persist_raw_usage_messages_from_source(
root,
HTTP_USAGE_QUEUE_SOURCE,
messages,
config,
)?);
if fetched < USAGE_QUEUE_BATCH_SIZE {
return Ok(saved_total);
return Ok(outcome);
}
}
}
fn publish_collected_records(app: &tauri::AppHandle, saved: usize, message: &str) {
fn publish_collected_records(app: &tauri::AppHandle, outcome: UsagePersistOutcome, message: &str) {
let saved = outcome.inserted;
let collected_at = Local::now().to_rfc3339();
if saved > 0 {
if saved > 0 || outcome.deleted > 0 {
app.state::<UsageCollectorState>()
.increment_total_records(saved);
.apply_record_changes(saved, outcome.deleted);
}
set_collector_status(
app,
@@ -1805,9 +2062,9 @@ pub(crate) fn persist_local_usage_event(
}
let config = app.state::<GuiConfigState>().snapshot()?;
let root = usage_root_dir()?;
let saved = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?;
publish_collected_records(app, saved, "已保存桌面健康检测使用记录");
Ok(saved)
let outcome = persist_queue_items_from_source(&root, collector_source, vec![value], &config)?;
publish_collected_records(app, outcome, "已保存桌面健康检测使用记录");
Ok(outcome.inserted)
}
async fn wait_or_cancel(token: &CancellationToken, seconds: u64) {
@@ -1909,7 +2166,7 @@ fn persist_queue_items_from_source(
source: &str,
items: Vec<Value>,
config: &GuiConfigFile,
) -> Result<usize, String> {
) -> Result<UsagePersistOutcome, String> {
let mut connection = open_usage_database_at(root)?;
enqueue_usage_queue_items(&mut connection, source, items)?;
process_usage_inbox(&mut connection, config)
@@ -2009,7 +2266,7 @@ fn persist_raw_usage_message_from_source(
source: &str,
raw_message: String,
config: &GuiConfigFile,
) -> Result<usize, String> {
) -> Result<UsagePersistOutcome, String> {
persist_raw_usage_messages_from_source(root, source, vec![raw_message], config)
}
@@ -2018,7 +2275,7 @@ fn persist_raw_usage_messages_from_source(
source: &str,
raw_messages: Vec<String>,
config: &GuiConfigFile,
) -> Result<usize, String> {
) -> Result<UsagePersistOutcome, String> {
let mut connection = open_usage_database_at(root)?;
enqueue_usage_raw_messages(&mut connection, source, raw_messages)?;
process_usage_inbox(&mut connection, config)
@@ -2027,10 +2284,10 @@ fn persist_raw_usage_messages_from_source(
fn process_usage_inbox(
connection: &mut Connection,
config: &GuiConfigFile,
) -> Result<usize, String> {
) -> Result<UsagePersistOutcome, String> {
let rows = list_processable_usage_inbox(connection, USAGE_INBOX_PROCESS_LIMIT)?;
if rows.is_empty() {
return Ok(0);
return Ok(UsagePersistOutcome::default());
}
let mut valid_rows = Vec::with_capacity(rows.len());
let mut records = Vec::with_capacity(rows.len());
@@ -2048,7 +2305,10 @@ fn process_usage_inbox(
}
}
if records.is_empty() {
return Ok(0);
return Ok(UsagePersistOutcome {
inserted: 0,
deleted: enforce_usage_database_limit(connection)?,
});
}
let persist_result = (|| -> Result<usize, String> {
@@ -2077,7 +2337,10 @@ fn process_usage_inbox(
Ok(inserted)
})();
match persist_result {
Ok(inserted) => Ok(inserted),
Ok(inserted) => Ok(UsagePersistOutcome {
inserted,
deleted: enforce_usage_database_limit(connection)?,
}),
Err(error) => {
mark_usage_inbox_process_failed(connection, &valid_rows, &error)?;
Err(error)
@@ -4067,12 +4330,15 @@ mod tests {
}
#[test]
fn collector_total_records_are_cached_and_incremented() {
fn collector_total_records_apply_insertions_and_retention_deletions() {
let state = UsageCollectorState::default();
state.set_total_records(40);
state.increment_total_records(2);
state.apply_record_changes(4, 2);
assert_eq!(state.status().unwrap().total_records, 42);
state.apply_record_changes(0, 100);
assert_eq!(state.status().unwrap().total_records, 0);
}
fn test_root(name: &str) -> PathBuf {
@@ -4117,6 +4383,86 @@ mod tests {
open_usage_database_at(root).unwrap()
}
#[test]
fn usage_database_limit_defaults_to_unlimited_and_persists() {
let root = test_root("database-limit-persistence");
let connection = open_test_database(&root);
assert_eq!(load_usage_database_limit(&connection).unwrap(), 0);
save_usage_database_limit(&connection, 128).unwrap();
drop(connection);
let connection = open_usage_database_at(&root).unwrap();
assert_eq!(load_usage_database_limit(&connection).unwrap(), 128);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn usage_database_limit_removes_oldest_records_after_collection() {
let root = test_root("database-limit-retention");
let mut connection = open_test_database(&root);
save_usage_database_limit(&connection, 1).unwrap();
let payload = "x".repeat(8 * 1024);
let transaction = connection.transaction().unwrap();
for timestamp_ms in 0_i64..512 {
transaction
.execute(
r#"INSERT INTO usage_events (
event_key, timestamp, timestamp_ms, local_hour, model,
failure_body, created_at
) VALUES (?1, ?2, ?3, '2026-01-01T00', 'retention-test', ?4, ?2)"#,
params![
format!("retention-{timestamp_ms}"),
format!("2026-01-01T00:00:{timestamp_ms:03}Z"),
timestamp_ms,
payload,
],
)
.unwrap();
}
transaction.commit().unwrap();
enqueue_usage_queue_items(
&mut connection,
"retention-test",
vec![serde_json::json!({
"timestamp": "2026-09-18T12:00:00+08:00",
"request_id": "newest-retained-record",
"model": "retention-test",
"tokens": { "input_tokens": 1, "output_tokens": 1 }
})],
)
.unwrap();
let outcome = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
assert_eq!(outcome.inserted, 1);
assert!(outcome.deleted > 0);
let (remaining, oldest_timestamp, newest_record): (u64, i64, u64) = connection
.query_row(
r#"SELECT COUNT(*), MIN(timestamp_ms),
SUM(CASE WHEN request_id = 'newest-retained-record' THEN 1 ELSE 0 END)
FROM usage_events"#,
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert!(remaining > 0);
assert!(remaining < 513);
assert!(oldest_timestamp > 0);
assert_eq!(newest_record, 1);
assert!(usage_database_active_bytes(&connection).unwrap() <= BYTES_PER_MB);
assert!(usage_database_disk_bytes(&root).unwrap() <= BYTES_PER_MB);
let settings = load_usage_storage_settings(&connection, &root, outcome.deleted).unwrap();
assert_eq!(settings.max_database_size_mb, 1);
assert_eq!(settings.total_records, remaining);
assert_eq!(settings.deleted_records, outcome.deleted);
drop(connection);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn repairs_legacy_claude_input_tokens_on_demand() {
let root = test_root("claude-input-migration");
@@ -4782,7 +5128,7 @@ mod tests {
&config,
)
.unwrap();
assert_eq!(inserted, 1);
assert_eq!(inserted.inserted, 1);
let connection = open_usage_database_at(&root).unwrap();
let inbox_count = connection
.query_row("SELECT COUNT(*) FROM usage_inbox", [], |row| {
@@ -4825,7 +5171,7 @@ mod tests {
row.get::<_, String>(0)
})
.unwrap();
assert_eq!(inserted, 0);
assert_eq!(inserted.inserted, 0);
assert_eq!(event_count, 0);
assert_eq!(status, "decode_failed");
drop(connection);
@@ -4883,7 +5229,7 @@ mod tests {
)
.unwrap();
assert_eq!(inserted, 2);
assert_eq!(inserted.inserted, 2);
assert_eq!(events.total, 2);
assert!(events.items.iter().all(|event| !event.failed));
assert!(events
@@ -5225,7 +5571,7 @@ mod tests {
)
.unwrap();
assert_eq!(inserted, 1);
assert_eq!(inserted.inserted, 1);
assert_eq!(event_count, 1);
assert_eq!(processed_count, 1);
assert_eq!(failed_count, 1);
@@ -5259,7 +5605,7 @@ mod tests {
.unwrap();
let inserted = process_usage_inbox(&mut connection, &GuiConfigFile::default()).unwrap();
assert_eq!(inserted, 2);
assert_eq!(inserted.inserted, 2);
let event_count = connection
.query_row("SELECT COUNT(*) FROM usage_events", [], |row| {
row.get::<_, i64>(0)
+11 -2
View File
@@ -448,9 +448,18 @@ export const jaOverrides = {
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
'usage.dataManagement.title': '履歴データ管理',
'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。',
'usage.dataManagement.description': '使用履歴の保存容量を管理し、既知の履歴データ異常を修復します。',
'usage.dataManagement.manualBadge': '手動操作',
'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。',
'usage.dataManagement.storageTitle': 'データベース容量上限',
'usage.dataManagement.storageDescription': '0 にすると無制限です。上限に達すると古い使用履歴から自動的に削除します。',
'usage.dataManagement.storageCurrent': '現在の使用量 {size} · {records} 件',
'usage.dataManagement.storageInput': 'データベース容量上限(MB)',
'usage.dataManagement.storageUnit': 'MB',
'usage.dataManagement.storageSave': '上限を保存',
'usage.dataManagement.storageSaving': '保存中…',
'usage.dataManagement.storageInvalid': 'データベース容量上限には 0 または正の整数を入力してください。',
'usage.dataManagement.storageSaved': 'データベース容量上限を保存しました。',
'usage.dataManagement.storageSavedWithCleanup': 'データベース容量上限を保存し、古い記録を {deleted} 件削除しました。',
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
'usage.dataManagement.run': 'スキャンして修復',
+11 -2
View File
@@ -435,9 +435,18 @@ export const en: Record<MessageKey, string> = {
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
'usage.dataManagement.title': 'Historical Data Management',
'usage.dataManagement.description': 'Scan and repair known anomalies in historical usage records.',
'usage.dataManagement.description': 'Manage usage record storage and repair known historical data anomalies.',
'usage.dataManagement.manualBadge': 'Manual action',
'usage.dataManagement.notice': 'A database backup is created first. Only cache anomalies and unknown records are processed.',
'usage.dataManagement.storageTitle': 'Database size limit',
'usage.dataManagement.storageDescription': 'Set to 0 for no limit. When the limit is reached, the oldest usage records are deleted automatically.',
'usage.dataManagement.storageCurrent': 'Currently using {size} · {records} records',
'usage.dataManagement.storageInput': 'Database size limit (MB)',
'usage.dataManagement.storageUnit': 'MB',
'usage.dataManagement.storageSave': 'Save limit',
'usage.dataManagement.storageSaving': 'Saving…',
'usage.dataManagement.storageInvalid': 'Enter 0 or a positive integer for the database size limit.',
'usage.dataManagement.storageSaved': 'Database size limit saved.',
'usage.dataManagement.storageSavedWithCleanup': 'Database size limit saved and {deleted} oldest records deleted.',
'usage.dataManagement.actionTitle': 'Repair historical anomalies',
'usage.dataManagement.actionDescription': 'Correct Claude cache token accounting and delete records recorded as unknown usage.',
'usage.dataManagement.run': 'Scan and repair',
+11 -2
View File
@@ -434,9 +434,18 @@ export const zhCN = {
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
'usage.dataManagement.title': '历史数据管理',
'usage.dataManagement.description': '扫描并修复历史使用记录中的已知数据异常。',
'usage.dataManagement.description': '管理使用记录存储并修复历史数据中的已知异常。',
'usage.dataManagement.manualBadge': '手动操作',
'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理缓存口径异常和 unknown 记录。',
'usage.dataManagement.storageTitle': '数据库大小限制',
'usage.dataManagement.storageDescription': '设为 0 时不限制;达到上限后自动删除最旧的使用记录。',
'usage.dataManagement.storageCurrent': '当前占用 {size} · {records} 条记录',
'usage.dataManagement.storageInput': '数据库大小上限(MB)',
'usage.dataManagement.storageUnit': 'MB',
'usage.dataManagement.storageSave': '保存限制',
'usage.dataManagement.storageSaving': '保存中…',
'usage.dataManagement.storageInvalid': '请输入 0 或正整数作为数据库大小上限。',
'usage.dataManagement.storageSaved': '数据库大小限制已保存。',
'usage.dataManagement.storageSavedWithCleanup': '数据库大小限制已保存,并删除了最旧的 {deleted} 条记录。',
'usage.dataManagement.actionTitle': '修复历史异常记录',
'usage.dataManagement.actionDescription': '修正错误缓存信息,并删除被误记为 unknown 的记录。',
'usage.dataManagement.run': '扫描并修复',
+108 -3
View File
@@ -170,6 +170,13 @@ type UsageRepairResult = {
backupPath: string | null;
};
type UsageStorageSettings = {
maxDatabaseSizeMb: number;
databaseSizeBytes: number;
totalRecords: number;
deletedRecords: number;
};
type ModelPriceSyncResult = {
imported: number;
skipped: number;
@@ -240,6 +247,13 @@ const rangeQuery = (range: UsageRange, customStart: string, customEnd: string):
const compactNumber = (value: number) => formatUsageNumber(value, getCurrentLocale());
const formatStorageBytes = (value: number) => {
const bytes = Number.isFinite(value) ? Math.max(0, value) : 0;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
};
const formatUsd = (amount: number) => {
if (!Number.isFinite(amount) || amount <= 0) return '$0.00';
const maximumFractionDigits =
@@ -715,6 +729,57 @@ function UsageDataManagementView() {
const [running, setRunning] = useState(false);
const [result, setResult] = useState<UsageRepairResult | null>(null);
const [error, setError] = useState('');
const [storage, setStorage] = useState<UsageStorageSettings | null>(null);
const [limitDraft, setLimitDraft] = useState('0');
const [loadingLimit, setLoadingLimit] = useState(true);
const [savingLimit, setSavingLimit] = useState(false);
const [storageNotice, setStorageNotice] = useState('');
useEffect(() => {
let disposed = false;
invoke<UsageStorageSettings>('get_usage_storage_settings')
.then((next) => {
if (disposed) return;
setStorage(next);
setLimitDraft(String(next.maxDatabaseSizeMb));
})
.catch((requestError) => {
if (!disposed) setError(String(requestError));
})
.finally(() => {
if (!disposed) setLoadingLimit(false);
});
return () => {
disposed = true;
};
}, []);
const saveStorageLimit = async () => {
const normalized = limitDraft.trim();
const maxDatabaseSizeMb = Number(normalized);
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(maxDatabaseSizeMb)) {
setError(t('usage.dataManagement.storageInvalid'));
return;
}
setSavingLimit(true);
setError('');
setStorageNotice('');
try {
const next = await invoke<UsageStorageSettings>('save_usage_storage_settings', { maxDatabaseSizeMb });
setStorage(next);
setLimitDraft(String(next.maxDatabaseSizeMb));
setStorageNotice(t(
next.deletedRecords > 0
? 'usage.dataManagement.storageSavedWithCleanup'
: 'usage.dataManagement.storageSaved',
{ deleted: next.deletedRecords.toLocaleString() },
));
} catch (requestError) {
setError(String(requestError));
} finally {
setSavingLimit(false);
}
};
const repair = async () => {
if (!await askConfirmation({ title: t('usage.dataManagement.title'), message: t('usage.dataManagement.confirm') })) return;
@@ -724,6 +789,8 @@ function UsageDataManagementView() {
try {
const next = await invoke<UsageRepairResult>('repair_usage_cache_records');
setResult(next);
const nextStorage = await invoke<UsageStorageSettings>('get_usage_storage_settings');
setStorage(nextStorage);
} catch (requestError) {
setError(String(requestError));
} finally {
@@ -745,11 +812,49 @@ function UsageDataManagementView() {
<span className="usage-data-management-badge">{t('usage.dataManagement.manualBadge')}</span>
</div>
<div className="usage-data-management-notice">
<TriangleAlert size={17} aria-hidden="true" />
<span>{t('usage.dataManagement.notice')}</span>
<div className="usage-data-management-action usage-storage-limit-action">
<div>
<strong>{t('usage.dataManagement.storageTitle')}</strong>
<span>{t('usage.dataManagement.storageDescription')}</span>
{storage ? (
<small>
{t('usage.dataManagement.storageCurrent', {
size: formatStorageBytes(storage.databaseSizeBytes),
records: compactNumber(storage.totalRecords),
})}
</small>
) : null}
</div>
<div className="usage-storage-limit-editor">
<label>
<input
type="number"
min="0"
step="1"
inputMode="numeric"
value={limitDraft}
disabled={loadingLimit || savingLimit}
onChange={(event) => setLimitDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !loadingLimit && !savingLimit) void saveStorageLimit();
}}
aria-label={t('usage.dataManagement.storageInput')}
/>
<span>{t('usage.dataManagement.storageUnit')}</span>
</label>
<button
type="button"
className="primary-button"
onClick={() => void saveStorageLimit()}
disabled={loadingLimit || savingLimit}
>
{savingLimit ? t('usage.dataManagement.storageSaving') : t('usage.dataManagement.storageSave')}
</button>
</div>
</div>
{storageNotice ? <MessageNotice tone="success" message={storageNotice} onDismiss={() => setStorageNotice('')} /> : null}
<div className="usage-data-management-action">
<div>
<strong>{t('usage.dataManagement.actionTitle')}</strong>
+77 -14
View File
@@ -9348,7 +9348,7 @@ body.table-col-resizing * {
}
.usage-data-management-heading p,
.usage-data-management-action span {
.usage-data-management-action > div:first-child > span {
display: block;
margin: 6px 0 0;
color: var(--theme-716b61);
@@ -9363,26 +9363,59 @@ body.table-col-resizing * {
font-size: 12px;
}
.usage-data-management-notice {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 22px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--theme-c77b30) 28%, var(--theme-d8d4cc));
border-radius: 10px;
background: color-mix(in srgb, var(--theme-c77b30) 8%, transparent);
color: var(--theme-716b61);
font-size: 13px;
}
.usage-data-management-action {
margin-top: 22px;
padding: 16px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 12px;
background: var(--theme-f8f5ee);
}
.usage-data-management-action + .usage-data-management-action {
margin-top: 12px;
}
.usage-storage-limit-action small {
display: block;
margin-top: 7px;
color: var(--theme-8a847c);
font-size: 12px;
}
.usage-storage-limit-editor {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.usage-storage-limit-editor label {
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid var(--theme-d8d4cc);
border-radius: 7px;
background: var(--theme-fffdf8);
}
.usage-storage-limit-editor input {
width: 104px;
border: 0;
background: transparent;
box-shadow: none;
text-align: right;
}
.usage-storage-limit-editor input:focus {
box-shadow: none;
}
.usage-storage-limit-editor label > span {
padding: 0 10px 0 4px;
color: var(--theme-716b61);
font-size: 12px;
}
.usage-data-management-result {
align-items: stretch;
margin-top: 26px;
@@ -10825,6 +10858,21 @@ body.table-col-resizing * {
width: 100%;
}
.usage-storage-limit-editor {
align-items: stretch;
flex-direction: column;
}
.usage-storage-limit-editor label {
width: 100%;
}
.usage-storage-limit-editor input {
flex: 1;
width: auto;
min-width: 0;
}
.panel-heading {
flex-direction: column;
}
@@ -14381,6 +14429,21 @@ body.table-col-resizing * {
width: 100%;
}
.usage-storage-limit-editor {
align-items: stretch;
flex-direction: column;
}
.usage-storage-limit-editor label {
width: 100%;
}
.usage-storage-limit-editor input {
flex: 1;
width: auto;
min-width: 0;
}
.usage-custom-range {
align-items: stretch;
flex-direction: column;