Skip to main content

dial9_destinations_s3/
s3.rs

1//! S3 uploader for sealed trace segments.
2//!
3//! Uploads processed segment bytes to S3 with a single `PutObject` per segment.
4//! Deletes local files only after confirmed upload.
5
6use 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
23/// Classify a `PutObject` `SdkError`'s retryability and wrap it as a
24/// [`ProcessErrorKind::transfer`]. A free fn rather than `impl From` because the
25/// orphan rule forbids implementing a foreign trait for a foreign type here.
26///
27/// Transport-level failures (timeouts, dispatch/IO, unparseable responses) are
28/// transient and worth retrying. For a service error we keep the segment when
29/// the response is a 5xx or a throttle (429), and give up on a 4xx (auth,
30/// permission, malformed request) — retrying those would only spin.
31fn 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        // ConstructionFailure and any future non-exhaustive variant: not worth
41        // retrying (the request never made it onto the wire coherently).
42        _ => false,
43    };
44    ProcessErrorKind::transfer(Box::new(e), retryable)
45}
46
47/// What [`S3KeyFn`] gets to build an object key from.
48#[derive(Debug, Clone, Default)]
49#[non_exhaustive]
50pub struct KeyContext {
51    /// The segment index (e.g. 3 for `trace.3.bin`).
52    pub index: u32,
53    /// Segment creation time as seconds since the Unix epoch.
54    pub epoch_secs: u64,
55    /// Identifier for this process lifetime, from the uploader's
56    /// [`S3Config`]. A new value each application start, so segment indices
57    /// from different runs do not collide.
58    pub boot_id: String,
59}
60
61/// Trait for custom S3 object key generation.
62///
63/// Implement this to control the S3 key layout. The default key layout is
64/// `{prefix}/version=1/date={date}/service={service}/time={HHMM}/instance={instance}/boot={boot_id}/{epoch}-{index}.bin.gz`.
65pub trait S3KeyFn: Send + Sync {
66    /// Generate the S3 object key for the given segment.
67    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/// Configuration for S3 uploads.
80///
81/// Only `bucket` and `service_name` are required. The remaining builder fields
82/// have sensible defaults:
83///
84/// - `instance_path`: system hostname
85/// - `prefix`: none (keys start at `version=1`)
86/// - `region`: auto-detected via `HeadBucket`
87/// - `key_fn`: built-in versioned layout
88///
89/// # Default key layout
90///
91/// ```text
92/// {prefix}/version=1/date={YYYY-MM-DD}/service={service_name}/time={HHMM}/instance={instance_path}/boot={boot_id}/{epoch_secs}-{index}.bin.gz
93/// ```
94///
95/// The consecutive `version/date/service/time` prefix is part of the default
96/// layout: the viewer uses it for efficient service and time
97/// discovery with S3 prefix listing.
98///
99/// Partition values use Hive path escaping, so `/` inside a service, instance,
100/// or boot id is stored as `%2F` instead of creating another path component.
101///
102/// The `boot_id` segment disambiguates segment indices across process
103/// restarts — without it, a service that restarts will produce colliding
104/// `{epoch_secs}-{index}` names.
105///
106/// Override with [`key_fn`](S3ConfigBuilder::key_fn) for a custom layout.
107#[derive(Clone, bon::Builder)]
108#[builder(on(String, into))]
109pub struct S3Config {
110    bucket: String,
111    service_name: String,
112    /// Instance identifier for S3 key paths. Defaults to the system hostname.
113    #[builder(into, default = InstanceIdentity::from_hostname())]
114    instance_path: InstanceIdentity,
115    /// Identifies this process lifetime. Included as both S3 object metadata
116    /// and in the default key path so segments (and segment indices) from
117    /// different runs of the same service on the same host don't collide.
118    ///
119    /// Not a builder field: when telemetry is configured through the managed
120    /// `recorder_from_env` path the runtime injects the same
121    /// boot_id it uses for the on-disk `{boot_id}/` namespace directory (via
122    /// [`set_boot_id`](Self::set_boot_id)), so a local trace segment and its S3
123    /// key share one identity. Defaults to a fresh `{4-alpha}-{pid}` when no
124    /// namespace is in play.
125    #[builder(skip = default_boot_id())]
126    boot_id: String,
127    /// Optional key prefix. When `None`, keys start at `version=1`.
128    prefix: Option<String>,
129    /// Optional AWS region override. When `None`, uses the SDK default.
130    region: Option<String>,
131    /// Custom S3 key function. When set, overrides the default key layout.
132    #[builder(with = |key_fn: impl S3KeyFn + 'static| Arc::new(key_fn) as Arc<dyn S3KeyFn>)]
133    key_fn: Option<Arc<dyn S3KeyFn>>,
134    /// Per-attempt wall-clock timeout for individual S3 operations
135    /// (`PutObject`, `HeadBucket`). Bounds how long a single HTTP attempt may
136    /// stall before the SDK aborts it; the SDK retry policy and the pipeline
137    /// circuit breaker then decide whether to re-drive. Without it a hung
138    /// request could block the upload worker indefinitely. Defaults to 30s.
139    ///
140    /// Only applied to the client dial9 builds itself (the `.s3(..)` path).
141    /// Pre-built and future-supplied clients keep their own timeout
142    /// configuration untouched.
143    #[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    /// The S3 bucket name.
161    pub(crate) fn bucket(&self) -> &str {
162        &self.bucket
163    }
164
165    /// Override the boot_id so S3 keys match the on-disk namespace directory.
166    /// Called cross-crate by the runtime builder when the writer is namespaced.
167    pub fn set_boot_id(&mut self, boot_id: impl Into<String>) {
168        self.boot_id = boot_id.into();
169    }
170
171    /// The configured fields as `(key, value)` pairs, for attaching as S3
172    /// object metadata or for inspection.
173    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    /// Optional region override for the S3 client.
186    pub(crate) fn region(&self) -> Option<&str> {
187        self.region.as_deref()
188    }
189
190    /// Per-attempt timeout applied to the client dial9 builds itself.
191    pub(crate) fn operation_attempt_timeout(&self) -> Duration {
192        self.operation_attempt_timeout
193    }
194
195    /// Build the S3 object key for a sealed segment.
196    ///
197    /// If a custom `key_fn` is set, delegates to it. Otherwise uses the
198    /// default versioned layout:
199    /// `{prefix}/version=1/date={date}/service={service}/time={HHMM}/instance={instance}/boot={boot_id}/{epoch_secs}-{index}.bin.gz`
200    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    /// Key of the per-dump manifest object: `{prefix}/dumps/{dump_id}.json`.
243    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/// JSON document written at `{prefix}/dumps/{dump_id}.json` when a dump
252/// completes: the index answering "which trace objects belong to this
253/// dump?" in a single GET. Its presence doubles as the cross-process
254/// completion signal.
255#[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
294/// S3 user-metadata keys ride HTTP headers; only pass caller keys that are
295/// trivially valid and do not collide with the fixed per-object fields.
296fn 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
313/// Values ride HTTP headers too; a non-ASCII or oversized value would fail
314/// the whole trace-object PUT, so a bad caller pair is skipped instead.
315fn valid_user_metadata_value(value: &str) -> bool {
316    value.len() <= 256 && value.bytes().all(|b| (0x20..=0x7e).contains(&b))
317}
318
319/// Convert epoch seconds to `(YYYY-MM-DD, HHMM)` for S3 key bucketing.
320fn 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/// Gzip-compress a file synchronously. Intended for use with `spawn_blocking`.
330#[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
348/// Uploads sealed trace segments to S3.
349pub(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    /// Create a new uploader with the given S3 client and config.
362    pub(crate) fn new(client: Client, config: S3Config) -> Self {
363        Self { client, config }
364    }
365
366    /// Upload segment bytes to S3, then delete the local file on success.
367    ///
368    /// Returns the S3 key of the uploaded object.
369    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        // Triggered dumps: tag the object with every dump it belongs to
405        // (comma-joined), plus caller correlation pairs with the `dump.`
406        // namespace stripped.
407        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        // Remove local files if disk-backed (memory segments are gone once popped).
435        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    /// Key the manifest for `dump_id` would be written at.
449    pub(crate) fn manifest_key(&self, dump_id: &str) -> String {
450        self.config.manifest_key(dump_id)
451    }
452
453    /// PUT a dump manifest. Small JSON object, no local file involved.
454    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
472// === S3 pipeline processor ===
473
474/// S3 uploader processor. Construction is synchronous; the worker resolves the
475/// AWS client and bucket region on its Tokio runtime before processing starts.
476pub struct S3PipelineUploader {
477    state: S3UploaderState,
478    /// Triggered mode: object keys written per dump id, accumulated while
479    /// the dump is open and flushed into its manifest at `finalize_dump`.
480    /// A key appears under several ids when forward windows overlap.
481    /// `pub(crate)` so the finalize tests can seed and inspect it.
482    pub(crate) dump_keys: HashMap<String, Vec<String>>,
483}
484
485type BoxedS3ClientFuture = Pin<Box<dyn Future<Output = aws_sdk_s3::Client> + Send + 'static>>;
486
487// The private mutex preserves `Sync`; initialization uses `get_mut`, so polling
488// still requires exclusive access and does not lock at runtime.
489struct 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            // Bound each attempt so a hung PutObject/HeadBucket can't wedge the
528            // upload worker; retries are left to the SDK policy and pipeline
529            // circuit breaker.
530            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    /// Create a new uploader from an [`S3Config`](S3Config) and an
542    /// optional pre-built S3 client. If `client` is `None`, the default
543    /// AWS configuration chain is used. Client and transfer-manager
544    /// construction run when the worker initializes the pipeline.
545    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    /// Construct the S3 client asynchronously when the pipeline worker starts.
560    ///
561    /// The future is created by the caller and polled exactly once on the
562    /// worker's Tokio runtime. If initialization is cancelled while it is
563    /// pending, a later attempt resumes the same pinned future.
564    #[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    /// Set (or override) the pre-built S3 client. Must be called before the
581    /// pipeline worker initializes the uploader.
582    /// Note: the only caller is the builder, which runs before the
583    /// worker is spawned, so reaching the `Ready` arm is a programmer error.
584    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    /// Take a pre-built client out of a pending uploader so it can be carried
596    /// into a replacement. Returns `None` for a future-backed or initialized
597    /// uploader.
598    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    /// Override the pending config's boot_id so S3 keys use the on-disk
618    /// namespace identity. The builder calls this before the worker spawns, so
619    /// the `Ready` arm is unreachable in practice; a no-op there keeps it safe.
620    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    /// Construct an uploader directly in the `Ready` state. Test-only;
627    /// production code goes through [`new`](Self::new) and worker initialization.
628    #[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        // Rebuild the client with the correct region.
653        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            // Keep direct SegmentProcessor drivers compatible even if they do
712            // not call the worker lifecycle hook.
713            self.ensure_initialized().await;
714            let S3UploaderState::Ready {
715                uploader,
716                circuit_breaker,
717            } = &mut self.state
718            else {
719                // Initialization currently always transitions to Ready. Return
720                // an error so a future state change cannot silently lose data.
721                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                    // Triggered dumps: remember the key under every dump id
741                    // the segment belongs to, for that dump's manifest.
742                    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        // Always take the entry so per-dump state clears even when no
775        // manifest gets written. An empty dump still gets an
776        // (empty-segments) manifest: its presence is the cross-process
777        // completion signal, so it is only written for dumps that
778        // completed (a failed dump resolves `Err` and leaves no manifest).
779        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            // Keep direct SegmentProcessor drivers compatible if they call
789            // finalize without first invoking the lifecycle hook.
790            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            // Best-effort: a failed manifest PUT never fails the receipt.
815            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
832/// Detect the region of an S3 bucket via HeadBucket.
833async 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    /// Build a config with a fixed boot_id for deterministic key assertions.
887    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    /// Create an `aws_sdk_s3::Client` backed by s3s-fs (in-memory fake S3).
916    /// The same builder drives both the uploader under test and the read-back
917    /// client tests use to verify uploaded objects; each call returns an
918    /// independent client over the same on-disk bucket at `fs_root`.
919    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    // --- Key format tests ---
956
957    #[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    // --- Gzip compression tests ---
1042
1043    #[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    // --- Builder tests ---
1085
1086    #[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        // No prefix → the version anchor is the first component.
1097        check!(key.starts_with("version=1/date=2025-03-05/"));
1098    }
1099
1100    // --- S3 integration tests via s3s-fs ---
1101
1102    #[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        // Create the bucket directory (s3s-fs uses directories as buckets)
1108        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        // Write a fake segment file
1116        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        // Compress, then upload and delete
1122        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        // Local file should be deleted
1134        check!(!segment_path.exists());
1135
1136        // Download from S3 and verify contents
1137        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        // Read back from fake S3
1176        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        // Body should be gzip — decompress and verify
1187        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        // HeadObject to read back metadata
1225        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        // No dump tagging in continuous mode.
1240        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()); // invalid key: skipped
1261        metadata.insert("dump.host".into(), "spoofed".into()); // reserved: skipped
1262        metadata.insert("dump.note".into(), "caf\u{e9}".into()); // non-ASCII value: skipped
1263
1264        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        // Reserved fixed field never overridden by caller pairs.
1283        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        // Destroy the S3 backend filesystem — uploads will fail
1350        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        // The local file must survive the failed upload
1358        check!(segment_path.exists());
1359    }
1360
1361    // --- Review finding #6: object_key with epoch_secs fallback to 0 ---
1362
1363    #[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        // No epoch_secs in metadata — falls back to 0
1368        let metadata = HashMap::new();
1369        let key = config.object_key(&segment, &metadata);
1370        // Epoch 0 is a silent misconfiguration, but still uses the v1 layout.
1371        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/// Worker-integration tests: drive a real `S3PipelineUploader` through the
1385/// pipeline against an s3s-fs fake S3, via the `dial9_core::test_util`
1386/// helpers (the worker internals are not exposed cross-crate).
1387#[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    // === Unit tests (no worker) ===
1401
1402    /// A NotFound read (an evicted segment) must not degrade the circuit
1403    /// breaker: only a genuine transfer error opens it.
1404    #[tokio::test]
1405    async fn evicted_file_does_not_trip_circuit_breaker() {
1406        let dir = tempfile::tempdir().unwrap();
1407        // A path that does not exist on disk (simulates eviction).
1408        let missing = dir.path().join("trace.0.bin");
1409
1410        let mut cb = CircuitBreaker::new();
1411        // Mirror the upload skip logic: a NotFound read is skipped, not a
1412        // failure; any other error degrades the breaker.
1413        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    /// `set_client` is only valid while the uploader is `Pending`. Calling it
1425    /// on a `Ready` uploader indicates internal misuse and must panic rather
1426    /// than silently drop the new client.
1427    #[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    /// The S3 stage clears per-dump state but writes no manifest for a
1478    /// failed dump (manifest presence means successful completion).
1479    #[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    // === Worker-integration tests (real S3 via s3s-fs, driven through dial9_core::test_util) ===
1511
1512    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    // === Flaky-retry end-to-end recovery ===
1657
1658    /// s3s wrapper that fails the first `fail_n` writes with 500, then
1659    /// delegates to the inner backend.
1660    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    /// Read the single object out of the fake S3 bucket. Panics if there
1700    /// isn't exactly one. Used to assert uploaded bytes survived retries.
1701    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            // Disable SDK-internal retries so each worker attempt = 1 PUT.
1745            .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        // > CB initial backoff (1s) so CB reopens between retries. CB
1771        // doubles per failure; budget=3 fits 1s+2s within the 15s cap below.
1772        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}