From 5d10c6b7473d02465fdc9dba6457c8433f909656 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 03:25:43 +0000 Subject: [PATCH 01/36] feat(rust): preserve original query task authority --- rust/README.md | 27 +- rust/src/runtime/backend.rs | 6 +- rust/src/runtime/cancellation.rs | 2 +- rust/src/runtime/execution.rs | 89 ++- rust/src/runtime/http.rs | 47 ++ rust/src/runtime/mod.rs | 2 + rust/src/runtime/queries.rs | 462 +++++++++--- rust/src/runtime/query_authority.rs | 357 ++++++++++ rust/src/runtime/query_codec_tests.rs | 172 +++++ rust/src/runtime/store.rs | 2 +- rust/tests/execution.rs | 36 +- rust/tests/execution/namespaces.rs | 2 +- rust/tests/execution/query_authority.rs | 905 ++++++++++++++++++++++++ 13 files changed, 1975 insertions(+), 134 deletions(-) create mode 100644 rust/src/runtime/query_authority.rs create mode 100644 rust/src/runtime/query_codec_tests.rs create mode 100644 rust/tests/execution/query_authority.rs diff --git a/rust/README.md b/rust/README.md index 512e8184c..976201ff7 100644 --- a/rust/README.md +++ b/rust/README.md @@ -260,19 +260,30 @@ worker as signals. Other missing paths return Starts, ordered history, task claims and outcomes are transactional. Expired or revoked owner/attempt fences cannot commit. Identical completion retries return the existing receipt without another outcome; conflicting retries are refused. -Query completion follows the PHP broker's terminal lease refusal on repetition. +The separate native query-authority slice retains content-bound completion and +failure decisions for the original owner and attempt. Matching retries return +the committed decision with `recorded=false`, including after the original +deadline; conflicting operations or payloads remain refused. This does not +change the activity or workflow receipt contract. Query poll-request receipt idempotency is explicitly advertised as false; repeat polling by the active owner retains its existing attempt and snapshot. Transient query entries use a native prefix in a separate `dw_query_cache` table, with -16 pending requests and 64 pending/results combined per default-namespace +16 pending requests and 64 pending/results combined per namespace in the development database. Arguments are capped at 64 KiB, each complete task and snapshot at one MiB, result blobs at 256 KiB, and completion bodies at 512 KiB. -The query deadline is 30 seconds, leases last at most ten seconds, and recent -results expire after 60 seconds. Admission/polling reclaim expired entries; -abandoned requests occupy a bounded slot until their deadline plus 60 seconds. -Results discard replay snapshots. These are development bounds, not qualified -customer limits. Full query failures, worker compatibility, busy routing and -request-id receipts still need reviewed fixtures. +Admission snapshots UTC timestamps with six fractional digits. Query timing +uses the PHP configuration aliases: the default result duration is 45 seconds, +the configured 60-second lease is capped by the original result deadline, and +the default retention is 180 seconds, with a minimum of the configured result +duration plus lease duration plus 60 seconds. Policy changes and polls preserve +those original snapshots; zero expires immediately. Missing legacy timing +gets a fixed refusal without a fresh execution budget. Results discard replay +snapshots. Protocol 1.20 status reads observe the original claim without +renewing it; 1.19 receives an explicit unavailable response. Discovery keeps +`query_task_authority=false` until separate current-PHP and installed-consumer +qualification. These are development bounds, not qualified customer limits. +Queue-lock pressure, full query failures, worker compatibility, busy routing, +request-id receipts and cross-runtime adoption still need reviewed fixtures. Poll IDs retain the original task/snapshot across process boundaries, with expired receipts reclaimed. Worker/control-plane history is paginated, capped at 1000 records and eight MiB of encoded payloads per page. Requests are capped diff --git a/rust/src/runtime/backend.rs b/rust/src/runtime/backend.rs index 2db9ecd9c..43944e0e4 100644 --- a/rust/src/runtime/backend.rs +++ b/rust/src/runtime/backend.rs @@ -22,7 +22,7 @@ pub(super) trait Backend: Database { const REPAIR_COUNTER_MAX: i64 = i32::MAX as i64; const RANGE_REPAIR_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace=$2 AND task_type='workflow' AND status='failed' AND payload->>'replay_blocked'='true' ORDER BY CASE WHEN available_at IS NULL THEN 1 ELSE 0 END,available_at,created_at,id LIMIT 1"; const ACTIVITY_OPEN_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='activity' AND status IN ('ready','leased') AND payload->>'activity_execution_id'=$2 ORDER BY id LIMIT 1"; - const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' ORDER BY \"key\" LIMIT 65"; + const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND value::jsonb->>'namespace'=$1 ORDER BY \"key\" LIMIT 65"; const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND value::jsonb->>'run_id'=$1 AND value::jsonb->>'status' IN ('pending','leased')"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1"; @@ -84,6 +84,7 @@ fn sqlite_instant(value: String) -> Result> { } impl Backend for Sqlite { + const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND json_extract(value,'$.namespace')=$1 ORDER BY \"key\" LIMIT 65"; const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND json_extract(value,'$.run_id')=$1 AND json_extract(value,'$.status') IN ('pending','leased')"; const SELECTION_RESOLUTION_SQL: &'static str = "SELECT id,payload FROM workflow_history_events WHERE workflow_run_id=$1 AND event_type='SelectionResolved' AND json_extract(payload,'$.selection_group_id')=$2 ORDER BY sequence LIMIT 1"; const REPAIR_COUNTER_MAX: i64 = i64::MAX; @@ -287,6 +288,7 @@ impl Backend for Postgres { } impl Backend for MySql { + const QUERY_CACHE_SCAN: &'static str = "SELECT `key`,value,expiration FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND BINARY JSON_UNQUOTE(JSON_EXTRACT(value,'$.namespace'))=BINARY ? ORDER BY `key` LIMIT 65"; const EXTERNAL_PAYLOAD_BYTES: &'static str = "SELECT CAST(COALESCE(SUM(size_bytes),0) AS SIGNED) FROM runtime_external_payloads WHERE namespace=$1"; const MEMO_UPSERT: &'static str = "INSERT INTO workflow_memos(workflow_run_id,workflow_instance_id,`key`,value,upserted_at_sequence,inherited_from_parent,created_at,updated_at,portable_value,portable_value_sequence) VALUES ($1,$2,$3,$4,$5,false,$6,$7,$8,$9) ON DUPLICATE KEY UPDATE value=VALUES(value),upserted_at_sequence=VALUES(upserted_at_sequence),inherited_from_parent=false,updated_at=VALUES(updated_at),portable_value=VALUES(portable_value),portable_value_sequence=VALUES(portable_value_sequence)"; const MEMO_DELETE: &'static str = @@ -301,7 +303,6 @@ impl Backend for MySql { type EncodedTime = DateTime; type EncodedOptionalTime = Option>; type EncodedDocument = Json; - const QUERY_CACHE_SCAN: &'static str = "SELECT `key`,value,expiration FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' ORDER BY `key` LIMIT 65"; const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.run_id'))=? AND expiration>? AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.status')) IN ('pending','leased')"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE `key`=$1"; @@ -325,6 +326,7 @@ impl Backend for MySql { || sql == Self::SELECTION_RESOLUTION_SQL || sql == Self::ACTIVITY_OPEN_TASK_SQL || sql == Self::QUERY_CACHE_PENDING_FOR_RUN + || sql == Self::QUERY_CACHE_SCAN { return Cow::Borrowed(sql); } diff --git a/rust/src/runtime/cancellation.rs b/rust/src/runtime/cancellation.rs index c24128fc8..db75a159c 100644 --- a/rust/src/runtime/cancellation.rs +++ b/rust/src/runtime/cancellation.rs @@ -197,7 +197,7 @@ where let updates = Self::scalar("SELECT COUNT(*) FROM workflow_updates WHERE workflow_run_id=$1 AND status='accepted'") .bind(&run_id).fetch_one(&mut *tx).await?; let pending_queries = - Self::query_entries(&mut tx) + self.query_entries(&mut tx) .await? .into_iter() .any(|(_, task)| { diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 132d165c8..9422fa9b6 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -21,6 +21,10 @@ enum Storage { MySql(Store), } +#[cfg(test)] +#[path = "query_codec_tests.rs"] +mod query_codec_tests; + #[derive(Clone)] pub struct Runtime { storage: Arc, @@ -29,6 +33,7 @@ pub struct Runtime { scheduler: Arc, signal_codec: Arc, pub(super) external: Arc, + queries: Arc, } struct TimerScheduler { @@ -114,6 +119,10 @@ impl Runtime { self.external = Arc::new(config); self } + pub fn with_query_task_config(mut self, config: super::QueryTaskConfig) -> Self { + self.queries = Arc::new(config); + self + } /// Immutable configured-token cohort. Database transitions and scheduler /// stay shared; configuring credentials does not mutate process state. pub fn with_role_tokens( @@ -140,34 +149,38 @@ impl Runtime { pub async fn open(database: &str, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(sqlite::open(database).await?); store.memo_size_limit = limit; - Self::from_storage(Storage::Sqlite(store), token, external).await + Self::from_storage(Storage::Sqlite(store), token, external, queries).await } pub async fn open_postgres(options: PgConnectOptions, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(PostgresStorage::open(options).await?.into_pool()); store.memo_size_limit = limit; - Self::from_storage(Storage::Postgres(store), token, external).await + Self::from_storage(Storage::Postgres(store), token, external, queries).await } pub async fn open_mysql(options: MySqlConnectOptions, token: String) -> Result { Self::validate_token(&token)?; let external = super::ExternalPayloadConfig::from_env()?; + let queries = super::QueryTaskConfig::from_env()?; let limit = super::memo_limits::configured_limit()?; let mut store = Store::new(MySqlStorage::open(options).await?.into_pool()); store.memo_size_limit = limit; - Self::from_storage(Storage::MySql(store), token, external).await + Self::from_storage(Storage::MySql(store), token, external, queries).await } async fn from_storage( storage: Storage, token: String, external: super::ExternalPayloadConfig, + queries: super::QueryTaskConfig, ) -> Result { let runtime = Self { storage: Arc::new(storage), @@ -176,6 +189,7 @@ impl Runtime { scheduler: Arc::new(TimerScheduler::new()), signal_codec: Arc::new(Semaphore::new(1)), external: Arc::new(external), + queries: Arc::new(queries), }; runtime.verify_ready().await?; if let Err(error) = runtime.initialize_namespace_registry().await { @@ -243,6 +257,7 @@ impl Runtime { scheduler: self.scheduler.clone(), signal_codec: self.signal_codec.clone(), external: self.external.clone(), + queries: self.queries.clone(), } } pub(super) fn namespace(&self) -> &str { @@ -511,23 +526,58 @@ impl Runtime { match &*self.storage { Storage::Sqlite(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } Storage::Postgres(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } Storage::MySql(store) => { store - .query_workflow(workflow_id, run_id, name, arguments, values) + .query_workflow(workflow_id, run_id, name, arguments, values, &self.queries) .await } } } - delegate!(poll_query(body: Value) -> Result); + pub(crate) async fn poll_query(&self, body: Value) -> Result { + match &*self.storage { + Storage::Sqlite(store) => store.poll_query(body, &self.queries).await, + Storage::Postgres(store) => store.poll_query(body, &self.queries).await, + Storage::MySql(store) => store.poll_query(body, &self.queries).await, + } + } + pub(crate) async fn query_status(&self, id: &str, owner: &str, attempt: i64) -> Result { + match &*self.storage { + Storage::Sqlite(store) => store.query_status(id, owner, attempt, &self.queries).await, + Storage::Postgres(store) => store.query_status(id, owner, attempt, &self.queries).await, + Storage::MySql(store) => store.query_status(id, owner, attempt, &self.queries).await, + } + } pub(crate) async fn complete_query(&self, task_id: &str, body: Value) -> Result { + // Check retained decisions and live authority before codec work, then + // repeat the same fences atomically after decoding. + let receipt = match &*self.storage { + Storage::Sqlite(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + Storage::Postgres(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + Storage::MySql(store) => { + store + .guard_query_outcome(task_id, &body, false, &self.queries) + .await + } + }?; + if let Some(receipt) = receipt { + return Ok(receipt); + } let result = super::envelope(&body, "result_envelope")?; if result.len() > super::queries::RESULT_BYTES { return Err(refuse( @@ -544,7 +594,30 @@ impl Runtime { .await?; self.finish_query(task_id, body, false).await } - delegate!(finish_query(task_id: &str, body: Value, failed: bool) -> Result); + pub(crate) async fn finish_query( + &self, + task_id: &str, + body: Value, + failed: bool, + ) -> Result { + match &*self.storage { + Storage::Sqlite(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + Storage::Postgres(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + Storage::MySql(store) => { + store + .finish_query(task_id, body, failed, &self.queries) + .await + } + } + } delegate!( history( workflow_id: &str, diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 7920407f0..eaeeaf048 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -168,6 +168,10 @@ pub fn router(runtime: Runtime) -> Router { post(complete_activity), ) .route("/api/worker/query-tasks/poll", post(poll_query)) + .route( + "/api/worker/query-tasks/{task_id}/status", + get(query_status), + ) .route( "/api/worker/query-tasks/{task_id}/complete", post(complete_query), @@ -387,6 +391,12 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { ) { return method == "POST"; } + if matches!( + parts.as_slice(), + [_, "query", _] | [_, "runs", _, "query", _] + ) { + return method == "POST"; + } return matches!( parts.as_slice(), [_] | [_, "cancel"] | [_, "runs", _] | [_, "runs", _, "history" | "cancel"] @@ -401,6 +411,7 @@ fn named_namespace_operation(method: &str, path: &str) -> bool { | ["registrations", _, "deregister"] | ["workflow-tasks" | "activity-tasks", "poll"] | ["query-tasks", "poll"] + | ["query-tasks", _, "complete" | "fail" | "status"] | ["workflow-tasks", _, "complete" | "history" | "heartbeat"] | [ "activity-tasks", @@ -1074,6 +1085,42 @@ async fn complete_query( ) -> Result> { runtime.complete_query(&task_id, body).await.map(Json) } +#[derive(Deserialize)] +struct QueryStatusRequest { + lease_owner: String, + query_task_attempt: i64, +} +async fn query_status( + Extension(runtime): Extension, + Path(task_id): Path, + request: Request, +) -> Result> { + let minor = request + .headers() + .get("x-durable-workflow-protocol-version") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .and_then(|version| version.split_once('.')) + .and_then(|(_, minor)| minor.parse::().ok()); + if minor.is_none_or(|minor| minor < 20) { + return Err(super::RuntimeError::Protocol { + status: StatusCode::UNPROCESSABLE_ENTITY, + response: json!({"reason":"query_task_status_unavailable","error":"Query task observation requires worker protocol 1.20.","minimum_protocol_version":"1.20"}), + }); + } + let Query(claim) = Query::::try_from_uri(request.uri()) + .map_err(|_| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_query_status"))?; + if claim.lease_owner.is_empty() || claim.query_task_attempt < 1 { + return Err(refuse( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_query_status", + )); + } + runtime + .query_status(&task_id, &claim.lease_owner, claim.query_task_attempt) + .await + .map(Json) +} async fn fail_query( Extension(runtime): Extension, Path(task_id): Path, diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 0e64a5caa..4ea47322c 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -25,6 +25,7 @@ mod namespaces; pub mod postgres; mod previews; mod queries; +mod query_authority; mod schedule_execution; mod schedule_http; mod schedule_storage; @@ -43,6 +44,7 @@ mod worker_incarnations; pub use execution::Runtime; pub use external_config::ExternalPayloadConfig; pub use http::router; +pub use query_authority::QueryTaskConfig; use axum::{ Json, diff --git a/rust/src/runtime/queries.rs b/rust/src/runtime/queries.rs index ebcaa6c49..244723009 100644 --- a/rust/src/runtime/queries.rs +++ b/rust/src/runtime/queries.rs @@ -3,7 +3,9 @@ use super::{ Result, backend::Backend, - envelope, refuse, + envelope, + query_authority::{self as authority, QueryTaskConfig}, + refuse, store::{Store, validate_arguments}, text, wire, }; @@ -16,13 +18,20 @@ use tokio::sync::Semaphore; const PREFIX: &str = "dw-native-query:"; const MAX_PENDING: usize = 16; const MAX_ENTRIES: usize = 64; -const WAIT_SECONDS: i64 = 30; -const LEASE_SECONDS: i64 = 10; -const RECEIPT_SECONDS: i64 = 60; const SNAPSHOT_BYTES: usize = 1024 * 1024; pub(super) const ARGUMENT_BYTES: usize = 64 * 1024; pub(super) const RESULT_BYTES: usize = 256 * 1024; +enum QueryClaim { + Task(Value), + Empty, + StaleWorker, +} + +fn poll_result(task: Option, status: &str) -> Value { + json!({"task":task,"poll_status":status,"server_capabilities":super::store::capabilities()}) +} + fn now() -> i64 { chrono::Utc::now().timestamp() } @@ -94,9 +103,11 @@ where usize: ColumnIndex, { pub(super) async fn query_entries( + &self, tx: &mut Transaction<'_, DB>, ) -> Result> { let rows = Self::query(DB::QUERY_CACHE_SCAN) + .bind(self.namespace.to_string()) .fetch_all(&mut **tx) .await?; let mut entries = vec![]; @@ -109,7 +120,11 @@ where .await?; continue; } - entries.push((key, serde_json::from_str(&DB::string(&row, "value")?)?)); + let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; + if authority::expire(&mut task, authority::now())? { + Self::put_query(tx, &key, &task, authority::expiration(&task)?).await?; + } + entries.push((key, task)); } if entries.len() > MAX_ENTRIES { return Err(refuse( @@ -142,6 +157,7 @@ where name: &str, arguments: String, values: Vec, + config: &QueryTaskConfig, ) -> Result<(StatusCode, Value)> { if name.is_empty() || name.len() > 255 { return Err(refuse( @@ -150,9 +166,15 @@ where )); } let mut tx = self.begin().await?; - let run = Self::query("SELECT r.* FROM workflow_runs r JOIN workflow_instances i ON i.id=r.workflow_instance_id WHERE i.id=$1 AND r.id=COALESCE($2,i.current_run_id) AND r.namespace='default'") - .bind(workflow_id).bind(selected_run).fetch_optional(&mut *tx).await? + let run = Self::query("SELECT r.* FROM workflow_runs r JOIN workflow_instances i ON i.id=r.workflow_instance_id WHERE i.id=$1 AND r.id=COALESCE($2,i.current_run_id) AND r.namespace=$3") + .bind(workflow_id).bind(selected_run).bind(self.namespace.as_ref()).fetch_optional(&mut *tx).await? .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "instance_not_found"))?; + if DB::string(&run, "namespace")? != self.namespace.as_ref() + || DB::string(&run, "workflow_instance_id")? != workflow_id + || selected_run.is_some_and(|id| DB::string(&run, "id").ok().as_deref() != Some(id)) + { + return Err(refuse(StatusCode::NOT_FOUND, "instance_not_found")); + } let run_id = DB::string(&run, "id")?; let status = DB::string(&run, "status")?; if !matches!( @@ -186,7 +208,7 @@ where "unsupported_query_contract", "invalid_query_arguments", )?; - let entries = Self::query_entries(&mut tx).await?; + let entries = self.query_entries(&mut tx).await?; if entries.len() >= MAX_ENTRIES || entries .iter() @@ -208,14 +230,18 @@ where } let task_id = ulid::Ulid::new().to_string(); let key = key(&task_id)?; - let deadline = now() + WAIT_SECONDS; - let task = json!({"query_task_id":task_id,"query_task_attempt":0,"status":"pending", - "namespace":"default","workflow_id":workflow_id,"run_id":run_id, + let admitted = authority::now(); + let (deadline, retained_until) = config.timing(admitted)?; + let mut task = json!({"query_task_id":task_id,"query_task_attempt":0,"status":"pending", + "namespace":self.namespace.as_ref(),"workflow_id":workflow_id,"run_id":run_id, "workflow_type":DB::string(&run,"workflow_type")?,"workflow_class":DB::string(&run,"workflow_class")?, "task_queue":DB::string(&run,"queue")?,"payload_codec":"avro","query_name":name, "workflow_arguments":wire(&DB::string(&run,"arguments")?),"query_arguments":wire(&arguments), "run_status":status,"history_events":history,"last_history_sequence":DB::number(&run,"last_history_sequence")?, - "history_cutoff_sequence":DB::number(&run,"last_history_sequence")?,"deadline":deadline}); + "history_cutoff_sequence":DB::number(&run,"last_history_sequence")?, + "query_admitted_at":authority::wire(admitted),"query_deadline_at":authority::wire(deadline), + "receipt_expires_at":authority::wire(retained_until)}); + authority::expire(&mut task, authority::now())?; if serde_json::to_vec(&task)?.len() > SNAPSHOT_BYTES { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, @@ -225,19 +251,27 @@ where Self::query(DB::QUERY_CACHE_INSERT) .bind(&key) .bind(task.to_string()) - .bind(deadline + RECEIPT_SECONDS) + .bind(authority::expiration(&task)?) .execute(&mut *tx) .await?; tx.commit().await?; self.wake.notify_waiters(); // Never hold the transition lock or a pooled connection across the wait. // Periodic probes observe results committed by another server process. + let timed_out = || super::RuntimeError::Protocol { + status: StatusCode::GATEWAY_TIMEOUT, + response: json!({"workflow_id":workflow_id,"run_id":run_id,"query_name":name, + "reason":"query_task_timed_out","message":"Timed out waiting for the original query result."}), + }; loop { let wake = self.wake.notified(); - let row = Self::query(DB::QUERY_CACHE_GET) + let remaining = (deadline - authority::now()).to_std().unwrap_or_default(); + let read = Self::query(DB::QUERY_CACHE_GET) .bind(&key) - .fetch_optional(&self.pool) - .await? + .fetch_optional(&self.pool); + let row = tokio::time::timeout(remaining, read) + .await + .map_err(|_| timed_out())?? .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_task_timed_out"))?; let task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; match task["status"].as_str() { @@ -260,7 +294,17 @@ where body["query_name"] = json!(name); return Ok((status, body)); } - _ if now() >= deadline => { + Some("timed_out") => { + return Err(timed_out()); + } + _ if authority::deadline(&task) + .is_some_and(|deadline| authority::now() >= deadline) => + { + // The stored deadline already fences every new outcome. + // Cleanup must not delay the caller behind a busy writer. + return Err(timed_out()); + } + _ if authority::deadline(&task).is_none() => { let mut tx = self.begin().await?; // Recheck under the same lock as completion: a committed // result wins over a racing timeout. @@ -270,13 +314,13 @@ where .await?; let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; if matches!(task["status"].as_str(), Some("completed" | "failed")) { + tx.rollback().await?; continue; } - task["status"] = json!("timed_out"); - task.as_object_mut().unwrap().remove("history_events"); - Self::put_query(&mut tx, &key, &task, now() + RECEIPT_SECONDS).await?; + authority::expire(&mut task, authority::now())?; + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; - return Err(refuse(StatusCode::CONFLICT, "query_task_timed_out")); + continue; } _ => {} } @@ -284,38 +328,111 @@ where } } - pub(super) async fn poll_query(&self, body: Value) -> Result { - text(&body, "worker_id")?; + pub(super) async fn poll_query(&self, body: Value, config: &QueryTaskConfig) -> Result { + let owner = text(&body, "worker_id")?; text(&body, "task_queue")?; let timeout = body .get("timeout_seconds") .map_or(Some(5), Value::as_u64) .filter(|v| *v <= 60) .ok_or_else(|| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_poll_timeout"))?; + let timeout = timeout.min(config.poll_timeout_seconds.max(1) as u64); let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout); + // One request belongs to the registration observed before its wait. + // A later same-ID registration cannot silently upgrade this poll. + let Some(proof) = + Self::query_worker_proof(&self.pool, self.namespace.as_ref(), owner, config).await? + else { + return Ok(poll_result(None, "stale_worker_registration")); + }; loop { let wake = self.wake.notified(); - if let Some(task) = self.claim_query(&body).await? { - return Ok( - json!({"task":task,"poll_status":"task","server_capabilities":super::store::capabilities()}), - ); + match self.claim_query(&body, config, &proof).await? { + QueryClaim::Task(task) => return Ok(poll_result(Some(task), "task")), + QueryClaim::StaleWorker => { + return Ok(poll_result(None, "stale_worker_registration")); + } + QueryClaim::Empty => {} } if tokio::time::Instant::now() >= deadline { - return Ok( - json!({"task":null,"poll_status":"timeout","server_capabilities":super::store::capabilities()}), - ); + return Ok(poll_result(None, "timeout")); } tokio::select! { _ = wake => {}, _ = tokio::time::sleep(Duration::from_millis(50)) => {} } } } - async fn claim_query(&self, body: &Value) -> Result> { + async fn query_worker_proof<'e, E>( + executor: E, + namespace: &str, + owner: &str, + config: &QueryTaskConfig, + ) -> Result> + where + E: Executor<'e, Database = DB>, + { + let rows = Self::query("SELECT w.worker_id,w.namespace,w.last_heartbeat_at,i.token,i.worker_id AS incarnation_worker_id,i.namespace AS incarnation_namespace FROM workflow_worker_registrations w JOIN dw_worker_registration_incarnations i ON i.worker_id=w.worker_id AND i.namespace=w.namespace AND i.status='active' WHERE w.namespace=$1 AND w.worker_id=$2 AND w.status='active' LIMIT 2") + .bind(namespace.to_owned()).bind(owner.to_owned()).fetch_all(executor).await?; + if rows.len() != 1 { + return Ok(None); + } + let row = &rows[0]; + if DB::string(row, "worker_id")? != owner + || DB::string(row, "namespace")? != namespace + || DB::string(row, "incarnation_worker_id")? != owner + || DB::string(row, "incarnation_namespace")? != namespace + || DB::instant(row, "last_heartbeat_at")? + <= QueryTaskConfig::add( + authority::now(), + -config.worker_stale_after_seconds.max(1), + )? + { + return Ok(None); + } + use sha2::{Digest, Sha256}; + let digest: String = Sha256::digest(DB::string(row, "token")?.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + Ok(Some(digest)) + } + + fn query_delivery(task: &Value) -> Value { + let mut delivered = task.clone(); + for field in [ + "receipt_expires_at", + "terminal_receipt", + "registration_incarnation", + "legacy_refusal_at", + ] { + delivered + .as_object_mut() + .expect("native query task") + .remove(field); + } + delivered["server_time"] = json!(authority::wire(authority::now())); + delivered + } + + async fn claim_query( + &self, + body: &Value, + config: &QueryTaskConfig, + expected_proof: &str, + ) -> Result { let owner = text(body, "worker_id")?; let queue = text(body, "task_queue")?; let mut tx = self.begin().await?; - let worker = Self::query("SELECT * FROM workflow_worker_registrations WHERE namespace=$1 AND worker_id=$2 AND status='active' AND last_heartbeat_at>$3") - .bind(self.namespace.as_ref()).bind(owner).bind(DB::bind_time(chrono::Utc::now() - chrono::Duration::seconds(60))) - .fetch_optional(&mut *tx).await?.ok_or_else(|| refuse(StatusCode::CONFLICT, "worker_not_registered"))?; + let worker = Self::query("SELECT * FROM workflow_worker_registrations WHERE namespace=$1 AND worker_id=$2 AND status='active'") + .bind(self.namespace.as_ref()).bind(owner) + .fetch_optional(&mut *tx).await?; + let Some(worker) = worker else { + return Ok(QueryClaim::StaleWorker); + }; + let proof = + Self::query_worker_proof(&mut *tx, self.namespace.as_ref(), owner, config).await?; + let Some(proof) = proof.filter(|proof| proof == expected_proof) else { + return Ok(QueryClaim::StaleWorker); + }; if DB::string(&worker, "task_queue")? != queue { return Err(refuse(StatusCode::CONFLICT, "task_queue_mismatch")); } @@ -326,12 +443,12 @@ where .as_array() .is_some_and(|values| values.iter().any(|v| v == "query_tasks")) { - return Ok(None); + return Ok(QueryClaim::Empty); } - for (key, mut task) in Self::query_entries(&mut tx).await? { + for (key, mut task) in self.query_entries(&mut tx).await? { if !matches!(task["status"].as_str(), Some("pending" | "leased")) || task["namespace"] != self.namespace.as_ref() - || task["deadline"].as_i64().unwrap_or(0) <= now() + || authority::deadline(&task).is_none_or(|deadline| deadline <= authority::now()) || task["task_queue"] != queue || !types .as_array() @@ -348,32 +465,144 @@ where { continue; } - if task["status"] == "leased" && task["lease_until"].as_i64().unwrap_or(0) > now() { - if task["lease_owner"] == owner { - return Ok(Some(task)); + if task["status"] == "leased" + && authority::timestamp(&task["lease_expires_at"]) + .is_some_and(|lease| lease > authority::now()) + { + let existing_owner = task["lease_owner"].as_str().unwrap_or(""); + let existing_proof = Self::query_worker_proof( + &mut *tx, + self.namespace.as_ref(), + existing_owner, + config, + ) + .await?; + if existing_proof + .as_deref() + .is_some_and(|proof| task["registration_incarnation"] == proof) + { + if existing_owner == owner { + let delivered = Self::query_delivery(&task); + tx.commit().await?; + return Ok(QueryClaim::Task(delivered)); + } + continue; } - continue; } - task["query_task_attempt"] = - json!(task["query_task_attempt"].as_i64().unwrap_or(0) + 1); + let next_attempt = task["query_task_attempt"] + .as_i64() + .and_then(|attempt| attempt.checked_add(1)) + .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_task_attempt_exhausted"))?; + task["query_task_attempt"] = json!(next_attempt); task["status"] = json!("leased"); task["lease_owner"] = json!(owner); - let expires = (now() + LEASE_SECONDS).min(task["deadline"].as_i64().unwrap()); - task["lease_until"] = json!(expires); - task["lease_expires_at"] = json!(chrono::DateTime::from_timestamp(expires, 0).unwrap()); - Self::put_query( - &mut tx, - &key, - &task, - task["deadline"].as_i64().unwrap() + RECEIPT_SECONDS, - ) - .await?; + let time = authority::now(); + let expires = QueryTaskConfig::add(time, config.lease_timeout_seconds.max(1))? + .min(authority::deadline(&task).expect("checked original deadline")); + if expires <= time { + continue; + } + task["lease_expires_at"] = json!(authority::wire(expires)); + task["registration_incarnation"] = json!(proof); + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; - return Ok(Some(task)); + return Ok(QueryClaim::Task(Self::query_delivery(&task))); } // Commit cleanup even when no compatible task is found. tx.commit().await?; - Ok(None) + Ok(QueryClaim::Empty) + } + + fn cached_query(row: &DB::Row, id: &str, namespace: &str) -> Result { + if DB::number(row, "expiration")? <= now() { + return Err(refuse(StatusCode::NOT_FOUND, "query_task_not_found")); + } + let task: Value = serde_json::from_str(&DB::string(row, "value")?)?; + authority::scope(&task, id, namespace)?; + Ok(task) + } + + async fn query_claim_reason<'e, E>( + executor: E, + task: &Value, + owner: &str, + attempt: i64, + config: &QueryTaskConfig, + ) -> Result> + where + E: Executor<'e, Database = DB>, + { + let reason = authority::claim_reason(task, owner, attempt, authority::now()); + if reason.is_some_and(|reason| reason != "lease_expired") { + return Ok(reason); + } + let proof = Self::query_worker_proof( + executor, + task["namespace"].as_str().unwrap_or(""), + owner, + config, + ) + .await?; + if proof + .as_deref() + .is_none_or(|proof| task["registration_incarnation"] != proof) + { + return Ok(Some("stale_worker_registration")); + } + // Database reads can consume the remaining lease. Recheck against a + // fresh clock after observing current registration authority. + Ok(authority::claim_reason( + task, + owner, + attempt, + authority::now(), + )) + } + + pub(super) async fn query_status( + &self, + id: &str, + owner: &str, + attempt: i64, + config: &QueryTaskConfig, + ) -> Result { + let key = key(id)?; + let row = Self::query(DB::QUERY_CACHE_GET) + .bind(&key) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; + let task = Self::cached_query(&row, id, self.namespace.as_ref())?; + // Observation reveals timing only to the bound claim. It neither + // expires a row nor writes a tombstone, heartbeat or receipt. + if task["lease_owner"] != owner { + return Err(authority::reject_reason("lease_owner_mismatch")); + } + if task["query_task_attempt"] != attempt { + return Err(authority::reject_reason("query_task_attempt_mismatch")); + } + let reason = Self::query_claim_reason(&self.pool, &task, owner, attempt, config).await?; + let status = match reason { + Some("query_deadline_unavailable") => "failed", + Some("query_task_timed_out") => "timed_out", + _ => task["status"].as_str().unwrap_or("failed"), + }; + Ok( + json!({"query_task_id":id,"query_task_attempt":attempt,"lease_owner":owner, + "query_admitted_at":task["query_admitted_at"],"query_deadline_at":task["query_deadline_at"], + "lease_expires_at":task["lease_expires_at"],"server_time":authority::wire(authority::now()), + "query_task_status":status,"can_continue":reason.is_none(),"reason":reason,"status":200}), + ) + } + + pub(super) async fn guard_query_outcome( + &self, + id: &str, + body: &Value, + failed: bool, + config: &QueryTaskConfig, + ) -> Result> { + self.query_outcome(id, body, failed, config, false).await } pub(super) async fn finish_query( @@ -381,7 +610,21 @@ where task_id: &str, body: Value, failed: bool, + config: &QueryTaskConfig, ) -> Result { + self.query_outcome(task_id, &body, failed, config, true) + .await? + .ok_or_else(|| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_outcome_unavailable")) + } + + async fn query_outcome( + &self, + task_id: &str, + body: &Value, + failed: bool, + config: &QueryTaskConfig, + commit: bool, + ) -> Result> { if serde_json::to_vec(&body)?.len() > 512 * 1024 { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, @@ -389,7 +632,7 @@ where )); } let key = key(task_id)?; - let owner = text(&body, "lease_owner")?; + let owner = text(body, "lease_owner")?; let attempt = body["query_task_attempt"] .as_i64() .filter(|v| *v > 0) @@ -405,73 +648,88 @@ where .fetch_optional(&mut *tx) .await? .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; - let mut task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; - if task["deadline"].as_i64().unwrap_or(0) <= now() - || DB::number(&row, "expiration")? <= now() + let mut task = Self::cached_query(&row, task_id, self.namespace.as_ref())?; + let fingerprint = authority::operation(body, failed)?; + if let Some(receipt) = + authority::receipt(&task, owner, attempt, &fingerprint, authority::now())? { - return Err(refuse(StatusCode::CONFLICT, "query_task_timed_out")); - } - if task["status"] != "leased" { - return Err(refuse(StatusCode::CONFLICT, "query_task_not_leased")); - } - if task["lease_owner"] != owner { - return Err(refuse( - StatusCode::CONFLICT, - "query_task_lease_owner_mismatch", - )); + tx.rollback().await?; + return Ok(Some(receipt)); } - if task["query_task_attempt"] != attempt { - return Err(refuse(StatusCode::CONFLICT, "query_task_attempt_mismatch")); + if authority::expire(&mut task, authority::now())? { + let reason = if authority::deadline(&task).is_none() { + "query_deadline_unavailable" + } else { + "query_task_timed_out" + }; + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; + tx.commit().await?; + self.wake.notify_waiters(); + return Err(authority::reject_reason(reason)); } - if task["lease_until"].as_i64().unwrap_or(0) <= now() { - return Err(refuse(StatusCode::CONFLICT, "query_task_lease_expired")); + if let Some(reason) = + Self::query_claim_reason(&mut *tx, &task, owner, attempt, config).await? + { + return Err(authority::reject_reason(reason)); } - // Registration revocation also fences an otherwise unexpired claim. - let registered = Self::query("SELECT worker_id FROM workflow_worker_registrations WHERE namespace='default' AND worker_id=$1 AND status='active'") - .bind(owner).fetch_optional(&mut *tx).await?; - if registered.is_none() { - return Err(refuse(StatusCode::CONFLICT, "worker_not_registered")); + if !commit { + tx.rollback().await?; + return Ok(None); } - let reason; + let reason: Option; + let failure; + let result; if failed { - let failure = &body["failure"]; - if !failure.is_object() || serde_json::to_vec(failure)?.len() > 32 * 1024 { + let supplied = &body["failure"]; + if !supplied.is_object() || serde_json::to_vec(supplied)?.len() > 32 * 1024 { return Err(refuse( StatusCode::UNPROCESSABLE_ENTITY, "invalid_query_failure", )); } - reason = failure["reason"] - .as_str() - .filter(|v| !v.is_empty() && v.len() <= 255) - .unwrap_or("query_rejected"); - task["failure"] = failure.clone(); - task["failure"]["reason"] = json!(reason); - task["status"] = json!("failed"); + reason = Some( + supplied["reason"] + .as_str() + .filter(|v| !v.is_empty() && v.len() <= 255) + .unwrap_or("query_rejected") + .to_owned(), + ); + failure = Some(supplied.clone()); + result = None; } else { - let result = envelope(&body, "result_envelope")?; - if result.len() > RESULT_BYTES { + let supplied = envelope(body, "result_envelope")?; + if supplied.len() > RESULT_BYTES { return Err(refuse( StatusCode::PAYLOAD_TOO_LARGE, "query_result_exceeds_budget", )); } - task["result_envelope"] = wire(&result); - task["status"] = json!("completed"); - reason = ""; + result = Some(wire(&supplied)); + failure = None; + reason = None; + } + let decided_at = authority::now(); + if let Some(reason) = authority::claim_reason(&task, owner, attempt, decided_at) { + return Err(authority::reject_reason(reason)); + } + let decision = json!({"query_task_id":task_id,"query_task_attempt":attempt, + "outcome":if failed {"failed"} else {"completed"},"reason":reason,"status":200,"recorded":true}); + task["status"] = json!(if failed { "failed" } else { "completed" }); + if let Some(failure) = failure { + task["failure"] = failure; + task["failure"]["reason"] = json!(reason); + } + if let Some(result) = result { + task["result_envelope"] = result; } + task["terminal_receipt"] = json!({"lease_owner":owner,"attempt":attempt, + "fingerprint":fingerprint,"decision":decision,"decided_at":authority::wire(decided_at)}); // Results reserve their slot at admission, and discard the large replay // snapshot. No workflow rows, histories, cursors or waits are changed. - let object = task.as_object_mut().unwrap(); - for field in ["history_events", "workflow_arguments", "query_arguments"] { - object.remove(field); - } - Self::put_query(&mut tx, &key, &task, now() + RECEIPT_SECONDS).await?; + authority::discard_snapshot(&mut task); + Self::put_query(&mut tx, &key, &task, authority::expiration(&task)?).await?; tx.commit().await?; self.wake.notify_waiters(); - Ok( - json!({"query_task_id":task_id,"query_task_attempt":attempt,"outcome":if failed {"failed"} else {"completed"}, - "reason":if failed {Some(reason)} else {None},"status":200}), - ) + Ok(Some(decision)) } } diff --git a/rust/src/runtime/query_authority.rs b/rust/src/runtime/query_authority.rs new file mode 100644 index 000000000..26bea17c4 --- /dev/null +++ b/rust/src/runtime/query_authority.rs @@ -0,0 +1,357 @@ +//! Original query timing and content-bound receipts. These helpers never +//! derive a new execution deadline from a poll, observation or retry. +use super::{Result, RuntimeError, refuse}; +use axum::http::StatusCode; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::env; + +#[derive(Clone, Debug)] +pub struct QueryTaskConfig { + pub timeout_seconds: i64, + pub lease_timeout_seconds: i64, + pub receipt_ttl_seconds: i64, + pub worker_stale_after_seconds: i64, + pub poll_timeout_seconds: i64, +} + +impl Default for QueryTaskConfig { + fn default() -> Self { + Self { + timeout_seconds: 45, + lease_timeout_seconds: 60, + receipt_ttl_seconds: 180, + worker_stale_after_seconds: 30, + poll_timeout_seconds: 5, + } + } +} + +impl QueryTaskConfig { + pub(super) fn from_env() -> Result { + fn integer(primary: &str, legacy: &str, default: i64) -> Result { + for key in [primary, legacy] { + match env::var(key) { + Ok(value) => { + if let Some(value) = environment_integer(&value) { + return Ok(value); + } + } + Err(env::VarError::NotPresent) => {} + Err(_) => { + return Err(refuse( + StatusCode::BAD_REQUEST, + "invalid_query_configuration", + )); + } + } + } + Ok(default) + } + let poll = integer( + "DW_WORKER_POLL_TIMEOUT", + "WORKFLOW_SERVER_WORKER_POLL_TIMEOUT", + 30, + )?; + let lease = integer("DW_WORKFLOW_TASK_TIMEOUT", "WORKFLOW_TASK_TIMEOUT", 60)?; + let heartbeat = integer( + "DW_WORKER_HEARTBEAT_INTERVAL_SECONDS", + "WORKFLOW_SERVER_WORKER_HEARTBEAT_INTERVAL_SECONDS", + 10, + )?; + Ok(Self { + timeout_seconds: integer( + "DW_QUERY_TASK_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_TIMEOUT", + poll.saturating_add(15).clamp(40, 55), + )? + .max(0), + lease_timeout_seconds: integer( + "DW_QUERY_TASK_LEASE_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_LEASE_TIMEOUT", + lease, + )? + .max(1), + receipt_ttl_seconds: integer( + "DW_QUERY_TASK_TTL_SECONDS", + "WORKFLOW_SERVER_QUERY_TASK_TTL_SECONDS", + 180, + )? + .max(60), + worker_stale_after_seconds: integer( + "DW_WORKER_STALE_AFTER_SECONDS", + "WORKFLOW_SERVER_WORKER_STALE_AFTER_SECONDS", + heartbeat.saturating_mul(3).max(30), + )? + .max(1), + poll_timeout_seconds: integer( + "DW_QUERY_TASK_POLL_TIMEOUT", + "WORKFLOW_SERVER_QUERY_TASK_POLL_TIMEOUT", + 5, + )? + .max(1), + }) + } + + pub(super) fn add(time: DateTime, seconds: i64) -> Result> { + chrono::Duration::try_seconds(seconds) + .and_then(|duration| time.checked_add_signed(duration)) + .ok_or_else(|| refuse(StatusCode::BAD_REQUEST, "invalid_query_configuration")) + } + + pub(super) fn timing(&self, admitted: DateTime) -> Result<(DateTime, DateTime)> { + let timeout = self.timeout_seconds.max(0); + let deadline = Self::add(admitted, timeout)?; + let retention = self.receipt_ttl_seconds.max(60).max( + timeout + .saturating_add(self.lease_timeout_seconds.max(1)) + .saturating_add(60), + ); + Ok((deadline, Self::add(admitted, retention)?)) + } +} + +fn environment_integer(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "null" | "(null)" => return None, + "true" | "(true)" => return Some(1), + "false" | "(false)" | "empty" | "(empty)" => return Some(0), + _ => {} + } + let value = raw + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(raw); + Some(super::memo_limits::cast_numeric_string(value)) +} + +pub(super) fn now() -> DateTime { + DateTime::from_timestamp_micros(Utc::now().timestamp_micros()).expect("current UTC time") +} + +pub(super) fn wire(time: DateTime) -> String { + time.to_rfc3339_opts(SecondsFormat::Micros, true) +} + +pub(super) fn timestamp(value: &Value) -> Option> { + let raw = value.as_str()?; + if raw.len() != 27 || !raw.ends_with('Z') { + return None; + } + let parsed = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc); + (wire(parsed) == raw).then_some(parsed) +} + +pub(super) fn deadline(task: &Value) -> Option> { + let admitted = timestamp(&task["query_admitted_at"])?; + let deadline = timestamp(&task["query_deadline_at"])?; + let retention = timestamp(&task["receipt_expires_at"])?; + let minimum = deadline.checked_add_signed(chrono::Duration::seconds(60))?; + (deadline >= admitted && retention >= minimum).then_some(deadline) +} + +pub(super) fn expiration(task: &Value) -> Result { + let expires = timestamp(&task["receipt_expires_at"]) + .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_deadline_unavailable"))?; + // SQL cache expiry is second-resolution. Round cleanup up, while the + // canonical microsecond timestamp remains the authority for every read. + Ok(expires + .timestamp() + .saturating_add(i64::from(expires.timestamp_subsec_micros() != 0))) +} + +pub(super) fn scope(task: &Value, id: &str, namespace: &str) -> Result<()> { + if task["query_task_id"] != id || task["namespace"] != namespace { + return Err(refuse(StatusCode::NOT_FOUND, "query_task_not_found")); + } + Ok(()) +} + +pub(super) fn operation(body: &Value, failed: bool) -> Result { + let payload = if failed { + body["failure"].clone() + } else { + json!({"result":body["result"],"result_envelope":body["result_envelope"]}) + }; + // serde_json's ordered maps preserve object/list and scalar distinctions. + // This fingerprint is private native cache state, never a wire primitive. + let bytes = serde_json::to_vec(&json!([if failed { "fail" } else { "complete" }, payload]))?; + Ok(Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +pub(super) fn receipt( + task: &Value, + owner: &str, + attempt: i64, + fingerprint: &str, + time: DateTime, +) -> Result> { + let receipt = &task["terminal_receipt"]; + if !receipt.is_object() + || deadline(task).is_none() + || timestamp(&task["receipt_expires_at"]).is_none_or(|expiry| expiry <= time) + || receipt["lease_owner"] != owner + || receipt["attempt"] != attempt + { + return Ok(None); + } + if receipt["fingerprint"] != fingerprint { + return Err(refuse(StatusCode::CONFLICT, "query_completion_conflict")); + } + let mut decision = receipt["decision"].clone(); + if !decision.is_object() || decision["query_task_id"] != task["query_task_id"] { + return Err(refuse(StatusCode::CONFLICT, "query_receipt_unavailable")); + } + decision["recorded"] = json!(false); + Ok(Some(decision)) +} + +pub(super) fn claim_reason( + task: &Value, + owner: &str, + attempt: i64, + time: DateTime, +) -> Option<&'static str> { + let Some(deadline) = deadline(task) else { + return Some("query_deadline_unavailable"); + }; + if matches!(task["status"].as_str(), Some("completed" | "failed")) { + return Some("query_task_not_leased"); + } + if time >= deadline { + return Some("query_task_timed_out"); + } + if task["status"] == "timed_out" { + return Some("query_task_timed_out"); + } + if task["status"] != "leased" { + return Some("query_task_not_leased"); + } + if task["lease_owner"] != owner { + return Some("lease_owner_mismatch"); + } + if task["query_task_attempt"] != attempt { + return Some("query_task_attempt_mismatch"); + } + if timestamp(&task["lease_expires_at"]).is_none_or(|lease| time >= lease) { + return Some("lease_expired"); + } + None +} + +pub(super) fn discard_snapshot(task: &mut Value) { + if let Some(object) = task.as_object_mut() { + for field in ["history_events", "workflow_arguments", "query_arguments"] { + object.remove(field); + } + } +} + +pub(super) fn expire(task: &mut Value, time: DateTime) -> Result { + if !matches!(task["status"].as_str(), Some("pending" | "leased")) { + return Ok(false); + } + let original = deadline(task); + if original.is_some_and(|deadline| time < deadline) { + return Ok(false); + } + if original.is_none() { + task["status"] = json!("failed"); + task["failure"] = json!({"reason":"query_deadline_unavailable", "message":"The original query result deadline is unavailable. Submit a new query explicitly."}); + // A legacy entry gets only one refusal tombstone. This timestamp never + // becomes an admission time, execution budget or successful receipt. + if timestamp(&task["receipt_expires_at"]).is_none() { + task["receipt_expires_at"] = json!(wire(QueryTaskConfig::add(time, 60)?)); + } + task["legacy_refusal_at"] = json!(wire(time)); + } else { + task["status"] = json!("timed_out"); + task["timed_out_at"] = json!(wire(time)); + } + discard_snapshot(task); + Ok(true) +} + +pub(super) fn reject_reason(reason: &'static str) -> RuntimeError { + refuse(StatusCode::CONFLICT, reason) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_timeout_preserves_admission_and_retention_microseconds() { + let admitted = timestamp(&json!("2026-10-15T00:00:00.123456Z")).unwrap(); + let policy = QueryTaskConfig { + timeout_seconds: 0, + receipt_ttl_seconds: 1, + lease_timeout_seconds: 2, + ..Default::default() + }; + let (deadline, retention) = policy.timing(admitted).unwrap(); + assert_eq!(deadline, admitted); + assert_eq!(retention, admitted + chrono::Duration::seconds(62)); + let task = json!({"query_admitted_at":wire(admitted),"query_deadline_at":wire(deadline),"receipt_expires_at":wire(retention)}); + assert_eq!(super::deadline(&task), Some(admitted)); + assert_eq!(expiration(&task).unwrap(), retention.timestamp() + 1); + assert_eq!( + claim_reason(&task, "worker", 1, admitted), + Some("query_task_timed_out") + ); + } + + #[test] + fn missing_or_noncanonical_original_time_gets_one_refusal_without_rebasing() { + let time = timestamp(&json!("2026-10-15T00:00:00.123456Z")).unwrap(); + for unavailable in [ + Value::Null, + json!("2026-10-15T00:00:00Z"), + json!("2026-10-15 00:00:00.123456"), + json!("2026-10-15T00:00:00.123456+00:00"), + ] { + let mut task = json!({"status":"leased","deadline":9999999999_i64,"query_admitted_at":wire(time), + "query_deadline_at":unavailable,"receipt_expires_at":wire(time+chrono::Duration::seconds(120)), + "history_events":[{"must_not_be_consumed":true}]}); + assert_eq!( + claim_reason(&task, "worker", 1, time), + Some("query_deadline_unavailable") + ); + assert!(expire(&mut task, time).unwrap()); + let once = task.clone(); + assert!(!expire(&mut task, time + chrono::Duration::seconds(20)).unwrap()); + assert_eq!(task, once); + assert_eq!(task["status"], "failed"); + assert_eq!(task["query_admitted_at"], wire(time)); + assert!(task.get("history_events").is_none()); + assert_eq!( + timestamp(&task["receipt_expires_at"]), + Some(time + chrono::Duration::seconds(120)) + ); + } + } + + #[test] + fn environment_values_keep_php_null_fallback_and_integer_casts() { + for (raw, expected) in [ + ("null", None), + ("(null)", None), + ("true", Some(1)), + ("false", Some(0)), + ("\"4\"", Some(4)), + ("-2", Some(-2)), + ("2e2", Some(200)), + ("garbage", Some(0)), + ] { + assert_eq!(environment_integer(raw), expected, "{raw}"); + } + } +} diff --git a/rust/src/runtime/query_codec_tests.rs b/rust/src/runtime/query_codec_tests.rs new file mode 100644 index 000000000..bddc5694b --- /dev/null +++ b/rust/src/runtime/query_codec_tests.rs @@ -0,0 +1,172 @@ +//! A real original loses authority while waiting for bounded codec capacity. +//! Holding this private semaphore is test instrumentation, not a wire clock. +use super::*; +use crate::{ + codec::{Value as Payload, ValueCodec}, + runtime::{QueryTaskConfig, RuntimeError}, +}; +use serde_json::json; + +fn envelope(value: Payload) -> Value { + json!({"codec":"avro","blob":ValueCodec::new().unwrap().encode(&value).unwrap()}) +} + +#[tokio::test] +async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holding_writer() { + let directory = tempfile::tempdir().unwrap(); + let runtime = Runtime::open( + directory.path().join("runtime.sqlite").to_str().unwrap(), + "test-token".into(), + ) + .await + .unwrap() + .with_query_task_config(QueryTaskConfig { + timeout_seconds: 2, + lease_timeout_seconds: 2, + ..Default::default() + }); + runtime.register(json!({"worker_id":"codec-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[],"capabilities":["query_tasks"], + "workflow_command_contracts":{"echo":{"queries":["state"],"query_contracts":[{"name":"state","parameters":[]}]}}})).await.unwrap(); + let started = runtime + .start( + json!({"workflow_id":"codec-query-original","workflow_type":"echo","task_queue":"test", + "input":envelope(Payload::Array(vec![]))}), + ) + .await + .unwrap(); + let workflow = runtime + .poll( + json!({"worker_id":"codec-worker","task_queue":"test","timeout_seconds":0}), + "workflow", + "1.20", + ) + .await + .unwrap()["task"] + .clone(); + runtime.complete_workflow(workflow["task_id"].as_str().unwrap(),json!({"lease_owner":"codec-worker", + "workflow_task_attempt":workflow["workflow_task_attempt"],"last_history_sequence":workflow["last_history_sequence"], + "commands":[{"type":"complete_workflow","result":envelope(Payload::Long(7))}]})).await.unwrap(); + let pool = runtime.sqlite_pool().clone(); + let history_before: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + let mut waiting = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .query_workflow( + "codec-query-original", + None, + "state", + json!({"input":envelope(Payload::Array(vec![]))}), + ) + .await + }) + }; + let task = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let response = runtime + .poll_query( + json!({"worker_id":"codec-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .unwrap(); + if !response["task"].is_null() { + break response["task"].clone(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let held = runtime.signal_codec.clone().acquire_owned().await.unwrap(); + let mut complete = { + let runtime = runtime.clone(); + let task = task.clone(); + tokio::spawn(async move { + runtime + .complete_query( + task["query_task_id"].as_str().unwrap(), + json!({ + "lease_owner":"codec-worker","query_task_attempt":task["query_task_attempt"], + "result_envelope":envelope(Payload::Long(9007199254740993))}), + ) + .await + }) + }; + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut complete) + .await + .is_err(), + "codec capacity must hold this submission" + ); + // A separate writer remains usable while the submission waits for the + // semaphore. The completion cannot hold its preflight transaction open. + let key = format!( + "dw-native-query:{}", + task["query_task_id"].as_str().unwrap() + ); + tokio::time::timeout( + Duration::from_millis(250), + sqlx::query("UPDATE dw_query_cache SET value=value WHERE \"key\"=$1") + .bind(&key) + .execute(&pool), + ) + .await + .expect("codec wait must release the writer") + .unwrap(); + let mut writer = pool.acquire().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .unwrap(); + let deadline = + chrono::DateTime::parse_from_rfc3339(task["query_deadline_at"].as_str().unwrap()) + .unwrap() + .with_timezone(&chrono::Utc); + let remaining = (deadline - chrono::Utc::now()).to_std().unwrap_or_default(); + let caller = tokio::time::timeout(remaining + Duration::from_millis(250), &mut waiting) + .await + .expect("bookkeeping lock pressure must not extend the original caller deadline") + .unwrap() + .unwrap_err(); + assert!( + matches!( + caller, + RuntimeError::Protocol { + status: StatusCode::GATEWAY_TIMEOUT, + .. + } + ), + "{caller:?}" + ); + sqlx::query("ROLLBACK").execute(&mut *writer).await.unwrap(); + drop(writer); + drop(held); + let result = complete.await.unwrap().unwrap_err(); + assert!( + matches!( + result, + RuntimeError::Refused { + status: StatusCode::CONFLICT, + reason: "query_task_timed_out" + } + ), + "{result:?}" + ); + let stored: String = sqlx::query_scalar("SELECT value FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + let stored: Value = serde_json::from_str(&stored).unwrap(); + assert_eq!(stored["query_admitted_at"], task["query_admitted_at"]); + assert_eq!(stored["query_deadline_at"], task["query_deadline_at"]); + assert_eq!(stored["status"], "timed_out"); + assert!(stored.get("result_envelope").is_none()); + assert!(stored.get("terminal_receipt").is_none()); + let history_after: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + assert_eq!(history_before, history_after); + runtime.close().await; +} diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 75fcc4272..3e543ffc3 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -2740,7 +2740,7 @@ pub(crate) fn capabilities() -> Value { "worker_deregistration_fencing":{"schema":"durable-workflow.v2.worker-deregistration.v1","supported":true,"receipt_retention_seconds":600,"endpoint":"/worker/registrations/{workerId}/deregister"}, "workflow_memo_updates": false, "cooperative_cancellation": true, "prepared_local_activities": false, "worker_sessions": false, "sticky_execution": false, "local_activities": false, "message_streams": false, - "workflow_updates": true, "query_tasks": true, "query_task_poll_request_idempotency": false}); + "workflow_updates": true, "query_tasks": true, "query_task_authority":false, "query_task_poll_request_idempotency": false}); if super::memos::UPDATES_SUPPORTED { value["supported_workflow_task_commands"] .as_array_mut() diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index ac1882a09..c8c01527c 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -69,6 +69,8 @@ mod condition_occurrences; #[path = "execution/query_arguments.rs"] mod query_arguments; +#[path = "execution/query_authority.rs"] +mod query_authority; #[path = "execution/external_payloads.rs"] mod external_payloads; @@ -2069,6 +2071,19 @@ async fn await_query(app: &Router, worker: &str) -> Value { .expect("query must be admitted without holding the database pool") } +fn assert_query_replay(first: &Value, repeated: Value) { + let initial = + chrono::DateTime::parse_from_rfc3339(first["server_time"].as_str().unwrap()).unwrap(); + let current = + chrono::DateTime::parse_from_rfc3339(repeated["server_time"].as_str().unwrap()).unwrap(); + assert!(current >= initial); + let mut replay = repeated; + // Only this observation clock is nondeterministic. Original query timing, + // lease, identities, values and every business-history field remain exact. + replay["server_time"] = first["server_time"].clone(); + assert_eq!(replay, *first); +} + #[tokio::test] async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() { let database = TestDatabase::new().await; @@ -2165,7 +2180,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() ); assert_eq!(projected, before["events"]); assert_eq!(claim["query_arguments"], input["input"]); - assert_eq!(poll(&a, "query-a", "query").await, claim); + assert_query_replay(&claim, poll(&a, "query-a", "query").await); assert!(poll(&b, "query-b", "query").await.is_null()); let finish_path = format!( "/api/worker/query-tasks/{}/complete", @@ -2177,7 +2192,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() wrong["lease_owner"] = json!("query-b"); assert_eq!( request(&b, "POST", &finish_path, wrong).await.1["reason"], - "query_task_lease_owner_mismatch" + "lease_owner_mismatch" ); let mut invalid = completion.clone(); invalid["result_envelope"]["blob"] = json!("not-avro"); @@ -2186,15 +2201,15 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() "invalid_query_result" ); match &database { - TestDatabase::Sqlite(_) => database.execute("UPDATE dw_query_cache SET value=json_set(value,'$.lease_until',0) WHERE value LIKE '%\"status\":\"leased\"%'").await, - TestDatabase::Postgres{..} => database.execute("UPDATE dw_query_cache SET value=jsonb_set(value::jsonb,'{lease_until}','0')::text WHERE value LIKE '%\"status\":\"leased\"%'").await, - TestDatabase::MySql{..} => database.execute("UPDATE dw_query_cache SET value=JSON_SET(value,'$.lease_until',0) WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::Sqlite(_) => database.execute("UPDATE dw_query_cache SET value=json_set(value,'$.lease_expires_at','2000-01-01T00:00:00.000000Z') WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::Postgres{..} => database.execute("UPDATE dw_query_cache SET value=jsonb_set(value::jsonb,'{lease_expires_at}','\"2000-01-01T00:00:00.000000Z\"')::text WHERE value LIKE '%\"status\":\"leased\"%'").await, + TestDatabase::MySql{..} => database.execute("UPDATE dw_query_cache SET value=JSON_SET(value,'$.lease_expires_at','2000-01-01T00:00:00.000000Z') WHERE value LIKE '%\"status\":\"leased\"%'").await, } assert_eq!( request(&a, "POST", &finish_path, completion.clone()) .await .1["reason"], - "query_task_lease_expired" + "lease_expired" ); let recovered = await_query(&a, "query-b").await; assert_eq!(recovered["query_task_id"], claim["query_task_id"]); @@ -2204,7 +2219,7 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() request(&a, "POST", &finish_path, completion.clone()) .await .1["reason"], - "query_task_lease_owner_mismatch" + "lease_owner_mismatch" ); let mut current = completion.clone(); current["lease_owner"] = json!("query-b"); @@ -2220,10 +2235,9 @@ async fn queries_fence_recovered_leases_across_pools_without_mutating_the_run() let outcome = waiting.await.unwrap(); assert_eq!(outcome.0, StatusCode::OK, "{}", outcome.1); assert_eq!(outcome.1["result_envelope"], result); - assert_eq!( - request(&b, "POST", &finish_path, current).await.1["reason"], - "query_task_not_leased" - ); + let duplicate = request(&b, "POST", &finish_path, current).await; + assert_eq!(duplicate.0, StatusCode::OK, "{}", duplicate.1); + assert_eq!(duplicate.1["recorded"], false); assert_eq!( request(&a, "GET", &history_path, Value::Null).await.1, before diff --git a/rust/tests/execution/namespaces.rs b/rust/tests/execution/namespaces.rs index a6c513e15..37eea8d9e 100644 --- a/rust/tests/execution/namespaces.rs +++ b/rust/tests/execution/namespaces.rs @@ -281,7 +281,7 @@ async fn named_idle_query_poll_preserves_same_id_default_namespace_claim() { idle.1["task"].is_null(), "named poll must not replay another namespace's same-ID lease" ); - assert_eq!(poll(&app, "shared-query-worker", "query").await, original); + assert_query_replay(&original, poll(&app, "shared-query-worker", "query").await); assert_eq!(request(&app,"POST", &format!("/api/worker/query-tasks/{}/complete",original["query_task_id"].as_str().unwrap()), json!({"lease_owner":"shared-query-worker","query_task_attempt":original["query_task_attempt"],"result_envelope":result})).await.0, diff --git a/rust/tests/execution/query_authority.rs b/rust/tests/execution/query_authority.rs new file mode 100644 index 000000000..211c53f43 --- /dev/null +++ b/rust/tests/execution/query_authority.rs @@ -0,0 +1,905 @@ +//! The published query-authority contract is independent of activity and +//! workflow completion receipts. No business history is changed by a query. +use super::*; +use chrono::{DateTime, SecondsFormat, Utc}; +use std::time::Duration; +use tokio::task::JoinHandle; + +struct Original { + database: TestDatabase, + runtime: Runtime, + app: Router, + claim: Value, + waiting: JoinHandle<(StatusCode, Value)>, + history_path: String, + history: Value, +} + +async fn original(name: &str) -> Original { + configured_original(name, None).await +} + +async fn configured_original( + name: &str, + config: Option, +) -> Original { + let database = TestDatabase::new().await; + let mut runtime = database.open().await.unwrap(); + if let Some(config) = config { + runtime = runtime.with_query_task_config(config); + } + let app = router(runtime.clone()); + query_worker(&app, "authority-worker").await; + let started = start(&app, name).await; + let task = poll(&app, "authority-worker", "workflow").await; + let completed = request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion( + &task, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(7))}]), + ), + ) + .await; + assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); + let history_path = format!( + "/api/workflows/{name}/runs/{}/history", + started["run_id"].as_str().unwrap() + ); + let history = request(&app, "GET", &history_path, Value::Null).await.1; + let waiting = { + let app = app.clone(); + let path = format!("/api/workflows/{name}/query/state"); + tokio::spawn(async move { + request( + &app, + "POST", + &path, + json!({"input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await + }) + }; + let claim = await_query(&app, "authority-worker").await; + Original { + database, + runtime, + app, + claim, + waiting, + history_path, + history, + } +} + +fn instant(value: &Value) -> DateTime { + let raw = value.as_str().expect("original query authority timestamp"); + let parsed = DateTime::parse_from_rfc3339(raw) + .unwrap() + .with_timezone(&Utc); + assert_eq!(raw, parsed.to_rfc3339_opts(SecondsFormat::Micros, true)); + parsed +} + +fn submitted(claim: &Value) -> Value { + json!({"lease_owner":"authority-worker", "query_task_attempt":claim["query_task_attempt"], + "result_envelope":envelope(Payload::Array(vec![Payload::Long(9007199254740993),Payload::Null,Payload::String("rangeλ".into())]))}) +} + +async fn cache_snapshot(database: &TestDatabase, id: &str) -> (String, i64) { + let key = format!("dw-native-query:{id}"); + macro_rules! read { + ($pool:expr,$sql:literal) => {{ + let pool = $pool; + let snapshot = sqlx::query_as($sql) + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + pool.close().await; + snapshot + }}; + } + match database { + TestDatabase::Sqlite(directory) => read!( + sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1" + ), + TestDatabase::Postgres { options, .. } => read!( + sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1" + ), + TestDatabase::MySql { options, .. } => read!( + sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "SELECT value,expiration FROM dw_query_cache WHERE `key`=?" + ), + } +} + +async fn replace_cache(database: &TestDatabase, id: &str, task: &Value, expires: i64) { + let key = format!("dw-native-query:{id}"); + macro_rules! write { + ($pool:expr,$sql:literal) => {{ + let pool = $pool; + assert_eq!( + sqlx::query($sql) + .bind(task.to_string()) + .bind(expires) + .bind(&key) + .execute(&pool) + .await + .unwrap() + .rows_affected(), + 1 + ); + pool.close().await; + }}; + } + match database { + TestDatabase::Sqlite(directory) => write!( + sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE \"key\"=$3" + ), + TestDatabase::Postgres { options, .. } => write!( + sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE \"key\"=$3" + ), + TestDatabase::MySql { options, .. } => write!( + sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(), + "UPDATE dw_query_cache SET value=?,expiration=? WHERE `key`=?" + ), + } +} + +async fn observation(app: &Router, id: &str, owner: &str, attempt: i64) -> (StatusCode, Value) { + let response = admission::admission_http( + app, + "GET", + &format!( + "/api/worker/query-tasks/{id}/status?lease_owner={owner}&query_task_attempt={attempt}" + ), + true, + Some("test-token"), + Some("1.20"), + None, + Value::Null, + ) + .await; + (response.0, response.2) +} + +#[tokio::test] +async fn concurrent_identical_failure_has_one_decision_and_conflicts_stay_refused() { + let original = original("query-authority-failed-receipt").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let path = format!("/api/worker/query-tasks/{id}/fail"); + let body = json!({"lease_owner":"authority-worker","query_task_attempt":original.claim["query_task_attempt"], + "failure":{"reason":"application_refused","message":"rangeλ","details":{"value":9007199254740993_i64,"nil":null}}}); + let (first, second) = tokio::join!( + request(&original.app, "POST", &path, body.clone()), + request(&original.app, "POST", &path, body.clone()) + ); + assert_eq!( + (first.0, second.0), + (StatusCode::OK, StatusCode::OK), + "{first:?} {second:?}" + ); + let mut recorded = [ + first.1["recorded"].as_bool().unwrap(), + second.1["recorded"].as_bool().unwrap(), + ]; + recorded.sort(); + assert_eq!(recorded, [false, true]); + assert_eq!(first.1["outcome"], "failed"); + let caller = original.waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::CONFLICT, "{}", caller.1); + assert_eq!(caller.1["reason"], "application_refused"); + let snapshot = cache_snapshot(&original.database, id).await; + let mut wrong_owner = body.clone(); + wrong_owner["lease_owner"] = json!("other-worker"); + for changed in [wrong_owner, Value::Null] { + // Different operations and claims cannot borrow an earlier decision. + let (route, payload, reason) = if changed.is_null() { + ( + format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + "query_completion_conflict", + ) + } else { + (path.clone(), changed, "query_task_not_leased") + }; + let refused = request(&original.app, "POST", &route, payload).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], reason); + } + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_task_not_leased"); + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn status_checks_auth_namespace_and_claim_before_revealing_original_timing() { + let original = original("query-authority-private-observation").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let path = format!( + "/api/worker/query-tasks/{id}/status?lease_owner=authority-worker&query_task_attempt=1" + ); + let snapshot = cache_snapshot(&original.database, id).await; + let unauthorized = admission::admission_http( + &original.app, + "GET", + &path, + true, + None, + Some("1.20"), + Some("ghost"), + Value::Null, + ) + .await; + assert_eq!(unauthorized.0, StatusCode::UNAUTHORIZED); + namespaces::create(&original.app, "alpha").await; + let foreign = admission::admission_http( + &original.app, + "GET", + &path, + true, + Some("test-token"), + Some("1.20"), + Some("alpha"), + Value::Null, + ) + .await; + assert_eq!(foreign.0, StatusCode::NOT_FOUND, "{}", foreign.2); + assert!(foreign.2.get("query_deadline_at").is_none()); + for (owner, attempt, reason) in [ + ("other-worker", 1, "lease_owner_mismatch"), + ("authority-worker", 2, "query_task_attempt_mismatch"), + ] { + let refused = observation(&original.app, id, owner, attempt).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], reason); + assert!(refused.1.get("query_deadline_at").is_none()); + } + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + let complete = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + ) + .await; + assert_eq!(complete.0, StatusCode::OK, "{}", complete.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn unavailable_legacy_deadline_gets_only_one_fixed_refusal_tombstone() { + legacy_refusal(false).await; + legacy_refusal(true).await; +} + +async fn legacy_refusal(missing_retention: bool) { + let original = original("query-authority-legacy-refusal").await; + original.waiting.abort(); + assert!(original.waiting.await.unwrap_err().is_cancelled()); + let id = original.claim["query_task_id"].as_str().unwrap(); + let (raw, expires) = cache_snapshot(&original.database, id).await; + let mut legacy: Value = serde_json::from_str(&raw).unwrap(); + legacy.as_object_mut().unwrap().remove("query_deadline_at"); + if missing_retention { + legacy.as_object_mut().unwrap().remove("receipt_expires_at"); + } + legacy["deadline"] = json!(9999999999_i64); + replace_cache(&original.database, id, &legacy, expires).await; + let before = cache_snapshot(&original.database, id).await; + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_deadline_unavailable"); + assert_eq!( + cache_snapshot(&original.database, id).await, + before, + "status must not create a tombstone" + ); + let path = format!("/api/worker/query-tasks/{id}/complete"); + let refused = request(&original.app, "POST", &path, submitted(&original.claim)).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_deadline_unavailable"); + let fixed = cache_snapshot(&original.database, id).await; + let tombstone: Value = serde_json::from_str(&fixed.0).unwrap(); + if missing_retention { + assert_eq!( + instant(&tombstone["receipt_expires_at"]) - instant(&tombstone["legacy_refusal_at"]), + chrono::Duration::seconds(60) + ); + } else { + assert_eq!( + tombstone["receipt_expires_at"], + legacy["receipt_expires_at"] + ); + } + assert_eq!(tombstone["query_admitted_at"], legacy["query_admitted_at"]); + assert!(tombstone.get("query_deadline_at").is_none()); + assert!(tombstone.get("terminal_receipt").is_none()); + assert!(tombstone.get("history_events").is_none()); + tokio::time::sleep(Duration::from_millis(20)).await; + let again = request(&original.app, "POST", &path, submitted(&original.claim)).await; + assert_eq!(again.0, StatusCode::CONFLICT, "{}", again.1); + assert_eq!(again.1["reason"], "query_deadline_unavailable"); + assert_eq!(cache_snapshot(&original.database, id).await, fixed); + assert!( + poll(&original.app, "authority-worker", "query") + .await + .is_null() + ); + assert_eq!(cache_snapshot(&original.database, id).await, fixed); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn expired_original_refuses_before_decoding_and_status_does_not_extend_it() { + let config = durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 2, + lease_timeout_seconds: 2, + ..Default::default() + }; + let original = configured_original("query-authority-expired-original", Some(config)).await; + let id = original.claim["query_task_id"].as_str().unwrap(); + assert_eq!( + original.waiting.await.unwrap().0, + StatusCode::GATEWAY_TIMEOUT + ); + let snapshot = cache_snapshot(&original.database, id).await; + let status = observation(&original.app, id, "authority-worker", 1).await; + assert_eq!(status.0, StatusCode::OK, "{}", status.1); + assert_eq!(status.1["can_continue"], false); + assert_eq!(status.1["reason"], "query_task_timed_out"); + for field in ["query_admitted_at", "query_deadline_at", "lease_expires_at"] { + assert_eq!(status.1[field], original.claim[field]); + } + assert_eq!(cache_snapshot(&original.database, id).await, snapshot); + let mut body = submitted(&original.claim); + body["result_envelope"] = json!({"codec":"avro","blob":"invalid-avro"}); + let refused = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + body, + ) + .await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_task_timed_out"); + let (raw, _) = cache_snapshot(&original.database, id).await; + let expired: Value = serde_json::from_str(&raw).unwrap(); + let before: Value = serde_json::from_str(&snapshot.0).unwrap(); + assert_eq!(expired["status"], "timed_out"); + for field in [ + "query_admitted_at", + "query_deadline_at", + "receipt_expires_at", + ] { + assert_eq!(expired[field], before[field]); + } + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn waiting_poll_cannot_claim_for_a_same_id_replacement_registration() { + let original = original("query-authority-poll-incarnation").await; + let id = original.claim["query_task_id"].as_str().unwrap(); + let finished = request( + &original.app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&original.claim), + ) + .await; + assert_eq!(finished.0, StatusCode::OK, "{}", finished.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + let waiting_poll = { + let app = original.app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":5}), + ) + .await + }) + }; + // Wait through multiple empty poll probes before replacing the already + // registered worker. This request belongs to the old registration. + tokio::time::sleep(Duration::from_millis(150)).await; + query_worker(&original.app, "authority-worker").await; + let stale = tokio::time::timeout(Duration::from_secs(2), waiting_poll) + .await + .expect("a replaced poll must refuse without waiting for new work") + .unwrap(); + assert_eq!(stale.0, StatusCode::OK, "{}", stale.1); + assert_eq!(stale.1["poll_status"], "stale_worker_registration"); + assert!(stale.1["task"].is_null()); + let waiting = { + let app = original.app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/workflows/query-authority-poll-incarnation/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(9)]))}), + ) + .await + }) + }; + let replacement = await_query(&original.app, "authority-worker").await; + assert_eq!(replacement["query_task_attempt"], 1); + let complete = request( + &original.app, + "POST", + &format!( + "/api/worker/query-tasks/{}/complete", + replacement["query_task_id"].as_str().unwrap() + ), + submitted(&replacement), + ) + .await; + assert_eq!(complete.0, StatusCode::OK, "{}", complete.1); + assert_eq!(waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn delivery_and_status_keep_original_microseconds_without_renewal() { + let original = original("query-authority-observation").await; + let admitted = instant(&original.claim["query_admitted_at"]); + let deadline = instant(&original.claim["query_deadline_at"]); + let lease = instant(&original.claim["lease_expires_at"]); + let served = instant(&original.claim["server_time"]); + let cached = cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap(), + ) + .await; + assert!(admitted <= served && served < deadline && lease <= deadline); + let status_path = format!( + "/api/worker/query-tasks/{}/status?lease_owner=authority-worker&query_task_attempt={}", + original.claim["query_task_id"].as_str().unwrap(), + original.claim["query_task_attempt"] + ); + for _ in 0..2 { + let (status, _, observation) = admission::admission_http( + &original.app, + "GET", + &status_path, + true, + Some("test-token"), + Some("1.20"), + None, + Value::Null, + ) + .await; + assert_eq!(status, StatusCode::OK, "{observation}"); + for field in [ + "query_task_id", + "query_task_attempt", + "lease_owner", + "query_admitted_at", + "query_deadline_at", + "lease_expires_at", + ] { + assert_eq!(observation[field], original.claim[field], "{field}"); + } + assert_eq!(observation["can_continue"], true); + assert_eq!(observation["query_task_status"], "leased"); + assert!(instant(&observation["server_time"]) >= served); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let legacy = admission::admission_http( + &original.app, + "GET", + &status_path, + true, + Some("test-token"), + Some("1.19"), + None, + Value::Null, + ) + .await; + assert_eq!(legacy.0, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(legacy.2["reason"], "query_task_status_unavailable"); + assert_eq!( + cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap() + ) + .await, + cached + ); + let finished = request( + &original.app, + "POST", + &format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ), + submitted(&original.claim), + ) + .await; + assert_eq!(finished.0, StatusCode::OK, "{}", finished.1); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn identical_committed_result_reconciles_before_and_after_original_expiry() { + let config = durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 4, + lease_timeout_seconds: 4, + ..Default::default() + }; + let original = configured_original("query-authority-receipt", Some(config)).await; + let path = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + let body = submitted(&original.claim); + let first = request(&original.app, "POST", &path, body.clone()).await; + assert_eq!(first.0, StatusCode::OK, "{}", first.1); + assert_eq!(first.1["recorded"], true); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + let before = request(&original.app, "POST", &path, body.clone()).await; + assert_eq!(before.0, StatusCode::OK, "{}", before.1); + assert_eq!(before.1["recorded"], false); + let mut conflicting = body.clone(); + conflicting["result_envelope"] = json!({"codec":"avro","blob":"not-the-committed-bytes"}); + let refused = request(&original.app, "POST", &path, conflicting.clone()).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_completion_conflict"); + let remaining = instant(&original.claim["query_deadline_at"]) - Utc::now(); + tokio::time::sleep(remaining.to_std().unwrap_or_default() + Duration::from_millis(20)).await; + let after = request(&original.app, "POST", &path, body).await; + assert_eq!(after.0, StatusCode::OK, "{}", after.1); + assert_eq!(after.1["recorded"], false); + let refused = request(&original.app, "POST", &path, conflicting).await; + assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); + assert_eq!(refused.1["reason"], "query_completion_conflict"); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn zero_is_immediate_and_later_policy_does_not_rebase_an_existing_query() { + let original = original("query-authority-policy").await; + let changed = router(original.runtime.clone().with_query_task_config( + durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 0, + receipt_ttl_seconds: 60, + ..Default::default() + }, + )); + let expired = request( + &changed, + "POST", + "/api/workflows/query-authority-policy/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(8)]))}), + ) + .await; + assert_eq!(expired.0, StatusCode::GATEWAY_TIMEOUT, "{}", expired.1); + assert_eq!(expired.1["reason"], "query_task_timed_out"); + assert_query_replay( + &original.claim, + poll(&changed, "authority-worker", "query").await, + ); + let complete_path = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + assert_eq!( + request(&changed, "POST", &complete_path, submitted(&original.claim)) + .await + .0, + StatusCode::OK + ); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request(&changed, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} + +#[tokio::test] +async fn same_worker_id_across_namespaces_and_replacement_keep_separate_originals() { + let original = original("query-authority-default").await; + namespaces::create(&original.app, "alpha").await; + let definition = json!({"worker_id":"authority-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[],"capabilities":["query_tasks"], + "workflow_command_contracts":{"echo":{"queries":["state"],"query_contracts":[{"name":"state", + "parameters":[{"name":"request","position":0,"type":"int","required":true, + "variadic":false,"default_available":false,"allows_null":false}]}]}}}); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/register", + definition.clone() + ) + .await + .0, + StatusCode::CREATED + ); + let started = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/workflows", + json!({ + "workflow_id":"query-authority-alpha","workflow_type":"echo","task_queue":"test", + "input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await; + assert_eq!(started.0, StatusCode::CREATED, "{}", started.1); + let task = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/workflow-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .1["task"] + .clone(); + assert!(!task.is_null()); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion( + &task, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(9))}]) + ) + ) + .await + .0, + StatusCode::OK + ); + let alpha_history_path = format!( + "/api/workflows/query-authority-alpha/runs/{}/history", + started.1["run_id"].as_str().unwrap() + ); + let alpha_history = namespaces::scoped( + &original.app, + "alpha", + "GET", + &alpha_history_path, + Value::Null, + ) + .await; + let alpha_waiting = { + let app = original.app.clone(); + tokio::spawn(async move { + namespaces::scoped( + &app, + "alpha", + "POST", + "/api/workflows/query-authority-alpha/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(9)]))}), + ) + .await + }) + }; + let alpha_claim = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let response = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await; + assert_eq!(response.0, StatusCode::OK, "{}", response.1); + if !response.1["task"].is_null() { + break response.1["task"].clone(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(alpha_claim["namespace"], "alpha"); + assert_ne!( + alpha_claim["query_task_id"], + original.claim["query_task_id"] + ); + let default_complete = format!( + "/api/worker/query-tasks/{}/complete", + original.claim["query_task_id"].as_str().unwrap() + ); + let foreign = namespaces::scoped( + &original.app, + "alpha", + "POST", + &default_complete, + submitted(&original.claim), + ) + .await; + assert_eq!(foreign.0, StatusCode::NOT_FOUND, "{}", foreign.1); + let alpha_complete = format!( + "/api/worker/query-tasks/{}/complete", + alpha_claim["query_task_id"].as_str().unwrap() + ); + assert_eq!( + request( + &original.app, + "POST", + &alpha_complete, + submitted(&alpha_claim) + ) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/register", + definition + ) + .await + .0, + StatusCode::CREATED + ); + let old = namespaces::scoped( + &original.app, + "alpha", + "POST", + &alpha_complete, + submitted(&alpha_claim), + ) + .await; + assert_eq!(old.0, StatusCode::CONFLICT, "{}", old.1); + assert_eq!(old.1["reason"], "stale_worker_registration"); + let replacement = namespaces::scoped( + &original.app, + "alpha", + "POST", + "/api/worker/query-tasks/poll", + json!({"worker_id":"authority-worker","task_queue":"test","timeout_seconds":0}), + ) + .await + .1["task"] + .clone(); + assert_eq!(replacement["query_task_id"], alpha_claim["query_task_id"]); + assert_eq!(replacement["query_task_attempt"], 2); + for field in ["query_admitted_at", "query_deadline_at", "history_events"] { + assert_eq!(replacement[field], alpha_claim[field], "{field}"); + } + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "POST", + &alpha_complete, + submitted(&replacement) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(alpha_waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + request( + &original.app, + "POST", + &default_complete, + submitted(&original.claim) + ) + .await + .0, + StatusCode::OK + ); + assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); + assert_eq!( + namespaces::scoped( + &original.app, + "alpha", + "GET", + &alpha_history_path, + Value::Null + ) + .await, + alpha_history + ); + assert_eq!( + request(&original.app, "GET", &original.history_path, Value::Null) + .await + .1, + original.history + ); + original.runtime.close().await; + original.database.remove().await; +} From d9ab68d9c2b03c017f576680d6805310486348fd Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 03:32:02 +0000 Subject: [PATCH 02/36] docs(rust): reconcile qualification and query authority status --- docs/rust-server-port.md | 81 +++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index ba9173f45..be62d8bb4 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -6,11 +6,13 @@ The unpublished Rust crate has an Avro foundation and an opt-in SQLite/PostgreSQ execution slice. Full runtime parity, migration, performance improvement and cutover remain unqualified. -The latest merged foundation is [#450](https://github.com/durable-workflow/server/pull/450), -at `e603a245e`, following #445's memo transaction preparation, #446's bounded -continuation and #448's SDK memo authoring. Each exact candidate tree passes -its own 13-job shared suite across six databases. Separate fresh-main -qualification remains pending; no result is carried across changed trees. +The latest qualified merged foundation is [#457](https://github.com/durable-workflow/server/pull/457), +at `303ffc633`, including #445's memo transaction preparation, #446's bounded +continuation, #448's SDK memo authoring and #450's local external transport. +Its separate fresh-main [38668891437](https://github.com/durable-workflow/server/actions/runs/38668891437) +passes all 13 jobs across six databases; all six execution logs were read. +Required PHP/source and boundary checks also pass. Earlier failed observations +remain failed; this current-tree result is not carried across later changes. The merged native development runtime enables memo upserts, while full external transport discovery stays unadvertised. Frozen published PHP, embedded and SDK tuples remain separate from current PHP release work. Representative timer, signal, @@ -20,41 +22,52 @@ failed; later qualifying runs are separate observations. ## Work order and current status -The active integration order is: - -- [#445](https://github.com/durable-workflow/server/pull/445): canonical memo - transaction foundation merged `bb199cb71`; its separate fresh-main 13-job - qualification passes in 38645794230, with every database log audited. - This preparation kept the memo-update gate disabled. -- [#446](https://github.com/durable-workflow/server/pull/446): cold continue-as-new - after a genuine activity, merged `dff9e846`. Its identical whole tree passes - all 13 candidate jobs after one unchanged MariaDB retry. The original hosted - runner loss remains failed without an established cause. Fresh main is pending. -- [#448](https://github.com/durable-workflow/server/pull/448): actual SDK memo - authoring and continuation inheritance, merged `284f5b822`. Its exact whole - tree passes all 13 candidate jobs and required source checks. Fresh main and - external-memo policy remain pending. -- [#450](https://github.com/durable-workflow/server/pull/450): verified local - external input, workflow/activity results and activity arguments. Installed - PHP/native originals, including a two-activity batch and cold parent, complete. - Two-reference atomic rollback/cold delivery controls pass. Its exact whole - tree is merged as `e603a245e` after all 13 candidate jobs and required source - checks pass. Fresh main is pending. Those regression jobs do not imply new - external-consumer execution on every database. One local 100 ms transfer control - failed; three isolated controls and an unchanged full repeat pass, with the - original cause still unknown. +The current integration order is: + +- Merged [#445](https://github.com/durable-workflow/server/pull/445), + [#446](https://github.com/durable-workflow/server/pull/446), + [#448](https://github.com/durable-workflow/server/pull/448) and + [#450](https://github.com/durable-workflow/server/pull/450) provide memo + transactions, actual SDK memo authoring, bounded continuation and verified + local external values. Their individual acceptance records preserve original + registration, runner and 100 ms upload failures. Current merged execution is + covered by the separate #457 fresh-main qualification above. External memo + policy, complete continuation transfer and full external transport remain open. - [#455](https://github.com/durable-workflow/server/pull/455): opt-in, - namespace/queue-scoped null-task poll wake. Its combined matrix remains - pending. An earlier component's SQLite PHP target failed after an activity + namespace/queue-scoped null-task poll wake. Component `1fa1cc2e6` passes all + 13 jobs and all six databases; promotion is through the combined #458 tree, + whose own matrix remains pending. An earlier component's SQLite PHP target failed after an activity completion response when the Rust actor's registration DELETE returned lock-pressure 503; later refusal/repair phases were not reached. That original stays failed. Idle/query cost and matched performance remain open. - [#457](https://github.com/durable-workflow/server/pull/457): extend the actual installed external consumers to separate PHP/native databases in all six - configurations. Its first local SQLite recipe completes five originals per - target and cold reads with exact typed values. Other database execution, - final-head hosted qualification and promotion remain pending. This does not - qualify automatic policy, S3 or completion-upload authority. + configurations. Candidate and separate merged-main executions now pass all + six configurations, with five original workflows per target and exact typed + cold reads. It is delivered at `303ffc633`. This does not qualify automatic + policy, S3 or completion-upload authority. +- [#458](https://github.com/durable-workflow/server/pull/458): completion-bound + local activity-result uploads, including exact PHP Unicode context hashing, + original live authority and durable allowances. Combined head `93368d2ae` + includes #455 and the current PHP source; its exact matrix remains pending. + Earlier component passes do not establish acceptance for this whole tree. +- [#460](https://github.com/durable-workflow/server/pull/460): read-only PHP + catalog/data inventory. Head `4459140cd` adds actual FIFO/directory refusal + and a separately budgeted fresh-upload capacity control. Its matrix is + pending; the earlier SQLite 100 ms upload failure remains failed. Inspection + does not establish backup consistency, PHP quiescence, stored-representation + compatibility or takeover authority. All those qualification flags stay false. +- Native [query authority](https://github.com/durable-workflow/server/issues/433) + is prepared at `5d10c6b74` under the agreed PHP contract: immutable UTC6 + timing, capped leases, non-renewing status, registration/namespace fences and + content-bound query receipts. Local staged checks cover SQLite, MySQL 8.4 + and PostgreSQL 17. The corrected waiter preserves the original deadline under + a held writer and refuses a result delayed by codec capacity. This is not a + clean full-suite or matched PHP/SDK qualification: the earlier complete + SQLite run still has its failed fresh-upload control. Receipt reconciliation + under writer pressure, continuation expiry, remaining pressure boundaries, + combined ancestry and installed consumers remain next. Discovery keeps + `query_task_authority=false` until those gates pass. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, From 879806994a56e91cb471d70dca2a55f5023b0f50 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 03:39:48 +0000 Subject: [PATCH 03/36] fix(rust): reconcile query receipts before writer admission --- rust/src/runtime/queries.rs | 17 +++- rust/src/runtime/query_codec_tests.rs | 130 +++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/rust/src/runtime/queries.rs b/rust/src/runtime/queries.rs index 244723009..16003657b 100644 --- a/rust/src/runtime/queries.rs +++ b/rust/src/runtime/queries.rs @@ -642,6 +642,22 @@ where "invalid_query_task_attempt", ) })?; + let fingerprint = authority::operation(body, failed)?; + // Retained decisions are immutable. Reconcile them using a scoped + // read before acquiring a writer or preparing the result again. + let observed = Self::query(DB::QUERY_CACHE_GET) + .bind(&key) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; + let observed = Self::cached_query(&observed, task_id, self.namespace.as_ref())?; + if let Some(receipt) = + authority::receipt(&observed, owner, attempt, &fingerprint, authority::now())? + { + return Ok(Some(receipt)); + } + // A decision may commit between this read and writer admission. The + // transaction repeats receipt lookup before any fresh-authority fence. let mut tx = self.begin().await?; let row = Self::query(DB::QUERY_CACHE_GET) .bind(&key) @@ -649,7 +665,6 @@ where .await? .ok_or_else(|| refuse(StatusCode::NOT_FOUND, "query_task_not_found"))?; let mut task = Self::cached_query(&row, task_id, self.namespace.as_ref())?; - let fingerprint = authority::operation(body, failed)?; if let Some(receipt) = authority::receipt(&task, owner, attempt, &fingerprint, authority::now())? { diff --git a/rust/src/runtime/query_codec_tests.rs b/rust/src/runtime/query_codec_tests.rs index bddc5694b..d7ba80cd8 100644 --- a/rust/src/runtime/query_codec_tests.rs +++ b/rust/src/runtime/query_codec_tests.rs @@ -11,8 +11,16 @@ fn envelope(value: Payload) -> Value { json!({"codec":"avro","blob":ValueCodec::new().unwrap().encode(&value).unwrap()}) } -#[tokio::test] -async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holding_writer() { +struct Original { + _directory: tempfile::TempDir, + runtime: Runtime, + started: Value, + history_before: Vec<(i64, String, String)>, + waiting: JoinHandle>, + task: Value, +} + +async fn original(timeout_seconds: i64) -> Original { let directory = tempfile::tempdir().unwrap(); let runtime = Runtime::open( directory.path().join("runtime.sqlite").to_str().unwrap(), @@ -21,8 +29,8 @@ async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holdi .await .unwrap() .with_query_task_config(QueryTaskConfig { - timeout_seconds: 2, - lease_timeout_seconds: 2, + timeout_seconds, + lease_timeout_seconds: timeout_seconds, ..Default::default() }); runtime.register(json!({"worker_id":"codec-worker","task_queue":"test","runtime":"php", @@ -50,7 +58,7 @@ async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holdi let pool = runtime.sqlite_pool().clone(); let history_before: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); - let mut waiting = { + let waiting = { let runtime = runtime.clone(); tokio::spawn(async move { runtime @@ -79,6 +87,27 @@ async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holdi }) .await .unwrap(); + Original { + _directory: directory, + runtime, + started, + history_before, + waiting, + task, + } +} + +#[tokio::test] +async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holding_writer() { + let Original { + _directory, + runtime, + started, + history_before, + mut waiting, + task, + } = original(2).await; + let pool = runtime.sqlite_pool().clone(); let held = runtime.signal_codec.clone().acquire_owned().await.unwrap(); let mut complete = { let runtime = runtime.clone(); @@ -170,3 +199,94 @@ async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holdi assert_eq!(history_before, history_after); runtime.close().await; } + +#[tokio::test] +async fn committed_query_receipt_ignores_writer_and_codec_pressure_before_and_after_expiry() { + let Original { + _directory, + runtime, + started, + history_before, + waiting, + task, + } = original(3).await; + let pool = runtime.sqlite_pool().clone(); + let id = task["query_task_id"].as_str().unwrap(); + let key = format!("dw-native-query:{id}"); + let body = json!({"lease_owner":"codec-worker","query_task_attempt":task["query_task_attempt"], + "result_envelope":envelope(Payload::Long(9007199254740993))}); + let first = runtime.complete_query(id, body.clone()).await.unwrap(); + assert_eq!(first["recorded"], true); + assert_eq!(waiting.await.unwrap().unwrap().0, StatusCode::OK); + let before: (String, i64) = + sqlx::query_as("SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + let held = runtime.signal_codec.clone().acquire_owned().await.unwrap(); + let mut writer = pool.acquire().await.unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *writer) + .await + .unwrap(); + for after_expiry in [false, true] { + if after_expiry { + let deadline = + chrono::DateTime::parse_from_rfc3339(task["query_deadline_at"].as_str().unwrap()) + .unwrap() + .with_timezone(&chrono::Utc); + tokio::time::sleep( + (deadline - chrono::Utc::now()).to_std().unwrap_or_default() + + Duration::from_millis(10), + ) + .await; + } + let duplicate = tokio::time::timeout( + Duration::from_millis(250), + runtime.complete_query(id, body.clone()), + ) + .await + .expect("a retained decision must not acquire the writer or codec slot") + .unwrap(); + let mut expected = first.clone(); + expected["recorded"] = json!(false); + assert_eq!(duplicate, expected); + } + let mut different = body; + different["result_envelope"] = json!({"codec":"avro","blob":"different-invalid-bytes"}); + let conflict = tokio::time::timeout( + Duration::from_millis(250), + runtime.complete_query(id, different), + ) + .await + .expect("conflicting retained decisions also refuse before writer/codec work") + .unwrap_err(); + assert!( + matches!( + conflict, + RuntimeError::Refused { + status: StatusCode::CONFLICT, + reason: "query_completion_conflict" + } + ), + "{conflict:?}" + ); + let after: (String, i64) = + sqlx::query_as("SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1") + .bind(&key) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + before, after, + "a retry must not write or extend the receipt" + ); + let history_after: Vec<(i64,String,String)> = sqlx::query_as("SELECT sequence,event_type,payload FROM workflow_history_events WHERE workflow_run_id=$1 ORDER BY sequence") + .bind(started["run_id"].as_str().unwrap()).fetch_all(&pool).await.unwrap(); + assert_eq!(history_before, history_after); + sqlx::query("ROLLBACK").execute(&mut *writer).await.unwrap(); + drop(writer); + drop(held); + runtime.close().await; +} From 52636931d6460f3c309d4498133656c36b6858c4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 03:52:58 +0000 Subject: [PATCH 04/36] test(rust): exercise query receipts under database writer locks --- docs/rust-server-port.md | 25 ++++-- rust/tests/execution/query_authority.rs | 114 +++++++++++++++++++++++- 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index be62d8bb4..e96b2f959 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -35,8 +35,8 @@ The current integration order is: policy, complete continuation transfer and full external transport remain open. - [#455](https://github.com/durable-workflow/server/pull/455): opt-in, namespace/queue-scoped null-task poll wake. Component `1fa1cc2e6` passes all - 13 jobs and all six databases; promotion is through the combined #458 tree, - whose own matrix remains pending. An earlier component's SQLite PHP target failed after an activity + 13 jobs and all six databases; promotion is through the separately qualified + combined #458 tree. An earlier component's SQLite PHP target failed after an activity completion response when the Rust actor's registration DELETE returned lock-pressure 503; later refusal/repair phases were not reached. That original stays failed. Idle/query cost and matched performance remain open. @@ -49,12 +49,16 @@ The current integration order is: - [#458](https://github.com/durable-workflow/server/pull/458): completion-bound local activity-result uploads, including exact PHP Unicode context hashing, original live authority and durable allowances. Combined head `93368d2ae` - includes #455 and the current PHP source; its exact matrix remains pending. - Earlier component passes do not establish acceptance for this whole tree. + includes #455 and passes its own 13-job, six-database matrix. Promotion awaits + the coordinated PHP dependency window and a fresh combined-tree qualification. + Earlier component passes are not carried across that integration. - [#460](https://github.com/durable-workflow/server/pull/460): read-only PHP catalog/data inventory. Head `4459140cd` adds actual FIFO/directory refusal and a separately budgeted fresh-upload capacity control. Its matrix is - pending; the earlier SQLite 100 ms upload failure remains failed. Inspection + incomplete. The SQLite job failed in frozen PHP restart preparation before + creating the recovered native target. Its later empty response therefore + reflects an absent prerequisite, not a demonstrated native crash. The earlier + SQLite 100 ms upload failure also remains failed. Inspection does not establish backup consistency, PHP quiescence, stored-representation compatibility or takeover authority. All those qualification flags stay false. - Native [query authority](https://github.com/durable-workflow/server/issues/433) @@ -64,9 +68,14 @@ The current integration order is: and PostgreSQL 17. The corrected waiter preserves the original deadline under a held writer and refuses a result delayed by codec capacity. This is not a clean full-suite or matched PHP/SDK qualification: the earlier complete - SQLite run still has its failed fresh-upload control. Receipt reconciliation - under writer pressure, continuation expiry, remaining pressure boundaries, - combined ancestry and installed consumers remain next. Discovery keeps + SQLite run still has its failed fresh-upload control. Retained receipts + now reconcile before writer admission and codec work. Actual held-writer + HTTP controls pass on SQLite, MySQL 8.4 and PostgreSQL 17 before and after + expiry, preserving conflicting-payload refusal and the original receipt's + bytes and expiry. The separate SQLite codec-and-writer counterfactual fails + the prior implementation and passes the correction. Continuation expiry, + remaining pressure boundaries, combined ancestry and a separate published + PHP/SDK reference profile remain next. Discovery keeps `query_task_authority=false` until those gates pass. Full transport discovery stays unadvertised. Complete batch workflow semantics, diff --git a/rust/tests/execution/query_authority.rs b/rust/tests/execution/query_authority.rs index 211c53f43..1561f4a21 100644 --- a/rust/tests/execution/query_authority.rs +++ b/rust/tests/execution/query_authority.rs @@ -194,6 +194,77 @@ async fn observation(app: &Router, id: &str, owner: &str, attempt: i64) -> (Stat (response.0, response.2) } +enum HeldWriter { + Sqlite(sqlx::SqliteConnection), + Postgres(sqlx::PgConnection), + MySql(sqlx::MySqlConnection), +} + +impl HeldWriter { + async fn acquire(database: &TestDatabase) -> Self { + use sqlx::Connection; + match database { + TestDatabase::Sqlite(directory) => { + let mut connection = sqlx::SqliteConnection::connect(&format!( + "sqlite://{}", + directory.path().join("runtime.sqlite").display() + )) + .await + .unwrap(); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut connection) + .await + .unwrap(); + Self::Sqlite(connection) + } + TestDatabase::Postgres { options, .. } => { + let mut connection = sqlx::PgConnection::connect_with(options).await.unwrap(); + sqlx::query("BEGIN").execute(&mut connection).await.unwrap(); + sqlx::query("SELECT pg_advisory_xact_lock(4924501473315676163)") + .execute(&mut connection) + .await + .unwrap(); + Self::Postgres(connection) + } + TestDatabase::MySql { options, .. } => { + let mut connection = sqlx::MySqlConnection::connect_with(options).await.unwrap(); + sqlx::raw_sql("START TRANSACTION") + .execute(&mut connection) + .await + .unwrap(); + sqlx::query("SELECT engine FROM dw_server_schema FOR UPDATE") + .execute(&mut connection) + .await + .unwrap(); + Self::MySql(connection) + } + } + } + + async fn release(self) { + match self { + Self::Sqlite(mut connection) => { + sqlx::query("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + Self::Postgres(mut connection) => { + sqlx::query("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + Self::MySql(mut connection) => { + sqlx::raw_sql("ROLLBACK") + .execute(&mut connection) + .await + .unwrap(); + } + } + } +} + #[tokio::test] async fn concurrent_identical_failure_has_one_decision_and_conflicts_stay_refused() { let original = original("query-authority-failed-receipt").await; @@ -613,22 +684,57 @@ async fn identical_committed_result_reconciles_before_and_after_original_expiry( assert_eq!(first.0, StatusCode::OK, "{}", first.1); assert_eq!(first.1["recorded"], true); assert_eq!(original.waiting.await.unwrap().0, StatusCode::OK); - let before = request(&original.app, "POST", &path, body.clone()).await; + let cache_before = cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap(), + ) + .await; + let writer = HeldWriter::acquire(&original.database).await; + let before = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, body.clone()), + ) + .await + .expect("retained query receipt must reconcile without writer admission"); assert_eq!(before.0, StatusCode::OK, "{}", before.1); assert_eq!(before.1["recorded"], false); let mut conflicting = body.clone(); conflicting["result_envelope"] = json!({"codec":"avro","blob":"not-the-committed-bytes"}); - let refused = request(&original.app, "POST", &path, conflicting.clone()).await; + let refused = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, conflicting.clone()), + ) + .await + .expect("conflicting receipt must refuse without writer admission"); assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); assert_eq!(refused.1["reason"], "query_completion_conflict"); let remaining = instant(&original.claim["query_deadline_at"]) - Utc::now(); tokio::time::sleep(remaining.to_std().unwrap_or_default() + Duration::from_millis(20)).await; - let after = request(&original.app, "POST", &path, body).await; + let after = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, body), + ) + .await + .expect("original expiry must not add writer admission to a retained receipt"); assert_eq!(after.0, StatusCode::OK, "{}", after.1); assert_eq!(after.1["recorded"], false); - let refused = request(&original.app, "POST", &path, conflicting).await; + let refused = tokio::time::timeout( + Duration::from_millis(500), + request(&original.app, "POST", &path, conflicting), + ) + .await + .expect("expired conflicting receipt must also refuse without writer admission"); assert_eq!(refused.0, StatusCode::CONFLICT, "{}", refused.1); assert_eq!(refused.1["reason"], "query_completion_conflict"); + assert_eq!( + cache_snapshot( + &original.database, + original.claim["query_task_id"].as_str().unwrap() + ) + .await, + cache_before + ); + writer.release().await; assert_eq!( request(&original.app, "GET", &original.history_path, Value::Null) .await From 084659561cc7001a13a9f8551eaae6a63354df87 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:06:50 +0000 Subject: [PATCH 05/36] test(conformance): add separate published query authority controls --- .../server-parity/query-authority-profile.sh | 44 + .../query-authority/composer.json | 14 + .../query-authority/composer.lock | 958 ++++++++++++++++++ .../server-parity/query-authority/worker.php | 266 +++++ .../query-authority/README.md | 41 + .../query-authority/artifacts.json | 30 + 6 files changed, 1353 insertions(+) create mode 100755 scripts/conformance/server-parity/query-authority-profile.sh create mode 100644 scripts/conformance/server-parity/query-authority/composer.json create mode 100644 scripts/conformance/server-parity/query-authority/composer.lock create mode 100644 scripts/conformance/server-parity/query-authority/worker.php create mode 100644 tests/Fixtures/ServerParityProfiles/query-authority/README.md create mode 100644 tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json diff --git a/scripts/conformance/server-parity/query-authority-profile.sh b/scripts/conformance/server-parity/query-authority-profile.sh new file mode 100755 index 000000000..5d65ad54d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-profile.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# The caller owns the isolated target, its original configuration and cleanup. +set -euo pipefail +test "$#" = 6 || { echo 'Expected NETWORK SERVER_CONTAINER URL SDK_DIRECTORY OUTPUT_DIRECTORY TARGET' >&2; exit 2; } +network=$1 +server=$2 +url=$3 +sdk=$(realpath "$4") +mkdir -p "$5" +output=$(realpath "$5") +target=$6 +case "$target" in php|rust) ;; *) exit 2 ;; esac +source_root=$(git rev-parse --show-toplevel) +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +test -n "${RESOURCE_SCOPE:?Set the unique owning resource scope}" +test -f "$sdk/vendor/autoload.php" +cmp scripts/conformance/server-parity/query-authority/composer.lock "$sdk/composer.lock" +docker inspect "$server" | jq -e --arg network "$network" \ + '.[0].State.Running == true and (.[0].NetworkSettings.Networks | has($network))' >/dev/null +if test "$target" = php; then + docker inspect "$server" | jq -e --arg revision "$(jq -r '.server.source' "$profile")" \ + '.[0].Config.Labels["org.opencontainers.image.revision"] == $revision' >/dev/null +fi +jq -n --arg revision "$(git rev-parse HEAD)" --arg native_tree "$(git rev-parse HEAD:rust)" \ + --arg server_image "$(docker inspect "$server" --format '{{.Image}}')" \ + --arg target "$target" --arg profile_sha256 "$(sha256sum "$profile" | cut -d ' ' -f1)" \ + '{runner_revision:$revision,native_tree:$native_tree,target:$target,server_image:$server_image, + profile_sha256:$profile_sha256,performance:"not_measured"}' > "$output/identity.json" +while IFS=$'\t' read -r mode protocol; do + name="$mode-${protocol//./}" + test ! -e "$output/$name.json" + timeout --signal=TERM --kill-after=5s 40s \ + docker run --rm --name "$RESOURCE_SCOPE-query-$target-$name" --user="$(id -u):$(id -g)" \ + --cpus=1 --memory=512m --memory-swap=512m --network "$network" --entrypoint php \ + -e DW_PARITY_SDK_AUTOLOAD=/sdk/vendor/autoload.php -e DW_PARITY_TOKEN \ + -v "$source_root:/source:ro" -v "$sdk:/sdk:ro" -w /source "$image" \ + scripts/conformance/server-parity/query-authority/worker.php "$url" "$mode" "$protocol" \ + > "$output/$name.json" 2> "$output/$name.stderr" + jq -e --arg mode "$mode" --arg protocol "$protocol" \ + '.outcome=="pass" and .mode==$mode and .protocol==$protocol and .worker_exit==0 + and .original_budget_seconds==30 and .elapsed_seconds<30' "$output/$name.json" >/dev/null + jq '{outcome,mode,protocol,run_id,elapsed_seconds,worker_exit}' "$output/$name.json" +done < <(jq -r '.cases[] | [.mode,.protocol] | @tsv' "$profile") diff --git a/scripts/conformance/server-parity/query-authority/composer.json b/scripts/conformance/server-parity/query-authority/composer.json new file mode 100644 index 000000000..48755e52d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/composer.json @@ -0,0 +1,14 @@ +{ + "name": "durable-workflow/server-query-authority-consumer", + "description": "Separate published query-authority consumer for the Server port", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.3", + "durable-workflow/sdk": "2.4.2" + }, + "config": { + "allow-plugins": false, + "sort-packages": true + } +} diff --git a/scripts/conformance/server-parity/query-authority/composer.lock b/scripts/conformance/server-parity/query-authority/composer.lock new file mode 100644 index 000000000..f9a11cd56 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/composer.lock @@ -0,0 +1,958 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "7c049633e8c1d4d7bf1d90a6605f8a33", + "packages": [ + { + "name": "apache/avro", + "version": "1.12.2", + "source": { + "type": "git", + "url": "https://github.com/apache/avro.git", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apache/avro/zipball/8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "reference": "8fa2067f70e3012cb3fd9a8839cd97e8c7cc1772", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-json": "*", + "ext-pcntl": "*", + "ext-xml": "*", + "php-mock/php-mock-phpunit": "^2.10", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Apache\\Avro\\": "lang/php/lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Apache Avro Developers", + "email": "dev@avro.apache.org", + "homepage": "http://avro.apache.org" + } + ], + "description": "Apache Avro™ is a data serialization system.", + "homepage": "http://avro.apache.org", + "keywords": [ + "avro", + "data", + "serialization" + ], + "support": { + "email": "dev@avro.apache.org", + "issues": "https://issues.apache.org/jira/browse/AVRO", + "source": "https://github.com/apache/avro/tree/release-1.12.2" + }, + "time": "2026-08-07T12:56:37+00:00" + }, + { + "name": "durable-workflow/sdk", + "version": "2.4.2", + "source": { + "type": "git", + "url": "https://github.com/durable-workflow/sdk-php.git", + "reference": "25d796ad2a55d0d114b58017c3ba23390b09e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/durable-workflow/sdk-php/zipball/25d796ad2a55d0d114b58017c3ba23390b09e120", + "reference": "25d796ad2a55d0d114b58017c3ba23390b09e120", + "shasum": "" + }, + "require": { + "apache/avro": "^1.12", + "composer-runtime-api": "^2.0", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8|^8.0", + "php": "^8.1", + "psr/container": "^1.1|^2.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^1.1|^2.0|^3.0" + }, + "require-dev": { + "phpdocumentor/phpdocumentor": "^3.7", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5|^11.0" + }, + "suggest": { + "ext-pcntl": "Required by framework console workers for graceful SIGINT and SIGTERM handling.", + "laravel/framework": "Enables the auto-discovered Laravel 9 through 13 service-mode bridge.", + "symfony/console": "Required with symfony/framework-bundle to run the Symfony 6.4, 7, and 8 worker command.", + "symfony/framework-bundle": "Enables the Symfony 6.4, 7, and 8 service-mode bundle." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "DurableWorkflow": "DurableWorkflow\\Bridge\\Laravel\\Facades\\DurableWorkflow" + }, + "providers": [ + "DurableWorkflow\\Bridge\\Laravel\\DurableWorkflowServiceProvider" + ] + }, + "durable-workflow": { + "frameworks": { + "laravel": "^9.0|^10.0|^11.0|^12.0|^13.0", + "symfony": "^6.4|^7.0|^8.0" + }, + "product-train": "2.4.2", + "payload-codecs": [ + "avro" + ], + "message-streams": true, + "version-markers": true, + "workflow-streams": { + "contract": "durable-workflow.v2.workflow-streams.contract@1", + "delivery": "at-least-once", + "operations": [ + "list", + "describe", + "subscribe", + "append", + "close", + "error" + ], + "authoring-command": "record_side_effect.workflow_stream", + "external-payload-references": "opaque-reference" + }, + "durable-selection": true, + "control-plane-version": "2", + "version-marker-command": "record_version_marker", + "version-marker-helpers": [ + "patched", + "deprecatePatch" + ], + "worker-protocol-version": "1.20", + "supported-server-versions": "2.5.0", + "version-marker-history-event": "VersionMarkerRecorded", + "message-streams-minimum-worker-protocol-version": "1.15", + "durable-selection-minimum-worker-protocol-version": "1.19" + } + }, + "autoload": { + "psr-4": { + "DurableWorkflow\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Durable Workflow Contributors" + } + ], + "description": "PHP client and worker SDK for Durable Workflow Cloud and self-hosted Server", + "homepage": "https://php.durable-workflow.com/", + "keywords": [ + "cloud", + "durable", + "laravel", + "orchestration", + "php", + "sdk", + "symfony", + "worker", + "workflow" + ], + "support": { + "docs": "https://php.durable-workflow.com/", + "issues": "https://github.com/durable-workflow/sdk-php/issues", + "source": "https://github.com/durable-workflow/sdk-php" + }, + "time": "2026-10-15T03:25:43+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "8.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/93939470950a9b11e2e84204166ef5e048c55fe4", + "reference": "93939470950a9b11e2e84204166ef5e048c55fe4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^3.0.2", + "guzzlehttp/psr7": "^3.1", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "4.0.1", + "guzzlehttp/test-server": "^1.0.1", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/8.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-09-06T13:55:09+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/42118e66a53c492effaf92bc357e931985d5c6f9", + "reference": "42118e66a53c492effaf92bc357e931985d5c6f9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:00:26+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "reference": "a3059ba1a84c9139c4ae03cf0f45bea276c97c74", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0", + "symfony/polyfill-php80": "^1.25", + "symfony/polyfill-php82": "^1.27" + }, + "provide": { + "psr/http-factory-implementation": "1.1", + "psr/http-message-implementation": "2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "php-http/psr7-integration-tests": "^1.5.1", + "phpunit/phpunit": "^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T11:02:13+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php new file mode 100644 index 000000000..f679c8b49 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -0,0 +1,266 @@ +inner = new Psr18Transport(); + } + + public function supportsBoundedRequests(): bool { return $this->inner->supportsBoundedRequests(); } + public function send(string $method, string $uri, array $headers, ?array $body = null): ?array + { + $this->beforeSend($method, $uri, $body); + return $this->observe($uri, $this->inner->send($method, $uri, $headers, $body)); + } + public function sendBounded(string $method, string $uri, array $headers, ?array $body, int $timeoutSeconds): ?array + { + $this->beforeSend($method, $uri, $body); + return $this->observe($uri, $this->inner->sendBounded($method, $uri, $headers, $body, $timeoutSeconds)); + } + public function uploadPayload(string $uri, array $headers, string $blob, int $timeoutSeconds): array + { return $this->inner->uploadPayload($uri, $headers, $blob, $timeoutSeconds); } + public function uploadPayloadBounded(string $uri, array $headers, string $blob, int $timeoutSeconds): array + { return $this->inner->uploadPayloadBounded($uri, $headers, $blob, $timeoutSeconds); } + public function fetchPayload(string $uri, array $headers, int $maxBytes): string + { return $this->inner->fetchPayload($uri, $headers, $maxBytes); } + public function fetchPayloadBounded(string $uri, array $headers, int $maxBytes, int $timeoutSeconds): string + { return $this->inner->fetchPayloadBounded($uri, $headers, $maxBytes, $timeoutSeconds); } + public function fetchPayloadWithProgress(string $uri, array $headers, int $maxBytes, Closure $progress): string + { return $this->inner->fetchPayloadWithProgress($uri, $headers, $maxBytes, $progress); } + public function fetchPayloadWithBoundedProgress(string $uri, array $headers, int $maxBytes, Closure $progress, ?int $timeoutSeconds = null): string + { return $this->inner->fetchPayloadWithBoundedProgress($uri, $headers, $maxBytes, $progress, $timeoutSeconds); } + + private function beforeSend(string $method, string $uri, ?array $body): void + { + $path = parse_url($uri, PHP_URL_PATH); + if ($method === 'POST' && preg_match('~/query-tasks/[^/]+/(complete|fail)$~', $path)) { + // A rejected late POST is still a submission. Observe before I/O, + // rather than counting successful acknowledgements alone. + ($this->record)('submission', ['path' => $path, + 'lease_owner' => $body['lease_owner'] ?? null, + 'query_task_attempt' => $body['query_task_attempt'] ?? null]); + } + } + + private function observe(string $uri, ?array $reply): ?array + { + $path = parse_url($uri, PHP_URL_PATH); + if ($path === '/api/worker/query-tasks/poll' && is_array($reply['task'] ?? null)) { + ($this->record)('claim', $reply['task']); + if ($this->mode === 'late-poll') { + sleep(6); + } + } elseif (str_contains($path, '/query-tasks/') && str_ends_with($path, '/status')) { + ($this->record)('status', $reply ?? []); + } elseif (preg_match('~/query-tasks/[^/]+/(complete|fail)$~', $path)) { + ($this->record)('acknowledgement', $reply ?? []); + } + + return $reply; + } +} + +[$script, $url, $mode, $protocol] = $argv; +queryCheck(in_array([$mode, $protocol], [ + ['timely', '1.20'], ['timely', '1.19'], ['late-poll', '1.20'], ['late-handler', '1.20'], +], true), 'Unknown query authority case.'); +$artifacts = json_decode(file_get_contents(__DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), true, flags: JSON_THROW_ON_ERROR); +queryCheck(InstalledVersions::getPrettyVersion('durable-workflow/sdk') === $artifacts['sdk_php']['version'] + && InstalledVersions::getReference('durable-workflow/sdk') === $artifacts['sdk_php']['source'], 'The installed SDK is not the separate qualified published tuple.'); +foreach ($artifacts['sdk_php']['loaded_sha256'] as $class => $sha256) { + queryCheck(hash_file('sha256', (new ReflectionClass($class))->getFileName()) === $sha256, + 'Loaded published SDK bytes differ: '.$class); +} +queryCheck(function_exists('pcntl_fork') && function_exists('posix_kill'), 'This real Worker fixture requires Unix CLI.'); +$started = hrtime(true) / 1e9; +$until = $started + $artifacts['original_observation_seconds']; +$namespace = 'parity-query-authority-'.bin2hex(random_bytes(6)); +$token = getenv('DW_PARITY_TOKEN') ?: 'query-authority-fixture-token'; +$admin = new Client($url, token: $token); +$admin->createNamespace($namespace); +$client = new Client($url, namespace: $namespace, token: $token); +$journal = tempnam(sys_get_temp_dir(), 'query-authority-'); +$record = static function (string $event, array $details = []) use ($journal): void { + file_put_contents($journal, json_encode(['event' => $event, 'observed_at' => microtime(true), + 'details' => $details], JSON_THROW_ON_ERROR)."\n", FILE_APPEND | LOCK_EX); +}; +$read = static fn (): array => array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + file($journal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); +$pid = 0; +$handle = null; +$result = null; +try { + $pid = pcntl_fork(); + queryCheck($pid >= 0, 'Could not fork the real Worker.'); + if ($pid === 0) { + try { + $worker = new Worker(new Client($url, namespace: $namespace, token: $token, + transport: new QueryAuthorityTransport($mode, $record), workerProtocolVersion: $protocol), $namespace, + clock: static function () use ($until): float { + queryCheck(hrtime(true) / 1e9 < $until, 'Original Worker observation deadline expired.'); + return microtime(true); + }, diagnosticListener: static function (string $event, array $details) use ($record): void { + if ($event === 'worker.claim_aborted') { + $record('aborted', $details); + } + }, requireQueryTaskAuthority: $protocol === '1.20'); + $worker->registerWorkflow('parity.query-authority.v1', static function (WorkflowContext $context): int { + $context->sleep(60); + return 7; + }); + $worker->registerQuery('parity.query-authority.v1', 'value', static function (QueryContext $context) use ($mode, $record): array { + $record('handler'); + if ($mode === 'late-handler') { + sleep(6); + } + $record('returned'); + return ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; + }); + pcntl_async_signals(true); + pcntl_signal(SIGTERM, static fn () => $worker->requestShutdown()); + $worker->run(0); + $record('stopped'); + exit(0); + } catch (Throwable $error) { + $record('actor-error', ['type' => $error::class, 'message' => $error->getMessage()]); + exit(1); + } + } + do { + $events = $read(); + queryCheck(!in_array('actor-error', array_column($events, 'event'), true), 'The Worker refused startup: '.json_encode($events)); + if ($client->listWorkers($namespace) !== []) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck(hrtime(true) / 1e9 < $until, 'No Worker registered within the original observation.'); + $handle = $client->startWorkflow('parity.query-authority.v1', $namespace, $namespace); + do { + $status = strtolower((string) $handle->describe()->status); + if ($status === 'waiting') { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck($status === 'waiting', 'The original did not reach its committed timer wait.'); + $before = $client->workflowHistory($namespace, $handle->selectedRunId); + $expected = ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; + if ($mode === 'timely') { + queryCheck($handle->query('value') === $expected, 'The actual typed query result changed.'); + } else { + try { + $handle->query('value'); + throw new RuntimeException('An expired original query succeeded.'); + } catch (QueryFailed $error) { + queryCheck($error->status === 504 && ($error->details['reason'] ?? null) === 'query_worker_execution_timeout', + 'The original caller did not retain its published timeout diagnostic.'); + } + do { + if (in_array('aborted', array_column($read(), 'event'), true)) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + queryCheck(in_array('aborted', array_column($read(), 'event'), true), 'The Worker did not refuse its expired original.'); + } + $events = $read(); + $named = static fn (string $name): array => array_values(array_filter($events, static fn (array $event): bool => $event['event'] === $name)); + queryCheck(count($named('claim')) === 1, 'Expected one actual original query claim.'); + $claim = $named('claim')[0]['details']; + queryCheck(($claim['workflow_id'] ?? null) === $namespace && ($claim['run_id'] ?? null) === $handle->selectedRunId + && ($claim['task_queue'] ?? null) === $namespace && ($claim['namespace'] ?? $client->namespace) === $namespace + && ($claim['query_task_attempt'] ?? null) === 1, 'The delivery changed its original run, namespace or attempt.'); + foreach (['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time'] as $field) { + queryCheck(is_string($claim[$field] ?? null) && preg_match('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/D', $claim[$field]) === 1, + 'Query timing is not canonical UTC6: '.$field); + } + $admitted = new DateTimeImmutable($claim['query_admitted_at']); + $deadline = new DateTimeImmutable($claim['query_deadline_at']); + queryCheck((float) $deadline->format('U.u') - (float) $admitted->format('U.u') === 4.0, + 'The original admission-to-result deadline is not four seconds.'); + queryCheck(new DateTimeImmutable($claim['lease_expires_at']) <= $deadline, 'The lease extends beyond original result authority.'); + foreach ($named('status') as $observation) { + foreach (['query_task_id', 'query_task_attempt', 'lease_owner', 'query_admitted_at', 'query_deadline_at', 'lease_expires_at'] as $field) { + queryCheck(($observation['details'][$field] ?? null) === $claim[$field], 'Status changed original query authority: '.$field); + } + } + queryCheck(($named('status') !== []) === ($protocol === '1.20'), 'Protocol status use does not match the declared boundary.'); + queryCheck(count($named('handler')) === ($mode === 'late-poll' ? 0 : 1), 'The callback dispatched outside its original authority.'); + queryCheck(count($named('submission')) === ($mode === 'timely' ? 1 : 0), 'An expired callback attempted a terminal submission.'); + queryCheck(count($named('acknowledgement')) === ($mode === 'timely' ? 1 : 0), 'An expired callback submitted a terminal outcome.'); + queryCheck($before === $client->workflowHistory($namespace, $handle->selectedRunId), 'Query execution changed the original workflow history.'); + queryCheck($named('actor-error') === [] && hrtime(true) / 1e9 < $until, 'The original Worker observation failed.'); + $result = ['outcome' => 'pass', 'mode' => $mode, 'protocol' => $protocol, 'workflow_id' => $namespace, + 'run_id' => $handle->selectedRunId, 'claim' => $claim, 'observations' => $events, + 'original_budget_seconds' => 30, 'sdk_version' => InstalledVersions::getPrettyVersion('durable-workflow/sdk'), + 'sdk_source' => InstalledVersions::getReference('durable-workflow/sdk'), + 'runner_sha256' => hash_file('sha256', __FILE__), + 'artifacts_sha256' => hash_file('sha256', __DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), + 'sdk_lock_sha256' => hash_file('sha256', dirname(getenv('DW_PARITY_SDK_AUTOLOAD'), 2).'/composer.lock'), + 'worker_sha256' => hash_file('sha256', (new ReflectionClass(Worker::class))->getFileName()), + 'history_sha256' => hash('sha256', json_encode($before, JSON_THROW_ON_ERROR))]; +} catch (Throwable $error) { + fwrite(STDERR, json_encode(['outcome' => 'fail', 'mode' => $mode, 'protocol' => $protocol, + 'workflow_id' => $namespace, 'run_id' => $handle?->selectedRunId, + 'error' => $error->getMessage(), 'observations' => $read()], JSON_THROW_ON_ERROR)."\n"); + throw $error; +} finally { + $drained = false; + if ($pid > 0) { + posix_kill($pid, SIGTERM); + do { + $ended = pcntl_waitpid($pid, $status, WNOHANG); + if ($ended === $pid) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); + if ($ended !== $pid) { + posix_kill($pid, SIGKILL); + pcntl_waitpid($pid, $status); + } + $drained = pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0; + } + try { + if ($handle !== null) { + $client->terminateWorkflow($namespace, 'query authority qualification complete', $handle->selectedRunId); + } + $admin->deleteNamespace($namespace); + } finally { + unlink($journal); + } + queryCheck($pid > 0 && $drained, 'The real Worker did not exit zero and deregister within the original observation.'); +} +queryCheck(hrtime(true) / 1e9 < $until, 'Cleanup exceeded the original observation budget.'); +echo json_encode($result + ['elapsed_seconds' => hrtime(true) / 1e9 - $started, 'worker_exit' => 0], + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n"; diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md new file mode 100644 index 000000000..bd674c7f9 --- /dev/null +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -0,0 +1,41 @@ +# Current query authority profile + +This separate profile uses normally installed PHP SDK 2.4.2 and published Server +2.8.0. It does not replace the frozen query actors. It is prepared and has no +passing HTTP qualification yet. Native discovery remains false until the new +authority and consumer gates qualify. + +The owning contract is [Server #433](https://github.com/durable-workflow/server/issues/433). +`artifacts.json` freezes the consumer, reference image and original budgets. +Install the adjacent `scripts/conformance/server-parity/query-authority` +Composer project normally. Run its `worker.php URL MODE PROTOCOL` in a Unix PHP +container with `DW_PARITY_SDK_AUTOLOAD` pointing at that installation and +`DW_PARITY_TOKEN` set to the isolated Server token. +The host wrapper `scripts/conformance/server-parity/query-authority-profile.sh` +takes `NETWORK SERVER_CONTAINER URL SDK_DIRECTORY OUTPUT_DIRECTORY TARGET`, +where `TARGET` is `php` or `rust`. Set a unique `RESOURCE_SCOPE` and the isolated +token before invoking it. It refuses an existing case output instead of +overwriting a failed original. + +Both services must use separate disposable databases, shared process-visible +polling caches and `DW_QUERY_TASK_TIMEOUT=4`. The caller owns bootstrap, +readiness and cleanup. Run each declared case once against each target. Every +original has one unchanged 30-second observation budget covering setup, query, +Worker drain and deregistration. The caller must also bound container lifetime +and remove its resources after success or failure. + +The actual Worker creates a waiting timer workflow, then serves its query. +Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. +Separate late controls hold a genuine poll reply for six seconds or let a +synchronous callback return after six seconds. They require the original caller +timeout, no late completion submission and no callback dispatch after an expired +poll reply. Query status preserves the original claim and timing without +renewal. Protocol 1.19 makes no status request. Every query preserves the exact +workflow history and the Worker must exit zero. +The raw delivery is retained. PHP may omit its namespace member, as supported +by the published SDK, in which case the authenticated Client namespace applies. +An explicit conflicting namespace still fails. + +External query loading, Python/Rust consumers, remaining route/pressure and +continuation boundaries, all-six integration and capability qualification remain +ahead. These are correctness controls and establish no performance result. diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json new file mode 100644 index 000000000..db33330e6 --- /dev/null +++ b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json @@ -0,0 +1,30 @@ +{ + "schema": "durable-workflow.server-query-authority/v1", + "qualification": "prepared-not-qualified", + "server": { + "version": "2.8.0", + "source": "dd720e6b51844707be2885f8873c23de98c847a7", + "image": "durableworkflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac" + }, + "sdk_php": { + "version": "2.4.2", + "source": "25d796ad2a55d0d114b58017c3ba23390b09e120", + "loaded_sha256": { + "DurableWorkflow\\Worker": "eb59fde750a37c755b551a63f93c3882039310673fe9538eec63b5f68bfd0891", + "DurableWorkflow\\Client": "5b53ceaa2aca9a4f33cd680f1786f642436fe06874a76efcc8987669b33fcc4c", + "DurableWorkflow\\Worker\\QueryTaskAuthority": "264df07849d2ba8343238b12ad9eef89920d370d61b6a4bd26a3b4895bc858b5", + "DurableWorkflow\\Transport\\Psr18Transport": "0857170cf128876338040f197ba469c5c6431cbb020da0453705c3798c60883a" + } + }, + "query_timeout_seconds": 4, + "original_observation_seconds": 30, + "delayed_response_seconds": 6, + "cases": [ + {"mode": "timely", "protocol": "1.20"}, + {"mode": "timely", "protocol": "1.19"}, + {"mode": "late-poll", "protocol": "1.20"}, + {"mode": "late-handler", "protocol": "1.20"} + ], + "embedded": "inapplicable: service query claims and status have no embedded worker lease", + "performance": "not_measured" +} From 465daf7612fdc95f4493a29e9ec364cc4d806d08 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:08:48 +0000 Subject: [PATCH 06/36] docs(rust): record current published query reference acceptance --- docs/rust-server-port.md | 7 +++++-- .../ServerParityProfiles/query-authority/README.md | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index e96b2f959..44cf91261 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -74,8 +74,11 @@ The current integration order is: expiry, preserving conflicting-payload refusal and the original receipt's bytes and expiry. The separate SQLite codec-and-writer counterfactual fails the prior implementation and passes the correction. Continuation expiry, - remaining pressure boundaries, combined ancestry and a separate published - PHP/SDK reference profile remain next. Discovery keeps + remaining pressure boundaries and combined ancestry remain next. A separate + published Server 2.8.0 / PHP SDK 2.4.2 profile now passes four actual PHP + controls on MySQL 8.4: timely 1.20/1.19, held poll reply and late synchronous + handler. It preserves the original clocks and history, with zero late + submissions. Native comparison and all-six integration remain pending. Discovery keeps `query_task_authority=false` until those gates pass. Full transport discovery stays unadvertised. Complete batch workflow semantics, diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index bd674c7f9..f7c378cb9 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -1,9 +1,10 @@ # Current query authority profile This separate profile uses normally installed PHP SDK 2.4.2 and published Server -2.8.0. It does not replace the frozen query actors. It is prepared and has no -passing HTTP qualification yet. Native discovery remains false until the new -authority and consumer gates qualify. +2.8.0. It does not replace the frozen query actors. The four published PHP cases +pass locally on isolated MySQL 8.4. Native comparison and all-six qualification +remain pending. Native discovery remains false until the new authority and +consumer gates qualify. The owning contract is [Server #433](https://github.com/durable-workflow/server/issues/433). `artifacts.json` freezes the consumer, reference image and original budgets. From 797ab382904bd869c9c77e437256a7cf19f9855c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:22:25 +0000 Subject: [PATCH 07/36] fix(rust): preserve query timeout diagnostics and release expired guards --- rust/src/runtime/backend.rs | 6 +- rust/src/runtime/continuations.rs | 30 ++++-- rust/src/runtime/queries.rs | 15 +-- rust/src/runtime/query_codec_tests.rs | 4 +- rust/tests/execution/query_authority.rs | 121 +++++++++++++++++++++++- 5 files changed, 152 insertions(+), 24 deletions(-) diff --git a/rust/src/runtime/backend.rs b/rust/src/runtime/backend.rs index 43944e0e4..b1fd435dd 100644 --- a/rust/src/runtime/backend.rs +++ b/rust/src/runtime/backend.rs @@ -23,7 +23,7 @@ pub(super) trait Backend: Database { const RANGE_REPAIR_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace=$2 AND task_type='workflow' AND status='failed' AND payload->>'replay_blocked'='true' ORDER BY CASE WHEN available_at IS NULL THEN 1 ELSE 0 END,available_at,created_at,id LIMIT 1"; const ACTIVITY_OPEN_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='activity' AND status IN ('ready','leased') AND payload->>'activity_execution_id'=$2 ORDER BY id LIMIT 1"; const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND value::jsonb->>'namespace'=$1 ORDER BY \"key\" LIMIT 65"; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND value::jsonb->>'run_id'=$1 AND value::jsonb->>'status' IN ('pending','leased')"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT value::jsonb->>'query_admitted_at' AS query_admitted_at,value::jsonb->>'query_deadline_at' AS query_deadline_at,value::jsonb->>'receipt_expires_at' AS receipt_expires_at FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND value::jsonb->>'run_id'=$1 AND value::jsonb->>'status' IN ('pending','leased') ORDER BY \"key\" LIMIT 65"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE \"key\"=$1"; const QUERY_CACHE_INSERT: &'static str = @@ -85,7 +85,7 @@ fn sqlite_instant(value: String) -> Result> { impl Backend for Sqlite { const QUERY_CACHE_SCAN: &'static str = "SELECT \"key\",value,expiration FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND json_extract(value,'$.namespace')=$1 ORDER BY \"key\" LIMIT 65"; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND json_extract(value,'$.run_id')=$1 AND json_extract(value,'$.status') IN ('pending','leased')"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT json_extract(value,'$.query_admitted_at') AS query_admitted_at,json_extract(value,'$.query_deadline_at') AS query_deadline_at,json_extract(value,'$.receipt_expires_at') AS receipt_expires_at FROM dw_query_cache WHERE \"key\" LIKE 'dw-native-query:%' AND expiration>$2 AND json_extract(value,'$.run_id')=$1 AND json_extract(value,'$.status') IN ('pending','leased') ORDER BY \"key\" LIMIT 65"; const SELECTION_RESOLUTION_SQL: &'static str = "SELECT id,payload FROM workflow_history_events WHERE workflow_run_id=$1 AND event_type='SelectionResolved' AND json_extract(payload,'$.selection_group_id')=$2 ORDER BY sequence LIMIT 1"; const REPAIR_COUNTER_MAX: i64 = i64::MAX; const RANGE_REPAIR_TASK_SQL: &'static str = "SELECT * FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace=$2 AND task_type='workflow' AND status='failed' AND json_extract(payload,'$.replay_blocked')=1 ORDER BY CASE WHEN available_at IS NULL THEN 1 ELSE 0 END,available_at,created_at,id LIMIT 1"; @@ -303,7 +303,7 @@ impl Backend for MySql { type EncodedTime = DateTime; type EncodedOptionalTime = Option>; type EncodedDocument = Json; - const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT COUNT(*) FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.run_id'))=? AND expiration>? AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.status')) IN ('pending','leased')"; + const QUERY_CACHE_PENDING_FOR_RUN: &'static str = "SELECT JSON_UNQUOTE(JSON_EXTRACT(value,'$.query_admitted_at')) AS query_admitted_at,JSON_UNQUOTE(JSON_EXTRACT(value,'$.query_deadline_at')) AS query_deadline_at,JSON_UNQUOTE(JSON_EXTRACT(value,'$.receipt_expires_at')) AS receipt_expires_at FROM dw_query_cache WHERE `key` LIKE 'dw-native-query:%' AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.run_id'))=? AND expiration>? AND JSON_UNQUOTE(JSON_EXTRACT(value,'$.status')) IN ('pending','leased') ORDER BY `key` LIMIT 65"; const QUERY_CACHE_GET: &'static str = "SELECT value,expiration FROM dw_query_cache WHERE `key`=$1"; const QUERY_CACHE_INSERT: &'static str = diff --git a/rust/src/runtime/continuations.rs b/rust/src/runtime/continuations.rs index 72054dd24..129179aa4 100644 --- a/rust/src/runtime/continuations.rs +++ b/rust/src/runtime/continuations.rs @@ -158,21 +158,35 @@ where "continue_as_new_transfer_unqualified", )); } - // A bounded cache prefix cannot prove that this run has no pending - // query. Check the complete native-owned scope without loading or - // parsing its history snapshots on the executor. - if Self::scalar(DB::QUERY_CACHE_PENDING_FOR_RUN) + // Select this run before applying the bound. Receipt retention does + // not extend original query execution, and unrelated cache prefixes + // cannot hide a live claim. Read timing only, never history snapshots. + let queries = Self::query(DB::QUERY_CACHE_PENDING_FOR_RUN) .bind(&old) .bind(now().timestamp()) - .fetch_one(&mut **tx) - .await? - != 0 - { + .fetch_all(&mut **tx) + .await?; + if queries.len() > super::queries::MAX_ENTRIES { return Err(refuse( StatusCode::CONFLICT, "continue_as_new_transfer_unqualified", )); } + let observed = super::query_authority::now(); + for query in queries { + let timing = json!({ + "query_admitted_at":DB::optional_string(&query,"query_admitted_at")?, + "query_deadline_at":DB::optional_string(&query,"query_deadline_at")?, + "receipt_expires_at":DB::optional_string(&query,"receipt_expires_at")?, + }); + if super::query_authority::deadline(&timing).is_none_or(|deadline| observed < deadline) + { + return Err(refuse( + StatusCode::CONFLICT, + "continue_as_new_transfer_unqualified", + )); + } + } let rows = Self::query(DB::MEMO_READ) .bind(&old) .fetch_all(&mut **tx) diff --git a/rust/src/runtime/queries.rs b/rust/src/runtime/queries.rs index 16003657b..77f3831d2 100644 --- a/rust/src/runtime/queries.rs +++ b/rust/src/runtime/queries.rs @@ -17,7 +17,7 @@ use tokio::sync::Semaphore; const PREFIX: &str = "dw-native-query:"; const MAX_PENDING: usize = 16; -const MAX_ENTRIES: usize = 64; +pub(super) const MAX_ENTRIES: usize = 64; const SNAPSHOT_BYTES: usize = 1024 * 1024; pub(super) const ARGUMENT_BYTES: usize = 64 * 1024; pub(super) const RESULT_BYTES: usize = 256 * 1024; @@ -258,11 +258,13 @@ where self.wake.notify_waiters(); // Never hold the transition lock or a pooled connection across the wait. // Periodic probes observe results committed by another server process. - let timed_out = || super::RuntimeError::Protocol { + let timed_out = |claimed| super::RuntimeError::Protocol { status: StatusCode::GATEWAY_TIMEOUT, response: json!({"workflow_id":workflow_id,"run_id":run_id,"query_name":name, - "reason":"query_task_timed_out","message":"Timed out waiting for the original query result."}), + "reason":if claimed { "query_worker_execution_timeout" } else { "query_task_not_claimed" }, + "message":"Timed out waiting for the original query result."}), }; + let mut claimed = false; loop { let wake = self.wake.notified(); let remaining = (deadline - authority::now()).to_std().unwrap_or_default(); @@ -271,9 +273,10 @@ where .fetch_optional(&self.pool); let row = tokio::time::timeout(remaining, read) .await - .map_err(|_| timed_out())?? + .map_err(|_| timed_out(claimed))?? .ok_or_else(|| refuse(StatusCode::CONFLICT, "query_task_timed_out"))?; let task: Value = serde_json::from_str(&DB::string(&row, "value")?)?; + claimed = task["query_task_attempt"].as_i64().unwrap_or(0) > 0; match task["status"].as_str() { Some("completed") => { return Ok(( @@ -295,14 +298,14 @@ where return Ok((status, body)); } Some("timed_out") => { - return Err(timed_out()); + return Err(timed_out(claimed)); } _ if authority::deadline(&task) .is_some_and(|deadline| authority::now() >= deadline) => { // The stored deadline already fences every new outcome. // Cleanup must not delay the caller behind a busy writer. - return Err(timed_out()); + return Err(timed_out(claimed)); } _ if authority::deadline(&task).is_none() => { let mut tx = self.begin().await?; diff --git a/rust/src/runtime/query_codec_tests.rs b/rust/src/runtime/query_codec_tests.rs index d7ba80cd8..35042771b 100644 --- a/rust/src/runtime/query_codec_tests.rs +++ b/rust/src/runtime/query_codec_tests.rs @@ -164,8 +164,8 @@ async fn query_outcome_rechecks_original_deadline_after_codec_wait_without_holdi caller, RuntimeError::Protocol { status: StatusCode::GATEWAY_TIMEOUT, - .. - } + ref response + } if response["reason"] == "query_worker_execution_timeout" ), "{caller:?}" ); diff --git a/rust/tests/execution/query_authority.rs b/rust/tests/execution/query_authority.rs index 1561f4a21..21737681f 100644 --- a/rust/tests/execution/query_authority.rs +++ b/rust/tests/execution/query_authority.rs @@ -461,10 +461,9 @@ async fn expired_original_refuses_before_decoding_and_status_does_not_extend_it( }; let original = configured_original("query-authority-expired-original", Some(config)).await; let id = original.claim["query_task_id"].as_str().unwrap(); - assert_eq!( - original.waiting.await.unwrap().0, - StatusCode::GATEWAY_TIMEOUT - ); + let caller = original.waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(caller.1["reason"], "query_worker_execution_timeout"); let snapshot = cache_snapshot(&original.database, id).await; let status = observation(&original.app, id, "authority-worker", 1).await; assert_eq!(status.0, StatusCode::OK, "{}", status.1); @@ -745,6 +744,118 @@ async fn identical_committed_result_reconciles_before_and_after_original_expiry( original.database.remove().await; } +#[tokio::test] +async fn original_query_deadline_releases_continuation_without_consuming_its_snapshot() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap().with_query_task_config( + durable_workflow_server::runtime::QueryTaskConfig { + timeout_seconds: 4, + lease_timeout_seconds: 4, + ..Default::default() + }, + ); + let app = router(runtime.clone()); + query_worker(&app, "authority-worker").await; + let started = start(&app, "query-authority-continuation").await; + let initial = poll(&app, "authority-worker", "workflow").await; + assert_eq!( + request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + initial["task_id"].as_str().unwrap() + ), + completion(&initial, json!([{"type":"start_timer","delay_seconds":1}])), + ) + .await + .0, + StatusCode::OK + ); + let waiting = { + let app = app.clone(); + tokio::spawn(async move { + request( + &app, + "POST", + "/api/workflows/query-authority-continuation/query/state", + json!({"input":envelope(Payload::Array(vec![Payload::Long(7)]))}), + ) + .await + }) + }; + let claim = await_query(&app, "authority-worker").await; + let id = claim["query_task_id"].as_str().unwrap(); + let snapshot = cache_snapshot(&database, id).await; + let resumed = loop { + let resumed = poll(&app, "authority-worker", "workflow").await; + if !resumed.is_null() { + break resumed; + } + assert!( + Utc::now() < instant(&claim["query_deadline_at"]), + "timer did not produce a genuine workflow lease within the original query deadline" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let path = format!( + "/api/worker/workflow-tasks/{}/complete", + resumed["task_id"].as_str().unwrap() + ); + let body = completion(&resumed, json!([{"type":"continue_as_new"}])); + let live = request(&app, "POST", &path, body.clone()).await; + assert_eq!(live.0, StatusCode::CONFLICT, "{}", live.1); + assert_eq!(live.1["reason"], "continue_as_new_transfer_unqualified"); + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let caller = waiting.await.unwrap(); + assert_eq!(caller.0, StatusCode::GATEWAY_TIMEOUT); + // No query poll or outcome has swept the retained claim. Its exact bytes + // and SQL expiry must survive continuation on the same workflow lease. + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let continued = request(&app, "POST", &path, body).await; + assert_eq!(continued.0, StatusCode::OK, "{}", continued.1); + assert_eq!(cache_snapshot(&database, id).await, snapshot); + let runs = request( + &app, + "GET", + "/api/workflows/query-authority-continuation/runs", + Value::Null, + ) + .await; + assert_eq!(runs.1["runs"].as_array().unwrap().len(), 2); + assert_eq!(runs.1["runs"][0]["run_id"], started["run_id"]); + let late = request( + &app, + "POST", + &format!("/api/worker/query-tasks/{id}/complete"), + submitted(&claim), + ) + .await; + assert_eq!(late.0, StatusCode::CONFLICT, "{}", late.1); + assert_eq!(late.1["reason"], "query_task_timed_out"); + let successor = poll(&app, "authority-worker", "workflow").await; + assert_ne!(successor["run_id"], started["run_id"]); + assert_eq!( + request( + &app, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + successor["task_id"].as_str().unwrap() + ), + completion( + &successor, + json!([{"type":"complete_workflow","result":envelope(Payload::Long(7))}]) + ) + ) + .await + .0, + StatusCode::OK + ); + runtime.close().await; + database.remove().await; +} + #[tokio::test] async fn zero_is_immediate_and_later_policy_does_not_rebase_an_existing_query() { let original = original("query-authority-policy").await; @@ -763,7 +874,7 @@ async fn zero_is_immediate_and_later_policy_does_not_rebase_an_existing_query() ) .await; assert_eq!(expired.0, StatusCode::GATEWAY_TIMEOUT, "{}", expired.1); - assert_eq!(expired.1["reason"], "query_task_timed_out"); + assert_eq!(expired.1["reason"], "query_task_not_claimed"); assert_query_replay( &original.claim, poll(&changed, "authority-worker", "query").await, From 8dea1ce4355f8d3f30ef0a26bf0fccb3295b25a0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:24:47 +0000 Subject: [PATCH 08/36] feat(rust): enable candidate query authority discovery --- docs/rust-server-port.md | 6 ++++-- rust/src/runtime/store.rs | 2 +- .../conformance/server-parity/query-authority/worker.php | 1 - .../ServerParityProfiles/query-authority/README.md | 8 ++++++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 44cf91261..3e3d61531 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -78,8 +78,10 @@ The current integration order is: published Server 2.8.0 / PHP SDK 2.4.2 profile now passes four actual PHP controls on MySQL 8.4: timely 1.20/1.19, held poll reply and late synchronous handler. It preserves the original clocks and history, with zero late - submissions. Native comparison and all-six integration remain pending. Discovery keeps - `query_task_authority=false` until those gates pass. + submissions. Native comparison and all-six integration remain pending. Main + keeps `query_task_authority=false`; the unpublished candidate advertises it + for actual SDK qualification before promotion. Full namespace CRUD remains + open, including the absent native DELETE route. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 3e543ffc3..41bf695fd 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -2740,7 +2740,7 @@ pub(crate) fn capabilities() -> Value { "worker_deregistration_fencing":{"schema":"durable-workflow.v2.worker-deregistration.v1","supported":true,"receipt_retention_seconds":600,"endpoint":"/worker/registrations/{workerId}/deregister"}, "workflow_memo_updates": false, "cooperative_cancellation": true, "prepared_local_activities": false, "worker_sessions": false, "sticky_execution": false, "local_activities": false, "message_streams": false, - "workflow_updates": true, "query_tasks": true, "query_task_authority":false, "query_task_poll_request_idempotency": false}); + "workflow_updates": true, "query_tasks": true, "query_task_authority":true, "query_task_poll_request_idempotency": false}); if super::memos::UPDATES_SUPPORTED { value["supported_workflow_task_commands"] .as_array_mut() diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index f679c8b49..9e79a9c1c 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -255,7 +255,6 @@ private function observe(string $uri, ?array $reply): ?array if ($handle !== null) { $client->terminateWorkflow($namespace, 'query authority qualification complete', $handle->selectedRunId); } - $admin->deleteNamespace($namespace); } finally { unlink($journal); } diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index f7c378cb9..702f46c09 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -3,8 +3,8 @@ This separate profile uses normally installed PHP SDK 2.4.2 and published Server 2.8.0. It does not replace the frozen query actors. The four published PHP cases pass locally on isolated MySQL 8.4. Native comparison and all-six qualification -remain pending. Native discovery remains false until the new authority and -consumer gates qualify. +remain pending. Main does not advertise native query authority. The unpublished +candidate advertises it for installed-consumer qualification before promotion. The owning contract is [Server #433](https://github.com/durable-workflow/server/issues/433). `artifacts.json` freezes the consumer, reference image and original budgets. @@ -24,6 +24,10 @@ readiness and cleanup. Run each declared case once against each target. Every original has one unchanged 30-second observation budget covering setup, query, Worker drain and deregistration. The caller must also bound container lifetime and remove its resources after success or failure. +The Worker and workflow stop within each original budget. The caller destroys +the entire isolated database afterward, including its task namespaces. This +profile does not qualify namespace DELETE, which is still absent in native +source. It makes the same cleanup calls on both targets. The actual Worker creates a waiting timer workflow, then serves its query. Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. From deff7c401376e010ef9b3cacf72e3b9d63f45ee1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:26:47 +0000 Subject: [PATCH 09/36] test(conformance): bind native query comparison to its executable --- .../conformance/server-parity/query-authority-profile.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/query-authority-profile.sh b/scripts/conformance/server-parity/query-authority-profile.sh index 5d65ad54d..723d29a65 100755 --- a/scripts/conformance/server-parity/query-authority-profile.sh +++ b/scripts/conformance/server-parity/query-authority-profile.sh @@ -22,10 +22,16 @@ if test "$target" = php; then docker inspect "$server" | jq -e --arg revision "$(jq -r '.server.source' "$profile")" \ '.[0].Config.Labels["org.opencontainers.image.revision"] == $revision' >/dev/null fi +native_binary_sha256='' +if test "$target" = rust; then + native_binary_sha256=$(docker exec "$server" sha256sum /target/debug/durable-workflow-server | cut -d ' ' -f1) +fi jq -n --arg revision "$(git rev-parse HEAD)" --arg native_tree "$(git rev-parse HEAD:rust)" \ --arg server_image "$(docker inspect "$server" --format '{{.Image}}')" \ + --arg native_binary_sha256 "$native_binary_sha256" \ --arg target "$target" --arg profile_sha256 "$(sha256sum "$profile" | cut -d ' ' -f1)" \ - '{runner_revision:$revision,native_tree:$native_tree,target:$target,server_image:$server_image, + '{runner_revision:$revision,native_tree:$native_tree,native_binary_sha256:$native_binary_sha256, + target:$target,server_image:$server_image, profile_sha256:$profile_sha256,performance:"not_measured"}' > "$output/identity.json" while IFS=$'\t' read -r mode protocol; do name="$mode-${protocol//./}" From a7aace148729e6f7c93bb0e317084d3062df68c5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:29:52 +0000 Subject: [PATCH 10/36] test(conformance): observe actual query worker registration directly --- scripts/conformance/server-parity/query-authority/worker.php | 4 +++- tests/Fixtures/ServerParityProfiles/query-authority/README.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index 9e79a9c1c..148037f2e 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -131,6 +131,8 @@ private function observe(string $uri, ?array $reply): ?array }, diagnosticListener: static function (string $event, array $details) use ($record): void { if ($event === 'worker.claim_aborted') { $record('aborted', $details); + } elseif ($event === 'worker.registered') { + $record('registered', $details); } }, requireQueryTaskAuthority: $protocol === '1.20'); $worker->registerWorkflow('parity.query-authority.v1', static function (WorkflowContext $context): int { @@ -158,7 +160,7 @@ private function observe(string $uri, ?array $reply): ?array do { $events = $read(); queryCheck(!in_array('actor-error', array_column($events, 'event'), true), 'The Worker refused startup: '.json_encode($events)); - if ($client->listWorkers($namespace) !== []) { + if (in_array('registered', array_column($events, 'event'), true)) { break; } usleep(20000); diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index 702f46c09..7d343458e 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -28,6 +28,9 @@ The Worker and workflow stop within each original budget. The caller destroys the entire isolated database afterward, including its task namespaces. This profile does not qualify namespace DELETE, which is still absent in native source. It makes the same cleanup calls on both targets. +Startup waits for the installed Worker's `worker.registered` diagnostic, emitted +after its actual registration acknowledgement. It does not depend on worker +listing, which is also outside the current native slice. The actual Worker creates a waiting timer workflow, then serves its query. Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. From 6150afd564fbdb17190dd6e23d520b24fd3e7830 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:33:18 +0000 Subject: [PATCH 11/36] test(conformance): separate named query and active timer coverage --- .../server-parity/query-authority/worker.php | 49 +++++++++++-------- .../query-authority/README.md | 5 +- .../query-authority/artifacts.json | 3 +- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index 148037f2e..373a958f1 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -91,7 +91,7 @@ private function observe(string $uri, ?array $reply): ?array [$script, $url, $mode, $protocol] = $argv; queryCheck(in_array([$mode, $protocol], [ - ['timely', '1.20'], ['timely', '1.19'], ['late-poll', '1.20'], ['late-handler', '1.20'], + ['timely', '1.20'], ['timely', '1.19'], ['late-poll', '1.20'], ['late-handler', '1.20'], ['timely-active', '1.20'], ], true), 'Unknown query authority case.'); $artifacts = json_decode(file_get_contents(__DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), true, flags: JSON_THROW_ON_ERROR); queryCheck(InstalledVersions::getPrettyVersion('durable-workflow/sdk') === $artifacts['sdk_php']['version'] @@ -103,10 +103,15 @@ private function observe(string $uri, ?array $reply): ?array queryCheck(function_exists('pcntl_fork') && function_exists('posix_kill'), 'This real Worker fixture requires Unix CLI.'); $started = hrtime(true) / 1e9; $until = $started + $artifacts['original_observation_seconds']; -$namespace = 'parity-query-authority-'.bin2hex(random_bytes(6)); +$workflowId = 'parity-query-authority-'.bin2hex(random_bytes(6)); +$active = $mode === 'timely-active'; +$timely = in_array($mode, ['timely', 'timely-active'], true); +$namespace = $active ? 'default' : $workflowId; $token = getenv('DW_PARITY_TOKEN') ?: 'query-authority-fixture-token'; $admin = new Client($url, token: $token); -$admin->createNamespace($namespace); +if (!$active) { + $admin->createNamespace($namespace); +} $client = new Client($url, namespace: $namespace, token: $token); $journal = tempnam(sys_get_temp_dir(), 'query-authority-'); $record = static function (string $event, array $details = []) use ($journal): void { @@ -124,7 +129,7 @@ private function observe(string $uri, ?array $reply): ?array if ($pid === 0) { try { $worker = new Worker(new Client($url, namespace: $namespace, token: $token, - transport: new QueryAuthorityTransport($mode, $record), workerProtocolVersion: $protocol), $namespace, + transport: new QueryAuthorityTransport($mode, $record), workerProtocolVersion: $protocol), $workflowId, clock: static function () use ($until): float { queryCheck(hrtime(true) / 1e9 < $until, 'Original Worker observation deadline expired.'); return microtime(true); @@ -135,8 +140,10 @@ private function observe(string $uri, ?array $reply): ?array $record('registered', $details); } }, requireQueryTaskAuthority: $protocol === '1.20'); - $worker->registerWorkflow('parity.query-authority.v1', static function (WorkflowContext $context): int { - $context->sleep(60); + $worker->registerWorkflow('parity.query-authority.v1', static function (WorkflowContext $context) use ($active): int { + if ($active) { + $context->sleep(60); + } return 7; }); $worker->registerQuery('parity.query-authority.v1', 'value', static function (QueryContext $context) use ($mode, $record): array { @@ -166,18 +173,19 @@ private function observe(string $uri, ?array $reply): ?array usleep(20000); } while (hrtime(true) / 1e9 < $until); queryCheck(hrtime(true) / 1e9 < $until, 'No Worker registered within the original observation.'); - $handle = $client->startWorkflow('parity.query-authority.v1', $namespace, $namespace); + $handle = $client->startWorkflow('parity.query-authority.v1', $workflowId, $workflowId); + $expectedStatus = $active ? 'waiting' : 'completed'; do { $status = strtolower((string) $handle->describe()->status); - if ($status === 'waiting') { + if ($status === $expectedStatus) { break; } usleep(20000); } while (hrtime(true) / 1e9 < $until); - queryCheck($status === 'waiting', 'The original did not reach its committed timer wait.'); - $before = $client->workflowHistory($namespace, $handle->selectedRunId); + queryCheck($status === $expectedStatus, 'The original did not reach its committed '.$expectedStatus.' state.'); + $before = $client->workflowHistory($workflowId, $handle->selectedRunId); $expected = ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; - if ($mode === 'timely') { + if ($timely) { queryCheck($handle->query('value') === $expected, 'The actual typed query result changed.'); } else { try { @@ -199,8 +207,8 @@ private function observe(string $uri, ?array $reply): ?array $named = static fn (string $name): array => array_values(array_filter($events, static fn (array $event): bool => $event['event'] === $name)); queryCheck(count($named('claim')) === 1, 'Expected one actual original query claim.'); $claim = $named('claim')[0]['details']; - queryCheck(($claim['workflow_id'] ?? null) === $namespace && ($claim['run_id'] ?? null) === $handle->selectedRunId - && ($claim['task_queue'] ?? null) === $namespace && ($claim['namespace'] ?? $client->namespace) === $namespace + queryCheck(($claim['workflow_id'] ?? null) === $workflowId && ($claim['run_id'] ?? null) === $handle->selectedRunId + && ($claim['task_queue'] ?? null) === $workflowId && ($claim['namespace'] ?? $client->namespace) === $namespace && ($claim['query_task_attempt'] ?? null) === 1, 'The delivery changed its original run, namespace or attempt.'); foreach (['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time'] as $field) { queryCheck(is_string($claim[$field] ?? null) && preg_match('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/D', $claim[$field]) === 1, @@ -218,11 +226,12 @@ private function observe(string $uri, ?array $reply): ?array } queryCheck(($named('status') !== []) === ($protocol === '1.20'), 'Protocol status use does not match the declared boundary.'); queryCheck(count($named('handler')) === ($mode === 'late-poll' ? 0 : 1), 'The callback dispatched outside its original authority.'); - queryCheck(count($named('submission')) === ($mode === 'timely' ? 1 : 0), 'An expired callback attempted a terminal submission.'); - queryCheck(count($named('acknowledgement')) === ($mode === 'timely' ? 1 : 0), 'An expired callback submitted a terminal outcome.'); - queryCheck($before === $client->workflowHistory($namespace, $handle->selectedRunId), 'Query execution changed the original workflow history.'); + queryCheck(count($named('submission')) === ($timely ? 1 : 0), 'An expired callback attempted a terminal submission.'); + queryCheck(count($named('acknowledgement')) === ($timely ? 1 : 0), 'An expired callback submitted a terminal outcome.'); + queryCheck($before === $client->workflowHistory($workflowId, $handle->selectedRunId), 'Query execution changed the original workflow history.'); queryCheck($named('actor-error') === [] && hrtime(true) / 1e9 < $until, 'The original Worker observation failed.'); - $result = ['outcome' => 'pass', 'mode' => $mode, 'protocol' => $protocol, 'workflow_id' => $namespace, + $result = ['outcome' => 'pass', 'mode' => $mode, 'protocol' => $protocol, 'workflow_id' => $workflowId, + 'namespace' => $namespace, 'original_run_status' => $expectedStatus, 'run_id' => $handle->selectedRunId, 'claim' => $claim, 'observations' => $events, 'original_budget_seconds' => 30, 'sdk_version' => InstalledVersions::getPrettyVersion('durable-workflow/sdk'), 'sdk_source' => InstalledVersions::getReference('durable-workflow/sdk'), @@ -233,7 +242,7 @@ private function observe(string $uri, ?array $reply): ?array 'history_sha256' => hash('sha256', json_encode($before, JSON_THROW_ON_ERROR))]; } catch (Throwable $error) { fwrite(STDERR, json_encode(['outcome' => 'fail', 'mode' => $mode, 'protocol' => $protocol, - 'workflow_id' => $namespace, 'run_id' => $handle?->selectedRunId, + 'workflow_id' => $workflowId, 'namespace' => $namespace, 'run_id' => $handle?->selectedRunId, 'error' => $error->getMessage(), 'observations' => $read()], JSON_THROW_ON_ERROR)."\n"); throw $error; } finally { @@ -254,8 +263,8 @@ private function observe(string $uri, ?array $reply): ?array $drained = pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0; } try { - if ($handle !== null) { - $client->terminateWorkflow($namespace, 'query authority qualification complete', $handle->selectedRunId); + if ($handle !== null && $active) { + $client->terminateWorkflow($workflowId, 'query authority qualification complete', $handle->selectedRunId); } } finally { unlink($journal); diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index 7d343458e..50da1accb 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -32,7 +32,10 @@ Startup waits for the installed Worker's `worker.registered` diagnostic, emitted after its actual registration acknowledgement. It does not depend on worker listing, which is also outside the current native slice. -The actual Worker creates a waiting timer workflow, then serves its query. +The four deadline cases use completed workflows in distinct named namespaces. +The additional timely-active case uses a genuine waiting timer workflow in the +default namespace. Native named-timer authoring still refuses explicitly and is +not qualified by this profile. Earlier waiting named originals remain failed. Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. Separate late controls hold a genuine poll reply for six seconds or let a synchronous callback return after six seconds. They require the original caller diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json index db33330e6..3e48781aa 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +++ b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json @@ -23,7 +23,8 @@ {"mode": "timely", "protocol": "1.20"}, {"mode": "timely", "protocol": "1.19"}, {"mode": "late-poll", "protocol": "1.20"}, - {"mode": "late-handler", "protocol": "1.20"} + {"mode": "late-handler", "protocol": "1.20"}, + {"mode": "timely-active", "protocol": "1.20"} ], "embedded": "inapplicable: service query claims and status have no embedded worker lease", "performance": "not_measured" From dd6a2f197643587861e2c049159b2e902297b0c1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:42:24 +0000 Subject: [PATCH 12/36] Qualify query authority in isolated published consumer cohorts --- .github/workflows/server-parity.yml | 32 ++++++ docs/rust-server-port.md | 31 ++++-- .../server-parity/query-authority-cleanup.sh | 65 +++++++++++++ .../server-parity/query-authority-cohort.sh | 97 +++++++++++++++++++ .../server-parity/query-authority/worker.php | 10 +- .../query-authority/README.md | 30 ++++-- 6 files changed, 240 insertions(+), 25 deletions(-) create mode 100755 scripts/conformance/server-parity/query-authority-cleanup.sh create mode 100755 scripts/conformance/server-parity/query-authority-cohort.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 2e8d9e7de..2744052d9 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -641,6 +641,14 @@ jobs: if: always() timeout-minutes: 5 run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh mysql + - name: Compare published query deadlines and installed Worker authority + if: ${{ !cancelled() }} + timeout-minutes: 10 + run: bash scripts/conformance/server-parity/query-authority-cohort.sh mysql + - name: Retire query authority resources + if: always() + timeout-minutes: 5 + run: bash scripts/conformance/server-parity/query-authority-cleanup.sh mysql - name: Compare pinned PHP, embedded and native worker-fencing source cohort if: ${{ !cancelled() }} run: bash scripts/conformance/server-parity/worker-fencing-profile.sh mysql @@ -1089,6 +1097,18 @@ jobs: env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh pgsql + - name: Compare published query deadlines and installed Worker authority + if: ${{ !cancelled() }} + timeout-minutes: 10 + env: + POSTGRES_IMAGE: ${{ matrix.image }} + run: bash scripts/conformance/server-parity/query-authority-cohort.sh pgsql + - name: Retire query authority resources + if: always() + timeout-minutes: 5 + env: + POSTGRES_IMAGE: ${{ matrix.image }} + run: bash scripts/conformance/server-parity/query-authority-cleanup.sh pgsql - name: Compare pinned PHP, embedded and native worker-fencing source cohort if: ${{ !cancelled() }} env: @@ -1512,6 +1532,18 @@ jobs: env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/external-consumer-cleanup.sh sqlite + - name: Compare published query deadlines and installed Worker authority + if: ${{ !cancelled() }} + timeout-minutes: 10 + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + run: bash scripts/conformance/server-parity/query-authority-cohort.sh sqlite + - name: Retire query authority resources + if: always() + timeout-minutes: 5 + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + run: bash scripts/conformance/server-parity/query-authority-cleanup.sh sqlite - name: Compare pinned PHP, embedded and native worker-fencing source cohort if: ${{ !cancelled() }} env: diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 3e3d61531..cae6f4a5f 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -49,9 +49,13 @@ The current integration order is: - [#458](https://github.com/durable-workflow/server/pull/458): completion-bound local activity-result uploads, including exact PHP Unicode context hashing, original live authority and durable allowances. Combined head `93368d2ae` - includes #455 and passes its own 13-job, six-database matrix. Promotion awaits - the coordinated PHP dependency window and a fresh combined-tree qualification. - Earlier component passes are not carried across that integration. + includes #455 and passes its own 13-job, six-database matrix. The later + `3a268200` incorporates qualified PHP `ed803519`, but its PostgreSQL 17 cell + fails the fresh upload capacity control under the preceding stalled request's + 100 ms test policy. The next fixture revision keeps that stalled deadline and + shared semaphore while giving the separate fresh upload normal policy. + Promotion needs fresh combined-tree qualification. Earlier passes are not + carried across that integration. - [#460](https://github.com/durable-workflow/server/pull/460): read-only PHP catalog/data inventory. Head `4459140cd` adds actual FIFO/directory refusal and a separately budgeted fresh-upload capacity control. Its matrix is @@ -73,15 +77,22 @@ The current integration order is: HTTP controls pass on SQLite, MySQL 8.4 and PostgreSQL 17 before and after expiry, preserving conflicting-payload refusal and the original receipt's bytes and expiry. The separate SQLite codec-and-writer counterfactual fails - the prior implementation and passes the correction. Continuation expiry, - remaining pressure boundaries and combined ancestry remain next. A separate - published Server 2.8.0 / PHP SDK 2.4.2 profile now passes four actual PHP - controls on MySQL 8.4: timely 1.20/1.19, held poll reply and late synchronous - handler. It preserves the original clocks and history, with zero late - submissions. Native comparison and all-six integration remain pending. Main + the prior implementation and passes the correction. Genuine query/continuation + controls now pass on SQLite, MySQL 8.4 and PostgreSQL 17: live original queries + still block continuation, while expired retained snapshots do not. + A separate published Server 2.8.0 / PHP SDK 2.4.2 profile matches four actual + named-namespace completed-workflow controls on PHP and native MySQL 8.4: + timely 1.20/1.19, held poll reply and late synchronous handler. Original clocks + and histories survive, with zero late submissions and eight zero-exit Workers. + Its fifth active native original remains failed at unsupported termination + cleanup. The revised query-only observer delegates database retirement to + the caller; its new five-case profile and all-six execution remain pending. + Earlier worker-list and named-timer originals remain failed. Main keeps `query_task_authority=false`; the unpublished candidate advertises it for actual SDK qualification before promotion. Full namespace CRUD remains - open, including the absent native DELETE route. + open, including the absent native DELETE route. Worker listing, named timer + authoring and termination are also outside this query slice. See the + [exact installed checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, diff --git a/scripts/conformance/server-parity/query-authority-cleanup.sh b/scripts/conformance/server-parity/query-authority-cleanup.sh new file mode 100755 index 000000000..fe883a019 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-cleanup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Retire only this profile, including failed setup, before its database is stopped. +set -uo pipefail +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +evidence=parity-evidence/query-authority +state=parity-query-authority-state +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +test -f "$evidence/identity.json" || exit 0 +test "$(jq -r .resource_scope "$evidence/identity.json")" = "$RESOURCE_SCOPE" || exit 1 +test "$(jq -r .backend "$evidence/identity.json")" = "$backend" || exit 1 +result=0 +profile_status=$(cat "$evidence/profile-status.txt" 2>/dev/null || printf incomplete) +for target in php rust; do + while IFS=$'\t' read -r mode protocol; do + name="$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" + if docker inspect "$name" >/dev/null 2>&1; then docker rm -f "$name" >/dev/null || result=1; fi + done < <(jq -r '.cases[]|[.mode,.protocol]|@tsv' "$profile") +done +for target in php native; do + name="$RESOURCE_SCOPE-query-authority-$target" + if docker inspect "$name" >/dev/null 2>&1; then + docker stop --signal=SIGTERM --timeout=10 "$name" > "$evidence/$target-stop.txt" 2>&1 || true + docker logs "$name" > "$evidence/$target-server.log" 2>&1 + docker inspect --format '{{json .State}}' "$name" > "$evidence/$target-state.json" + if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null; then + cat "$evidence/$target-state.json" + result=1 + fi + docker rm -f "$name" >/dev/null || result=1 + if docker inspect "$name" >/dev/null 2>&1; then result=1; fi + elif test "$profile_status" = 0; then + if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null 2>&1; then + printf '%s\n' "Completed query profile has no successful $target shutdown receipt" >&2 + result=1 + fi + fi +done +case "$backend" in + mysql|pgsql) + for target in php native; do + test -f "$evidence/$target-database-owned.txt" || continue + test "$(cat "$evidence/$target-database-owned.txt")" = "$RESOURCE_SCOPE" || { result=1; continue; } + database="query_authority_$target" + if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h parity-mysql -uroot -pparity-disposable-password -e "DROP DATABASE IF EXISTS $database" \ + > "$evidence/$target-database-retire.txt" 2>&1 || result=1 + else + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD=parity-disposable-password \ + --entrypoint dropdb "$POSTGRES_IMAGE" --if-exists -h parity-pg -U parity "$database" \ + > "$evidence/$target-database-retire.txt" 2>&1 || result=1 + fi + done ;; + sqlite) ;; + *) exit 2 ;; +esac +if test -d "$state"; then + image=$(jq -r '.server.image' "$profile") + docker run --rm --user=1000:1000 --network=none --cpus=1 --memory=128m --memory-swap=128m \ + --entrypoint sh -v "$PWD/$state:/state" "$image" \ + -c 'find /state -mindepth 1 -depth -delete' || result=1 + rmdir -- "$state" || result=1 +fi +exit "$result" diff --git a/scripts/conformance/server-parity/query-authority-cohort.sh b/scripts/conformance/server-parity/query-authority-cohort.sh new file mode 100755 index 000000000..6f85234c5 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-cohort.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Keep current query authority separate from the frozen query actors. +set -euo pipefail +test "$#" = 1 || exit 2 +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +docker network inspect "$RESOURCE_SCOPE" >/dev/null +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 +state=parity-query-authority-state +evidence=parity-evidence/query-authority +sdk="$state/sdk" +resource="$RESOURCE_SCOPE-query-authority" +test -f rust-target/debug/durable-workflow-server +test ! -e "$state" +test ! -e "$evidence" +mkdir -p "$sdk" "$state/native" "$evidence"/{php,rust} +mkdir -p "$state/php/storage/framework/"{cache/data,sessions,views} "$state/php/storage/logs" +cp scripts/conformance/server-parity/query-authority/composer.{json,lock} "$sdk/" +chmod -R a+rwX "$state" "$evidence" +jq -n --arg scope "$RESOURCE_SCOPE" --arg backend "$backend" --arg revision "$(git rev-parse HEAD)" \ + --arg image "$image" --arg binary "$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1)" \ + '{resource_scope:$scope,backend:$backend,runner_revision:$revision,php_image:$image,native_binary_sha256:$binary}' \ + > "$evidence/identity.json" +# The workflow's always-step retires partial setup and every admitted original. +trap 'printf "%s\n" "$?" > "$evidence/profile-status.txt"' EXIT +docker run --rm --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + --entrypoint composer -e COMPOSER_HOME=/tmp/query-composer \ + -v "$PWD/$sdk:/sdk" -w /sdk "$image" install --no-dev --no-scripts --no-interaction --prefer-dist \ + > "$evidence/sdk-install.txt" 2>&1 +case "$backend" in + mysql) db=(-e DB_CONNECTION=mysql -e DB_HOST=parity-mysql -e DB_USERNAME=root -e DB_PASSWORD=parity-disposable-password) ;; + pgsql) db=(-e DB_CONNECTION=pgsql -e DB_HOST=parity-pg -e DB_USERNAME=parity -e DB_PASSWORD=parity-disposable-password) ;; + sqlite) db=(-e DB_CONNECTION=sqlite) ;; + *) exit 2 ;; +esac +for target in php native; do + database="query_authority_$target" + if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h parity-mysql -uroot -pparity-disposable-password \ + -e "CREATE DATABASE $database CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" > "$evidence/$target-create.txt" + printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/$target-database-owned.txt" + elif test "$backend" = pgsql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD=parity-disposable-password \ + --entrypoint createdb "$POSTGRES_IMAGE" -h parity-pg -U parity "$database" > "$evidence/$target-create.txt" + printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/$target-database-owned.txt" + else + database=/state/database.sqlite + if test "$target" = php; then touch "$state/php/database.sqlite"; chmod 0666 "$state/php/database.sqlite"; fi + fi + configuration=("${db[@]}" -e DB_DATABASE="$database" -e DW_AUTH_DRIVER=token \ + -e DW_AUTH_TOKEN=query-authority-fixture-token -e DW_QUERY_TASK_TIMEOUT=4) + mount=(-v "$PWD/$state/$target:/state") + if test "$target" = php; then + php_runtime=(-e APP_ENV=testing -e APP_DEBUG=false -e CACHE_STORE=file -e QUEUE_CONNECTION=database \ + -e DW_SERVER_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= \ + -v "$PWD/$state/php/storage:/app/storage") + docker run --rm --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m --network "$RESOURCE_SCOPE" \ + "${configuration[@]}" "${mount[@]}" "${php_runtime[@]}" --entrypoint php "$image" \ + artisan server:bootstrap --force > "$evidence/php-bootstrap.txt" 2>&1 + docker run -d --name "$resource-php" --network "$RESOURCE_SCOPE" --network-alias=parity-query-authority-php \ + --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m \ + "${configuration[@]}" "${mount[@]}" "${php_runtime[@]}" "$image" > "$evidence/php-container.txt" + else + native=(-e DW_RUST_EXPERIMENTAL=1 -v "$PWD/rust-target:/target:ro" --entrypoint /target/debug/durable-workflow-server) + if test "$backend" != sqlite; then + docker run --rm --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m --network "$RESOURCE_SCOPE" \ + "${configuration[@]}" "${mount[@]}" "${native[@]}" "$rust_image" schema-bootstrap \ + > "$evidence/native-bootstrap.json" + fi + docker run -d --name "$resource-native" --network "$RESOURCE_SCOPE" --network-alias=parity-query-authority-native \ + --user=1000:1000 --cpus=1 --memory=768m --memory-swap=768m "${configuration[@]}" "${mount[@]}" \ + -e DW_BIND_ADDRESS=0.0.0.0:8080 "${native[@]}" "$rust_image" > "$evidence/native-container.txt" + fi + ready=0 + for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS "http://parity-query-authority-$target:8080/api/ready" > "$evidence/$target-readiness.json"; then ready=1; break; fi + sleep 1 + done + test "$ready" = 1 +done +export DW_PARITY_TOKEN=query-authority-fixture-token +bash scripts/conformance/server-parity/query-authority-profile.sh "$RESOURCE_SCOPE" "$resource-php" \ + http://parity-query-authority-php:8080 "$sdk" "$evidence/php" php > "$evidence/php-tests.txt" +bash scripts/conformance/server-parity/query-authority-profile.sh "$RESOURCE_SCOPE" "$resource-native" \ + http://parity-query-authority-native:8080 "$sdk" "$evidence/rust" rust > "$evidence/rust-tests.txt" +jq -s --arg backend "$backend" \ + '{outcome:"pass",backend:$backend,originals:length,cases:map({target,mode,protocol,namespace,run_id,original_run_status, + elapsed_seconds,worker_exit,history_sha256,sdk_source,runner_sha256})}' \ + <(for target in php rust; do + jq -c --arg target "$target" '.+{target:$target}' "$evidence/$target/"*-1*.json + done) > "$evidence/comparison.json" +test "$(jq -r .originals "$evidence/comparison.json")" = "$(jq '.cases|length*2' "$profile")" +cat "$evidence/comparison.json" diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index 373a958f1..52d487356 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -262,13 +262,9 @@ private function observe(string $uri, ?array $reply): ?array } $drained = pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0; } - try { - if ($handle !== null && $active) { - $client->terminateWorkflow($workflowId, 'query authority qualification complete', $handle->selectedRunId); - } - } finally { - unlink($journal); - } + // The caller destroys this profile's isolated database. Query authority + // does not depend on a separate workflow-termination capability. + unlink($journal); queryCheck($pid > 0 && $drained, 'The real Worker did not exit zero and deregister within the original observation.'); } queryCheck(hrtime(true) / 1e9 < $until, 'Cleanup exceeded the original observation budget.'); diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index 50da1accb..2e44f90a6 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -1,9 +1,11 @@ # Current query authority profile This separate profile uses normally installed PHP SDK 2.4.2 and published Server -2.8.0. It does not replace the frozen query actors. The four published PHP cases -pass locally on isolated MySQL 8.4. Native comparison and all-six qualification -remain pending. Main does not advertise native query authority. The unpublished +2.8.0. It does not replace the frozen query actors. Four completed-workflow cases +pass on both targets using separate MySQL 8.4 databases. The earlier active +native original failed during termination cleanup and remains failed. The +revised five-case profile and all-six qualification remain pending. +Main does not advertise native query authority. The unpublished candidate advertises it for installed-consumer qualification before promotion. The owning contract is [Server #433](https://github.com/durable-workflow/server/issues/433). @@ -18,16 +20,26 @@ where `TARGET` is `php` or `rust`. Set a unique `RESOURCE_SCOPE` and the isolate token before invoking it. It refuses an existing case output instead of overwriting a failed original. +The hosted entry point `query-authority-cohort.sh BACKEND` creates separate PHP +and native databases, installs the locked SDK normally, and runs all five cases +against each target. `BACKEND` is `sqlite`, `mysql` or `pgsql`; the existing six +database jobs provide their pinned database images and isolated network. It +reuses the job's compiled native binary. Its separate always-step invokes +`query-authority-cleanup.sh BACKEND`, which checks ownership, stops both APIs, +records their actual exit states, and removes the two databases and SDK state. +The small comparison keeps original run identities and history hashes; raw +query snapshots are excluded from retained job summaries. + Both services must use separate disposable databases, shared process-visible polling caches and `DW_QUERY_TASK_TIMEOUT=4`. The caller owns bootstrap, readiness and cleanup. Run each declared case once against each target. Every original has one unchanged 30-second observation budget covering setup, query, Worker drain and deregistration. The caller must also bound container lifetime and remove its resources after success or failure. -The Worker and workflow stop within each original budget. The caller destroys -the entire isolated database afterward, including its task namespaces. This -profile does not qualify namespace DELETE, which is still absent in native -source. It makes the same cleanup calls on both targets. +The Worker stops within each original budget. The caller destroys the entire +isolated database afterward, including its active workflow and task namespaces. +This profile does not qualify workflow termination or namespace DELETE, which +are still absent in native source. It makes the same cleanup calls on both targets. Startup waits for the installed Worker's `worker.registered` diagnostic, emitted after its actual registration acknowledgement. It does not depend on worker listing, which is also outside the current native slice. @@ -45,7 +57,9 @@ renewal. Protocol 1.19 makes no status request. Every query preserves the exact workflow history and the Worker must exit zero. The raw delivery is retained. PHP may omit its namespace member, as supported by the published SDK, in which case the authenticated Client namespace applies. -An explicit conflicting namespace still fails. +An explicit conflicting namespace still fails. The earlier failed originals and +the exact four-case match are recorded in +[the owning query checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). External query loading, Python/Rust consumers, remaining route/pressure and continuation boundaries, all-six integration and capability qualification remain From 00268f8a3d04efb344b0ad1410fd79a303281f1b Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:49:28 +0000 Subject: [PATCH 13/36] Observe Worker acknowledgements within the original query budget --- .../server-parity/query-authority/worker.php | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index 52d487356..5e3fe3813 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -118,8 +118,19 @@ private function observe(string $uri, ?array $reply): ?array file_put_contents($journal, json_encode(['event' => $event, 'observed_at' => microtime(true), 'details' => $details], JSON_THROW_ON_ERROR)."\n", FILE_APPEND | LOCK_EX); }; -$read = static fn (): array => array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), - file($journal, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); +$read = static function () use ($journal): array { + $file = fopen($journal, 'rb'); + queryCheck(is_resource($file), 'Could not read the original Worker journal.'); + try { + queryCheck(flock($file, LOCK_SH), 'Could not lock the original Worker journal.'); + $bytes = stream_get_contents($file); + } finally { + flock($file, LOCK_UN); + fclose($file); + } + return array_map(static fn (string $line): array => json_decode($line, true, flags: JSON_THROW_ON_ERROR), + array_values(array_filter(explode("\n", $bytes), static fn (string $line): bool => $line !== ''))); +}; $pid = 0; $handle = null; $result = null; @@ -187,6 +198,16 @@ private function observe(string $uri, ?array $reply): ?array $expected = ['long' => 9007199254740993, 'nil' => null, 'text' => 'rangeλ']; if ($timely) { queryCheck($handle->query('value') === $expected, 'The actual typed query result changed.'); + // The independent caller can observe the commit before the Worker + // receives its acknowledgement. Wait inside the original budget. + do { + $events = $read(); + queryCheck(!in_array('actor-error', array_column($events, 'event'), true), 'The Worker refused the original outcome.'); + if (in_array('acknowledgement', array_column($events, 'event'), true)) { + break; + } + usleep(20000); + } while (hrtime(true) / 1e9 < $until); } else { try { $handle->query('value'); @@ -227,7 +248,7 @@ private function observe(string $uri, ?array $reply): ?array queryCheck(($named('status') !== []) === ($protocol === '1.20'), 'Protocol status use does not match the declared boundary.'); queryCheck(count($named('handler')) === ($mode === 'late-poll' ? 0 : 1), 'The callback dispatched outside its original authority.'); queryCheck(count($named('submission')) === ($timely ? 1 : 0), 'An expired callback attempted a terminal submission.'); - queryCheck(count($named('acknowledgement')) === ($timely ? 1 : 0), 'An expired callback submitted a terminal outcome.'); + queryCheck(count($named('acknowledgement')) === ($timely ? 1 : 0), 'The original Worker acknowledgement count changed.'); queryCheck($before === $client->workflowHistory($workflowId, $handle->selectedRunId), 'Query execution changed the original workflow history.'); queryCheck($named('actor-error') === [] && hrtime(true) / 1e9 < $until, 'The original Worker observation failed.'); $result = ['outcome' => 'pass', 'mode' => $mode, 'protocol' => $protocol, 'workflow_id' => $workflowId, From 8e4cb2efc4d1d9b9aa543fe6ebfff1dc96747660 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:52:01 +0000 Subject: [PATCH 14/36] Record the installed SQLite query authority checkpoint --- docs/rust-server-port.md | 6 +++++- .../ServerParityProfiles/query-authority/README.md | 9 +++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 978c20eed..3f8f32b2a 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -86,7 +86,11 @@ The current integration order is: and histories survive, with zero late submissions and eight zero-exit Workers. Its fifth active native original remains failed at unsupported termination cleanup. The revised query-only observer delegates database retirement to - the caller; its new five-case profile and all-six execution remain pending. + the caller. Its revised five-case profile passes all ten fresh SQLite originals + with the same four-second query and thirty-second observation budgets; all-six + execution remains pending. The first PHP SQLite observer raced the Worker's + acknowledgement and stays failed. The revised observer waits for that actual + acknowledgement inside its original budget. Earlier worker-list and named-timer originals remain failed. Main keeps `query_task_authority=false`; the unpublished candidate advertises it for actual SDK qualification before promotion. Full namespace CRUD remains diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index 2e44f90a6..e3e8af18a 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -1,10 +1,11 @@ # Current query authority profile This separate profile uses normally installed PHP SDK 2.4.2 and published Server -2.8.0. It does not replace the frozen query actors. Four completed-workflow cases -pass on both targets using separate MySQL 8.4 databases. The earlier active -native original failed during termination cleanup and remains failed. The -revised five-case profile and all-six qualification remain pending. +2.8.0. It does not replace the frozen query actors. The revised five-case profile +passes on both targets using separate SQLite databases. Earlier four-case +MySQL observations are separate. The earlier active native termination-cleanup +failure and PHP acknowledgement-observer race remain failed. All-six +qualification is pending. Main does not advertise native query authority. The unpublished candidate advertises it for installed-consumer qualification before promotion. From 8e54941639ef31f39fd15c55a505cb4bcd8e07f7 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 04:55:53 +0000 Subject: [PATCH 15/36] Make query cleanup resource ownership explicit to the isolation checker --- .../server-parity/query-authority-cleanup.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority-cleanup.sh b/scripts/conformance/server-parity/query-authority-cleanup.sh index fe883a019..b22e4df55 100755 --- a/scripts/conformance/server-parity/query-authority-cleanup.sh +++ b/scripts/conformance/server-parity/query-authority-cleanup.sh @@ -13,22 +13,22 @@ result=0 profile_status=$(cat "$evidence/profile-status.txt" 2>/dev/null || printf incomplete) for target in php rust; do while IFS=$'\t' read -r mode protocol; do - name="$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" - if docker inspect "$name" >/dev/null 2>&1; then docker rm -f "$name" >/dev/null || result=1; fi + if docker inspect "$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" >/dev/null 2>&1; then + docker rm -f "$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" >/dev/null || result=1 + fi done < <(jq -r '.cases[]|[.mode,.protocol]|@tsv' "$profile") done for target in php native; do - name="$RESOURCE_SCOPE-query-authority-$target" - if docker inspect "$name" >/dev/null 2>&1; then - docker stop --signal=SIGTERM --timeout=10 "$name" > "$evidence/$target-stop.txt" 2>&1 || true - docker logs "$name" > "$evidence/$target-server.log" 2>&1 - docker inspect --format '{{json .State}}' "$name" > "$evidence/$target-state.json" + if docker inspect "$RESOURCE_SCOPE-query-authority-$target" >/dev/null 2>&1; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-authority-$target" >"$evidence/$target-stop.txt" 2>&1 || true + docker logs "$RESOURCE_SCOPE-query-authority-$target" > "$evidence/$target-server.log" 2>&1 + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-authority-$target" > "$evidence/$target-state.json" if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null; then cat "$evidence/$target-state.json" result=1 fi - docker rm -f "$name" >/dev/null || result=1 - if docker inspect "$name" >/dev/null 2>&1; then result=1; fi + docker rm -f "$RESOURCE_SCOPE-query-authority-$target" >/dev/null || result=1 + if docker inspect "$RESOURCE_SCOPE-query-authority-$target" >/dev/null 2>&1; then result=1; fi elif test "$profile_status" = 0; then if ! jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/$target-state.json" >/dev/null 2>&1; then printf '%s\n' "Completed query profile has no successful $target shutdown receipt" >&2 From a5f3a3e85e96ff9cf7a9cbecd26a7f277bd68908 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:08:49 +0000 Subject: [PATCH 16/36] Specify named timer identity and cold recovery before enabling admission --- rust/tests/execution.rs | 3 + rust/tests/execution/named_timers.rs | 139 +++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 rust/tests/execution/named_timers.rs diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index 39cc04d3c..d22329f5e 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -37,6 +37,9 @@ mod role_tokens; #[path = "execution/namespaces.rs"] mod namespaces; +#[path = "execution/named_timers.rs"] +mod named_timers; + #[path = "execution/selected_run_signals.rs"] mod selected_run_signals; diff --git a/rust/tests/execution/named_timers.rs b/rust/tests/execution/named_timers.rs new file mode 100644 index 000000000..806c56858 --- /dev/null +++ b/rust/tests/execution/named_timers.rs @@ -0,0 +1,139 @@ +use super::*; +use super::namespaces::{create, scoped, start_scoped}; + +async fn register_timer_worker(app: &Router, namespace: &str) { + let (status, body) = scoped(app, namespace, "POST", "/api/worker/register", json!({ + "worker_id":"timer-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[] + })).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); +} + +async fn claim(app: &Router, namespace: &str, request_id: &str) -> Value { + let (status, body) = scoped(app, namespace, "POST", "/api/worker/workflow-tasks/poll", json!({ + "worker_id":"timer-worker","task_queue":"test","timeout_seconds":0, + "poll_request_id":request_id + })).await; + assert_eq!(status, StatusCode::OK, "{body}"); + body["task"].clone() +} + +async fn finish(app: &Router, namespace: &str, task: &Value, commands: Value) -> (StatusCode, Value) { + scoped(app, namespace, "POST", &format!("/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap()), completion(task, commands)).await +} + +#[tokio::test] +async fn named_timers_keep_original_namespaces_across_cold_two_node_recovery() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(35); + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let mut originals = Vec::new(); + for namespace in ["alpha", "beta", "default"] { + if namespace != "default" { create(&app, namespace).await; } + register_timer_worker(&app, namespace).await; + let workflow = format!("named-timer-{namespace}"); + let run = start_scoped(&app, namespace, &workflow).await; + let task = claim(&app, namespace, "original-claim").await; + assert_eq!(task["run_id"], run["run_id"]); + originals.push((namespace, workflow, run, task)); + } + let mut snapshots = Vec::new(); + for (namespace, workflow, run, task) in &originals { + let commands = json!([{"type":"start_timer","delay_seconds":10}]); + let accepted = finish(&app, namespace, task, commands.clone()).await; + assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); + assert_eq!(finish(&app, namespace, task, commands).await.1["recorded"], false); + let path = format!("/api/workflows/{workflow}/runs/{}/history", run["run_id"].as_str().unwrap()); + let before = scoped(&app, namespace, "GET", &path, Value::Null).await; + assert_eq!(before.0, StatusCode::OK, "{}", before.1); + assert_eq!(before.1["events"].as_array().unwrap().len(), 3); + assert_eq!(before.1["events"][2]["event_type"], "TimerScheduled"); + snapshots.push((path, before.1)); + } + runtime.close().await; + let (a, b) = tokio::join!(database.open(), database.open()); + let (a, b) = (a.unwrap(), b.unwrap()); + let app_a = router(a.clone()); + let app_b = router(b.clone()); + for ((namespace, _, run, _), (path, before)) in originals.iter().zip(&snapshots) { + let fired = loop { + let response = scoped(&app_a, namespace, "GET", path, Value::Null).await; + assert_eq!(response.0, StatusCode::OK, "{}", response.1); + if response.1["events"].as_array().unwrap().len() == 4 { break response.1; } + assert!(tokio::time::Instant::now() < deadline, "original timer never fired: {}", response.1); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + assert_eq!(&fired["events"].as_array().unwrap()[..3], before["events"].as_array().unwrap()); + let scheduled = &before["events"][2]["payload"]; + let event = &fired["events"][3]; + assert_eq!(event["event_type"], "TimerFired"); + for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { + assert_eq!(event["payload"][field], scheduled[field], "{field}"); + } + assert!(chrono::DateTime::parse_from_rfc3339(event["payload"]["fired_at"].as_str().unwrap()).unwrap() + >= chrono::DateTime::parse_from_rfc3339(scheduled["fire_at"].as_str().unwrap()).unwrap()); + let (first, second) = tokio::join!(claim(&app_a, namespace, "recovered-a"), claim(&app_b, namespace, "recovered-b")); + assert_ne!(first.is_null(), second.is_null()); + let task = if first.is_null() { second } else { first }; + assert_eq!(task["run_id"], run["run_id"]); + assert_eq!(task["workflow_task_attempt"], 1); + let foreign = if *namespace == "alpha" { "beta" } else { "alpha" }; + let commands = json!([{"type":"complete_workflow","result":envelope(Payload::Long(9007199254740993))}]); + assert_eq!(finish(&app_b, foreign, &task, commands.clone()).await.0, StatusCode::NOT_FOUND); + assert_eq!(scoped(&app_a, namespace, "GET", path, Value::Null).await.1, fired); + let completed = finish(&app_b, namespace, &task, commands).await; + assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); + // Other namespaces still have due work on this exact queue/worker ID. + assert!(claim(&app_a, namespace, "no-foreign-delivery").await.is_null()); + } + tokio::time::sleep(Duration::from_millis(300)).await; + for ((namespace, _, _, _), (path, before)) in originals.iter().zip(&snapshots) { + let after = scoped(&app_b, namespace, "GET", path, Value::Null).await.1; + assert_eq!(after["events"].as_array().unwrap().len(), 5); + assert_eq!(&after["events"].as_array().unwrap()[..3], before["events"].as_array().unwrap()); + assert_eq!(after["events"][4]["event_type"], "WorkflowCompleted"); + } + a.close().await; + b.close().await; + database.remove().await; +} + +#[tokio::test] +async fn named_timer_invalid_batch_is_atomic_and_zero_delay_keeps_its_identity() { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + create(&app, "alpha").await; + register_timer_worker(&app, "alpha").await; + let run = start_scoped(&app, "alpha", "named-timer-atomic").await; + let task = claim(&app, "alpha", "original-claim").await; + let path = format!("/api/workflows/named-timer-atomic/runs/{}/history", run["run_id"].as_str().unwrap()); + let before = scoped(&app, "alpha", "GET", &path, Value::Null).await; + let refused = finish(&app, "alpha", &task, json!([ + {"type":"start_timer","delay_seconds":1},{"type":"start_timer","delay_seconds":-1} + ])).await; + assert_eq!(refused.0, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(refused.1["reason"], "invalid_timer_delay"); + assert_eq!(scoped(&app, "alpha", "GET", &path, Value::Null).await, before); + let scheduled = finish(&app, "alpha", &task, json!([{"type":"start_timer","delay_seconds":0}])).await; + assert_eq!(scheduled.0, StatusCode::OK, "{}", scheduled.1); + let history = loop { + let history = scoped(&app, "alpha", "GET", &path, Value::Null).await.1; + if history["events"].as_array().unwrap().len() == 4 { break history; } + assert!(tokio::time::Instant::now() < deadline, "zero timer never fired: {history}"); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { + assert_eq!(history["events"][2]["payload"][field], history["events"][3]["payload"][field]); + } + assert_eq!(history["events"][2]["payload"]["delay_seconds"], 0); + let resumed = claim(&app, "alpha", "zero-timer-wakeup").await; + assert_eq!(resumed["run_id"], run["run_id"]); + assert_eq!(finish(&app, "alpha", &resumed, json!([{"type":"complete_workflow","result":envelope(Payload::Null)}])).await.0, + StatusCode::OK); + runtime.close().await; + database.remove().await; +} From ccac8e928465c8e88f6209294919fd6debb27264 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:13:47 +0000 Subject: [PATCH 17/36] Format named timer regressions for the original runtime counterfactual --- rust/tests/execution/named_timers.rs | 179 +++++++++++++++++++++------ 1 file changed, 142 insertions(+), 37 deletions(-) diff --git a/rust/tests/execution/named_timers.rs b/rust/tests/execution/named_timers.rs index 806c56858..4cdef648c 100644 --- a/rust/tests/execution/named_timers.rs +++ b/rust/tests/execution/named_timers.rs @@ -1,26 +1,55 @@ -use super::*; use super::namespaces::{create, scoped, start_scoped}; +use super::*; +use std::time::Duration; async fn register_timer_worker(app: &Router, namespace: &str) { - let (status, body) = scoped(app, namespace, "POST", "/api/worker/register", json!({ - "worker_id":"timer-worker","task_queue":"test","runtime":"php", - "supported_workflow_types":["echo"],"supported_activity_types":[] - })).await; + let (status, body) = scoped( + app, + namespace, + "POST", + "/api/worker/register", + json!({ + "worker_id":"timer-worker","task_queue":"test","runtime":"php", + "supported_workflow_types":["echo"],"supported_activity_types":[] + }), + ) + .await; assert_eq!(status, StatusCode::CREATED, "{body}"); } async fn claim(app: &Router, namespace: &str, request_id: &str) -> Value { - let (status, body) = scoped(app, namespace, "POST", "/api/worker/workflow-tasks/poll", json!({ - "worker_id":"timer-worker","task_queue":"test","timeout_seconds":0, - "poll_request_id":request_id - })).await; + let (status, body) = scoped( + app, + namespace, + "POST", + "/api/worker/workflow-tasks/poll", + json!({ + "worker_id":"timer-worker","task_queue":"test","timeout_seconds":0, + "poll_request_id":request_id + }), + ) + .await; assert_eq!(status, StatusCode::OK, "{body}"); body["task"].clone() } -async fn finish(app: &Router, namespace: &str, task: &Value, commands: Value) -> (StatusCode, Value) { - scoped(app, namespace, "POST", &format!("/api/worker/workflow-tasks/{}/complete", - task["task_id"].as_str().unwrap()), completion(task, commands)).await +async fn finish( + app: &Router, + namespace: &str, + task: &Value, + commands: Value, +) -> (StatusCode, Value) { + scoped( + app, + namespace, + "POST", + &format!( + "/api/worker/workflow-tasks/{}/complete", + task["task_id"].as_str().unwrap() + ), + completion(task, commands), + ) + .await } #[tokio::test] @@ -31,7 +60,9 @@ async fn named_timers_keep_original_namespaces_across_cold_two_node_recovery() { let app = router(runtime.clone()); let mut originals = Vec::new(); for namespace in ["alpha", "beta", "default"] { - if namespace != "default" { create(&app, namespace).await; } + if namespace != "default" { + create(&app, namespace).await; + } register_timer_worker(&app, namespace).await; let workflow = format!("named-timer-{namespace}"); let run = start_scoped(&app, namespace, &workflow).await; @@ -44,8 +75,14 @@ async fn named_timers_keep_original_namespaces_across_cold_two_node_recovery() { let commands = json!([{"type":"start_timer","delay_seconds":10}]); let accepted = finish(&app, namespace, task, commands.clone()).await; assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1); - assert_eq!(finish(&app, namespace, task, commands).await.1["recorded"], false); - let path = format!("/api/workflows/{workflow}/runs/{}/history", run["run_id"].as_str().unwrap()); + assert_eq!( + finish(&app, namespace, task, commands).await.1["recorded"], + false + ); + let path = format!( + "/api/workflows/{workflow}/runs/{}/history", + run["run_id"].as_str().unwrap() + ); let before = scoped(&app, namespace, "GET", &path, Value::Null).await; assert_eq!(before.0, StatusCode::OK, "{}", before.1); assert_eq!(before.1["events"].as_array().unwrap().len(), 3); @@ -61,38 +98,71 @@ async fn named_timers_keep_original_namespaces_across_cold_two_node_recovery() { let fired = loop { let response = scoped(&app_a, namespace, "GET", path, Value::Null).await; assert_eq!(response.0, StatusCode::OK, "{}", response.1); - if response.1["events"].as_array().unwrap().len() == 4 { break response.1; } - assert!(tokio::time::Instant::now() < deadline, "original timer never fired: {}", response.1); + if response.1["events"].as_array().unwrap().len() == 4 { + break response.1; + } + assert!( + tokio::time::Instant::now() < deadline, + "original timer never fired: {}", + response.1 + ); tokio::time::sleep(Duration::from_millis(50)).await; }; - assert_eq!(&fired["events"].as_array().unwrap()[..3], before["events"].as_array().unwrap()); + assert_eq!( + &fired["events"].as_array().unwrap()[..3], + before["events"].as_array().unwrap().as_slice() + ); let scheduled = &before["events"][2]["payload"]; let event = &fired["events"][3]; assert_eq!(event["event_type"], "TimerFired"); for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { assert_eq!(event["payload"][field], scheduled[field], "{field}"); } - assert!(chrono::DateTime::parse_from_rfc3339(event["payload"]["fired_at"].as_str().unwrap()).unwrap() - >= chrono::DateTime::parse_from_rfc3339(scheduled["fire_at"].as_str().unwrap()).unwrap()); - let (first, second) = tokio::join!(claim(&app_a, namespace, "recovered-a"), claim(&app_b, namespace, "recovered-b")); + assert!( + chrono::DateTime::parse_from_rfc3339(event["payload"]["fired_at"].as_str().unwrap()) + .unwrap() + >= chrono::DateTime::parse_from_rfc3339(scheduled["fire_at"].as_str().unwrap()) + .unwrap() + ); + let (first, second) = tokio::join!( + claim(&app_a, namespace, "recovered-a"), + claim(&app_b, namespace, "recovered-b") + ); assert_ne!(first.is_null(), second.is_null()); let task = if first.is_null() { second } else { first }; assert_eq!(task["run_id"], run["run_id"]); assert_eq!(task["workflow_task_attempt"], 1); - let foreign = if *namespace == "alpha" { "beta" } else { "alpha" }; + let foreign = if *namespace == "alpha" { + "beta" + } else { + "alpha" + }; let commands = json!([{"type":"complete_workflow","result":envelope(Payload::Long(9007199254740993))}]); - assert_eq!(finish(&app_b, foreign, &task, commands.clone()).await.0, StatusCode::NOT_FOUND); - assert_eq!(scoped(&app_a, namespace, "GET", path, Value::Null).await.1, fired); + assert_eq!( + finish(&app_b, foreign, &task, commands.clone()).await.0, + StatusCode::NOT_FOUND + ); + assert_eq!( + scoped(&app_a, namespace, "GET", path, Value::Null).await.1, + fired + ); let completed = finish(&app_b, namespace, &task, commands).await; assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); // Other namespaces still have due work on this exact queue/worker ID. - assert!(claim(&app_a, namespace, "no-foreign-delivery").await.is_null()); + assert!( + claim(&app_a, namespace, "no-foreign-delivery") + .await + .is_null() + ); } tokio::time::sleep(Duration::from_millis(300)).await; for ((namespace, _, _, _), (path, before)) in originals.iter().zip(&snapshots) { let after = scoped(&app_b, namespace, "GET", path, Value::Null).await.1; assert_eq!(after["events"].as_array().unwrap().len(), 5); - assert_eq!(&after["events"].as_array().unwrap()[..3], before["events"].as_array().unwrap()); + assert_eq!( + &after["events"].as_array().unwrap()[..3], + before["events"].as_array().unwrap().as_slice() + ); assert_eq!(after["events"][4]["event_type"], "WorkflowCompleted"); } a.close().await; @@ -110,30 +180,65 @@ async fn named_timer_invalid_batch_is_atomic_and_zero_delay_keeps_its_identity() register_timer_worker(&app, "alpha").await; let run = start_scoped(&app, "alpha", "named-timer-atomic").await; let task = claim(&app, "alpha", "original-claim").await; - let path = format!("/api/workflows/named-timer-atomic/runs/{}/history", run["run_id"].as_str().unwrap()); + let path = format!( + "/api/workflows/named-timer-atomic/runs/{}/history", + run["run_id"].as_str().unwrap() + ); let before = scoped(&app, "alpha", "GET", &path, Value::Null).await; - let refused = finish(&app, "alpha", &task, json!([ - {"type":"start_timer","delay_seconds":1},{"type":"start_timer","delay_seconds":-1} - ])).await; + let refused = finish( + &app, + "alpha", + &task, + json!([ + {"type":"start_timer","delay_seconds":1},{"type":"start_timer","delay_seconds":-1} + ]), + ) + .await; assert_eq!(refused.0, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(refused.1["reason"], "invalid_timer_delay"); - assert_eq!(scoped(&app, "alpha", "GET", &path, Value::Null).await, before); - let scheduled = finish(&app, "alpha", &task, json!([{"type":"start_timer","delay_seconds":0}])).await; + assert_eq!( + scoped(&app, "alpha", "GET", &path, Value::Null).await, + before + ); + let scheduled = finish( + &app, + "alpha", + &task, + json!([{"type":"start_timer","delay_seconds":0}]), + ) + .await; assert_eq!(scheduled.0, StatusCode::OK, "{}", scheduled.1); let history = loop { let history = scoped(&app, "alpha", "GET", &path, Value::Null).await.1; - if history["events"].as_array().unwrap().len() == 4 { break history; } - assert!(tokio::time::Instant::now() < deadline, "zero timer never fired: {history}"); + if history["events"].as_array().unwrap().len() == 4 { + break history; + } + assert!( + tokio::time::Instant::now() < deadline, + "zero timer never fired: {history}" + ); tokio::time::sleep(Duration::from_millis(20)).await; }; for field in ["timer_id", "sequence", "delay_seconds", "fire_at"] { - assert_eq!(history["events"][2]["payload"][field], history["events"][3]["payload"][field]); + assert_eq!( + history["events"][2]["payload"][field], + history["events"][3]["payload"][field] + ); } assert_eq!(history["events"][2]["payload"]["delay_seconds"], 0); let resumed = claim(&app, "alpha", "zero-timer-wakeup").await; assert_eq!(resumed["run_id"], run["run_id"]); - assert_eq!(finish(&app, "alpha", &resumed, json!([{"type":"complete_workflow","result":envelope(Payload::Null)}])).await.0, - StatusCode::OK); + assert_eq!( + finish( + &app, + "alpha", + &resumed, + json!([{"type":"complete_workflow","result":envelope(Payload::Null)}]) + ) + .await + .0, + StatusCode::OK + ); runtime.close().await; database.remove().await; } From 6e2d825cf0ef9e0e1d87526f21a81595b0087544 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:17:03 +0000 Subject: [PATCH 18/36] Fire named workflow timers in their persisted namespace --- rust/src/runtime/backend.rs | 6 +++--- rust/src/runtime/store.rs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/rust/src/runtime/backend.rs b/rust/src/runtime/backend.rs index 88ef19d28..b91ca99c4 100644 --- a/rust/src/runtime/backend.rs +++ b/rust/src/runtime/backend.rs @@ -115,7 +115,7 @@ ORDER BY t.available_order,t.id LIMIT 100 type EncodedTime = String; type EncodedOptionalTime = Option; type EncodedDocument = String; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace='default' AND task_type='timer' AND status='ready' AND json_extract(payload,'$.timer_id')=$2"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' AND status='ready' AND json_extract(payload,'$.timer_id')=$2 AND namespace=$3"; const HISTORY_PAGE: &'static str = "SELECT * FROM (SELECT *,SUM(length(CAST(payload AS BLOB))) OVER (ORDER BY sequence) AS page_bytes FROM workflow_history_events WHERE workflow_run_id=$1 AND sequence>$2 ORDER BY sequence LIMIT $3) AS page WHERE page_bytes<=8388608 ORDER BY sequence"; // Normalize only the two supported UTC storage shapes. Removing trailing // fractional zeros preserves exact decimal ordering, including earlier @@ -234,7 +234,7 @@ impl Backend for Postgres { type EncodedTime = NaiveDateTime; type EncodedOptionalTime = Option; type EncodedDocument = PostgresDocument; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND namespace='default' AND task_type='timer' AND status='ready' AND payload->>'timer_id'=$2"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' AND status='ready' AND payload->>'timer_id'=$2 AND namespace=$3"; const HISTORY_PAGE: &'static str = "SELECT * FROM (SELECT *,SUM(octet_length(payload::text)) OVER (ORDER BY sequence) AS page_bytes FROM workflow_history_events WHERE workflow_run_id=$1 AND sequence>$2 ORDER BY sequence LIMIT $3) AS page WHERE page_bytes<=8388608 ORDER BY sequence"; fn bind_time(time: DateTime) -> NaiveDateTime { time.naive_utc() @@ -333,7 +333,7 @@ impl Backend for MySql { const QUERY_CACHE_UPDATE: &'static str = "UPDATE dw_query_cache SET value=$1,expiration=$2 WHERE `key`=$3"; const QUERY_CACHE_DELETE: &'static str = "DELETE FROM dw_query_cache WHERE `key`=$1"; - const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=? AND namespace='default' AND task_type='timer' AND status='ready' AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.timer_id'))=?"; + const TIMER_TASK_SQL: &'static str = "SELECT id,payload FROM workflow_tasks WHERE workflow_run_id=? AND task_type='timer' AND status='ready' AND JSON_UNQUOTE(JSON_EXTRACT(payload,'$.timer_id'))=? AND BINARY namespace=BINARY ?"; // First use the preserved run/sequence index to materialize only IDs and // byte counts for this bounded page. Sorting JSON payloads in the window // exhausts MySQL's default sort buffer before the size rejection can run. diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index f9e1cc888..648a19bc5 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -1452,7 +1452,7 @@ where if self.namespace.as_ref() != "default" && !matches!( text(command, "type")?, - "complete_workflow" | "schedule_activity" + "complete_workflow" | "schedule_activity" | "start_timer" ) && !(text(command, "type")? == "open_condition_wait" && commands.len() == 1) { @@ -2115,7 +2115,9 @@ where // New timers use the canonical PHP timestamp shape on SQLite and typed // UTC values elsewhere. Existing PHP data still cannot be adopted. // The read probe avoids acquiring the transition lock when idle. - const DUE: &str = "SELECT t.*,r.queue FROM workflow_run_timers t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE r.namespace='default' AND r.status IN ('running','waiting') AND t.status='pending' AND t.fire_at<=$1 ORDER BY t.fire_at,t.id LIMIT 50"; + // Maintenance visits every namespace; each timer's persisted run + // supplies its namespace, independently of the store that woke it. + const DUE: &str = "SELECT t.*,r.queue,r.namespace AS run_namespace FROM workflow_run_timers t JOIN workflow_runs r ON r.id=t.workflow_run_id WHERE r.status IN ('running','waiting') AND t.status='pending' AND t.fire_at<=$1 ORDER BY t.fire_at,t.id LIMIT 50"; if Self::query(DUE) .bind(DB::bind_time(now())) .fetch_optional(&self.pool) @@ -2150,6 +2152,7 @@ where let task = Self::query(DB::TIMER_TASK_SQL) .bind(&run_id) .bind(&timer_id) + .bind(DB::string(&timer, "run_namespace")?) .fetch_one(&mut *tx) .await?; let task_id = DB::string(&task, "id")?; From 92d7210b7cf3f4290fb6c14254f2d076138789d3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:18:21 +0000 Subject: [PATCH 19/36] Add an installed query original on a waiting named timer workflow --- .../server-parity/query-authority/worker.php | 9 +++++---- .../ServerParityProfiles/query-authority/README.md | 14 ++++++++------ .../query-authority/artifacts.json | 3 ++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority/worker.php b/scripts/conformance/server-parity/query-authority/worker.php index 5e3fe3813..d659f2864 100644 --- a/scripts/conformance/server-parity/query-authority/worker.php +++ b/scripts/conformance/server-parity/query-authority/worker.php @@ -92,6 +92,7 @@ private function observe(string $uri, ?array $reply): ?array [$script, $url, $mode, $protocol] = $argv; queryCheck(in_array([$mode, $protocol], [ ['timely', '1.20'], ['timely', '1.19'], ['late-poll', '1.20'], ['late-handler', '1.20'], ['timely-active', '1.20'], + ['timely-named-active', '1.20'], ], true), 'Unknown query authority case.'); $artifacts = json_decode(file_get_contents(__DIR__.'/../../../../tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json'), true, flags: JSON_THROW_ON_ERROR); queryCheck(InstalledVersions::getPrettyVersion('durable-workflow/sdk') === $artifacts['sdk_php']['version'] @@ -104,12 +105,12 @@ private function observe(string $uri, ?array $reply): ?array $started = hrtime(true) / 1e9; $until = $started + $artifacts['original_observation_seconds']; $workflowId = 'parity-query-authority-'.bin2hex(random_bytes(6)); -$active = $mode === 'timely-active'; -$timely = in_array($mode, ['timely', 'timely-active'], true); -$namespace = $active ? 'default' : $workflowId; +$active = in_array($mode, ['timely-active', 'timely-named-active'], true); +$timely = in_array($mode, ['timely', 'timely-active', 'timely-named-active'], true); +$namespace = $mode === 'timely-active' ? 'default' : $workflowId; $token = getenv('DW_PARITY_TOKEN') ?: 'query-authority-fixture-token'; $admin = new Client($url, token: $token); -if (!$active) { +if ($namespace !== 'default') { $admin->createNamespace($namespace); } $client = new Client($url, namespace: $namespace, token: $token); diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index e3e8af18a..0fba3ede5 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -1,8 +1,9 @@ # Current query authority profile This separate profile uses normally installed PHP SDK 2.4.2 and published Server -2.8.0. It does not replace the frozen query actors. The revised five-case profile -passes on both targets using separate SQLite databases. Earlier four-case +2.8.0. It does not replace the frozen query actors. The earlier five-case profile +passes on both targets using separate SQLite databases. The additional named +waiting-workflow case is prepared and needs its own qualification. Earlier four-case MySQL observations are separate. The earlier active native termination-cleanup failure and PHP acknowledgement-observer race remain failed. All-six qualification is pending. @@ -22,7 +23,7 @@ token before invoking it. It refuses an existing case output instead of overwriting a failed original. The hosted entry point `query-authority-cohort.sh BACKEND` creates separate PHP -and native databases, installs the locked SDK normally, and runs all five cases +and native databases, installs the locked SDK normally, and runs every declared case against each target. `BACKEND` is `sqlite`, `mysql` or `pgsql`; the existing six database jobs provide their pinned database images and isolated network. It reuses the job's compiled native binary. Its separate always-step invokes @@ -46,9 +47,10 @@ after its actual registration acknowledgement. It does not depend on worker listing, which is also outside the current native slice. The four deadline cases use completed workflows in distinct named namespaces. -The additional timely-active case uses a genuine waiting timer workflow in the -default namespace. Native named-timer authoring still refuses explicitly and is -not qualified by this profile. Earlier waiting named originals remain failed. +The timely-active and timely-named-active cases use genuine waiting timer +workflows in the default namespace and a distinct named namespace respectively. +The latter covers named-timer admission and query delivery together. Earlier +waiting named originals remain failed; this new case cannot rescue them. Timely 1.20 and 1.19 controls retain the exact int64/null/Unicode result. Separate late controls hold a genuine poll reply for six seconds or let a synchronous callback return after six seconds. They require the original caller diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json index 3e48781aa..a272f7c06 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +++ b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json @@ -24,7 +24,8 @@ {"mode": "timely", "protocol": "1.19"}, {"mode": "late-poll", "protocol": "1.20"}, {"mode": "late-handler", "protocol": "1.20"}, - {"mode": "timely-active", "protocol": "1.20"} + {"mode": "timely-active", "protocol": "1.20"}, + {"mode": "timely-named-active", "protocol": "1.20"} ], "embedded": "inapplicable: service query claims and status have no embedded worker lease", "performance": "not_measured" From 2e4dab4b604ced0d61385c950a28dd30bb88edf1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:21:16 +0000 Subject: [PATCH 20/36] Record named timer and installed waiting-query observations --- docs/rust-server-port.md | 18 +++++++++++++++--- .../query-authority/README.md | 5 +++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 3f8f32b2a..f8505e41d 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -88,14 +88,26 @@ The current integration order is: cleanup. The revised query-only observer delegates database retirement to the caller. Its revised five-case profile passes all ten fresh SQLite originals with the same four-second query and thirty-second observation budgets; all-six - execution remains pending. The first PHP SQLite observer raced the Worker's + execution remains pending. A later six-case SQLite cohort passes all twelve + fresh originals, including a query on a genuine waiting named timer workflow. + That new case uses the same installed SDK, reference image and original budgets. + The first PHP SQLite observer raced the Worker's acknowledgement and stays failed. The revised observer waits for that actual acknowledgement inside its original budget. Earlier worker-list and named-timer originals remain failed. Main keeps `query_task_authority=false`; the unpublished candidate advertises it for actual SDK qualification before promotion. Full namespace CRUD remains - open, including the absent native DELETE route. Worker listing, named timer - authoring and termination are also outside this query slice. See the + open, including the absent native DELETE route. Worker listing and termination + remain outside this query slice. The narrow named-timer correction admits the + existing timer command and carries its persisted namespace through global + maintenance and backend task lookup. Both new baseline tests fail at the + former namespace refusal; both corrected cases pass on SQLite, MySQL 8.4 and + PostgreSQL 17, including cold two-node recovery, duplicate identity, foreign + completion refusal, atomic invalid batches and zero-delay firing. Three + existing default timer/selection controls also pass per backend. The initial + MySQL regression database hit its 768 MiB memory limit and remains failed; + its separate fresh 1.5 GiB database run passes with unchanged test deadlines. + These local controls do not establish full namespace or timer parity. See the [exact installed checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). The query integration also preserves #455's opt-in poll hints and #458's completion uploads. Hint readiness now reads the same immutable UTC6 query diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index 0fba3ede5..dc1047fbc 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -2,8 +2,9 @@ This separate profile uses normally installed PHP SDK 2.4.2 and published Server 2.8.0. It does not replace the frozen query actors. The earlier five-case profile -passes on both targets using separate SQLite databases. The additional named -waiting-workflow case is prepared and needs its own qualification. Earlier four-case +passes on both targets using separate SQLite databases. A fresh six-case cohort, +including the additional named waiting-workflow case, also passes on both +SQLite targets. Earlier four-case MySQL observations are separate. The earlier active native termination-cleanup failure and PHP acknowledgement-observer race remain failed. All-six qualification is pending. From accc79dd2e6fe3d23c2019ed5cfb056d8e9d05c2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:43:57 +0000 Subject: [PATCH 21/36] Prepare normal Python and Rust query authority consumers --- .../query-authority-clients-profile.sh | 86 + .../query-authority/observer.mjs | 120 ++ .../server-parity/query-authority/python.py | 128 ++ .../query-authority/requirements.txt | 10 + .../query-authority/rust/Cargo.lock | 1793 +++++++++++++++++ .../query-authority/rust/Cargo.toml | 16 + .../query-authority/rust/src/main.rs | 147 ++ .../query-authority/artifacts.json | 23 + 8 files changed, 2323 insertions(+) create mode 100755 scripts/conformance/server-parity/query-authority-clients-profile.sh create mode 100644 scripts/conformance/server-parity/query-authority/observer.mjs create mode 100644 scripts/conformance/server-parity/query-authority/python.py create mode 100644 scripts/conformance/server-parity/query-authority/requirements.txt create mode 100644 scripts/conformance/server-parity/query-authority/rust/Cargo.lock create mode 100644 scripts/conformance/server-parity/query-authority/rust/Cargo.toml create mode 100644 scripts/conformance/server-parity/query-authority/rust/src/main.rs diff --git a/scripts/conformance/server-parity/query-authority-clients-profile.sh b/scripts/conformance/server-parity/query-authority-clients-profile.sh new file mode 100755 index 000000000..cf737d863 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-clients-profile.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Run unchanged published clients behind a bounded, byte-preserving observer. +set -euo pipefail +test "$#" = 5 || exit 2 +network=$1 +url=$2 +python_sdk=$(realpath "$3") +output=$(realpath -m "$4") +target=$5 +case "$target" in php|rust) ;; *) exit 2 ;; esac +test "$network" = "${RESOURCE_SCOPE:?Set the isolated owning scope}" +source_root=$(git rev-parse --show-toplevel) +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +image=$(jq -r '.server.image' "$profile") +test ! -e "$output" +test -f "$python_sdk/durable_workflow/__init__.py" +test -f rust-target/debug/server-parity-query-authority-client +mkdir -p "$output" +jq -n --arg revision "$(git rev-parse HEAD)" --arg binary "$(sha256sum rust-target/debug/server-parity-query-authority-client | cut -d ' ' -f1)" \ + --arg observer "$(sha256sum scripts/conformance/server-parity/query-authority/observer.mjs | cut -d ' ' -f1)" \ + --arg profile "$(sha256sum "$profile" | cut -d ' ' -f1)" \ + '{runner_revision:$revision,consumer_binary_sha256:$binary,observer_sha256:$observer,profile_sha256:$profile}' >"$output/identity.json" +case_output='' +name='' +language='' +retire() { + local result=0 + if test -f "$case_output/client-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null 2>&1; then + test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-$target-$language-$name")" = "$(cat "$case_output/client-container.id")" || return 1 + docker rm -f "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null || result=1 + fi + if test -f "$case_output/observer-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then + test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-observer")" = "$(cat "$case_output/observer-container.id")" || return 1 + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer-stop.txt" 2>&1 || true + docker logs "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer.log" 2>&1 + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer-state.json" + jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$case_output/observer-state.json" >/dev/null || result=1 + docker rm -f "$RESOURCE_SCOPE-query-client-observer" >/dev/null || result=1 + fi + return "$result" +} +trap retire EXIT +for language in python rust; do + while IFS=$'\t' read -r mode protocol; do + name="$mode-${protocol//./}" + case_output="$output/$language-$name" + mkdir "$case_output" + if docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then exit 1; fi + docker run -d --name "$RESOURCE_SCOPE-query-client-observer" --network "$network" \ + --network-alias=parity-query-client-observer --user="$(id -u):$(id -g)" \ + --cpus=1 --memory=256m --memory-swap=256m --entrypoint node \ + -v "$source_root:/source:ro" -v "$case_output:/evidence" "$image" \ + /source/scripts/conformance/server-parity/query-authority/observer.mjs "$url" "$mode" /evidence/http.jsonl \ + >"$case_output/observer-container.id" + ready=0 + for attempt in $(seq 1 50); do + if docker logs "$RESOURCE_SCOPE-query-client-observer" 2>/dev/null | jq -e '.ready==true' >/dev/null; then ready=1; break; fi + sleep .05 + done + test "$ready" = 1 + client_image=$(jq -r --arg language "$language" '.client_images[$language]' "$profile") + if test "$language" = python; then + actor=(--entrypoint python -e PYTHONPATH=/sdk -e PYTHONDONTWRITEBYTECODE=1 \ + -v "$python_sdk:/sdk:ro" "$client_image" /source/scripts/conformance/server-parity/query-authority/python.py) + else + actor=(--entrypoint /target/debug/server-parity-query-authority-client -v "$PWD/rust-target:/target:ro" "$client_image") + fi + status=0 + timeout --signal=TERM --kill-after=5s 40s docker run --rm --cidfile "$case_output/client-container.id" \ + --name "$RESOURCE_SCOPE-query-client-$target-$language-$name" --network "$network" --user="$(id -u):$(id -g)" \ + --cpus=1 --memory=512m --memory-swap=512m -e DW_PARITY_TOKEN \ + -e DW_PARITY_QUERY_PROFILE=/source/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json \ + -v "$source_root:/source:ro" -v "$case_output:/evidence:ro" "${actor[@]}" \ + http://parity-query-client-observer:8080 "$mode" "$protocol" /evidence/http.jsonl \ + >"$case_output/result.json" 2>"$case_output/client.stderr" || status=$? + retire || status=1 + test "$status" = 0 + jq -e --arg language "$language" --arg mode "$mode" --arg protocol "$protocol" \ + '.outcome=="pass" and .language==$language and .mode==$mode and .protocol==$protocol + and .worker_exit==0 and .original_budget_seconds==30 and .elapsed_seconds<30' "$case_output/result.json" >/dev/null + jq '{outcome,language,mode,protocol,run_id,elapsed_seconds,worker_exit}' "$case_output/result.json" + done < <(jq -r '.client_cases[]|[.mode,.protocol]|@tsv' "$profile") +done +jq -s --arg target "$target" '{outcome:"pass",target:$target,originals:length,cases:map({language,mode,protocol, + namespace,run_id,elapsed_seconds,worker_exit,history_sha256})}' "$output/"*/result.json >"$output/comparison.json" +test "$(jq -r .originals "$output/comparison.json")" = "$(jq '.client_cases|length*2' "$profile")" diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs new file mode 100644 index 000000000..604308e75 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -0,0 +1,120 @@ +// Isolated test observer: forward original HTTP bytes, hold one actual poll +// response when requested, and retain only bounded authority metadata. +import http from 'node:http'; +import {appendFileSync} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {gunzipSync, inflateSync, brotliDecompressSync} from 'node:zlib'; + +const [target, mode, journal] = process.argv.slice(2); +if (!['timely', 'late-poll', 'late-handler-async', 'late-handler-sync'].includes(mode)) throw Error('Unknown case'); +const upstream = new URL(target); +if (upstream.protocol !== 'http:') throw Error('Use only an isolated HTTP target'); +let count = 0, retainedBytes = 0, held = false; +const authorityFields = ['query_task_id', 'query_task_attempt', 'lease_owner', 'task_queue', 'namespace', + 'query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time', 'can_continue', 'reason']; +const select = value => Object.fromEntries(authorityFields.filter(key => key in value).map(key => [key, value[key]])); +function record(event, details = {}) { + const line = JSON.stringify({event, observed_at: Date.now() / 1000, ...details}) + '\n'; + if (++count > 256 || (retainedBytes += Buffer.byteLength(line)) > 256 * 1024) throw Error('Observer limit exceeded'); + appendFileSync(journal, line); +} +function exchange(method, path, headers, body = Buffer.alloc(0)) { + return new Promise((resolve, reject) => { + const request = http.request(new URL(path, upstream), {method, headers: {...headers, host: upstream.host}}, incoming => { + const chunks = []; + let size = 0; + incoming.on('data', chunk => { + size += chunk.length; + if (size > 2 * 1024 * 1024) incoming.destroy(Error('Observer response limit exceeded')); + else chunks.push(chunk); + }); + incoming.on('error', reject); + incoming.on('end', () => resolve({status: incoming.statusCode, headers: incoming.headers, bytes: Buffer.concat(chunks)})); + }); + request.setTimeout(35_000, () => request.destroy(Error('Observer exchange timeout'))); + request.on('error', reject); + request.end(body); + }); +} +function json(reply) { + const options = {maxOutputLength: 2 * 1024 * 1024}; + const decoders = {identity: value => value, gzip: value => gunzipSync(value, options), + deflate: value => inflateSync(value, options), br: value => brotliDecompressSync(value, options)}; + const decoder = decoders[reply.headers['content-encoding'] ?? 'identity']; + if (!decoder) throw Error('Unknown observed encoding'); + return reply.bytes.length ? JSON.parse(decoder(reply.bytes).toString('utf8')) : {}; +} +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const server = http.createServer(async (request, response) => { + try { + const path = new URL(request.url, upstream).pathname; + const chunks = []; + let size = 0; + for await (const chunk of request) { + if ((size += chunk.length) > 128 * 1024) throw Error('Observer request limit exceeded'); + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + const headers = {...request.headers}; + const terminal = request.method === 'POST' && /\/query-tasks\/[^/]+\/(complete|fail)$/.test(path); + if (terminal) record('submission', {path}); + const query = request.method === 'POST' && /^\/api\/workflows\/[^/]+\/(?:runs\/[^/]+\/)?query\//.test(path); + let historyPath; + let before; + if (query) { + const workflowPath = path.replace(/\/query\/[^/]+$/, ''); + if (workflowPath.includes('/runs/')) historyPath = workflowPath + '/history'; + else { + const description = await exchange('GET', workflowPath, {...headers, 'content-length': '0'}); + if (description.status !== 200 || typeof json(description).run_id !== 'string') throw Error('Original run unavailable'); + historyPath = workflowPath + '/runs/' + encodeURIComponent(json(description).run_id) + '/history'; + } + const snapshot = await exchange('GET', historyPath, {...headers, 'content-length': '0'}); + if (snapshot.status !== 200) throw Error('Original history unavailable'); + before = sha(Buffer.from(JSON.stringify(json(snapshot)))); + } + const reply = await exchange(request.method, request.url, headers, body); + if (path === '/api/worker/register' && request.method === 'POST') record('registered', {status: reply.status}); + if (path.endsWith('/query-tasks/poll') && reply.status === 200) { + const task = json(reply).task; + if (task && typeof task === 'object') { + const utc6 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/; + for (const key of ['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time']) { + if (!utc6.test(task[key])) throw Error('Missing original UTC6 authority: ' + key); + } + if (Date.parse(task.query_deadline_at) - Date.parse(task.query_admitted_at) !== 4_000 + || Date.parse(task.lease_expires_at) > Date.parse(task.query_deadline_at) + || task.query_task_attempt !== 1 || !task.lease_owner) throw Error('Changed original query authority'); + record('claim', {protocol: request.headers['x-durable-workflow-protocol-version'], claim: select(task)}); + if (mode === 'late-poll') { + if (held) throw Error('A second original query was claimed'); + held = true; + record('held'); + await new Promise(resolve => setTimeout(resolve, 6_000)); + } + record('delivered'); + } + } else if (/\/query-tasks\/[^/]+\/status$/.test(path)) { + record('status', {status: reply.status, observation: select(json(reply))}); + } else if (terminal) { + record('acknowledgement', {status: reply.status, recorded: json(reply).recorded}); + } else if (request.method === 'DELETE' && path.startsWith('/api/worker')) { + record('deregistered', {status: reply.status}); + } + if (query) { + const snapshot = await exchange('GET', historyPath, {...headers, 'content-length': '0'}); + if (snapshot.status !== 200) throw Error('Original history disappeared'); + const after = sha(Buffer.from(JSON.stringify(json(snapshot)))); + if (after !== before) throw Error('Query changed the original workflow history'); + record('query-result', {status: reply.status, reason: json(reply).reason ?? null, history_sha256: before}); + } + response.writeHead(reply.status, reply.headers); + response.end(reply.bytes); + } catch (error) { + record('observer-error', {message: String(error)}); + response.destroy(error); + } +}); +server.listen(8080, '0.0.0.0', () => console.log(JSON.stringify({ready: true}))); +process.on('SIGTERM', () => server.close(() => process.exit(0))); + diff --git a/scripts/conformance/server-parity/query-authority/python.py b/scripts/conformance/server-parity/query-authority/python.py new file mode 100644 index 000000000..f59b25cfd --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/python.py @@ -0,0 +1,128 @@ +"""Normal installed Python Worker/client; the external observer holds real bytes.""" +import asyncio +import importlib.metadata +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +from durable_workflow import Client, Worker, workflow +from durable_workflow.errors import QueryFailed +import durable_workflow + +URL, MODE, PROTOCOL, JOURNAL = sys.argv[1:] +TYPE = 'parity.query-authority.python.v1' +EXPECTED = {'long': 9223372036854775807, 'nil': None, 'text': 'rangeλ'} +COUNTERS = {'calls': 0, 'returned': 0, 'cancelled': 0} + + +def events(): + text = Path(JOURNAL).read_text() if Path(JOURNAL).exists() else '' + # Only a complete line is an observation; the next read sees an append + # that was still in flight. No record or response is rewritten. + return [json.loads(line) for line in text.split('\n')[:-1] if line] + + +@workflow.defn(name=TYPE) +class QueryWorkflow: + def run(self, ctx, value): + self.value = value + yield ctx.sleep(60) + return value + + @workflow.query('value') + async def current(self): + COUNTERS['calls'] += 1 + assert self.value == 7 + try: + if MODE == 'late-handler-async': + await asyncio.sleep(6) + elif MODE == 'late-handler-sync': + time.sleep(6) + COUNTERS['returned'] += 1 + return EXPECTED + except asyncio.CancelledError: + COUNTERS['cancelled'] += 1 + raise + + +async def main(): + artifacts = json.loads(Path(os.environ['DW_PARITY_QUERY_PROFILE']).read_text()) + identity = artifacts['sdk_python'] + assert importlib.metadata.version('durable-workflow') == identity['version'] + package = Path(durable_workflow.__file__).parent + hashes = {str(path.relative_to(package)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in package.rglob('*') if path.is_file() and (path.suffix == '.py' or path.name == 'py.typed')} + assert len(hashes) == identity['loaded_files'] + assert hashlib.sha256(json.dumps(hashes, sort_keys=True, separators=(',', ':')).encode()).hexdigest() == identity['loaded_tree_sha256'] + assert PROTOCOL in ('1.20', '1.19') and (MODE == 'timely' or PROTOCOL == '1.20') + os.environ['DURABLE_WORKFLOW_WORKER_PROTOCOL_VERSION'] = PROTOCOL + started = time.monotonic() + namespace = 'query-python-' + os.urandom(6).hex() + async with asyncio.timeout(30): + async with Client(URL, token=os.environ['DW_PARITY_TOKEN']) as admin: + await admin.create_namespace(namespace) + async with Client(URL, token=os.environ['DW_PARITY_TOKEN'], namespace=namespace) as client: + worker = Worker(client, workflows=[QueryWorkflow], task_queue=namespace, + worker_id=namespace, poll_timeout=.2, shutdown_timeout=5, + require_query_task_authority=PROTOCOL == '1.20') + runner = asyncio.create_task(worker.run()) + try: + while not any(x['event'] == 'registered' and x['status'] == 201 for x in events()): + if runner.done(): + await runner + raise RuntimeError('Worker stopped before registration') + await asyncio.sleep(.02) + handle = await client.start_workflow(workflow_type=TYPE, task_queue=namespace, + workflow_id=namespace, input=[7]) + while (await handle.describe()).status.lower() != 'waiting': + if runner.done(): + await runner + raise RuntimeError('Worker stopped before timer admission') + await asyncio.sleep(.02) + if MODE == 'timely': + result = await client.query_workflow(namespace, 'value', args=[]) + assert result['result'] == EXPECTED and type(result['result']['long']) is int + else: + try: + await client.query_workflow(namespace, 'value', args=[]) + raise AssertionError('An expired original succeeded') + except QueryFailed as error: + assert error.status == 504 and error.reason == 'query_worker_execution_timeout' + while (MODE == 'late-poll' and not any(x['event'] == 'delivered' for x in events()) + or MODE == 'late-handler-async' and COUNTERS['cancelled'] != 1 + or MODE == 'late-handler-sync' and COUNTERS['returned'] != 1): + await asyncio.sleep(.02) + finally: + await worker.stop() + await runner + assert time.monotonic() - started < 30 + observed = events() + named = lambda name: [x for x in observed if x['event'] == name] + assert not named('observer-error') + assert len(named('claim')) == len(named('delivered')) == len(named('query-result')) == 1 + assert len(named('submission')) == len(named('acknowledgement')) == (1 if MODE == 'timely' else 0) + assert named('deregistered')[-1]['status'] == 200 + assert COUNTERS == {'calls': 0 if MODE == 'late-poll' else 1, + 'returned': 1 if MODE in ('timely', 'late-handler-sync') else 0, + 'cancelled': 1 if MODE == 'late-handler-async' else 0} + claim = named('claim')[0]['claim'] + assert named('claim')[0]['protocol'] == PROTOCOL + assert bool(named('status')) == (PROTOCOL == '1.20') + for entry in named('status'): + for field in ('query_task_id', 'query_task_attempt', 'lease_owner', 'query_admitted_at', + 'query_deadline_at', 'lease_expires_at'): + assert entry['observation'][field] == claim[field], field + print(json.dumps({'outcome': 'pass', 'language': 'python', 'mode': MODE, 'protocol': PROTOCOL, + 'sdk_version': importlib.metadata.version('durable-workflow'), + 'namespace': namespace, 'run_id': handle.run_id, 'claim': claim, + 'counters': COUNTERS, 'status_reads': len(named('status')), + 'history_sha256': named('query-result')[0]['history_sha256'], + 'original_budget_seconds': 30, 'elapsed_seconds': time.monotonic() - started, + 'worker_exit': 0})) + + +asyncio.run(main()) + diff --git a/scripts/conformance/server-parity/query-authority/requirements.txt b/scripts/conformance/server-parity/query-authority/requirements.txt new file mode 100644 index 000000000..2b48883ad --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/requirements.txt @@ -0,0 +1,10 @@ +# Separate current query-authority consumer; frozen actors remain unchanged. +durable-workflow==2.5.15 --hash=sha256:b905d7e440d5d3708c4570ad8c8986d2ff83149bee6ba4bce209992ad3e8a7dc +fastavro==1.13.1 --hash=sha256:e3d7e0850230a9af977184dd0677e2bc6341659835d55a73a2fa76c7d2d2d65e +httpx==0.28.1 --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad +httpcore==1.0.9 --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 +h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 +anyio==4.15.1 --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 +idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c +typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +certifi==2026.7.22 --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 diff --git a/scripts/conformance/server-parity/query-authority/rust/Cargo.lock b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock new file mode 100644 index 000000000..644a6030d --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/Cargo.lock @@ -0,0 +1,1793 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "apache-avro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" +dependencies = [ + "bigdecimal", + "bon", + "digest", + "log", + "miniz_oxide", + "num-bigint", + "quad-rand", + "rand 0.9.5", + "regex-lite", + "serde", + "serde_bytes", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "durable-workflow" +version = "3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04147fcb82f749ea1e308c0a3836e30454efc415d5933884d615ca4aae76bb2e" +dependencies = [ + "apache-avro", + "base64 0.22.1", + "bon", + "chrono", + "futures-util", + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "reqwest", + "serde", + "serde-value", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "uuid", + "yoke-derive", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quad-rand" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server-parity-query-authority-client" +version = "0.0.0" +dependencies = [ + "durable-workflow", + "reqwest", + "serde_json", + "tokio", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure 0.14.0", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/scripts/conformance/server-parity/query-authority/rust/Cargo.toml b/scripts/conformance/server-parity/query-authority/rust/Cargo.toml new file mode 100644 index 000000000..6f826273c --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "server-parity-query-authority-client" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +durable-workflow = "=3.5.5" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } + +[profile.dev] +debug = 0 +incremental = false + diff --git a/scripts/conformance/server-parity/query-authority/rust/src/main.rs b/scripts/conformance/server-parity/query-authority/rust/src/main.rs new file mode 100644 index 000000000..f7a0a0f94 --- /dev/null +++ b/scripts/conformance/server-parity/query-authority/rust/src/main.rs @@ -0,0 +1,147 @@ +//! Normal published SDK Worker/client; only namespace setup uses plain HTTP. +use durable_workflow::{Client, Error, Worker, WorkerProtocolVersion, SDK_VERSION}; +use serde_json::{json, Value}; +use std::{ + path::Path, + sync::{atomic::{AtomicUsize, Ordering}, Arc}, + time::{Duration, Instant}, +}; + +type Result = std::result::Result>; +const TYPE: &str = "parity.query-authority.rust.v1"; + +#[derive(Default)] +struct Counters { calls: AtomicUsize, returned: AtomicUsize, cancelled: AtomicUsize } +struct Callback { counters: Arc, returned: bool } +impl Drop for Callback { + fn drop(&mut self) { + if !self.returned { self.counters.cancelled.fetch_add(1, Ordering::SeqCst); } + } +} +fn expected() -> Value { json!({"long":9223372036854775807_i64,"nil":null,"text":"rangeλ"}) } +fn events(path: &Path) -> Vec { + let text = std::fs::read_to_string(path).unwrap_or_default(); + let complete = text.rsplit_once('\n').map(|(prefix, _)| prefix).unwrap_or(""); + complete.lines().filter(|line| !line.is_empty()).map(|line| serde_json::from_str(line).unwrap()).collect() +} +fn named<'a>(events: &'a [Value], event: &str) -> Vec<&'a Value> { + events.iter().filter(|value| value["event"] == event).collect() +} +async fn wait(until: Instant, mut condition: impl FnMut() -> bool) -> Result<()> { + while !condition() { + if Instant::now() >= until { return Err("Original thirty-second observation expired".into()); } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(()) +} + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() -> Result<()> { + let arguments: Vec = std::env::args().skip(1).collect(); + let [url, mode, protocol, journal] = arguments.as_slice() else { return Err("Expected URL MODE PROTOCOL JOURNAL".into()); }; + let profile: Value = serde_json::from_slice(&std::fs::read(std::env::var("DW_PARITY_QUERY_PROFILE")?)?)?; + assert_eq!(SDK_VERSION, format!("durable-workflow-rust/{}", profile["sdk_rust"]["version"].as_str().unwrap())); + assert!(matches!(mode.as_str(), "timely" | "late-poll" | "late-handler-async" | "late-handler-sync")); + assert!(protocol == "1.20" || protocol == "1.19" && mode == "timely"); + let started = Instant::now(); + let until = started + Duration::from_secs(30); + let namespace = format!("query-rust-{}", durable_workflow::Uuid::new_v4().simple()); + let token = std::env::var("DW_PARITY_TOKEN")?; + let response = reqwest::Client::new().post(format!("{url}/api/namespaces")) + .bearer_auth(&token).header("X-Namespace", "default") + .header("X-Durable-Workflow-Control-Plane-Version", "2") + .json(&json!({"name":namespace})).send().await?; + assert_eq!(response.status(), reqwest::StatusCode::CREATED); + let mut builder = Client::builder(url).namespace(&namespace).token(Some(token)); + if protocol == "1.19" { builder = builder.worker_protocol_version(WorkerProtocolVersion::V119); } + let client = builder.build()?; + let counters = Arc::new(Counters::default()); + let mut worker = Worker::new(client.clone(), &namespace).worker_id(&namespace) + .poll_timeout(Duration::from_millis(200)).require_query_task_authority(protocol == "1.20"); + worker.register_workflow(TYPE, |context, arguments| async move { + if arguments != json!([7]) { return Err(Error::WorkerLoop("Original input changed".into())); } + context.sleep(Duration::from_secs(60)).await?; + Ok(json!(7)) + }); + let callback_counters = Arc::clone(&counters); + let callback_mode = mode.clone(); + worker.register_query(TYPE, "value", move |context, arguments| { + let counters = Arc::clone(&callback_counters); + let mode = callback_mode.clone(); + async move { + counters.calls.fetch_add(1, Ordering::SeqCst); + let mut callback = Callback { counters: Arc::clone(&counters), returned: false }; + if arguments != json!([]) || context.workflow_input() != &json!([7]) { + return Err(Error::WorkerLoop("Original query snapshot changed".into())); + } + if mode == "late-handler-async" { tokio::time::sleep(Duration::from_secs(6)).await; } + if mode == "late-handler-sync" { std::thread::sleep(Duration::from_secs(6)); } + callback.returned = true; + counters.returned.fetch_add(1, Ordering::SeqCst); + Ok(expected()) + } + }); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let runner = tokio::spawn(async move { worker.run_until(async { let _ = stopped.await; }).await }); + let outcome: Result = async { + wait(until, || named(&events(Path::new(journal)), "registered").iter().any(|event| event["status"] == 201)).await?; + client.start_workflow(TYPE, &namespace, &namespace, json!([7])).await?; + let run_id = loop { + let run = client.describe_workflow(&namespace).await?; + if run.status.as_deref() == Some("waiting") { break run.run_id.ok_or("Original run ID missing")?; } + if Instant::now() >= until { return Err("Original timer admission expired".into()); } + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let query = client.query_workflow(&namespace, "value", json!([])).await; + if mode == "timely" { assert_eq!(query?, expected()); } + else { + match query { + Err(Error::QueryFailed(failure)) => { + assert_eq!(failure.status, 504); + assert_eq!(failure.reason, "query_worker_execution_timeout"); + } + other => return Err(format!("Expired original did not retain its caller timeout: {other:?}").into()), + } + wait(until, || match mode.as_str() { + "late-poll" => !named(&events(Path::new(journal)), "delivered").is_empty(), + "late-handler-async" => counters.cancelled.load(Ordering::SeqCst) == 1, + "late-handler-sync" => counters.returned.load(Ordering::SeqCst) == 1, + _ => false, + }).await?; + } + Ok(run_id) + }.await; + let _ = shutdown.send(()); + tokio::time::timeout(until.saturating_duration_since(Instant::now()), runner).await???; + let run_id = outcome?; + let observed = events(Path::new(journal)); + assert!(named(&observed, "observer-error").is_empty()); + assert_eq!(named(&observed, "claim").len(), 1); + assert_eq!(named(&observed, "delivered").len(), 1); + assert_eq!(named(&observed, "query-result").len(), 1); + let submissions = if mode == "timely" { 1 } else { 0 }; + assert_eq!(named(&observed, "submission").len(), submissions); + assert_eq!(named(&observed, "acknowledgement").len(), submissions); + assert_eq!(named(&observed, "deregistered").last().unwrap()["status"], 200); + assert_eq!(counters.calls.load(Ordering::SeqCst), usize::from(mode != "late-poll")); + assert_eq!(counters.returned.load(Ordering::SeqCst), usize::from(mode == "timely" || mode == "late-handler-sync")); + assert_eq!(counters.cancelled.load(Ordering::SeqCst), usize::from(mode == "late-handler-async")); + let claim_event = named(&observed, "claim")[0]; + let claim = &claim_event["claim"]; + assert_eq!(claim_event["protocol"], protocol.as_str()); + let status = named(&observed, "status"); + assert_eq!(!status.is_empty(), protocol == "1.20"); + for observation in &status { + for field in ["query_task_id", "query_task_attempt", "lease_owner", "query_admitted_at", "query_deadline_at", "lease_expires_at"] { + assert_eq!(observation["observation"][field], claim[field], "{field}"); + } + } + assert!(Instant::now() < until); + println!("{}", json!({"outcome":"pass","language":"rust","mode":mode,"protocol":protocol, + "sdk_version":SDK_VERSION,"namespace":namespace,"run_id":run_id,"claim":claim, + "calls":counters.calls.load(Ordering::SeqCst),"returned":counters.returned.load(Ordering::SeqCst), + "cancelled":counters.cancelled.load(Ordering::SeqCst),"status_reads":status.len(), + "history_sha256":named(&observed,"query-result")[0]["history_sha256"], + "original_budget_seconds":30,"elapsed_seconds":started.elapsed().as_secs_f64(),"worker_exit":0})); + Ok(()) +} diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json index a272f7c06..c23e52e28 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +++ b/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json @@ -16,6 +16,29 @@ "DurableWorkflow\\Transport\\Psr18Transport": "0857170cf128876338040f197ba469c5c6431cbb020da0453705c3798c60883a" } }, + "sdk_python": { + "version": "2.5.15", + "source": "35aad4ab344cf13e01de3e5f4704380eac1532a6", + "wheel_sha256": "b905d7e440d5d3708c4570ad8c8986d2ff83149bee6ba4bce209992ad3e8a7dc", + "loaded_files": 34, + "loaded_tree_sha256": "88a3d64aee16523fcd577ab005ad6cda094349c4f88ee9f61fec3b8e4e679e57" + }, + "sdk_rust": { + "version": "3.5.5", + "source": "4d5047ba9da39785512340b0975ce28da09f0cee", + "crate_sha256": "04147fcb82f749ea1e308c0a3836e30454efc415d5933884d615ca4aae76bb2e" + }, + "client_images": { + "python": "mirror.gcr.io/library/python@sha256:bf44cdfcb76cd3b41e879bc058fc37ec5872002ccfde7fcb765e218cde0cd79c", + "rust": "mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0" + }, + "client_cases": [ + {"mode": "timely", "protocol": "1.20"}, + {"mode": "timely", "protocol": "1.19"}, + {"mode": "late-poll", "protocol": "1.20"}, + {"mode": "late-handler-async", "protocol": "1.20"}, + {"mode": "late-handler-sync", "protocol": "1.20"} + ], "query_timeout_seconds": 4, "original_observation_seconds": 30, "delayed_response_seconds": 6, From a72c6327706ae4e4d139a569b5e93c58b93540dc Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:45:53 +0000 Subject: [PATCH 22/36] Keep the injected poll reply inside its transport window --- scripts/conformance/server-parity/query-authority/observer.mjs | 2 +- scripts/conformance/server-parity/query-authority/python.py | 2 +- .../conformance/server-parity/query-authority/rust/src/main.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs index 604308e75..60c8f9688 100644 --- a/scripts/conformance/server-parity/query-authority/observer.mjs +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -87,7 +87,7 @@ const server = http.createServer(async (request, response) => { || task.query_task_attempt !== 1 || !task.lease_owner) throw Error('Changed original query authority'); record('claim', {protocol: request.headers['x-durable-workflow-protocol-version'], claim: select(task)}); if (mode === 'late-poll') { - if (held) throw Error('A second original query was claimed'); + if (held) throw Error('Multiple poll responses invalidate this single-delivery fault control'); held = true; record('held'); await new Promise(resolve => setTimeout(resolve, 6_000)); diff --git a/scripts/conformance/server-parity/query-authority/python.py b/scripts/conformance/server-parity/query-authority/python.py index f59b25cfd..2e4d840c2 100644 --- a/scripts/conformance/server-parity/query-authority/python.py +++ b/scripts/conformance/server-parity/query-authority/python.py @@ -66,7 +66,7 @@ async def main(): await admin.create_namespace(namespace) async with Client(URL, token=os.environ['DW_PARITY_TOKEN'], namespace=namespace) as client: worker = Worker(client, workflows=[QueryWorkflow], task_queue=namespace, - worker_id=namespace, poll_timeout=.2, shutdown_timeout=5, + worker_id=namespace, poll_timeout=8, shutdown_timeout=5, require_query_task_authority=PROTOCOL == '1.20') runner = asyncio.create_task(worker.run()) try: diff --git a/scripts/conformance/server-parity/query-authority/rust/src/main.rs b/scripts/conformance/server-parity/query-authority/rust/src/main.rs index f7a0a0f94..6a993efc8 100644 --- a/scripts/conformance/server-parity/query-authority/rust/src/main.rs +++ b/scripts/conformance/server-parity/query-authority/rust/src/main.rs @@ -57,7 +57,7 @@ async fn main() -> Result<()> { let client = builder.build()?; let counters = Arc::new(Counters::default()); let mut worker = Worker::new(client.clone(), &namespace).worker_id(&namespace) - .poll_timeout(Duration::from_millis(200)).require_query_task_authority(protocol == "1.20"); + .poll_timeout(Duration::from_secs(8)).require_query_task_authority(protocol == "1.20"); worker.register_workflow(TYPE, |context, arguments| async move { if arguments != json!([7]) { return Err(Error::WorkerLoop("Original input changed".into())); } context.sleep(Duration::from_secs(60)).await?; From fe8aad7064f6f991870bfa8eb3d56b5f48181d57 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 05:56:54 +0000 Subject: [PATCH 23/36] Share installed query consumers across isolated database jobs --- .github/workflows/server-parity.yml | 29 +++ docs/rust-server-port.md | 9 + .../server-parity/query-authority-adapters.sh | 49 +++++ .../server-parity/query-authority-cleanup.sh | 28 +++ .../query-authority-clients-profile.sh | 28 +-- .../server-parity/query-authority-cohort.sh | 17 ++ .../query-authority/rust/src/main.rs | 186 ++++++++++++++---- .../query-authority/README.md | 24 ++- 8 files changed, 313 insertions(+), 57 deletions(-) create mode 100755 scripts/conformance/server-parity/query-authority-adapters.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index fbcb9fd5c..51d89b5eb 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -132,6 +132,15 @@ jobs: DW_PARITY_CORRECTED_CARGO: sdk-cargo DW_PARITY_CORRECTED_TARGET: sdk-target run: bash scripts/conformance/server-parity/corrected-marker-adapters.sh + - name: Build the separate current query consumers once + run: bash scripts/conformance/server-parity/query-authority-adapters.sh + - name: Share current query consumers without compiler caches + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: query-authority-adapters + path: query-authority-adapters + retention-days: 1 + if-no-files-found: error - name: Share the corrected cohort without compiler caches or target trees uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -193,6 +202,8 @@ jobs: corrected-marker-adapters/*-build.txt corrected-marker-adapters/*-install.txt codec-native-build/build.txt + query-authority-adapters/*-build.txt + query-authority-adapters/*-install.txt retention-days: 1 if-no-files-found: ignore @@ -302,6 +313,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -773,6 +790,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -1253,6 +1276,12 @@ jobs: name: published-sdk-patch-adapters path: published-sdk-adapters digest-mismatch: error + - name: Fetch this run's current query consumers + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: query-authority-adapters + path: query-authority-adapters + digest-mismatch: error - name: Fetch this run's genuine legacy-marker adapters uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 4c9422f95..941410c9e 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -964,6 +964,15 @@ expiry, non-renewing protocol-1.20 observation and committed-receipt ordering. Native implementation and separate shared qualification remain pending; current internal timing does not establish compliance with the proposed wire contract. +The later [installed-consumer observation](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) +passes twenty Python 2.5.15 / Rust 3.5.5 originals across separate PHP 2.8.0 and +candidate native SQLite APIs. Timely 1.20/1.19 and expired poll/async/sync +delivery preserve original four-second query and thirty-second whole budgets, +typed values, history and claim identity without late submissions. The earlier +short poll-transport observer failure stays failed. Subsequent fixture/build +sharing edits and the combined source require fresh hosted qualification; +external loading, pressure and full capability qualification remain open. + Earlier MariaDB/MySQL failure causes remain unestablished; the new lookup site does not retroactively establish them. Full namespace authorization, other families, consumers, diff --git a/scripts/conformance/server-parity/query-authority-adapters.sh b/scripts/conformance/server-parity/query-authority-adapters.sh new file mode 100755 index 000000000..4d777cc5b --- /dev/null +++ b/scripts/conformance/server-parity/query-authority-adapters.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Compile/install this separate published tuple once, then share runtime files. +set -euo pipefail +profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json +output=query-authority-adapters +cargo_home=${DW_PARITY_QUERY_CARGO:-sdk-cargo} +cargo_target=${DW_PARITY_QUERY_TARGET:-sdk-target} +test ! -e "$output" +mkdir -p "$output/python" "$cargo_home" "$cargo_target" +rust_image=$(jq -r '.client_images.rust' "$profile") +python_image=$(jq -r '.client_images.python' "$profile") +docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=2g --memory-swap=2g \ + -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e CARGO_INCREMENTAL=0 -e CARGO_PROFILE_DEV_DEBUG=0 \ + -v "$PWD:/source:ro" -v "$(realpath "$cargo_home"):/cargo" -v "$(realpath "$cargo_target"):/target" \ + -w /source/scripts/conformance/server-parity/query-authority/rust "$rust_image" \ + cargo build --locked >"$output/rust-build.txt" 2>&1 +cp "$cargo_target/debug/server-parity-query-authority-client" "$output/rust-client" +cp scripts/conformance/server-parity/query-authority/rust/Cargo.lock "$output/Cargo.lock" +docker run --rm --user="$(id -u):$(id -g)" --cpus=1 --memory=512m --memory-swap=512m \ + -v "$PWD:/source:ro" -v "$PWD/$output:/adapters" "$python_image" \ + python -m pip install --disable-pip-version-check --no-cache-dir --no-compile --only-binary=:all: --require-hashes \ + --target /adapters/python --report /adapters/python-install.json \ + -r /source/scripts/conformance/server-parity/query-authority/requirements.txt >"$output/python-install.txt" 2>&1 +docker run --rm -i --user="$(id -u):$(id -g)" --network=none --cpus=1 --memory=256m --memory-swap=256m \ + -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/adapters/python \ + -v "$PWD:/source:ro" -v "$PWD/$output:/adapters:ro" "$python_image" python - >"$output/identity.json" <<'PY' +import hashlib, importlib.metadata, json, tomllib +from pathlib import Path +import durable_workflow +profile=json.loads(Path('/source/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json').read_text()) +package=Path(durable_workflow.__file__).parent +hashes={str(path.relative_to(package)):hashlib.sha256(path.read_bytes()).hexdigest() + for path in package.rglob('*') if path.is_file() and (path.suffix=='.py' or path.name=='py.typed')} +tree=hashlib.sha256(json.dumps(hashes,sort_keys=True,separators=(',',':')).encode()).hexdigest() +assert importlib.metadata.version('durable-workflow')==profile['sdk_python']['version'] +assert len(hashes)==profile['sdk_python']['loaded_files'] and tree==profile['sdk_python']['loaded_tree_sha256'] +packages=tomllib.loads(Path('/adapters/Cargo.lock').read_text())['package'] +sdk=next(package for package in packages if package['name']=='durable-workflow') +assert sdk['version']==profile['sdk_rust']['version'] and sdk['checksum']==profile['sdk_rust']['crate_sha256'] +assert sdk['source']=='registry+https://github.com/rust-lang/crates.io-index' +print(json.dumps({'outcome':'pass','python_version':profile['sdk_python']['version'],'python_loaded_tree_sha256':tree, + 'rust_version':sdk['version'],'rust_crate_sha256':sdk['checksum']})) +PY +printf '%s\n' "$(git rev-parse HEAD)" >"$output/runner-revision.txt" +sha256sum scripts/conformance/server-parity/query-authority/{observer.mjs,python.py,requirements.txt} \ + scripts/conformance/server-parity/query-authority/rust/{Cargo.toml,Cargo.lock,src/main.rs} \ + "$profile" >"$output/source-hashes.sha256" +(cd "$output"; find . -type f ! -path ./SHA256SUMS -print0 | sort -z | xargs -0 sha256sum >SHA256SUMS) diff --git a/scripts/conformance/server-parity/query-authority-cleanup.sh b/scripts/conformance/server-parity/query-authority-cleanup.sh index b22e4df55..35f700a77 100755 --- a/scripts/conformance/server-parity/query-authority-cleanup.sh +++ b/scripts/conformance/server-parity/query-authority-cleanup.sh @@ -11,6 +11,34 @@ test "$(jq -r .resource_scope "$evidence/identity.json")" = "$RESOURCE_SCOPE" || test "$(jq -r .backend "$evidence/identity.json")" = "$backend" || exit 1 result=0 profile_status=$(cat "$evidence/profile-status.txt" 2>/dev/null || printf incomplete) +for target in php rust; do + for language in python rust; do + while IFS=$'\t' read -r mode protocol; do + if docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker rm -f "$RESOURCE_SCOPE-query-client-$target-$language-$mode-${protocol//./}" >/dev/null || result=1 + else result=1; fi + fi + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null 2>&1 || true + docker rm -f "$RESOURCE_SCOPE-query-client-observer-$target-$language-$mode-${protocol//./}" >/dev/null || result=1 + else result=1; fi + fi + done < <(jq -r '.client_cases[]|[.mode,.protocol]|@tsv' "$profile") + done +done +if docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then + if docker inspect "$RESOURCE_SCOPE-query-client-observer" | \ + jq -e --arg scope "$RESOURCE_SCOPE" '.[0].Config.Labels["durable-workflow.query-authority-client"]==$scope' >/dev/null; then + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer" >"$evidence/client-observer-stop.txt" 2>&1 || true + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer" >"$evidence/client-observer-state.json" + jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$evidence/client-observer-state.json" >/dev/null || result=1 + docker rm -f "$RESOURCE_SCOPE-query-client-observer" >/dev/null || result=1 + else result=1; fi +fi for target in php rust; do while IFS=$'\t' read -r mode protocol; do if docker inspect "$RESOURCE_SCOPE-query-$target-$mode-${protocol//./}" >/dev/null 2>&1; then diff --git a/scripts/conformance/server-parity/query-authority-clients-profile.sh b/scripts/conformance/server-parity/query-authority-clients-profile.sh index cf737d863..003e6f50b 100755 --- a/scripts/conformance/server-parity/query-authority-clients-profile.sh +++ b/scripts/conformance/server-parity/query-authority-clients-profile.sh @@ -14,9 +14,10 @@ profile=tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json image=$(jq -r '.server.image' "$profile") test ! -e "$output" test -f "$python_sdk/durable_workflow/__init__.py" -test -f rust-target/debug/server-parity-query-authority-client +client_binary=$(realpath "${DW_PARITY_QUERY_CLIENT_BIN:-rust-target/debug/server-parity-query-authority-client}") +test -f "$client_binary" mkdir -p "$output" -jq -n --arg revision "$(git rev-parse HEAD)" --arg binary "$(sha256sum rust-target/debug/server-parity-query-authority-client | cut -d ' ' -f1)" \ +jq -n --arg revision "$(git rev-parse HEAD)" --arg binary "$(sha256sum "$client_binary" | cut -d ' ' -f1)" \ --arg observer "$(sha256sum scripts/conformance/server-parity/query-authority/observer.mjs | cut -d ' ' -f1)" \ --arg profile "$(sha256sum "$profile" | cut -d ' ' -f1)" \ '{runner_revision:$revision,consumer_binary_sha256:$binary,observer_sha256:$observer,profile_sha256:$profile}' >"$output/identity.json" @@ -27,15 +28,14 @@ retire() { local result=0 if test -f "$case_output/client-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null 2>&1; then test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-$target-$language-$name")" = "$(cat "$case_output/client-container.id")" || return 1 - docker rm -f "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null || result=1 + docker stop --signal=SIGTERM --timeout=5 "$RESOURCE_SCOPE-query-client-$target-$language-$name" >/dev/null || result=1 fi - if test -f "$case_output/observer-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then - test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-observer")" = "$(cat "$case_output/observer-container.id")" || return 1 - docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer-stop.txt" 2>&1 || true - docker logs "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer.log" 2>&1 - docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer" >"$case_output/observer-state.json" + if test -f "$case_output/observer-container.id" && docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >/dev/null 2>&1; then + test "$(docker inspect --format '{{.Id}}' "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name")" = "$(cat "$case_output/observer-container.id")" || return 1 + docker stop --signal=SIGTERM --timeout=10 "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer-stop.txt" 2>&1 || true + docker logs "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer.log" 2>&1 + docker inspect --format '{{json .State}}' "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >"$case_output/observer-state.json" jq -e '.ExitCode==0 and .OOMKilled==false and .Running==false' "$case_output/observer-state.json" >/dev/null || result=1 - docker rm -f "$RESOURCE_SCOPE-query-client-observer" >/dev/null || result=1 fi return "$result" } @@ -45,8 +45,9 @@ for language in python rust; do name="$mode-${protocol//./}" case_output="$output/$language-$name" mkdir "$case_output" - if docker inspect "$RESOURCE_SCOPE-query-client-observer" >/dev/null 2>&1; then exit 1; fi - docker run -d --name "$RESOURCE_SCOPE-query-client-observer" --network "$network" \ + if docker inspect "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" >/dev/null 2>&1; then exit 1; fi + docker run -d --name "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" --network "$network" \ + --label "durable-workflow.query-authority-client=$RESOURCE_SCOPE" \ --network-alias=parity-query-client-observer --user="$(id -u):$(id -g)" \ --cpus=1 --memory=256m --memory-swap=256m --entrypoint node \ -v "$source_root:/source:ro" -v "$case_output:/evidence" "$image" \ @@ -54,7 +55,7 @@ for language in python rust; do >"$case_output/observer-container.id" ready=0 for attempt in $(seq 1 50); do - if docker logs "$RESOURCE_SCOPE-query-client-observer" 2>/dev/null | jq -e '.ready==true' >/dev/null; then ready=1; break; fi + if docker logs "$RESOURCE_SCOPE-query-client-observer-$target-$language-$name" 2>/dev/null | jq -e '.ready==true' >/dev/null; then ready=1; break; fi sleep .05 done test "$ready" = 1 @@ -63,11 +64,12 @@ for language in python rust; do actor=(--entrypoint python -e PYTHONPATH=/sdk -e PYTHONDONTWRITEBYTECODE=1 \ -v "$python_sdk:/sdk:ro" "$client_image" /source/scripts/conformance/server-parity/query-authority/python.py) else - actor=(--entrypoint /target/debug/server-parity-query-authority-client -v "$PWD/rust-target:/target:ro" "$client_image") + actor=(--entrypoint /client -v "$client_binary:/client:ro" "$client_image") fi status=0 timeout --signal=TERM --kill-after=5s 40s docker run --rm --cidfile "$case_output/client-container.id" \ --name "$RESOURCE_SCOPE-query-client-$target-$language-$name" --network "$network" --user="$(id -u):$(id -g)" \ + --label "durable-workflow.query-authority-client=$RESOURCE_SCOPE" \ --cpus=1 --memory=512m --memory-swap=512m -e DW_PARITY_TOKEN \ -e DW_PARITY_QUERY_PROFILE=/source/tests/Fixtures/ServerParityProfiles/query-authority/artifacts.json \ -v "$source_root:/source:ro" -v "$case_output:/evidence:ro" "${actor[@]}" \ diff --git a/scripts/conformance/server-parity/query-authority-cohort.sh b/scripts/conformance/server-parity/query-authority-cohort.sh index 6f85234c5..d8598ea66 100755 --- a/scripts/conformance/server-parity/query-authority-cohort.sh +++ b/scripts/conformance/server-parity/query-authority-cohort.sh @@ -95,3 +95,20 @@ jq -s --arg backend "$backend" \ done) > "$evidence/comparison.json" test "$(jq -r .originals "$evidence/comparison.json")" = "$(jq '.cases|length*2' "$profile")" cat "$evidence/comparison.json" +# Current Python/Rust consumers are separate from every frozen SDK actor. +(cd query-authority-adapters; sha256sum -c SHA256SUMS >/dev/null) +sha256sum -c query-authority-adapters/source-hashes.sha256 >/dev/null +test "$(cat query-authority-adapters/runner-revision.txt)" = "$(git rev-parse HEAD)" +test "$(jq -r .outcome query-authority-adapters/identity.json)" = pass +chmod +x query-authority-adapters/rust-client +export DW_PARITY_QUERY_CLIENT_BIN="$PWD/query-authority-adapters/rust-client" +for target in php rust; do + server_target=$target + if test "$target" = rust; then server_target=native; fi + bash scripts/conformance/server-parity/query-authority-clients-profile.sh "$RESOURCE_SCOPE" \ + "http://parity-query-authority-$server_target:8080" query-authority-adapters/python "$evidence/clients/$target" "$target" \ + >"$evidence/$target-clients-tests.txt" +done +jq -s '{outcome:"pass",originals:(map(.originals)|add),targets:.}' \ + "$evidence/clients/php/comparison.json" "$evidence/clients/rust/comparison.json" >"$evidence/clients-comparison.json" +test "$(jq -r .originals "$evidence/clients-comparison.json")" = "$(jq '.client_cases|length*4' "$profile")" diff --git a/scripts/conformance/server-parity/query-authority/rust/src/main.rs b/scripts/conformance/server-parity/query-authority/rust/src/main.rs index 6a993efc8..5be71cc1a 100644 --- a/scripts/conformance/server-parity/query-authority/rust/src/main.rs +++ b/scripts/conformance/server-parity/query-authority/rust/src/main.rs @@ -3,7 +3,10 @@ use durable_workflow::{Client, Error, Worker, WorkerProtocolVersion, SDK_VERSION use serde_json::{json, Value}; use std::{ path::Path, - sync::{atomic::{AtomicUsize, Ordering}, Arc}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, time::{Duration, Instant}, }; @@ -11,25 +14,48 @@ type Result = std::result::Result const TYPE: &str = "parity.query-authority.rust.v1"; #[derive(Default)] -struct Counters { calls: AtomicUsize, returned: AtomicUsize, cancelled: AtomicUsize } -struct Callback { counters: Arc, returned: bool } +struct Counters { + calls: AtomicUsize, + returned: AtomicUsize, + cancelled: AtomicUsize, +} +struct Callback { + counters: Arc, + returned: bool, +} impl Drop for Callback { fn drop(&mut self) { - if !self.returned { self.counters.cancelled.fetch_add(1, Ordering::SeqCst); } + if !self.returned { + self.counters.cancelled.fetch_add(1, Ordering::SeqCst); + } } } -fn expected() -> Value { json!({"long":9223372036854775807_i64,"nil":null,"text":"rangeλ"}) } +fn expected() -> Value { + json!({"long":9223372036854775807_i64,"nil":null,"text":"rangeλ"}) +} fn events(path: &Path) -> Vec { let text = std::fs::read_to_string(path).unwrap_or_default(); - let complete = text.rsplit_once('\n').map(|(prefix, _)| prefix).unwrap_or(""); - complete.lines().filter(|line| !line.is_empty()).map(|line| serde_json::from_str(line).unwrap()).collect() + let complete = text + .rsplit_once('\n') + .map(|(prefix, _)| prefix) + .unwrap_or(""); + complete + .lines() + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() } fn named<'a>(events: &'a [Value], event: &str) -> Vec<&'a Value> { - events.iter().filter(|value| value["event"] == event).collect() + events + .iter() + .filter(|value| value["event"] == event) + .collect() } async fn wait(until: Instant, mut condition: impl FnMut() -> bool) -> Result<()> { while !condition() { - if Instant::now() >= until { return Err("Original thirty-second observation expired".into()); } + if Instant::now() >= until { + return Err("Original thirty-second observation expired".into()); + } tokio::time::sleep(Duration::from_millis(20)).await; } Ok(()) @@ -38,28 +64,52 @@ async fn wait(until: Instant, mut condition: impl FnMut() -> bool) -> Result<()> #[tokio::main(flavor = "multi_thread", worker_threads = 2)] async fn main() -> Result<()> { let arguments: Vec = std::env::args().skip(1).collect(); - let [url, mode, protocol, journal] = arguments.as_slice() else { return Err("Expected URL MODE PROTOCOL JOURNAL".into()); }; - let profile: Value = serde_json::from_slice(&std::fs::read(std::env::var("DW_PARITY_QUERY_PROFILE")?)?)?; - assert_eq!(SDK_VERSION, format!("durable-workflow-rust/{}", profile["sdk_rust"]["version"].as_str().unwrap())); - assert!(matches!(mode.as_str(), "timely" | "late-poll" | "late-handler-async" | "late-handler-sync")); + let [url, mode, protocol, journal] = arguments.as_slice() else { + return Err("Expected URL MODE PROTOCOL JOURNAL".into()); + }; + let profile: Value = + serde_json::from_slice(&std::fs::read(std::env::var("DW_PARITY_QUERY_PROFILE")?)?)?; + assert_eq!( + SDK_VERSION, + format!( + "durable-workflow-rust/{}", + profile["sdk_rust"]["version"].as_str().unwrap() + ) + ); + assert!(matches!( + mode.as_str(), + "timely" | "late-poll" | "late-handler-async" | "late-handler-sync" + )); assert!(protocol == "1.20" || protocol == "1.19" && mode == "timely"); let started = Instant::now(); let until = started + Duration::from_secs(30); let namespace = format!("query-rust-{}", durable_workflow::Uuid::new_v4().simple()); let token = std::env::var("DW_PARITY_TOKEN")?; - let response = reqwest::Client::new().post(format!("{url}/api/namespaces")) - .bearer_auth(&token).header("X-Namespace", "default") + let response = reqwest::Client::new() + .post(format!("{url}/api/namespaces")) + .bearer_auth(&token) + .header("X-Namespace", "default") .header("X-Durable-Workflow-Control-Plane-Version", "2") - .json(&json!({"name":namespace})).send().await?; + .json(&json!({"name":namespace})) + .send() + .await?; assert_eq!(response.status(), reqwest::StatusCode::CREATED); - let mut builder = Client::builder(url).namespace(&namespace).token(Some(token)); - if protocol == "1.19" { builder = builder.worker_protocol_version(WorkerProtocolVersion::V119); } + let mut builder = Client::builder(url) + .namespace(&namespace) + .token(Some(token)); + if protocol == "1.19" { + builder = builder.worker_protocol_version(WorkerProtocolVersion::V119); + } let client = builder.build()?; let counters = Arc::new(Counters::default()); - let mut worker = Worker::new(client.clone(), &namespace).worker_id(&namespace) - .poll_timeout(Duration::from_secs(8)).require_query_task_authority(protocol == "1.20"); + let mut worker = Worker::new(client.clone(), &namespace) + .worker_id(&namespace) + .poll_timeout(Duration::from_secs(8)) + .require_query_task_authority(protocol == "1.20"); worker.register_workflow(TYPE, |context, arguments| async move { - if arguments != json!([7]) { return Err(Error::WorkerLoop("Original input changed".into())); } + if arguments != json!([7]) { + return Err(Error::WorkerLoop("Original input changed".into())); + } context.sleep(Duration::from_secs(60)).await?; Ok(json!(7)) }); @@ -70,47 +120,79 @@ async fn main() -> Result<()> { let mode = callback_mode.clone(); async move { counters.calls.fetch_add(1, Ordering::SeqCst); - let mut callback = Callback { counters: Arc::clone(&counters), returned: false }; + let mut callback = Callback { + counters: Arc::clone(&counters), + returned: false, + }; if arguments != json!([]) || context.workflow_input() != &json!([7]) { return Err(Error::WorkerLoop("Original query snapshot changed".into())); } - if mode == "late-handler-async" { tokio::time::sleep(Duration::from_secs(6)).await; } - if mode == "late-handler-sync" { std::thread::sleep(Duration::from_secs(6)); } + if mode == "late-handler-async" { + tokio::time::sleep(Duration::from_secs(6)).await; + } + if mode == "late-handler-sync" { + std::thread::sleep(Duration::from_secs(6)); + } callback.returned = true; counters.returned.fetch_add(1, Ordering::SeqCst); Ok(expected()) } }); let (shutdown, stopped) = tokio::sync::oneshot::channel(); - let runner = tokio::spawn(async move { worker.run_until(async { let _ = stopped.await; }).await }); + let runner = tokio::spawn(async move { + worker + .run_until(async { + let _ = stopped.await; + }) + .await + }); let outcome: Result = async { - wait(until, || named(&events(Path::new(journal)), "registered").iter().any(|event| event["status"] == 201)).await?; - client.start_workflow(TYPE, &namespace, &namespace, json!([7])).await?; + wait(until, || { + named(&events(Path::new(journal)), "registered") + .iter() + .any(|event| event["status"] == 201) + }) + .await?; + client + .start_workflow(TYPE, &namespace, &namespace, json!([7])) + .await?; let run_id = loop { let run = client.describe_workflow(&namespace).await?; - if run.status.as_deref() == Some("waiting") { break run.run_id.ok_or("Original run ID missing")?; } - if Instant::now() >= until { return Err("Original timer admission expired".into()); } + if run.status.as_deref() == Some("waiting") { + break run.run_id.ok_or("Original run ID missing")?; + } + if Instant::now() >= until { + return Err("Original timer admission expired".into()); + } tokio::time::sleep(Duration::from_millis(20)).await; }; let query = client.query_workflow(&namespace, "value", json!([])).await; - if mode == "timely" { assert_eq!(query?, expected()); } - else { + if mode == "timely" { + assert_eq!(query?, expected()); + } else { match query { Err(Error::QueryFailed(failure)) => { assert_eq!(failure.status, 504); assert_eq!(failure.reason, "query_worker_execution_timeout"); } - other => return Err(format!("Expired original did not retain its caller timeout: {other:?}").into()), + other => { + return Err(format!( + "Expired original did not retain its caller timeout: {other:?}" + ) + .into()) + } } wait(until, || match mode.as_str() { "late-poll" => !named(&events(Path::new(journal)), "delivered").is_empty(), "late-handler-async" => counters.cancelled.load(Ordering::SeqCst) == 1, "late-handler-sync" => counters.returned.load(Ordering::SeqCst) == 1, _ => false, - }).await?; + }) + .await?; } Ok(run_id) - }.await; + } + .await; let _ = shutdown.send(()); tokio::time::timeout(until.saturating_duration_since(Instant::now()), runner).await???; let run_id = outcome?; @@ -122,26 +204,48 @@ async fn main() -> Result<()> { let submissions = if mode == "timely" { 1 } else { 0 }; assert_eq!(named(&observed, "submission").len(), submissions); assert_eq!(named(&observed, "acknowledgement").len(), submissions); - assert_eq!(named(&observed, "deregistered").last().unwrap()["status"], 200); - assert_eq!(counters.calls.load(Ordering::SeqCst), usize::from(mode != "late-poll")); - assert_eq!(counters.returned.load(Ordering::SeqCst), usize::from(mode == "timely" || mode == "late-handler-sync")); - assert_eq!(counters.cancelled.load(Ordering::SeqCst), usize::from(mode == "late-handler-async")); + assert_eq!( + named(&observed, "deregistered").last().unwrap()["status"], + 200 + ); + assert_eq!( + counters.calls.load(Ordering::SeqCst), + usize::from(mode != "late-poll") + ); + assert_eq!( + counters.returned.load(Ordering::SeqCst), + usize::from(mode == "timely" || mode == "late-handler-sync") + ); + assert_eq!( + counters.cancelled.load(Ordering::SeqCst), + usize::from(mode == "late-handler-async") + ); let claim_event = named(&observed, "claim")[0]; let claim = &claim_event["claim"]; assert_eq!(claim_event["protocol"], protocol.as_str()); let status = named(&observed, "status"); assert_eq!(!status.is_empty(), protocol == "1.20"); for observation in &status { - for field in ["query_task_id", "query_task_attempt", "lease_owner", "query_admitted_at", "query_deadline_at", "lease_expires_at"] { + for field in [ + "query_task_id", + "query_task_attempt", + "lease_owner", + "query_admitted_at", + "query_deadline_at", + "lease_expires_at", + ] { assert_eq!(observation["observation"][field], claim[field], "{field}"); } } assert!(Instant::now() < until); - println!("{}", json!({"outcome":"pass","language":"rust","mode":mode,"protocol":protocol, + println!( + "{}", + json!({"outcome":"pass","language":"rust","mode":mode,"protocol":protocol, "sdk_version":SDK_VERSION,"namespace":namespace,"run_id":run_id,"claim":claim, "calls":counters.calls.load(Ordering::SeqCst),"returned":counters.returned.load(Ordering::SeqCst), "cancelled":counters.cancelled.load(Ordering::SeqCst),"status_reads":status.len(), "history_sha256":named(&observed,"query-result")[0]["history_sha256"], - "original_budget_seconds":30,"elapsed_seconds":started.elapsed().as_secs_f64(),"worker_exit":0})); + "original_budget_seconds":30,"elapsed_seconds":started.elapsed().as_secs_f64(),"worker_exit":0}) + ); Ok(()) } diff --git a/tests/Fixtures/ServerParityProfiles/query-authority/README.md b/tests/Fixtures/ServerParityProfiles/query-authority/README.md index dc1047fbc..723c3f9da 100644 --- a/tests/Fixtures/ServerParityProfiles/query-authority/README.md +++ b/tests/Fixtures/ServerParityProfiles/query-authority/README.md @@ -65,6 +65,24 @@ An explicit conflicting namespace still fails. The earlier failed originals and the exact four-case match are recorded in [the owning query checkpoint](https://github.com/durable-workflow/server/issues/433#issuecomment-6184509976). -External query loading, Python/Rust consumers, remaining route/pressure and -continuation boundaries, all-six integration and capability qualification remain -ahead. These are correctness controls and establish no performance result. +The separate installed Python 2.5.15 and Rust 3.5.5 consumers pass twenty actual +SQLite originals across PHP and native targets at runner `a72c63277`. Both +languages cover timely 1.20/1.19, a six-second held poll response, async expiry +and blocking-handler expiry. Original query/whole budgets remain four/thirty +seconds. Typed results, unchanged history, original claim/timing, no late +submission and successful Worker/observer shutdown are checked. The +[local outcome](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) +preserves the earlier failed observer control and exact installed identities. + +`query-authority-adapters.sh` installs the hash-locked Python wheel and builds +the normal registry Rust consumer once. It shares only checked runtime files +with the database jobs. `query-authority-clients-profile.sh` executes both +languages through a bounded byte-preserving observer; its per-case observers +stop after observation and the workflow's always-step removes them. Changes +after the local outcome still require an exact combined hosted qualification. + +External query loading, remaining route/pressure and continuation boundaries, +all-six consumer integration and capability qualification remain ahead. These +are correctness controls and establish no performance result. Completed raw +observations and build caches are deleted after audit; readable outcomes and +reproduction code remain. From 396a22e989a751ac4fe83d00f7eeb67c4aa211aa Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:00:02 +0000 Subject: [PATCH 24/36] Record completed component query matrix and separate next gates --- docs/rust-server-port.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 941410c9e..b59ee1005 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -94,8 +94,11 @@ The current integration order is: Its fifth active native original remains failed at unsupported termination cleanup. The revised query-only observer delegates database retirement to the caller. Its revised five-case profile passes all ten fresh SQLite originals - with the same four-second query and thirty-second observation budgets; all-six - execution remains pending. A later six-case SQLite cohort passes all twelve + with the same four-second query and thirty-second observation budgets. Its + [original all-six execution](https://github.com/durable-workflow/server/pull/463#issuecomment-6185577908) + passes all thirteen jobs at `8e4cb2efc`: sixty actual PHP SDK query originals + and 123 native execution tests per configuration. The separate boundary + cleanup check fails and remains failed. A later six-case SQLite cohort passes all twelve fresh originals, including a query on a genuine waiting named timer workflow. That new case uses the same installed SDK, reference image and original budgets. The first PHP SQLite observer raced the Worker's @@ -122,6 +125,14 @@ The current integration order is: proof. This combined source needs its own qualification; component passes do not qualify the merge. +The next query qualification also includes the separately qualified PHP +`7e215d29` metadata source and normally installed Python/Rust consumers described +in the [local outcome](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047). +The preceding sixty-case result does not qualify these additions. New consumers +run in every existing database configuration with their original deadlines; +their runtime artifacts are shared without compiler caches. Cleanup is owned by +the existing unconditional workflow steps. + Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, cleanup/backup/restart and full database/consumer qualification remain open. From 0b77f61e97dcbece717ed568f32955b5b742bc15 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:14:37 +0000 Subject: [PATCH 25/36] Keep namespace refusal control on an unqualified child command --- rust/tests/execution/namespaces.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rust/tests/execution/namespaces.rs b/rust/tests/execution/namespaces.rs index 37eea8d9e..21449e985 100644 --- a/rust/tests/execution/namespaces.rs +++ b/rust/tests/execution/namespaces.rs @@ -531,7 +531,9 @@ async fn same_worker_id_isolated_claims_completion_and_deregistration_by_namespa &path, completion( &first_task, - json!([{"type":"start_timer","delay_seconds":0}]), + // Timers have separate positive namespace coverage. Child commands + // remain outside this namespace slice and must fail atomically. + json!([child_command(9007199254740993)]), ), ) .await; From 62b45ac90d29b1b2fe9d7c1e35302aec61639758 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:19:40 +0000 Subject: [PATCH 26/36] Require cancellation delivery to retire its original timer task --- rust/tests/execution.rs | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index d22329f5e..b0a6da801 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -253,6 +253,50 @@ impl TestDatabase { } } + async fn timer_task_states(&self, run: &Value) -> Vec<(String, String)> { + let query = "SELECT id,status FROM workflow_tasks WHERE workflow_run_id=$1 AND task_type='timer' ORDER BY id"; + let run = run.as_str().unwrap(); + match self { + Self::Sqlite(dir) => { + let pool = sqlx::SqlitePool::connect(&format!( + "sqlite://{}", + dir.path().join("runtime.sqlite").display() + )) + .await + .unwrap(); + let rows = sqlx::query_as(query) + .bind(run) + .fetch_all(&pool) + .await + .unwrap(); + pool.close().await; + rows + } + Self::Postgres { options, .. } => { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(); + let rows = sqlx::query_as(query) + .bind(run) + .fetch_all(&pool) + .await + .unwrap(); + pool.close().await; + rows + } + Self::MySql { options, .. } => { + let pool = sqlx::mysql::MySqlPoolOptions::new() + .connect_with(options.as_ref().clone()) + .await + .unwrap(); + let rows = sqlx::query_as("SELECT id,status FROM workflow_tasks WHERE workflow_run_id=? AND task_type='timer' ORDER BY id").bind(run).fetch_all(&pool).await.unwrap(); + pool.close().await; + rows + } + } + } + async fn update_failure_count(&self, command: &str, failure: &str) -> i64 { let query = "SELECT COUNT(*) FROM workflow_failures WHERE source_id=$1 AND id=$2 AND source_kind='workflow_command' AND propagation_kind='update' AND failure_category='application' AND non_retryable=true AND handled=false"; match self { @@ -3261,6 +3305,8 @@ async fn cooperative_delivery_cleanup_and_original_deadline_survive_fresh_runtim assert_eq!(completed.0, StatusCode::OK, "{}", completed.1); } let prefix = run_history(&app_b, &workflow, &run).await; + let timer_tasks = database.timer_task_states(&run).await; + assert_eq!(timer_tasks.len(), usize::from(phase == "pending_timer")); let control = format!( "/api/workflows/cleanup-root/runs/{}/request-cancellation", run.as_str().unwrap() @@ -3299,6 +3345,14 @@ async fn cooperative_delivery_cleanup_and_original_deadline_survive_fresh_runtim let delivered = request_protocol(&app_a, "POST", &endpoint, delivery.clone(), "1.20").await; assert_eq!(delivered.0, StatusCode::OK, "{}", delivered.1); assert_eq!(delivered.1["delivered"], true); + assert_eq!( + database.timer_task_states(&run).await, + timer_tasks + .into_iter() + .map(|(id, _)| (id, "cancelled".to_owned())) + .collect::>(), + "cancellation must retire the same original timer task at delivery" + ); let canonical_history = run_history(&app_a, &workflow, &run).await; let worker_history = request_protocol(&app_a, "POST", &format!("/api/worker/workflow-tasks/{}/history", task["task_id"].as_str().unwrap()), From a78956cd24dc077065ebb48d570be45e26650eec Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:22:33 +0000 Subject: [PATCH 27/36] Bind canonical namespace when cancelling timer tasks --- rust/src/runtime/cooperative.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/rust/src/runtime/cooperative.rs b/rust/src/runtime/cooperative.rs index 68045f294..6b9bb113a 100644 --- a/rust/src/runtime/cooperative.rs +++ b/rust/src/runtime/cooperative.rs @@ -306,7 +306,7 @@ where "cancellation_delivery_not_eligible", )); } - Self::cancel_root_timer(&mut tx, &run_id, request_id, &timer, Some(task_id), now()) + Self::cancel_root_timer(&mut tx, &run, request_id, &timer, Some(task_id), now()) .await?; } let at = now(); @@ -338,20 +338,23 @@ where async fn cancel_root_timer( tx: &mut Transaction<'_, DB>, - run_id: &str, + run: &DB::Row, request_id: &str, timer: &DB::Row, task_id: Option<&str>, at: DateTime, ) -> Result<()> { + let run_id = DB::string(run, "id")?; + let namespace = DB::string(run, "namespace")?; let timer_id = DB::string(timer, "id")?; Self::query("UPDATE workflow_run_timers SET status='cancelled' WHERE id=$1") .bind(&timer_id) .execute(&mut **tx) .await?; for timer_task in Self::query(DB::TIMER_TASK_SQL) - .bind(run_id) + .bind(&run_id) .bind(&timer_id) + .bind(&namespace) .fetch_all(&mut **tx) .await? { @@ -362,7 +365,7 @@ where .execute(&mut **tx) .await?; } - Self::append(tx,run_id,"TimerCancelled",json!({"timer_id":timer_id,"sequence":DB::number(timer,"sequence")?, + Self::append(tx,&run_id,"TimerCancelled",json!({"timer_id":timer_id,"sequence":DB::number(timer,"sequence")?, "delay_seconds":DB::number(timer,"delay_seconds")?,"fire_at":DB::instant(timer,"fire_at")?,"cancelled_at":at}),task_id,Some(request_id)).await } @@ -427,7 +430,7 @@ where .bind(&run_id).execute(&mut **tx).await?; Self::cancel_open_activities(tx, &run_id, &request_id, activities, at).await?; for timer in timers { - Self::cancel_root_timer(tx, &run_id, &request_id, &timer, task_id, at).await?; + Self::cancel_root_timer(tx, run, &request_id, &timer, task_id, at).await?; } let failure_id = id(); let message = format!("Workflow cancelled: {reason}"); From 1738222868444f85f330ad093c7f046996bdee06 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:29:24 +0000 Subject: [PATCH 28/36] Record failed query integration and proved timer cancellation correction --- docs/rust-server-port.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index b59ee1005..73b163be4 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -133,6 +133,19 @@ run in every existing database configuration with their original deadlines; their runtime artifacts are shared without compiler caches. Cleanup is owned by the existing unconditional workflow steps. +Candidate `396a22e98` first failed during a pinned image pull before scenarios. +Its identical-source retry exposed a missing third namespace binding in +cooperative timer cancellation, producing protocol errors on network databases. +SQLite silently left the original timer task ready at delivery. The +[three-database counterfactual and correction](https://github.com/durable-workflow/server/pull/463#issuecomment-6185925195) +fail before and pass after `a78956cd2`, checking the same original task reaches +cancelled state while preserving cancellation history, deadlines and replay. +Fresh corrected SQLite passes 75 library tests with eighteen existing ignores +and all 125 execution tests. The separate older namespace test now retains its +unqualified-command refusal using a child command, since timers have positive +coverage. Hosted failures remain failed; neither correction qualifies a new +combined matrix or explains historical PHP storage failures. + Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, cleanup/backup/restart and full database/consumer qualification remain open. From 2b59918b07d5607212a4f19bafefe0185085978a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:45:49 +0000 Subject: [PATCH 29/36] Wait for actual query authority refusal before worker shutdown --- docs/rust-server-port.md | 15 ++++++ .../query-authority/observer.mjs | 28 ++++++++--- .../server-parity/query-authority/python.py | 49 +++++++++++++++++-- .../query-authority/rust/src/main.rs | 33 +++++++++++-- 4 files changed, 113 insertions(+), 12 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 73b163be4..b4397e538 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -146,6 +146,21 @@ unqualified-command refusal using a child command, since timers have positive coverage. Hosted failures remain failed; neither correction qualifies a new combined matrix or explains historical PHP storage failures. +The current client observer records delivery before a Worker can consume the +response, so stopping immediately at that event does not prove expiry refusal. +The follow-up recipe waits for Python's actual public authority-loss diagnostic +or Rust's recorded expired authority observation. Proven original or monotonic +expiry can legally omit redundant status HTTP; Python's no-status acceptance +requires its actual expiry reason, exact claim identity/timing and affirmative +discovery. Rust exposes no public reason for an immediate local refusal, so such +a case stays unqualified rather than being called an SDK defect. Unexpired work +still requires observation. Observer failures now retain bounded method/path +diagnostics instead of an unexplained assertion. Five fresh installed Python +2.5.15 controls pass against the pinned PHP 2.8.0 SQLite API, with the original +four/thirty-second budgets; the held poll records an actual timeout refusal. +The earlier PostgreSQL observer error remains unexplained, and native/Rust and +full hosted qualification of these recipe changes remain ahead. + Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, cleanup/backup/restart and full database/consumer qualification remain open. diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs index 60c8f9688..8dc5193a5 100644 --- a/scripts/conformance/server-parity/query-authority/observer.mjs +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -11,7 +11,7 @@ const upstream = new URL(target); if (upstream.protocol !== 'http:') throw Error('Use only an isolated HTTP target'); let count = 0, retainedBytes = 0, held = false; const authorityFields = ['query_task_id', 'query_task_attempt', 'lease_owner', 'task_queue', 'namespace', - 'query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time', 'can_continue', 'reason']; + 'workflow_id', 'run_id', 'query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time', 'can_continue', 'reason']; const select = value => Object.fromEntries(authorityFields.filter(key => key in value).map(key => [key, value[key]])); function record(event, details = {}) { const line = JSON.stringify({event, observed_at: Date.now() / 1000, ...details}) + '\n'; @@ -46,8 +46,8 @@ function json(reply) { } const sha = bytes => createHash('sha256').update(bytes).digest('hex'); const server = http.createServer(async (request, response) => { + const path = new URL(request.url, upstream).pathname; try { - const path = new URL(request.url, upstream).pathname; const chunks = []; let size = 0; for await (const chunk of request) { @@ -74,18 +74,32 @@ const server = http.createServer(async (request, response) => { before = sha(Buffer.from(JSON.stringify(json(snapshot)))); } const reply = await exchange(request.method, request.url, headers, body); + if (path === '/api/cluster/info' && request.method === 'GET') { + record('discovery', {status: reply.status, + query_task_authority: json(reply).worker_protocol?.server_capabilities?.query_task_authority === true}); + } if (path === '/api/worker/register' && request.method === 'POST') record('registered', {status: reply.status}); if (path.endsWith('/query-tasks/poll') && reply.status === 200) { const task = json(reply).task; if (task && typeof task === 'object') { const utc6 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/; for (const key of ['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time']) { - if (!utc6.test(task[key])) throw Error('Missing original UTC6 authority: ' + key); + if (!utc6.test(task[key]) || !Number.isFinite(Date.parse(task[key]))) throw Error('Missing original UTC6 authority: ' + key); } + const original = JSON.parse(body.toString('utf8')); if (Date.parse(task.query_deadline_at) - Date.parse(task.query_admitted_at) !== 4_000 + || Date.parse(task.lease_expires_at) < Date.parse(task.query_admitted_at) || Date.parse(task.lease_expires_at) > Date.parse(task.query_deadline_at) - || task.query_task_attempt !== 1 || !task.lease_owner) throw Error('Changed original query authority'); - record('claim', {protocol: request.headers['x-durable-workflow-protocol-version'], claim: select(task)}); + || Date.parse(task.server_time) < Date.parse(task.query_admitted_at) + || task.query_task_attempt !== 1 || task.lease_owner !== original.worker_id + || task.task_queue !== original.task_queue + || typeof request.headers['x-namespace'] !== 'string' || !request.headers['x-namespace'] + || (task.namespace !== undefined && task.namespace !== request.headers['x-namespace']) + || ['query_task_id', 'workflow_id', 'run_id'].some(key => typeof task[key] !== 'string' || !task[key])) { + throw Error('Changed original query authority'); + } + record('claim', {protocol: request.headers['x-durable-workflow-protocol-version'], + namespace: request.headers['x-namespace'], claim: select(task)}); if (mode === 'late-poll') { if (held) throw Error('Multiple poll responses invalidate this single-delivery fault control'); held = true; @@ -111,7 +125,9 @@ const server = http.createServer(async (request, response) => { response.writeHead(reply.status, reply.headers); response.end(reply.bytes); } catch (error) { - record('observer-error', {message: String(error)}); + const details = {method: request.method, path, message: String(error).slice(0, 1024)}; + record('observer-error', details); + console.error(JSON.stringify({event: 'observer-error', ...details})); response.destroy(error); } }); diff --git a/scripts/conformance/server-parity/query-authority/python.py b/scripts/conformance/server-parity/query-authority/python.py index 2e4d840c2..c4205e9ee 100644 --- a/scripts/conformance/server-parity/query-authority/python.py +++ b/scripts/conformance/server-parity/query-authority/python.py @@ -3,9 +3,12 @@ import importlib.metadata import hashlib import json +import logging import os +import re import sys import time +from datetime import datetime from pathlib import Path from durable_workflow import Client, Worker, workflow @@ -16,6 +19,24 @@ TYPE = 'parity.query-authority.python.v1' EXPECTED = {'long': 9223372036854775807, 'nil': None, 'text': 'rangeλ'} COUNTERS = {'calls': 0, 'returned': 0, 'cancelled': 0} +AUTHORITY_LOSSES = [] + + +class AuthorityLog(logging.Handler): + """Observe the installed SDK's public logging without changing its worker.""" + def emit(self, record): + match = re.fullmatch( + r"(?:query poll error|query delivery suppressed): query task ['\"]([A-Za-z0-9_-]{1,128})['\"] cannot continue: ([a-z_]{1,64})", + record.getMessage()) + if match and len(AUTHORITY_LOSSES) < 16: + loss = {'query_task_id': match[1], 'reason': match[2], 'observed_at': time.time()} + AUTHORITY_LOSSES.append(loss) + print('query authority refusal: ' + json.dumps(loss), file=sys.stderr) + + +worker_log = logging.getLogger('durable_workflow.worker') +worker_log.setLevel(logging.INFO) +worker_log.addHandler(AuthorityLog()) def events(): @@ -91,7 +112,11 @@ async def main(): raise AssertionError('An expired original succeeded') except QueryFailed as error: assert error.status == 504 and error.reason == 'query_worker_execution_timeout' - while (MODE == 'late-poll' and not any(x['event'] == 'delivered' for x in events()) + # Delivery is recorded before the SDK receives its response. + # Wait for its actual refusal before stopping the worker. + while (MODE == 'late-poll' and not any( + loss['query_task_id'] == event['claim']['query_task_id'] + for loss in AUTHORITY_LOSSES for event in events() if event['event'] == 'claim') or MODE == 'late-handler-async' and COUNTERS['cancelled'] != 1 or MODE == 'late-handler-sync' and COUNTERS['returned'] != 1): await asyncio.sleep(.02) @@ -101,7 +126,7 @@ async def main(): assert time.monotonic() - started < 30 observed = events() named = lambda name: [x for x in observed if x['event'] == name] - assert not named('observer-error') + assert not named('observer-error'), named('observer-error') assert len(named('claim')) == len(named('delivered')) == len(named('query-result')) == 1 assert len(named('submission')) == len(named('acknowledgement')) == (1 if MODE == 'timely' else 0) assert named('deregistered')[-1]['status'] == 200 @@ -110,7 +135,23 @@ async def main(): 'cancelled': 1 if MODE == 'late-handler-async' else 0} claim = named('claim')[0]['claim'] assert named('claim')[0]['protocol'] == PROTOCOL - assert bool(named('status')) == (PROTOCOL == '1.20') + assert named('claim')[0]['namespace'] == claim.get('namespace', namespace) == namespace + assert claim['task_queue'] == claim['lease_owner'] == namespace + assert claim['workflow_id'] == namespace and claim['run_id'] == handle.run_id + discovery = named('discovery') + if PROTOCOL == '1.20': + assert discovery and all(x['status'] == 200 and x['query_task_authority'] is True for x in discovery), discovery + if not named('status'): + # Proven expiry needs no redundant HTTP observation. No callback + # alone would also admit malformed metadata or an observer failure. + bound = min(datetime.fromisoformat(claim[key].replace('Z', '+00:00')).timestamp() + for key in ('query_deadline_at', 'lease_expires_at')) + assert MODE == 'late-poll' + assert any(loss['query_task_id'] == claim['query_task_id'] + and loss['reason'] in ('query_execution_budget_expired', 'query_task_timed_out', 'query_lease_expired') + and loss['observed_at'] >= bound for loss in AUTHORITY_LOSSES), AUTHORITY_LOSSES + else: + assert not named('status') for entry in named('status'): for field in ('query_task_id', 'query_task_attempt', 'lease_owner', 'query_admitted_at', 'query_deadline_at', 'lease_expires_at'): @@ -119,6 +160,8 @@ async def main(): 'sdk_version': importlib.metadata.version('durable-workflow'), 'namespace': namespace, 'run_id': handle.run_id, 'claim': claim, 'counters': COUNTERS, 'status_reads': len(named('status')), + 'authority_losses': AUTHORITY_LOSSES, + 'authority_discovery': discovery, 'history_sha256': named('query-result')[0]['history_sha256'], 'original_budget_seconds': 30, 'elapsed_seconds': time.monotonic() - started, 'worker_exit': 0})) diff --git a/scripts/conformance/server-parity/query-authority/rust/src/main.rs b/scripts/conformance/server-parity/query-authority/rust/src/main.rs index 5be71cc1a..45fae759d 100644 --- a/scripts/conformance/server-parity/query-authority/rust/src/main.rs +++ b/scripts/conformance/server-parity/query-authority/rust/src/main.rs @@ -183,7 +183,15 @@ async fn main() -> Result<()> { } } wait(until, || match mode.as_str() { - "late-poll" => !named(&events(Path::new(journal)), "delivered").is_empty(), + "late-poll" => named(&events(Path::new(journal)), "status") + .iter() + .any(|entry| { + entry["observation"]["can_continue"] == false + && matches!( + entry["observation"]["reason"].as_str(), + Some("query_task_timed_out" | "query_lease_expired") + ) + }), "late-handler-async" => counters.cancelled.load(Ordering::SeqCst) == 1, "late-handler-sync" => counters.returned.load(Ordering::SeqCst) == 1, _ => false, @@ -197,7 +205,11 @@ async fn main() -> Result<()> { tokio::time::timeout(until.saturating_duration_since(Instant::now()), runner).await???; let run_id = outcome?; let observed = events(Path::new(journal)); - assert!(named(&observed, "observer-error").is_empty()); + assert!( + named(&observed, "observer-error").is_empty(), + "{:?}", + named(&observed, "observer-error") + ); assert_eq!(named(&observed, "claim").len(), 1); assert_eq!(named(&observed, "delivered").len(), 1); assert_eq!(named(&observed, "query-result").len(), 1); @@ -224,7 +236,21 @@ async fn main() -> Result<()> { let claim = &claim_event["claim"]; assert_eq!(claim_event["protocol"], protocol.as_str()); let status = named(&observed, "status"); - assert_eq!(!status.is_empty(), protocol == "1.20"); + let discovery = named(&observed, "discovery"); + if protocol == "1.20" { + assert!(!discovery.is_empty()); + assert!(discovery + .iter() + .all(|entry| entry["status"] == 200 && entry["query_task_authority"] == true)); + if status.is_empty() { + // Immediate expiry may legally skip status. This SDK exposes no + // public refusal reason, so this recipe cannot qualify that case + // from zero callbacks alone. + return Err("No retained Rust expiry disposition; observation is unqualified".into()); + } + } else { + assert!(status.is_empty()); + } for observation in &status { for field in [ "query_task_id", @@ -244,6 +270,7 @@ async fn main() -> Result<()> { "sdk_version":SDK_VERSION,"namespace":namespace,"run_id":run_id,"claim":claim, "calls":counters.calls.load(Ordering::SeqCst),"returned":counters.returned.load(Ordering::SeqCst), "cancelled":counters.cancelled.load(Ordering::SeqCst),"status_reads":status.len(), + "authority_discovery":discovery, "history_sha256":named(&observed,"query-result")[0]["history_sha256"], "original_budget_seconds":30,"elapsed_seconds":started.elapsed().as_secs_f64(),"worker_exit":0}) ); From ab633a8cbd89f4a383efca5ead86c8ae41560583 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 06:53:15 +0000 Subject: [PATCH 30/36] Require the original expiry reason for every late Python control --- .../server-parity/query-authority/python.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/conformance/server-parity/query-authority/python.py b/scripts/conformance/server-parity/query-authority/python.py index c4205e9ee..7de8e8244 100644 --- a/scripts/conformance/server-parity/query-authority/python.py +++ b/scripts/conformance/server-parity/query-authority/python.py @@ -8,7 +8,6 @@ import re import sys import time -from datetime import datetime from pathlib import Path from durable_workflow import Client, Worker, workflow @@ -20,6 +19,12 @@ EXPECTED = {'long': 9223372036854775807, 'nil': None, 'text': 'rangeλ'} COUNTERS = {'calls': 0, 'returned': 0, 'cancelled': 0} AUTHORITY_LOSSES = [] +EXPIRED_REASONS = {'query_execution_budget_expired', 'query_task_timed_out', 'query_lease_expired'} + + +def expired_losses(task_id): + return [loss for loss in AUTHORITY_LOSSES + if loss['query_task_id'] == task_id and loss['reason'] in EXPIRED_REASONS] class AuthorityLog(logging.Handler): @@ -115,8 +120,8 @@ async def main(): # Delivery is recorded before the SDK receives its response. # Wait for its actual refusal before stopping the worker. while (MODE == 'late-poll' and not any( - loss['query_task_id'] == event['claim']['query_task_id'] - for loss in AUTHORITY_LOSSES for event in events() if event['event'] == 'claim') + expired_losses(event['claim']['query_task_id']) + for event in events() if event['event'] == 'claim') or MODE == 'late-handler-async' and COUNTERS['cancelled'] != 1 or MODE == 'late-handler-sync' and COUNTERS['returned'] != 1): await asyncio.sleep(.02) @@ -138,18 +143,15 @@ async def main(): assert named('claim')[0]['namespace'] == claim.get('namespace', namespace) == namespace assert claim['task_queue'] == claim['lease_owner'] == namespace assert claim['workflow_id'] == namespace and claim['run_id'] == handle.run_id + if MODE != 'timely': + assert expired_losses(claim['query_task_id']), AUTHORITY_LOSSES discovery = named('discovery') if PROTOCOL == '1.20': assert discovery and all(x['status'] == 200 and x['query_task_authority'] is True for x in discovery), discovery if not named('status'): # Proven expiry needs no redundant HTTP observation. No callback # alone would also admit malformed metadata or an observer failure. - bound = min(datetime.fromisoformat(claim[key].replace('Z', '+00:00')).timestamp() - for key in ('query_deadline_at', 'lease_expires_at')) assert MODE == 'late-poll' - assert any(loss['query_task_id'] == claim['query_task_id'] - and loss['reason'] in ('query_execution_budget_expired', 'query_task_timed_out', 'query_lease_expired') - and loss['observed_at'] >= bound for loss in AUTHORITY_LOSSES), AUTHORITY_LOSSES else: assert not named('status') for entry in named('status'): From 1f78f6b975c09b8de9589e50b8f9200464601497 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:30:00 +0000 Subject: [PATCH 31/36] Preserve bounded query client failure diagnostics --- scripts/conformance/server-parity/README.md | 4 ++++ .../server-parity/artifact-summary.mjs | 1 + .../query-authority-clients-profile.sh | 8 ++++++- .../Unit/ServerParityArtifactSummaryTest.mjs | 21 +++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/README.md b/scripts/conformance/server-parity/README.md index e42dbfefb..498782d62 100644 --- a/scripts/conformance/server-parity/README.md +++ b/scripts/conformance/server-parity/README.md @@ -19,6 +19,10 @@ assertions. Reproduce a failed scenario with its original profile and limits when omitted raw details are needed, and keep only the useful failure trace in its owning issue. +Query client failures print a bounded error tail in the job log. Their client +and observer diagnostics take precedence over completed setup logs in failed +artifacts, within the same file, byte and entry limits. + Native cluster discovery publishes the same carrier auth-composition v1 contract as the frozen PHP reference. Published workers validate this manifest before registration; it declares client setting precedence and secret redaction, diff --git a/scripts/conformance/server-parity/artifact-summary.mjs b/scripts/conformance/server-parity/artifact-summary.mjs index 25fa669e7..91b58da86 100644 --- a/scripts/conformance/server-parity/artifact-summary.mjs +++ b/scripts/conformance/server-parity/artifact-summary.mjs @@ -9,6 +9,7 @@ const ENTRY_LIMIT = 128; function priority(path, failed) { const name = basename(path); if (/\.status$/.test(name) || /-status\.txt$/.test(name)) return -1; + if (failed && /(?:^|\/)query-authority\/clients\/(?:php|rust)\/[^/]+\/(?:client\.stderr|observer\.log)$/.test(path)) return 0; if (/^(rust|mysql|postgres|comparator|codec-load|artifact-summary)(-execution)?-tests\.txt$/.test(name)) return 0; if (/^(comparison|compare)([-.].*)?\.json$/.test(name) || /-comparison\.json$/.test(name)) return 1; if (failed && (/^(?:php|embedded|rust)-record\.txt$/.test(name) diff --git a/scripts/conformance/server-parity/query-authority-clients-profile.sh b/scripts/conformance/server-parity/query-authority-clients-profile.sh index 003e6f50b..5db38ff71 100755 --- a/scripts/conformance/server-parity/query-authority-clients-profile.sh +++ b/scripts/conformance/server-parity/query-authority-clients-profile.sh @@ -76,7 +76,13 @@ for language in python rust; do http://parity-query-client-observer:8080 "$mode" "$protocol" /evidence/http.jsonl \ >"$case_output/result.json" 2>"$case_output/client.stderr" || status=$? retire || status=1 - test "$status" = 0 + if test "$status" != 0; then + printf 'Query client failed: target=%s language=%s mode=%s protocol=%s exit=%s\n' \ + "$target" "$language" "$mode" "$protocol" "$status" >&2 + tail -c 8192 "$case_output/client.stderr" >&2 + if test -f "$case_output/observer.log"; then tail -c 8192 "$case_output/observer.log" >&2; fi + exit "$status" + fi jq -e --arg language "$language" --arg mode "$mode" --arg protocol "$protocol" \ '.outcome=="pass" and .language==$language and .mode==$mode and .protocol==$protocol and .worker_exit==0 and .original_budget_seconds==30 and .elapsed_seconds<30' "$case_output/result.json" >/dev/null diff --git a/tests/Unit/ServerParityArtifactSummaryTest.mjs b/tests/Unit/ServerParityArtifactSummaryTest.mjs index cbaa3d422..3dbe14f49 100644 --- a/tests/Unit/ServerParityArtifactSummaryTest.mjs +++ b/tests/Unit/ServerParityArtifactSummaryTest.mjs @@ -92,6 +92,27 @@ test('empty actor diagnostics cannot hide failed profile status or the actual er assert.equal(summary.retained[0].path, 'sdk-profile.status'); }); +test('query client failure survives a full collection of earlier successful setup logs', t => { + const {source, output, root} = fixture(t); + for (let index = 0; index < 140; index++) { + writeFileSync(join(source, `completed-${index}-install.txt`), 'installed successfully\n'); + } + const path = 'query-authority/clients/rust/python-late-poll-120'; + mkdirSync(join(source, path), {recursive: true}); + writeFileSync(join(source, path, 'client.stderr'), 'AssertionError: no original expiry reason\n'); + writeFileSync(join(source, path, 'observer.log'), 'observer error: upstream response closed\n'); + writeFileSync(join(source, 'query-authority/profile-status.txt'), '1\n'); + const summary = summarizeEvidence(source, output, {outcome: 'failure'}); + assert.equal(readFileSync(join(output, path, 'client.stderr'), 'utf8'), 'AssertionError: no original expiry reason\n'); + assert.equal(readFileSync(join(output, path, 'observer.log'), 'utf8'), 'observer error: upstream response closed\n'); + assert.equal(summary.retained.length, 128); + assert.equal(summary.limits.content_bytes, 2 * 1024 * 1024); + const success = join(root, 'success'); + summarizeEvidence(source, success, {outcome: 'success'}); + assert.equal(existsSync(join(success, path, 'client.stderr')), false); + assert.equal(existsSync(join(success, path, 'observer.log')), false); +}); + test('failed recorder diagnostic tails survive without retaining raw records or enlarging limits', t => { const {source, output, root} = fixture(t); const diagnostic = `${'x'.repeat(128 * 1024)}\nOriginal query observation failed\n`; From 4fe4c61c3e9ecff4b0d68df2a002de419503a113 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:50:18 +0000 Subject: [PATCH 32/36] Reject invalid calendar dates in query authority observations --- .../query-authority/observer.mjs | 9 ++- tests/Unit/ServerParityQueryObserverTest.mjs | 71 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/ServerParityQueryObserverTest.mjs diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs index 8dc5193a5..f2bc12d92 100644 --- a/scripts/conformance/server-parity/query-authority/observer.mjs +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -84,7 +84,11 @@ const server = http.createServer(async (request, response) => { if (task && typeof task === 'object') { const utc6 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/; for (const key of ['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time']) { - if (!utc6.test(task[key]) || !Number.isFinite(Date.parse(task[key]))) throw Error('Missing original UTC6 authority: ' + key); + const parsed = Date.parse(task[key]); + if (!utc6.test(task[key]) || !Number.isFinite(parsed) + || new Date(parsed).toISOString() !== task[key].slice(0, 23) + 'Z') { + throw Error('Missing original UTC6 authority: ' + key); + } } const original = JSON.parse(body.toString('utf8')); if (Date.parse(task.query_deadline_at) - Date.parse(task.query_admitted_at) !== 4_000 @@ -131,6 +135,7 @@ const server = http.createServer(async (request, response) => { response.destroy(error); } }); -server.listen(8080, '0.0.0.0', () => console.log(JSON.stringify({ready: true}))); +server.listen(Number(process.env.DW_PARITY_QUERY_OBSERVER_PORT ?? 8080), '0.0.0.0', () => + console.log(JSON.stringify({ready: true, port: server.address().port}))); process.on('SIGTERM', () => server.close(() => process.exit(0))); diff --git a/tests/Unit/ServerParityQueryObserverTest.mjs b/tests/Unit/ServerParityQueryObserverTest.mjs new file mode 100644 index 000000000..1fecda63a --- /dev/null +++ b/tests/Unit/ServerParityQueryObserverTest.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {spawn} from 'node:child_process'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +test('observer forwards valid canonical metadata bytes and refuses normalized invalid calendar dates', {timeout: 5000}, async t => { + const directory = mkdtempSync(join(tmpdir(), 'query-observer-')); + t.after(() => rmSync(directory, {recursive: true, force: true})); + const journal = join(directory, 'journal.jsonl'); + let date = '2026-02-28'; + let original; + const upstream = http.createServer(async (request, response) => { + for await (const _chunk of request) { /* Drain this constructed poll body. */ } + original = JSON.stringify({task: { + query_task_id: 'original-query', query_task_attempt: 1, + namespace: 'isolated', task_queue: 'queue', lease_owner: 'worker', + workflow_id: 'workflow', run_id: 'run', + query_admitted_at: date + 'T00:00:00.123456Z', + query_deadline_at: date + 'T00:00:04.123456Z', + lease_expires_at: date + 'T00:00:04.123456Z', + server_time: date + 'T00:00:00.123456Z', + }}); + response.writeHead(200, {'content-type': 'application/json'}); + response.end(original); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + t.after(() => new Promise(resolve => upstream.close(resolve))); + const observer = spawn(process.execPath, [ + fileURLToPath(new URL('../../scripts/conformance/server-parity/query-authority/observer.mjs', import.meta.url)), + `http://127.0.0.1:${upstream.address().port}`, 'timely', journal, + ], {env: {...process.env, DW_PARITY_QUERY_OBSERVER_PORT: '0'}, stdio: ['ignore', 'pipe', 'pipe']}); + const exited = once(observer, 'exit'); + t.after(async () => { observer.kill('SIGTERM'); assert.deepEqual(await exited, [0, null]); }); + let diagnostic = ''; + observer.stderr.on('data', chunk => { diagnostic += chunk; }); + const ready = await new Promise((resolve, reject) => { + let output = ''; + observer.on('error', reject); + observer.stdout.on('data', chunk => { + output += chunk; + if (output.includes('\n')) resolve(JSON.parse(output.split('\n')[0])); + }); + observer.on('exit', () => reject(Error('Observer stopped before readiness: ' + diagnostic))); + }); + assert.equal(ready.ready, true); + const poll = () => new Promise((resolve, reject) => { + const request = http.request(`http://127.0.0.1:${ready.port ?? 8080}/api/worker/query-tasks/poll`, { + method: 'POST', headers: {'X-Namespace': 'isolated', 'X-Durable-Workflow-Protocol-Version': '1.20'}, + }, response => { + let bytes = ''; + response.on('data', chunk => { bytes += chunk; }); + response.on('error', reject); + response.on('end', () => resolve({status: response.statusCode, bytes})); + }); + request.on('error', reject); + request.end(JSON.stringify({worker_id: 'worker', task_queue: 'queue'})); + }); + assert.deepEqual(await poll(), {status: 200, bytes: original}); + date = '2026-02-30'; + await assert.rejects(poll(), /socket hang up|reset|aborted/i); + const events = readFileSync(journal, 'utf8').trim().split('\n').map(line => JSON.parse(line)); + assert.equal(events.filter(event => event.event === 'claim').length, 1); + assert.equal(events.filter(event => event.event === 'delivered').length, 1); + assert.match(events.find(event => event.event === 'observer-error').message, /query_admitted_at/); +}); From c184f28da460db4b406ecb6eef6396c768d6dc6d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:52:24 +0000 Subject: [PATCH 33/36] Compare original query authority at microsecond precision --- .github/workflows/server-parity.yml | 4 +++- .../query-authority/observer.mjs | 10 ++++++---- tests/Unit/ServerParityQueryObserverTest.mjs | 20 +++++++++++++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 51d89b5eb..62c6cad7f 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -11,6 +11,7 @@ on: - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs + - tests/Unit/ServerParityQueryObserverTest.mjs - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs - tests/Unit/ServerParityCooperativeContractTest.mjs - tests/Unit/ServerParityChildCancellationContractTest.mjs @@ -47,6 +48,7 @@ on: - tests/Fixtures/ServerParityNormalization/** - tests/Unit/ServerParityRunnerTest.mjs - tests/Unit/ServerParityArtifactSummaryTest.mjs + - tests/Unit/ServerParityQueryObserverTest.mjs - tests/Unit/ServerParityCodecDeadlineLoadTest.mjs - tests/Unit/ServerParityCooperativeContractTest.mjs - tests/Unit/ServerParityChildCancellationContractTest.mjs @@ -1343,7 +1345,7 @@ jobs: "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityArtifactSummaryTest.mjs > parity-evidence/artifact-summary-tests.txt + "$image" --test tests/Unit/ServerParityArtifactSummaryTest.mjs tests/Unit/ServerParityQueryObserverTest.mjs > parity-evidence/artifact-summary-tests.txt docker network create "$RESOURCE_SCOPE" docker run -d --name "$RESOURCE_SCOPE-rust" --network "$RESOURCE_SCOPE" --network-alias=parity-rust \ --user="$(id -u):$(id -g)" --cpus=1 --memory=256m --memory-swap=256m \ diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs index f2bc12d92..cbd4ad937 100644 --- a/scripts/conformance/server-parity/query-authority/observer.mjs +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -83,18 +83,20 @@ const server = http.createServer(async (request, response) => { const task = json(reply).task; if (task && typeof task === 'object') { const utc6 = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{6}Z$/; + const times = {}; for (const key of ['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time']) { const parsed = Date.parse(task[key]); if (!utc6.test(task[key]) || !Number.isFinite(parsed) || new Date(parsed).toISOString() !== task[key].slice(0, 23) + 'Z') { throw Error('Missing original UTC6 authority: ' + key); } + times[key] = BigInt(parsed) * 1000n + BigInt(task[key].slice(23, 26)); } const original = JSON.parse(body.toString('utf8')); - if (Date.parse(task.query_deadline_at) - Date.parse(task.query_admitted_at) !== 4_000 - || Date.parse(task.lease_expires_at) < Date.parse(task.query_admitted_at) - || Date.parse(task.lease_expires_at) > Date.parse(task.query_deadline_at) - || Date.parse(task.server_time) < Date.parse(task.query_admitted_at) + if (times.query_deadline_at - times.query_admitted_at !== 4_000_000n + || times.lease_expires_at < times.query_admitted_at + || times.lease_expires_at > times.query_deadline_at + || times.server_time < times.query_admitted_at || task.query_task_attempt !== 1 || task.lease_owner !== original.worker_id || task.task_queue !== original.task_queue || typeof request.headers['x-namespace'] !== 'string' || !request.headers['x-namespace'] diff --git a/tests/Unit/ServerParityQueryObserverTest.mjs b/tests/Unit/ServerParityQueryObserverTest.mjs index 1fecda63a..f5f3292f4 100644 --- a/tests/Unit/ServerParityQueryObserverTest.mjs +++ b/tests/Unit/ServerParityQueryObserverTest.mjs @@ -8,11 +8,12 @@ import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {fileURLToPath} from 'node:url'; -test('observer forwards valid canonical metadata bytes and refuses normalized invalid calendar dates', {timeout: 5000}, async t => { +test('observer preserves response bytes and refuses invalid dates and microsecond authority changes', {timeout: 5000}, async t => { const directory = mkdtempSync(join(tmpdir(), 'query-observer-')); t.after(() => rmSync(directory, {recursive: true, force: true})); const journal = join(directory, 'journal.jsonl'); - let date = '2026-02-28'; + const date = '2026-02-28'; + let overrides = {}; let original; const upstream = http.createServer(async (request, response) => { for await (const _chunk of request) { /* Drain this constructed poll body. */ } @@ -24,6 +25,7 @@ test('observer forwards valid canonical metadata bytes and refuses normalized in query_deadline_at: date + 'T00:00:04.123456Z', lease_expires_at: date + 'T00:00:04.123456Z', server_time: date + 'T00:00:00.123456Z', + ...overrides, }}); response.writeHead(200, {'content-type': 'application/json'}); response.end(original); @@ -62,10 +64,20 @@ test('observer forwards valid canonical metadata bytes and refuses normalized in request.end(JSON.stringify({worker_id: 'worker', task_queue: 'queue'})); }); assert.deepEqual(await poll(), {status: 200, bytes: original}); - date = '2026-02-30'; - await assert.rejects(poll(), /socket hang up|reset|aborted/i); + const invalid = [ + Object.fromEntries(['query_admitted_at', 'query_deadline_at', 'lease_expires_at', 'server_time'].map(key => + [key, `2026-02-30T00:00:${key === 'query_deadline_at' || key === 'lease_expires_at' ? '04' : '00'}.123456Z`])), + {query_deadline_at: date + 'T00:00:04.123457Z'}, + {lease_expires_at: date + 'T00:00:04.123457Z'}, + {server_time: date + 'T00:00:00.123455Z'}, + ]; + for (const fields of invalid) { + overrides = fields; + await assert.rejects(poll(), /socket hang up|reset|aborted/i, JSON.stringify(fields)); + } const events = readFileSync(journal, 'utf8').trim().split('\n').map(line => JSON.parse(line)); assert.equal(events.filter(event => event.event === 'claim').length, 1); assert.equal(events.filter(event => event.event === 'delivered').length, 1); + assert.equal(events.filter(event => event.event === 'observer-error').length, invalid.length); assert.match(events.find(event => event.event === 'observer-error').message, /query_admitted_at/); }); From ea1e5eee9a17a5a414b608865da1df925ee5ead2 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:59:06 +0000 Subject: [PATCH 34/36] Record scoped query fixture and native Python outcomes --- docs/rust-server-port.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index b4397e538..98cae46d6 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -59,8 +59,11 @@ The current integration order is: gives the separate fresh upload normal policy, gates dependent recovered readers on successful target preparation and incorporates qualified PHP empty-command-contract source `35cd3642`. - Promotion needs fresh combined-tree qualification. Earlier passes are not - carried across that integration. + That component's six execution and six health cells pass in run + `38686850381`. Attempt 2 reran only MySQL 8.0, with unchanged source and + limits. Its original query transport failure remains failed and unexplained. + The tested merge tree equals the candidate tree; later combined promotion + still needs fresh qualification. - [#460](https://github.com/durable-workflow/server/pull/460): read-only PHP catalog/data inventory. Four actual published PHP 2.8.0 profiles pass locally, with eight original pending runs, twelve refusals and unchanged source state. @@ -158,8 +161,26 @@ still requires observation. Observer failures now retain bounded method/path diagnostics instead of an unexplained assertion. Five fresh installed Python 2.5.15 controls pass against the pinned PHP 2.8.0 SQLite API, with the original four/thirty-second budgets; the held poll records an actual timeout refusal. -The earlier PostgreSQL observer error remains unexplained, and native/Rust and -full hosted qualification of these recipe changes remain ahead. +The earlier PostgreSQL observer error remains unexplained. Run `38692717327` +at unchanged `17382228` then passes four execution cells and all six health +cells, while MySQL 8.4 and PostgreSQL 17 fail in the installed-client phase +without a retained client error. Those originals remain failed. + +Prepared fixture `c184f28da` preserves bounded client errors ahead of completed +setup logs, refuses invalid calendar dates and compares UTC6 timing using +integer microseconds. Seven focused fixture/exporter controls pass. The old +observer accepts February 30; its calendar-corrected successor still accepts a +one-microsecond deadline change. The corresponding constructed HTTP controls +fail those parents and pass the corrected observer without rewriting bytes. +Five fresh Python 2.5.15 originals also pass against the hosted native binary +from `17382228` / tested merge `3c05f817`, using this revised fixture. The held +poll records `query_task_timed_out` with no callback; async cancellation and +late sync return record their original budget-expiry reasons with no result +submission. Timely 1.20 and 1.19 return exact typed values. All five histories +remain unchanged through their queries and all five Workers exit zero. This +does not qualify the no-status expiry branch, revised Rust consumer or a new +all-database combined tree. Earlier PHP component checks remain scoped to +their original fixture source. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, From ee219c0b49926184efe4b73bf4077500259b6aa4 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 10:35:46 +0000 Subject: [PATCH 35/36] Record query matrix and qualified PHP source integration --- docs/rust-server-port.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 079529c00..d53ccb233 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -1021,8 +1021,21 @@ The [query-authority proposal](https://github.com/durable-workflow/server/issues and [native/shared acknowledgment](https://github.com/durable-workflow/server/issues/433#issuecomment-6138337171) agree on immutable original timing, six-digit UTC fields, inclusive authority expiry, non-renewing protocol-1.20 observation and committed-receipt ordering. -Native implementation and separate shared qualification remain pending; current -internal timing does not establish compliance with the proposed wire contract. +Native implementation is in [#463](https://github.com/durable-workflow/server/pull/463). +Candidate `76c6d35a` passes all thirteen shared jobs in run `38713356493`, +including six execution and six codec/deadline-health database cells. All six +execution artifacts independently match the tested native tree and binary, +with 29 passing comparisons and 20 successful profile statuses each. The +query profiles include normally installed PHP, Python and Rust SDKs, original +timing, delayed poll delivery and late handlers. Conditional prepared-local +and cancellation source qualification remains skipped. + +The next combined candidate incorporates qualified PHP security source +`f7d1bfc7` through merge `500299d6`, preserving the native tree and all shared +fixtures. This requires fresh whole-tree qualification. The PHP registry tag +was rebuilt after its earlier installed check; that earlier check belongs to +its original immutable image only. No current image adoption, complete query +capability, performance, migration or release qualification follows here. The later [installed-consumer observation](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) passes twenty Python 2.5.15 / Rust 3.5.5 originals across separate PHP 2.8.0 and From 083419709e701f6f122c06a57c4af03c5305e175 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 11:46:53 +0000 Subject: [PATCH 36/36] Record matched query qualification and current release guard integration --- docs/rust-server-port.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index d53ccb233..0f6ff5469 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -1030,12 +1030,20 @@ query profiles include normally installed PHP, Python and Rust SDKs, original timing, delayed poll delivery and late handlers. Conditional prepared-local and cancellation source qualification remains skipped. -The next combined candidate incorporates qualified PHP security source -`f7d1bfc7` through merge `500299d6`, preserving the native tree and all shared -fixtures. This requires fresh whole-tree qualification. The PHP registry tag -was rebuilt after its earlier installed check; that earlier check belongs to -its original immutable image only. No current image adoption, complete query -capability, performance, migration or release qualification follows here. +Combined candidate `ee219c0b` incorporates qualified PHP security source +`f7d1bfc7` through merge `500299d6`. Its exact whole tree now passes all thirteen +shared jobs in `38721415410` and every applicable ordinary check. Each of the +six independently audited execution archives has 29 passing comparisons and +20 zero exit records, with exact merge/native/binary identities. The +[qualified checkpoint](https://github.com/durable-workflow/server/pull/463#issuecomment-6191285509) +retains all six digests and earlier failed-original limits. + +The next integration includes qualified release-workflow-only main `28914d25` +through `c7e81c24`. Native, shared, runner and OpenAPI bytes remain unchanged; +that whole tree still requires its own qualification before promotion. The +published PHP image acceptance remains separate from these frozen reference +profiles. No current image adoption, complete query capability, performance, +migration or release qualification follows here. The later [installed-consumer observation](https://github.com/durable-workflow/server/issues/433#issuecomment-6185525047) passes twenty Python 2.5.15 / Rust 3.5.5 originals across separate PHP 2.8.0 and