Skip to main content

lance_io/
object_writer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::io;
5use std::num::NonZeroUsize;
6use std::pin::Pin;
7use std::sync::{Arc, OnceLock};
8use std::task::Poll;
9use std::time::Instant;
10
11use crate::object_store::ObjectStore as LanceObjectStore;
12use async_trait::async_trait;
13use bytes::Bytes;
14use futures::FutureExt;
15use futures::future::BoxFuture;
16use object_store::{MultipartUpload, ObjectStoreExt};
17use object_store::{ObjectStore, path::Path};
18use tokio::io::{AsyncWrite, AsyncWriteExt};
19use tokio::task::JoinSet;
20
21use lance_core::{Error, Result};
22use tracing::Instrument;
23
24use crate::traits::Writer;
25use crate::utils::tracking_store::{IOTracker, IoMetricsGuard};
26use tokio::runtime::Handle;
27
28/// Start at 5MB.
29const INITIAL_UPLOAD_STEP: usize = 1024 * 1024 * 5;
30
31pub fn max_upload_parallelism() -> usize {
32    static MAX_UPLOAD_PARALLELISM: OnceLock<usize> = OnceLock::new();
33    *MAX_UPLOAD_PARALLELISM.get_or_init(|| {
34        std::env::var("LANCE_UPLOAD_CONCURRENCY")
35            .ok()
36            .and_then(|s| s.parse::<usize>().ok())
37            .unwrap_or(10)
38    })
39}
40
41/// Upper bound on the bytes one multipart upload of `total_bytes` holds at
42/// once: the parts in flight plus the buffer being filled, each at the part
43/// size the growth rule in `ObjectWriter::next_part_buffer` has reached by the
44/// end of the upload.
45pub fn upload_memory_bound_bytes(total_bytes: u64, constant_upload_size: bool) -> u64 {
46    let initial = initial_upload_size() as u64;
47    let step = INITIAL_UPLOAD_STEP as u64;
48    let mut part_size = initial;
49    if !constant_upload_size {
50        let mut covered = 0u64;
51        let mut tier = 0u64;
52        while covered < total_bytes {
53            part_size = initial.max((tier + 1) * step);
54            covered = covered.saturating_add(part_size.saturating_mul(100));
55            tier += 1;
56        }
57    }
58    part_size.saturating_mul(max_upload_parallelism() as u64 + 1)
59}
60
61/// Maximum body size for a single S3 PUT: strictly less than 5 GiB.
62/// AWS rejects single-PUT bodies of exactly 5 GiB (= 5 * 1024^3) with
63/// `EntityTooLarge`, so we clamp `LANCE_INITIAL_UPLOAD_SIZE` one byte
64/// below that threshold to keep the buffer-fills-to-clamp single-PUT
65/// path safe. See lance#6750 for the related txn-file write fix.
66const MAX_UPLOAD_PART_SIZE: usize = 1024 * 1024 * 1024 * 5 - 1;
67
68/// Clamps a requested upload part size to the valid [5MB, 5GB] range.
69/// Returns the clamped value and whether clamping was necessary.
70fn clamp_initial_upload_size(raw: usize) -> (usize, bool) {
71    let clamped = raw.clamp(INITIAL_UPLOAD_STEP, MAX_UPLOAD_PART_SIZE);
72    (clamped, clamped != raw)
73}
74
75pub fn initial_upload_size() -> usize {
76    static LANCE_INITIAL_UPLOAD_SIZE: OnceLock<usize> = OnceLock::new();
77    *LANCE_INITIAL_UPLOAD_SIZE.get_or_init(|| {
78        let Some(raw) = std::env::var("LANCE_INITIAL_UPLOAD_SIZE")
79            .ok()
80            .and_then(|s| s.parse::<usize>().ok())
81        else {
82            return INITIAL_UPLOAD_STEP;
83        };
84        let (clamped, was_clamped) = clamp_initial_upload_size(raw);
85        if was_clamped {
86            // OnceLock caches the result, so this warning fires at most once per process.
87            tracing::warn!(
88                requested = raw,
89                clamped,
90                "LANCE_INITIAL_UPLOAD_SIZE must be between 5MB and 5GB; clamping to valid range"
91            );
92        }
93        clamped
94    })
95}
96
97/// Writer to an object in an object store.
98///
99/// If the object is small enough, the writer will upload the object in a single
100/// PUT request. If the object is larger, the writer will create a multipart
101/// upload and upload parts in parallel.
102///
103/// Parts stay in flight across writes and flushes, so a writer can hold up to
104/// `LANCE_UPLOAD_CONCURRENCY` part bodies in memory at once. With a large
105/// `LANCE_INITIAL_UPLOAD_SIZE` that product is what bounds the writer's
106/// footprint, not the part size alone.
107///
108/// This implements the `AsyncWrite` trait.
109pub struct ObjectWriter {
110    state: UploadState,
111    path: Arc<Path>,
112    cursor: usize,
113    upload_parallelism: NonZeroUsize,
114    buffer: Vec<u8>,
115    // TODO: use constant size to support R2
116    use_constant_size_upload_parts: bool,
117}
118
119#[derive(Debug, Clone, Default)]
120pub struct WriteResult {
121    pub size: usize,
122    pub e_tag: Option<String>,
123}
124
125/// An object-store upload failure, annotated with what Lance was uploading.
126///
127/// `object_store` reports its own elapsed time, but its clock starts inside
128/// `RetryContext::new`, which runs on the *first poll* of the request future.
129/// The `elapsed` reported here is measured from the moment Lance handed the
130/// request to the uploader, so the two together tell a slow request (both
131/// durations agree) apart from one whose task sat unpolled before it ever
132/// issued (this duration is much larger). That distinction is what identifies
133/// runtime starvation as the cause of a whole-request timeout, and it is not
134/// recoverable from the object-store error alone.
135#[derive(Debug)]
136struct UploadFailure {
137    context: String,
138    /// The kind `into_io_error` restores. Without it every contextualized
139    /// failure would collapse to `ErrorKind::Other`, changing what callers
140    /// matching on the kind observe.
141    kind: io::ErrorKind,
142    source: Box<dyn std::error::Error + Send + Sync>,
143}
144
145impl UploadFailure {
146    /// Wraps an object store error.
147    ///
148    /// The `io::ErrorKind` is taken from `object_store`'s own conversion rather
149    /// than a local copy of its mapping, and the error itself is kept as the
150    /// source, so both callers matching on the kind and `Error::is_not_found`
151    /// (which downcasts along the source chain) keep working.
152    fn new(context: String, source: object_store::Error) -> Self {
153        let mapped = io::Error::from(source);
154        let kind = mapped.kind();
155        let source: Box<dyn std::error::Error + Send + Sync> =
156            match mapped.downcast::<object_store::Error>() {
157                Ok(source) => Box::new(source),
158                Err(mapped) => Box::new(mapped),
159            };
160        Self {
161            context,
162            kind,
163            source,
164        }
165    }
166
167    /// Wraps a failure that carries no object store error to map a kind from.
168    fn from_task(context: String, source: tokio::task::JoinError) -> Self {
169        Self {
170            context,
171            kind: io::ErrorKind::Other,
172            source: Box::new(source),
173        }
174    }
175
176    fn into_io_error(self) -> io::Error {
177        let kind = self.kind;
178        io::Error::new(kind, self)
179    }
180}
181
182impl std::fmt::Display for UploadFailure {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        write!(f, "{}: {}", self.context, self.source)
185    }
186}
187
188impl std::error::Error for UploadFailure {
189    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
190        Some(self.source.as_ref())
191    }
192}
193
194type UploadResult<T> = std::result::Result<T, UploadFailure>;
195
196/// Identifies a single part upload, for its failure message.
197struct PartUpload {
198    path: Arc<Path>,
199    part_idx: u16,
200    /// Concurrent part uploads, counting this one, when it was submitted.
201    parts_in_flight: usize,
202    /// The part size in effect, which is the buffer capacity this part was
203    /// filled to. [`ObjectWriter::next_part_buffer`] grows the part size every
204    /// 100 parts so one upload can cover a very large object within the
205    /// 10,000-part limit, so on a long upload this is a multiple of
206    /// `LANCE_INITIAL_UPLOAD_SIZE` rather than equal to it. A body smaller than
207    /// this is the final flush.
208    part_size: usize,
209}
210
211/// Describes the upload knobs in effect, for inclusion in failure messages.
212///
213/// Both are process-global and read from the environment, so a failure that is
214/// sensitive to either is impossible to interpret without them. Note that
215/// `LANCE_INITIAL_UPLOAD_SIZE` is the starting part size, not the size in
216/// effect: see [`PartUpload::part_size`].
217fn upload_settings() -> String {
218    format!(
219        "LANCE_INITIAL_UPLOAD_SIZE={} bytes, LANCE_UPLOAD_CONCURRENCY={}",
220        initial_upload_size(),
221        max_upload_parallelism()
222    )
223}
224
225enum UploadState {
226    /// The writer has been opened but no data has been written yet. Will be in
227    /// this state until the buffer is full or the writer is shut down.
228    Started(Arc<dyn ObjectStore>),
229    /// The writer is in the process of creating a multipart upload.
230    CreatingUpload(BoxFuture<'static, UploadResult<Box<dyn MultipartUpload>>>),
231    /// The writer is in the process of uploading parts.
232    InProgress {
233        part_idx: u16,
234        upload: Box<dyn MultipartUpload>,
235        futures: JoinSet<UploadResult<()>>,
236    },
237    /// The writer is in the process of uploading data in a single PUT request.
238    /// This happens when shutdown is called before the buffer is full.
239    PuttingSingle(BoxFuture<'static, UploadResult<WriteResult>>),
240    /// The writer is in the process of completing the multipart upload.
241    Completing(BoxFuture<'static, UploadResult<WriteResult>>),
242    /// The writer has been shut down and all data has been written.
243    Done(WriteResult),
244}
245
246/// Methods for state transitions.
247impl UploadState {
248    fn started_to_putting_single(&mut self, path: Arc<Path>, buffer: Vec<u8>) {
249        // To get owned self, we temporarily swap with Done.
250        let this = std::mem::replace(self, Self::Done(WriteResult::default()));
251        *self = match this {
252            Self::Started(store) => {
253                tracing::Span::current().record("part_count", 1_u64);
254                let started_at = Instant::now();
255                let fut = async move {
256                    let size = buffer.len();
257                    let res = store.put(&path, buffer.into()).await.map_err(|source| {
258                        UploadFailure::new(
259                            format!(
260                                "single PUT of {path} failed after {:?} ({size} bytes, {})",
261                                started_at.elapsed(),
262                                upload_settings()
263                            ),
264                            source,
265                        )
266                    })?;
267                    Ok(WriteResult {
268                        size,
269                        e_tag: res.e_tag,
270                    })
271                };
272                Self::PuttingSingle(Box::pin(fut))
273            }
274            _ => unreachable!(),
275        }
276    }
277
278    fn in_progress_to_completing(&mut self, path: Arc<Path>, bytes_written: usize) {
279        // To get owned self, we temporarily swap with Done.
280        let this = std::mem::replace(self, Self::Done(WriteResult::default()));
281        *self = match this {
282            Self::InProgress {
283                mut upload,
284                futures,
285                part_idx,
286            } => {
287                debug_assert!(futures.is_empty());
288                tracing::Span::current().record("part_count", part_idx as u64);
289                let started_at = Instant::now();
290                let fut = async move {
291                    let res = match upload.complete().await {
292                        Ok(result) => result,
293                        Err(source) => {
294                            let elapsed = started_at.elapsed();
295                            if let Err(error) = upload.abort().await {
296                                tracing::warn!(
297                                    path = %path,
298                                    error = %error,
299                                    "Failed to abort multipart upload after completion error"
300                                );
301                            }
302                            return Err(UploadFailure::new(
303                                format!(
304                                    "completing multipart upload of {path} failed after {:?} \
305                                 ({part_idx} parts, {bytes_written} bytes, {})",
306                                    elapsed,
307                                    upload_settings()
308                                ),
309                                source,
310                            ));
311                        }
312                    };
313                    Ok(WriteResult {
314                        size: 0, // This will be set properly later.
315                        e_tag: res.e_tag,
316                    })
317                };
318                Self::Completing(Box::pin(fut))
319            }
320            _ => unreachable!(),
321        };
322    }
323}
324
325impl ObjectWriter {
326    /// Creates a writer, rejecting `LANCE_UPLOAD_CONCURRENCY=0` before allocating
327    /// an upload buffer or issuing any requests.
328    pub async fn new(object_store: &LanceObjectStore, path: &Path) -> Result<Self> {
329        Self::with_upload_parallelism(object_store, path, max_upload_parallelism())
330    }
331
332    fn with_upload_parallelism(
333        object_store: &LanceObjectStore,
334        path: &Path,
335        upload_parallelism: usize,
336    ) -> Result<Self> {
337        let upload_parallelism = NonZeroUsize::new(upload_parallelism).ok_or_else(|| {
338            Error::invalid_input(format!(
339                "LANCE_UPLOAD_CONCURRENCY must be greater than zero, got {upload_parallelism}"
340            ))
341        })?;
342        Ok(Self {
343            state: UploadState::Started(object_store.inner.clone()),
344            cursor: 0,
345            upload_parallelism,
346            path: Arc::new(path.clone()),
347            buffer: Vec::with_capacity(initial_upload_size()),
348            use_constant_size_upload_parts: object_store.use_constant_size_upload_parts,
349        })
350    }
351
352    /// Returns the contents of `buffer` as a `Bytes` object and resets `buffer`.
353    /// The new capacity of `buffer` is determined by the current part index.
354    fn next_part_buffer(buffer: &mut Vec<u8>, part_idx: u16, constant_upload_size: bool) -> Bytes {
355        let new_capacity = if constant_upload_size {
356            // The store does not support variable part sizes, so use the initial size.
357            initial_upload_size()
358        } else {
359            // Increase the upload size every 100 parts. This gives maximum part size of 2.5TB.
360            initial_upload_size().max(((part_idx / 100) as usize + 1) * INITIAL_UPLOAD_STEP)
361        };
362        let new_buffer = Vec::with_capacity(new_capacity);
363        let part = std::mem::replace(buffer, new_buffer);
364        Bytes::from(part)
365    }
366
367    fn put_part(
368        upload: &mut dyn MultipartUpload,
369        buffer: Bytes,
370        part: PartUpload,
371    ) -> BoxFuture<'static, UploadResult<()>> {
372        let body_size = buffer.len();
373        log::debug!("MultipartUpload submitting part with {} bytes", body_size);
374        // Stamped before the future is spawned so the reported duration covers
375        // any time the task spent waiting to be polled, not just the request.
376        let queued_at = Instant::now();
377        let fut = upload.put_part(buffer.into());
378        Box::pin(async move {
379            fut.await.map_err(|source| {
380                let PartUpload {
381                    path,
382                    part_idx,
383                    parts_in_flight,
384                    part_size,
385                } = part;
386                UploadFailure::new(
387                    format!(
388                        "multipart upload of part {part_idx} of {path} failed after {:?} \
389                         ({body_size} bytes, part_size={part_size} bytes, \
390                          parts_in_flight={parts_in_flight} at submission, {})",
391                        queued_at.elapsed(),
392                        upload_settings()
393                    ),
394                    source,
395                )
396            })
397        })
398    }
399
400    fn poll_tasks(
401        mut self: Pin<&mut Self>,
402        cx: &mut std::task::Context<'_>,
403    ) -> std::result::Result<(), io::Error> {
404        let mut_self = &mut *self;
405        loop {
406            match &mut mut_self.state {
407                UploadState::Started(_) | UploadState::Done(_) => break,
408                UploadState::CreatingUpload(fut) => match fut.poll_unpin(cx) {
409                    Poll::Ready(Ok(mut upload)) => {
410                        let mut futures = JoinSet::new();
411
412                        // Read before the buffer is swapped out: capacity is the
413                        // part size this body was filled to.
414                        let part_size = mut_self.buffer.capacity();
415                        let data = Self::next_part_buffer(
416                            &mut mut_self.buffer,
417                            0,
418                            mut_self.use_constant_size_upload_parts,
419                        );
420                        futures.spawn(Self::put_part(
421                            upload.as_mut(),
422                            data,
423                            PartUpload {
424                                path: mut_self.path.clone(),
425                                part_idx: 0,
426                                parts_in_flight: 1,
427                                part_size,
428                            },
429                        ));
430
431                        mut_self.state = UploadState::InProgress {
432                            part_idx: 1, // We just used 0
433                            futures,
434                            upload,
435                        };
436                    }
437                    Poll::Ready(Err(err)) => return Err(err.into_io_error()),
438                    Poll::Pending => break,
439                },
440                UploadState::InProgress { futures, .. } => {
441                    while let Poll::Ready(Some(res)) = futures.poll_join_next(cx) {
442                        match res {
443                            Ok(Ok(())) => {}
444                            Err(err) => {
445                                return Err(UploadFailure::from_task(
446                                    format!(
447                                        "multipart upload task for {} did not complete",
448                                        mut_self.path
449                                    ),
450                                    err,
451                                )
452                                .into_io_error());
453                            }
454                            Ok(Err(err)) => return Err(err.into_io_error()),
455                        }
456                    }
457                    break;
458                }
459                UploadState::PuttingSingle(fut) | UploadState::Completing(fut) => {
460                    match fut.poll_unpin(cx) {
461                        Poll::Ready(Ok(mut res)) => {
462                            res.size = mut_self.cursor;
463                            mut_self.state = UploadState::Done(res)
464                        }
465                        Poll::Ready(Err(err)) => return Err(err.into_io_error()),
466                        Poll::Pending => break,
467                    }
468                }
469            }
470        }
471        Ok(())
472    }
473
474    pub async fn abort(&mut self) {
475        let state = std::mem::replace(&mut self.state, UploadState::Done(WriteResult::default()));
476        if let UploadState::InProgress { mut upload, .. } = state {
477            let _ = upload.abort().await;
478        }
479    }
480}
481
482impl Drop for ObjectWriter {
483    fn drop(&mut self) {
484        // If there is a multipart upload started but not finished, we should abort it.
485        if matches!(self.state, UploadState::InProgress { .. }) {
486            // Take ownership of the state.
487            let state =
488                std::mem::replace(&mut self.state, UploadState::Done(WriteResult::default()));
489            if let UploadState::InProgress { mut upload, .. } = state
490                && let Ok(handle) = Handle::try_current()
491            {
492                handle.spawn(async move {
493                    let _ = upload.abort().await;
494                });
495            }
496        }
497    }
498}
499
500impl AsyncWrite for ObjectWriter {
501    fn poll_write(
502        mut self: std::pin::Pin<&mut Self>,
503        cx: &mut std::task::Context<'_>,
504        buf: &[u8],
505    ) -> std::task::Poll<std::result::Result<usize, std::io::Error>> {
506        self.as_mut().poll_tasks(cx)?;
507
508        // Fill buffer up to remaining capacity.
509        let remaining_capacity = self.buffer.capacity() - self.buffer.len();
510        let bytes_to_write = std::cmp::min(remaining_capacity, buf.len());
511        self.buffer.extend_from_slice(&buf[..bytes_to_write]);
512        self.cursor += bytes_to_write;
513
514        // Rust needs a little help to borrow self mutably and immutably at the same time
515        // through a Pin.
516        let mut_self = &mut *self;
517
518        // Instantiate next request, if available.
519        if mut_self.buffer.capacity() == mut_self.buffer.len() {
520            match &mut mut_self.state {
521                UploadState::Started(store) => {
522                    let path = mut_self.path.clone();
523                    let store = store.clone();
524                    let started_at = Instant::now();
525                    let fut = Box::pin(async move {
526                        store.put_multipart(path.as_ref()).await.map_err(|source| {
527                            UploadFailure::new(
528                                format!(
529                                    "failed to create multipart upload for {path} after {:?} ({})",
530                                    started_at.elapsed(),
531                                    upload_settings()
532                                ),
533                                source,
534                            )
535                        })
536                    });
537                    self.state = UploadState::CreatingUpload(fut);
538                }
539                // TODO: Make max concurrency configurable from storage options.
540                UploadState::InProgress {
541                    upload,
542                    part_idx,
543                    futures,
544                    ..
545                } if futures.len() < mut_self.upload_parallelism.get() => {
546                    // Read before the buffer is swapped out: capacity is the
547                    // part size this body was filled to, which grows as the
548                    // upload progresses.
549                    let part_size = mut_self.buffer.capacity();
550                    let data = Self::next_part_buffer(
551                        &mut mut_self.buffer,
552                        *part_idx,
553                        mut_self.use_constant_size_upload_parts,
554                    );
555                    let part = PartUpload {
556                        path: mut_self.path.clone(),
557                        part_idx: *part_idx,
558                        parts_in_flight: futures.len() + 1,
559                        part_size,
560                    };
561                    futures.spawn(
562                        Self::put_part(upload.as_mut(), data, part)
563                            .instrument(tracing::Span::current()),
564                    );
565                    *part_idx += 1;
566                }
567                _ => {}
568            }
569        }
570
571        self.poll_tasks(cx)?;
572
573        match bytes_to_write {
574            0 => Poll::Pending,
575            _ => Poll::Ready(Ok(bytes_to_write)),
576        }
577    }
578
579    fn poll_flush(
580        mut self: std::pin::Pin<&mut Self>,
581        cx: &mut std::task::Context<'_>,
582    ) -> std::task::Poll<std::result::Result<(), std::io::Error>> {
583        self.as_mut().poll_tasks(cx)?;
584
585        match &self.state {
586            UploadState::Started(_) | UploadState::Done(_) => Poll::Ready(Ok(())),
587            UploadState::CreatingUpload(_)
588            | UploadState::Completing(_)
589            | UploadState::PuttingSingle(_) => Poll::Pending,
590            // In-flight parts are spawned tasks, so the runtime drives them
591            // whether or not this writer is polled again; `poll_tasks` above
592            // only reaps them. Waiting for them here would serialize every part
593            // upload behind the caller's next batch, because callers flush once
594            // per batch. `poll_shutdown` still drains them before completing the
595            // upload, which is the only point at which the object becomes
596            // readable. Note this never flushed the tail buffer either, so it
597            // was not a "all data has reached the destination" barrier to begin
598            // with.
599            UploadState::InProgress { .. } => Poll::Ready(Ok(())),
600        }
601    }
602
603    fn poll_shutdown(
604        mut self: std::pin::Pin<&mut Self>,
605        cx: &mut std::task::Context<'_>,
606    ) -> std::task::Poll<std::result::Result<(), std::io::Error>> {
607        loop {
608            self.as_mut().poll_tasks(cx)?;
609
610            // Rust needs a little help to borrow self mutably and immutably at the same time
611            // through a Pin.
612            let mut_self = &mut *self;
613            match &mut mut_self.state {
614                UploadState::Done(_) => return Poll::Ready(Ok(())),
615                UploadState::CreatingUpload(_)
616                | UploadState::PuttingSingle(_)
617                | UploadState::Completing(_) => return Poll::Pending,
618                UploadState::Started(_) => {
619                    // If we didn't start a multipart upload, we can just do a single put.
620                    let part = std::mem::take(&mut mut_self.buffer);
621                    let path = mut_self.path.clone();
622                    self.state.started_to_putting_single(path, part);
623                }
624                UploadState::InProgress {
625                    upload,
626                    futures,
627                    part_idx,
628                } => {
629                    // Flush final batch
630                    if !mut_self.buffer.is_empty()
631                        && futures.len() < mut_self.upload_parallelism.get()
632                    {
633                        // We can just use `take` since we don't need the buffer anymore.
634                        let part_size = mut_self.buffer.capacity();
635                        let data = Bytes::from(std::mem::take(&mut mut_self.buffer));
636                        let part = PartUpload {
637                            path: mut_self.path.clone(),
638                            part_idx: *part_idx,
639                            parts_in_flight: futures.len() + 1,
640                            part_size,
641                        };
642                        // Counted like every other part so the part total
643                        // reported when completing the upload is accurate.
644                        *part_idx += 1;
645                        futures.spawn(
646                            Self::put_part(upload.as_mut(), data, part)
647                                .instrument(tracing::Span::current()),
648                        );
649                        // We need to go back to beginning of loop to poll the
650                        // new feature and get the waker registered on the ctx.
651                        continue;
652                    }
653
654                    // We handle the transition from in progress to completing here.
655                    if futures.is_empty() {
656                        // No task can wake this writer if the tail could not be submitted.
657                        // Never report success while accepted bytes remain buffered.
658                        if !mut_self.buffer.is_empty() {
659                            return Poll::Ready(Err(io::Error::other(format!(
660                                "cannot complete multipart upload of {}: {} bytes remain buffered \
661                                 with no outstanding parts (LANCE_UPLOAD_CONCURRENCY={})",
662                                mut_self.path,
663                                mut_self.buffer.len(),
664                                mut_self.upload_parallelism
665                            ))));
666                        }
667                        let path = mut_self.path.clone();
668                        let bytes_written = mut_self.cursor;
669                        self.state.in_progress_to_completing(path, bytes_written);
670                    } else {
671                        return Poll::Pending;
672                    }
673                }
674            }
675        }
676    }
677}
678
679#[async_trait]
680impl Writer for ObjectWriter {
681    async fn tell(&mut self) -> Result<usize> {
682        Ok(self.cursor)
683    }
684
685    async fn shutdown(&mut self) -> Result<WriteResult> {
686        // Propagated structurally rather than formatted into a message: every
687        // failure from this writer already names the path, and stringifying it
688        // would flatten the object store error out of the source chain.
689        AsyncWriteExt::shutdown(self).await?;
690        if let UploadState::Done(result) = &self.state {
691            Ok(result.clone())
692        } else {
693            unreachable!()
694        }
695    }
696}
697
698pub struct LocalWriter {
699    path: Path,
700    state: LocalWriteState,
701}
702
703#[derive(Default)]
704enum LocalWriteState {
705    Writing(Box<WritingState>),
706    Finishing {
707        size: usize,
708        future: BoxFuture<'static, Result<WriteResult>>,
709    },
710    Done(WriteResult),
711    #[default]
712    Poisoned,
713}
714
715struct WritingState {
716    writer: tokio::io::BufWriter<tokio::fs::File>,
717    cursor: usize,
718    /// Temp path that auto-deletes on drop. Set to `None` after `persist()`.
719    temp_path: tempfile::TempPath,
720    io_tracker: Arc<IOTracker>,
721    /// The whole file is reported as a single `put`, so this covers everything
722    /// from opening the file to it being durable under its final path. A writer
723    /// dropped before `persist()` records nothing, like an aborted upload.
724    metrics: IoMetricsGuard,
725}
726
727impl LocalWriter {
728    pub fn new(
729        file: tokio::fs::File,
730        path: Path,
731        temp_path: tempfile::TempPath,
732        io_tracker: Arc<IOTracker>,
733    ) -> Self {
734        Self {
735            path,
736            state: LocalWriteState::Writing(Box::new(WritingState {
737                writer: tokio::io::BufWriter::new(file),
738                cursor: 0,
739                temp_path,
740                metrics: io_tracker.begin_io("put"),
741                io_tracker,
742            })),
743        }
744    }
745
746    fn already_closed_err(path: &Path) -> io::Error {
747        io::Error::other(format!(
748            "cannot write to LocalWriter for {} after shutdown",
749            path
750        ))
751    }
752
753    fn poisoned_err(path: &Path) -> io::Error {
754        io::Error::other(format!("LocalWriter for {} is in poisoned state", path))
755    }
756
757    async fn persist(
758        temp_path: tempfile::TempPath,
759        final_path: Path,
760        size: usize,
761        io_tracker: Arc<IOTracker>,
762        metrics: IoMetricsGuard,
763    ) -> Result<WriteResult> {
764        let local_path = crate::local::to_local_path(&final_path);
765        let persisted = tokio::task::spawn_blocking(move || -> Result<String> {
766            temp_path.persist(&local_path).map_err(|e| {
767                Error::io(format!(
768                    "failed to persist temp file to {}: {}",
769                    local_path, e.error
770                ))
771            })?;
772
773            let metadata = std::fs::metadata(&local_path).map_err(|e| {
774                Error::io(format!("failed to read metadata for {}: {}", local_path, e))
775            })?;
776            Ok(get_etag(&metadata))
777        })
778        .await
779        .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))
780        .and_then(|e_tag| e_tag);
781
782        metrics.record(&persisted, size as u64);
783        let e_tag = persisted?;
784
785        io_tracker.record_write("put", final_path, size as u64);
786
787        Ok(WriteResult {
788            size,
789            e_tag: Some(e_tag),
790        })
791    }
792}
793
794impl AsyncWrite for LocalWriter {
795    fn poll_write(
796        mut self: Pin<&mut Self>,
797        cx: &mut std::task::Context<'_>,
798        buf: &[u8],
799    ) -> Poll<std::result::Result<usize, std::io::Error>> {
800        if let LocalWriteState::Writing(state) = &mut self.state {
801            let poll = Pin::new(&mut state.writer).poll_write(cx, buf);
802            if let Poll::Ready(Ok(n)) = &poll {
803                state.cursor += *n;
804            }
805            poll
806        } else {
807            Poll::Ready(Err(Self::already_closed_err(&self.path)))
808        }
809    }
810
811    fn poll_flush(
812        mut self: Pin<&mut Self>,
813        cx: &mut std::task::Context<'_>,
814    ) -> Poll<std::result::Result<(), std::io::Error>> {
815        if let LocalWriteState::Writing(state) = &mut self.state {
816            Pin::new(&mut state.writer).poll_flush(cx)
817        } else {
818            Poll::Ready(Err(Self::already_closed_err(&self.path)))
819        }
820    }
821
822    fn poll_shutdown(
823        mut self: Pin<&mut Self>,
824        cx: &mut std::task::Context<'_>,
825    ) -> Poll<std::result::Result<(), std::io::Error>> {
826        let mut_self = &mut *self;
827        loop {
828            match &mut mut_self.state {
829                LocalWriteState::Writing(state) => {
830                    if Pin::new(&mut state.writer).poll_shutdown(cx).is_pending() {
831                        return Poll::Pending;
832                    }
833
834                    // Write is complete, we can transition to persisting.
835                    let LocalWriteState::Writing(state) =
836                        std::mem::replace(&mut mut_self.state, LocalWriteState::Poisoned)
837                    else {
838                        unreachable!()
839                    };
840                    let size = state.cursor;
841                    mut_self.state = LocalWriteState::Finishing {
842                        size,
843                        future: Box::pin(Self::persist(
844                            state.temp_path,
845                            mut_self.path.clone(),
846                            size,
847                            state.io_tracker,
848                            state.metrics,
849                        )),
850                    };
851                }
852                LocalWriteState::Finishing { future, .. } => match future.poll_unpin(cx) {
853                    Poll::Ready(Ok(result)) => mut_self.state = LocalWriteState::Done(result),
854                    Poll::Ready(Err(e)) => {
855                        return Poll::Ready(Err(io::Error::other(e)));
856                    }
857                    Poll::Pending => return Poll::Pending,
858                },
859                LocalWriteState::Done(_) => return Poll::Ready(Ok(())),
860                LocalWriteState::Poisoned => {
861                    return Poll::Ready(Err(Self::poisoned_err(&self.path)));
862                }
863            }
864        }
865    }
866}
867
868#[async_trait]
869impl Writer for LocalWriter {
870    async fn tell(&mut self) -> Result<usize> {
871        match &mut self.state {
872            LocalWriteState::Writing(state) => Ok(state.cursor),
873            LocalWriteState::Finishing { size, .. } => Ok(*size),
874            LocalWriteState::Done(result) => Ok(result.size),
875            LocalWriteState::Poisoned => Err(Self::poisoned_err(&self.path).into()),
876        }
877    }
878
879    async fn shutdown(&mut self) -> Result<WriteResult> {
880        AsyncWriteExt::shutdown(self).await.map_err(|e| {
881            Error::io(format!(
882                "failed to shutdown local writer for {}: {}",
883                self.path, e
884            ))
885        })?;
886
887        match &self.state {
888            LocalWriteState::Done(result) => Ok(result.clone()),
889            _ => unreachable!(),
890        }
891    }
892}
893
894// Based on object store's implementation.
895pub fn get_etag(metadata: &std::fs::Metadata) -> String {
896    let inode = get_inode(metadata);
897    let size = metadata.len();
898    let mtime = metadata
899        .modified()
900        .ok()
901        .and_then(|mtime| mtime.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
902        .unwrap_or_default()
903        .as_micros();
904
905    // Use an ETag scheme based on that used by many popular HTTP servers
906    // <https://httpd.apache.org/docs/2.2/mod/core.html#fileetag>
907    format!("{inode:x}-{mtime:x}-{size:x}")
908}
909
910#[cfg(unix)]
911fn get_inode(metadata: &std::fs::Metadata) -> u64 {
912    std::os::unix::fs::MetadataExt::ino(metadata)
913}
914
915#[cfg(not(unix))]
916fn get_inode(_metadata: &std::fs::Metadata) -> u64 {
917    0
918}
919
920#[cfg(test)]
921mod tests {
922    use futures::stream::BoxStream;
923    use object_store::{
924        CopyOptions, GetOptions, GetResult, ListResult, ObjectMeta, ObjectStoreExt,
925        PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, Result as OSResult,
926        UploadPart,
927    };
928    use rstest::rstest;
929    use std::sync::Mutex;
930    use std::time::Duration;
931    use tokio::io::AsyncWriteExt;
932    use tokio::sync::Semaphore;
933
934    use super::*;
935
936    /// Which stage of an upload the mock store rejects.
937    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
938    enum FailAt {
939        Nothing,
940        CreateMultipart,
941        PutPart,
942        Complete,
943        SinglePut,
944    }
945
946    /// What the mock store saw, so a test can assert on uploads that are still
947    /// in flight as well as on the object they eventually assemble.
948    #[derive(Debug)]
949    struct UploadObservations {
950        /// One permit per part upload that has begun. A test waits on this
951        /// rather than sampling a counter: `write_all` and a non-waiting
952        /// `flush` can both complete without ever returning `Pending`, so on a
953        /// current-thread runtime the spawned upload tasks may not have run yet.
954        started: Semaphore,
955        /// `(part index, body)` in completion order. The index is recorded
956        /// because it, not completion order, determines the assembled object.
957        parts: Mutex<Vec<(usize, Vec<u8>)>>,
958    }
959
960    impl Default for UploadObservations {
961        fn default() -> Self {
962            Self {
963                started: Semaphore::new(0),
964                parts: Mutex::new(Vec::new()),
965            }
966        }
967    }
968
969    fn rejected(stage: &'static str) -> object_store::Error {
970        object_store::Error::Generic {
971            store: "FailingUploadStore",
972            source: format!("{stage} rejected by test").into(),
973        }
974    }
975
976    #[derive(Debug)]
977    struct FailingUpload {
978        fail_at: FailAt,
979        /// When set, a part upload does not resolve until the gate is given a
980        /// permit, so a test can hold requests in flight.
981        gate: Option<Arc<Semaphore>>,
982        observations: Arc<UploadObservations>,
983        next_part: usize,
984    }
985
986    #[async_trait]
987    impl MultipartUpload for FailingUpload {
988        fn put_part(&mut self, data: PutPayload) -> UploadPart {
989            let fails = self.fail_at == FailAt::PutPart;
990            let part_idx = self.next_part;
991            self.next_part += 1;
992            let gate = self.gate.clone();
993            let observations = self.observations.clone();
994            Box::pin(async move {
995                observations.started.add_permits(1);
996                if let Some(gate) = gate {
997                    // `forget` keeps the permit from being returned on drop, so
998                    // adding N permits releases exactly N parts.
999                    gate.acquire_owned().await.unwrap().forget();
1000                }
1001                if fails {
1002                    return Err(rejected("part"));
1003                }
1004                let body = data
1005                    .iter()
1006                    .flat_map(|chunk| chunk.iter().copied())
1007                    .collect();
1008                observations.parts.lock().unwrap().push((part_idx, body));
1009                Ok(())
1010            })
1011        }
1012
1013        async fn complete(&mut self) -> OSResult<PutResult> {
1014            if self.fail_at == FailAt::Complete {
1015                Err(rejected("complete"))
1016            } else {
1017                Ok(PutResult {
1018                    e_tag: None,
1019                    version: None,
1020                    extensions: Default::default(),
1021                })
1022            }
1023        }
1024
1025        async fn abort(&mut self) -> OSResult<()> {
1026            Ok(())
1027        }
1028    }
1029
1030    /// Rejects exactly one stage of an upload so each failure site can be
1031    /// exercised on its own, and optionally holds part uploads open.
1032    #[derive(Debug)]
1033    struct FailingUploadStore {
1034        fail_at: FailAt,
1035        gate: Option<Arc<Semaphore>>,
1036        observations: Arc<UploadObservations>,
1037    }
1038
1039    impl FailingUploadStore {
1040        fn new(fail_at: FailAt) -> Self {
1041            Self {
1042                fail_at,
1043                gate: None,
1044                observations: Arc::new(UploadObservations::default()),
1045            }
1046        }
1047
1048        /// Builds a store whose part uploads stay in flight until the returned
1049        /// gate is given permits.
1050        fn gated(fail_at: FailAt) -> (Self, Arc<Semaphore>) {
1051            let gate = Arc::new(Semaphore::new(0));
1052            let store = Self {
1053                fail_at,
1054                gate: Some(gate.clone()),
1055                observations: Arc::new(UploadObservations::default()),
1056            };
1057            (store, gate)
1058        }
1059    }
1060
1061    impl std::fmt::Display for FailingUploadStore {
1062        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1063            write!(f, "FailingUploadStore")
1064        }
1065    }
1066
1067    #[async_trait]
1068    impl ObjectStore for FailingUploadStore {
1069        async fn put_opts(
1070            &self,
1071            _location: &Path,
1072            _bytes: PutPayload,
1073            _opts: PutOptions,
1074        ) -> OSResult<PutResult> {
1075            if self.fail_at == FailAt::SinglePut {
1076                Err(rejected("single put"))
1077            } else {
1078                Ok(PutResult {
1079                    e_tag: None,
1080                    version: None,
1081                    extensions: Default::default(),
1082                })
1083            }
1084        }
1085
1086        async fn put_multipart_opts(
1087            &self,
1088            _location: &Path,
1089            _opts: PutMultipartOptions,
1090        ) -> OSResult<Box<dyn MultipartUpload>> {
1091            if self.fail_at == FailAt::CreateMultipart {
1092                Err(rejected("create multipart"))
1093            } else {
1094                Ok(Box::new(FailingUpload {
1095                    fail_at: self.fail_at,
1096                    gate: self.gate.clone(),
1097                    observations: self.observations.clone(),
1098                    next_part: 0,
1099                }))
1100            }
1101        }
1102
1103        async fn get_opts(&self, _location: &Path, _options: GetOptions) -> OSResult<GetResult> {
1104            unimplemented!()
1105        }
1106
1107        fn delete_stream(
1108            &self,
1109            _locations: BoxStream<'static, OSResult<Path>>,
1110        ) -> BoxStream<'static, OSResult<Path>> {
1111            unimplemented!()
1112        }
1113
1114        fn list(&self, _prefix: Option<&Path>) -> BoxStream<'static, OSResult<ObjectMeta>> {
1115            unimplemented!()
1116        }
1117
1118        fn list_with_offset(
1119            &self,
1120            _prefix: Option<&Path>,
1121            _offset: &Path,
1122        ) -> BoxStream<'static, OSResult<ObjectMeta>> {
1123            unimplemented!()
1124        }
1125
1126        async fn list_with_delimiter(&self, _prefix: Option<&Path>) -> OSResult<ListResult> {
1127            unimplemented!()
1128        }
1129
1130        async fn copy_opts(&self, _from: &Path, _to: &Path, _opts: CopyOptions) -> OSResult<()> {
1131            unimplemented!()
1132        }
1133
1134        async fn rename_opts(
1135            &self,
1136            _from: &Path,
1137            _to: &Path,
1138            _opts: RenameOptions,
1139        ) -> OSResult<()> {
1140            unimplemented!()
1141        }
1142    }
1143
1144    const FAILING_UPLOAD_PATH: &str = "part_7_invert.lance";
1145
1146    /// Enough bytes for two full multipart parts, so a failing part has a
1147    /// sibling in flight. Derived from the configured part size rather than the
1148    /// default, since `LANCE_INITIAL_UPLOAD_SIZE` may raise it.
1149    fn two_parts() -> usize {
1150        initial_upload_size() * 2
1151    }
1152
1153    /// Drives a write against a store that rejects `fail_at`, returning the
1154    /// error. The failure can surface either from a write or from shutdown
1155    /// depending on when the rejected request is reaped, so both are checked.
1156    async fn failing_upload(fail_at: FailAt, num_bytes: usize) -> io::Error {
1157        let mut store = LanceObjectStore::memory();
1158        store.inner = Arc::new(FailingUploadStore::new(fail_at));
1159
1160        let mut writer = ObjectWriter::new(&store, &Path::from(FAILING_UPLOAD_PATH))
1161            .await
1162            .unwrap();
1163        let buf = vec![0u8; num_bytes];
1164        match writer.write_all(buf.as_slice()).await {
1165            Err(err) => err,
1166            Ok(()) => AsyncWriteExt::shutdown(&mut writer)
1167                .await
1168                .expect_err("upload should have failed"),
1169        }
1170    }
1171
1172    #[tokio::test]
1173    async fn test_part_upload_failure_reports_upload_context() {
1174        let err = failing_upload(FailAt::PutPart, two_parts()).await;
1175        let message = err.to_string();
1176
1177        assert!(
1178            message.contains("multipart upload of part"),
1179            "should name the failing stage: {message}"
1180        );
1181        assert!(
1182            message.contains(FAILING_UPLOAD_PATH),
1183            "should name the object: {message}"
1184        );
1185        assert!(
1186            message.contains(&format!("{} bytes", initial_upload_size())),
1187            "should report the body size: {message}"
1188        );
1189        assert!(
1190            message.contains(&format!("part_size={} bytes", initial_upload_size())),
1191            "should report the part size in effect: {message}"
1192        );
1193        assert!(
1194            message.contains("parts_in_flight="),
1195            "should report upload concurrency in use: {message}"
1196        );
1197        assert!(
1198            message.contains("LANCE_INITIAL_UPLOAD_SIZE")
1199                && message.contains("LANCE_UPLOAD_CONCURRENCY"),
1200            "should report the knobs governing the request: {message}"
1201        );
1202        assert!(
1203            message.contains("part rejected by test"),
1204            "should keep the underlying object store error: {message}"
1205        );
1206    }
1207
1208    // The elapsed time is the whole point of the added context: it is what tells
1209    // a slow request apart from one whose task was never polled.
1210    #[tokio::test]
1211    async fn test_part_upload_failure_reports_elapsed_time() {
1212        let err = failing_upload(FailAt::PutPart, two_parts()).await;
1213        let message = err.to_string();
1214        assert!(
1215            message.contains("failed after"),
1216            "should report how long the request took: {message}"
1217        );
1218    }
1219
1220    // `part_size` in the failure message is the live buffer capacity, which is
1221    // what makes it report the size actually in effect. The part size grows
1222    // every 100 parts, so on a long upload that diverges from
1223    // LANCE_INITIAL_UPLOAD_SIZE by a multiple; reporting only the configured
1224    // value would understate a late part by that factor. Reaching part 100
1225    // through the writer would mean allocating hundreds of MiB, so the growth
1226    // is asserted on the buffer the message reads from.
1227    #[test]
1228    fn test_part_buffer_capacity_tracks_grown_part_size() {
1229        let mut buffer = Vec::<u8>::with_capacity(initial_upload_size());
1230        assert_eq!(buffer.capacity(), initial_upload_size());
1231
1232        let _ = ObjectWriter::next_part_buffer(&mut buffer, 0, false);
1233        assert_eq!(
1234            buffer.capacity(),
1235            initial_upload_size(),
1236            "early parts stay at the configured size"
1237        );
1238
1239        let _ = ObjectWriter::next_part_buffer(&mut buffer, 100, false);
1240        assert_eq!(
1241            buffer.capacity(),
1242            initial_upload_size().max(2 * INITIAL_UPLOAD_STEP),
1243            "the part size has grown past the first step"
1244        );
1245
1246        // A store pinned to constant part sizes never grows, so the reported
1247        // size stays equal to the configured one.
1248        let _ = ObjectWriter::next_part_buffer(&mut buffer, 100, true);
1249        assert_eq!(buffer.capacity(), initial_upload_size());
1250    }
1251
1252    #[tokio::test]
1253    async fn test_part_upload_failure_preserves_source_chain() {
1254        let err = failing_upload(FailAt::PutPart, two_parts()).await;
1255
1256        let failure = err
1257            .get_ref()
1258            .expect("io error should carry the upload failure");
1259        let source = std::error::Error::source(failure)
1260            .expect("upload failure should expose the object store error");
1261        assert!(
1262            source.downcast_ref::<object_store::Error>().is_some(),
1263            "source should still be the object store error, got: {source}"
1264        );
1265    }
1266
1267    /// Adding context must not flatten the `io::ErrorKind` that `object_store`
1268    /// maps an error to, since that kind is observable to callers.
1269    #[test]
1270    fn test_upload_failure_preserves_error_kind() {
1271        fn not_found() -> object_store::Error {
1272            object_store::Error::NotFound {
1273                path: FAILING_UPLOAD_PATH.to_string(),
1274                source: "not found".into(),
1275            }
1276        }
1277
1278        let unwrapped = io::Error::from(not_found());
1279        assert_eq!(unwrapped.kind(), io::ErrorKind::NotFound);
1280
1281        let wrapped =
1282            UploadFailure::new("part upload failed".to_string(), not_found()).into_io_error();
1283        assert_eq!(wrapped.kind(), unwrapped.kind());
1284    }
1285
1286    /// `Writer::shutdown` is the public boundary most callers see. The object
1287    /// store error has to remain reachable through it, not be flattened into a
1288    /// message.
1289    #[tokio::test]
1290    async fn test_writer_shutdown_preserves_object_store_source() {
1291        let mut store = LanceObjectStore::memory();
1292        store.inner = Arc::new(FailingUploadStore::new(FailAt::SinglePut));
1293        let mut writer = ObjectWriter::new(&store, &Path::from(FAILING_UPLOAD_PATH))
1294            .await
1295            .unwrap();
1296        writer.write_all(&[0u8; 256]).await.unwrap();
1297        let err = Writer::shutdown(&mut writer).await.unwrap_err();
1298
1299        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&err);
1300        let mut found_object_store = false;
1301        while let Some(source) = current {
1302            if source.downcast_ref::<object_store::Error>().is_some() {
1303                found_object_store = true;
1304                break;
1305            }
1306            current = source.source();
1307        }
1308        assert!(found_object_store, "source chain was flattened: {err:?}");
1309
1310        assert!(
1311            err.to_string().contains(FAILING_UPLOAD_PATH),
1312            "should still name the object: {err}"
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn test_create_multipart_failure_reports_upload_context() {
1318        let err = failing_upload(FailAt::CreateMultipart, two_parts()).await;
1319        let message = err.to_string();
1320
1321        assert!(
1322            message.contains("failed to create multipart upload for"),
1323            "should name the failing stage: {message}"
1324        );
1325        assert!(
1326            message.contains(FAILING_UPLOAD_PATH),
1327            "should name the object: {message}"
1328        );
1329        assert!(
1330            message.contains("create multipart rejected by test"),
1331            "should keep the underlying object store error: {message}"
1332        );
1333    }
1334
1335    #[tokio::test]
1336    async fn test_complete_multipart_failure_reports_upload_context() {
1337        let num_bytes = two_parts();
1338        let err = failing_upload(FailAt::Complete, num_bytes).await;
1339        let message = err.to_string();
1340
1341        assert!(
1342            message.contains("completing multipart upload of"),
1343            "should name the failing stage: {message}"
1344        );
1345        assert!(
1346            message.contains(&format!("{num_bytes} bytes")),
1347            "should report how much had been written: {message}"
1348        );
1349        assert!(
1350            message.contains("complete rejected by test"),
1351            "should keep the underlying object store error: {message}"
1352        );
1353    }
1354
1355    #[tokio::test]
1356    async fn test_single_put_failure_reports_upload_context() {
1357        // Below the multipart threshold, so shutdown takes the single-PUT path.
1358        let err = failing_upload(FailAt::SinglePut, 256).await;
1359        let message = err.to_string();
1360
1361        assert!(
1362            message.contains("single PUT of"),
1363            "should name the failing stage: {message}"
1364        );
1365        assert!(
1366            message.contains(FAILING_UPLOAD_PATH),
1367            "should name the object: {message}"
1368        );
1369        assert!(
1370            message.contains("256 bytes"),
1371            "should report the body size: {message}"
1372        );
1373        assert!(
1374            message.contains("single put rejected by test"),
1375            "should keep the underlying object store error: {message}"
1376        );
1377    }
1378
1379    /// Released permits, comfortably above the part count of any test here so
1380    /// no test depends on the exact number of parts a payload produces.
1381    const GATE_RELEASE: usize = 64;
1382
1383    /// How long a flush is allowed to take before it counts as waiting. A flush
1384    /// that does not wait resolves immediately; this bound only has to be short
1385    /// of the test harness timeout.
1386    const FLUSH_BOUND: Duration = Duration::from_secs(10);
1387
1388    /// Blocks until a part upload has begun, so the assertions that follow are
1389    /// made against a request that is genuinely in flight.
1390    async fn await_part_in_flight(observations: &UploadObservations) {
1391        tokio::time::timeout(FLUSH_BOUND, observations.started.acquire())
1392            .await
1393            .expect("a part upload should have started")
1394            .unwrap()
1395            .forget();
1396    }
1397
1398    #[tokio::test]
1399    async fn test_flush_does_not_wait_for_in_flight_parts() {
1400        let (store, gate) = FailingUploadStore::gated(FailAt::Nothing);
1401        let observations = store.observations.clone();
1402        let mut lance_store = LanceObjectStore::memory();
1403        lance_store.inner = Arc::new(store);
1404
1405        let mut writer = ObjectWriter::new(&lance_store, &Path::from("gated.lance"))
1406            .await
1407            .unwrap();
1408        // Distinct bytes so a part landing out of order is detectable.
1409        let payload = (0..two_parts()).map(|i| i as u8).collect::<Vec<_>>();
1410        writer.write_all(payload.as_slice()).await.unwrap();
1411        await_part_in_flight(&observations).await;
1412
1413        tokio::time::timeout(FLUSH_BOUND, AsyncWriteExt::flush(&mut writer))
1414            .await
1415            .expect("flush must not wait for in-flight part uploads")
1416            .unwrap();
1417
1418        assert!(
1419            observations.parts.lock().unwrap().is_empty(),
1420            "no gated part may have completed before the gate opened"
1421        );
1422
1423        gate.add_permits(GATE_RELEASE);
1424        let result = Writer::shutdown(&mut writer).await.unwrap();
1425        assert_eq!(result.size, payload.len());
1426
1427        let mut parts = observations.parts.lock().unwrap().clone();
1428        parts.sort_by_key(|(part_idx, _)| *part_idx);
1429        let assembled = parts
1430            .into_iter()
1431            .flat_map(|(_, body)| body)
1432            .collect::<Vec<_>>();
1433        assert_eq!(
1434            assembled, payload,
1435            "parts must reassemble into the original bytes"
1436        );
1437    }
1438
1439    #[tokio::test]
1440    async fn test_part_failure_after_flush_surfaces_at_shutdown() {
1441        let (store, gate) = FailingUploadStore::gated(FailAt::PutPart);
1442        let observations = store.observations.clone();
1443        let mut lance_store = LanceObjectStore::memory();
1444        lance_store.inner = Arc::new(store);
1445
1446        let mut writer = ObjectWriter::new(&lance_store, &Path::from(FAILING_UPLOAD_PATH))
1447            .await
1448            .unwrap();
1449        writer
1450            .write_all(vec![0u8; two_parts()].as_slice())
1451            .await
1452            .unwrap();
1453        await_part_in_flight(&observations).await;
1454        // The parts are still gated, so nothing has failed yet and flush passes.
1455        AsyncWriteExt::flush(&mut writer).await.unwrap();
1456
1457        // Now let them fail. Shutdown is the first place that can report it, so
1458        // no longer waiting in flush must not lose the error.
1459        gate.add_permits(GATE_RELEASE);
1460        let err = AsyncWriteExt::shutdown(&mut writer)
1461            .await
1462            .expect_err("a failed part upload must still surface");
1463        let message = err.to_string();
1464        assert!(
1465            message.contains(FAILING_UPLOAD_PATH),
1466            "should name the object being written: {message}"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_zero_upload_parallelism_rejected() {
1472        let store = LanceObjectStore::memory();
1473        let path = Path::from("zero-concurrency.lance");
1474        let error = ObjectWriter::with_upload_parallelism(&store, &path, 0)
1475            .err()
1476            .expect("zero concurrency must be rejected");
1477
1478        assert!(matches!(error, Error::InvalidInput { .. }));
1479        assert!(error.to_string().contains("LANCE_UPLOAD_CONCURRENCY"));
1480        assert!(error.to_string().contains("got 0"));
1481    }
1482
1483    #[rstest]
1484    #[case::empty(0, 0)]
1485    #[case::below_boundary(1, -1)]
1486    #[case::at_boundary(1, 0)]
1487    #[case::above_boundary(1, 16)]
1488    #[case::multipart_tail(2, 1)]
1489    #[tokio::test]
1490    async fn test_upload_parallelism_round_trip(
1491        #[case] full_parts: usize,
1492        #[case] tail: isize,
1493        #[values(Some(1), None)] upload_parallelism: Option<usize>,
1494    ) {
1495        let store = LanceObjectStore::memory();
1496        let path = Path::from("round-trip.lance");
1497        let mut writer = match upload_parallelism {
1498            Some(upload_parallelism) => {
1499                ObjectWriter::with_upload_parallelism(&store, &path, upload_parallelism).unwrap()
1500            }
1501            None => ObjectWriter::new(&store, &path).await.unwrap(),
1502        };
1503        let size = (initial_upload_size() * full_parts)
1504            .checked_add_signed(tail)
1505            .unwrap();
1506        let payload = (0..size).map(|i| i as u8).collect::<Vec<_>>();
1507
1508        let result = tokio::time::timeout(Duration::from_millis(500), async {
1509            writer.write_all(&payload).await.unwrap();
1510            Writer::shutdown(&mut writer).await.unwrap()
1511        })
1512        .await
1513        .expect("valid concurrency must not stall writes or shutdown");
1514        assert_eq!(result.size, payload.len());
1515        let persisted = store.inner.get(&path).await.unwrap().bytes().await.unwrap();
1516        assert_eq!(persisted.as_ref(), payload.as_slice());
1517    }
1518
1519    #[tokio::test]
1520    async fn test_shutdown_waits_for_tail_at_concurrency_one() {
1521        let (store, gate) = FailingUploadStore::gated(FailAt::Nothing);
1522        let observations = store.observations.clone();
1523        let mut lance_store = LanceObjectStore::memory();
1524        lance_store.inner = Arc::new(store);
1525        let mut writer =
1526            ObjectWriter::with_upload_parallelism(&lance_store, &Path::from("gated-tail.lance"), 1)
1527                .unwrap();
1528        let payload = (0..initial_upload_size() + 16)
1529            .map(|i| i as u8)
1530            .collect::<Vec<_>>();
1531        writer.write_all(&payload).await.unwrap();
1532        await_part_in_flight(&observations).await;
1533
1534        assert!(futures::poll!(Box::pin(AsyncWriteExt::shutdown(&mut writer))).is_pending());
1535        assert_eq!(writer.buffer.len(), 16);
1536        assert!(matches!(writer.state, UploadState::InProgress { .. }));
1537
1538        gate.add_permits(GATE_RELEASE);
1539        let result = Writer::shutdown(&mut writer).await.unwrap();
1540        assert_eq!(result.size, payload.len());
1541        let mut parts = observations.parts.lock().unwrap().clone();
1542        parts.sort_by_key(|(part_idx, _)| *part_idx);
1543        assert_eq!(parts.len(), 2);
1544        let assembled = parts
1545            .into_iter()
1546            .flat_map(|(_, body)| body)
1547            .collect::<Vec<_>>();
1548        assert_eq!(assembled, payload);
1549    }
1550
1551    #[tokio::test]
1552    async fn test_write() {
1553        let store = LanceObjectStore::memory();
1554
1555        let mut object_writer = ObjectWriter::new(&store, &Path::from("/foo"))
1556            .await
1557            .unwrap();
1558        assert_eq!(object_writer.tell().await.unwrap(), 0);
1559
1560        let buf = vec![0; 256];
1561        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
1562        assert_eq!(object_writer.tell().await.unwrap(), 256);
1563
1564        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
1565        assert_eq!(object_writer.tell().await.unwrap(), 512);
1566
1567        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
1568        assert_eq!(object_writer.tell().await.unwrap(), 256 * 3);
1569
1570        let res = Writer::shutdown(&mut object_writer).await.unwrap();
1571        assert_eq!(res.size, 256 * 3);
1572
1573        // Trigger multi part upload
1574        let mut object_writer = ObjectWriter::new(&store, &Path::from("/bar"))
1575            .await
1576            .unwrap();
1577        let buf = vec![0; INITIAL_UPLOAD_STEP / 3 * 2];
1578        for i in 0..5 {
1579            // Write more data to trigger the multipart upload
1580            // This should be enough to trigger a multipart upload
1581            object_writer.write_all(buf.as_slice()).await.unwrap();
1582            // Check the cursor
1583            assert_eq!(object_writer.tell().await.unwrap(), (i + 1) * buf.len());
1584        }
1585        let res = Writer::shutdown(&mut object_writer).await.unwrap();
1586        assert_eq!(res.size, buf.len() * 5);
1587    }
1588
1589    #[tokio::test]
1590    async fn test_abort_write() {
1591        let store = LanceObjectStore::memory();
1592
1593        let mut object_writer = ObjectWriter::new(&store, &Path::from("/foo"))
1594            .await
1595            .unwrap();
1596        object_writer.abort().await;
1597    }
1598
1599    #[tokio::test]
1600    async fn test_local_writer_shutdown() {
1601        let tmp = lance_core::utils::tempfile::TempStdDir::default();
1602        let file_path = tmp.join("test_local_writer.bin");
1603        let os_path = Path::from_absolute_path(&file_path).unwrap();
1604        let io_tracker = Arc::new(IOTracker::default());
1605
1606        let named_temp = tempfile::NamedTempFile::new_in(&*tmp).unwrap();
1607        let temp_file_path = named_temp.path().to_owned();
1608        let (std_file, temp_path) = named_temp.into_parts();
1609        let file = tokio::fs::File::from_std(std_file);
1610        let mut writer = LocalWriter::new(file, os_path, temp_path, io_tracker.clone());
1611
1612        let data = b"hello local writer";
1613        writer.write_all(data).await.unwrap();
1614
1615        // Before shutdown, the final path should not exist
1616        assert!(!file_path.exists());
1617        // But the temp file should exist
1618        assert!(temp_file_path.exists());
1619
1620        let result = Writer::shutdown(&mut writer).await.unwrap();
1621        assert_eq!(result.size, data.len());
1622        assert!(result.e_tag.is_some());
1623        assert!(!result.e_tag.as_ref().unwrap().is_empty());
1624
1625        // After shutdown, the final path should exist and temp should be gone
1626        assert!(file_path.exists());
1627        assert!(!temp_file_path.exists());
1628
1629        let stats = io_tracker.stats();
1630        assert_eq!(stats.write_iops, 1);
1631        assert_eq!(stats.written_bytes, data.len() as u64);
1632    }
1633
1634    #[tokio::test]
1635    async fn test_local_writer_drop_cleans_up() {
1636        let tmp = lance_core::utils::tempfile::TempStdDir::default();
1637        let file_path = tmp.join("test_drop.bin");
1638        let os_path = Path::from_absolute_path(&file_path).unwrap();
1639        let io_tracker = Arc::new(IOTracker::default());
1640
1641        let named_temp = tempfile::NamedTempFile::new_in(&*tmp).unwrap();
1642        let temp_file_path = named_temp.path().to_owned();
1643        let (std_file, temp_path) = named_temp.into_parts();
1644        let file = tokio::fs::File::from_std(std_file);
1645        let mut writer = LocalWriter::new(file, os_path, temp_path, io_tracker);
1646
1647        writer.write_all(b"some data").await.unwrap();
1648        assert!(temp_file_path.exists());
1649
1650        // Drop without shutdown should clean up the temp file
1651        drop(writer);
1652        assert!(!temp_file_path.exists());
1653        assert!(!file_path.exists());
1654    }
1655
1656    #[test]
1657    fn clamp_initial_upload_size_below_min_is_clamped_up() {
1658        assert_eq!(clamp_initial_upload_size(0), (INITIAL_UPLOAD_STEP, true));
1659        assert_eq!(
1660            clamp_initial_upload_size(INITIAL_UPLOAD_STEP - 1),
1661            (INITIAL_UPLOAD_STEP, true)
1662        );
1663    }
1664
1665    #[test]
1666    fn clamp_initial_upload_size_within_range_is_unchanged() {
1667        assert_eq!(
1668            clamp_initial_upload_size(INITIAL_UPLOAD_STEP),
1669            (INITIAL_UPLOAD_STEP, false)
1670        );
1671        assert_eq!(
1672            clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE),
1673            (MAX_UPLOAD_PART_SIZE, false)
1674        );
1675        let mid = INITIAL_UPLOAD_STEP * 8; // 40MB, in range
1676        assert_eq!(clamp_initial_upload_size(mid), (mid, false));
1677    }
1678
1679    #[test]
1680    fn clamp_initial_upload_size_above_max_is_clamped_down() {
1681        assert_eq!(
1682            clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE + 1),
1683            (MAX_UPLOAD_PART_SIZE, true)
1684        );
1685        assert_eq!(
1686            clamp_initial_upload_size(usize::MAX),
1687            (MAX_UPLOAD_PART_SIZE, true)
1688        );
1689    }
1690
1691    /// Regression for the foot-gun where `LANCE_INITIAL_UPLOAD_SIZE=5368709120`
1692    /// (exactly 5 GiB, Pucheng's setting) caused a single-PUT of 5 GiB on
1693    /// shutdown — which S3 rejects with `EntityTooLarge`. After tightening
1694    /// `MAX_UPLOAD_PART_SIZE` to 5 GiB - 1, raw 5 GiB must clamp DOWN.
1695    #[test]
1696    fn clamp_initial_upload_size_at_5gib_clamps_down() {
1697        let exactly_5_gib: usize = 5 * 1024 * 1024 * 1024;
1698        assert_eq!(
1699            clamp_initial_upload_size(exactly_5_gib),
1700            (MAX_UPLOAD_PART_SIZE, true)
1701        );
1702    }
1703}