Packages

Ultra-fast JSON encoding/decoding for Elixir. A drop-in Jason replacement that's 2-3x faster with 2-4x less memory, plus key interning for up to 2x faster bulk decoding. Full RFC 8259 compliance and memory safety. Purpose-built Rust NIFs, no serde.

Current section

Files

Jump to
rustyjson docs ARCHITECTURE.md
Raw

docs/ARCHITECTURE.md

# RustyJson Architecture
This document explains the architectural decisions behind RustyJson and compares them to Jason's approach.
## Overview
RustyJson is a Rust NIF-based JSON library that prioritizes **memory efficiency** over theoretical purity. While Jason returns true iolists, RustyJson returns single binaries - a deliberate trade-off that yields 10-20x memory reduction during encoding.
## Encoding Architecture
### Jason's Approach (Pure Elixir, True iolist)
```
Elixir Term
Protocol Dispatch (Jason.Encoder)
Build iolist incrementally
["{", ['"', "key", '"'], ":", "1", "}"]
Return iolist (many small binaries)
```
**Characteristics:**
- Many small BEAM binary allocations
- Each struct/map/list creates nested list structures
- BEAM owns all memory from the start
- Can theoretically stream to sockets without flattening
### RustyJson's Approach (Rust NIF, Single Binary)
```
Elixir Term
Optional: Protocol Dispatch (RustyJson.Encoder)
NIF Call ──────────────────────────┐
│ │
▼ │ Rust
Walk term tree directly │
Write to single buffer (mimalloc) │
│ │
▼ │
Copy buffer to BEAM binary ────────┘
Return single binary
```
**Characteristics:**
- One buffer allocation in Rust
- No intermediate Elixir terms created
- Single copy from Rust → BEAM at the end
- Must complete encoding before returning
## Memory Comparison
For a 10MB JSON payload:
| Phase | Jason | RustyJson |
|-------|-------|-----------|
| Input term | 10 MB | 10 MB |
| During encoding | +140 MB (intermediate binaries) | +5 MB (Rust buffer) |
| Peak memory | ~150 MB | ~15 MB |
| Final output | 10 MB iolist | 10 MB binary |
The dramatic difference comes from Jason creating many intermediate binary allocations during the recursive encoding process.
## Safety Analysis
### The NIF Safety Question
A common concern with NIFs is: "What if it crashes and takes down my entire BEAM VM?"
This is a valid concern for C-based NIFs, where a single buffer overflow, null pointer dereference, or use-after-free can crash the VM. However, **RustyJson's architecture makes this effectively impossible**.
### Why RustyJson Cannot Crash the VM
#### 1. Rust's Compile-Time Memory Safety
Rust eliminates entire categories of bugs at compile time:
| Bug Category | C/C++ NIF | Rust NIF | How Rust Prevents |
|--------------|-----------|----------|-------------------|
| Null pointer dereference | Runtime crash | Won't compile | `Option<T>` forces handling |
| Buffer overflow | Runtime crash | Won't compile | Bounds checking enforced |
| Use after free | Runtime crash | Won't compile | Ownership system |
| Double free | Runtime crash | Won't compile | Single owner rule |
| Data races | Undefined behavior | Won't compile | Send/Sync traits |
| Uninitialized memory | Undefined behavior | Won't compile | All values initialized |
These aren't runtime checks that could be bypassed—the Rust compiler literally refuses to produce code with these bugs.
#### 2. Rustler's Safety Layer
[Rustler](https://github.com/rusterlium/rustler) adds BEAM-specific protections:
- **Panic catching**: Rust panics are caught via `std::panic::catch_unwind` and converted to Elixir exceptions. A panic cannot crash the VM.
- **Term safety**: All Erlang term manipulation goes through safe APIs that validate types.
- **No raw pointers**: Rustler's `Term` type wraps raw BEAM pointers safely.
- **Lifetime enforcement**: Rust's borrow checker ensures terms aren't used after their scope ends.
#### 3. Zero `unsafe` Code in Our Codebase
**RustyJson's source code contains zero `unsafe` blocks.** Not minimized, not
audited—literally zero. Every line of Rust we wrote is 100% safe Rust.
```rust
// All our code uses safe patterns like this:
fn peek(&self) -> Option<u8> {
self.input.get(self.pos).copied() // Safe: returns None if out of bounds
}
fn skip_whitespace(&mut self) {
while let Some(&byte) = self.input.get(self.pos) { // Safe: bounds-checked
match byte {
b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1,
_ => break,
}
}
}
```
**Our source files:**
| File | Purpose | Lines | Unsafe |
|------|---------|-------|--------|
| `lib.rs` | NIF entry point | ~100 | **0** |
| `direct_json.rs` | JSON encoder | ~1,000 | **0** |
| `direct_decode.rs` | JSON decoder | ~500 | **0** |
| `compression.rs` | Gzip compression | ~60 | **0** |
| `decimal.rs` | Decimal handling | ~110 | **0** |
| **Total** | | **~1,770** | **0** |
**Design choices that eliminate unsafe:**
1. **`&str` over `&[u8]`** - UTF-8 validity guaranteed at compile time
2. **`.get()` over indexing** - Returns `Option` instead of panicking
3. **`while let Some()`** - Idiomatic safe iteration pattern
**Where `unsafe` does exist (dependencies only):**
| Dependency | Purpose | Trust Level |
|------------|---------|-------------|
| Rustler | NIF ↔ BEAM bridge | High - 10+ years, 500+ projects |
| mimalloc | Memory allocator | High - Microsoft, battle-tested |
| Rust stdlib | Core functionality | Highest - Rust core team |
Any memory safety bug would have to originate in a dependency, not in RustyJson code.
#### 4. Explicit Resource Limits
We enforce limits that prevent resource exhaustion:
| Resource | Limit | Rationale |
|----------|-------|-----------|
| Nesting depth | 128 levels | Prevents stack overflow, per RFC 7159 |
| Recursion | Bounded by depth | No unbounded recursion possible |
| Allocation | System memory | Same as pure Elixir |
### What Would Actually Need to Go Wrong?
For RustyJson to crash the VM, one of these would need to happen:
| Scenario | Likelihood | Why It's Unlikely |
|----------|------------|-------------------|
| Bug in Rustler | Extremely low | Mature library, used in production by many projects |
| Bug in Rust compiler | Essentially zero | Rust compiler is formally verified for memory safety |
| Bug in mimalloc | Extremely low | Microsoft's allocator, battle-tested |
| Cosmic ray bit flip | Non-zero but... | Not a software problem |
None of these are RustyJson bugs—they're infrastructure bugs that would affect any Rust-based system.
### Comparison: Theoretical vs Practical Risk
| Aspect | Theoretical Risk | Practical Risk |
|--------|------------------|----------------|
| Memory corruption | "NIFs can crash" | Rust prevents at compile time |
| Stack overflow | "Deep recursion" | 128-depth limit enforced |
| Panic/exception | "Unhandled errors" | Rustler converts to Elixir errors |
| Scheduler blocking | "Long NIF calls" | JSON encoding is fast, bounded |
### Our Position
We consider RustyJson **as safe as pure Elixir code** for the following reasons:
1. **Safe Rust is memory-safe by construction**. The compiler guarantees it.
2. **Rustler has been production-tested** across hundreds of Elixir projects for years.
3. **We use no `unsafe` code** in our encoding/decoding logic.
4. **Panics become exceptions**, not crashes.
5. **Resource limits are enforced** (depth, recursion).
The "NIFs can crash your VM" warning applies to **C-based NIFs** where a single bug can corrupt memory. It does not meaningfully apply to safe Rust NIFs, which have the same memory safety guarantees as the BEAM itself.
### When to Consider Alternatives
If you require defense-in-depth for untrusted input, you could:
1. **Run encoding in a Task**: Crashes isolated to that process
2. **Use Jason for untrusted input**: Pure Elixir, cannot crash VM
3. **Add pre-validation**: Check input structure before encoding
However, we believe these are unnecessary for RustyJson given the safety guarantees above.
### Garbage Collection
| Aspect | Jason | RustyJson |
|--------|-------|-----------|
| Allocation pattern | Many small binaries | One large binary |
| GC type | Regular + refc | Refc only |
| GC timing | Incremental | Atomic release |
| Memory spike | During encoding | Brief, at copy |
**Refc binaries** (>64 bytes) are reference-counted and stored outside the process heap. RustyJson always produces refc binaries for non-trivial output.
## Performance Characteristics
### When Jason Wins
1. **Tiny payloads (<100 bytes)**: NIF call overhead exceeds encoding time
2. **Streaming scenarios**: True iolists can be sent to sockets incrementally
3. **Partial failure recovery**: Failed encodes don't leave large allocations
### When RustyJson Wins
1. **Medium payloads (1KB-1MB)**: 3-6x faster, 10-20x less memory
2. **Large payloads (1MB+)**: Memory efficiency becomes critical
3. **High-throughput APIs**: Less GC pressure, more consistent latency
4. **Memory-constrained environments**: Lower peak memory usage
### Real-World Performance
In production workloads (Amazon settlement reports):
| Metric | Jason | RustyJson | Improvement |
|--------|-------|-----------|-------------|
| Encode time (13K rows) | 1,556 ms | 70 ms | 22x faster |
| Memory during encode | +146.8 MB | +6.7 MB | 22x less |
## Protocol Architecture
### Default Path (Maximum Performance)
```
RustyJson.encode!(data)
Rust NIF
Direct term walking
(no Elixir preprocessing)
```
No Elixir code runs during encoding. The Rust NIF walks the term tree directly.
### Protocol Path (Custom Encoding)
```
RustyJson.encode!(data, protocol: true)
RustyJson.Encoder.encode/1
Protocol dispatch
(Map, List, Tuple, Any, custom)
Rust NIF
```
When `protocol: true`:
1. Elixir's protocol system preprocesses the data
2. Custom `RustyJson.Encoder` implementations are called
3. Falls back to `Jason.Encoder` if available (via `RustyJson.Compat.Jason`)
4. Preprocessed data is sent to Rust
### Jason Compatibility Layer
```
Struct with @derive Jason.Encoder
RustyJson.Encoder.Any.encode/1
Check: Jason.Encoder.impl_for(struct)
├─► Found: Call Jason.encode_to_iodata!/1
│ Wrap in RustyJson.Fragment
└─► Not found: Map.from_struct()
Recurse
```
This allows existing `@derive Jason.Encoder` code to work without changes.
## Decoding Architecture
### Jason
```
JSON String
Recursive descent parser (Elixir)
Build Elixir terms during parse
Return term
```
### RustyJson
```
JSON String
NIF Call ──────────────────────────┐
│ │
▼ │ Rust
Custom parser │
Build Erlang terms via Rustler │
Zero-copy strings when possible │
│ │
▼ ─────────────────────────────┘
Return term (already on BEAM heap)
```
Decoding builds terms directly on the BEAM heap via Rustler's term API, avoiding intermediate Rust allocations.
## Fragment Architecture
Fragments allow injecting pre-encoded JSON:
```
%RustyJson.Fragment{encode: ~s({"pre":"encoded"})}
Rust NIF
Detect Fragment struct
Write iodata directly to buffer
(no re-encoding)
```
This is critical for:
- PostgreSQL `jsonb_agg` results
- Cached JSON responses
- Third-party API proxying
## Design Decisions
### Why Single Binary Instead of iolist?
1. **Memory efficiency**: The 10-20x memory reduction during encoding far outweighs the theoretical benefits of iolists.
2. **Phoenix flattens anyway**: `Plug.Conn` typically calls `IO.iodata_to_binary/1` before sending responses.
3. **Simpler NIF interface**: Returning a single binary is cleaner than building nested Erlang lists in Rust.
4. **Predictable performance**: Single allocation is easier to reason about than many small ones.
### Why Optional Protocol?
1. **Maximum default performance**: Most data doesn't need custom encoding.
2. **Explicit opt-in**: Users consciously trade performance for flexibility.
3. **Jason compatibility**: The protocol layer enables `Jason.Encoder` fallback.
### Why 128-Level Depth Limit?
1. **RFC 7159 compliance**: The spec recommends implementations limit nesting.
2. **Stack safety**: Prevents stack overflow in recursive encoding/decoding.
3. **DoS protection**: Malicious deeply-nested JSON can't exhaust resources.
## Future Considerations
### Potential Optimizations
1. **SIMD parsing**: Libraries like simd-json could accelerate decoding, though term construction dominates.
2. **Chunked output**: For 100MB+ payloads, returning iolists could reduce memory spikes.
3. **Streaming decode**: Parse JSON incrementally for very large inputs.
### Not Planned
1. **True iolist output**: The complexity isn't justified by real-world benefits.
2. **Unsafe Rust**: Memory safety is non-negotiable.
3. **Custom allocators per-call**: mimalloc is fast enough globally.
## References
- [Rustler](https://github.com/rusterlium/rustler) - Safe Rust NIFs for Erlang/Elixir
- [Jason](https://github.com/michalmuskala/jason) - Reference implementation
- [RFC 8259](https://tools.ietf.org/html/rfc8259) - JSON specification
- [mimalloc](https://github.com/microsoft/mimalloc) - Memory allocator