Inside Lioran S3: How a PUT Becomes a Durable Object in the Rust Engine
Most object-storage APIs make PUT look almost offensively simple:
PUT /bucket/key
<body>
Inside Lioran S3 / Lioran Bastion, that request crosses several boundaries before the engine returns success.
I’m Swaraj Puppalwar, Founder & CTO of Lioran Group and Lioran Developer Solutions (LDS). This article begins a deeper series about the actual Rust engine behind Lioran S3 V1 Pre-Alpha.
The implementation discussed here is the current pre-alpha code, not a generic object-store design.
The important types
The local engine is centered around LocalObjectStore:
pub struct LocalObjectStore {
layout: StorageLayout,
metadata: Arc<dyn MetadataStore>,
durability: DurabilityMode,
chunk_size: usize,
metrics: Arc<PutMetrics>,
min_free_space_bytes: u64,
min_free_space_percent: Option<f64>,
}
This already tells us a lot.
The object engine does not own one giant database containing everything. It coordinates:
- physical filesystem layout
- a metadata abstraction
- durability policy
- bounded streaming
- performance metrics
- disk-capacity guardrails
Step 1: validate the logical target
put_object rejects empty bucket names and keys.
Then it asks the metadata layer whether the bucket exists:
let bucket_meta = self
.metadata
.get_bucket(bucket)?
.ok_or_else(|| BastionError::BucketNotFound(bucket.to_string()))?;
The filesystem is not the namespace authority.
The metadata layer is.
Step 2: check physical capacity
Before receiving the object, Bastion checks host free-space guardrails:
check_disk_space_available(...)
This is separate from bucket quota.
That distinction matters.
A bucket may have logical quota remaining while the physical host is nearly full.
Step 3: calculate overwrite-aware quota state
The engine checks whether the key already points to a committed object.
Its size is subtracted from projected usage so replacing a 5 GiB object with another 5 GiB object is not treated as adding a fresh 5 GiB forever.
Conceptually:
projected =
current_usage
- existing_object_size
+ new_object_size
There is an early quota guard before streaming and an exact check after the actual byte count is known.
Step 4: create independent IDs
The engine generates:
object_id
staging_id
using UUIDs.
The user key is not used as the physical filename.
That separation is fundamental to Bastion's storage model.
Step 5: stream into staging
The staging path is generated by StorageLayout.
The engine creates a temporary file and calls:
stream_to_staging(
stream,
&mut staging_file,
self.durability,
self.chunk_size,
)
That function performs the heavy data-path work:
AsyncRead
↓
fixed-size buffer
↓
SHA-256 update
↓
staging file write
↓
flush
↓
optional fsync
No full-object heap buffer is required.
Step 6: close the staging handle
After streaming, the file handle is explicitly dropped before rename.
That is especially relevant on platforms where open-handle rename behavior differs.
Step 7: validate capacity again
The engine checks host free space again after receiving the payload.
It also recalculates bucket quota using the exact number of bytes written.
If the final size exceeds quota, the staging file is removed.
Step 8: choose physical placement
The object ID is mapped into a partitioned physical path.
For an ID beginning with:
ab12...
the layout becomes conceptually:
objects/
└── ab/
└── 12/
└── <full-object-uuid>
This keeps arbitrary user keys away from raw filesystem path construction.
Step 9: promote the physical object
The current implementation performs:
fs::rename(&staging_path, &abs_path).await
to promote the staged file into the permanent object tree.
This is an important detail: the current code promotes the physical file before writing the final metadata record.
That is the behavior of the implementation today.
Step 10: build metadata
The engine constructs ObjectMetadata containing information including:
- internal object ID
- bucket
- logical key
- physical relative path
- byte size
- content type
- SHA-256
Step 11: commit metadata to RocksDB
The metadata record is written through:
self.metadata.put_object(&meta)
If that operation fails, the engine attempts to roll back the already-promoted physical file:
let _ = fs::remove_file(&abs_path).await;
This avoids intentionally leaving committed physical bytes with no metadata record.
Step 12: record timings
The PUT path records separate timings for:
receive
write
SHA-256
flush
fsync
close
mkdir
rename
metadata
total
When BASTION_TRACE_PUT_TIMINGS is enabled, those stages can be inspected individually.
That is far more useful than seeing only:
PUT took 427 ms
because it tells us where the 427 ms went.
The actual V1 write pipeline
The current implementation can be summarized as:
validate bucket/key
↓
check disk + quota
↓
create staging file
↓
stream + hash
↓
flush / optional fsync
↓
re-check capacity + quota
↓
choose UUID-based final path
↓
rename staging → object path
↓
write metadata to RocksDB
↓
rollback physical file if metadata fails
↓
return committed metadata
This is the kind of machinery hidden behind one innocent-looking PUT.
Lioran S3 V1 Pre-Alpha launched on October 1, 2026. The next planned release is November 17, 2026.
Repository: https://github.com/LioranGroupOfficial/LioranBastion-Rust
Documentation: https://docs.liorans3.sbs
Top comments (0)