fabro/lib/apps/fabro-server/src/server/handler/artifacts.rs
Bryan Helmkamp de60eb900f
refactor: deduplicate cached-run access and event scans
- Add AppState::cached_run with the standard 500/404 mapping and use it
  everywhere handlers read the shared run-projection cache. This also
  normalizes two inconsistencies: graph-source cache errors now map to
  500 (was 502), and a missing projection in PR create/unlink now maps
  to the canonical 404 (was a bespoke 500).
- Extract an EventScan cursor shared by the four run-event scan loops,
  delegate list_events_from to the paginated variant, and stop the
  stage-event scan once its page is full instead of walking the rest of
  the log.
- Hold Arc<RunProjection> in the local projection cache so opening a run
  no longer deep-copies the projection (copy-on-write via Arc::make_mut),
  and drop the now-unreachable shared-cache branch in last_event_seq.
- Trim hot-path clones: run_files serves the projection Arc directly,
  run-state serializes by reference, artifacts only checks existence, and
  the command-log handler opens a reader only for the CAS-blob branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:25:42 -04:00

649 lines
22 KiB
Rust

use std::sync::Arc;
use super::super::{
ApiError, AppState, ArtifactEntry, ArtifactKey, ArtifactListResponse, AsyncWriteExt, Body,
Bytes, DefaultBodyLimit, Digest, HashMap, HashSet, HeaderMap, IntoResponse, Json, NodeArtifact,
Path, Query, RequireRunBlob, RequireRunScoped, RequireStageArtifact, RequiredUser, Response,
Router, RunArtifactEntry, RunArtifactListResponse, RunId, Sha256, StageArtifactEntry, StageId,
State, StatusCode, StreamExt, WriteBlobResponse, axum_extract, bad_request_response, get,
header, octet_stream_response, parse_run_id_path, parse_stage_id_path,
payload_too_large_response, post, reject_if_archived, required_query_param,
validate_relative_artifact_path,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs/{id}/checkpoint", get(get_checkpoint))
.route("/runs/{id}/blobs", post(write_run_blob))
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
.route("/runs/{id}/artifacts", get(list_run_artifacts))
.route(
"/runs/{id}/stages/{stageId}/artifacts",
get(list_stage_artifacts)
.post(put_stage_artifact)
.layer(DefaultBodyLimit::disable()),
)
.route(
"/runs/{id}/stages/{stageId}/artifacts/download",
get(get_stage_artifact),
)
}
#[derive(serde::Deserialize)]
struct ArtifactFilenameParams {
#[serde(default)]
filename: Option<String>,
#[serde(default)]
retry: Option<u32>,
}
const MAX_SINGLE_ARTIFACT_BYTES: u64 = 10 * 1024 * 1024;
const MAX_MULTIPART_ARTIFACTS: usize = 100;
const MAX_MULTIPART_REQUEST_BYTES: u64 = 50 * 1024 * 1024;
const MAX_MULTIPART_MANIFEST_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ArtifactBatchUploadManifest {
entries: Vec<ArtifactBatchUploadEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct ArtifactBatchUploadEntry {
part: String,
path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expected_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
}
async fn get_checkpoint(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match state.cached_run(&id).await {
Ok(cached) => match cached.projection.current_checkpoint() {
Some(cp) => (StatusCode::OK, Json(cp.clone())).into_response(),
None => (StatusCode::OK, Json(serde_json::json!(null))).into_response(),
},
Err(err) => err.into_response(),
}
}
async fn write_run_blob(
RequireRunScoped(id): RequireRunScoped,
State(state): State<Arc<AppState>>,
body: Bytes,
) -> Response {
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
match state.stores.runs.open_run(&id).await {
Ok(run_store) => match run_store.write_blob(&body).await {
Ok(blob_id) => Json(WriteBlobResponse {
id: blob_id.to_string(),
})
.into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
},
Err(_) => ApiError::not_found("Run not found.").into_response(),
}
}
async fn read_run_blob(
RequireRunBlob(id, blob_id): RequireRunBlob,
State(state): State<Arc<AppState>>,
) -> Response {
match state.stores.runs.open_run_reader(&id).await {
Ok(run_store) => match run_store.read_blob(&blob_id).await {
Ok(Some(bytes)) => octet_stream_response(bytes),
Ok(None) => ApiError::not_found("Blob not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
},
Err(_) => ApiError::not_found("Run not found.").into_response(),
}
}
async fn ensure_run_exists(state: &AppState, run_id: &RunId) -> Result<(), Response> {
state
.cached_run(run_id)
.await
.map(|_| ())
.map_err(IntoResponse::into_response)
}
async fn list_run_artifacts(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Err(response) = ensure_run_exists(state.as_ref(), &id).await {
return response;
}
match state.artifact_store.list_for_run(&id).await {
Ok(entries) => Json(RunArtifactListResponse {
data: entries.into_iter().map(run_artifact_entry_from).collect(),
})
.into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
fn run_artifact_entry_from(entry: NodeArtifact) -> RunArtifactEntry {
RunArtifactEntry {
stage_id: entry.node.to_string(),
node_slug: entry.node.node_id().to_string(),
retry: entry.retry.cast_signed(),
relative_path: entry.filename,
size: entry.size.cast_signed(),
}
}
fn artifact_entry_from(entry: StageArtifactEntry) -> ArtifactEntry {
ArtifactEntry {
filename: entry.filename,
retry: entry.retry.cast_signed(),
size: entry.size.cast_signed(),
}
}
async fn list_stage_artifacts(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path((id, stage_id)): Path<(String, String)>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let stage_id = match parse_stage_id_path(&stage_id) {
Ok(stage_id) => stage_id,
Err(response) => return response,
};
if let Err(response) = ensure_run_exists(state.as_ref(), &id).await {
return response;
}
match state.artifact_store.list_for_node(&id, &stage_id).await {
Ok(entries) => Json(ArtifactListResponse {
data: entries.into_iter().map(artifact_entry_from).collect(),
})
.into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
enum ArtifactUploadContentType {
OctetStream,
Multipart { boundary: String },
}
struct ValidatedArtifactBatchEntry {
path: String,
sha256: Option<String>,
expected_bytes: Option<u64>,
}
#[allow(
clippy::result_large_err,
reason = "Upload content-type parsing returns HTTP client errors directly."
)]
fn artifact_upload_content_type(
headers: &HeaderMap,
) -> Result<ArtifactUploadContentType, Response> {
let value = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
ApiError::new(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"artifact uploads require a supported Content-Type",
)
.into_response()
})?;
let mime = value.split(';').next().unwrap_or(value).trim();
match mime {
"application/octet-stream" => Ok(ArtifactUploadContentType::OctetStream),
"multipart/form-data" => multer::parse_boundary(value)
.map(|boundary| ArtifactUploadContentType::Multipart { boundary })
.map_err(|err| bad_request_response(format!("invalid multipart boundary: {err}"))),
_ => Err(ApiError::new(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"artifact uploads only support application/octet-stream or multipart/form-data",
)
.into_response()),
}
}
#[allow(
clippy::result_large_err,
reason = "Content-Length parsing returns HTTP client errors directly."
)]
fn content_length_from_headers(headers: &HeaderMap) -> Result<Option<u64>, Response> {
headers
.get(header::CONTENT_LENGTH)
.map(|value| {
value
.to_str()
.map_err(|err| {
bad_request_response(format!("invalid content-length header: {err}"))
})
.and_then(|value| {
value.parse::<u64>().map_err(|err| {
bad_request_response(format!("invalid content-length header: {err}"))
})
})
})
.transpose()
}
#[allow(
clippy::result_large_err,
reason = "Multipart manifest parsing returns HTTP client errors directly."
)]
async fn read_multipart_manifest(
field: &mut multer::Field<'_>,
) -> Result<ArtifactBatchUploadManifest, Response> {
let mut manifest_bytes = Vec::new();
while let Some(chunk) = field
.chunk()
.await
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))?
{
manifest_bytes.extend_from_slice(&chunk);
if manifest_bytes.len() > MAX_MULTIPART_MANIFEST_BYTES {
return Err(payload_too_large_response(
"multipart manifest exceeds the server limit",
));
}
}
serde_json::from_slice(&manifest_bytes)
.map_err(|err| bad_request_response(format!("invalid multipart manifest: {err}")))
}
#[allow(
clippy::result_large_err,
reason = "Artifact batch validation returns HTTP client errors directly."
)]
fn validate_artifact_batch_manifest(
manifest: ArtifactBatchUploadManifest,
) -> Result<HashMap<String, ValidatedArtifactBatchEntry>, Response> {
if manifest.entries.is_empty() {
return Err(bad_request_response(
"multipart manifest must include at least one artifact entry",
));
}
if manifest.entries.len() > MAX_MULTIPART_ARTIFACTS {
return Err(payload_too_large_response(format!(
"multipart upload exceeds the {MAX_MULTIPART_ARTIFACTS} artifact limit"
)));
}
let mut entries = HashMap::with_capacity(manifest.entries.len());
let mut seen_paths = HashSet::new();
let mut expected_total_bytes = 0_u64;
for entry in manifest.entries {
if entry.part.is_empty() {
return Err(bad_request_response(
"multipart manifest part names must not be empty",
));
}
if entry.part == "manifest" {
return Err(bad_request_response(
"multipart manifest part name 'manifest' is reserved",
));
}
let path = validate_relative_artifact_path("manifest path", &entry.path)?;
if !seen_paths.insert(path.clone()) {
return Err(bad_request_response(format!(
"duplicate artifact path in multipart manifest: {path}"
)));
}
if let Some(sha256) = entry.sha256.as_ref() {
if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(bad_request_response(format!(
"invalid sha256 for multipart part {}",
entry.part
)));
}
}
if let Some(expected_bytes) = entry.expected_bytes {
if expected_bytes > MAX_SINGLE_ARTIFACT_BYTES {
return Err(payload_too_large_response(format!(
"artifact {path} exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
)));
}
expected_total_bytes = expected_total_bytes.saturating_add(expected_bytes);
if expected_total_bytes > MAX_MULTIPART_REQUEST_BYTES {
return Err(payload_too_large_response(format!(
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
)));
}
}
if entries
.insert(entry.part.clone(), ValidatedArtifactBatchEntry {
path,
sha256: entry.sha256.map(|value| value.to_ascii_lowercase()),
expected_bytes: entry.expected_bytes,
})
.is_some()
{
return Err(bad_request_response(format!(
"duplicate multipart part name in manifest: {}",
entry.part
)));
}
}
Ok(entries)
}
async fn upload_stage_artifact_octet_stream(
state: &AppState,
run_id: &RunId,
stage_id: &StageId,
retry: u32,
filename: String,
body: Body,
content_length: Option<u64>,
) -> Response {
let relative_path = match validate_relative_artifact_path("filename", &filename) {
Ok(path) => path,
Err(response) => return response,
};
if content_length.is_some_and(|length| length > MAX_SINGLE_ARTIFACT_BYTES) {
return payload_too_large_response(format!(
"artifact exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
));
}
let mut writer = match state.artifact_store.writer(
run_id,
&ArtifactKey::new(stage_id.clone(), retry, relative_path),
) {
Ok(writer) => writer,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mut bytes_written = 0_u64;
let mut data_stream = body.into_data_stream();
while let Some(chunk) = data_stream.next().await {
let chunk = match chunk
.map_err(|err| bad_request_response(format!("invalid request body: {err}")))
{
Ok(chunk) => chunk,
Err(response) => return response,
};
bytes_written =
bytes_written.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
if bytes_written > MAX_SINGLE_ARTIFACT_BYTES {
return payload_too_large_response(format!(
"artifact exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit"
));
}
if let Err(err) = writer.write_all(&chunk).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
match writer.shutdown().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
async fn upload_stage_artifact_multipart(
state: &AppState,
run_id: &RunId,
stage_id: &StageId,
retry: u32,
boundary: String,
body: Body,
) -> Response {
let mut multipart = multer::Multipart::new(body.into_data_stream(), boundary);
let Some(mut manifest_field) = (match multipart
.next_field()
.await
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
{
Ok(field) => field,
Err(response) => return response,
}) else {
return bad_request_response("multipart upload must begin with a manifest part");
};
if manifest_field.name() != Some("manifest") {
return bad_request_response("multipart upload must begin with a manifest part");
}
let manifest = match read_multipart_manifest(&mut manifest_field).await {
Ok(manifest) => manifest,
Err(response) => return response,
};
drop(manifest_field);
let mut expected_parts = match validate_artifact_batch_manifest(manifest) {
Ok(entries) => entries,
Err(response) => return response,
};
let mut total_bytes = 0_u64;
while let Some(mut field) = match multipart
.next_field()
.await
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
{
Ok(field) => field,
Err(response) => return response,
} {
let Some(part_name) = field.name().map(ToOwned::to_owned) else {
return bad_request_response("multipart file parts must be named");
};
let Some(entry) = expected_parts.remove(&part_name) else {
return bad_request_response(format!("unexpected multipart part: {part_name}"));
};
let mut writer = match state.artifact_store.writer(
run_id,
&ArtifactKey::new(stage_id.clone(), retry, entry.path.clone()),
) {
Ok(writer) => writer,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mut bytes_written = 0_u64;
let mut sha256 = Sha256::new();
while let Some(chunk) = match field
.chunk()
.await
.map_err(|err| bad_request_response(format!("invalid multipart body: {err}")))
{
Ok(chunk) => chunk,
Err(response) => return response,
} {
let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX);
bytes_written = bytes_written.saturating_add(chunk_len);
total_bytes = total_bytes.saturating_add(chunk_len);
if bytes_written > MAX_SINGLE_ARTIFACT_BYTES {
return payload_too_large_response(format!(
"artifact {} exceeds the {MAX_SINGLE_ARTIFACT_BYTES} byte limit",
entry.path
));
}
if total_bytes > MAX_MULTIPART_REQUEST_BYTES {
return payload_too_large_response(format!(
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
));
}
sha256.update(&chunk);
if let Err(err) = writer.write_all(&chunk).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
if let Some(expected_bytes) = entry.expected_bytes {
if bytes_written != expected_bytes {
return bad_request_response(format!(
"multipart part {part_name} expected {expected_bytes} bytes but received {bytes_written}"
));
}
}
if let Some(expected_sha256) = entry.sha256.as_ref() {
let actual_sha256 = hex::encode(sha256.finalize());
if actual_sha256 != *expected_sha256 {
return bad_request_response(format!(
"multipart part {part_name} sha256 did not match manifest"
));
}
}
if let Err(err) = writer.shutdown().await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
if !expected_parts.is_empty() {
let mut missing = expected_parts.into_keys().collect::<Vec<_>>();
missing.sort();
return bad_request_response(format!(
"multipart upload is missing part(s): {}",
missing.join(", ")
));
}
StatusCode::NO_CONTENT.into_response()
}
async fn put_stage_artifact(
State(state): State<Arc<AppState>>,
RequireStageArtifact(id, stage_id): RequireStageArtifact,
Query(params): Query<ArtifactFilenameParams>,
request: axum_extract::Request,
) -> Response {
let (parts, body) = request.into_parts();
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
if let Err(response) = ensure_run_exists(state.as_ref(), &id).await {
return response;
}
let retry = match required_query_param(params.retry.as_ref(), "retry") {
Ok(retry) => retry,
Err(response) => return response,
};
let content_length = match content_length_from_headers(&parts.headers) {
Ok(length) => length,
Err(response) => return response,
};
match artifact_upload_content_type(&parts.headers) {
Ok(ArtifactUploadContentType::OctetStream) => {
let filename = match required_query_param(params.filename.as_ref(), "filename") {
Ok(filename) => filename,
Err(response) => return response,
};
upload_stage_artifact_octet_stream(
state.as_ref(),
&id,
&stage_id,
retry,
filename,
body,
content_length,
)
.await
}
Ok(ArtifactUploadContentType::Multipart { boundary }) => {
if content_length.is_some_and(|length| length > MAX_MULTIPART_REQUEST_BYTES) {
return payload_too_large_response(format!(
"multipart upload exceeds the {MAX_MULTIPART_REQUEST_BYTES} byte limit"
));
}
upload_stage_artifact_multipart(state.as_ref(), &id, &stage_id, retry, boundary, body)
.await
}
Err(response) => response,
}
}
async fn get_stage_artifact(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path((id, stage_id)): Path<(String, String)>,
Query(params): Query<ArtifactFilenameParams>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let stage_id = match parse_stage_id_path(&stage_id) {
Ok(stage_id) => stage_id,
Err(response) => return response,
};
let filename = match required_query_param(params.filename.as_ref(), "filename") {
Ok(filename) => filename,
Err(response) => return response,
};
let retry = match required_query_param(params.retry.as_ref(), "retry") {
Ok(retry) => retry,
Err(response) => return response,
};
let relative_path = match validate_relative_artifact_path("filename", &filename) {
Ok(path) => path,
Err(response) => return response,
};
if let Err(response) = ensure_run_exists(state.as_ref(), &id).await {
return response;
}
match state
.artifact_store
.get(
&id,
&ArtifactKey::new(stage_id.clone(), retry, relative_path),
)
.await
{
Ok(Some(bytes)) => octet_stream_response(bytes),
Ok(None) => ApiError::not_found("Artifact not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}