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:
@@ -176,9 +176,14 @@ pub fn list_report_objects_filtered(
|
||||
start_pp_index,
|
||||
start_object_index,
|
||||
);
|
||||
ObjectScanSeed { state: &mut state }
|
||||
.deserialize(&mut deserializer)
|
||||
.map_err(QueryDbError::from)?;
|
||||
let scan = ObjectScanSeed { state: &mut state }.deserialize(&mut deserializer);
|
||||
match scan {
|
||||
Ok(()) => {}
|
||||
// A full page plus its next cursor is all the caller needs; the
|
||||
// visitor aborts the remaining scan with a sentinel error.
|
||||
Err(err) if is_object_scan_complete(&err) => {}
|
||||
Err(err) => return Err(QueryDbError::from(err)),
|
||||
}
|
||||
Ok(QueryPage {
|
||||
data: state.data,
|
||||
next_cursor: state.next_cursor,
|
||||
@@ -215,6 +220,16 @@ pub fn object_cursor(pp_index: u64, object_index: u64) -> String {
|
||||
format!("r1:{pp_index}:{object_index}")
|
||||
}
|
||||
|
||||
/// Sentinel message used to abort the streaming object scan early once a list
|
||||
/// page (plus its next cursor) has been collected. The deserializer is left
|
||||
/// mid-document and dropped, which is fine because the caller only needs the
|
||||
/// state accumulated so far.
|
||||
const OBJECT_SCAN_COMPLETE_MSG: &str = "rpki report object scan complete: page collected";
|
||||
|
||||
fn is_object_scan_complete(err: &serde_json::Error) -> bool {
|
||||
err.is_data() && err.to_string().starts_with(OBJECT_SCAN_COMPLETE_MSG)
|
||||
}
|
||||
|
||||
fn parse_object_cursor(cursor: Option<&str>) -> QueryDbResult<(u64, u64)> {
|
||||
let Some(cursor) = cursor else {
|
||||
return Ok((0, 0));
|
||||
@@ -732,6 +747,15 @@ impl<'a> ObjectScanState<'a> {
|
||||
scanned_objects: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once a list page is full and its next cursor is fixed; everything
|
||||
/// after this point cannot alter the page (cursor semantics: the cursor
|
||||
/// already points at the first item of the next page).
|
||||
fn list_page_complete(&self) -> bool {
|
||||
matches!(self.mode, ObjectScanMode::List)
|
||||
&& self.data.len() >= self.limit
|
||||
&& self.next_cursor.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -807,7 +831,13 @@ impl<'de> Visitor<'de> for ObjectScanPpsVisitor<'_, '_> {
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
let mut pp_index = 0u64;
|
||||
while let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? {
|
||||
loop {
|
||||
if self.state.list_page_complete() {
|
||||
return Err(de::Error::custom(OBJECT_SCAN_COMPLETE_MSG));
|
||||
}
|
||||
let Some(raw_pp) = seq.next_element::<Box<RawValue>>()? else {
|
||||
break;
|
||||
};
|
||||
process_raw_publication_point(self.state, pp_index, raw_pp.get())
|
||||
.map_err(de::Error::custom)?;
|
||||
pp_index += 1;
|
||||
@@ -822,6 +852,20 @@ fn process_raw_publication_point(
|
||||
raw_pp: &str,
|
||||
) -> QueryDbResult<()> {
|
||||
state.scanned_publication_points += 1;
|
||||
if matches!(state.mode, ObjectScanMode::List) && pp_index < state.start_pp_index {
|
||||
// Every object in this PP sits before the cursor, so none of them can
|
||||
// be part of the page: skip materializing the subtree entirely.
|
||||
return Ok(());
|
||||
}
|
||||
if state.scope != ObjectScope::All {
|
||||
// Scope is decidable from PP-level fields alone; probe them before
|
||||
// materializing the (potentially huge) objects subtree.
|
||||
let probe: PublicationPointScopeProbe = serde_json::from_str(raw_pp)?;
|
||||
let ctx = PpObjectContext::from_pp(state.run_id, &probe.into_summary());
|
||||
if !state.scope.matches(&ctx) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let pp: PublicationPointForObjects = serde_json::from_str(raw_pp)?;
|
||||
let ctx = PpObjectContext::from_pp(state.run_id, &pp.summary);
|
||||
if !state.scope.matches(&ctx) {
|
||||
@@ -909,6 +953,30 @@ impl<'de> Visitor<'de> for PublicationPointForObjectsVisitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight probe of the PP-level fields needed to decide `ObjectScope`
|
||||
/// before the (potentially huge) objects subtree is materialized.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
struct PublicationPointScopeProbe {
|
||||
node_id: Option<u64>,
|
||||
rsync_base_uri: Option<String>,
|
||||
manifest_rsync_uri: Option<String>,
|
||||
publication_point_rsync_uri: Option<String>,
|
||||
rrdp_notification_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl PublicationPointScopeProbe {
|
||||
fn into_summary(self) -> PublicationPointSummary {
|
||||
PublicationPointSummary {
|
||||
node_id: self.node_id,
|
||||
rsync_base_uri: self.rsync_base_uri,
|
||||
manifest_rsync_uri: self.manifest_rsync_uri,
|
||||
publication_point_rsync_uri: self.publication_point_rsync_uri,
|
||||
rrdp_notification_uri: self.rrdp_notification_uri,
|
||||
..PublicationPointSummary::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct ReportObject {
|
||||
uri: String,
|
||||
@@ -1360,4 +1428,195 @@ mod tests {
|
||||
assert_eq!(found.object.result, "error");
|
||||
assert_eq!(found.resolution.mode, "report_scan");
|
||||
}
|
||||
|
||||
/// Three PPs with two objects each; used to anchor paging and scope
|
||||
/// behavior of the early-stop object scan.
|
||||
fn write_multi_pp_report(report_path: &std::path::Path) {
|
||||
fs::write(
|
||||
report_path,
|
||||
r#"{
|
||||
"meta":{"validation_time_rfc3339_utc":"2026-06-16T00:00:00Z"},
|
||||
"tree":{"warnings":[]},
|
||||
"publication_points":[
|
||||
{
|
||||
"node_id":1,
|
||||
"rsync_base_uri":"rsync://repo-a.example/rpki/",
|
||||
"manifest_rsync_uri":"rsync://repo-a.example/rpki/m.mft",
|
||||
"publication_point_rsync_uri":"rsync://repo-a.example/rpki/",
|
||||
"rrdp_notification_uri":"https://repo-a.example/rrdp/notification.xml",
|
||||
"objects":[
|
||||
{"rsync_uri":"rsync://repo-a.example/rpki/m.mft","sha256_hex":"a1","kind":"manifest","result":"ok"},
|
||||
{"rsync_uri":"rsync://repo-a.example/rpki/a.roa","sha256_hex":"a2","kind":"roa","result":"ok"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id":2,
|
||||
"rsync_base_uri":"rsync://repo-b.example/rpki/",
|
||||
"manifest_rsync_uri":"rsync://repo-b.example/rpki/m.mft",
|
||||
"publication_point_rsync_uri":"rsync://repo-b.example/rpki/",
|
||||
"objects":[
|
||||
{"rsync_uri":"rsync://repo-b.example/rpki/m.mft","sha256_hex":"b1","kind":"manifest","result":"ok"},
|
||||
{"rsync_uri":"rsync://repo-b.example/rpki/b.roa","sha256_hex":"b2","kind":"roa","result":"error","detail":"bad roa"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id":3,
|
||||
"rsync_base_uri":"rsync://repo-c.example/rpki/",
|
||||
"manifest_rsync_uri":"rsync://repo-c.example/rpki/m.mft",
|
||||
"publication_point_rsync_uri":"rsync://repo-c.example/rpki/",
|
||||
"rrdp_notification_uri":"https://repo-c.example/rrdp/notification.xml",
|
||||
"objects":[
|
||||
{"rsync_uri":"rsync://repo-c.example/rpki/m.mft","sha256_hex":"c1","kind":"manifest","result":"ok"},
|
||||
{"rsync_uri":"rsync://repo-c.example/rpki/c.roa","sha256_hex":"c2","kind":"roa","result":"ok"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_listing_pages_across_publication_points_with_stable_cursors() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let report_path = temp.path().join("report.json");
|
||||
write_multi_pp_report(&report_path);
|
||||
|
||||
// Page 1: fills inside PP 0; the cursor must resume at PP 0 object 2.
|
||||
let page1 = list_report_objects(&report_path, "run_0001", ObjectScope::All, 2, None)
|
||||
.expect("page1");
|
||||
assert_eq!(page1.data.len(), 2);
|
||||
assert_eq!(page1.data[0].uri, "rsync://repo-a.example/rpki/m.mft");
|
||||
assert_eq!(page1.data[1].uri, "rsync://repo-a.example/rpki/a.roa");
|
||||
assert_eq!(page1.next_cursor.as_deref(), Some("r1:0:2"));
|
||||
|
||||
// Page 2: PP 0 is exhausted; the two PP 1 objects fill the page.
|
||||
let page2 = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::All,
|
||||
2,
|
||||
page1.next_cursor.as_deref(),
|
||||
)
|
||||
.expect("page2");
|
||||
assert_eq!(page2.data.len(), 2);
|
||||
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
|
||||
assert_eq!(page2.data[1].uri, "rsync://repo-b.example/rpki/b.roa");
|
||||
assert_eq!(page2.next_cursor.as_deref(), Some("r1:1:2"));
|
||||
|
||||
// Page 3: last PP, page fills at the final object.
|
||||
let page3 = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::All,
|
||||
2,
|
||||
page2.next_cursor.as_deref(),
|
||||
)
|
||||
.expect("page3");
|
||||
assert_eq!(page3.data.len(), 2);
|
||||
assert_eq!(page3.next_cursor.as_deref(), Some("r1:2:2"));
|
||||
|
||||
// Page 4: nothing left.
|
||||
let page4 = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::All,
|
||||
2,
|
||||
page3.next_cursor.as_deref(),
|
||||
)
|
||||
.expect("page4");
|
||||
assert!(page4.data.is_empty());
|
||||
assert_eq!(page4.next_cursor, None);
|
||||
|
||||
// A page larger than the remaining tail must not produce a cursor.
|
||||
let tail = list_report_objects(&report_path, "run_0001", ObjectScope::All, 10, None)
|
||||
.expect("tail");
|
||||
assert_eq!(tail.data.len(), 6);
|
||||
assert_eq!(tail.next_cursor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_listing_scope_skips_non_matching_publication_points() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let report_path = temp.path().join("report.json");
|
||||
write_multi_pp_report(&report_path);
|
||||
|
||||
// PP scope: only PP 1 (node_id 2) objects, cursor resumes within PP 1.
|
||||
let page1 = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||
1,
|
||||
None,
|
||||
)
|
||||
.expect("page1");
|
||||
assert_eq!(page1.data.len(), 1);
|
||||
assert_eq!(page1.data[0].uri, "rsync://repo-b.example/rpki/m.mft");
|
||||
assert_eq!(page1.next_cursor.as_deref(), Some("r1:1:1"));
|
||||
let page2 = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||
10,
|
||||
page1.next_cursor.as_deref(),
|
||||
)
|
||||
.expect("page2");
|
||||
assert_eq!(page2.data.len(), 1);
|
||||
assert_eq!(page2.data[0].uri, "rsync://repo-b.example/rpki/b.roa");
|
||||
assert_eq!(page2.next_cursor, None);
|
||||
|
||||
// Repo scope derived from the PP without rrdp uri (repo-b).
|
||||
let probe_pp = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::PublicationPoint("node_2".to_string()),
|
||||
10,
|
||||
None,
|
||||
)
|
||||
.expect("probe");
|
||||
let repo_id = probe_pp.data[0].repo_id.clone();
|
||||
let repo_page = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::Repo(repo_id),
|
||||
10,
|
||||
None,
|
||||
)
|
||||
.expect("repo page");
|
||||
assert_eq!(repo_page.data.len(), 2);
|
||||
assert!(
|
||||
repo_page
|
||||
.data
|
||||
.iter()
|
||||
.all(|object| object.uri.contains("repo-b.example"))
|
||||
);
|
||||
|
||||
// Unknown scope id: empty page, no cursor, no error.
|
||||
let empty = list_report_objects(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::PublicationPoint("node_999".to_string()),
|
||||
10,
|
||||
None,
|
||||
)
|
||||
.expect("empty");
|
||||
assert!(empty.data.is_empty());
|
||||
assert_eq!(empty.next_cursor, None);
|
||||
|
||||
// Filtered listing keeps working across skipped PPs.
|
||||
let mut filter = ObjectFilter::default();
|
||||
filter.query = Some("c.roa".to_string());
|
||||
let filtered = list_report_objects_filtered(
|
||||
&report_path,
|
||||
"run_0001",
|
||||
ObjectScope::All,
|
||||
&filter,
|
||||
10,
|
||||
None,
|
||||
)
|
||||
.expect("filtered");
|
||||
assert_eq!(filtered.data.len(), 1);
|
||||
assert_eq!(filtered.data[0].uri, "rsync://repo-c.example/rpki/c.roa");
|
||||
assert_eq!(filtered.next_cursor, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user