Docs

Build an object-storage-backed vector database

In this example, we are going to build a vector database that runs in a customer's cloud and keeps its vectors in customer-owned object storage. A public router fronts separate Rust readers and writers.

The interesting part is not the similarity function. It is where the data lives and how it survives releases: vectors remain in the customer's S3, Google Cloud Storage, or Azure Blob Storage account. The Containers keep no durable state on local disk. A writer creates immutable segments in object storage; readers load them and answer queries. Restarting or replacing compute does not remove the database.

Clients send vector database requests to a public router in the customer environment. The router selects a reader or writer, and those services share durable object storage.

We will first describe the service topology in alien.ts. Then we will follow an upsert into object storage, follow a query back out, and test that the data survives the compute that wrote it.

Map the application in alien.ts

The stack begins with one Storage resource. Alien maps it to S3, Google Cloud Storage, Azure Blob Storage, or the local filesystem for development.

alien.ts
const data = new alien.Storage("data").build()

The writer and reader use the same Rust binary. An environment variable selects which HTTP routes each process serves.

alien.ts
const writer = new alien.Container("writer")
  .code({
    type: "source",
    src: ".",
    toolchain: { type: "rust", binaryName: "byocdb" },
  })
  .port(8081)
  .environment({ BYOCDB_MODE: "writer", PORT: "8081" })
  .link(data)
  .permissions("default")
  .build()

const reader = new alien.Container("reader")
  .code({
    type: "source",
    src: ".",
    toolchain: { type: "rust", binaryName: "byocdb" },
  })
  .port(8082)
  .environment({ BYOCDB_MODE: "reader", PORT: "8082" })
  .link(data)
  .permissions("default")
  .build()

Only the Nginx router receives public traffic. The reader, writer, and bucket remain private to the deployment.

alien.ts
const router = new alien.Container("router")
  .code({ type: "source", src: "./router", toolchain: { type: "docker" } })
  .port(8080)
  .publicEndpoint("web", 8080, "http")
  .healthCheck({ path: "/health", method: "GET", timeoutSeconds: 1, failureThreshold: 3 })
  .permissions("default")
  .build()

export default new alien.Stack("byoc-database")
  .add(data, "frozen")
  .add(writer, "live")
  .add(reader, "live")
  .add(router, "live")
  .permissions({
    profiles: {
      default: { data: ["storage/data-read", "storage/data-write"] },
    },
  })
  .build()

The router, readers, and writer are live, so Alien can create, update, replace, or remove them during a rollout. Storage is frozen, so it remains owned by customer setup; an ordinary rollout cannot replace or delete it. This lets you ship new service code without giving ongoing deployment management authority over the bucket.

Start the Rust process in reader or writer mode

Both Containers start in src/main.rs. The mode determines which routes are registered:

src/main.rs
let mode = Mode::from_str(
    &std::env::var("BYOCDB_MODE").expect("BYOCDB_MODE is required"),
)?;
let bindings = Bindings::from_env().expect("Alien bindings are required");
let storage = bindings.storage("data").await
    .expect("the data Storage binding is required");

let app = match mode {
    Mode::Writer => Router::new()
        .route("/health", get(health))
        .route("/api/v1/namespaces/{namespace}/upsert", post(upsert))
        .with_state(WriterState {
            writer: Arc::new(Writer::new(storage)),
        }),
    Mode::Reader => Router::new()
        .route("/health", get(health))
        .route("/api/v1/namespaces/{namespace}/query", post(query))
        .with_state(ReaderState {
            reader: Arc::new(Reader::new(storage)),
        }),
};

The Nginx router sends /upsert to writer.svc:8081 and /query to reader.svc:8082. Those .svc names are available only inside the deployment.

router/nginx.conf.template
location ~ ^/api/v1/namespaces/.*/upsert$ {
    set $writer_backend writer.svc:8081;
    proxy_pass http://$writer_backend;
}

location ~ ^/api/v1/namespaces/.*/query$ {
    set $reader_backend reader.svc:8082;
    proxy_pass http://$reader_backend;
}

Store vectors as immutable segments

An upsert becomes a new JSON segment. The object layout for a namespace looks like this:

demo/
├── metadata.json
└── segments/
    ├── 2fb1….json
    └── 81ac….json

