rename hulylake -> server

Signed-off-by: Alexey Aristov <aav@acm.org>
This commit is contained in:
Alexey Aristov
2025-08-29 16:47:12 +02:00
parent ebebada1b1
commit c634e31cdc
10 changed files with 1 additions and 1 deletions
+243
View File
@@ -0,0 +1,243 @@
use actix_web::dev::ServiceRequest;
use actix_web::http::header::ContentLength;
use actix_web::web::{Header, Payload};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use blake3::Hasher;
use bytes::BytesMut;
use futures_util::StreamExt;
use size::Size;
use tracing::*;
use crate::s3::S3Client;
use crate::{
config::CONFIG,
postgres::{self, Pool},
};
use crate::handlers::{ApiError, HandlerResult};
pub struct Blob {
pub s3_key: String,
pub length: u64,
pub inline: Option<Vec<u8>>,
}
const MULTIPART_THRESHOLD: usize = 4; // mb
const INLINE_THRESHHOLD: usize = 100; // kb
fn random_key() -> String {
ksuid::Ksuid::generate().to_base62()
}
#[instrument(level = "debug", skip_all, fields(s3_bucket))]
pub async fn upload(
s3: &S3Client,
pool: &Pool,
request: &mut ServiceRequest,
payload: Payload,
) -> Result<Blob, ApiError> {
let span = Span::current();
let s3_bucket = &CONFIG.s3_bucket;
span.record("s3_bucket", &s3_bucket);
if let Ok(length) = request.extract::<Header<ContentLength>>().await
&& length.0 < Size::from_megabytes(MULTIPART_THRESHOLD).bytes() as usize
{
upload_regular(
s3,
pool,
&s3_bucket,
length.0 < Size::from_kilobytes(INLINE_THRESHHOLD).bytes() as usize,
payload,
)
.await
} else {
upload_multipart(s3, pool, &s3_bucket, payload).await
}
}
#[instrument(level = "debug", skip_all, fields(s3_key))]
async fn upload_regular(
s3: &S3Client,
pool: &Pool,
s3_bucket: &str,
require_inline: bool,
payload: Payload,
) -> Result<Blob, ApiError> {
let span = Span::current();
let payload = payload
.to_bytes_limited(Size::from_megabytes(MULTIPART_THRESHOLD).bytes() as usize)
.await
.map_err(|_| actix_web::error::ErrorPayloadTooLarge("payload too large"))??;
let length = payload.len() as u64;
let inline = if require_inline {
Some(payload.to_vec())
} else {
None
};
let mut hash = Hasher::new();
hash.update(&payload);
let hash = hash.finalize().to_hex().to_string();
let s3_key = if let Some(s3_key_found) = postgres::find_blob_by_hash(&pool, &hash).await? {
span.record("s3_key", &s3_key_found);
debug!(s3_key_found, "blob deduplicated");
s3_key_found
} else {
let s3_key = random_key();
span.record("s3_key", &s3_key);
s3.put_object()
.bucket(s3_bucket)
.key(&s3_key)
.body(ByteStream::from(payload))
.send()
.await?;
postgres::insert_blob(&pool, &s3_key, &hash).await?;
debug!("blob created");
s3_key
};
Ok(Blob {
s3_key,
length,
inline,
})
}
#[instrument(level = "debug", skip_all, fields(upload, s3_key))]
async fn upload_multipart(
s3: &S3Client,
pool: &Pool,
s3_bucket: &str,
mut payload: Payload,
) -> Result<Blob, ApiError> {
let span = Span::current();
let s3_key = random_key();
span.record("s3_key", &s3_key);
let create_multipart = s3
.create_multipart_upload()
.bucket(s3_bucket)
.key(&s3_key)
.send()
.await?;
let upload_id = create_multipart.upload_id().unwrap();
span.record("upload", &upload_id[upload_id.len().saturating_sub(16)..]);
debug!("upload start");
let upload_part = async |number, buffer: BytesMut| -> HandlerResult<CompletedPart> {
let upload = s3
.upload_part()
.bucket(s3_bucket)
.key(&s3_key)
.upload_id(upload_id)
.body(buffer.freeze().into())
.part_number(number)
.send()
.await?;
let part = CompletedPart::builder()
.e_tag(upload.e_tag.unwrap())
.part_number(number)
.build();
Ok(part)
};
let mut buffer = BytesMut::with_capacity(1024 * 1024 * 6);
let mut complete = CompletedMultipartUpload::builder();
let mut part_number = 1;
let mut hash = Hasher::new();
let mut total_in = 0;
let mut total_uploaded = 0;
while let Some(part) = payload.next().await {
if let Ok(part) = part {
hash.update(&part);
total_in += part.len();
buffer.extend_from_slice(&part);
// each part must be at least 5MB
if buffer.len() > 1024 * 1024 * 5 {
trace!(length = buffer.len(), part_number, "upload part");
total_uploaded += buffer.len();
let uploaded = upload_part(part_number, buffer).await?;
complete = complete.parts(uploaded);
buffer = BytesMut::new();
part_number += 1;
}
} else {
// TODO: cleanup incomplete upload
panic!("read error")
}
}
// the last part
if buffer.len() > 0 {
total_uploaded += buffer.len();
trace!(length = buffer.len(), part_number, "upload part");
let uploaded = upload_part(part_number, buffer).await?;
complete = complete.parts(uploaded);
}
assert_eq!(total_in, total_uploaded);
let _ = s3
.complete_multipart_upload()
.bucket(s3_bucket)
.key(&s3_key)
.multipart_upload(complete.build())
.upload_id(upload_id)
.send()
.await?;
let hash = hash.finalize().to_hex().to_string();
debug!(hash, "upload complete");
let s3_key = if let Some(s3_key_found) = postgres::find_blob_by_hash(&pool, &hash).await? {
debug!(s3_key_found, "blob deduplicated");
// delete uploaded
s3.delete_object()
.bucket(s3_bucket)
.key(s3_key)
.send()
.await?;
s3_key_found
} else {
debug!("blob created");
postgres::insert_blob(&pool, &s3_key, &hash).await?;
s3_key
};
Ok(Blob {
s3_key,
length: total_uploaded as u64,
inline: None,
})
}
+81
View File
@@ -0,0 +1,81 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
use std::{path::Path, sync::LazyLock};
use config::FileFormat;
use secrecy::SecretString;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct Config {
pub bind_port: u16,
pub bind_host: String,
pub token_secret: SecretString,
pub db_connection: String,
pub db_scheme: String,
pub s3_bucket: String,
}
pub mod hulyrs {
use std::sync::LazyLock;
pub static CONFIG: LazyLock<hulyrs::Config> = LazyLock::new(|| match hulyrs::Config::auto() {
Ok(config) => config,
Err(error) => {
eprintln!("configuration error: {}", error);
std::process::exit(1);
}
});
}
pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
const DEFAULTS: &str = r#"
bind_port = 8096
bind_host = "0.0.0.0"
token_secret = "secret"
db_connection = "postgresql://root@huly.local:26257/defaultdb?sslmode=disable"
db_scheme = "hulylake"
s3_bucket = "hulylake"
"#;
let mut builder =
config::Config::builder().add_source(config::File::from_str(DEFAULTS, FileFormat::Toml));
let path = Path::new("etc/config.toml");
if path.exists() {
builder = builder.add_source(config::File::with_name(path.as_os_str().to_str().unwrap()));
}
let settings = builder
.add_source(config::Environment::with_prefix("HULY"))
.build()
.and_then(|c| c.try_deserialize::<Config>());
match settings {
Ok(settings) => settings,
Err(error) => {
eprintln!("configuration error: {}", error);
std::process::exit(1);
}
}
});
+302
View File
@@ -0,0 +1,302 @@
use std::{collections::HashMap, fmt::Display, sync::Arc};
use actix_web::{
HttpRequest, HttpResponse,
body::SizedStream,
dev::ServiceRequest,
http::{
self,
header::{self, ContentType},
},
web::{Data, Header, Path, Payload},
};
use aws_sdk_s3::error::SdkError;
use bytes::Bytes;
use futures_util::Stream;
use serde::{Deserialize, Serialize};
use tracing::*;
use uuid::Uuid;
use crate::blob::upload;
use crate::s3::S3Client;
use crate::{
config::CONFIG,
postgres::{self, Pool},
};
#[derive(Deserialize, Debug)]
pub struct ObjectPath {
workspace: Uuid,
key: String,
}
#[derive(thiserror::Error, Debug)]
pub enum ApiError {
#[error("S3 Error: {0}")]
S3(String),
#[error(transparent)]
ActixError(#[from] actix_web::error::Error),
#[error(transparent)]
ActixParseError(#[from] actix_web::error::ParseError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type HandlerResult<T> = Result<T, ApiError>;
impl actix_web::error::ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
match self {
ApiError::ActixError(error) => error.error_response(),
_ => {
tracing::error!(error=%self, "Internal error in http handler");
HttpResponse::InternalServerError().body("Internal Server Error")
}
}
}
}
impl<E: Display + std::error::Error + 'static, B: std::fmt::Debug> From<SdkError<E, B>>
for ApiError
{
fn from(error: SdkError<E, B>) -> Self {
error.raw_response();
ApiError::S3(format!("{} {:#?}", error, error.raw_response()))
}
}
#[derive(Serialize, serde::Deserialize, Debug)]
struct PartData {
workspace: Uuid,
key: String,
part: u32,
size: u64,
blob: String,
etag: String,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
meta: Option<HashMap<String, String>>,
}
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
pub async fn put(request: HttpRequest, payload: Payload) -> HandlerResult<HttpResponse> {
let span = Span::current();
let mut request = ServiceRequest::from_request(request);
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
span.record("workspace", path.workspace.to_string());
span.record("huly_key", &path.key);
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
let s3 = request.app_data::<Data<S3Client>>().unwrap().to_owned();
debug!("put request");
let mut headers = Vec::new();
for (key, value) in request.headers().iter() {
if let Some(header) = key.as_str().strip_prefix("huly-header-") {
if let Ok(value) = value.to_str() {
headers.push((header.to_owned(), value.to_owned()));
}
}
}
let content_type = request
.extract::<Header<header::ContentType>>()
.await
.unwrap_or(Header(ContentType::octet_stream()))
.into_inner();
headers.push((
http::header::CONTENT_TYPE.as_str().to_owned(),
content_type.to_string(),
));
let mut meta = Vec::new();
for (key, value) in request.headers().iter() {
if let Some(header) = key.as_str().strip_prefix("huly-meta-") {
if let Ok(value) = value.to_str() {
meta.push((header.to_owned(), value.to_owned()));
}
}
}
let uploaded = upload(&s3, &pool, &mut request, payload).await?;
let part_data = PartData {
workspace: path.workspace,
key: path.key,
part: 0,
blob: uploaded.s3_key,
size: uploaded.length,
etag: ksuid::Ksuid::generate().to_base62(),
headers: Some(headers.clone().into_iter().collect()),
meta: Some(meta.into_iter().collect()),
};
postgres::set_part(
&pool,
path.workspace,
&part_data.key,
uploaded.inline,
&part_data,
)
.await?;
let mut response = HttpResponse::Created();
response.insert_header((header::CONTENT_LOCATION, part_data.key));
response.insert_header((header::ETAG, part_data.etag));
for (key, value) in headers {
response.insert_header((key.as_str(), value));
}
Ok(response.finish())
}
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
pub async fn post(request: HttpRequest, payload: Payload) -> HandlerResult<HttpResponse> {
let span = Span::current();
let mut request = ServiceRequest::from_request(request);
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
span.record("workspace", path.workspace.to_string());
span.record("huly_key", &path.key);
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
let s3 = request.app_data::<Data<S3Client>>().unwrap().to_owned();
let uploaded = upload(&s3, &pool, &mut request, payload).await?;
let parts = postgres::find_parts::<PartData>(&pool, path.workspace, &path.key).await?;
let part = parts
.iter()
.map(|p| p.data.part)
.reduce(u32::max)
.map(|m| m + 1)
.unwrap_or(0);
let part_data = PartData {
workspace: path.workspace,
key: path.key,
part,
blob: uploaded.s3_key,
size: uploaded.length,
etag: ksuid::Ksuid::generate().to_base62(),
headers: None,
meta: None,
};
if parts.is_empty() {
postgres::set_part(&pool, path.workspace, &part_data.key, None, &part_data).await?;
} else {
// append
postgres::append_part(
&pool,
path.workspace,
&part_data.key,
part_data.part,
uploaded.inline,
&part_data,
)
.await?;
}
let mut response = HttpResponse::Created();
response.insert_header((header::ETAG, part_data.etag));
Ok(response.finish())
}
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
pub async fn get(request: HttpRequest) -> HandlerResult<HttpResponse> {
let span = Span::current();
let mut request = ServiceRequest::from_request(request);
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
span.record("workspace", path.workspace.to_string());
span.record("huly_key", &path.key);
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
let s3 = request
.app_data::<Data<S3Client>>()
.unwrap()
.to_owned()
.into_inner();
let parts = postgres::find_parts::<PartData>(&pool, path.workspace, &path.key).await?;
fn stream(
parts: Vec<postgres::ObjectPart<PartData>>,
s3: Arc<S3Client>,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> {
use async_stream::stream;
stream! {
for parts in parts {
match parts.inline {
Some(inline) => {
yield Ok(Bytes::from(inline));
},
None => {
match s3.get_object().bucket(&CONFIG.s3_bucket).key(parts.data.blob).send().await {
Ok(mut response) => {
while let Some(bytes) = response.body.next().await {
yield Ok(bytes?);
}
},
Err(error) => {
yield Err(std::io::Error::new(std::io::ErrorKind::Other, error));
break;
}
}
}
}
}
}
}
let response = if !parts.is_empty() {
let mut content_length = 0;
for part in parts.iter() {
content_length += part.data.size;
}
let mut response = HttpResponse::Ok();
let etag = parts.last().unwrap().data.etag.to_owned();
response.insert_header((header::CONTENT_LENGTH, content_length));
response.insert_header((header::ETAG, etag));
let headers = parts[0].data.headers.as_ref();
if let Some(headers) = headers {
for (header, value) in headers.iter() {
response.insert_header((header.as_str(), value.to_owned()));
}
}
response.body(SizedStream::new(content_length, stream(parts, s3)))
} else {
HttpResponse::NotFound().finish()
};
Ok(response)
}
pub async fn delete(_path: Path<ObjectPath>) -> HandlerResult<HttpResponse> {
unimplemented!("delete is not implemented")
}
+113
View File
@@ -0,0 +1,113 @@
use std::net::SocketAddr;
use actix_cors::Cors;
use actix_web::{
App, Error, HttpMessage, HttpServer,
body::MessageBody,
dev::{ServiceRequest, ServiceResponse},
middleware::{Next, from_fn},
web::{self, Data, Path},
};
use tracing::*;
use tracing_actix_web::TracingLogger;
use uuid::Uuid;
use hulyrs::services::jwt::actix::ServiceRequestExt;
mod blob;
mod config;
mod handlers;
mod postgres;
mod s3;
use config::CONFIG;
fn initialize_tracing() {
use tracing_subscriber::{filter::targets::Targets, prelude::*};
let filter = Targets::default()
.with_target(env!("CARGO_BIN_NAME"), config::hulyrs::CONFIG.log)
.with_target("actix", Level::WARN);
let format = tracing_subscriber::fmt::layer().compact();
tracing_subscriber::registry()
.with(filter)
.with(format)
.init();
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
initialize_tracing();
tracing::info!(
"{}/{} started",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
);
let postgres = postgres::pool().await?;
let s3 = s3::client().await;
let bind_to = SocketAddr::new(CONFIG.bind_host.as_str().parse()?, CONFIG.bind_port);
async fn auth(
mut request: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
let claims = request
.extract_claims(&CONFIG.token_secret)
.map_err(|error| {
warn!(%error, "Unauthorized request");
error
})?;
let workspace = Uuid::parse_str(&request.extract::<Path<String>>().await?);
if claims.is_system() || Ok(claims.workspace.clone()) == workspace.clone().map(Some) {
request.extensions_mut().insert(claims);
next.call(request).await
} else {
warn!(
expected = ?claims.workspace,
actual = ?workspace,
"Unauthorized request, workspace mismatch"
);
Err(actix_web::error::ErrorUnauthorized("Unauthorized").into())
}
}
let server = HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600);
const KEY_PATH: &str = "/{key:.*}";
App::new()
.app_data(Data::new(postgres.clone()))
.app_data(Data::new(s3.clone()))
.wrap(TracingLogger::default())
.wrap(cors)
.service(
web::scope("/api/{workspace}")
.wrap(from_fn(auth))
.route(KEY_PATH, web::get().to(handlers::get))
.route(KEY_PATH, web::put().to(handlers::put))
.route(KEY_PATH, web::post().to(handlers::post))
.route(KEY_PATH, web::delete().to(handlers::delete)),
)
.route("/status", web::get().to(async || "ok"))
})
.bind(bind_to)?
.run();
info!("http listener on {}", bind_to);
server.await?;
Ok(())
}
+194
View File
@@ -0,0 +1,194 @@
use std::pin::Pin;
use bb8_postgres::PostgresConnectionManager;
use serde::de::DeserializeOwned;
use tokio_postgres::NoTls;
use tokio_postgres::{self as pg};
use tracing::*;
use crate::config::CONFIG;
pub type Pool = bb8::Pool<PostgresConnectionManager<NoTls>>;
pub async fn pool() -> anyhow::Result<Pool> {
tracing::debug!(
connection = CONFIG.db_connection,
"database connection string"
);
let manager = bb8_postgres::PostgresConnectionManager::new_from_stringlike(
&CONFIG.db_connection,
tokio_postgres::NoTls,
)?;
#[derive(Debug)]
struct ConnectionCustomizer;
impl bb8::CustomizeConnection<pg::Client, pg::Error> for ConnectionCustomizer {
fn on_acquire<'a>(
&'a self,
client: &'a mut pg::Client,
) -> Pin<Box<dyn Future<Output = Result<(), pg::Error>> + Send + 'a>> {
Box::pin(async {
client
.execute("set search_path to $1", &[&CONFIG.db_scheme])
.await
.unwrap();
Ok(())
})
}
}
let pool = bb8::Pool::builder()
.max_size(15)
.connection_customizer(Box::new(ConnectionCustomizer))
.build(manager)
.await?;
{
let mut connection = pool.dedicated_connection().await?;
// query params cannot be bound in ddl statements
connection
.execute(
&format!("create schema if not exists {}", CONFIG.db_scheme),
&[],
)
.await?;
refinery::embed_migrations!("etc/migrations");
let report = migrations::runner()
.set_migration_table_name("migrations")
.run_async(&mut connection)
.await?;
for m in report.applied_migrations().iter() {
info!(migration = m.to_string(), "apply migration");
}
}
Ok(pool)
}
#[instrument(level = "debug", skip_all)]
pub async fn find_blob_by_hash(pool: &Pool, hash: &str) -> anyhow::Result<Option<String>> {
let connection = pool.get().await?;
let blob = connection
.query("select key from blob where hash = $1", &[&hash])
.await?;
Ok(match blob.as_slice() {
[found] => Some(found.get::<_, String>("key")),
[] => None,
_ => panic!(),
})
}
#[instrument(level = "debug", skip_all)]
pub async fn insert_blob(pool: &Pool, key: &str, hash: &str) -> anyhow::Result<()> {
let connection = pool.get().await?;
connection
.execute(
"insert into blob (key, hash) values ($1, $2)",
&[&key, &hash],
)
.await?;
Ok(())
}
#[derive(Debug)]
pub struct ObjectPart<T: DeserializeOwned + std::fmt::Debug> {
pub inline: Option<Vec<u8>>,
pub data: T,
}
pub async fn find_parts<T: DeserializeOwned + std::fmt::Debug>(
pool: &Pool,
workspace: uuid::Uuid,
key: &str,
) -> anyhow::Result<Vec<ObjectPart<T>>> {
let connection = pool.get().await?;
let rows = connection
.query(
"select part, data, inline from object where workspace = $1 and key = $2 order by part",
&[&workspace, &key],
)
.await?;
let mut parts = Vec::with_capacity(rows.len());
for row in rows {
let data = row.get::<_, serde_json::Value>("data");
let inline = row.get::<_, Option<Vec<u8>>>("inline");
let data = serde_json::from_value(data)?;
parts.push(ObjectPart { inline, data })
}
Ok(parts)
}
pub async fn append_part<D: serde::Serialize>(
pool: &Pool,
workspace: uuid::Uuid,
key: &str,
part: u32,
inline: Option<Vec<u8>>,
data: &D,
) -> anyhow::Result<()> {
let connection = pool.get().await?;
let data = serde_json::to_value(data)?;
connection
.execute(
"insert into object (workspace, key, part, inline, data) values ($1, $2, $3, $4, $5)",
&[&workspace, &key, &part, &inline, &data],
)
.await?;
Ok(())
}
pub async fn set_part<D: serde::Serialize>(
pool: &Pool,
workspace: uuid::Uuid,
key: &str,
inline: Option<Vec<u8>>,
data: &D,
) -> anyhow::Result<()> {
let mut connection = pool.get().await?;
let transaction = connection.transaction().await?;
transaction
.execute(
"delete from object where workspace = $1 and key = $2",
&[&workspace, &key],
)
.await?;
let data = serde_json::to_value(data)?;
transaction
.execute(
r#"
insert into object (workspace, key, part, inline, data) values ($1, $2, 0, $3, $4)
on conflict (workspace, key, part) do update set
inline = $3,
data = $4
"#,
&[&workspace, &key, &inline, &data],
)
.await?;
transaction.commit().await?;
Ok(())
}
+156
View File
@@ -0,0 +1,156 @@
use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_s3::{
Config,
types::{CompletedMultipartUpload, CompletedPart},
};
use blake3::{Hash, Hasher};
use bytes::{Bytes, BytesMut};
use futures::stream::StreamExt;
use futures_util::Stream;
use tracing::*;
pub type S3Client = aws_sdk_s3::Client;
pub async fn client() -> S3Client {
let ref sdk_config = aws_config::defaults(BehaviorVersion::latest())
.load()
.await
.into_builder()
.build();
let s3_config = Config::from(sdk_config)
.to_builder()
.force_path_style(true)
.build();
S3Client::from_conf(s3_config)
}
pub struct Upload {
pub hash: Hash,
pub length: usize,
}
async fn multipart_upload_stream(
s3: &S3Client,
bucket: &str,
key: &str,
upload_id: &str,
mut source: impl Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
) -> Result<(CompletedMultipartUpload, Upload)> {
debug!("upload start");
let upload_part = async |number, buffer: Bytes| -> Result<CompletedPart> {
let upload = s3
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.body(buffer.into())
.part_number(number)
.send()
.await?;
let part = CompletedPart::builder()
.e_tag(upload.e_tag.unwrap())
.part_number(number)
.build();
Ok(part)
};
let mut buffer = BytesMut::with_capacity(1024 * 1024 * 6);
let mut complete = CompletedMultipartUpload::builder();
let mut part_number = 1;
let mut hash = Hasher::new();
let mut total_in = 0;
let mut length = 0;
while let Some(part) = source.next().await {
let part = part?;
hash.update(&part);
total_in += part.len();
buffer.extend_from_slice(&part);
// each part must be at least 5MB
if buffer.len() > 1024 * 1024 * 5 {
trace!(length = buffer.len(), part_number, "upload part");
length += buffer.len();
let uploaded = upload_part(part_number, buffer.freeze()).await?;
complete = complete.parts(uploaded);
buffer = BytesMut::new();
part_number += 1;
}
}
// the last part
if buffer.len() > 0 {
length += buffer.len();
trace!(length = buffer.len(), part_number, "upload part");
let uploaded = upload_part(part_number, buffer.freeze()).await?;
complete = complete.parts(uploaded);
}
assert_eq!(total_in, length);
let hash = hash.finalize();
Ok((complete.build(), Upload { hash, length }))
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn multipart_upload<S>(
s3: &S3Client,
bucket: &str,
key: &str,
source: impl Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
) -> Result<Upload> {
let span = Span::current();
let create_multipart = s3
.create_multipart_upload()
.bucket(bucket)
.key(key)
.send()
.await?;
let upload_id = create_multipart.upload_id().unwrap();
span.record("upload", &upload_id[upload_id.len().saturating_sub(16)..]);
match multipart_upload_stream(s3, bucket, key, upload_id, source).await {
Ok((complete, upload)) => {
s3.complete_multipart_upload()
.bucket(bucket)
.key(key)
.multipart_upload(complete)
.upload_id(upload_id)
.send()
.await?;
debug!(hash = %upload.hash, length = upload.length, "upload complete");
Ok(upload)
}
Err(error) => {
s3.abort_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.send()
.await?;
error!(%error, "upload error");
Err(error)
}
}
}