Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/rust-server-port-checkpoints/2026-10-22-poll-waits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# October 22, worker poll waits and operation names

- **Gap.** A workflow or activity poll without `timeout_seconds` waited 5
seconds; PHP waits `DW_WORKER_POLL_TIMEOUT` seconds, 30 by default. A
query-task poll without one also waited 5 seconds where PHP starts from
the same setting. Native refused an explicit `null` wait, which PHP's
`nullable` rule accepts.
- **Source.** Server 2.10.0's `LongPoller::until`,
`WorkflowQueryTaskBroker::queryPollTimeoutSeconds` and
`WorkflowController::validateOperationName`.
- **Poll waits.**
- A workflow or activity poll without a wait, or with `null`, waits
`max(0, DW_WORKER_POLL_TIMEOUT)` seconds. The setting is not capped.
- A query-task poll waits the requested time or that setting, capped by
`DW_QUERY_TASK_POLL_TIMEOUT` (default 5); zero answers at once.
- A requested wait passes Laravel's `integer` rule (so `"5"` is five
seconds) and must lie from 0 to 60.
- **Operation names.**
- Signal, update and query requests check the name first, after the
payload transport check, as PHP's controller does. Empty names, names
over 256 bytes and names with ASCII control characters get PHP's
`validation_failed` errors and messages.
- PHP's signal and update name columns hold 255 bytes. Native keeps its
storage guard and refuses a 256-byte or all-space name with
`invalid_signal_name` or `invalid_update_name`. Query names are not
stored in those columns, so a 256-byte query name is accepted.
- **Fixed along the way.** A query poll from a worker registered without
`capabilities` answered 503 `storage_unavailable`, because the claim read
the missing list as required. It now polls empty, as PHP's `stringArray`
does.
- **Tests** (`poll_waits.rs`).
- Workflow, activity and query polls without a wait, or with `null`, take
the configured second.
- A zero setting and a requested `"0"` answer at once.
- Requested waits of 61, -1, `"soon"` and 1.5 are refused.
- A task started during a default wait is claimed.
- A 255-byte signal name is accepted; 256-byte signal and update names
are refused with the storage reasons. Too-long names and names with
control characters get PHP's messages for each kind.
- Full SQLite run (all targets): 228 unit, 397 execution and 2 upgrade
tests passed. Poll, signal, update and query tests: PostgreSQL 122,
MySQL 122.
10 changes: 8 additions & 2 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,14 @@ contract fixture before its correction.
non-default value of the other 67
([checkpoint](rust-server-port-checkpoints/2026-10-22-environment-contract.md)).
Several of those are cheap to honor: repair timing and the multi-node
declaration. Native's defaults still differ from PHP's for two honored
settings: the worker poll timeout and operation name length.
declaration.
- **Signal and update names PHP cannot store.** Native validates signal,
update and query names as PHP does (1 to 256 bytes, no control
characters). PHP's signal and update name columns hold 255 bytes, so PHP
stores a 256-byte name on SQLite and fails with a database error on MySQL
and PostgreSQL. Native refuses such a name, and a name of only spaces,
with `invalid_signal_name` or `invalid_update_name` on every database
([checkpoint](rust-server-port-checkpoints/2026-10-22-poll-waits.md)).
- **Key order inside plain JSON input.** For input that is not an envelope,
PHP encodes nested objects as Avro maps in request order; native encodes
them with sorted keys. The decoded values are equal, but the blobs differ.
Expand Down
14 changes: 10 additions & 4 deletions rust/src/runtime/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ impl Runtime {
max: self.queries.history_page_size_max,
}
}
/// The wait of a workflow or activity poll that names none
/// (DW_WORKER_POLL_TIMEOUT).
pub(super) fn worker_poll_wait(&self) -> u64 {
u64::try_from(self.queries.worker_poll_timeout_seconds).unwrap_or(0)
}
/// Immutable configured-token cohort. Database transitions and scheduler
/// stay shared; configuring credentials does not mutate process state.
pub fn with_role_tokens(
Expand Down Expand Up @@ -513,6 +518,7 @@ impl Runtime {
) -> Result<Value> {
let body = super::start_request::normalized(body);
super::input_envelope::transport(&body)?;
super::laravel_validation::operation_name(name, "signal")?;
super::command_requests::signal(&body)?;
let arguments = self.command_arguments(&body).await?;
let prepared = super::store::prepare_control_arguments(
Expand Down Expand Up @@ -629,6 +635,7 @@ impl Runtime {
) -> Result<(StatusCode, Value)> {
let body = super::start_request::normalized(body);
super::input_envelope::transport(&body)?;
super::laravel_validation::operation_name(name, "update")?;
super::command_requests::update(&body)?;
let arguments = self.command_arguments(&body).await?;
if arguments.len() > super::queries::ARGUMENT_BYTES {
Expand Down Expand Up @@ -684,9 +691,7 @@ impl Runtime {
}
error
};
if name.len() > 256 {
return Err(invalid("query_name", "The query name exceeds the maximum length of 256 bytes."));
}
super::laravel_validation::operation_name(name, "query")?;
self.query_target(workflow_id, run_id).await?;
let input = super::laravel_validation::normalized(body.get("input")).filter(|value| !value.is_null());
if input.as_ref().is_some_and(|value| !value.is_object() && !value.is_array()) {
Expand Down Expand Up @@ -945,8 +950,9 @@ impl Runtime {
Storage::MySql(store) => store.deregister_fenced(worker_id, body, codec).await,
}
}
#[cfg(test)]
delegate!(poll(body: Value, kind: &'static str, protocol_version: &str) -> Result<Value>);
delegate!(poll_with_pages(body: Value, kind: &'static str, protocol_version: &str, pages: super::history_pages::HistoryPages) -> Result<Value>);
delegate!(poll_with_pages(body: Value, kind: &'static str, protocol_version: &str, pages: super::history_pages::HistoryPages, default_wait: u64) -> Result<Value>);
delegate!(heartbeat_task(task_id: &str, body: Value) -> Result<Value>);
pub(crate) async fn deliver_cancellation(&self, task_id: &str, body: Value, protocol: &str) -> Result<Value> {
let codec = self.transition_codec.clone();
Expand Down
6 changes: 4 additions & 2 deletions rust/src/runtime/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1539,8 +1539,9 @@ async fn poll_workflow(
Json(body): Json<Value>,
) -> Result<Json<Value>> {
let pages = runtime.history_pages();
let wait = runtime.worker_poll_wait();
runtime
.poll_with_pages(body, "workflow", request_protocol(&headers)?, pages)
.poll_with_pages(body, "workflow", request_protocol(&headers)?, pages, wait)
.await
.map(Json)
}
Expand All @@ -1549,8 +1550,9 @@ async fn poll_activity(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>> {
let wait = runtime.worker_poll_wait();
runtime
.poll(body, "activity", request_protocol(&headers)?)
.poll_with_pages(body, "activity", request_protocol(&headers)?, Default::default(), wait)
.await
.map(Json)
}
Expand Down
17 changes: 17 additions & 0 deletions rust/src/runtime/laravel_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,20 @@ fn messages(
}
found
}

/// `WorkflowController::validateOperationName`, which a signal, update or
/// query request runs first: the name is one to 256 bytes
/// (DW_MAX_OPERATION_NAME_LENGTH, which native runs only at its default)
/// without ASCII control characters.
pub(super) fn operation_name(name: &str, kind: &str) -> super::Result<()> {
let message = if name.is_empty() {
format!("The {kind} name must not be empty.")
} else if name.len() > 256 {
format!("The {kind} name exceeds the maximum length of 256 bytes.")
} else if name.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
format!("The {kind} name must not contain control characters.")
} else {
return Ok(());
};
Err(failure_messages(vec![(format!("{kind}_name"), vec![message])]).expect("one message"))
}
17 changes: 11 additions & 6 deletions rust/src/runtime/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,11 +553,15 @@ where
pub(super) async fn poll_query(&self, body: Value, config: &QueryTaskConfig) -> Result<Value> {
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"))?;
// `queryPollTimeoutSeconds`: the requested wait, or the worker poll
// wait (DW_WORKER_POLL_TIMEOUT), capped by DW_QUERY_TASK_POLL_TIMEOUT.
let timeout = match body.get("timeout_seconds") {
None | Some(Value::Null) => u64::try_from(config.worker_poll_timeout_seconds).unwrap_or(0),
Some(value) => super::laravel_validation::integer(value)
.filter(|v| (0..=60).contains(v))
.and_then(|v| u64::try_from(v).ok())
.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.
Expand Down Expand Up @@ -788,7 +792,8 @@ where
return Err(refuse(StatusCode::CONFLICT, "task_queue_mismatch"));
}
let types = DB::document_row(&worker, "supported_workflow_types")?;
let capabilities = DB::document_row(&worker, "capabilities")?;
// A registration without capabilities has none (`stringArray`).
let capabilities = DB::optional_document_row(&worker, "capabilities")?.unwrap_or_default();
if !capabilities
.as_array()
.is_some_and(|values| values.iter().any(|v| v == "query_tasks"))
Expand Down
5 changes: 5 additions & 0 deletions rust/src/runtime/query_authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct QueryTaskConfig {
pub history_page_size_default: i64,
pub history_page_size_max: i64,
pub poll_timeout_seconds: i64,
/// How long a workflow or activity poll that names no `timeout_seconds`
/// waits, as PHP's `LongPoller`: `max(0, DW_WORKER_POLL_TIMEOUT)`.
pub worker_poll_timeout_seconds: i64,
}

impl Default for QueryTaskConfig {
Expand All @@ -43,6 +46,7 @@ impl Default for QueryTaskConfig {
history_page_size_default: 500,
history_page_size_max: 1000,
poll_timeout_seconds: 5,
worker_poll_timeout_seconds: 30,
}
}
}
Expand Down Expand Up @@ -118,6 +122,7 @@ impl QueryTaskConfig {
5,
)?
.max(1),
worker_poll_timeout_seconds: poll.max(0),
})
}

Expand Down
23 changes: 17 additions & 6 deletions rust/src/runtime/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,8 @@ where
// The authenticated request's context; a direct call records only
// the request id and signal name.
let context = context.unwrap_or_else(|| json!({"request":{"request_id":body["request_id"]},"signal_name":name}));
// PHP's signal_name column holds 255 bytes, one fewer than its
// validation allows; native refuses what it cannot store.
if name.trim().is_empty() || name.len() > 255 {
return Err(refuse(
StatusCode::UNPROCESSABLE_ENTITY,
Expand Down Expand Up @@ -1252,31 +1254,40 @@ where
)
}

/// A poll with the default page sizes and PHP's default 30-second wait.
#[cfg(test)]
pub(crate) async fn poll(
&self,
body: Value,
kind: &'static str,
protocol_version: &str,
) -> Result<Value> {
self.poll_with_pages(body, kind, protocol_version, Default::default())
self.poll_with_pages(body, kind, protocol_version, Default::default(), 30)
.await
}

/// A workflow or activity poll, with the configured history page sizes.
/// A poll that names no `timeout_seconds` waits `default_wait` seconds,
/// as PHP's `LongPoller` waits DW_WORKER_POLL_TIMEOUT.
pub(crate) async fn poll_with_pages(
&self,
body: Value,
kind: &'static str,
protocol_version: &str,
pages: super::history_pages::HistoryPages,
default_wait: u64,
) -> Result<Value> {
text(&body, "worker_id")?;
text(&body, "task_queue")?;
let timeout = body
.get("timeout_seconds")
.map_or(Some(5), Value::as_u64)
.filter(|t| *t <= 60)
.ok_or_else(|| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_poll_timeout"))?;
// Laravel's `nullable|integer|min:0|max:60` bounds a requested wait;
// the configured default is not capped.
let timeout = match body.get("timeout_seconds") {
None | Some(Value::Null) => default_wait,
Some(value) => super::laravel_validation::integer(value)
.filter(|t| (0..=60).contains(t))
.and_then(|t| u64::try_from(t).ok())
.ok_or_else(|| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_poll_timeout"))?,
};
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout);
let requested = super::history_pages::requested_size(&body)?;
let encoding = if kind == "workflow" {
Expand Down
2 changes: 2 additions & 0 deletions rust/src/runtime/updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ where
context: Option<Value>,
codec: Arc<Semaphore>,
) -> Result<(StatusCode, Value)> {
// PHP's update_name column holds 255 bytes, one fewer than its
// validation allows; native refuses what it cannot store.
if name.trim().is_empty() || name.len() > 255 {
return Err(refuse(
StatusCode::UNPROCESSABLE_ENTITY,
Expand Down
2 changes: 2 additions & 0 deletions rust/tests/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ mod parent_close_policies;
mod history_pages;
#[path = "execution/declared_signals.rs"]
mod declared_signals;
#[path = "execution/poll_waits.rs"]
mod poll_waits;
#[path = "execution/child_cascade.rs"]
mod child_cascade;
#[path = "execution/completion_open_work.rs"]
Expand Down
Loading
Loading