Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing more granular Errors using snafu. #913

Merged
merged 2 commits into from
May 30, 2023
Merged

Conversation

gsilvestrin
Copy link
Contributor

@gsilvestrin gsilvestrin commented May 29, 2023

Uses snafu to expose more granular errors in Lance's api. Check out this example to get a feeling about how snafu can be used. For now I only changed some parts of the Dataset::write / Dataset::open functions.

  • If we go on this route, we will have a lot more error types. We can create Error types per modules to avoid poluting the main one
  • I kept the existing Error types to make the transition easier, but had to change them from enum types to struct (that's what makes the PR so big)
  • In the future, replace From<..> with context
  • impl From&lt;Error&gt; for ArrowError is this method used by the duckdb integration? What's the best way to handle an increasing number of Error types?

Closes #885

return Err(Error::IO(
"Attempt to write empty record batches".to_string(),
));
return Err(Error::EmptyDataset);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is an example of how a generic error is replaced with a more granular one.

let test_uri = test_dir.path().to_str().unwrap();
let mut reader: Box<dyn RecordBatchReader> = Box::new(RecordBatchBuffer::empty());
let result = Dataset::write(&mut reader, test_uri, None).await;
assert!(matches!(result.unwrap_err(), Error::EmptyDataset { .. }));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example of how the granular error can be handled

#[snafu(display("Append with different schema: original={original} new={new}"))]
SchemaMismatch { original: Schema, new: Schema },
#[snafu(display("Dataset at path {path} was not found: {source}"))]
DatasetNotFound {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example of how the error is defined

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guidance about what granualirty should we define Error enums?

for example, SchemaMismatch could be a sub-error of Schema. Similar to other Dataset ones to Error::IO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the idea is that each module will declare its own error types - for instance the encoding module might have SchemaMismatch / CorruptedData / ..... Then the dataset.write method can add them as context with using the source column, like Intermediate in this example: https://docs.rs/snafu/latest/snafu/guide/examples/basic/enum.Error.html

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one of the challenges of Rust errors is making them not about where they came from, but what the type of issues was. This is because you either have to use &dyn Error as the source, which makes the error opaque, or create a variant for every possible source.

IMO, ideally we'd have error variants that map closer to what are top-level exceptions in other languages. So all errors about invalid arguments are one variant, all errors about IO failures are another, etc. That will make it easier to map them to proper exceptions in the Node and Python bindings.

For the operations, I think we might wrap them up into another enum for brevity. So I'd imagine the end results looks something like:

enum LanceError {
    #[snafu(display("Invalid input: {source}"))]
    InvalidInput { source: &dyn Error }, // Python: ValueError, Node: RangeError
    #[snafu(display("Operation not allowed: {source}"))]
    InvalidOperation { source: OperationError },
    #[snafu(display("Operation failed due to IO error: {source}"))]
    IOError { source: &dyn Error }, // Python: IOError
    #[snafu(display("Not authenticated or unauthorized: {source}"))]
    AuthError { source: &dyn Error },
}

enum OperationError {
    #[snafu(display("Attempt to write empty record batches"))]
    EmptyDataset,
    #[snafu(display("Dataset already exists: {uri}"))]
    DatasetAlreadyExists { uri: String },
    #[snafu(display("Append with different schema: original={original} new={new}"))]
    SchemaMismatch { original: Schema, new: Schema },
    #[snafu(display("Dataset at path {path} was not found: {source}"))]
    DatasetNotFound {
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea of having some Errors that are reusable (such as InvalidInput), but at the end of the day we do need a single top-level Result / Error type for user APIs - how would that work when we have multiple enums such as LanceError and OperationError?

@gsilvestrin gsilvestrin marked this pull request as ready for review May 29, 2023 23:45
#[snafu(display("Append with different schema: original={original} new={new}"))]
SchemaMismatch { original: Schema, new: Schema },
#[snafu(display("Dataset at path {path} was not found: {source}"))]
DatasetNotFound {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a guidance about what granualirty should we define Error enums?

for example, SchemaMismatch could be a sub-error of Schema. Similar to other Dataset ones to Error::IO.

.as_ref()
.ok_or_else(|| Error::Index("Must specify column".to_string()))?;
let _ = index_type.ok_or_else(|| Error::Index("Must specify index type".to_string()))?;
let col = column.as_ref().ok_or_else(|| Error::Index {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does make more typing than before, can we add a few methods, such as

impl Error {
   fn index(msg: &str) -> Self {}
}

and etc. Otherwise need to check out the internal structure of each Error type everytime.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think for common cases that would be good. To handle strings maybe

impl Error {
  fn index(msg: impl From<String>) -> Self { ... }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing Error::Index will be deprecated - we won't write new code that uses it. The snafu way is to define a new Error:

#[snafu(display("Must specify index type"))]
MissingIndexType,

And then add context in the actual function

let _ = index_type.context(MissingIndexType);

Or using the ensure! macro (could replace most panics in our code)

ensure!(index_type.is_some() , MissingIndexType);

But making all this changes right now takes time - that's why I implemented Error::Index this way

Comment on lines +239 to +242
object_store::Error::NotFound { path: _, source } => Error::DatasetNotFound {
path: base_path.to_string(),
source,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +245 to +247
return Err(Error::IO {
message: "Cannot create FragmentReader with zero readers".to_string(),
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make an error Error::InvalidInput for these kind of validation errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so - I would like to keep this PR focused on the few use cases I have for Dataset, the PR is already pretty big :)

.as_ref()
.ok_or_else(|| Error::Index("Must specify column".to_string()))?;
let _ = index_type.ok_or_else(|| Error::Index("Must specify index type".to_string()))?;
let col = column.as_ref().ok_or_else(|| Error::Index {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think for common cases that would be good. To handle strings maybe

impl Error {
  fn index(msg: impl From<String>) -> Self { ... }
}

#[snafu(display("Append with different schema: original={original} new={new}"))]
SchemaMismatch { original: Schema, new: Schema },
#[snafu(display("Dataset at path {path} was not found: {source}"))]
DatasetNotFound {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one of the challenges of Rust errors is making them not about where they came from, but what the type of issues was. This is because you either have to use &dyn Error as the source, which makes the error opaque, or create a variant for every possible source.

IMO, ideally we'd have error variants that map closer to what are top-level exceptions in other languages. So all errors about invalid arguments are one variant, all errors about IO failures are another, etc. That will make it easier to map them to proper exceptions in the Node and Python bindings.

For the operations, I think we might wrap them up into another enum for brevity. So I'd imagine the end results looks something like:

enum LanceError {
    #[snafu(display("Invalid input: {source}"))]
    InvalidInput { source: &dyn Error }, // Python: ValueError, Node: RangeError
    #[snafu(display("Operation not allowed: {source}"))]
    InvalidOperation { source: OperationError },
    #[snafu(display("Operation failed due to IO error: {source}"))]
    IOError { source: &dyn Error }, // Python: IOError
    #[snafu(display("Not authenticated or unauthorized: {source}"))]
    AuthError { source: &dyn Error },
}

enum OperationError {
    #[snafu(display("Attempt to write empty record batches"))]
    EmptyDataset,
    #[snafu(display("Dataset already exists: {uri}"))]
    DatasetAlreadyExists { uri: String },
    #[snafu(display("Append with different schema: original={original} new={new}"))]
    SchemaMismatch { original: Schema, new: Schema },
    #[snafu(display("Dataset at path {path} was not found: {source}"))]
    DatasetNotFound {
}

Copy link
Contributor

@wjones127 wjones127 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good first pass. I think we'll want follow ups:

  • Create general errors that can be mapped to Python/Node exceptions (e.g. InvalidInput -> ValueError)
  • Explore snafu's traceback support to make debugging easier.

@gsilvestrin
Copy link
Contributor Author

thanks @wjones127. Agree this will need follow ups, especially related about how to manage the growing number of Errors (having InvalidInput would help here). Ideally we can create more Errors as we work on other tasks so it doesn't feel so overwhelming

@gsilvestrin gsilvestrin merged commit 505dbcd into main May 30, 2023
@gsilvestrin gsilvestrin deleted the gsilvestrin/snafu branch May 30, 2023 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Expose the internal errors
3 participants