1use crate::connection;
7pub use crate::instance_metadata::InstanceIdentity;
8use crate::segment_object_key::format_v1_segment_object_key;
9use aws_sdk_s3::Client;
10use aws_sdk_s3::error::SdkError;
11use aws_sdk_s3::operation::put_object::PutObjectError;
12use dial9_core::boot_id::generate_boot_id as default_boot_id;
13use dial9_core::pipeline::{
14 ProcessError, ProcessErrorKind, SegmentData, SegmentProcessor, SegmentRef,
15};
16use dial9_core::rate_limited;
17use std::collections::HashMap;
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::Arc;
21use std::time::Duration;
22
23fn put_error_kind(e: SdkError<PutObjectError>) -> ProcessErrorKind {
32 let retryable = match &e {
33 SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) | SdkError::ResponseError(_) => {
34 true
35 }
36 SdkError::ServiceError(ctx) => {
37 let status = ctx.raw().status().as_u16();
38 status >= 500 || status == 429
39 }
40 _ => false,
43 };
44 ProcessErrorKind::transfer(Box::new(e), retryable)
45}
46
47#[derive(Debug, Clone, Default)]
49#[non_exhaustive]
50pub struct KeyContext {
51 pub index: u32,
53 pub epoch_secs: u64,
55 pub boot_id: String,
59}
60
61pub trait S3KeyFn: Send + Sync {
66 fn object_key(&self, segment: &KeyContext) -> String;
68}
69
70impl<F> S3KeyFn for F
71where
72 F: Fn(&KeyContext) -> String + Send + Sync,
73{
74 fn object_key(&self, segment: &KeyContext) -> String {
75 self(segment)
76 }
77}
78
79#[derive(Clone, bon::Builder)]
108#[builder(on(String, into))]
109pub struct S3Config {
110 bucket: String,
111 service_name: String,
112 #[builder(into, default = InstanceIdentity::from_hostname())]
114 instance_path: InstanceIdentity,
115 #[builder(skip = default_boot_id())]
126 boot_id: String,
127 prefix: Option<String>,
129 region: Option<String>,
131 #[builder(with = |key_fn: impl S3KeyFn + 'static| Arc::new(key_fn) as Arc<dyn S3KeyFn>)]
133 key_fn: Option<Arc<dyn S3KeyFn>>,
134 #[builder(default = Duration::from_secs(30))]
144 operation_attempt_timeout: Duration,
145}
146
147impl std::fmt::Debug for S3Config {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("S3Config")
150 .field("bucket", &self.bucket)
151 .field("service_name", &self.service_name)
152 .field("prefix", &self.prefix)
153 .field("region", &self.region)
154 .field("operation_attempt_timeout", &self.operation_attempt_timeout)
155 .finish_non_exhaustive()
156 }
157}
158
159impl S3Config {
160 pub(crate) fn bucket(&self) -> &str {
162 &self.bucket
163 }
164
165 pub fn set_boot_id(&mut self, boot_id: impl Into<String>) {
168 self.boot_id = boot_id.into();
169 }
170
171 pub fn as_metadata(&self) -> impl Iterator<Item = (&str, &str)> {
174 [
175 ("bucket", self.bucket.as_str()),
176 ("service_name", self.service_name.as_str()),
177 ("instance_path", self.instance_path.as_str()),
178 ("boot_id", self.boot_id.as_str()),
179 ]
180 .into_iter()
181 .chain(self.prefix.as_ref().map(|p| ("prefix", p.as_str())))
182 .chain(self.region.as_ref().map(|r| ("region", r.as_str())))
183 }
184
185 pub(crate) fn region(&self) -> Option<&str> {
187 self.region.as_deref()
188 }
189
190 pub(crate) fn operation_attempt_timeout(&self) -> Duration {
192 self.operation_attempt_timeout
193 }
194
195 pub(crate) fn object_key(
201 &self,
202 segment: &SegmentRef,
203 metadata: &HashMap<String, String>,
204 ) -> String {
205 let epoch_secs: u64 = metadata
206 .get("epoch_secs")
207 .and_then(|s| s.parse().ok())
208 .unwrap_or(0);
209
210 if let Some(key_fn) = &self.key_fn {
211 let info = KeyContext {
212 index: segment.index(),
213 epoch_secs,
214 boot_id: self.boot_id.clone(),
215 };
216 return key_fn.object_key(&info);
217 }
218 let (date, time) = time_bucket_from_epoch(epoch_secs);
219 let ts = epoch_secs.to_string();
220
221 let extension = if metadata
222 .get("content_encoding")
223 .is_some_and(|v| v == "gzip")
224 {
225 ".bin.gz"
226 } else {
227 ".bin"
228 };
229
230 let filename = format!("{}-{}{}", ts, segment.index(), extension);
231 format_v1_segment_object_key(
232 self.prefix.as_deref(),
233 &date,
234 &self.service_name,
235 &time,
236 self.instance_path.as_str(),
237 &self.boot_id,
238 &filename,
239 )
240 }
241
242 pub(crate) fn manifest_key(&self, dump_id: &str) -> String {
244 match &self.prefix {
245 Some(p) => format!("{p}/dumps/{dump_id}.json"),
246 None => format!("dumps/{dump_id}.json"),
247 }
248 }
249}
250
251#[derive(Debug, serde::Serialize)]
256pub(crate) struct DumpManifest {
257 pub(crate) dump_id: String,
258 pub(crate) triggered_at: String,
259 pub(crate) time_range: [String; 2],
260 pub(crate) segments_processed: usize,
261 pub(crate) metadata: std::collections::BTreeMap<String, String>,
262 pub(crate) segments: Vec<String>,
263}
264
265impl DumpManifest {
266 pub(crate) fn new(
267 completion: &dial9_core::dump::DumpCompletion,
268 segments: Vec<String>,
269 ) -> Self {
270 Self {
271 dump_id: completion.dump_id.to_string(),
272 triggered_at: rfc3339(completion.triggered_at),
273 time_range: [
274 rfc3339(completion.time_range.0),
275 rfc3339(completion.time_range.1),
276 ],
277 segments_processed: completion.segments_processed,
278 metadata: completion
279 .metadata
280 .iter()
281 .map(|(k, v)| (k.clone(), v.clone()))
282 .collect(),
283 segments,
284 }
285 }
286}
287
288fn rfc3339(t: std::time::SystemTime) -> String {
289 time::OffsetDateTime::from(t)
290 .format(&time::format_description::well_known::Rfc3339)
291 .unwrap_or_else(|_| "invalid-timestamp".to_string())
292}
293
294fn valid_user_metadata_key(key: &str) -> bool {
297 const RESERVED: &[&str] = &[
298 "service",
299 "boot-id",
300 "segment-index",
301 "start-time",
302 "host",
303 "dump-id",
304 ];
305 !key.is_empty()
306 && key.len() <= 128
307 && key
308 .bytes()
309 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
310 && !RESERVED.contains(&key)
311}
312
313fn valid_user_metadata_value(value: &str) -> bool {
316 value.len() <= 256 && value.bytes().all(|b| (0x20..=0x7e).contains(&b))
317}
318
319fn time_bucket_from_epoch(epoch_secs: u64) -> (String, String) {
321 let dt = time::OffsetDateTime::from_unix_timestamp(epoch_secs as i64)
322 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
323 (
324 format!("{:04}-{:02}-{:02}", dt.year(), dt.month() as u8, dt.day()),
325 format!("{:02}{:02}", dt.hour(), dt.minute()),
326 )
327}
328
329#[cfg(test)]
331pub(crate) fn gzip_compress_file_sync(path: &std::path::Path) -> std::io::Result<Vec<u8>> {
332 use flate2::Compression;
333 use flate2::write::GzEncoder;
334 use std::io::{Read, Write};
335 let mut file = std::fs::File::open(path)?;
336 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
337 let mut buf = [0u8; 64 * 1024];
338 loop {
339 let n = file.read(&mut buf)?;
340 if n == 0 {
341 break;
342 }
343 encoder.write_all(&buf[..n])?;
344 }
345 encoder.finish()
346}
347
348pub(crate) struct S3Uploader {
350 client: Client,
351 config: S3Config,
352}
353
354impl std::fmt::Debug for S3Uploader {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.debug_struct("S3Uploader").finish_non_exhaustive()
357 }
358}
359
360impl S3Uploader {
361 pub(crate) fn new(client: Client, config: S3Config) -> Self {
363 Self { client, config }
364 }
365
366 pub(crate) async fn upload_and_delete(
370 &self,
371 segment: &SegmentRef,
372 payload: dial9_core::pipeline::Payload,
373 metadata: &HashMap<String, String>,
374 ) -> Result<String, ProcessErrorKind> {
375 let key = self.config.object_key(segment, metadata);
376
377 let content_type = if metadata
378 .get("content_encoding")
379 .is_some_and(|v| v == "gzip")
380 {
381 "application/gzip"
382 } else {
383 "application/octet-stream"
384 };
385
386 let mut req = self
387 .client
388 .put_object()
389 .bucket(&self.config.bucket)
390 .key(&key)
391 .content_type(content_type)
392 .metadata("service", &self.config.service_name)
393 .metadata("boot-id", &self.config.boot_id)
394 .metadata("segment-index", segment.index().to_string())
395 .metadata(
396 "start-time",
397 metadata
398 .get("epoch_secs")
399 .map(|s| s.as_str())
400 .unwrap_or("0"),
401 )
402 .metadata("host", self.config.instance_path.as_str());
403
404 if let Some(dump_ids) = metadata.get("dump_id") {
408 req = req.metadata("dump-id", dump_ids);
409 for (k, v) in metadata {
410 if let Some(stripped) = k.strip_prefix("dump.") {
411 let header_key = stripped.to_ascii_lowercase();
412 if valid_user_metadata_key(&header_key) && valid_user_metadata_value(v) {
413 req = req.metadata(header_key, v);
414 } else {
415 rate_limited!(Duration::from_secs(60), {
416 tracing::warn!(
417 target: "dial9_worker",
418 key = %stripped,
419 "dump metadata pair not valid as S3 user metadata, skipping"
420 );
421 });
422 }
423 }
424 }
425 }
426
427 req.body(aws_sdk_s3::primitives::ByteStream::from(
428 payload.into_bytes(),
429 ))
430 .send()
431 .await
432 .map_err(put_error_kind)?;
433
434 if let Some(path) = segment.disk_path() {
436 match tokio::fs::remove_file(path).await {
437 Ok(()) => {}
438 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
439 tracing::debug!(target: "dial9_worker", path = %path.display(), "segment already removed");
440 }
441 Err(e) => return Err(e.into()),
442 }
443 }
444
445 Ok(key)
446 }
447
448 pub(crate) fn manifest_key(&self, dump_id: &str) -> String {
450 self.config.manifest_key(dump_id)
451 }
452
453 pub(crate) async fn upload_manifest(
455 &self,
456 key: &str,
457 body: Vec<u8>,
458 ) -> Result<(), ProcessErrorKind> {
459 self.client
460 .put_object()
461 .bucket(&self.config.bucket)
462 .key(key)
463 .content_type("application/json")
464 .body(aws_sdk_s3::primitives::ByteStream::from(body))
465 .send()
466 .await
467 .map_err(put_error_kind)?;
468 Ok(())
469 }
470}
471
472pub struct S3PipelineUploader {
477 state: S3UploaderState,
478 pub(crate) dump_keys: HashMap<String, Vec<String>>,
483}
484
485type BoxedS3ClientFuture = Pin<Box<dyn Future<Output = aws_sdk_s3::Client> + Send + 'static>>;
486
487struct S3ClientFuture(tokio::sync::Mutex<BoxedS3ClientFuture>);
490
491impl S3ClientFuture {
492 fn new(future: impl Future<Output = aws_sdk_s3::Client> + Send + 'static) -> Self {
493 Self(tokio::sync::Mutex::new(Box::pin(future)))
494 }
495
496 fn get_mut(&mut self) -> &mut BoxedS3ClientFuture {
497 self.0.get_mut()
498 }
499}
500
501enum S3ClientSource {
502 Future(S3ClientFuture),
503 Ready(aws_sdk_s3::Client),
504}
505
506enum S3UploaderState {
507 Pending {
508 s3_config: S3Config,
509 client_source: S3ClientSource,
510 },
511 Ready {
512 uploader: S3Uploader,
513 circuit_breaker: connection::CircuitBreaker,
514 },
515}
516
517impl std::fmt::Debug for S3PipelineUploader {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 f.debug_struct("S3PipelineUploader").finish_non_exhaustive()
520 }
521}
522
523impl S3PipelineUploader {
524 fn default_client_source(s3_config: &S3Config) -> S3ClientSource {
525 let operation_attempt_timeout = s3_config.operation_attempt_timeout();
526 S3ClientSource::Future(S3ClientFuture::new(async move {
527 let timeout_config = aws_sdk_s3::config::timeout::TimeoutConfig::builder()
531 .operation_attempt_timeout(operation_attempt_timeout)
532 .build();
533 let sdk_config = aws_config::defaults(aws_config::BehaviorVersion::latest())
534 .timeout_config(timeout_config)
535 .load()
536 .await;
537 aws_sdk_s3::Client::new(&sdk_config)
538 }))
539 }
540
541 pub fn new(s3_config: S3Config, client: Option<aws_sdk_s3::Client>) -> Self {
546 let client_source = match client {
547 Some(client) => S3ClientSource::Ready(client),
548 None => Self::default_client_source(&s3_config),
549 };
550 Self {
551 state: S3UploaderState::Pending {
552 s3_config,
553 client_source,
554 },
555 dump_keys: HashMap::new(),
556 }
557 }
558
559 #[must_use]
565 pub fn with_client_future<F>(mut self, client_future: F) -> Self
566 where
567 F: Future<Output = aws_sdk_s3::Client> + Send + 'static,
568 {
569 match &mut self.state {
570 S3UploaderState::Pending { client_source, .. } => {
571 *client_source = S3ClientSource::Future(S3ClientFuture::new(client_future));
572 }
573 S3UploaderState::Ready { .. } => {
574 unreachable!("with_client_future called after uploader initialization")
575 }
576 }
577 self
578 }
579
580 pub fn set_client(&mut self, client: aws_sdk_s3::Client) {
585 match &mut self.state {
586 S3UploaderState::Pending { client_source, .. } => {
587 *client_source = S3ClientSource::Ready(client);
588 }
589 S3UploaderState::Ready { .. } => {
590 unreachable!("set_client called after uploader initialization")
591 }
592 }
593 }
594
595 pub fn take_client(&mut self) -> Option<aws_sdk_s3::Client> {
599 let S3UploaderState::Pending {
600 s3_config,
601 client_source,
602 } = &mut self.state
603 else {
604 return None;
605 };
606 if !matches!(client_source, S3ClientSource::Ready(_)) {
607 return None;
608 }
609
610 let replacement = Self::default_client_source(s3_config);
611 match std::mem::replace(client_source, replacement) {
612 S3ClientSource::Ready(client) => Some(client),
613 S3ClientSource::Future(_) => unreachable!("client source changed while borrowed"),
614 }
615 }
616
617 pub fn set_boot_id(&mut self, boot_id: impl Into<String>) {
621 if let S3UploaderState::Pending { s3_config, .. } = &mut self.state {
622 s3_config.set_boot_id(boot_id);
623 }
624 }
625
626 #[cfg(test)]
629 pub(crate) fn from_ready(
630 uploader: S3Uploader,
631 circuit_breaker: connection::CircuitBreaker,
632 ) -> Self {
633 Self {
634 state: S3UploaderState::Ready {
635 uploader,
636 circuit_breaker,
637 },
638 dump_keys: HashMap::new(),
639 }
640 }
641
642 async fn build_uploader(
643 s3_config: S3Config,
644 bootstrap_client: aws_sdk_s3::Client,
645 ) -> (S3Uploader, connection::CircuitBreaker) {
646 let region = match s3_config.region() {
647 Some(r) => r.to_owned(),
648 None => detect_bucket_region(&bootstrap_client, s3_config.bucket()).await,
649 };
650 tracing::info!(target: "dial9_worker", bucket = %s3_config.bucket(), %region, "resolved bucket region");
651
652 let corrected_conf = bootstrap_client
654 .config()
655 .to_builder()
656 .region(aws_sdk_s3::config::Region::new(region))
657 .build();
658 let corrected_client = aws_sdk_s3::Client::from_conf(corrected_conf);
659
660 (
661 S3Uploader::new(corrected_client, s3_config),
662 connection::CircuitBreaker::new(),
663 )
664 }
665
666 async fn ensure_initialized(&mut self) {
667 let (s3_config, bootstrap_client) = match &mut self.state {
668 S3UploaderState::Pending {
669 s3_config,
670 client_source,
671 } => {
672 let s3_config = s3_config.clone();
673 let client = match client_source {
674 S3ClientSource::Future(client_future) => {
675 let client = client_future.get_mut().as_mut().await;
676 *client_source = S3ClientSource::Ready(client.clone());
677 client
678 }
679 S3ClientSource::Ready(client) => client.clone(),
680 };
681 (s3_config, client)
682 }
683 S3UploaderState::Ready { .. } => return,
684 };
685
686 let (uploader, circuit_breaker) = Self::build_uploader(s3_config, bootstrap_client).await;
687 self.state = S3UploaderState::Ready {
688 uploader,
689 circuit_breaker,
690 };
691 }
692}
693
694impl SegmentProcessor for S3PipelineUploader {
695 fn name(&self) -> &'static str {
696 "S3Upload"
697 }
698
699 fn initialize(&mut self) -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send + '_>> {
700 Box::pin(async move {
701 self.ensure_initialized().await;
702 Ok(())
703 })
704 }
705
706 fn process(
707 &mut self,
708 mut data: SegmentData,
709 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>> {
710 Box::pin(async move {
711 self.ensure_initialized().await;
714 let S3UploaderState::Ready {
715 uploader,
716 circuit_breaker,
717 } = &mut self.state
718 else {
719 return Err(ProcessError::io(
722 data,
723 std::io::Error::other("S3 uploader in unexpected state"),
724 ));
725 };
726 if !circuit_breaker.should_attempt() {
727 tracing::debug!(target: "dial9_worker", segment = %data.segment(), "circuit breaker open, skipping upload");
728 return Err(ProcessError::new(
729 data,
730 ProcessErrorKind::transfer(Box::from("circuit breaker open"), true),
731 ));
732 }
733 let payload = data.take_payload();
734 match uploader
735 .upload_and_delete(data.segment(), payload, data.metadata())
736 .await
737 {
738 Ok(key) => {
739 circuit_breaker.on_success();
740 if let Some(dump_ids) = data.metadata().get("dump_id") {
743 for id in dump_ids.split(',').filter(|id| !id.is_empty()) {
744 self.dump_keys
745 .entry(id.to_string())
746 .or_default()
747 .push(key.clone());
748 }
749 }
750 rate_limited!(Duration::from_secs(10), {
751 tracing::info!(target: "dial9_worker", "uploaded {key}");
752 });
753 Ok(data)
754 }
755 Err(kind) => {
756 if kind.already_deleted() {
757 tracing::debug!(target: "dial9_worker", segment = %data.segment(), "segment already evicted, skipping");
758 } else {
759 circuit_breaker.on_failure();
760 rate_limited!(Duration::from_secs(60), {
761 tracing::warn!(target: "dial9_worker", error = %kind, "upload failed");
762 });
763 }
764 Err(ProcessError::new(data, kind))
765 }
766 }
767 })
768 }
769
770 fn finalize_dump(
771 &mut self,
772 completion: &dial9_core::dump::DumpCompletion,
773 ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
774 let segments = self
780 .dump_keys
781 .remove(&completion.dump_id.to_string())
782 .unwrap_or_default();
783 if completion.failed {
784 return Box::pin(std::future::ready(None));
785 }
786 let manifest = DumpManifest::new(completion, segments);
787 Box::pin(async move {
788 self.ensure_initialized().await;
791 let S3UploaderState::Ready {
792 uploader,
793 circuit_breaker,
794 } = &mut self.state
795 else {
796 return None;
797 };
798 if !circuit_breaker.should_attempt() {
799 rate_limited!(Duration::from_secs(60), {
800 tracing::warn!(target: "dial9_worker", dump_id = %manifest.dump_id, "circuit breaker open, skipping dump manifest");
801 });
802 return None;
803 }
804 let body = match serde_json::to_vec(&manifest) {
805 Ok(body) => body,
806 Err(e) => {
807 rate_limited!(Duration::from_secs(60), {
808 tracing::warn!(target: "dial9_worker", error = %e, "failed to serialize dump manifest");
809 });
810 return None;
811 }
812 };
813 let key = uploader.manifest_key(&manifest.dump_id);
814 match uploader.upload_manifest(&key, body).await {
816 Ok(()) => {
817 circuit_breaker.on_success();
818 Some(key)
819 }
820 Err(e) => {
821 circuit_breaker.on_failure();
822 rate_limited!(Duration::from_secs(60), {
823 tracing::warn!(target: "dial9_worker", error = %e, dump_id = %manifest.dump_id, "failed to write dump manifest");
824 });
825 None
826 }
827 }
828 })
829 }
830}
831
832async fn detect_bucket_region(client: &aws_sdk_s3::Client, bucket: &str) -> String {
834 match client.head_bucket().bucket(bucket).send().await {
835 Ok(resp) => {
836 let region = resp.bucket_region().unwrap_or("us-east-1");
837 if resp.bucket_region().is_none() {
838 tracing::warn!(
839 target: "dial9_worker",
840 %bucket,
841 "HeadBucket succeeded but returned no region, falling back to us-east-1"
842 );
843 }
844 region.to_owned()
845 }
846 Err(e) => {
847 let from_header = e
848 .raw_response()
849 .and_then(|r| r.headers().get("x-amz-bucket-region"))
850 .map(|v| v.to_owned());
851 match from_header {
852 Some(r) => r,
853 None => {
854 tracing::warn!(
855 target: "dial9_worker",
856 %bucket,
857 error = ?e,
858 "failed to detect bucket region, falling back to us-east-1"
859 );
860 "us-east-1".to_owned()
861 }
862 }
863 }
864 }
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870 use assert2::check;
871 use dial9_core::pipeline::Payload;
872 use dial9_core::pipeline::SegmentRef;
873 use flate2::read::GzDecoder;
874 use std::io::Read;
875 use std::path::PathBuf;
876
877 fn gzip_compress_bytes(data: &[u8]) -> std::io::Result<Vec<u8>> {
878 use flate2::Compression;
879 use flate2::write::GzEncoder;
880 use std::io::Write;
881 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
882 encoder.write_all(data)?;
883 encoder.finish()
884 }
885
886 fn with_boot_id(mut config: S3Config, boot_id: &str) -> S3Config {
888 config.set_boot_id(boot_id);
889 config
890 }
891
892 fn make_config() -> S3Config {
893 with_boot_id(
894 S3Config::builder()
895 .bucket("test-bucket")
896 .prefix("traces")
897 .service_name("checkout-api")
898 .instance_path("us-east-1/i-0abc123")
899 .build(),
900 "test-boot-id",
901 )
902 }
903
904 fn make_segment(path: impl Into<PathBuf>, index: u32) -> SegmentRef {
905 dial9_core::test_util::disk_segment(path, index)
906 }
907
908 fn make_metadata(epoch_secs: u64) -> HashMap<String, String> {
909 HashMap::from([
910 ("epoch_secs".into(), epoch_secs.to_string()),
911 ("content_encoding".into(), "gzip".into()),
912 ])
913 }
914
915 fn fake_s3_client(fs_root: &std::path::Path) -> aws_sdk_s3::Client {
920 let fs = s3s_fs::FileSystem::new(fs_root).unwrap();
921 let mut builder = s3s::service::S3ServiceBuilder::new(fs);
922 builder.set_auth(s3s::auth::SimpleAuth::from_single("test", "test"));
923 let s3_service = builder.build();
924 let s3_client: s3s_aws::Client = s3_service.into();
925
926 let s3_config = aws_sdk_s3::Config::builder()
927 .behavior_version_latest()
928 .credentials_provider(aws_sdk_s3::config::Credentials::new(
929 "test", "test", None, None, "test",
930 ))
931 .region(aws_sdk_s3::config::Region::new("us-east-1"))
932 .http_client(s3_client)
933 .force_path_style(true)
934 .build();
935
936 aws_sdk_s3::Client::from_conf(s3_config)
937 }
938
939 #[test]
940 fn operation_attempt_timeout_defaults_to_30s_and_is_overridable() {
941 let default_cfg = S3Config::builder().bucket("b").service_name("svc").build();
942 check!(
943 default_cfg.operation_attempt_timeout() == std::time::Duration::from_secs(30),
944 "self-built client must get a bounded per-attempt timeout by default"
945 );
946
947 let custom = S3Config::builder()
948 .bucket("b")
949 .service_name("svc")
950 .operation_attempt_timeout(std::time::Duration::from_secs(5))
951 .build();
952 check!(custom.operation_attempt_timeout() == std::time::Duration::from_secs(5));
953 }
954
955 #[test]
958 fn object_key_includes_all_components() {
959 let config = make_config();
960 let segment = make_segment("/tmp/trace.3.bin", 3);
961 let metadata = make_metadata(1741209000);
962 let key = config.object_key(&segment, &metadata);
963 check!(
964 key == "traces/version=1/date=2025-03-05/service=checkout-api/time=2110/instance=us-east-1%2Fi-0abc123/boot=test-boot-id/1741209000-3.bin.gz"
965 );
966 }
967
968 #[test]
969 fn object_key_empty_prefix() {
970 let config = with_boot_id(
971 S3Config::builder()
972 .bucket("my-traces")
973 .service_name("checkout-api")
974 .instance_path("us-east-1/i-0abc123")
975 .build(),
976 "test-boot-id",
977 );
978 let segment = make_segment("/tmp/trace.0.bin", 0);
979 let metadata = make_metadata(1741209000);
980 let key = config.object_key(&segment, &metadata);
981 check!(
982 key == "version=1/date=2025-03-05/service=checkout-api/time=2110/instance=us-east-1%2Fi-0abc123/boot=test-boot-id/1741209000-0.bin.gz"
983 );
984 }
985
986 #[test]
987 fn object_key_without_compression() {
988 let config = make_config();
989 let segment = make_segment("/tmp/trace.0.bin", 0);
990 let metadata = HashMap::from([("epoch_secs".into(), "1741209000".into())]);
991 let key = config.object_key(&segment, &metadata);
992 check!(
993 key == "traces/version=1/date=2025-03-05/service=checkout-api/time=2110/instance=us-east-1%2Fi-0abc123/boot=test-boot-id/1741209000-0.bin"
994 );
995 }
996
997 #[test]
998 fn object_key_hive_escapes_partition_values() {
999 let config = with_boot_id(
1000 S3Config::builder()
1001 .bucket("my-traces")
1002 .prefix("company/date=archive/%25")
1003 .service_name("payments/api")
1004 .instance_path("cluster/worker=blue%1")
1005 .build(),
1006 "boot/id",
1007 );
1008 let key = config.object_key(
1009 &make_segment("/tmp/trace.0.bin", 0),
1010 &make_metadata(1741209000),
1011 );
1012 check!(
1013 key == "company/date=archive/%25/version=1/date=2025-03-05/service=payments%2Fapi/time=2110/instance=cluster%2Fworker%3Dblue%251/boot=boot%2Fid/1741209000-0.bin.gz"
1014 );
1015 }
1016
1017 #[test]
1018 fn default_boot_id_is_alpha_timestamp_and_pid() {
1019 let id = default_boot_id();
1020 let (ts, pid) = id.split_once("-").unwrap();
1021 assert_eq!(ts.len(), 4);
1022 pid.parse::<u64>().unwrap();
1023 }
1024
1025 #[test]
1026 fn custom_key_fn_overrides_default() {
1027 let config = S3Config::builder()
1028 .bucket("test-bucket")
1029 .service_name("svc")
1030 .instance_path("host")
1031 .key_fn(|segment: &KeyContext| {
1032 format!("custom/{}-{}.bin.gz", segment.epoch_secs, segment.index)
1033 })
1034 .build();
1035 let segment = make_segment("/tmp/trace.5.bin", 5);
1036 let metadata = make_metadata(1741209000);
1037 let key = config.object_key(&segment, &metadata);
1038 check!(key == "custom/1741209000-5.bin.gz");
1039 }
1040
1041 #[test]
1044 fn gzip_compress_roundtrips() {
1045 let original = b"hello world, this is trace data that should compress well!";
1046 let dir = tempfile::tempdir().unwrap();
1047 let path = dir.path().join("test.bin");
1048 std::fs::write(&path, original).unwrap();
1049
1050 let compressed = gzip_compress_file_sync(&path).unwrap();
1051 check!(compressed[..] != original[..]);
1052
1053 let mut decoder = GzDecoder::new(&compressed[..]);
1054 let mut decompressed = Vec::new();
1055 decoder.read_to_end(&mut decompressed).unwrap();
1056 check!(decompressed == original);
1057 }
1058
1059 #[test]
1060 fn gzip_compress_bytes_roundtrips() {
1061 let original = b"hello world, this is trace data that should compress well!";
1062 let compressed = gzip_compress_bytes(original).unwrap();
1063 check!(compressed[..] != original[..]);
1064
1065 let mut decoder = GzDecoder::new(&compressed[..]);
1066 let mut decompressed = Vec::new();
1067 decoder.read_to_end(&mut decompressed).unwrap();
1068 check!(decompressed == original);
1069 }
1070
1071 #[test]
1072 fn gzip_compress_empty_input() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let path = dir.path().join("empty.bin");
1075 std::fs::write(&path, b"").unwrap();
1076
1077 let compressed = gzip_compress_file_sync(&path).unwrap();
1078 let mut decoder = GzDecoder::new(&compressed[..]);
1079 let mut decompressed = Vec::new();
1080 decoder.read_to_end(&mut decompressed).unwrap();
1081 check!(decompressed.is_empty());
1082 }
1083
1084 #[test]
1087 fn builder_prefix_defaults_to_empty() {
1088 let config = S3Config::builder()
1089 .bucket("bucket")
1090 .service_name("svc")
1091 .instance_path("path")
1092 .build();
1093 let segment = make_segment("/tmp/trace.0.bin", 0);
1094 let metadata = make_metadata(1741209000);
1095 let key = config.object_key(&segment, &metadata);
1096 check!(key.starts_with("version=1/date=2025-03-05/"));
1098 }
1099
1100 #[tokio::test]
1103 async fn upload_and_delete_writes_to_s3_and_removes_local_file() {
1104 let s3_root = tempfile::tempdir().unwrap();
1105 let local_dir = tempfile::tempdir().unwrap();
1106
1107 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1109
1110 let client = fake_s3_client(s3_root.path());
1111 let raw_client = fake_s3_client(s3_root.path());
1112 let config = make_config();
1113 let uploader = S3Uploader::new(client, config);
1114
1115 let segment_path = local_dir.path().join("trace.0.bin");
1117 let original_data = b"trace data here";
1118 std::fs::write(&segment_path, original_data).unwrap();
1119 let segment = make_segment(&segment_path, 0);
1120
1121 let compressed = gzip_compress_file_sync(&segment_path).unwrap();
1123 let metadata = make_metadata(1741209000);
1124 let key = uploader
1125 .upload_and_delete(&segment, Payload::from_vec(compressed), &metadata)
1126 .await
1127 .unwrap();
1128
1129 check!(
1130 key == "traces/version=1/date=2025-03-05/service=checkout-api/time=2110/instance=us-east-1%2Fi-0abc123/boot=test-boot-id/1741209000-0.bin.gz"
1131 );
1132
1133 check!(!segment_path.exists());
1135
1136 let resp = raw_client
1138 .get_object()
1139 .bucket("test-bucket")
1140 .key(&key)
1141 .send()
1142 .await
1143 .unwrap();
1144 let body = resp.body.collect().await.unwrap().into_bytes();
1145 let mut decoder = GzDecoder::new(&body[..]);
1146 let mut decompressed = Vec::new();
1147 decoder.read_to_end(&mut decompressed).unwrap();
1148 check!(decompressed == original_data);
1149 }
1150
1151 #[tokio::test]
1152 async fn uploaded_object_contains_gzipped_original_data() {
1153 let s3_root = tempfile::tempdir().unwrap();
1154 let local_dir = tempfile::tempdir().unwrap();
1155 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1156
1157 let client = fake_s3_client(s3_root.path());
1158 let raw_s3_client = fake_s3_client(s3_root.path());
1159
1160 let config = make_config();
1161 let uploader = S3Uploader::new(client, config);
1162
1163 let original_data = b"important trace data that must survive the roundtrip";
1164 let segment_path = local_dir.path().join("trace.5.bin");
1165 std::fs::write(&segment_path, original_data).unwrap();
1166 let segment = make_segment(&segment_path, 5);
1167
1168 let compressed = gzip_compress_file_sync(&segment_path).unwrap();
1169 let metadata = make_metadata(1741209000);
1170 let _key = uploader
1171 .upload_and_delete(&segment, Payload::from_vec(compressed), &metadata)
1172 .await
1173 .unwrap();
1174
1175 let get_result = raw_s3_client
1177 .get_object()
1178 .bucket("test-bucket")
1179 .key(&_key)
1180 .send()
1181 .await
1182 .unwrap();
1183
1184 let body = get_result.body.collect().await.unwrap().into_bytes();
1185
1186 let mut decoder = GzDecoder::new(&body[..]);
1188 let mut decompressed = Vec::new();
1189 decoder.read_to_end(&mut decompressed).unwrap();
1190 check!(decompressed == original_data);
1191 }
1192
1193 #[tokio::test]
1194 async fn upload_sets_s3_object_metadata_headers() {
1195 let s3_root = tempfile::tempdir().unwrap();
1196 let local_dir = tempfile::tempdir().unwrap();
1197 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1198
1199 let client = fake_s3_client(s3_root.path());
1200 let raw_s3_client = fake_s3_client(s3_root.path());
1201
1202 let config = with_boot_id(
1203 S3Config::builder()
1204 .bucket("test-bucket")
1205 .prefix("traces")
1206 .service_name("checkout-api")
1207 .instance_path("us-east-1/i-0abc123")
1208 .build(),
1209 "a3f7c2d1-dead-beef-1234-567890abcdef",
1210 );
1211 let uploader = S3Uploader::new(client, config);
1212
1213 let segment_path = local_dir.path().join("trace.3.bin");
1214 std::fs::write(&segment_path, b"trace data").unwrap();
1215 let segment = make_segment(&segment_path, 3);
1216
1217 let compressed = gzip_compress_file_sync(&segment_path).unwrap();
1218 let metadata = make_metadata(1741209000);
1219 let key = uploader
1220 .upload_and_delete(&segment, Payload::from_vec(compressed), &metadata)
1221 .await
1222 .unwrap();
1223
1224 let head = raw_s3_client
1226 .head_object()
1227 .bucket("test-bucket")
1228 .key(&key)
1229 .send()
1230 .await
1231 .unwrap();
1232
1233 let meta = head.metadata().unwrap();
1234 check!(meta.get("service").unwrap() == "checkout-api");
1235 check!(meta.get("boot-id").unwrap() == "a3f7c2d1-dead-beef-1234-567890abcdef");
1236 check!(meta.get("segment-index").unwrap() == "3");
1237 check!(meta.get("start-time").unwrap() == "1741209000");
1238 check!(meta.get("host").unwrap() == "us-east-1/i-0abc123");
1239 check!(!meta.contains_key("dump-id"));
1241 }
1242
1243 #[tokio::test]
1244 async fn upload_attaches_dump_id_and_stripped_dump_pairs() {
1245 let s3_root = tempfile::tempdir().unwrap();
1246 let local_dir = tempfile::tempdir().unwrap();
1247 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1248
1249 let client = fake_s3_client(s3_root.path());
1250 let raw_s3_client = fake_s3_client(s3_root.path());
1251 let uploader = S3Uploader::new(client, make_config());
1252
1253 let segment_path = local_dir.path().join("trace.4.bin");
1254 std::fs::write(&segment_path, b"trace data").unwrap();
1255 let segment = make_segment(&segment_path, 4);
1256
1257 let mut metadata = make_metadata(1741209000);
1258 metadata.insert("dump_id".into(), "01ABC,01DEF".into());
1259 metadata.insert("dump.reason".into(), "idle-ratio-drop".into());
1260 metadata.insert("dump.Incident ID!".into(), "i-99".into()); metadata.insert("dump.host".into(), "spoofed".into()); metadata.insert("dump.note".into(), "caf\u{e9}".into()); let compressed = gzip_compress_file_sync(&segment_path).unwrap();
1265 let key = uploader
1266 .upload_and_delete(&segment, Payload::from_vec(compressed), &metadata)
1267 .await
1268 .unwrap();
1269
1270 let head = raw_s3_client
1271 .head_object()
1272 .bucket("test-bucket")
1273 .key(&key)
1274 .send()
1275 .await
1276 .unwrap();
1277 let meta = head.metadata().unwrap();
1278 check!(meta.get("dump-id").unwrap() == "01ABC,01DEF");
1279 check!(meta.get("reason").unwrap() == "idle-ratio-drop");
1280 check!(!meta.contains_key("incident id!"));
1281 check!(!meta.contains_key("note"), "non-ASCII value skipped");
1282 check!(meta.get("host").unwrap() == "us-east-1/i-0abc123");
1284 }
1285
1286 #[test]
1287 fn manifest_key_layout() {
1288 let with_prefix = make_config();
1289 check!(with_prefix.manifest_key("01ABC") == "traces/dumps/01ABC.json");
1290
1291 let no_prefix = S3Config::builder()
1292 .bucket("b")
1293 .service_name("s")
1294 .instance_path("i")
1295 .build();
1296 check!(no_prefix.manifest_key("01ABC") == "dumps/01ABC.json");
1297 }
1298
1299 #[test]
1300 fn dump_manifest_serializes_doc_shape() {
1301 use std::time::{Duration, UNIX_EPOCH};
1302
1303 let dump_id = dial9_core::test_util::new_dump_id();
1304 let completion = dial9_core::test_util::new_dump_completion(
1305 dump_id,
1306 UNIX_EPOCH + Duration::from_secs(1741209000),
1307 (
1308 UNIX_EPOCH + Duration::from_secs(1741208700),
1309 UNIX_EPOCH + Duration::from_secs(1741209300),
1310 ),
1311 2,
1312 vec![("reason".into(), "idle-ratio-drop".into())],
1313 false,
1314 );
1315 let manifest = DumpManifest::new(
1316 &completion,
1317 vec!["traces/a.bin.gz".into(), "traces/b.bin.gz".into()],
1318 );
1319 let value = serde_json::to_value(&manifest).unwrap();
1320
1321 check!(value["dump_id"] == serde_json::json!(dump_id.to_string()));
1322 check!(value["triggered_at"] == serde_json::json!("2025-03-05T21:10:00Z"));
1323 check!(
1324 value["time_range"]
1325 == serde_json::json!(["2025-03-05T21:05:00Z", "2025-03-05T21:15:00Z"])
1326 );
1327 check!(value["segments_processed"] == serde_json::json!(2));
1328 check!(value["metadata"] == serde_json::json!({"reason": "idle-ratio-drop"}));
1329 check!(value["segments"] == serde_json::json!(["traces/a.bin.gz", "traces/b.bin.gz"]));
1330 }
1331
1332 #[tokio::test]
1333 async fn upload_failure_does_not_delete_local_file() {
1334 let s3_root = tempfile::tempdir().unwrap();
1335 let local_dir = tempfile::tempdir().unwrap();
1336 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1337
1338 let client = fake_s3_client(s3_root.path());
1339 let config = make_config();
1340 let uploader = S3Uploader::new(client, config);
1341
1342 let segment_path = local_dir.path().join("trace.0.bin");
1343 std::fs::write(&segment_path, b"should survive").unwrap();
1344
1345 let segment = make_segment(&segment_path, 0);
1346 let compressed = gzip_compress_bytes(b"should survive").unwrap();
1347 let metadata = make_metadata(1741209000);
1348
1349 drop(s3_root);
1351
1352 let result = uploader
1353 .upload_and_delete(&segment, Payload::from_vec(compressed), &metadata)
1354 .await;
1355
1356 check!(result.is_err());
1357 check!(segment_path.exists());
1359 }
1360
1361 #[test]
1364 fn object_key_epoch_secs_fallback_to_zero_produces_1970_path() {
1365 let config = make_config();
1366 let segment = make_segment("/tmp/trace.0.bin", 0);
1367 let metadata = HashMap::new();
1369 let key = config.object_key(&segment, &metadata);
1370 check!(key.contains("date=1970-01-01/service=checkout-api/time=0000"));
1372 }
1373
1374 #[test]
1375 fn object_key_epoch_secs_unparseable_falls_back_to_zero() {
1376 let config = make_config();
1377 let segment = make_segment("/tmp/trace.0.bin", 0);
1378 let metadata = HashMap::from([("epoch_secs".into(), "not-a-number".into())]);
1379 let key = config.object_key(&segment, &metadata);
1380 check!(key.contains("date=1970-01-01/service=checkout-api/time=0000"));
1381 }
1382}
1383
1384#[cfg(test)]
1388mod worker_integration_tests {
1389 use super::{S3PipelineUploader, S3Uploader};
1390 use crate::connection::CircuitBreaker;
1391 use crate::s3;
1392 use assert2::check;
1393 use dial9_core::pipeline::SegmentProcessor;
1394 use dial9_core::worker::processors::GzipCompressor;
1395 use std::path::Path;
1396 use std::sync::Arc;
1397 use std::sync::atomic::Ordering;
1398 use std::time::Duration;
1399
1400 #[tokio::test]
1405 async fn evicted_file_does_not_trip_circuit_breaker() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let missing = dir.path().join("trace.0.bin");
1409
1410 let mut cb = CircuitBreaker::new();
1411 if cb.should_attempt() {
1414 match tokio::fs::read(&missing).await {
1415 Ok(_) => cb.on_success(),
1416 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1417 Err(_) => cb.on_failure(),
1418 }
1419 }
1420
1421 check!(cb == CircuitBreaker::Closed);
1422 }
1423
1424 #[test]
1428 #[should_panic(expected = "set_client called after uploader initialization")]
1429 fn set_client_after_ready_panics() {
1430 let s3_config = s3::S3Config::builder()
1431 .bucket("test")
1432 .service_name("test")
1433 .instance_path("test")
1434 .region("us-east-1")
1435 .build();
1436 let sdk_config = aws_sdk_s3::Config::builder()
1437 .behavior_version_latest()
1438 .credentials_provider(aws_sdk_s3::config::Credentials::new(
1439 "test", "test", None, None, "test",
1440 ))
1441 .region(aws_sdk_s3::config::Region::new("us-east-1"))
1442 .build();
1443 let sdk_client = aws_sdk_s3::Client::from_conf(sdk_config);
1444 let uploader = S3Uploader::new(sdk_client.clone(), s3_config);
1445 let mut pipeline_uploader = S3PipelineUploader::from_ready(uploader, CircuitBreaker::new());
1446 pipeline_uploader.set_client(sdk_client);
1447 }
1448
1449 #[test]
1450 fn take_client_preserves_pending_fallback() {
1451 let s3_config = s3::S3Config::builder()
1452 .bucket("test")
1453 .service_name("test")
1454 .instance_path("test")
1455 .region("us-east-1")
1456 .build();
1457 let sdk_config = aws_sdk_s3::Config::builder()
1458 .behavior_version_latest()
1459 .credentials_provider(aws_sdk_s3::config::Credentials::new(
1460 "test", "test", None, None, "test",
1461 ))
1462 .region(aws_sdk_s3::config::Region::new("us-east-1"))
1463 .build();
1464 let sdk_client = aws_sdk_s3::Client::from_conf(sdk_config);
1465 let mut pipeline_uploader = S3PipelineUploader::new(s3_config, Some(sdk_client));
1466
1467 check!(pipeline_uploader.take_client().is_some());
1468 check!(pipeline_uploader.take_client().is_none());
1469 }
1470
1471 #[test]
1472 fn pipeline_uploader_remains_send_sync() {
1473 fn assert_send_sync<T: Send + Sync>() {}
1474 assert_send_sync::<S3PipelineUploader>();
1475 }
1476
1477 #[tokio::test]
1480 async fn s3_finalize_skips_manifest_for_failed_dump() {
1481 let config = s3::S3Config::builder()
1482 .bucket("b")
1483 .service_name("s")
1484 .instance_path("i")
1485 .build();
1486 let mut uploader = S3PipelineUploader::new(config, None);
1487
1488 let dump_id = dial9_core::test_util::new_dump_id();
1489 uploader
1490 .dump_keys
1491 .insert(dump_id.to_string(), vec!["traces/x.bin.gz".into()]);
1492
1493 let now = std::time::SystemTime::now();
1494 let completion = dial9_core::test_util::new_dump_completion(
1495 dump_id,
1496 now,
1497 (now, now),
1498 0,
1499 Vec::new(),
1500 true,
1501 );
1502 let key = uploader.finalize_dump(&completion).await;
1503 check!(key.is_none());
1504 check!(
1505 uploader.dump_keys.is_empty(),
1506 "per-dump state still cleared"
1507 );
1508 }
1509
1510 fn fake_s3_client(fs_root: &Path) -> aws_sdk_s3::Client {
1513 let fs = s3s_fs::FileSystem::new(fs_root).unwrap();
1514 let mut builder = s3s::service::S3ServiceBuilder::new(fs);
1515 builder.set_auth(s3s::auth::SimpleAuth::from_single("test", "test"));
1516 let s3_service = builder.build();
1517 let s3_client: s3s_aws::Client = s3_service.into();
1518 let s3_config = aws_sdk_s3::Config::builder()
1519 .behavior_version_latest()
1520 .credentials_provider(aws_sdk_s3::config::Credentials::new(
1521 "test", "test", None, None, "test",
1522 ))
1523 .region(aws_sdk_s3::config::Region::new("us-east-1"))
1524 .http_client(s3_client)
1525 .force_path_style(true)
1526 .build();
1527 aws_sdk_s3::Client::from_conf(s3_config)
1528 }
1529
1530 fn s3_uploader_for(root: &Path) -> S3PipelineUploader {
1531 let config = s3::S3Config::builder()
1532 .bucket("test-bucket")
1533 .prefix("traces")
1534 .service_name("test")
1535 .instance_path("test")
1536 .region("us-east-1")
1537 .build();
1538 let uploader = S3Uploader::new(fake_s3_client(root), config);
1539 S3PipelineUploader::from_ready(uploader, CircuitBreaker::new())
1540 }
1541
1542 fn read_manifest(root: &Path, key: &str) -> serde_json::Value {
1543 let path = root.join("test-bucket").join(key);
1544 let bytes = std::fs::read(&path)
1545 .unwrap_or_else(|e| panic!("manifest at {} unreadable: {e}", path.display()));
1546 serde_json::from_slice(&bytes).unwrap()
1547 }
1548
1549 fn now_epoch() -> u64 {
1550 std::time::SystemTime::now()
1551 .duration_since(std::time::UNIX_EPOCH)
1552 .unwrap()
1553 .as_secs()
1554 }
1555
1556 #[tokio::test]
1557 async fn dump_writes_manifest_listing_uploaded_keys() {
1558 let s3_root = tempfile::tempdir().unwrap();
1559 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1560
1561 let pipeline = dial9_core::test_util::spawn_triggered_pipeline(vec![
1562 Box::new(GzipCompressor),
1563 Box::new(s3_uploader_for(s3_root.path())),
1564 ]);
1565 pipeline.seal(0, now_epoch());
1566 pipeline.seal(1, now_epoch());
1567
1568 let receipt = pipeline
1569 .trigger
1570 .dump_current_data()
1571 .with_metadata("reason", "test")
1572 .await
1573 .unwrap();
1574
1575 let manifest_key = receipt
1576 .manifest_key
1577 .clone()
1578 .expect("S3 pipeline writes a manifest");
1579 check!(
1580 manifest_key == format!("traces/dumps/{}.json", receipt.dump_id),
1581 "manifest key layout"
1582 );
1583
1584 let manifest = read_manifest(s3_root.path(), &manifest_key);
1585 check!(manifest["dump_id"] == serde_json::json!(receipt.dump_id.to_string()));
1586 check!(manifest["segments_processed"] == serde_json::json!(2));
1587 check!(manifest["metadata"]["reason"] == serde_json::json!("test"));
1588 let segments = manifest["segments"].as_array().unwrap();
1589 check!(segments.len() == 2);
1590 for key in segments {
1591 let key = key.as_str().unwrap();
1592 check!(
1593 s3_root.path().join("test-bucket").join(key).exists(),
1594 "manifest lists a real object: {key}"
1595 );
1596 }
1597
1598 pipeline.shutdown().await;
1599 }
1600
1601 #[tokio::test]
1602 async fn overlapping_dumps_fan_out_shared_key_to_both_manifests() {
1603 let s3_root = tempfile::tempdir().unwrap();
1604 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1605
1606 let pipeline = dial9_core::test_util::spawn_triggered_pipeline(vec![
1607 Box::new(GzipCompressor),
1608 Box::new(s3_uploader_for(s3_root.path())),
1609 ]);
1610
1611 let fut_a = std::future::IntoFuture::into_future(
1612 pipeline
1613 .trigger
1614 .dump_time_range(Duration::from_secs(60), Duration::from_secs(1)),
1615 );
1616 let fut_b = std::future::IntoFuture::into_future(
1617 pipeline
1618 .trigger
1619 .dump_time_range(Duration::from_secs(60), Duration::from_secs(1)),
1620 );
1621 tokio::time::sleep(Duration::from_millis(100)).await;
1622 pipeline.seal(0, now_epoch());
1623
1624 let (receipt_a, receipt_b) = tokio::join!(fut_a, fut_b);
1625 let receipt_a = receipt_a.unwrap();
1626 let receipt_b = receipt_b.unwrap();
1627
1628 let manifest_a = read_manifest(s3_root.path(), receipt_a.manifest_key.as_ref().unwrap());
1629 let manifest_b = read_manifest(s3_root.path(), receipt_b.manifest_key.as_ref().unwrap());
1630 let segs_a = manifest_a["segments"].as_array().unwrap();
1631 let segs_b = manifest_b["segments"].as_array().unwrap();
1632 check!(segs_a.len() == 1);
1633 check!(segs_a == segs_b, "the shared key appears in both manifests");
1634
1635 pipeline.shutdown().await;
1636 }
1637
1638 #[tokio::test]
1639 async fn empty_dump_still_writes_manifest_as_completion_signal() {
1640 let s3_root = tempfile::tempdir().unwrap();
1641 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1642
1643 let pipeline = dial9_core::test_util::spawn_triggered_pipeline(vec![
1644 Box::new(GzipCompressor),
1645 Box::new(s3_uploader_for(s3_root.path())),
1646 ]);
1647
1648 let receipt = pipeline.trigger.dump_current_data().await.unwrap();
1649 check!(receipt.segments_processed == 0);
1650 let manifest = read_manifest(s3_root.path(), receipt.manifest_key.as_ref().unwrap());
1651 check!(manifest["segments"] == serde_json::json!([]));
1652
1653 pipeline.shutdown().await;
1654 }
1655
1656 struct FlakyS3<S> {
1661 inner: S,
1662 remaining_failures: Arc<std::sync::atomic::AtomicU32>,
1663 }
1664
1665 impl<S> FlakyS3<S> {
1666 fn should_fail(&self) -> bool {
1667 let prev = self.remaining_failures.load(Ordering::SeqCst);
1668 if prev == 0 {
1669 return false;
1670 }
1671 self.remaining_failures
1672 .compare_exchange(prev, prev - 1, Ordering::SeqCst, Ordering::SeqCst)
1673 .is_ok()
1674 }
1675 }
1676
1677 #[async_trait::async_trait]
1678 impl<S: s3s::S3 + Send + Sync> s3s::S3 for FlakyS3<S> {
1679 async fn put_object(
1680 &self,
1681 req: s3s::S3Request<s3s::dto::PutObjectInput>,
1682 ) -> s3s::S3Result<s3s::S3Response<s3s::dto::PutObjectOutput>> {
1683 if self.should_fail() {
1684 return Err(s3s::S3Error::with_message(
1685 s3s::S3ErrorCode::InternalError,
1686 "injected 500",
1687 ));
1688 }
1689 self.inner.put_object(req).await
1690 }
1691 }
1692
1693 struct FlakyHarness {
1694 uploader: S3Uploader,
1695 fail_counter: Arc<std::sync::atomic::AtomicU32>,
1696 s3_root: tempfile::TempDir,
1697 }
1698
1699 fn read_only_object(s3_root: &Path) -> Vec<u8> {
1702 fn walk(p: &Path, out: &mut Vec<std::path::PathBuf>) {
1703 let Ok(rd) = std::fs::read_dir(p) else { return };
1704 for entry in rd.flatten() {
1705 let path = entry.path();
1706 if path.is_dir() {
1707 walk(&path, out);
1708 } else if path.is_file()
1709 && path
1710 .file_name()
1711 .and_then(|n| n.to_str())
1712 .is_some_and(|n| !n.ends_with(".s3s-fs"))
1713 {
1714 out.push(path);
1715 }
1716 }
1717 }
1718 let mut found = Vec::new();
1719 walk(&s3_root.join("test-bucket"), &mut found);
1720 assert_eq!(found.len(), 1, "expected exactly one object, got {found:?}");
1721 std::fs::read(&found[0]).unwrap()
1722 }
1723
1724 fn flaky_s3_harness(fail_n: u32) -> FlakyHarness {
1725 let s3_root = tempfile::tempdir().unwrap();
1726 std::fs::create_dir(s3_root.path().join("test-bucket")).unwrap();
1727 let fail_counter = Arc::new(std::sync::atomic::AtomicU32::new(fail_n));
1728
1729 let fs = s3s_fs::FileSystem::new(s3_root.path()).unwrap();
1730 let flaky = FlakyS3 {
1731 inner: fs,
1732 remaining_failures: Arc::clone(&fail_counter),
1733 };
1734 let mut svc = s3s::service::S3ServiceBuilder::new(flaky);
1735 svc.set_auth(s3s::auth::SimpleAuth::from_single("test", "test"));
1736 let s3_client: s3s_aws::Client = svc.build().into();
1737
1738 let sdk_config = aws_sdk_s3::Config::builder()
1739 .behavior_version_latest()
1740 .credentials_provider(aws_sdk_s3::config::Credentials::new(
1741 "test", "test", None, None, "test",
1742 ))
1743 .region(aws_sdk_s3::config::Region::new("us-east-1"))
1744 .retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
1746 .http_client(s3_client)
1747 .force_path_style(true)
1748 .build();
1749 let sdk_client = aws_sdk_s3::Client::from_conf(sdk_config);
1750 let s3_config = s3::S3Config::builder()
1751 .bucket("test-bucket")
1752 .service_name("test")
1753 .instance_path("test")
1754 .region("us-east-1")
1755 .build();
1756 FlakyHarness {
1757 uploader: S3Uploader::new(sdk_client, s3_config),
1758 fail_counter,
1759 s3_root,
1760 }
1761 }
1762
1763 #[tokio::test(flavor = "current_thread")]
1764 async fn mem_e2e_real_s3_pipeline_recovers_within_budget() {
1765 let FlakyHarness {
1766 uploader,
1767 fail_counter,
1768 s3_root,
1769 } = flaky_s3_harness(2);
1770 let poll_interval = Duration::from_millis(1100);
1773
1774 let payload = b"segment-payload-bytes".to_vec();
1775 let uploader_stage = S3PipelineUploader::from_ready(uploader, CircuitBreaker::new());
1776
1777 tokio::time::timeout(
1778 Duration::from_secs(15),
1779 dial9_core::test_util::run_pipeline_continuous(
1780 vec![payload.clone()],
1781 vec![Box::new(uploader_stage)],
1782 poll_interval,
1783 ),
1784 )
1785 .await
1786 .expect("worker hung")
1787 .expect("pipeline run failed");
1788
1789 check!(
1790 fail_counter.load(Ordering::SeqCst) == 0,
1791 "all injected failures consumed",
1792 );
1793 let uploaded = read_only_object(s3_root.path());
1794 check!(
1795 uploaded == payload,
1796 "uploaded body must match seal'd bytes (snapshot survived retries)",
1797 );
1798 }
1799}