1use std::collections::BTreeMap;
4
5use async_trait::async_trait;
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::Result;
11
12pub const MAX_REPLICATION_DIFF_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
14pub const MAX_REPLICATION_INSPECTION_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ReplicationMetricScope {
20 Unavailable,
21 NodeLocal,
22 ClusterAggregated,
23 PartialCluster,
24 Unknown(String),
25}
26
27impl ReplicationMetricScope {
28 pub fn as_str(&self) -> &str {
29 match self {
30 Self::Unavailable => "unavailable",
31 Self::NodeLocal => "node_local",
32 Self::ClusterAggregated => "cluster_aggregated",
33 Self::PartialCluster => "partial_cluster",
34 Self::Unknown(value) => value,
35 }
36 }
37}
38
39impl Serialize for ReplicationMetricScope {
40 fn serialize<S: serde::Serializer>(
41 &self,
42 serializer: S,
43 ) -> std::result::Result<S::Ok, S::Error> {
44 serializer.serialize_str(self.as_str())
45 }
46}
47
48impl<'de> Deserialize<'de> for ReplicationMetricScope {
49 fn deserialize<D: serde::Deserializer<'de>>(
50 deserializer: D,
51 ) -> std::result::Result<Self, D::Error> {
52 let value = String::deserialize(deserializer)?;
53 Ok(match value.as_str() {
54 "unavailable" => Self::Unavailable,
55 "node_local" => Self::NodeLocal,
56 "cluster_aggregated" => Self::ClusterAggregated,
57 "partial_cluster" => Self::PartialCluster,
58 _ => Self::Unknown(value),
59 })
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
64pub struct ReplicationCountSize {
65 pub count: u64,
66 #[serde(rename = "bytes", alias = "size")]
67 pub size: u64,
68 #[serde(flatten, default)]
69 pub extra: BTreeMap<String, Value>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct ReplicationQueueMetric {
74 pub curr: ReplicationCountSize,
75 pub avg: ReplicationCountSize,
76 pub max: ReplicationCountSize,
77 pub last_minute: ReplicationCountSize,
78 #[serde(flatten, default)]
79 pub extra: BTreeMap<String, Value>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct ReplicationLatencyMetric {
84 pub avg: f64,
85 pub curr: f64,
86 pub max: f64,
87 #[serde(flatten, default)]
88 pub extra: BTreeMap<String, Value>,
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
92pub struct ReplicationTransferRate {
93 pub avg: f64,
94 pub curr: f64,
95 pub peak: f64,
96 #[serde(flatten, default)]
97 pub extra: BTreeMap<String, Value>,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct ReplicationTargetMetric {
102 pub replicated_size: u64,
103 pub replicated_count: u64,
104 pub failed: ReplicationCountSize,
105 #[serde(default)]
106 pub fail_stats: Option<ReplicationCountSize>,
107 pub latency: ReplicationLatencyMetric,
108 pub xfer_rate_lrg: ReplicationTransferRate,
109 pub xfer_rate_sml: ReplicationTransferRate,
110 pub bandwidth_limit_bytes_per_sec: u64,
111 pub current_bandwidth_bytes_per_sec: f64,
112 #[serde(default)]
113 pub latency_scope: Option<ReplicationMetricScope>,
114 #[serde(default)]
115 pub bandwidth_scope: Option<ReplicationMetricScope>,
116 #[serde(flatten, default)]
117 pub extra: BTreeMap<String, Value>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct ReplicationMetrics {
122 pub stats: BTreeMap<String, ReplicationTargetMetric>,
123 pub replica_size: u64,
124 pub replica_count: u64,
125 pub replicated_size: u64,
126 pub replicated_count: u64,
127 pub q_stat: ReplicationQueueMetric,
128 #[serde(default)]
129 pub provider_available: Option<bool>,
130 #[serde(default)]
131 pub cluster_complete: Option<bool>,
132 #[serde(default)]
133 pub observed_node_count: Option<u32>,
134 #[serde(default)]
135 pub expected_node_count: Option<u32>,
136 #[serde(default)]
137 pub queue_scope: Option<ReplicationMetricScope>,
138 #[serde(flatten, default)]
139 pub extra: BTreeMap<String, Value>,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct ReplicationMrfTarget {
144 #[serde(rename = "ARN")]
145 pub arn: String,
146 #[serde(rename = "FailedCount")]
147 pub failed_count: u64,
148 #[serde(rename = "FailedSize")]
149 pub failed_size: u64,
150 #[serde(rename = "ObservationScope")]
151 pub observation_scope: ReplicationMetricScope,
152 #[serde(flatten, default)]
153 pub extra: BTreeMap<String, Value>,
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct ReplicationMrf {
158 #[serde(rename = "Bucket")]
159 pub bucket: String,
160 #[serde(rename = "Targets")]
161 pub targets: Vec<ReplicationMrfTarget>,
162 #[serde(rename = "TotalFailedCount")]
163 pub total_failed_count: u64,
164 #[serde(rename = "TotalFailedSize")]
165 pub total_failed_size: u64,
166 #[serde(rename = "QueuedCount")]
167 pub queued_count: u64,
168 #[serde(rename = "QueuedSize")]
169 pub queued_size: u64,
170 #[serde(rename = "PerObjectEntriesAvailable")]
171 pub per_object_entries_available: bool,
172 #[serde(rename = "RuntimeStatsAvailable")]
173 pub runtime_stats_available: bool,
174 #[serde(rename = "ClusterComplete")]
175 pub cluster_complete: bool,
176 #[serde(rename = "ObservedNodeCount")]
177 pub observed_node_count: u32,
178 #[serde(rename = "ExpectedNodeCount")]
179 pub expected_node_count: u32,
180 #[serde(rename = "DurableBacklogAvailable")]
181 pub durable_backlog_available: bool,
182 #[serde(rename = "DurableCount")]
183 pub durable_count: u64,
184 #[serde(rename = "DurableSize")]
185 pub durable_size: u64,
186 #[serde(rename = "PerTargetDurableEntriesAvailable")]
187 pub per_target_durable_entries_available: bool,
188 #[serde(flatten, default)]
189 pub extra: BTreeMap<String, Value>,
190}
191
192#[async_trait]
193pub trait ReplicationInspectionApi: Send + Sync {
194 async fn replication_metrics(&self, bucket: &str) -> Result<ReplicationMetrics>;
195 async fn replication_mrf(&self, bucket: &str) -> Result<ReplicationMrf>;
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct ReplicationDiff {
201 #[serde(rename = "Entries")]
202 pub entries: Vec<ReplicationDiffEntry>,
203 #[serde(rename = "IsTruncated")]
204 pub is_truncated: bool,
205 #[serde(rename = "ScannedVersions")]
206 pub scanned_versions: usize,
207 #[serde(flatten, default)]
208 pub extra: BTreeMap<String, Value>,
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct ReplicationDiffEntry {
214 #[serde(rename = "Object")]
215 pub object: String,
216 #[serde(rename = "VersionID")]
217 pub version_id: Option<String>,
218 #[serde(rename = "Size")]
219 pub size_bytes: u64,
220 #[serde(rename = "IsDeleteMarker")]
221 pub delete_marker: bool,
222 #[serde(rename = "ReplicationStatus")]
223 pub replication_status: String,
224 #[serde(rename = "LastModified")]
225 pub last_modified: Option<Timestamp>,
226 #[serde(flatten, default)]
227 pub extra: BTreeMap<String, Value>,
228}
229
230#[async_trait]
232pub trait ReplicationDiffApi: Send + Sync {
233 async fn replication_diff(&self, bucket: &str, prefix: Option<&str>)
235 -> Result<ReplicationDiff>;
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn response_preserves_unknown_fields_and_typed_entries() {
244 let response: ReplicationDiff = serde_json::from_str(
245 r#"{
246 "Entries": [{
247 "Object": "reports/a.json",
248 "VersionID": "v1",
249 "Size": 42,
250 "IsDeleteMarker": false,
251 "ReplicationStatus": "FAILED",
252 "LastModified": "2026-07-21T04:00:00Z",
253 "TargetDetail": {"attempts": 2}
254 }],
255 "IsTruncated": true,
256 "ScannedVersions": 10000,
257 "ServerRevision": 7
258 }"#,
259 )
260 .expect("typed replication diff");
261
262 assert_eq!(response.entries[0].size_bytes, 42);
263 assert_eq!(response.entries[0].version_id.as_deref(), Some("v1"));
264 assert_eq!(response.entries[0].extra["TargetDetail"]["attempts"], 2);
265 assert_eq!(response.extra["ServerRevision"], 7);
266 }
267
268 #[test]
269 fn response_accepts_delete_marker_without_version_or_timestamp() {
270 let response: ReplicationDiff = serde_json::from_str(
271 r#"{
272 "Entries": [{
273 "Object": "removed.txt",
274 "VersionID": null,
275 "Size": 0,
276 "IsDeleteMarker": true,
277 "ReplicationStatus": "PENDING",
278 "LastModified": null
279 }],
280 "IsTruncated": false,
281 "ScannedVersions": 1
282 }"#,
283 )
284 .expect("delete marker diff");
285
286 assert!(response.entries[0].delete_marker);
287 assert!(response.entries[0].version_id.is_none());
288 assert!(response.entries[0].last_modified.is_none());
289 }
290
291 #[test]
292 fn response_rejects_negative_sizes_and_malformed_timestamps() {
293 for payload in [
294 r#"{"Entries":[{"Object":"a","VersionID":null,"Size":-1,"IsDeleteMarker":false,"ReplicationStatus":"FAILED","LastModified":null}],"IsTruncated":false,"ScannedVersions":1}"#,
295 r#"{"Entries":[{"Object":"a","VersionID":null,"Size":1,"IsDeleteMarker":false,"ReplicationStatus":"FAILED","LastModified":"yesterday"}],"IsTruncated":false,"ScannedVersions":1}"#,
296 ] {
297 assert!(serde_json::from_str::<ReplicationDiff>(payload).is_err());
298 }
299 }
300
301 #[test]
302 fn response_requires_scan_completeness_fields() {
303 let payload = r#"{"Entries":[]}"#;
304 assert!(serde_json::from_str::<ReplicationDiff>(payload).is_err());
305 }
306
307 #[test]
308 fn metrics_distinguish_legacy_metadata_and_preserve_unknown_scope() {
309 let legacy: ReplicationMetrics = serde_json::from_str(r#"{"stats":{},"replica_size":0,"replica_count":0,"replicated_size":0,"replicated_count":0,"q_stat":{"curr":{"count":0,"size":0},"avg":{"count":0,"size":0},"max":{"count":0,"size":0},"last_minute":{"count":0,"size":0}}}"#).expect("legacy metrics");
310 assert_eq!(legacy.provider_available, None);
311 assert_eq!(legacy.cluster_complete, None);
312
313 let current: ReplicationMetrics = serde_json::from_str(r#"{"stats":{},"replica_size":0,"replica_count":0,"replicated_size":0,"replicated_count":0,"q_stat":{"curr":{"count":0,"size":0},"avg":{"count":0,"size":0},"max":{"count":0,"size":0},"last_minute":{"count":0,"size":0}},"provider_available":true,"cluster_complete":false,"observed_node_count":1,"expected_node_count":2,"queue_scope":"future_scope"}"#).expect("current metrics");
314 assert_eq!(current.provider_available, Some(true));
315 assert_eq!(
316 current.queue_scope,
317 Some(ReplicationMetricScope::Unknown("future_scope".into()))
318 );
319 }
320
321 #[test]
322 fn metrics_and_mrf_reject_negative_counters() {
323 let metrics = r#"{"stats":{},"replica_size":-1,"replica_count":0,"replicated_size":0,"replicated_count":0,"q_stat":{"curr":{"count":0,"size":0},"avg":{"count":0,"size":0},"max":{"count":0,"size":0},"last_minute":{"count":0,"size":0}}}"#;
324 assert!(serde_json::from_str::<ReplicationMetrics>(metrics).is_err());
325 let mrf = r#"{"Bucket":"b","Targets":[],"TotalFailedCount":-1,"TotalFailedSize":0,"QueuedCount":0,"QueuedSize":0,"PerObjectEntriesAvailable":false,"RuntimeStatsAvailable":true,"ClusterComplete":false,"ObservedNodeCount":1,"ExpectedNodeCount":2,"DurableBacklogAvailable":false,"DurableCount":0,"DurableSize":0,"PerTargetDurableEntriesAvailable":false}"#;
326 assert!(serde_json::from_str::<ReplicationMrf>(mrf).is_err());
327 }
328}