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 thing that makes the destination .bin path unique. The pid and
the per-process sequence number are diagnostic: they let a leftover temporary be
traced back to the process and the upload that abandoned it. They are not a
uniqueness mechanism, and must not be treated as one, because two containers
each see pid 1 with the sequence starting at 0.
The .tmp suffix is deliberately outside the .bin / .meta.json naming 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 len(&self) -> usize;
async fn is_empty(&self) -> bool;
}
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 are what 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 the
magic-byte checks (image signatures for
vision, executable signatures forassistants/user_data), and a non-allocating UTF-8 validator tracks the whole body for the JSONL purposes. The validator carries a partial multi-byte sequence across a chunk boundary, so a character split between two network chunks is judged exactly as it would be in a single buffer, and a body that ends 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 gets 403 without the
router ever opening the file. The body is then a stream 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, the router still 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), so it never fires spuriously.
Transient Disk Use¶
The verdict is taken after the whole body is on disk, because the purpose
arrives in a later multipart part and the purpose selects the 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, where the buffered design it replaced could take the whole process down 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 | The .tmp file survives, because no in-process cleanup can run on SIGKILL, an OOM kill, a node eviction, or power loss. It is never visible through the API. Starting the router reclaims every temporary it recognises as its own (.file-{id}...tmp and file-{id}.meta.json.tmp) that is older than 24 hours, and FileService::detect_orphans reports the count so the leak is observable before then. The match is deliberately narrow rather than "anything ending in .tmp": files.storage_path is not guaranteed to be a directory the router owns exclusively. |
The 24 hour threshold exists so the sweep cannot delete a temporary belonging to
an upload that is still streaming, including one in another router process
sharing the storage directory. A temporary's mtime advances with every write, so
an in-progress transfer keeps refreshing itself out of range no matter how long
it runs. The reclamation is deliberately not gated on
cleanup_orphans_on_startup: that flag guards deleting .bin files that may
still be recoverable data, whereas an aged temporary is unambiguously garbage
that no API can reach.
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 |
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_MODEis set or in debug builds - 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
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
genuinely raises the limit.
When retention_days is nonzero, startup runs a retention sweep over stored
metadata records. The age basis is the metadata sidecar created_at timestamp,
not filesystem mtime; files whose age is at least the configured number of days
are removed with their .meta.json sidecar. retention_days: 0 keeps files
forever. The sweep removes metadata before content, so new lookups fail closed
once a file is selected; a download that already opened the content handle keeps
streaming on POSIX, while a caller that resolved metadata but has not opened
content yet may see a 404 if the unlink wins that race. Each nonzero sweep
logs the deleted and failed counts and increments
file_retention_deletes_total{status="success"} for reclaimed files.
A value outside that range is reported as a startup warning, not a startup
failure, and the derived body cap is clamped into the range. The warning rather
than a hard error is deliberate: max_file_size: 0 is accepted today, small
values are plausible, and a fatal check would turn a working router into one
that refuses to start. The clamp is what actually protects the routes, 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 |
The second case used to answer 400 invalid_request, because the handlers
classified every multipart failure as malformed input. A body the transport
truncated is a size failure, so the status now follows the cause rather than
how the client happened to frame the request.
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 at all; the override is layered onto FilesConfig::default() in that case, matching how the rest of the router treats an absent files: section. These variables have no CLI-flag counterpart, so precedence is simply the environment variable (when set) over the file's files.auth value over the default. An unrecognized CONTINUUM_FILES_AUTH_METHOD value, or a boolean variable set to anything other than exactly true or false, fails startup with a clear error instead of silently falling back.
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:
1. Fits existing file-based architecture
2. No additional dependencies (uses existing serde_json)
3. Files and metadata are co-located for easy backup/restore
4. Atomic writes possible with rename pattern
5. 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