Skip to content
Tutorials

External blob storage

A post can have a large attachment without loading those bytes every time you list posts. Blob storage separates the attachment's bytes from the record that refers to them. You choose which fields load eagerly and which wait until asked.

This example uses BlobRepository, an in-memory entity catalog paired with an object bucket. It shows the blob API independently of SQL. It does not make a SQL transaction and an external bucket one atomic transaction.

Configure the fields

The body is required and loads during a scan. The attachment loads on demand and uses tenant-specific encryption. Supply master_seed from your application's key configuration:

use rent::driver::blob::{
    BlobFieldConfig, BlobMutation, BlobRepository, MemoryBlobBucket,
    TenantAes256Gcm,
};

let bucket = MemoryBlobBucket::default();
let mut repository = BlobRepository::new(
    bucket,
    [
        BlobFieldConfig::new("body").required().load_on_scan(),
        BlobFieldConfig::new("attachment")
            .prefix("attachments/")
            .codec(TenantAes256Gcm::new(master_seed)),
    ],
);

MemoryBlobBucket keeps this walkthrough self-contained. Field configuration also supports prefixes and dual writes when changing object storage. Reference checks help keep stored objects associated with their records.

Save a post and its attachment

let post = repository
    .create(
        BlobMutation::named("first-post")
            .set_string("body", "Stored outside the row")
            .set("attachment", b"private bytes".to_vec()),
        Some("acme"),
    )
    .await?;

let body = post.string("body")?;

The body is available on the returned record. Read the attachment separately when the user opens it:

let attachment = repository
    .read(post.id, "attachment", Some("acme"))
    .await?;

The result is optional bytes. Supplying a different tenant cannot decrypt this attachment. Use the tenant established by your application, rather than accepting an arbitrary tenant value from the download request.

Remove the associated objects

repository.delete(post.id).await?;

Deleting the repository record also removes its associated objects. Your generated SQL models and this repository are separate APIs. Coordinate any application-level SQL references deliberately when adding an external bucket.

Complete example

Download this project, or copy the files below into an empty directory.

Replace RENT_REPOSITORY_URL in Cargo.toml with the URL supplied with your preview access. Use the matching Rent CLI release.

rent generate
cargo run

The project includes its database setup. Each lesson uses a small, self-contained schema. You can run it without applying the earlier lessons.

Cargo.toml
[package]
name = "rent-lesson-13-external-blobs"
version = "0.1.0"
edition = "2024"

[workspace]

[[bin]]
name = "rent-lesson-13-external-blobs"
path = "main.rs"

[dependencies]
rent = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6", default-features = false, features = ["sqlite", "migrate", "extensions"] }
anyhow = "1.0.104"
tokio = { version = "1.53.1", features = ["full"] }
rent-schema-language = { git = "RENT_REPOSITORY_URL", tag = "v0.8.6" }
rent.toml
schema = "schema.rsl"
generated = "generated"
migrations = "migrations"

[database]
dialect = "sqlite"
backends = ["sqlite"]
schema.rsl
datasource db {
  provider = "sqlite"
}

model Document {
  id   BigInt  @id
  body Blob

  @@map("documents")
}
main.rs
use anyhow::{Result, ensure};
use rent::driver::blob::{BlobFieldConfig, BlobMutation, BlobRepository, MemoryBlobBucket, TenantAes256Gcm};

async fn exercise() -> Result<()> {
    rent_schema_language::parse_schema("schema.rsl", include_str!("schema.rsl"))?;
    let bucket = MemoryBlobBucket::default();
    let mut repository = BlobRepository::new(
        bucket.clone(),
        [
            BlobFieldConfig::new("body").required().load_on_scan(),
            BlobFieldConfig::new("attachment")
                .prefix("attachments/")
                .codec(TenantAes256Gcm::new(b"blob master seed".to_vec())),
        ],
    );
    let post = repository
        .create(
            BlobMutation::named("first-post")
                .set_string("body", "Stored outside the row")
                .set("attachment", b"private bytes".to_vec()),
            Some("acme"),
        )
        .await?;

    ensure!(post.string("body")? == "Stored outside the row");
    ensure!(repository.read(post.id, "attachment", Some("acme")).await? == Some(b"private bytes".to_vec()));
    ensure!(repository.read(post.id, "attachment", Some("northwind")).await.is_err());
    ensure!(bucket.keys().await.len() == 2);

    repository.delete(post.id).await?;
    ensure!(bucket.keys().await.is_empty());
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    exercise().await
}

#[cfg(test)]
mod tests {
    #[tokio::test]
    async fn example_runs() -> anyhow::Result<()> {
        super::exercise().await
    }
}