Skip to content

Commit e475f54

Browse files
committed
fix(sof): spec-conformance fixes from PR HL7/sql-on-fhir#365 assessment
Four fixes found by auditing the implementation against spec PR #365: - Unsupported _format → 400 (was 415 on the sof-server body path): SofError::UnsupportedContentType now maps to ServerError::BadRequest, per the spec's "SHALL be rejected with 400 Bad Request + OperationOutcome". The stub tests that entrenched 415 are corrected, and a new in-bin test locks the 400 against the production handler. - _format=json export shards are now downloaded with Content-Type: application/json (previously fell through to the ndjson branch); test asserts the header. - Export cancellation race: a job cancelled mid-run could be resurrected to Completed/Failed by its background task, making post-DELETE polls return 200 instead of the spec-required 404. Completion/failure/progress writebacks now go through set_status_if_running, which leaves a Cancelled job untouched. Deterministic unit test added using a blocking mock SofRunner. - HFS_EXPORT_PRESIGN_TTL_SECS default raised 3600 → 86400 so out-of-the-box S3 deployments honor the spec's >= 24h output.location validity (matching the Expires header already advertised on the completion poll).
1 parent bbcaa0a commit e475f54

9 files changed

Lines changed: 170 additions & 23 deletions

File tree

crates/rest/src/config.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,11 @@ pub struct ServerConfig {
583583
pub export_s3_region: Option<String>,
584584

585585
/// Pre-signed URL TTL (seconds) for S3 export sink.
586-
#[arg(long, env = "HFS_EXPORT_PRESIGN_TTL_SECS", default_value = "3600")]
586+
///
587+
/// Defaults to 24 hours: the SQL-on-FHIR spec requires `output.location`
588+
/// download URLs to remain valid for at least 24 hours after export
589+
/// completion (matching the `Expires` header on the completion poll).
590+
#[arg(long, env = "HFS_EXPORT_PRESIGN_TTL_SECS", default_value = "86400")]
587591
pub export_presign_ttl_secs: u64,
588592

589593
/// Maximum concurrent export jobs.
@@ -682,7 +686,7 @@ impl Default for ServerConfig {
682686
export_dir: "./exports".to_string(),
683687
export_s3_bucket: None,
684688
export_s3_region: None,
685-
export_presign_ttl_secs: 3600,
689+
export_presign_ttl_secs: 86_400,
686690
export_max_concurrency: 4,
687691
export_shard_rows: 500_000,
688692
export_controller: "memory".to_string(),
@@ -793,7 +797,7 @@ impl ServerConfig {
793797
export_dir: "./exports".to_string(),
794798
export_s3_bucket: None,
795799
export_s3_region: None,
796-
export_presign_ttl_secs: 3600,
800+
export_presign_ttl_secs: 86_400,
797801
export_max_concurrency: 4,
798802
export_shard_rows: 500_000,
799803
export_controller: "memory".to_string(),

crates/rest/src/export/in_memory.rs

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,9 @@ impl<Sink: ExportSink + 'static> ExportJobController for InMemoryController<Sink
157157
shards = completed_files.len(),
158158
"export job completed"
159159
);
160-
jobs.insert(
161-
jid,
160+
set_status_if_running(
161+
&jobs,
162+
&jid,
162163
JobStatus::Completed {
163164
files: completed_files,
164165
submitted_at,
@@ -170,8 +171,9 @@ impl<Sink: ExportSink + 'static> ExportJobController for InMemoryController<Sink
170171
}
171172
Err(message) => {
172173
warn!(job_id = %jid, error = %message, "export job failed");
173-
jobs.insert(
174-
jid,
174+
set_status_if_running(
175+
&jobs,
176+
&jid,
175177
JobStatus::Failed {
176178
message,
177179
submitted_at,
@@ -236,8 +238,23 @@ fn ext_for(format: &str) -> &'static str {
236238
}
237239
}
238240

241+
/// Transitions `jid` to `status` only if the job is still `Running`.
242+
///
243+
/// A job cancelled mid-run keeps its `Cancelled` state: the spec requires
244+
/// status polls after a DELETE to return 404, so a background task that
245+
/// finishes anyway must not resurrect the job to Completed/Failed.
246+
fn set_status_if_running(jobs: &DashMap<String, JobStatus>, jid: &str, status: JobStatus) {
247+
if let Some(mut entry) = jobs.get_mut(jid) {
248+
if matches!(&*entry, JobStatus::Running { .. }) {
249+
*entry = status;
250+
}
251+
}
252+
}
253+
239254
/// Records job progress, capped at 99 while running so callers don't see
240255
/// "100%" until the manifest is actually available from the status poll.
256+
/// A job that is no longer `Running` (e.g. cancelled mid-run) is left
257+
/// untouched — see [`set_status_if_running`].
241258
fn record_progress(
242259
jobs: &DashMap<String, JobStatus>,
243260
jid: &str,
@@ -246,8 +263,9 @@ fn record_progress(
246263
total: u32,
247264
) {
248265
let percent = ((done * 100) / total.max(1)).min(99) as u8;
249-
jobs.insert(
250-
jid.to_string(),
266+
set_status_if_running(
267+
jobs,
268+
jid,
251269
JobStatus::Running {
252270
percent,
253271
submitted_at,
@@ -648,3 +666,86 @@ fn csv_cell(v: &serde_json::Value) -> String {
648666
}
649667
}
650668
}
669+
670+
#[cfg(test)]
671+
mod tests {
672+
use super::*;
673+
use crate::export::sink::InMemorySink;
674+
use async_trait::async_trait;
675+
use helios_persistence::core::sof_runner::{RowStream, SofError, ViewFilters};
676+
use helios_persistence::tenant::{TenantContext, TenantId, TenantPermissions};
677+
use tokio::sync::Notify;
678+
679+
/// A `SofRunner` that blocks until `release` is notified, then yields an
680+
/// empty row stream. Lets a test hold a job in the Running state for as
681+
/// long as it needs.
682+
struct BlockingRunner {
683+
release: Arc<Notify>,
684+
}
685+
686+
#[async_trait]
687+
impl SofRunner for BlockingRunner {
688+
async fn run_view(
689+
&self,
690+
_tenant: &TenantContext,
691+
_view_definition: serde_json::Value,
692+
_filters: ViewFilters,
693+
) -> Result<RowStream, SofError> {
694+
self.release.notified().await;
695+
Ok(Box::pin(futures::stream::empty()))
696+
}
697+
698+
fn runner_name(&self) -> &'static str {
699+
"blocking-test-runner"
700+
}
701+
}
702+
703+
/// Spec (#363): status polls after a DELETE return 404, so a job
704+
/// cancelled while running must stay Cancelled — the background task
705+
/// finishing later must not overwrite the state with Completed.
706+
#[tokio::test]
707+
async fn cancelled_job_is_not_resurrected_by_late_completion() {
708+
let release = Arc::new(Notify::new());
709+
let runner = Arc::new(BlockingRunner {
710+
release: Arc::clone(&release),
711+
});
712+
let controller =
713+
InMemoryController::new(runner, InMemorySink::new("http://localhost"), None);
714+
715+
let tenant = TenantContext::new(TenantId::new("t1"), TenantPermissions::full_access());
716+
let job_id = controller.submit(ExportTask {
717+
work: ExportWork::Views(vec![NamedView {
718+
name: "patients".to_string(),
719+
view: serde_json::json!({
720+
"resourceType": "ViewDefinition",
721+
"resource": "Patient",
722+
"status": "active",
723+
"select": [{"column": [{"name": "id", "path": "id"}]}]
724+
}),
725+
}]),
726+
tenant,
727+
filters: ViewFilters::default(),
728+
format: "ndjson".to_string(),
729+
header: true,
730+
client_tracking_id: None,
731+
});
732+
733+
// The runner is blocked, so the job is still Running — cancel it.
734+
assert!(controller.cancel("t1", &job_id));
735+
assert!(matches!(
736+
controller.get_status("t1", &job_id),
737+
Some(JobStatus::Cancelled)
738+
));
739+
740+
// Unblock the background task and give it time to run to completion.
741+
// The Cancelled state must survive.
742+
release.notify_one();
743+
for _ in 0..20 {
744+
tokio::time::sleep(Duration::from_millis(10)).await;
745+
match controller.get_status("t1", &job_id) {
746+
Some(JobStatus::Cancelled) => {}
747+
other => panic!("cancelled job must stay Cancelled, got {other:?}"),
748+
}
749+
}
750+
}
751+
}

crates/rest/src/handlers/sof/export.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,9 @@ where
12851285
"application/vnd.apache.parquet"
12861286
} else if filename.ends_with(".fhir.ndjson") {
12871287
"application/fhir+ndjson"
1288+
} else if filename.ends_with(".json") {
1289+
// `_format=json` shards hold a single JSON array of rows.
1290+
"application/json"
12881291
} else {
12891292
"application/x-ndjson"
12901293
};

crates/rest/tests/sof_export.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,14 @@ mod sof_export_tests {
803803
.add_header(X_TENANT_ID, "test-tenant")
804804
.await;
805805
assert_eq!(dl.status_code(), StatusCode::OK);
806+
// The shard is a JSON array, so it must be served as
807+
// `application/json` — not the ndjson fallthrough.
808+
assert_eq!(
809+
dl.headers()
810+
.get("content-type")
811+
.and_then(|v| v.to_str().ok()),
812+
Some("application/json")
813+
);
806814
let body = dl.text();
807815
assert!(
808816
body.trim_start().starts_with('['),

crates/sof/src/error.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,11 @@ impl std::error::Error for ServerError {}
7373
impl From<SofError> for ServerError {
7474
fn from(err: SofError) -> Self {
7575
match &err {
76-
SofError::UnsupportedContentType(_) => {
77-
ServerError::UnsupportedMediaType(err.to_string())
78-
}
76+
// Spec (operations-common, Output Formats): an unsupported
77+
// `_format` value SHALL be rejected with 400 Bad Request +
78+
// OperationOutcome — 415 is reserved for transport-level
79+
// Content-Type/Content-Encoding problems.
80+
SofError::UnsupportedContentType(_) => ServerError::BadRequest(err.to_string()),
7981
SofError::InvalidSource(_)
8082
| SofError::SourceNotFound(_)
8183
| SofError::UnsupportedSourceProtocol(_) => ServerError::BadRequest(err.to_string()),

crates/sof/src/server.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,38 @@ mod tests {
520520
assert_eq!(json["service"], "sof-server");
521521
}
522522

523+
// ── Unsupported `_format` ─────────────────────────────────────────────
524+
525+
/// Spec (operations-common, Output Formats): an unsupported `_format`
526+
/// value SHALL be rejected with 400 Bad Request + OperationOutcome —
527+
/// for the body parameter as well as the query parameter. (The stub
528+
/// suite in `tests/` used to entrench 415 for the body path.)
529+
#[tokio::test]
530+
async fn test_unsupported_body_format_returns_400() {
531+
let server = TestServer::new(create_app()).unwrap();
532+
533+
let mut body = run_request_body();
534+
body["parameter"]
535+
.as_array_mut()
536+
.unwrap()
537+
.push(serde_json::json!({"name": "_format", "valueCode": "text/plain"}));
538+
539+
let response = server
540+
.post("/ViewDefinition/$viewdefinition-run")
541+
.json(&body)
542+
.await;
543+
544+
assert_eq!(
545+
response.status_code(),
546+
StatusCode::BAD_REQUEST,
547+
"unsupported body _format must be 400, got {}: {}",
548+
response.status_code(),
549+
response.text()
550+
);
551+
let json: serde_json::Value = response.json();
552+
assert_eq!(json["resourceType"], "OperationOutcome");
553+
}
554+
523555
// ── HTTP compression ──────────────────────────────────────────────────
524556

525557
/// A minimal valid `$viewdefinition-run` Parameters body.

crates/sof/tests/common/mod.rs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -449,17 +449,12 @@ async fn run_view_definition_handler(
449449
// (overly-strict) production behavior; aligned to the new lenient
450450
// rule per audit item #14.
451451

452+
// Per spec (operations-common, Output Formats): an unsupported
453+
// `_format` value → 400 Bad Request + OperationOutcome (mirrors the
454+
// production mapping of SofError::UnsupportedContentType).
452455
let content_type = match parse_content_type(accept, format, header_param) {
453456
Ok(ct) => ct,
454-
Err(e) => match e {
455-
helios_sof::SofError::UnsupportedContentType(_) => {
456-
return error_response(
457-
axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE,
458-
&e.to_string(),
459-
);
460-
}
461-
_ => return error_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()),
462-
},
457+
Err(e) => return error_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()),
463458
};
464459

465460
// Create ViewDefinition and Bundle

crates/sof/tests/server_tests.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ async fn test_run_view_definition_unsupported_format() {
336336
.json(&request_body)
337337
.await;
338338

339-
assert_eq!(response.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
339+
// Spec: unsupported `_format` → 400 Bad Request + OperationOutcome.
340+
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
340341

341342
let json: serde_json::Value = response.json();
342343
assert_eq!(json["resourceType"], "OperationOutcome");

crates/sof/tests/test_format_parameter_body.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ async fn test_invalid_format_parameter_in_body() {
353353
.json(&request_body)
354354
.await;
355355

356-
assert_eq!(response.status_code(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
356+
// Spec: unsupported `_format` → 400 Bad Request + OperationOutcome.
357+
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
357358

358359
let json: serde_json::Value = response.json();
359360
assert_eq!(json["resourceType"], "OperationOutcome");

0 commit comments

Comments
 (0)