File Storage Architecture¶
This document describes the file storage system architecture for the OpenAI Files API compatibility layer, including persistent metadata storage.
Table of Contents¶
- Overview
- Storage Modes
- Architecture
- Storage Structure
- Metadata Schema
- Storage Backends
- Content Streaming and the Memory Model
- Authentication and Authorization
- Startup Recovery
- Orphan Detection and Cleanup
- Configuration
- API Endpoints
- Design Decisions
Overview¶
The File Storage system provides OpenAI Files API compatible file management with persistent metadata storage. It allows users to upload files for fine-tuning, batch processing, and other purposes while ensuring data durability across server restarts.
Key Features¶
- OpenAI Files API Compatibility: Full support for
/v1/filesendpoints - Persistent Metadata: File metadata survives server restarts
- Automatic Recovery: Rebuilds metadata index from sidecar files on startup
- Orphan Management: Detects and cleans up inconsistent file states
- Pluggable Backends: Support for memory and persistent storage backends
- Streamed Content: Uploads and downloads move chunk by chunk, so peak memory per transfer is a buffer rather than a file
Storage Modes¶
Two metadata storage backends are available via files.metadata_storage: memory and persistent (the default). The diagrams below show why persistent is the default.
In-Memory Metadata (metadata_storage: memory)¶
With the memory backend, file metadata lives only in an in-memory DashMap:
Constraints of the memory backend:
- Complete metadata loss on server restart
- Orphaned files on disk with no API access
- Inconsistent state between files and metadata
- No way to recover uploaded files
Persistent Metadata (metadata_storage: persistent, default)¶
With persistent metadata storage:
Architecture¶
Component Diagram¶
Layer Responsibilities¶
| Layer | Component | Responsibility |
|---|---|---|
| HTTP | Handlers | Request parsing, validation, response formatting |
| Services | FileService | Business logic, coordination |
| Services | MetadataBackend | Metadata storage abstraction |
| Infrastructure | LocalFileStorage | Physical file I/O |
Storage Structure¶
Directory Layout¶
storage/
├── a1b2c/ # Subdirectory (first 5 chars of file ID)
│ ├── file-a1b2c3d4e5f6.bin # Binary data file
│ └── file-a1b2c3d4e5f6.meta.json # Metadata sidecar file
├── x9y8z/
│ ├── file-x9y8z7w6v5u4.bin
│ └── file-x9y8z7w6v5u4.meta.json
└── ...
File Naming Convention¶
| File Type | Extension | Pattern | Description |
|---|---|---|---|
| Data | .bin | file-{id}.bin | Raw file content |
| Metadata | .meta.json | file-{id}.meta.json | JSON metadata sidecar |
| In-progress upload | .tmp | .file-{id}.{pid}.{seq}.tmp | Content of an upload that has not been published yet |
Uniqueness of the .tmp path comes from {id}, which is file- plus a v4 UUID, the same identifier that makes the destination .bin path unique. The process ID and per-process sequence number are diagnostic: they trace a leftover temporary back to the process and upload that abandoned it. They are not a uniqueness mechanism because two containers each see PID 1 with the sequence starting at 0.
The .tmp suffix is deliberately outside the .bin / .meta.json naming that the orphan scanner matches, so a live upload is never mistaken for stored content.
Sidecar Pattern Benefits¶
- Co-location: Data and metadata are stored together
- Atomic Operations: Metadata writes use atomic rename pattern
- Easy Backup: Simple directory copy preserves everything
- Debug Friendly: Human-readable JSON metadata
- No External Dependencies: No database required
Metadata Schema¶
FileMetadata Structure¶
{
"id": "file-abc123def456",
"object": "file",
"filename": "training_data.jsonl",
"bytes": 1048576,
"purpose": "fine-tune",
"created_at": 1699574400,
"content_type": "application/jsonl",
"storage_path": "a1b2c/file-abc123def456.bin"
}
Field Descriptions¶
| Field | Type | Description |
|---|---|---|
id | string | Unique file identifier (OpenAI format: file-{random}) |
object | string | Always "file" for API compatibility |
filename | string | Original uploaded filename |
bytes | integer | File size in bytes |
purpose | string | File purpose: fine-tune, batch, assistants, etc. |
created_at | integer | Unix timestamp of creation |
content_type | string | MIME type of the file |
storage_path | string | Relative path to data file |
Supported Purposes¶
| Purpose | Description |
|---|---|
fine-tune | Training data for fine-tuning |
batch | Batch API input files |
assistants | Files for Assistants API |
vision | Image files for vision models |
user_data | General user uploads |
evals | Evaluation datasets |
Storage Backends¶
MetadataBackend Trait¶
#[async_trait]
pub trait MetadataBackend: Send + Sync {
async fn insert(&self, metadata: FileMetadata) -> Result<(), FileError>;
async fn get(&self, id: &str) -> Option<FileMetadata>;
async fn remove(&self, id: &str) -> Option<FileMetadata>;
async fn list(&self, query: &FileListQuery) -> Vec<FileMetadata>;
async fn list_page(&self, query: &FileListQuery) -> FileListPage;
async fn list_by_owner(&self, query: &FileListQuery, owner_id: &str) -> FileListPage;
async fn len(&self) -> usize;
async fn is_empty(&self) -> bool;
}
Listing goes through list_page (all owners) and list_by_owner (a non-admin caller under files.auth.enforce_ownership). Both stores filter by purpose and owner while iterating their in-memory index, then one shared function (src/services/files/listing.rs) sorts by created_at with the file ID as a tie-break, applies the after cursor, cuts to limit, and sets has_more.
Filtering before the page cut keeps a caller's page from being filled by other owners' newer files. An after ID outside the filtered listing returns an empty page with has_more: false. Both methods have default bodies built on list, so an implementor that provides only list still pages correctly.
Backend Comparison¶
| Feature | MetadataStore (Memory) | PersistentMetadataStore |
|---|---|---|
| Persistence | No | Yes |
| Startup Recovery | No | Yes |
| Performance | Fastest | Fast (cached) |
| Orphan Detection | No | Yes |
| Use Case | Development/Testing | Production |
Write Path (Persistent)¶
1. Generate file ID
2. Stream content into storage/{subdir}/.file-{id}.{pid}.{seq}.tmp
3. Validate (size, type, content) once the purpose is known
4. fsync + atomic rename: .file-{id}.{pid}.{seq}.tmp → file-{id}.bin
5. Create metadata JSON
6. Write to temp file: file-{id}.meta.json.tmp
7. Atomic rename: file-{id}.meta.json.tmp → file-{id}.meta.json
8. Update in-memory cache
Steps 2 to 4 make the data file atomic: the total size is not known when the first byte arrives, so the content cannot be written straight to its final path. A reader therefore never observes a half-written .bin, and an upload that fails validation, errors, or is cancelled mid-flight leaves nothing behind. See Content Streaming and the Memory Model.
Read Path (Persistent)¶
1. Check in-memory cache (DashMap)
2. If hit → return cached metadata
3. If miss → (only on startup recovery)
a. Scan directory for .meta.json files
b. Parse and validate each file
c. Populate cache
Content Streaming and the Memory Model¶
File content is streamed end to end. An upload is written to disk chunk by chunk as it arrives off the wire, and a download is read back chunk by chunk as it is written to the client. Peak resident memory for a transfer is therefore a 64KB buffer plus whatever the HTTP layer holds, not the size of the file.
This matters for capacity planning: files.max_file_size is a policy and disk decision, not a memory decision. A router with a 512Mi memory limit can serve a 512MB upload, and concurrent transfers add a chunk buffer each rather than a file each.
Upload Path¶
multipart chunk --> size check --> incremental validation --> temp file
|
(all checks passed) --- fsync + rename ----+--> file-{id}.bin
- Size check. Each chunk is counted before it is accepted. Crossing
files.max_file_sizereturns413immediately, without reading the rest of the body. - Incremental validation. The first 13 bytes are retained for magic-byte checks (image signatures for
vision, executable signatures forassistants/user_data), and a non-allocating UTF-8 validator tracks the whole body for JSONL purposes. The validator carries a partial multi-byte sequence across a chunk boundary so a character split between two network chunks is judged identically to a single buffer, and a body ending mid-sequence is rejected. - Verdict. A multipart client may send the
filepart before thepurposepart, and the purpose selects which check applies, so the verdict is taken after the body is complete. Nothing is published before it passes. - Publish. The temporary file is fsynced and renamed into place. Only then is the metadata sidecar written.
Download Path¶
The authorization decision is made against metadata alone, before any content is read. A caller who knows a file ID but does not own it receives 403 without the router opening the file. The body is then streamed over the open file, so the response starts before the file has been fully read and never exists in memory as a whole.
What Still Buffers¶
Two consumers hold a whole file by nature, and both are bounded independently of files.max_file_size:
| Path | Why it buffers | Bound |
|---|---|---|
| File injection into chat requests | The content is base64-encoded into a JSON request body | 10MB for images, 32MB for PDFs (DefaultTransformer::with_max_size) |
| Provider passthrough upload | The bytes are re-uploaded to an upstream provider's Files API | The provider's own limit |
Because of these paths, the router emits a startup warning when files.max_file_size exceeds a quarter of the container memory limit reported by cgroup v2 (/sys/fs/cgroup/memory.max). The warning is skipped when there is no cgroup v2 limit, when the limit reads max, and on platforms without cgroups (macOS, bare metal).
Transient Disk Use¶
The verdict is taken after the whole body is on disk because the purpose can arrive in a later multipart part and selects the validation check. A rejected upload therefore occupies its full size on disk for the duration of the transfer before it is discarded. With N concurrent uploads the transient peak is N times max_file_size, and the Files group carries no rate-limit mount of its own.
This is a deliberate trade: disk pressure degrades a request, whereas the buffered design it replaced could terminate the process with an OOM kill. Operators who want the peak bounded have three levers:
server.max_concurrent_requestscaps in-flight requests process-wide. It is unset by default.- A smaller
files.max_file_sizelowers the per-upload ceiling directly. - A dedicated volume for
files.storage_pathkeeps a full upload directory from affecting anything else on the node.
Failure Modes¶
| Failure | Result |
|---|---|
| Client disconnects mid-upload | Temporary file removed, no metadata written |
| Validation rejects the content | Temporary file removed, 400, no metadata written |
| Disk full or write error | Temporary file removed, 500, no metadata written |
| Process killed mid-upload | Temporary .tmp file remains on disk (invisible to the API) and is reclaimed on startup after 24 hours |
When a process is killed mid-upload (SIGKILL, OOM kill, node eviction, or power loss), no in-process cleanup can run, so the .tmp file remains on disk without becoming visible through the API. On startup, the router reclaims its own temporaries (.file-{id}...tmp and file-{id}.meta.json.tmp) older than 24 hours, and FileService::detect_orphans reports the count so leaks remain observable. The pattern match stays narrow because files.storage_path may not be owned exclusively by the router.
The 24-hour threshold prevents the sweep from deleting a temporary belonging to an upload that is still streaming, including one in another router process sharing the storage directory. Because a temporary's mtime advances with every write, an active transfer keeps refreshing itself out of range. This reclamation is not gated on cleanup_orphans_on_startup: that flag guards .bin files that may still be recoverable data, whereas an aged temporary is unreachable by any API.
Authentication and Authorization¶
The Files API includes authentication and authorization to secure file operations.
Authentication Methods¶
| Method | Description | Use Case |
|---|---|---|
api_key (default) | Bearer token authentication | Production environments |
none | No authentication | Development/testing only |
Authorization Model¶
┌─────────────────────────────────────────────────────────────────┐
│ Files API Request │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Authentication Layer │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Extract Bearer Token → Validate API Key → Check Scope ││
│ └─────────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Authorization Layer │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Check File Ownership → Admin Override → Allow/Deny ││
│ └─────────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ File Operation │
└─────────────────────────────────────────────────────────────────┘
File Ownership¶
When enforce_ownership is enabled (default):
| Operation | Owner | Admin | Other Users |
|---|---|---|---|
| Upload | Creates owned file | Creates owned file | Creates owned file |
| List | Own files only | All files | Own files only |
| Get | Own files only | All files | 403 Forbidden |
| Download | Own files only | All files | 403 Forbidden |
| Delete | Own files only | All files | 403 Forbidden |
A file with no owner was stored before ownership tracking existed and stays readable by every authenticated caller. POST /admin/files does not create such files: it records the synthetic owner __admin__, which no caller identity matches, even a key whose user_id is __admin__. On the public Files API only an admin-scope key with admin_can_access_all reaches an admin upload, and the Responses and Anthropic file resolvers, which have no admin policy, refuse it.
Metadata Fields for Authorization¶
The FileMetadata structure includes ownership fields:
{
"id": "file-abc123def456",
"owner_id": "user-xyz789",
"organization_id": "org-abc123",
"source_ip": "192.168.1.100",
"created_at": 1699574400
}
| Field | Description |
|---|---|
owner_id | User ID who uploaded the file |
organization_id | Organization the user belongs to |
source_ip | IP address of the upload request (for audit) |
Audit Logging¶
All file operations are logged with authentication context:
INFO file_uploaded file_id="file-abc123" user_id="user-xyz" org_id="org-abc" client_ip="192.168.1.1"
INFO file_downloaded file_id="file-abc123" user_id="user-xyz"
INFO file_deleted file_id="file-abc123" user_id="user-xyz" client_ip="192.168.1.1"
WARN file_access_denied file_id="file-abc123" user_id="user-xyz" file_owner="user-other"
Security Considerations¶
- Development Keys: Only available when
CONTINUUM_DEV_MODEistrueor1, or in debug builds where the variable is unset - Scope Requirements: API keys must have the configured scope (default: "files")
- Legacy Files: Files without
owner_idare accessible by all authenticated users - Admin Override: Users with "admin" scope bypass ownership checks if configured
Startup Recovery¶
Recovery Process¶
┌─────────────────────────────────────────────────────┐
│ Server Startup │
└──────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Scan storage directory recursively │
│ Find all *.meta.json files │
└──────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ For each .meta.json file: │
│ 1. Parse JSON content │
│ 2. Validate schema │
│ 3. Check corresponding .bin exists │
│ 4. Add to in-memory cache │
└──────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Log recovery statistics: │
│ - Files recovered: N │
│ - Orphans detected: M │
└─────────────────────────────────────────────────────┘
Recovery Guarantees¶
- Idempotent: Safe to run multiple times
- Non-destructive: Never deletes files during recovery
- Partial Success: Continues even if some files are corrupted
- Logged: All recovery actions are logged for debugging
Orphan Detection and Cleanup¶
Orphan Types¶
| Type | Description | Cause |
|---|---|---|
| Orphaned Data | .bin file without .meta.json | Crash during upload, manual deletion |
| Orphaned Metadata | .meta.json without .bin | Crash during deletion, disk corruption |
Detection Algorithm¶
pub async fn detect_orphans(&self) -> Result<(Vec<PathBuf>, Vec<PathBuf>), FileError> {
// Scan all files in storage directory
for file in storage_directory {
if file.ends_with(".bin") {
// Check if corresponding .meta.json exists
let meta_path = file.replace(".bin", ".meta.json");
if !meta_path.exists() {
orphaned_data.push(file);
}
} else if file.ends_with(".meta.json") {
// Check if corresponding .bin exists
let data_path = file.replace(".meta.json", ".bin");
if !data_path.exists() {
orphaned_metadata.push(file);
}
}
}
Ok((orphaned_data, orphaned_metadata))
}
Cleanup Options¶
| Option | cleanup_orphans_on_startup | Effect |
|---|---|---|
| Disabled (default) | false | Only detect and log orphans |
| Enabled | true | Auto-delete orphaned metadata files |
Warning: Data file cleanup requires manual intervention to prevent accidental data loss.
TOCTOU Safety Note¶
Orphan cleanup is NOT safe during active file operations due to Time-of-Check-Time-of-Use race conditions:
Thread A: detect_orphans() → finds file-X as orphan
Thread B: upload() → creates metadata for file-X
Thread A: cleanup() → deletes "orphan" (now valid!)
Recommendation: Only run cleanup during server startup or maintenance windows.
Configuration¶
YAML Configuration¶
files:
enabled: true
max_file_size: 536870912 # 512MB
storage_path: "./data/files" # Supports ~ expansion
retention_days: 0 # startup sweep in days; 0 = keep forever
metadata_storage: persistent # "memory" or "persistent"
cleanup_orphans_on_startup: false
auth:
method: api_key # "none" or "api_key"
required_scope: files
enforce_ownership: true
admin_can_access_all: true
openai:
strategy: base64 # "base64" or "upload"
fallback_to_base64: true
cache_capacity: 1000
cache_ttl_secs: 86400
rate_limiting:
enabled: false
uploads_per_minute: 10 # POST /v1/files
downloads_per_minute: 100 # GET /v1/files/{id}/content
operations_per_minute: 1000 # GET /v1/files, GET/HEAD/DELETE /v1/files/{id}
When files.rate_limiting.enabled is true, the router enforces separate per-caller token-bucket limits (in requests per minute) across three operation categories: files_upload (uploads_per_minute), files_download (downloads_per_minute), and files_operations (operations_per_minute).
Each bucket is keyed per caller by the SHA-256 hash of the presented API key (Authorization: Bearer or x-api-key), the authenticated user ID, or the client IP, and returns x-ratelimit-* headers on every Files API response (429 Too Many Requests with Retry-After and details.limit_type when exceeded).
max_file_size supports 1KB to 5GB, and the whole range takes effect: the outer HTTP body cap on the upload routes is derived from this value (plus a fixed 1MB allowance for the multipart envelope), so raising it above 512MB raises the effective limit.
When retention_days is nonzero, startup runs a retention sweep over stored metadata records using the metadata sidecar created_at timestamp rather than filesystem mtime. Files whose age is at least the configured number of days are removed along with their .meta.json sidecar, while retention_days: 0 keeps files forever.
The sweep removes metadata before content so new lookups fail closed once a file is selected. On POSIX, a download that already opened the content handle continues streaming, while a caller that resolved metadata before opening content may receive a 404 if the unlink completes first. Each nonzero sweep logs the deleted and failed counts and increments file_retention_deletes_total{status="success"} for reclaimed files.
A max_file_size value outside the 1KB to 5GB range is reported as a startup warning rather than a startup failure, and the derived body cap is clamped into the range so an out-of-range value can neither remove the outer cap nor push it below the multipart envelope.
There are two places an oversized upload can be stopped, and both answer the same way:
| Request | Stopped by | Response |
|---|---|---|
File over max_file_size, whole body within the derived cap | The handler, mid-stream | 413, {"error": {"code": "file_too_large", ...}}, message File too large: exceeds maximum N bytes |
Body over the derived cap (roughly max_file_size + 1MB) | The transport, which truncates the body | The same 413 and the same body |
Environment overrides¶
The four files.auth fields have dedicated direct CONTINUUM_FILES_* overrides; no other Files API field does, and none of them expand ${VAR} references, so render deployment-specific values for the rest of files: into YAML/TOML before validation, or maintain separate validated deployment files.
export CONTINUUM_FILES_AUTH_METHOD=none # or api_key
export CONTINUUM_FILES_AUTH_SCOPE=files
export CONTINUUM_FILES_ENFORCE_OWNERSHIP=true # strict "true"/"false" only
export CONTINUUM_FILES_ADMIN_ACCESS_ALL=true # strict "true"/"false" only
Each variable applies even when the configuration has no files: section, layering onto FilesConfig::default() just like the rest of the router. Because these variables have no CLI-flag counterpart, precedence is environment variable > files.auth file value > default. An unrecognized CONTINUUM_FILES_AUTH_METHOD value, or a boolean variable set to anything other than true or false, fails startup with an explicit error instead of falling back silently.
The config-file watcher re-reads the process environment on every reload, so a CONTINUUM_FILES_* value is never silently replaced by file contents when the file changes; it is re-applied on top of the freshly parsed file every time.
Storage Backend Selection¶
| Backend | When to Use |
|---|---|
memory | Development, testing, ephemeral workloads |
persistent | Production, data durability required |
API Endpoints¶
POST /v1/files¶
Upload a new file. The body is streamed to storage as it arrives; see Content Streaming and the Memory Model.
curl -X POST http://localhost:8080/v1/files \
-H "Content-Type: multipart/form-data" \
-F "file=@training.jsonl" \
-F "purpose=fine-tune"
GET /v1/files¶
List all files.
GET /v1/files/:id¶
Get file metadata.
GET /v1/files/:id/content¶
Download file content. Authorization is decided before the file is opened, and the response body is streamed from disk.
DELETE /v1/files/:id¶
Delete a file.
Design Decisions¶
Why Sidecar JSON Files?¶
Alternatives Considered:
| Option | Pros | Cons |
|---|---|---|
| SQLite | ACID, queries | Additional dependency, complexity |
| Single JSON file | Simple | Concurrency issues, large file problems |
| RocksDB/LevelDB | Fast, durable | Heavy dependency |
| Sidecar JSON | Simple, no deps, co-located | Many small files |
Decision: Sidecar JSON files were chosen because:
- Fits existing file-based architecture
- No additional dependencies (uses existing
serde_json) - Files and metadata are co-located for easy backup/restore
- Atomic writes possible with rename pattern
- Human-readable for debugging
Why In-Memory Cache + Disk?¶
Pattern: Write-through cache with disk persistence
Benefits:
- Sub-millisecond read latency
- Durable writes
- Automatic recovery on restart
Why Not Database?¶
For the Files API use case:
- Typically hundreds to thousands of files, not millions
- Simple key-value access pattern
- No complex queries needed
- Filesystem already provides atomicity guarantees
A database would add:
- Operational complexity
- Additional dependency
- Potential single point of failure
Related Documentation¶
- Configuration Guide - Full configuration reference
- API Documentation - Complete API reference
- Architecture Guide - Overall system architecture