Our journey from nightly to stable Rust
When we shipped Seq 5.0 back in November, our new storage engine was compiled against Rust's unstable nightly channel. As of Seq 5.1, we can instead use the supported stable channel. That feels like a bit of a milestone so I'd like to share a few details about our journey from nightly to stable, and celebrate the progress the community has made on the language, libraries, and tooling over the last twelve months that made that journey painless for us.
How Rust is shipped
The Rust programming language is released through a few channels with different levels of stability:
- stable: the currently supported version. A new minor version is cut every six weeks. Point releases are uncommon but are made from time to time.
- beta: candidate for the next supported version.
- nightly: the current head of the
master
branch where most development takes place, and enables access to unstable features.
The trade-off between the stable and nightly channels is early access to new features but less stability in the compiler, libraries, and the availability of associated tools like the language server.
Starting on nightly
When we started working on our Rust codebase in early 2018 there were some very compelling unstable features that pulled us towards the nightly channel. The main one was non-lexical lifetimes, and the previews of the 2018 edition as they became available. Through the course of the year we both took on new unstable features and shed a few as they stabilized. We were careful not to depend on unstable features that were difficult to shim or didn't have a clear path to stabilization in the near future though, one example being specialization.
I dug back through the history of our Rust codebase to find some of the unstable features we ended up using. Very early into development the list was already pretty large:
// For the custom `reason` attribute used by unsafe macros
#![feature(custom_attribute)]
// For supporting visibility on the `unsafe_fn` macro
#![feature(macro_vis_matcher)]
// For attributes on blocks in `unsafe_block` macro expansion
#![feature(stmt_expr_attributes)]
// For using the `RangeArgument` trait
#![feature(collections_range)]
// For non-zero unique timestamps
#![feature(nonzero)]
// For hardware accelerated crc32c
#![feature(stdsimd, target_feature, cfg_target_feature)]
// For converting Rust results into FFI results
#![feature(try_trait)]
// For sanity
#![feature(nll)]
Turns out at this point we almost had more unstable features than implemented methods:
impl IngestBuf {
/**
Create an ingestion buffer.
The file must not already exist. A new file will be created at `path`.
*/
pub fn create(path: impl AsRef<Path>) -> Result<Self, Error> {
unimplemented!()
}
}
Aspirational-Doc-Comment-Driven Development at its best.
A year later that set of unstable features looked like this:
// For converting Rust results into FFI results
#![feature(try_trait)]
// Easier to create safe synchronization primitives
#![feature(optin_builtin_traits)]
// For using the `RangeBounds` trait
#![feature(range_contains)]
// For using the `SliceIndex` trait
#![feature(slice_index_methods)]
By this time the two big unstable features we needed; nll
and target_feature
; had both stabilized so we were left with just a few things to work around.
Moving from nightly to stable
In a previous post we described our std_ext
module where various utilities and extensions to the standard library live. To support the remaining unstable features in some form on the stable channel we ended up adding a new unstable
module under std_ext
specifically for shimming:
src
└── std_ext
├── unstable
│ ├── marker.rs
│ ├── mod.rs
│ ├── ops.rs
│ └── range.rs
├── fs.rs
├── io.rs
├── iter.rs
├── mod.rs
├── num.rs
├── ops.rs
├── option.rs
├── range.rs
├── rc.rs
├── result.rs
├── slice.rs
├── sync.rs
└── vec.rs
Collecting these shims together lets us track them as tech-debt that we can pay down once the original feature stabilizes. Let's look at how we shimmed our way around each of the remaining unstable features we depended on.
try_trait
The Try
trait lets you hook into the ?
operator. We use it to convert Rust's Result<T, E>
into a flat, FFI-safe FlareResult
in our C bindings. We ended up copying the implementation of the Try
trait itself so we didn't need to change any impl
blocks on our FlareResult
type:
mod ops {
// #![feature(try_trait)]
pub(crate) trait Try {
type Ok;
type Error;
fn into_result(self) -> Result<Self::Ok, Self::Error>;
fn from_error(v: Self::Error) -> Self;
fn from_ok(v: Self::Ok) -> Self;
}
}
The ?
s themselves were replaced with a flare_try!
macro (reminiscent of ye olde try!
macro) that called from_ok
or from_err
to carry the result like ?
would.
Once try_trait
stabilizes we can remove our version and replace flare_try
with ?
again.
range_contains
Our storage engine has a lot of range code and we used RangeBounds::contains
extensively. Thankfully its implementation was fairly straightforward (we've got some truly crazy range code) so we made it an extension trait. We had to change the name of the method from contains
to contains_item
though so it didn't clash with the unstable one:
mod range {
use std::ops::{
Bound,
RangeBounds,
};
// #![feature(range_contains)]
pub(crate) trait RangeContainsExt<T> {
fn contains_item<U>(&self, item: &U) -> bool
where
T: PartialOrd<U>,
U: ?Sized + PartialOrd<T>;
}
impl<T, R> RangeContainsExt<T> for R
where
R: RangeBounds<T>,
{
fn contains_item<U>(&self, item: &U) -> bool
where
T: PartialOrd<U>,
U: ?Sized + PartialOrd<T>,
{
(match self.start_bound() {
Bound::Included(ref start) => *start <= item,
Bound::Excluded(ref start) => *start < item,
Bound::Unbounded => true,
}) && (match self.end_bound() {
Bound::Included(ref end) => item <= *end,
Bound::Excluded(ref end) => item < *end,
Bound::Unbounded => true,
})
}
}
}
Once range_contains
stabilizes we can remove our implementation.
slice_index_methods
Turns out we were only using the SliceIndex::get_unchecked
method in a single place, so we inlined its implementation there.
optin_builtin_traits
There's an unstable feature that lets you opt-out of opt-in-built-in-traits (OIBITs) like Send
and Sync
using special syntax. If you find yourself needing this you're usually either building some synchronization primitive or a container that lies about what's in it. We have some primitive types that are explicitly !Sync
, so instead of impl !Sync for Foo
, we add a zero-sized field to these types that is !Send + !Sync
:
mod marker {
use std::marker::PhantomData;
// #![feature(optin_builtin_traits)]
#[derive(Default)]
pub(crate) struct ClobberOibits(PhantomData<*mut fn()>);
}
Once optin_builtin_traits
stabilizes we can remove ClobberOibits
and restore the explicit opt-out impl
blocks.
test
We did have a few internal micro-benchmarks that used the #[bench]
attribute for checking things like the difference between our SSE4.2-optimized CRC checksum and its fall-back implementation. These benchmarks were useful in initial development, but not really worthwhile anymore so we just removed them. Our regular benchmark suite was already using a simple, custom framework that we have more control over so we didn't lose much.
Going forwards on stable
There's an ongoing conversation in the Rust community right now about minimal supported rustc
versions (MSRVs). How hard should libraries work to support older versions of the Rust compiler? As a data-point, for our codebase we expect to bump the compiler version to the latest stable whenever we update our Cargo.lock
(which is checked in to source control). So the MSRV we anticipate from our dependencies is effectively the latest stable.
Now that we can build our Rust codebase on the stable channel we'll be very hesitant to move back to nightly. There are some upcoming features I'd be interested to spike out though so that we can get a sense of how effective they are and provide feedback. The first one that comes to mind is impl
Trait on type aliases, which could reduce some of the clumsy type boilerplate we have around iterators.
It's been exciting to see our list of reasons to depend on the unstable nightly channel grow smaller and smaller since we started as important features have been polished and stabilized! It's a solid platform to build on for the future.