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