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;
8
9use crate::object_store::ObjectStore as LanceObjectStore;
10use async_trait::async_trait;
11use bytes::Bytes;
12use futures::FutureExt;
13use futures::future::BoxFuture;
14use object_store::{Error as OSError, ObjectStore, Result as OSResult, path::Path};
15use object_store::{MultipartUpload, ObjectStoreExt};
16use rand::Rng;
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;
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
40fn max_conn_reset_retries() -> u16 {
41    static MAX_CONN_RESET_RETRIES: OnceLock<u16> = OnceLock::new();
42    *MAX_CONN_RESET_RETRIES.get_or_init(|| {
43        std::env::var("LANCE_CONN_RESET_RETRIES")
44            .ok()
45            .and_then(|s| s.parse::<u16>().ok())
46            .unwrap_or(20)
47    })
48}
49
50/// Maximum body size for a single S3 PUT: strictly less than 5 GiB.
51/// AWS rejects single-PUT bodies of exactly 5 GiB (= 5 * 1024^3) with
52/// `EntityTooLarge`, so we clamp `LANCE_INITIAL_UPLOAD_SIZE` one byte
53/// below that threshold to keep the buffer-fills-to-clamp single-PUT
54/// path safe. See lance#6750 for the related txn-file write fix.
55const MAX_UPLOAD_PART_SIZE: usize = 1024 * 1024 * 1024 * 5 - 1;
56
57/// Clamps a requested upload part size to the valid [5MB, 5GB] range.
58/// Returns the clamped value and whether clamping was necessary.
59fn clamp_initial_upload_size(raw: usize) -> (usize, bool) {
60    let clamped = raw.clamp(INITIAL_UPLOAD_STEP, MAX_UPLOAD_PART_SIZE);
61    (clamped, clamped != raw)
62}
63
64fn initial_upload_size() -> usize {
65    static LANCE_INITIAL_UPLOAD_SIZE: OnceLock<usize> = OnceLock::new();
66    *LANCE_INITIAL_UPLOAD_SIZE.get_or_init(|| {
67        let Some(raw) = std::env::var("LANCE_INITIAL_UPLOAD_SIZE")
68            .ok()
69            .and_then(|s| s.parse::<usize>().ok())
70        else {
71            return INITIAL_UPLOAD_STEP;
72        };
73        let (clamped, was_clamped) = clamp_initial_upload_size(raw);
74        if was_clamped {
75            // OnceLock caches the result, so this warning fires at most once per process.
76            tracing::warn!(
77                requested = raw,
78                clamped,
79                "LANCE_INITIAL_UPLOAD_SIZE must be between 5MB and 5GB; clamping to valid range"
80            );
81        }
82        clamped
83    })
84}
85
86/// Writer to an object in an object store.
87///
88/// If the object is small enough, the writer will upload the object in a single
89/// PUT request. If the object is larger, the writer will create a multipart
90/// upload and upload parts in parallel.
91///
92/// This implements the `AsyncWrite` trait.
93pub struct ObjectWriter {
94    state: UploadState,
95    path: Arc<Path>,
96    cursor: usize,
97    connection_resets: u16,
98    buffer: Vec<u8>,
99    // TODO: use constant size to support R2
100    use_constant_size_upload_parts: bool,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct WriteResult {
105    pub size: usize,
106    pub e_tag: Option<String>,
107}
108
109enum UploadState {
110    /// The writer has been opened but no data has been written yet. Will be in
111    /// this state until the buffer is full or the writer is shut down.
112    Started(Arc<dyn ObjectStore>),
113    /// The writer is in the process of creating a multipart upload.
114    CreatingUpload(BoxFuture<'static, OSResult<Box<dyn MultipartUpload>>>),
115    /// The writer is in the process of uploading parts.
116    InProgress {
117        part_idx: u16,
118        upload: Box<dyn MultipartUpload>,
119        futures: JoinSet<std::result::Result<(), UploadPutError>>,
120    },
121    /// The writer is in the process of uploading data in a single PUT request.
122    /// This happens when shutdown is called before the buffer is full.
123    PuttingSingle(BoxFuture<'static, OSResult<WriteResult>>),
124    /// The writer is in the process of completing the multipart upload.
125    Completing(BoxFuture<'static, OSResult<WriteResult>>),
126    /// The writer has been shut down and all data has been written.
127    Done(WriteResult),
128}
129
130/// Methods for state transitions.
131impl UploadState {
132    fn started_to_putting_single(&mut self, path: Arc<Path>, buffer: Vec<u8>) {
133        // To get owned self, we temporarily swap with Done.
134        let this = std::mem::replace(self, Self::Done(WriteResult::default()));
135        *self = match this {
136            Self::Started(store) => {
137                let fut = async move {
138                    let size = buffer.len();
139                    let res = store.put(&path, buffer.into()).await?;
140                    Ok(WriteResult {
141                        size,
142                        e_tag: res.e_tag,
143                    })
144                };
145                Self::PuttingSingle(Box::pin(fut))
146            }
147            _ => unreachable!(),
148        }
149    }
150
151    fn in_progress_to_completing(&mut self) {
152        // To get owned self, we temporarily swap with Done.
153        let this = std::mem::replace(self, Self::Done(WriteResult::default()));
154        *self = match this {
155            Self::InProgress {
156                mut upload,
157                futures,
158                ..
159            } => {
160                debug_assert!(futures.is_empty());
161                let fut = async move {
162                    let res = upload.complete().await?;
163                    Ok(WriteResult {
164                        size: 0, // This will be set properly later.
165                        e_tag: res.e_tag,
166                    })
167                };
168                Self::Completing(Box::pin(fut))
169            }
170            _ => unreachable!(),
171        };
172    }
173}
174
175impl ObjectWriter {
176    pub async fn new(object_store: &LanceObjectStore, path: &Path) -> Result<Self> {
177        Ok(Self {
178            state: UploadState::Started(object_store.inner.clone()),
179            cursor: 0,
180            path: Arc::new(path.clone()),
181            connection_resets: 0,
182            buffer: Vec::with_capacity(initial_upload_size()),
183            use_constant_size_upload_parts: object_store.use_constant_size_upload_parts,
184        })
185    }
186
187    /// Returns the contents of `buffer` as a `Bytes` object and resets `buffer`.
188    /// The new capacity of `buffer` is determined by the current part index.
189    fn next_part_buffer(buffer: &mut Vec<u8>, part_idx: u16, constant_upload_size: bool) -> Bytes {
190        let new_capacity = if constant_upload_size {
191            // The store does not support variable part sizes, so use the initial size.
192            initial_upload_size()
193        } else {
194            // Increase the upload size every 100 parts. This gives maximum part size of 2.5TB.
195            initial_upload_size().max(((part_idx / 100) as usize + 1) * INITIAL_UPLOAD_STEP)
196        };
197        let new_buffer = Vec::with_capacity(new_capacity);
198        let part = std::mem::replace(buffer, new_buffer);
199        Bytes::from(part)
200    }
201
202    fn put_part(
203        upload: &mut dyn MultipartUpload,
204        buffer: Bytes,
205        part_idx: u16,
206        sleep: Option<std::time::Duration>,
207    ) -> BoxFuture<'static, std::result::Result<(), UploadPutError>> {
208        log::debug!(
209            "MultipartUpload submitting part with {} bytes",
210            buffer.len()
211        );
212        let fut = upload.put_part(buffer.clone().into());
213        Box::pin(async move {
214            if let Some(sleep) = sleep {
215                tokio::time::sleep(sleep).await;
216            }
217            fut.await.map_err(|source| UploadPutError {
218                part_idx,
219                buffer,
220                source,
221            })?;
222            Ok(())
223        })
224    }
225
226    fn poll_tasks(
227        mut self: Pin<&mut Self>,
228        cx: &mut std::task::Context<'_>,
229    ) -> std::result::Result<(), io::Error> {
230        let mut_self = &mut *self;
231        loop {
232            match &mut mut_self.state {
233                UploadState::Started(_) | UploadState::Done(_) => break,
234                UploadState::CreatingUpload(fut) => match fut.poll_unpin(cx) {
235                    Poll::Ready(Ok(mut upload)) => {
236                        let mut futures = JoinSet::new();
237
238                        let data = Self::next_part_buffer(
239                            &mut mut_self.buffer,
240                            0,
241                            mut_self.use_constant_size_upload_parts,
242                        );
243                        futures.spawn(Self::put_part(upload.as_mut(), data, 0, None));
244
245                        mut_self.state = UploadState::InProgress {
246                            part_idx: 1, // We just used 0
247                            futures,
248                            upload,
249                        };
250                    }
251                    Poll::Ready(Err(e)) => return Err(std::io::Error::other(e)),
252                    Poll::Pending => break,
253                },
254                UploadState::InProgress {
255                    upload, futures, ..
256                } => {
257                    while let Poll::Ready(Some(res)) = futures.poll_join_next(cx) {
258                        match res {
259                            Ok(Ok(())) => {}
260                            Err(err) => return Err(std::io::Error::other(err)),
261                            Ok(Err(err)) if should_retry_upload_put(&err.source) => {
262                                if mut_self.connection_resets < max_conn_reset_retries() {
263                                    // Retry, but only up to max_conn_reset_retries of them.
264                                    mut_self.connection_resets += 1;
265
266                                    // Resubmit with random jitter
267                                    let sleep_time_ms = rand::rng().random_range(2_000..8_000);
268                                    let sleep_time =
269                                        std::time::Duration::from_millis(sleep_time_ms);
270
271                                    futures.spawn(Self::put_part(
272                                        upload.as_mut(),
273                                        err.buffer,
274                                        err.part_idx,
275                                        Some(sleep_time),
276                                    ));
277                                } else {
278                                    return Err(io::Error::new(
279                                        io::ErrorKind::ConnectionReset,
280                                        Box::new(ConnectionResetError {
281                                            message: format!(
282                                                "Hit max retries ({}) for retryable upload error",
283                                                max_conn_reset_retries()
284                                            ),
285                                            source: Box::new(err.source),
286                                        }),
287                                    ));
288                                }
289                            }
290                            Ok(Err(err)) => return Err(err.source.into()),
291                        }
292                    }
293                    break;
294                }
295                UploadState::PuttingSingle(fut) | UploadState::Completing(fut) => {
296                    match fut.poll_unpin(cx) {
297                        Poll::Ready(Ok(mut res)) => {
298                            res.size = mut_self.cursor;
299                            mut_self.state = UploadState::Done(res)
300                        }
301                        Poll::Ready(Err(e)) => return Err(std::io::Error::other(e)),
302                        Poll::Pending => break,
303                    }
304                }
305            }
306        }
307        Ok(())
308    }
309
310    pub async fn abort(&mut self) {
311        let state = std::mem::replace(&mut self.state, UploadState::Done(WriteResult::default()));
312        if let UploadState::InProgress { mut upload, .. } = state {
313            let _ = upload.abort().await;
314        }
315    }
316}
317
318impl Drop for ObjectWriter {
319    fn drop(&mut self) {
320        // If there is a multipart upload started but not finished, we should abort it.
321        if matches!(self.state, UploadState::InProgress { .. }) {
322            // Take ownership of the state.
323            let state =
324                std::mem::replace(&mut self.state, UploadState::Done(WriteResult::default()));
325            if let UploadState::InProgress { mut upload, .. } = state
326                && let Ok(handle) = Handle::try_current()
327            {
328                handle.spawn(async move {
329                    let _ = upload.abort().await;
330                });
331            }
332        }
333    }
334}
335
336/// Returned error from trying to upload a part.
337/// Has the part_idx and buffer so we can pass
338/// them to the retry logic.
339struct UploadPutError {
340    part_idx: u16,
341    buffer: Bytes,
342    source: OSError,
343}
344
345fn should_retry_upload_put(source: &OSError) -> bool {
346    let OSError::Generic { source, .. } = source else {
347        return false;
348    };
349
350    let message = source.to_string().to_ascii_lowercase();
351    message.contains("connection reset by peer") || message.contains("requesttimeout")
352}
353
354#[derive(Debug)]
355struct ConnectionResetError {
356    message: String,
357    source: Box<dyn std::error::Error + Send + Sync>,
358}
359
360impl std::error::Error for ConnectionResetError {}
361
362impl std::fmt::Display for ConnectionResetError {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        write!(f, "{}: {}", self.message, self.source)
365    }
366}
367
368impl AsyncWrite for ObjectWriter {
369    fn poll_write(
370        mut self: std::pin::Pin<&mut Self>,
371        cx: &mut std::task::Context<'_>,
372        buf: &[u8],
373    ) -> std::task::Poll<std::result::Result<usize, std::io::Error>> {
374        self.as_mut().poll_tasks(cx)?;
375
376        // Fill buffer up to remaining capacity.
377        let remaining_capacity = self.buffer.capacity() - self.buffer.len();
378        let bytes_to_write = std::cmp::min(remaining_capacity, buf.len());
379        self.buffer.extend_from_slice(&buf[..bytes_to_write]);
380        self.cursor += bytes_to_write;
381
382        // Rust needs a little help to borrow self mutably and immutably at the same time
383        // through a Pin.
384        let mut_self = &mut *self;
385
386        // Instantiate next request, if available.
387        if mut_self.buffer.capacity() == mut_self.buffer.len() {
388            match &mut mut_self.state {
389                UploadState::Started(store) => {
390                    let path = mut_self.path.clone();
391                    let store = store.clone();
392                    let fut = Box::pin(async move { store.put_multipart(path.as_ref()).await });
393                    self.state = UploadState::CreatingUpload(fut);
394                }
395                // TODO: Make max concurrency configurable from storage options.
396                UploadState::InProgress {
397                    upload,
398                    part_idx,
399                    futures,
400                    ..
401                } if futures.len() < max_upload_parallelism() => {
402                    let data = Self::next_part_buffer(
403                        &mut mut_self.buffer,
404                        *part_idx,
405                        mut_self.use_constant_size_upload_parts,
406                    );
407                    futures.spawn(
408                        Self::put_part(upload.as_mut(), data, *part_idx, None)
409                            .instrument(tracing::Span::current()),
410                    );
411                    *part_idx += 1;
412                }
413                _ => {}
414            }
415        }
416
417        self.poll_tasks(cx)?;
418
419        match bytes_to_write {
420            0 => Poll::Pending,
421            _ => Poll::Ready(Ok(bytes_to_write)),
422        }
423    }
424
425    fn poll_flush(
426        mut self: std::pin::Pin<&mut Self>,
427        cx: &mut std::task::Context<'_>,
428    ) -> std::task::Poll<std::result::Result<(), std::io::Error>> {
429        self.as_mut().poll_tasks(cx)?;
430
431        match &self.state {
432            UploadState::Started(_) | UploadState::Done(_) => Poll::Ready(Ok(())),
433            UploadState::CreatingUpload(_)
434            | UploadState::Completing(_)
435            | UploadState::PuttingSingle(_) => Poll::Pending,
436            UploadState::InProgress { futures, .. } => {
437                if futures.is_empty() {
438                    Poll::Ready(Ok(()))
439                } else {
440                    Poll::Pending
441                }
442            }
443        }
444    }
445
446    fn poll_shutdown(
447        mut self: std::pin::Pin<&mut Self>,
448        cx: &mut std::task::Context<'_>,
449    ) -> std::task::Poll<std::result::Result<(), std::io::Error>> {
450        loop {
451            self.as_mut().poll_tasks(cx)?;
452
453            // Rust needs a little help to borrow self mutably and immutably at the same time
454            // through a Pin.
455            let mut_self = &mut *self;
456            match &mut mut_self.state {
457                UploadState::Done(_) => return Poll::Ready(Ok(())),
458                UploadState::CreatingUpload(_)
459                | UploadState::PuttingSingle(_)
460                | UploadState::Completing(_) => return Poll::Pending,
461                UploadState::Started(_) => {
462                    // If we didn't start a multipart upload, we can just do a single put.
463                    let part = std::mem::take(&mut mut_self.buffer);
464                    let path = mut_self.path.clone();
465                    self.state.started_to_putting_single(path, part);
466                }
467                UploadState::InProgress {
468                    upload,
469                    futures,
470                    part_idx,
471                } => {
472                    // Flush final batch
473                    if !mut_self.buffer.is_empty() && futures.len() < max_upload_parallelism() {
474                        // We can just use `take` since we don't need the buffer anymore.
475                        let data = Bytes::from(std::mem::take(&mut mut_self.buffer));
476                        futures.spawn(
477                            Self::put_part(upload.as_mut(), data, *part_idx, None)
478                                .instrument(tracing::Span::current()),
479                        );
480                        // We need to go back to beginning of loop to poll the
481                        // new feature and get the waker registered on the ctx.
482                        continue;
483                    }
484
485                    // We handle the transition from in progress to completing here.
486                    if futures.is_empty() {
487                        self.state.in_progress_to_completing();
488                    } else {
489                        return Poll::Pending;
490                    }
491                }
492            }
493        }
494    }
495}
496
497#[async_trait]
498impl Writer for ObjectWriter {
499    async fn tell(&mut self) -> Result<usize> {
500        Ok(self.cursor)
501    }
502
503    async fn shutdown(&mut self) -> Result<WriteResult> {
504        AsyncWriteExt::shutdown(self).await.map_err(|e| {
505            Error::io(format!(
506                "failed to shutdown object writer for {}: {}",
507                self.path, e
508            ))
509        })?;
510        if let UploadState::Done(result) = &self.state {
511            Ok(result.clone())
512        } else {
513            unreachable!()
514        }
515    }
516}
517
518pub struct LocalWriter {
519    path: Path,
520    state: LocalWriteState,
521}
522
523#[derive(Default)]
524enum LocalWriteState {
525    Writing(WritingState),
526    Finishing {
527        size: usize,
528        future: BoxFuture<'static, Result<WriteResult>>,
529    },
530    Done(WriteResult),
531    #[default]
532    Poisoned,
533}
534
535struct WritingState {
536    writer: tokio::io::BufWriter<tokio::fs::File>,
537    cursor: usize,
538    /// Temp path that auto-deletes on drop. Set to `None` after `persist()`.
539    temp_path: tempfile::TempPath,
540    io_tracker: Arc<IOTracker>,
541}
542
543impl LocalWriter {
544    pub fn new(
545        file: tokio::fs::File,
546        path: Path,
547        temp_path: tempfile::TempPath,
548        io_tracker: Arc<IOTracker>,
549    ) -> Self {
550        Self {
551            path,
552            state: LocalWriteState::Writing(WritingState {
553                writer: tokio::io::BufWriter::new(file),
554                cursor: 0,
555                temp_path,
556                io_tracker,
557            }),
558        }
559    }
560
561    fn already_closed_err(path: &Path) -> io::Error {
562        io::Error::other(format!(
563            "cannot write to LocalWriter for {} after shutdown",
564            path
565        ))
566    }
567
568    fn poisoned_err(path: &Path) -> io::Error {
569        io::Error::other(format!("LocalWriter for {} is in poisoned state", path))
570    }
571
572    async fn persist(
573        temp_path: tempfile::TempPath,
574        final_path: Path,
575        size: usize,
576        io_tracker: Arc<IOTracker>,
577    ) -> Result<WriteResult> {
578        let local_path = crate::local::to_local_path(&final_path);
579        let e_tag = tokio::task::spawn_blocking(move || -> Result<String> {
580            temp_path.persist(&local_path).map_err(|e| {
581                Error::io(format!(
582                    "failed to persist temp file to {}: {}",
583                    local_path, e.error
584                ))
585            })?;
586
587            let metadata = std::fs::metadata(&local_path).map_err(|e| {
588                Error::io(format!("failed to read metadata for {}: {}", local_path, e))
589            })?;
590            Ok(get_etag(&metadata))
591        })
592        .await
593        .map_err(|e| Error::io(format!("spawn_blocking failed: {}", e)))??;
594
595        io_tracker.record_write("put", final_path, size as u64);
596
597        Ok(WriteResult {
598            size,
599            e_tag: Some(e_tag),
600        })
601    }
602}
603
604impl AsyncWrite for LocalWriter {
605    fn poll_write(
606        mut self: Pin<&mut Self>,
607        cx: &mut std::task::Context<'_>,
608        buf: &[u8],
609    ) -> Poll<std::result::Result<usize, std::io::Error>> {
610        if let LocalWriteState::Writing(state) = &mut self.state {
611            let poll = Pin::new(&mut state.writer).poll_write(cx, buf);
612            if let Poll::Ready(Ok(n)) = &poll {
613                state.cursor += *n;
614            }
615            poll
616        } else {
617            Poll::Ready(Err(Self::already_closed_err(&self.path)))
618        }
619    }
620
621    fn poll_flush(
622        mut self: Pin<&mut Self>,
623        cx: &mut std::task::Context<'_>,
624    ) -> Poll<std::result::Result<(), std::io::Error>> {
625        if let LocalWriteState::Writing(state) = &mut self.state {
626            Pin::new(&mut state.writer).poll_flush(cx)
627        } else {
628            Poll::Ready(Err(Self::already_closed_err(&self.path)))
629        }
630    }
631
632    fn poll_shutdown(
633        mut self: Pin<&mut Self>,
634        cx: &mut std::task::Context<'_>,
635    ) -> Poll<std::result::Result<(), std::io::Error>> {
636        let mut_self = &mut *self;
637        loop {
638            match &mut mut_self.state {
639                LocalWriteState::Writing(state) => {
640                    if Pin::new(&mut state.writer).poll_shutdown(cx).is_pending() {
641                        return Poll::Pending;
642                    }
643
644                    // Write is complete, we can transition to persisting.
645                    let LocalWriteState::Writing(state) =
646                        std::mem::replace(&mut mut_self.state, LocalWriteState::Poisoned)
647                    else {
648                        unreachable!()
649                    };
650                    let size = state.cursor;
651                    mut_self.state = LocalWriteState::Finishing {
652                        size,
653                        future: Box::pin(Self::persist(
654                            state.temp_path,
655                            mut_self.path.clone(),
656                            size,
657                            state.io_tracker,
658                        )),
659                    };
660                }
661                LocalWriteState::Finishing { future, .. } => match future.poll_unpin(cx) {
662                    Poll::Ready(Ok(result)) => mut_self.state = LocalWriteState::Done(result),
663                    Poll::Ready(Err(e)) => {
664                        return Poll::Ready(Err(io::Error::other(e)));
665                    }
666                    Poll::Pending => return Poll::Pending,
667                },
668                LocalWriteState::Done(_) => return Poll::Ready(Ok(())),
669                LocalWriteState::Poisoned => {
670                    return Poll::Ready(Err(Self::poisoned_err(&self.path)));
671                }
672            }
673        }
674    }
675}
676
677#[async_trait]
678impl Writer for LocalWriter {
679    async fn tell(&mut self) -> Result<usize> {
680        match &mut self.state {
681            LocalWriteState::Writing(state) => Ok(state.cursor),
682            LocalWriteState::Finishing { size, .. } => Ok(*size),
683            LocalWriteState::Done(result) => Ok(result.size),
684            LocalWriteState::Poisoned => Err(Self::poisoned_err(&self.path).into()),
685        }
686    }
687
688    async fn shutdown(&mut self) -> Result<WriteResult> {
689        AsyncWriteExt::shutdown(self).await.map_err(|e| {
690            Error::io(format!(
691                "failed to shutdown local writer for {}: {}",
692                self.path, e
693            ))
694        })?;
695
696        match &self.state {
697            LocalWriteState::Done(result) => Ok(result.clone()),
698            _ => unreachable!(),
699        }
700    }
701}
702
703// Based on object store's implementation.
704pub fn get_etag(metadata: &std::fs::Metadata) -> String {
705    let inode = get_inode(metadata);
706    let size = metadata.len();
707    let mtime = metadata
708        .modified()
709        .ok()
710        .and_then(|mtime| mtime.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
711        .unwrap_or_default()
712        .as_micros();
713
714    // Use an ETag scheme based on that used by many popular HTTP servers
715    // <https://httpd.apache.org/docs/2.2/mod/core.html#fileetag>
716    format!("{inode:x}-{mtime:x}-{size:x}")
717}
718
719#[cfg(unix)]
720fn get_inode(metadata: &std::fs::Metadata) -> u64 {
721    std::os::unix::fs::MetadataExt::ino(metadata)
722}
723
724#[cfg(not(unix))]
725fn get_inode(_metadata: &std::fs::Metadata) -> u64 {
726    0
727}
728
729#[cfg(test)]
730mod tests {
731    use tokio::io::AsyncWriteExt;
732
733    use super::*;
734
735    #[tokio::test]
736    async fn test_write() {
737        let store = LanceObjectStore::memory();
738
739        let mut object_writer = ObjectWriter::new(&store, &Path::from("/foo"))
740            .await
741            .unwrap();
742        assert_eq!(object_writer.tell().await.unwrap(), 0);
743
744        let buf = vec![0; 256];
745        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
746        assert_eq!(object_writer.tell().await.unwrap(), 256);
747
748        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
749        assert_eq!(object_writer.tell().await.unwrap(), 512);
750
751        assert_eq!(object_writer.write(buf.as_slice()).await.unwrap(), 256);
752        assert_eq!(object_writer.tell().await.unwrap(), 256 * 3);
753
754        let res = Writer::shutdown(&mut object_writer).await.unwrap();
755        assert_eq!(res.size, 256 * 3);
756
757        // Trigger multi part upload
758        let mut object_writer = ObjectWriter::new(&store, &Path::from("/bar"))
759            .await
760            .unwrap();
761        let buf = vec![0; INITIAL_UPLOAD_STEP / 3 * 2];
762        for i in 0..5 {
763            // Write more data to trigger the multipart upload
764            // This should be enough to trigger a multipart upload
765            object_writer.write_all(buf.as_slice()).await.unwrap();
766            // Check the cursor
767            assert_eq!(object_writer.tell().await.unwrap(), (i + 1) * buf.len());
768        }
769        let res = Writer::shutdown(&mut object_writer).await.unwrap();
770        assert_eq!(res.size, buf.len() * 5);
771    }
772
773    #[tokio::test]
774    async fn test_abort_write() {
775        let store = LanceObjectStore::memory();
776
777        let mut object_writer = ObjectWriter::new(&store, &Path::from("/foo"))
778            .await
779            .unwrap();
780        object_writer.abort().await;
781    }
782
783    #[tokio::test]
784    async fn test_local_writer_shutdown() {
785        let tmp = lance_core::utils::tempfile::TempStdDir::default();
786        let file_path = tmp.join("test_local_writer.bin");
787        let os_path = Path::from_absolute_path(&file_path).unwrap();
788        let io_tracker = Arc::new(IOTracker::default());
789
790        let named_temp = tempfile::NamedTempFile::new_in(&*tmp).unwrap();
791        let temp_file_path = named_temp.path().to_owned();
792        let (std_file, temp_path) = named_temp.into_parts();
793        let file = tokio::fs::File::from_std(std_file);
794        let mut writer = LocalWriter::new(file, os_path, temp_path, io_tracker.clone());
795
796        let data = b"hello local writer";
797        writer.write_all(data).await.unwrap();
798
799        // Before shutdown, the final path should not exist
800        assert!(!file_path.exists());
801        // But the temp file should exist
802        assert!(temp_file_path.exists());
803
804        let result = Writer::shutdown(&mut writer).await.unwrap();
805        assert_eq!(result.size, data.len());
806        assert!(result.e_tag.is_some());
807        assert!(!result.e_tag.as_ref().unwrap().is_empty());
808
809        // After shutdown, the final path should exist and temp should be gone
810        assert!(file_path.exists());
811        assert!(!temp_file_path.exists());
812
813        let stats = io_tracker.stats();
814        assert_eq!(stats.write_iops, 1);
815        assert_eq!(stats.written_bytes, data.len() as u64);
816    }
817
818    #[tokio::test]
819    async fn test_local_writer_drop_cleans_up() {
820        let tmp = lance_core::utils::tempfile::TempStdDir::default();
821        let file_path = tmp.join("test_drop.bin");
822        let os_path = Path::from_absolute_path(&file_path).unwrap();
823        let io_tracker = Arc::new(IOTracker::default());
824
825        let named_temp = tempfile::NamedTempFile::new_in(&*tmp).unwrap();
826        let temp_file_path = named_temp.path().to_owned();
827        let (std_file, temp_path) = named_temp.into_parts();
828        let file = tokio::fs::File::from_std(std_file);
829        let mut writer = LocalWriter::new(file, os_path, temp_path, io_tracker);
830
831        writer.write_all(b"some data").await.unwrap();
832        assert!(temp_file_path.exists());
833
834        // Drop without shutdown should clean up the temp file
835        drop(writer);
836        assert!(!temp_file_path.exists());
837        assert!(!file_path.exists());
838    }
839
840    #[test]
841    fn clamp_initial_upload_size_below_min_is_clamped_up() {
842        assert_eq!(clamp_initial_upload_size(0), (INITIAL_UPLOAD_STEP, true));
843        assert_eq!(
844            clamp_initial_upload_size(INITIAL_UPLOAD_STEP - 1),
845            (INITIAL_UPLOAD_STEP, true)
846        );
847    }
848
849    #[test]
850    fn clamp_initial_upload_size_within_range_is_unchanged() {
851        assert_eq!(
852            clamp_initial_upload_size(INITIAL_UPLOAD_STEP),
853            (INITIAL_UPLOAD_STEP, false)
854        );
855        assert_eq!(
856            clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE),
857            (MAX_UPLOAD_PART_SIZE, false)
858        );
859        let mid = INITIAL_UPLOAD_STEP * 8; // 40MB, in range
860        assert_eq!(clamp_initial_upload_size(mid), (mid, false));
861    }
862
863    #[test]
864    fn should_retry_upload_put_detects_transient_errors() {
865        let request_timeout = OSError::Generic {
866            store: "S3",
867            source: Box::new(io::Error::other(
868                "Server returned non-2xx status code: 400 Bad Request: \
869                 <Error><Code>RequestTimeout</Code><Message>Your socket connection to the server \
870                 was not read from or written to within the timeout period. Idle connections will \
871                 be closed.</Message></Error>",
872            )),
873        };
874        assert!(should_retry_upload_put(&request_timeout));
875
876        let connection_reset = OSError::Generic {
877            store: "S3",
878            source: Box::new(io::Error::new(
879                io::ErrorKind::ConnectionReset,
880                "connection reset by peer",
881            )),
882        };
883        assert!(should_retry_upload_put(&connection_reset));
884
885        let not_retryable = OSError::Generic {
886            store: "S3",
887            source: Box::new(io::Error::other("access denied")),
888        };
889        assert!(!should_retry_upload_put(&not_retryable));
890    }
891
892    #[test]
893    fn clamp_initial_upload_size_above_max_is_clamped_down() {
894        assert_eq!(
895            clamp_initial_upload_size(MAX_UPLOAD_PART_SIZE + 1),
896            (MAX_UPLOAD_PART_SIZE, true)
897        );
898        assert_eq!(
899            clamp_initial_upload_size(usize::MAX),
900            (MAX_UPLOAD_PART_SIZE, true)
901        );
902    }
903
904    /// Regression for the foot-gun where `LANCE_INITIAL_UPLOAD_SIZE=5368709120`
905    /// (exactly 5 GiB, Pucheng's setting) caused a single-PUT of 5 GiB on
906    /// shutdown — which S3 rejects with `EntityTooLarge`. After tightening
907    /// `MAX_UPLOAD_PART_SIZE` to 5 GiB - 1, raw 5 GiB must clamp DOWN.
908    #[test]
909    fn clamp_initial_upload_size_at_5gib_clamps_down() {
910        let exactly_5_gib: usize = 5 * 1024 * 1024 * 1024;
911        assert_eq!(
912            clamp_initial_upload_size(exactly_5_gib),
913            (MAX_UPLOAD_PART_SIZE, true)
914        );
915    }
916}