Skip to main content

Overview

flora uses three storage backends for different purposes:
  • PostgreSQL - Relational data (deployments, tokens, KV metadata, secrets)
  • Redis/Valkey - Cache and session storage
  • Sled - Per-guild key-value data (on-disk embedded database)

PostgreSQL

Connection Management

Using sqlx with async connection pooling.
let db_pool = PgPoolOptions::new()
    .max_connections(config.database.max_connections)
    .connect(&config.database.url)
    .await?;
Source: apps/runtime/src/main.rs:76 Configuration:
  • Default max connections: 5
  • Configurable via DATABASE_MAX_CONNECTIONS
  • Connection pooling handled by sqlx

Migrations

Migrations run automatically at startup using sqlx migrations.
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
MIGRATOR.run(&db_pool).await?;
Source: apps/runtime/src/main.rs:81 Migration files: apps/runtime/migrations/

Schema

CREATE TABLE deployments (
    id BIGSERIAL PRIMARY KEY,
    guild_id TEXT NOT NULL UNIQUE,
    entry_point TEXT NOT NULL,
    bundle TEXT NOT NULL,
    source_map JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Stores guild script bundles and source maps for error reporting.
CREATE TABLE kv_stores (
    id BIGSERIAL PRIMARY KEY,
    guild_id TEXT NOT NULL,
    store_name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(guild_id, store_name)
);
Indexes KV stores. Actual key-value data lives in Sled.
CREATE TABLE tokens (
    id BIGSERIAL PRIMARY KEY,
    token_hash TEXT NOT NULL UNIQUE,
    guild_id TEXT NOT NULL,
    description TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at TIMESTAMPTZ
);
API tokens for CLI authentication.
CREATE TABLE secrets (
    id BIGSERIAL PRIMARY KEY,
    guild_id TEXT NOT NULL,
    key TEXT NOT NULL,
    encrypted_value BYTEA NOT NULL,
    placeholder TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(guild_id, key)
);
Encrypted guild secrets (encrypted with ChaCha20-Poly1305).

Query Patterns

Compile-time checked queries:
let row = sqlx::query_as::<_, KvStoreRow>(
    r#"
    SELECT id, guild_id, store_name, created_at, updated_at
    FROM kv_stores
    WHERE guild_id = $1 AND store_name = $2
    "#,
)
.bind(guild_id)
.bind(store_name)
.fetch_one(&self.db)
.await?;
Source: apps/runtime/src/services/kv/service.rs:92 Transaction support:
let mut tx = db.begin().await?;
sqlx::query("INSERT INTO ...")
    .execute(&mut *tx)
    .await?;
tx.commit().await?;

Redis/Valkey

Connection Management

Using fred client with connection pooling and automatic reconnection.
let cache_client = Builder::from_config(cache_config)
    .with_connection_config(|config| {
        config.connection_timeout = Duration::from_secs(10);
    })
    .set_policy(ReconnectPolicy::new_exponential(0, 100, 30_000, 2))
    .build_pool(config.cache.pool_size)
    .expect("Failed to create redis pool");
Source: apps/runtime/src/main.rs:86 Reconnection policy:
  • Exponential backoff
  • Start: 100ms
  • Max: 30s
  • Multiplier: 2x

Use Cases

Session Storage

OAuth sessions stored with TTL.
pub async fn create_session(
    &self,
    user_id: &str,
    guild_id: &str,
) -> Result<String> {
    let session_id = generate_session_id();
    let key = format!("session:{}", session_id);
    let value = serde_json::json!({
        "user_id": user_id,
        "guild_id": guild_id,
    });
    self.redis
        .set(&key, value.to_string(), Some(Expiration::EX(self.ttl)), None, false)
        .await?;
    Ok(session_id)
}
Source: apps/runtime/src/services/auth/service.rs

Deployment Cache

Deployments cached to reduce PostgreSQL load.
let cache_key = format!("deployment:{}", guild_id);
if let Ok(cached) = self.cache.get::<String, String>(&cache_key).await {
    return Ok(serde_json::from_str(&cached)?);
}
Source: apps/runtime/src/services/deployments/mod.rs Cache invalidation:
  • On deployment upsert
  • On deployment delete
  • TTL-based expiration

Data Structures

Strings:
  • Session data (JSON serialized)
  • Cached deployments
Sets:
  • Active guild IDs
  • User permissions
Hashes:
  • User profiles
  • Guild metadata

Sled

Architecture

Sled is an embedded database (like SQLite) optimized for concurrent workloads. Source: apps/runtime/src/services/kv/service.rs:24 Key features:
  • Lock-free reads
  • ACID transactions
  • Zero-copy operations
  • Crash-safe (write-ahead log)

File Layout

data/kv/
├── {guild_id}/
│   ├── {store_name}/
│   │   ├── conf
│   │   ├── db
│   │   └── snap.{timestamp}/
│   └── backups/
│       └── {backup_id}/
│           └── {store_name}/
Each guild-store pair gets its own Sled instance.

Instance Caching

Sled instances are cached in memory to avoid reopening.
pub struct KvService {
    db: Pool<Postgres>,
    db_cache: Arc<RwLock<BoundedCache>>,
    base_path: PathBuf,
}
Source: apps/runtime/src/services/kv/service.rs:24 Cache policy:
  • LRU eviction
  • Max size: configurable (default 100 instances)
  • Thread-safe via Arc<RwLock>
Source: apps/runtime/src/services/kv/cache.rs

KV Operations

Set

pub async fn set(
    &self,
    guild_id: &str,
    store_name: &str,
    key: &str,
    value: &str,
    expiration: Option<i64>,
    metadata: Option<serde_json::Value>,
) -> Result<()> {
    let db = self.get_or_open_db(guild_id, store_name)?;
    db.insert(key.as_bytes(), value.as_bytes())?;

    if expiration.is_some() || metadata.is_some() {
        let key_metadata = RawKvKeyMetadata { expiration, metadata };
        let metadata_bytes = serde_json::to_vec(&key_metadata)?;
        self.get_metadata_tree(&db)?
            .insert(key.as_bytes(), metadata_bytes)?;
    }
    Ok(())
}
Source: apps/runtime/src/services/kv/service.rs:140

Get

pub async fn get(
    &self,
    guild_id: &str,
    store_name: &str,
    key: &str,
) -> Result<Option<String>> {
    let db = self.get_or_open_db(guild_id, store_name)?;
    match db.get(key.as_bytes())? {
        Some(bytes) => Ok(Some(String::from_utf8(bytes.to_vec())?)),
        None => Ok(None),
    }
}
Source: apps/runtime/src/services/kv/service.rs:107

List

pub async fn list_keys(
    &self,
    guild_id: &str,
    store_name: &str,
    prefix: Option<&str>,
    limit: Option<u32>,
    cursor: Option<&str>,
) -> Result<RawKvListKeysResult> {
    let db = self.get_or_open_db(guild_id, store_name)?;
    let start_key = cursor.or(prefix).unwrap_or("");
    let iter = db.range(start_key.as_bytes()..);
    // ... pagination logic
}
Source: apps/runtime/src/services/kv/service.rs:214 Pagination:
  • Cursor-based (key-based continuation)
  • Default limit: 100
  • Max limit: 1000

Metadata Tree

Each Sled instance has a separate tree for metadata.
const METADATA_TREE_NAME: &str = "__metadata";

fn get_metadata_tree(&self, db: &Db) -> Result<sled::Tree> {
    Ok(db.open_tree(METADATA_TREE_NAME)?)
}
Source: apps/runtime/src/services/kv/service.rs:346 Metadata structure:
pub struct RawKvKeyMetadata {
    pub expiration: Option<i64>, // Unix timestamp
    pub metadata: Option<serde_json::Value>,
}
Stored separately to avoid inflating all key reads.

Size Limits

LimitDefaultConfig
Max value size1 MBMAX_VALUE_SIZE
Max key length512 chars-
Max store name64 chars-
List limit100DEFAULT_LIST_LIMIT
List max1000MAX_LIST_LIMIT
Source: apps/runtime/src/services/kv/validation.rs

Backup and Export

pub async fn export_guild(&self, guild_id: &str) -> Result<String> {
    let stores = self.list_stores(guild_id).await?;
    let backup_id = Utc::now().timestamp().to_string();
    let backup_dir = self.base_path
        .join(guild_id)
        .join("backups")
        .join(&backup_id);

    for store in stores {
        let db = self.get_or_open_db(guild_id, &store.store_name)?;
        db.flush()?; // Ensure all writes are persisted
        copy_dir_all(&db_path, &store_backup_dir)?;
    }
    Ok(backup_id)
}
Source: apps/runtime/src/services/kv/service.rs:293 Backup strategy:
  1. Flush all pending writes
  2. Copy Sled directory to backup location
  3. Return backup ID (timestamp)

Storage Comparison

FeaturePostgreSQLRedisSled
TypeRelationalKey-ValueEmbedded KV
PersistenceDurableOptionalDurable
TransactionsFull ACIDLimitedACID
QueriesSQLCommandsRange scans
NetworkTCPTCPIn-process
Use CaseStructured dataCache/sessionsGuild KV
Backuppg_dumpRDB/AOFDirectory copy

Data Flow Example

Setting a KV value

  1. HTTP API receives request
  2. Verify store exists in PostgreSQL
  3. Open Sled instance (or get from cache)
  4. Insert key-value into Sled
  5. Insert metadata into Sled metadata tree (if provided)
  6. Flush to disk (async)

Loading a deployment

  1. Check Redis cache for deployment
  2. If miss, query PostgreSQL
  3. Cache in Redis with TTL
  4. Return deployment to caller

Storing a secret

  1. Encrypt value with ChaCha20-Poly1305
  2. Derive placeholder from key
  3. Insert into PostgreSQL secrets table
  4. Refresh runtime secrets for guild

Performance Tuning

PostgreSQL

[database]
max_connections = 10  # Increase for high load
Considerations:
  • More connections = more memory
  • Monitor connection pool usage
  • Use read replicas for scaling

Redis

[cache]
pool_size = 20  # Increase for high concurrency
Considerations:
  • Connection pooling reduces latency
  • Monitor cache hit rate
  • Set appropriate TTLs

Sled

Configuration:
  • Sled auto-tunes for workload
  • Increase cache size via BoundedCache
  • Flush periodically to reduce memory
const MAX_DB_CACHE_SIZE: usize = 100;
Source: apps/runtime/src/services/kv/cache.rs
Sled instances are kept open in cache. Limit cache size to prevent memory exhaustion.

Build docs developers (and LLMs) love