Skip to content

BufWriter panic #810

Open

Description

@logo306142054

Describe the bug

ObjectStore version: 0.13.2

I build a parquet::arrow::AsyncArrowWriter with BufWriter to write parquet file, and after the write method returned an error, I accidentally proceeded to call writer.close(), which triggered a panic error inside BufWriter.

The critical panic call stack is as follows:

thread 'writer::test::test::test_async_arrow_writer_close_after_write_error_panics' (198483) panicked at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/object_store-0.13.2/src/buffered.rs:407:71:
`async fn` resumed after completion
stack backtrace:
   0: __rustc::rust_begin_unwind
             at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/std/src/panicking.rs:689:5
   1: core::panicking::panic_fmt
             at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/panicking.rs:80:14
   2: core::panicking::panic_const::panic_const_async_fn_resumed
             at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/panicking.rs:175:17
   3: <object_store::buffered::BufWriter as tokio::io::async_write::AsyncWrite>::poll_write::{{closure}}
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/object_store-0.13.2/src/buffered.rs:407:71
   4: <core::pin::Pin<P> as core::future::future::Future>::poll
             at /home/xxx/.rust/toolchains/1.96.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/future/future.rs:133:9
   5: futures_util::future::future::FutureExt::poll_unpin
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.32/src/future/future/mod.rs:556:24
   6: <object_store::buffered::BufWriter as tokio::io::async_write::AsyncWrite>::poll_write
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/object_store-0.13.2/src/buffered.rs:394:65
   7: <&mut T as tokio::io::async_write::AsyncWrite>::poll_write
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/io/async_write.rs:186:35
   8: <tokio::io::util::write_all::WriteAll<W> as core::future::future::Future>::poll
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/io/util/write_all.rs:43:54
   9: <T as parquet::arrow::async_writer::AsyncFileWriter>::write::{{closure}}
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/parquet-58.3.0/src/arrow/async_writer/mod.rs:109:33
  10: <core::pin::Pin<P> as core::future::future::Future>::poll
             at /home/xxx/.rust/toolchains/1.96.0-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/future/future.rs:133:9
  11: parquet::arrow::async_writer::AsyncArrowWriter<W>::do_write::{{closure}}
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/parquet-58.3.0/src/arrow/async_writer/mod.rs:288:14
  12: parquet::arrow::async_writer::AsyncArrowWriter<W>::write::{{closure}}
             at /home/xxx/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/parquet-58.3.0/src/arrow/async_writer/mod.rs:223:29

Similar panic errors have been reported before #414

To Reproduce

Here are two test cases that can stably reproduce this panic

#[cfg(test)]
mod test {
    use std::sync::Arc;

    use arrow_array::{Int64Array, RecordBatch};
    use object_store::Error;
    use object_store::buffered::BufWriter;
    use object_store::local::LocalFileSystem;
    use object_store::path::Path;
    use object_store::{
        CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore,
        PutMultipartOptions, PutOptions, PutPayload, PutResult, Result,
    };
    use arrow_schema::{DataType, Field, Schema};
    use bytes::Bytes;
    use futures_util::stream::BoxStream;
    use parquet::{
        arrow::AsyncArrowWriter, basic::Compression, file::properties::WriterProperties,
    };
    use tokio::io::AsyncWriteExt;

    #[derive(Debug)]
    struct FailMultipartInitStore {
        inner: Arc<LocalFileSystem>,
    }

    impl FailMultipartInitStore {
        fn new(inner: Arc<LocalFileSystem>) -> Self {
            Self { inner }
        }
    }

