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