metadata.json records the vector dimension and the segment IDs. Each segment contains the vectors written by one upsert.

src/writer.rs
let segment_id = Uuid::new_v4().to_string();
let segment = Segment::new(segment_id.clone(), request.vectors.clone());
let segment_path = Path::from(format!(
    "{namespace}/segments/{segment_id}.json"
));

self.storage
    .put(&segment_path, Bytes::from(serde_json::to_vec(&segment)?).into())
    .await?;

The segment is immutable once written. The writer then appends its ID to metadata.json.

Coordinate writers with ETags

Two writers may read the same metadata and try to append different segment IDs. The writer uses the object's ETag as an optimistic lock:

src/writer.rs
let (mut metadata, etag) = self.read_metadata_with_etag(&metadata_path).await?;
metadata.segments.push(segment_id.clone());

let mode = match etag {
    Some(version) => PutMode::Update(version),
    None => PutMode::Create,
};

match self.storage.put_opts(
    &metadata_path,
    Bytes::from(serde_json::to_vec(&metadata)?).into(),
    PutOptions { mode, ..Default::default() },
).await {
    Ok(_) => break,
    Err(object_store::Error::Precondition { .. }) => continue,
    Err(error) => return Err(Error::Storage(error.to_string())),
}

If another writer changed the file first, the conditional write fails and this writer reads the new metadata before trying again. The example needs no separate lock service or coordination database.

Read segments and rank the vectors

A query starts from metadata.json, loads each referenced segment, and combines its vectors:

src/reader.rs
let metadata = self.read_metadata(&metadata_path).await?;
let mut vectors = Vec::new();

for segment_id in &metadata.segments {
    let segment = self.read_segment(namespace, segment_id).await?;
    vectors.extend(segment.vectors);
}

The example computes cosine similarity directly and returns the highest-scoring vectors. A production database would normally cache or build an index, but the storage and deployment model would remain the same.

src/reader.rs
let mut scored: Vec<_> = vectors
    .iter()
    .enumerate()
    .map(|(index, vector)| {
        (index, cosine_similarity(&request.vector, &vector.values))
    })
    .collect();

scored.sort_by(|a, b| {
    b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});

Run an upsert and query

Start the complete stack:

cd examples/byoc-database
alien dev

Write three vectors to the demo namespace:

curl -X POST http://localhost:8080/api/v1/namespaces/demo/upsert \
  -H 'content-type: application/json' \
  -d '{
    "vectors": [
      {"id":"doc1","values":[0.1,0.2,0.3,0.4],"metadata":{"title":"Hello"}},
      {"id":"doc2","values":[0.2,0.3,0.4,0.5],"metadata":{"title":"World"}},
      {"id":"doc3","values":[0.9,0.8,0.7,0.6],"metadata":{"title":"Other"}}
    ]
  }'

Ask for the two nearest vectors:

curl -X POST http://localhost:8080/api/v1/namespaces/demo/query \
  -H 'content-type: application/json' \
  -d '{"vector":[0.1,0.2,0.3,0.4],"topK":2}'

The exact match, doc1, should be first with a score near 1.0. doc2 should follow.

Prove that compute is disposable

The repository's integration test writes a vector, queries it, and queries it again independently of the process that handled the write:

npm test

When running in a cloud deployment, replace or restart a reader and send the same query again. The result remains because the namespace metadata and segments live in Storage, not in the Container filesystem.

Put the database in a customer's cloud

1 · Release
alien release

Publishes a version. Nothing is deployed for a customer yet.

2 · Invite
alien onboard acme-corp

Creates a deployment link for that customer.

3 · Deploy

The customer opens the link and deploys into their environment.

The public router, Rust readers and writers, and durable Storage are created in the customer's environment. Later rollouts can replace the live services. Changing or removing the setup-owned Storage resource requires setup authority again.

What you built

You built one HTTPS vector API from three stateless services and one durable customer-owned resource. Writes become immutable objects, ETags coordinate concurrent metadata updates, and readers reconstruct the current namespace from Storage.

That is the larger Alien pattern: keep releaseable compute separate from customer-owned state. Your control plane can ship new reader and writer versions, while the customer's vectors remain in their cloud account.

Complete source: examples/byoc-database.

Next: Storage, Frozen and live resources, and Releases.

On this page