Skip to content
Closed
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
60 changes: 60 additions & 0 deletions docs/rust-server-port-checkpoints/2026-10-22-worker-admission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# October 22, worker registration admission

- **Gap.** Native accepted a registration without the `capability_manifest`
PHP requires from protocol 1.18. It also skipped most of PHP's request
rules and capability guards, refused a missing `worker_id` or type list,
and answered metadata-capability mismatches in its own shape.
- **Source.** Server 2.10.0 `WorkerController::register`:
- its Laravel rules;
- the prepared-local-activity, Nexus and cooperative-cancellation guards;
- `guardPortableWorkerCapabilityManifest`;
- `WorkflowMetadataCapabilityPolicy::firstProtocolMismatch`;
- the message-streams floor;
- `portableWorkerCapabilityManifest`.
- **Request rules.** Laravel trims strings and turns empty ones into null
first; native now does the same for the whole body.
- Every field follows PHP's rules: `runtime` from PHP's list, sizes,
capacities, task slots, process metrics, heartbeat interval.
- `capability_manifest` is required from protocol 1.18 and must hold the
`local_activities`, `worker_sessions` and `sticky_execution` entries.
Each entry needs `supported` (boolean) and `minimum_protocol_version`.
- Failures answer Laravel's 422 `validation_failed` with its messages. The
rule engine gained `numeric` and `required_array_keys`. A `*.field` rule
now covers only array items, as Laravel's expansion does.
- **Guards.** Each answers 409 with `registered: false` and PHP's fields, in
PHP's order:
- `prepared_local_activity_cancellation_policy_capability_mismatch`,
`prepared_local_activity_group_capability_mismatch` and
`prepared_local_activity_capability_mismatch`;
- `nexus_cooperative_cancellation_capability_mismatch` and
`cooperative_cancellation_protocol_mismatch`, below protocol 1.20;
- `worker_capability_manifest_mismatch`, when a flat affinity capability
and its `supported` flag disagree;
- `worker_capability_protocol_floor_mismatch`, when an entry's floor is
below 1.18 or above the request's protocol;
- `workflow_metadata_capability_protocol_mismatch`, for `memo_upserts`
below 1.14 and `typed_search_attributes` below 1.16. This now uses PHP's
`capability`, `requested_version` and `remediation` fields, not native's
former `worker_capability` and `recorded`;
- `message_streams_unavailable`, below 1.15.
- **Accepted registrations.**
- A missing `worker_id` gets a ULID, as PHP does.
- Missing type lists are stored empty.
- Capabilities keep their non-empty strings.
- The manifest is stored normalized: the three entries, a trimmed floor,
and non-empty `implementation` and `reason`.
- **Worker protocol bodies.** Registration refusals now carry
`protocol_version` and `server_capabilities`, as `WorkerProtocol::json`
does. Other worker-plane refusals still lack them (#46's shape sweep).
- **Tests.**
- `worker_admission.rs` uses the server's own router. It covers the
manifest requirement and its errors, field rules with Laravel's
messages, the generated id, the stored manifest, and every guard.
- The other tests' router adds the manifest an SDK sends from 1.18,
matching each registration's flat capabilities.
- Two cooperative tests registered `cooperative_cancellation` at protocol
1.19, which PHP refuses. They now register at 1.20 and still poll at the
protocol each case tests.
- Full SQLite run (all targets): 230 unit, 401 execution and 2 upgrade
tests passed. Worker, registration, admission, cooperative and
namespace tests: PostgreSQL 124, MySQL 124.
3 changes: 0 additions & 3 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,6 @@ contract fixture before its correction.
deep. Native checks every body as PHP does before any route, but its
handlers parse with serde_json's 128-level limit. A body nested 129 to 512
levels deep passes the check and is then refused with a plain-text 400.
- **Registration without a capability manifest.** At protocol 1.19,
published PHP 2.5.14 refuses a registration that has no
`capability_manifest` (422). Native accepts it.
- **Python worker shutdown after a late synchronous query handler (not yet
diagnosed).** In the SQLite query-authority cohort on #515's run
39431491496, the published Python SDK 2.5.15 client in `late-handler-sync`
Expand Down
59 changes: 38 additions & 21 deletions rust/src/runtime/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1319,29 +1319,46 @@ async fn register(
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<(StatusCode, Json<Value>)> {
if body["capabilities"]
.as_array()
.is_some_and(|flags| flags.iter().any(|flag| flag == "memo_upserts"))
{
memo_protocol(&headers)?;
let protocol = request_protocol(&headers)?;
// Laravel trims every string and turns empty ones into null first.
let mut body = super::laravel_rules::normalize(&body);
super::worker_admission::admit(&body, Some(protocol.trim())).map_err(worker_refusal)?;
// PHP names an unnamed worker with a fresh ULID and stores the
// normalized capability manifest.
if body["worker_id"].is_null() {
body["worker_id"] = json!(ulid::Ulid::new().to_string());
}
if body["capabilities"].as_array().is_some_and(|flags| {
flags
.iter()
.any(|flag| flag == super::search_attribute_upserts::CAPABILITY)
}) && !protocol_at_least(&headers, 16)?
{
return Err(super::RuntimeError::Protocol {
status: StatusCode::CONFLICT,
response: json!({"reason":"workflow_metadata_capability_protocol_mismatch",
"worker_capability":super::search_attribute_upserts::CAPABILITY,
"minimum_protocol_version":super::search_attribute_upserts::MINIMUM_PROTOCOL_VERSION,"recorded":false}),
});
let manifest = super::worker_admission::manifest(&body);
body["capability_manifest"] = manifest;
// Missing type lists are empty; capabilities keep their non-empty
// strings (`nonEmptyStringArray`).
for field in ["supported_workflow_types", "supported_activity_types"] {
if body[field].is_null() {
body[field] = json!([]);
}
}
let flags: Vec<Value> = body["capabilities"]
.as_array()
.into_iter()
.flatten()
.filter(|flag| flag.as_str().is_some_and(|flag| !flag.is_empty()))
.cloned()
.collect();
body["capabilities"] = json!(flags);
let registered = runtime.register(body, Some(protocol)).await.map_err(worker_refusal)?;
Ok((StatusCode::CREATED, Json(worker_response(registered))))
}

/// `WorkerProtocol::json` names the protocol and server capabilities on
/// every worker response, refusals included.
fn worker_refusal(error: super::RuntimeError) -> super::RuntimeError {
match error {
super::RuntimeError::Protocol { status, response } => super::RuntimeError::Protocol {
status,
response: worker_response(response),
},
other => other,
}
Ok((
StatusCode::CREATED,
Json(worker_response(runtime.register(body, Some(request_protocol(&headers)?)).await?)),
))
}

fn worker_response(mut body: Value) -> Value {
Expand Down
33 changes: 30 additions & 3 deletions rust/src/runtime/laravel_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ pub(crate) enum Rule {
String,
Array,
Integer,
/// `numeric`: PHP's `is_numeric`.
Numeric,
Boolean,
/// `required_array_keys`: an array holding each named key.
RequiredArrayKeys(&'static [&'static str]),
/// A size floor: a number, string length or item count, by type rule.
Min(i64),
Max(i64),
Expand Down Expand Up @@ -97,7 +101,12 @@ fn expand(data: &Value, pattern: &str) -> Vec<String> {
Some(Value::Object(fields)) => next.extend(fields.keys().map(|key| joined(key))),
_ => {}
}
} else {
} else if prefix.is_empty()
|| !pattern_has_wildcard_before(pattern, &prefix)
|| lookup(data, &prefix).is_some_and(|item| item.is_array() || item.is_object())
{
// Laravel initializes `item.field` only under array items
// (`data_set` with `*`), so a scalar item has no such field.
next.push(joined(segment));
}
}
Expand All @@ -106,6 +115,12 @@ fn expand(data: &Value, pattern: &str) -> Vec<String> {
paths
}

/// Whether `prefix`, a path built from `pattern`, already passed a `*`.
fn pattern_has_wildcard_before(pattern: &str, prefix: &str) -> bool {
let depth = prefix.split('.').count();
pattern.split('.').take(depth).any(|segment| segment == "*")
}

/// Laravel's displayable attribute: expanded `*` attributes keep their raw
/// path; others read with spaces for underscores.
fn display(attribute: &str, expanded: bool) -> String {
Expand Down Expand Up @@ -163,7 +178,7 @@ fn php_string(value: &Value) -> Option<String> {
}

fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) -> String {
let kind = if rules.iter().any(|rule| matches!(rule, Rule::Integer)) {
let kind = if rules.iter().any(|rule| matches!(rule, Rule::Integer | Rule::Numeric)) {
"numeric"
} else if rules.iter().any(|rule| matches!(rule, Rule::Array)) || value.is_some_and(Value::is_array) {
"array"
Expand All @@ -178,6 +193,10 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) -
Rule::String => format!("The {attribute} field must be a string."),
Rule::Array => format!("The {attribute} field must be an array."),
Rule::Integer => format!("The {attribute} field must be an integer."),
Rule::Numeric => format!("The {attribute} field must be a number."),
Rule::RequiredArrayKeys(keys) => {
format!("The {attribute} field must contain entries for: {}.", keys.join(", "))
}
Rule::Boolean => format!("The {attribute} field must be true or false."),
Rule::In(_) => format!("The selected {attribute} is invalid."),
Rule::Min(limit) => match kind {
Expand All @@ -199,7 +218,7 @@ fn message(rule: Rule, attribute: &str, rules: &[Rule], value: Option<&Value>) -
/// Laravel's `getSize`: a numeric value under a numeric rule, otherwise
/// an item count or the string length.
fn size(value: &Value, rules: &[Rule]) -> Option<f64> {
if rules.iter().any(|rule| matches!(rule, Rule::Integer)) {
if rules.iter().any(|rule| matches!(rule, Rule::Integer | Rule::Numeric)) {
match value {
Value::Number(number) => return number.as_f64(),
Value::String(text) if super::schedule_cron::php_is_numeric(text) => {
Expand Down Expand Up @@ -231,6 +250,14 @@ fn passes(rule: Rule, data: &Value, value: Option<&Value>, rules: &[Rule]) -> bo
.is_some_and(|text| super::schedule_backfill::php_date(text).is_some()),
Rule::Array => value.is_some_and(|value| value.is_array() || value.is_object()),
Rule::Integer => value.and_then(integer).is_some(),
Rule::Numeric => value.is_some_and(|value| match value {
Value::Number(_) => true,
Value::String(text) => super::schedule_cron::php_is_numeric(text),
_ => false,
}),
Rule::RequiredArrayKeys(keys) => value
.and_then(Value::as_object)
.is_some_and(|fields| keys.iter().all(|key| fields.contains_key(*key))),
Rule::Boolean => matches!(
value,
Some(Value::Bool(_))
Expand Down
1 change: 1 addition & 0 deletions rust/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ mod wait_timeouts;
mod worker_contracts;
mod worker_incarnations;
mod worker_management;
mod worker_admission;
mod workflow_failures;
mod workflow_task_guard;

Expand Down
Loading
Loading