20260726 修复rpki-explorer体验问题32项(#130):RocksDB max_open_files+compose ulimits、objects列表流式早停+scope前置(每页2.85s->10ms级)、未知/过期run统一404、runs倒序、导出文件名Content-Disposition、路径段percent_decode、nginx /api/v1代理与400回SPA;前端serialNumber schema、30s请求超时、reason筛选、组合VRP查询、导出反馈与repo/PP入口、键盘可达、cursor进URL、分页/对比度/skip-link等体验项
This commit is contained in:
@@ -60,6 +60,8 @@ pub enum QueryDbError {
|
||||
MissingColumnFamily(&'static str),
|
||||
#[error("invalid run artifact: {0}")]
|
||||
InvalidArtifact(String),
|
||||
#[error("report data for run {0} is no longer available (retention cleanup)")]
|
||||
RunDataGone(String),
|
||||
#[error("CIR decode error: {0}")]
|
||||
CirDecode(String),
|
||||
}
|
||||
@@ -289,6 +291,7 @@ impl QueryDb {
|
||||
opts.create_if_missing(true);
|
||||
opts.create_missing_column_families(true);
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||
.iter()
|
||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||
@@ -310,6 +313,7 @@ impl QueryDb {
|
||||
opts.create_if_missing(false);
|
||||
opts.create_missing_column_families(false);
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
let descriptors = QUERY_DB_COLUMN_FAMILIES
|
||||
.iter()
|
||||
.map(|name| ColumnFamilyDescriptor::new(*name, cf_options()))
|
||||
@@ -351,8 +355,10 @@ impl QueryDb {
|
||||
pub fn resolve_run_id(&self, run_id: &str) -> QueryDbResult<Option<String>> {
|
||||
if run_id == "latest" || run_id == "latest_run" {
|
||||
self.latest_ready_run()
|
||||
} else {
|
||||
} else if self.get_run(run_id)?.is_some() {
|
||||
Ok(Some(run_id.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +367,38 @@ impl QueryDb {
|
||||
limit: usize,
|
||||
cursor: Option<&str>,
|
||||
) -> QueryDbResult<QueryPage<RunRecord>> {
|
||||
self.list_json_by_prefix(CF_RUNS, "run/", limit, cursor)
|
||||
// Runs are listed newest first: zero-padded run ids sort by age, so a
|
||||
// reverse scan over the `run/` prefix yields the latest run on page 1.
|
||||
// Cursor semantics mirror `list_json_by_prefix`: the cursor is the
|
||||
// first key of the next page (inclusive).
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let cf = self.cf(CF_RUNS)?;
|
||||
let prefix = "run/";
|
||||
let start = match cursor {
|
||||
Some(cursor) => cursor.as_bytes().to_vec(),
|
||||
None => prefix_range_end(prefix.as_bytes())
|
||||
.unwrap_or_else(|| prefix.as_bytes().to_vec()),
|
||||
};
|
||||
let mut data = Vec::new();
|
||||
let mut next_cursor = None;
|
||||
let mode = IteratorMode::From(&start, rocksdb::Direction::Reverse);
|
||||
for item in self.db.iterator_cf(cf, mode) {
|
||||
let (key, value) = item?;
|
||||
let key_str = String::from_utf8_lossy(&key);
|
||||
if !key_str.starts_with(prefix) {
|
||||
break;
|
||||
}
|
||||
if data.len() >= limit {
|
||||
next_cursor = Some(key_str.to_string());
|
||||
break;
|
||||
}
|
||||
data.push(serde_json::from_slice(&value)?);
|
||||
}
|
||||
Ok(QueryPage {
|
||||
data,
|
||||
next_cursor,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_repos(
|
||||
@@ -691,9 +728,17 @@ impl QueryDb {
|
||||
}
|
||||
|
||||
fn report_path_for_run(&self, run_id: &str) -> QueryDbResult<Option<PathBuf>> {
|
||||
Ok(self
|
||||
.get_run(run_id)?
|
||||
.map(|run| Path::new(&run.run_dir).join("report.json")))
|
||||
let Some(run) = self.get_run(run_id)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = Path::new(&run.run_dir).join("report.json");
|
||||
if !path.exists() {
|
||||
// The run is still indexed but its report.json was removed by
|
||||
// retention cleanup; callers must be able to tell this apart
|
||||
// from a genuine io failure (mapped to 404 upstream).
|
||||
return Err(QueryDbError::RunDataGone(run_id.to_string()));
|
||||
}
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
pub fn get_object_projection(
|
||||
@@ -1094,6 +1139,7 @@ impl QueryDb {
|
||||
fn cf_options() -> Options {
|
||||
let mut opts = Options::default();
|
||||
opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
|
||||
opts.set_max_open_files(512);
|
||||
opts
|
||||
}
|
||||
|
||||
@@ -1951,15 +1997,17 @@ mod tests {
|
||||
db.resolve_run_id("run_0001").unwrap().as_deref(),
|
||||
Some("run_0001")
|
||||
);
|
||||
assert_eq!(db.resolve_run_id("run_9999").unwrap(), None);
|
||||
|
||||
let first_page = db.list_runs(1, None).expect("runs");
|
||||
assert_eq!(first_page.data.len(), 1);
|
||||
assert_eq!(first_page.data[0].run_id, "run_0001");
|
||||
assert_eq!(first_page.data[0].run_id, "run_0002");
|
||||
let second_page = db
|
||||
.list_runs(1, first_page.next_cursor.as_deref())
|
||||
.expect("second runs");
|
||||
assert_eq!(second_page.data.len(), 1);
|
||||
assert_eq!(second_page.data[0].run_id, "run_0002");
|
||||
assert_eq!(second_page.data[0].run_id, "run_0001");
|
||||
assert!(second_page.next_cursor.is_none());
|
||||
|
||||
let repos = db.list_repos("run_0002", 10, None).expect("repos");
|
||||
assert_eq!(repos.data.len(), 1);
|
||||
@@ -2105,6 +2153,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_report_file_is_reported_as_run_data_gone() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let run1 = temp.path().join("runs/run_0001");
|
||||
fs::create_dir_all(&run1).expect("run1");
|
||||
write_sample_run(&run1, "run_0001", 1);
|
||||
|
||||
let query_db_path = temp.path().join("query-db");
|
||||
index_artifacts(&ArtifactIndexerConfig {
|
||||
query_db_path: query_db_path.clone(),
|
||||
run_root: Some(temp.path().to_path_buf()),
|
||||
run_dir: None,
|
||||
repo_bytes_db_path: None,
|
||||
projection_entry_limit: 50,
|
||||
min_run_seq: None,
|
||||
retain_indexed_runs: None,
|
||||
})
|
||||
.expect("index");
|
||||
let db = QueryDb::open(&query_db_path).expect("open query db");
|
||||
assert_eq!(db.list_objects("run_0001", 10, None).unwrap().data.len(), 2);
|
||||
|
||||
// Simulate retention cleanup removing the run directory while the
|
||||
// index record survives: object listing/lookup must fail with a
|
||||
// distinguishable error instead of a bare io error.
|
||||
fs::remove_dir_all(&run1).expect("remove run dir");
|
||||
let err = db.list_objects("run_0001", 10, None).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, QueryDbError::RunDataGone(ref run_id) if run_id == "run_0001"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
let err = db.get_object_by_sha256("run_0001", "22").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, QueryDbError::RunDataGone(_)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_indexing_does_not_move_latest_backwards() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in New Issue
Block a user