fabro/lib/apps/fabro-server/tests/it/api/events.rs
Scott Werner d65785d888 Simplify blob activation and share the test store fixture
Blob activation cleanups:
- Reuse fabro-db's append_to_path, remove_file_if_exists, and
  set_private_permissions instead of local duplicates.
- Return the store directly from activate_blob_storage; the report
  wrapper existed only to be logged internally and then discarded.
- Collapse compute_disk_preflight to return the required free bytes
  instead of echoing its inputs back through a struct.
- Deduplicate the "exactly one ok row" PRAGMA integrity_check protocol
  into one executor-generic helper used by the backup and live checks.
- Skip re-validating a freshly published backup; the staging copy was
  validated immediately before the atomic rename, so only a
  concurrently published file needs its own validation.
- Replace the manual anyhow wrapping plus duplicate error log in
  serve.rs with a plain .context(), matching other startup errors.
- Extract the disk-candidate enumeration in resource_sampler.rs that
  available_space_for_path had copy-pasted from sample_disk_resources.

Test fixture cleanups:
- Route all hand-assembled Database::new(..., test_blob_store()) test
  fixtures (32 sites) through fabro_store::test_support::test_database,
  and make that helper infallible instead of returning an unconditional
  Ok.
- Install the test blob schema from fabro_db::BLOBS_MIGRATION_SQL via a
  test-support-gated optional dependency instead of a four-level
  relative include_str! into fabro-db's migrations directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:02:35 -04:00

173 lines
5.8 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use chrono::{SecondsFormat, Utc};
use fabro_static::EnvVars;
use object_store::ObjectStore;
use object_store::memory::InMemory;
use tokio::sync::Barrier;
use tower::ServiceExt;
use crate::helpers::{MINIMAL_DOT, api, minimal_manifest_json, response_json, test_settings};
fn app_with_store(object_store: Arc<dyn ObjectStore>) -> axum::Router {
let settings = test_settings();
let store = Arc::new(fabro_store::test_support::test_database(
Arc::clone(&object_store),
"event-race",
Duration::from_millis(1),
None,
));
let artifact_store = fabro_store::ArtifactStore::new(object_store, "artifacts");
let state = fabro_server::test_support::TestAppStateBuilder::new()
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.store_bundle(store, artifact_store)
.build();
fabro_server::test_support::build_test_router(state)
}
async fn create_run(app: &axum::Router) -> String {
let request = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&minimal_manifest_json(MINIMAL_DOT))
.expect("manifest should serialize"),
))
.expect("create-run request should build");
let body = response_json(
app.clone().oneshot(request).await.unwrap(),
StatusCode::CREATED,
"POST /api/v1/runs",
)
.await;
body["id"]
.as_str()
.expect("create-run response should include an id")
.to_string()
}
fn append_stage_started_request(run_id: &str, index: usize) -> Request<Body> {
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"id": ulid::Ulid::new().to_string(),
"ts": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
"run_id": run_id,
"event": "stage.started",
"node_id": format!("race-{index}"),
"node_label": format!("Race {index}"),
"stage_id": format!("race-{index}@1"),
"actor": {
"kind": "worker",
"run_id": run_id,
},
"properties": {
"index": index,
"handler_type": "noop",
"attempt": 1,
"max_attempts": 1,
},
}))
.expect("event should serialize"),
))
.expect("append-event request should build")
}
async fn append_status_and_body(
app: axum::Router,
run_id: String,
index: usize,
barrier: Arc<Barrier>,
) -> (usize, StatusCode, String) {
barrier.wait().await;
let response = app
.oneshot(append_stage_started_request(&run_id, index))
.await
.expect("append-event response should execute");
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("append-event body should buffer");
(index, status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_event_appends_after_restart_keep_projection_cache_contiguous() {
let object_store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let first_app = app_with_store(Arc::clone(&object_store));
let run_id = create_run(&first_app).await;
tokio::time::sleep(Duration::from_millis(25)).await;
// Simulate a server restart: a fresh AppState opens the existing run with
// an empty active-run cache, so concurrent appends all race through the
// public event endpoint instead of sharing an already-open RunDatabase.
let restarted_app = app_with_store(object_store);
let appends = 64;
let barrier = Arc::new(Barrier::new(appends));
let mut tasks = Vec::with_capacity(appends);
for index in 0..appends {
tasks.push(tokio::spawn(append_status_and_body(
restarted_app.clone(),
run_id.clone(),
index,
Arc::clone(&barrier),
)));
}
let mut results = Vec::with_capacity(appends);
for task in tasks {
results.push(task.await.expect("append task should not panic"));
}
let failures = results
.iter()
.filter(|(_, status, _)| *status != StatusCode::OK)
.collect::<Vec<_>>();
assert!(
failures.is_empty(),
"all concurrent event appends should succeed, got failures: {failures:#?}"
);
let request = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/events")))
.body(Body::empty())
.expect("list-events request should build");
let body = response_json(
restarted_app.clone().oneshot(request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events"),
)
.await;
let events = body["data"]
.as_array()
.expect("events response should include data");
let seqs = events
.iter()
.map(|event| {
event["seq"]
.as_u64()
.expect("event should include numeric seq")
})
.collect::<Vec<_>>();
assert_eq!(
events.len(),
appends + 2,
"every append should be durable; observed seqs: {seqs:?}"
);
assert_eq!(
seqs,
(1..=u64::try_from(appends + 2).unwrap()).collect::<Vec<_>>(),
"event seqs should be contiguous"
);
}