    impl std::fmt::Display for FailMultipartInitStore {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "FailMultipartInitStore")
        }
    }

    #[async_trait::async_trait]
    impl ObjectStore for FailMultipartInitStore {
        async fn put_opts(
            &self,
            location: &Path,
            payload: PutPayload,
            opts: PutOptions,
        ) -> Result<PutResult> {
            self.inner.put_opts(location, payload, opts).await
        }

        async fn put_multipart_opts(
            &self,
            _location: &Path,
            _opts: PutMultipartOptions,
        ) -> Result<Box<dyn MultipartUpload>> {
            Err(Error::Generic {
                store: "FailMultipartInitStore",
                source: Box::new(std::io::Error::other("StorageFull")),
            })
        }

        async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> {
            self.inner.get_opts(location, options).await
        }

        fn delete_stream(
            &self,
            locations: BoxStream<'static, Result<Path>>,
        ) -> BoxStream<'static, Result<Path>> {
            self.inner.delete_stream(locations)
        }

        fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> {
            self.inner.list(prefix)
        }

        async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> {
            self.inner.list_with_delimiter(prefix).await
        }

        async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> Result<()> {
            self.inner.copy_opts(from, to, options).await
        }
    }

    #[tokio::test]
    #[should_panic(expected = "`async fn` resumed after completion")]
    async fn test_buf_writer_shutdown_after_write_error_panics() {
        let dir = tempfile::tempdir().unwrap();
        let dir = dir.path().to_str().unwrap();
        let inner = Arc::new(LocalFileSystem::new_with_prefix(dir).unwrap());
        let store = Arc::new(FailMultipartInitStore::new(inner));
        let path = Path::from("panic-repro.parquet");
        let mut writer = BufWriter::with_capacity(store, path, 1);

        let err = writer
            .write_all_buf(&mut Bytes::from_static(b"trigger-prepare-error"))
            .await;
        assert!(
            err.is_err(),
            "expected write_all_buf to fail before shutdown"
        );

        let _ = writer.shutdown().await;
    }

    #[tokio::test]
    #[should_panic(expected = "`async fn` resumed after completion")]
    async fn test_async_arrow_writer_close_after_write_error_panics() {
        let dir = tempfile::tempdir().unwrap();
        let dir = dir.path().to_str().unwrap();
        let inner = Arc::new(LocalFileSystem::new_with_prefix(dir).unwrap());
        let store = Arc::new(FailMultipartInitStore::new(inner));
        let path = Path::from("panic-repro-arrow-writer.parquet");
        let buf_writer = BufWriter::with_capacity(store, path, 1);

        let schema = Arc::new(Schema::new(vec![Field::new(
            "value",
            DataType::Int64,
            false,
        )]));
        let writer_props = WriterProperties::builder()
            .set_compression(Compression::UNCOMPRESSED)
            .set_max_row_group_row_count(Some(1))
            .build();
        let mut writer =
            AsyncArrowWriter::try_new(buf_writer, schema.clone(), Some(writer_props)).unwrap();

        let values = Int64Array::from_iter_values([1_i64]);
        let batch = RecordBatch::try_new(schema, vec![Arc::new(values)]).unwrap();

        for _ in 0..100_000 {
            let _ = writer.write(&batch).await;
        }

        let _ = writer.close().await;
    }
}

Expected behavior

I believe that even when an error has already occurred, calling the shutdown or close method should return an error instead of triggering a panic.

Additional context

Activity

  1. hindriix commented on Jul 25, 2026

    @hindriix

    Opened #816 to fix this. Root cause: when the future backing BufWriterState::Prepare/Flush resolves to an error, the completed future was left in self.state and re-polled on the next call, panicking with "async fn resumed after completion". The PR adds a terminal Errored state so subsequent write/flush/shutdown/put calls return an error instead of panicking. Thanks for the clear minimal repro — I adapted it into a regression test.

  2. arbelonson-source commented on Aug 31, 2026

    @arbelonson-source

    Traced this — happy to open a PR if you're okay with it. Disclosure up front: I run agent loops (Claude Code) that find and fix issues like this, so this diagnosis and fix are AI-produced, not something I personally read the code for. Asking before opening a PR rather than doing it unprompted. (I checked the ASF's generative-tooling guidance before starting — following its recommended Generated-by: commit trailer below.)

    Root cause: poll_write, poll_flush, poll_shutdown (and the plain-async put()) all handle the Prepare/Flush state's inner future the same way — on Ok, transition state and continue; on Err, propagate via ? inside ready!(...) (or .await?) without ever updating self.state. Since ? short-circuits before the state assignment runs, self.state is left holding the exact same future that just returned Ready. Call write/flush/shutdown again afterward — as your repro's .close() after a failed .write() does — and that same, already-completed future gets polled a second time, which is exactly what panics with `async fn` resumed after completion instead of erroring.

    Verified this matches your report precisely: reproducing your two test cases against current main panics at src/buffered.rs:407:71 — the exact file, line, and column your original report gave against 0.13.2, confirming this hasn't shifted since you filed it.

    Fix: added a terminal BufWriterState::Errored variant. Every place that used to propagate a Prepare/Flush error via ? now instead transitions into Errored and returns the error explicitly, and every state-matching function (poll_write, poll_flush, poll_shutdown, put, abort) has a corresponding Errored arm. abort() treats Errored the same as Buffer/Prepare (returns Ok(())), since a failed Prepare/Flush future never durably created anything remotely (no multipart upload, no partial single-shot object) that needs cleaning up — flagging that specific choice in case you'd rather it behave differently.

    Verified: added 3 regression tests using your own FailMultipartInitStore repro pattern (shutdown-after-error, flush-after-error, abort-after-error) — confirmed all three that exercise the bug panic on unmodified main with your exact reported message/location, and pass cleanly with the fix. abort()'s test passes on main too, since that path already handled this gracefully before my change (only needed a new match arm for exhaustiveness once Errored exists). Full cargo test --lib: 104 passed, 0 failed. cargo clippy --lib -- -D warnings and cargo fmt --check both clean. (The workspace's --all-targets clippy run hits 4 pre-existing, unrelated unused_macros/unused_imports errors in src/lib.rs's integration-test macros — confirmed identical on unmodified main, so scoped my checks to --lib to avoid that noise.)

    Diff, if useful before a PR exists: main...arbelonson-source:arrow-rs-object-store:fix/810-bufwriter-panic-after-error

  3. alamb commented on Sep 9, 2026

    @alamb
    Contributor

    Diff, if useful before a PR exists: main...arbelonson-source:arrow-rs-object-store:fix/810-bufwriter-panic-after-error

    Seems reasonable to me -- thank you

  4. added a commit that references this issue on Sep 13, 2026
    f2c274c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions