Skip to content

Commit b80fb41

Browse files
committed
chore: add vbare blog
1 parent bbdf1c1 commit b80fb41

File tree

2 files changed

+248
-0
lines changed
  • site/src/posts/2025-09-24-vbare-simple-schema-evolution-with-maximum-performance

2 files changed

+248
-0
lines changed
301 KB
Loading
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
export const author = "nathan-flurry"
2+
export const published = "2025-09-24"
3+
export const category = "technical"
4+
export const keywords = ["bare","serialization","internals"]
5+
6+
# VBARE: A simple alternative to Protobuf & Cap'n Proto for schema evolution
7+
8+
**At Rivet, we're building an open-source alternative to Cloudflare Durable Objects — a tool for running stateful compute workloads. VBARE is a small but crucial component in meeting the demanding performance requirements of Rivet. If you enjoy this article, [consider giving Rivet a star on GitHub](https://github.com/rivet-dev/engine).**
9+
10+
## Growing pains with Protobuf
11+
12+
We decided to adopt Protocol Buffers for any internal communications & data stored at rest. We've always believed it's a flawed technology, but it was the **only mainstream and portable tool** that provided binary serialization and schema evolution — or what we _thought_ would be sufficient schema evolution.
13+
14+
### The problem
15+
16+
Issues started arriving after our v1 launched and we started adding new features that **required significant changes to the datastructures in our Protobufs**. Eventually, all of our Protobuf files started generating bitrot of sorts. They became tedious to read, understand defaults, and understand migration paths. We frequently ended up with a handful of common issues:
17+
18+
- **All new properties need to be optional**: We had to litter our application logic with defaults if a property was none
19+
- **Properties cannot be moved or restructured**: Say we want to change a bool to an enum or a struct to a union — good luck
20+
- **Datastructures need to be reshaped**: The most simple example is switching from a list to a map or switching from a bool to a union
21+
22+
More importantly, the more we changed in Protobuf, **more and more cruft had to be added to our application logic** that made adding features a tedious process.
23+
24+
### The crude solution
25+
26+
To solve this, we started following this pattern to allow us to clean up our application logic:
27+
28+
1. Copy and paste the entire Protobuf spec to a v2
29+
2. Update the v2 to the ideal schema
30+
3. Write a migration function between v1 and v2
31+
4. Feed the final v2 version into the application logic
32+
33+
This became such a standard practice that most of our API saw a turnover to a new version once every 3-6 months.
34+
35+
### The breaking point
36+
37+
While this process helped simplify our application logic significantly, it required a lot of manual effort to implement the Protobuf migrations. So we started evaluating other options that might provide better schema evolution.
38+
39+
## Evaluating existing options
40+
41+
### Goals
42+
43+
- **Simple** — Minimal features, can be easily forked and modified if needed
44+
- **Portable** — Cross-language support with a well-defined standard
45+
- **Fast** — Self-contained binary encoding (think: binary data without keys), ideally with zero-copy reads
46+
- **Clean SDK** — Ser/de code has a huge impact on the legibility & friction of working with application logic
47+
48+
### Non-Goals
49+
50+
- **Data compactness** — That's what gzip is for
51+
- **RPC layer** — This is trivial to implement yourself based on your specific requirements
52+
53+
### Exploring existing options
54+
55+
We evaluated numerous serialization protocols before deciding to build VBARE. Here's a brief overview of why each fell short:
56+
57+
- **Protocol Buffers**: Makes migrations your problem at runtime by making everything optional, with dangerous default values that lead to subtle bugs
58+
- **Flatbuffers**: Similar issues to Protocol Buffers with field indexing requirements, awful codegen
59+
- **Cap'n Proto**: Provides powerful schema migrations at the cost of complexity, focused on C++, poor generated clients for non-C++ languages
60+
- **CBOR/MessagePack/BSON**: Self-describing formats, unsuitable for our performance needs
61+
- **Bebop/Borsh**: Provides cross-language, self-contained binary encoding. We almost chose one of these instead of BARE, but BARE is simpler.
62+
- **Rust-specific options (postcard, bincode, etc.)**: Not cross-language, only supports Rust
63+
64+
(_For a detailed comparison, see our [full evaluation document](https://github.com/rivet-dev/vbare/blob/e08ca552b3e3703cf23e4764342be772ddc43879/docs/COMPARISON.md)._)
65+
66+
In the end, none of these solutions provided a compelling schema evolution mechanism.
67+
68+
## VBARE overview
69+
70+
Enter, VBARE: a **tiny extension of BARE to provide a version header** and handle version migrations.
71+
72+
Instead of using an off-the-shelf solution, we opted to build a simple evolution system similar to the pattern we were already using: **manually writing code to migrate between schemas**. We would pair this technique with the BARE encoding to create VBARE.
73+
74+
<Info>
75+
[BARE (Binary Application Record Encoding)](https://baremessages.org/) — which VBARE extends — is a simple binary serialization format designed for efficiency and simplicity. Unlike self-describing formats like JSON or CBOR, BARE requires a schema to encode and decode data, similar to Protocol Buffers but with a much simpler design philosophy.
76+
</Info>
77+
78+
### Schema evolution in VBARE
79+
80+
VBARE's design philosophy centers on four core beliefs:
81+
82+
1. **Make smaller, incremental schema changes** instead of massive v1 to v2 overhauls. Build tools that make this easy and your schema will be easier to work with.
83+
84+
2. **Manual evolution simplifies application logic** by isolating schema evolution logic to a separate module before passing it to the application logic.
85+
86+
3. **Real-world schema evolutions require more than simple property renaming**: it involves complex reshaping and fetching data from remote sources, which automatic migration systems can't handle.
87+
88+
4. **Manual evolution is less error prone** by forcing developers to explicitly handle edge cases of migrations and breaking changes, trading verbosity for safety.
89+
90+
### Versions
91+
92+
VBARE operates by declaring a schema file for every version of your protocol, then writing explicit conversion functions between adjacent versions.
93+
94+
Each message has an associated version number (unsigned 16-bit integer) that increases monotonically from 1. Versions are specified in the filename: `my-schema/v1.bare`, `my-schema/v2.bare`, etc.
95+
96+
### Converters
97+
98+
Servers define conversion code that transforms between versions bidirectionally:
99+
- **Upgrade** converters for deserialization (old → new)
100+
- **Downgrade** converters for serialization (new → old)
101+
102+
There are no evolution semantics in the schema language itself. To create a new version, you simply copy the previous schema and make your changes.
103+
104+
### Version Negotiation
105+
106+
Every message has an associated version, which can be either:
107+
108+
- **Embedded** in the message itself (first 2 bytes)
109+
- **Pre-negotiated** via HTTP paths (`POST /v3/users`), query parameters, or handshakes
110+
111+
### Servers vs Clients
112+
113+
- **Servers** must include converters between all versions to handle any client version.
114+
115+
- **Clients** only need to include a single version since the server handles all version conversion.
116+
117+
## Use cases
118+
119+
Common use cases include:
120+
121+
- **Network protocols** — Allow the server to cleanly evolve the protocol version without breaking old clients
122+
- **Data at rest** — Upgrade your file format without breaking old files
123+
124+
For examples, VBARE powers almost all of Rivet:
125+
126+
- **[Rivet Engine](https://github.com/rivet-dev/engine)**
127+
- [Data at rest](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/data)
128+
- Internal network protocols ([Epoxy](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/epoxy-protocol), [UPS](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/ups-protocol))
129+
- [Public network protocols](https://github.com/rivet-dev/engine/tree/f62142df1fa538499692deeb44f79b68f6e3c3c0/sdks/schemas/runner-protocol)
130+
131+
- **[RivetKit](https://github.com/rivet-dev/rivetkit)**
132+
- [Client protocol](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol)
133+
- [Persisted state](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/actor-persist)
134+
- [File system driver](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/file-system-driver)
135+
136+
137+
## Example Code
138+
139+
Here's a simple example demonstrating how VBARE handles a complex schema migration that is not possible with any existing tools:
140+
141+
```text {{"title":"schema/v1.bare"}}
142+
type User struct {
143+
id: string
144+
name: string
145+
}
146+
```
147+
148+
```text {{"title":"schema/v2.bare"}}
149+
type User struct {
150+
id: string
151+
// Split `name` in to 2 properties
152+
firstName: string
153+
lastName: string
154+
}
155+
```
156+
157+
```typescript
158+
import * as V1 from "./v1";
159+
import * as V2 from "./v2";
160+
import { createVersionedDataHandler } from "vbare";
161+
162+
// Converter from v1 to v2
163+
function upgradeUserV1ToV2(v1: V1.User): V2.User {
164+
const [firstName, ...rest] = v1.name.split(' ');
165+
return {
166+
...v1,
167+
firstName,
168+
lastName: rest.join(' ') || ''
169+
};
170+
}
171+
172+
// Converter from v2 to v1
173+
function downgradeUserV2ToV1(v2: V2.User): V1.User {
174+
return {
175+
...v2,
176+
name: `${v2.firstName} ${v2.lastName}`.trim()
177+
};
178+
}
179+
180+
// Create versioned data handler
181+
export const USER_VERSIONED = createVersionedDataHandler<V2.User>({
182+
deserializeVersion: (bytes: Uint8Array, version: number): any => {
183+
switch (version) {
184+
case 1:
185+
return V1.decodeUser(bytes);
186+
case 2:
187+
return V2.decodeUser(bytes);
188+
default:
189+
throw new Error(`invalid version: ${version}`);
190+
}
191+
},
192+
serializeVersion: (data: any, version: number): Uint8Array => {
193+
switch (version) {
194+
case 1:
195+
return V1.encodeUser(data);
196+
case 2:
197+
return V2.encodeUser(data);
198+
default:
199+
throw new Error(`invalid version: ${version}`);
200+
}
201+
},
202+
deserializeConverters: () => [upgradeUserV1ToV2],
203+
serializeConverters: () => [downgradeUserV2ToV1],
204+
});
205+
206+
```
207+
208+
## Implementations
209+
210+
- **[TypeScript](https://github.com/rivet-dev/vbare/tree/main/typescript)**[Example Code](https://github.com/rivet-dev/vbare/blob/main/typescript/examples/basic/src/index.ts)
211+
- **[Rust](https://github.com/rivet-dev/vbare/tree/main/rust)**[Example Code](https://github.com/rivet-dev/vbare/blob/main/rust/examples/basic/src/lib.rs)
212+
213+
For a full list of BARE implementations, visit [baremessages.org](https://baremessages.org/).
214+
215+
## FAQ
216+
217+
### Why is copying the entire schema for every version better than using decorators for gradual migrations?
218+
219+
- Decorators are limited and become very complicated over time
220+
- It's unclear at what version of the protocol a decorator takes effect — explicit versions help clarify this
221+
- Generated SDKs become more and more bloated with every change
222+
- You need a validation build step for your validators
223+
- Manual migrations provide more flexibility for complex transformations
224+
225+
### Why not include RPC?
226+
227+
RPC interfaces are trivial to implement yourself. Libraries that provide RPC interfaces tend to add extra bloat and cognitive load through things like abstracting transports, compatibility with the language's async runtime, and complex codegen to implement handlers.
228+
229+
Usually, you just want a `ToServer` and `ToClient` union that looks like this:
230+
- [ToClient example](https://github.com/rivet-dev/rivetkit/blob/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol/v1.bare#L34)
231+
- [ToServer example](https://github.com/rivet-dev/rivetkit/blob/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/client-protocol/v1.bare#L56)
232+
233+
### Don't migration steps get repetitive?
234+
235+
Migration steps are fairly minimal to write. The most verbose migration steps will be for deeply nested structures that changed, but even that is relatively straightforward.
236+
237+
### What are the downsides?
238+
239+
- More verbose migration code — but this is usually because VBARE forces you to handle all edge cases you wouldn't otherwise bother with
240+
- The older the version, the more migration steps that need to run to bring it to the latest version — though migration steps are usually negligible in cost
241+
- Migration steps are not portable across languages, but only the server needs the migration steps, so this is usually only implemented once
242+
243+
## Getting started & source code
244+
245+
VBARE is open source and [available on GitHub](https://github.com/rivet-dev/vbare).
246+
247+
See the guides for getting started with [TypeScript](https://github.com/rivet-dev/vbare/tree/main/typescript) and [Rust](https://github.com/rivet-dev/vbare/tree/main/rust).
248+

0 commit comments

Comments
 (0)