diff --git a/crates/ecstore/src/store/list_objects.rs b/crates/ecstore/src/store/list_objects.rs index 62d0001e6..0805da668 100644 --- a/crates/ecstore/src/store/list_objects.rs +++ b/crates/ecstore/src/store/list_objects.rs @@ -3312,6 +3312,59 @@ mod test { assert!(!cancel.is_cancelled()); } + #[test] + fn list_path_forward_past_is_idempotent_for_same_marker() { + let mut first_page = sorted_entries(&["obj-0001", "obj-0002", "obj-0003", "obj-0004"]); + first_page.forward_past(Some("obj-0002".to_string())); + + let first_page_names = first_page + .entries() + .into_iter() + .map(|entry| entry.name.clone()) + .collect::>(); + + let mut second_page = sorted_entries(&["obj-0001", "obj-0002", "obj-0003", "obj-0004"]); + second_page.forward_past(Some("obj-0002".to_string())); + + let second_page_names = second_page + .entries() + .into_iter() + .map(|entry| entry.name.clone()) + .collect::>(); + + assert_eq!(first_page_names, second_page_names); + assert_eq!(first_page_names, vec!["obj-0003".to_string(), "obj-0004".to_string()]); + } + + #[test] + fn list_path_parse_marker_replay_still_stable() { + let marker = "photos/2026/image.jpg[rustfs_cache:v1,id:list-cache-id,p:3,s:7]".to_string(); + + let mut parsed = ListPathOptions { + marker: Some(marker), + ..Default::default() + }; + parsed.parse_marker(); + assert_eq!(parsed.marker.as_deref(), Some("photos/2026/image.jpg")); + assert_eq!(parsed.id.as_deref(), Some("list-cache-id")); + assert_eq!(parsed.pool_idx, Some(3)); + assert_eq!(parsed.set_idx, Some(7)); + + let base_marker = parsed.marker.clone().expect("marker should parse"); + let replay_marker = parsed.encode_marker(base_marker.as_str()); + let mut replay = ListPathOptions { + marker: Some(replay_marker), + ..Default::default() + }; + replay.parse_marker(); + + assert_eq!(replay.marker.as_deref(), Some("photos/2026/image.jpg")); + assert_eq!(replay.id.as_deref(), Some("list-cache-id")); + assert_eq!(replay.pool_idx, Some(3)); + assert_eq!(replay.set_idx, Some(7)); + assert_eq!(replay.create, false); + } + #[test] fn test_max_keys_plus_one_caps_before_lookahead() { assert_eq!(max_keys_plus_one(999, true), 1000); diff --git a/scripts/validate_issue_785_list_objects.sh b/scripts/validate_issue_785_list_objects.sh index 0931c738e..91e588ff8 100755 --- a/scripts/validate_issue_785_list_objects.sh +++ b/scripts/validate_issue_785_list_objects.sh @@ -41,6 +41,8 @@ run_unit_checks() { list_path_gather_results_returns_after_limit_without_waiting_for_input_close list_path_gather_results_keeps_marker_entry_for_version_marker_listing list_path_gather_results_skips_marker_entry_by_default + list_path_forward_past_is_idempotent_for_same_marker + list_path_parse_marker_replay_still_stable normalize_list_quorum_falls_back_to_strict list_objects_quorum_from_env_defaults_to_optimal ) @@ -91,8 +93,12 @@ run_live_smoke() { run_metrics_context() { log_info "Collecting local metric symbol index for review" local metric_log="$OUT_DIR/issue-785-metrics-context.log" - rg -n "record_stage_duration\(\"store_list_objects_|record_stage_duration\(\"sets_list_objects_|record_stage_duration\(\"set_disks_list_objects_" "$PROJECT_ROOT/crates/ecstore/src/store/list_objects.rs" > "$metric_log" - log_info "Metric context saved: $metric_log" + if rg -n "record_stage_duration\(" "$PROJECT_ROOT/crates/ecstore/src/store/list_objects.rs" > "$metric_log"; then + log_info "Metric context saved: $metric_log" + else + log_error "No metric stage context found" + return 1 + fi } parse_args() {