diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 54eaaa326..2fb2da819 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -73,6 +73,7 @@ on: - tests/Unit/ServerParityChildVisibilityContractTest.mjs - tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs - tests/Unit/ServerParityDeepJsonBodiesContractTest.mjs + - tests/Unit/ServerParityKeyOrderContractTest.mjs - tests/Unit/ServerParityVisibilityCollationContractTest.mjs - tests/Unit/ServerParitySearchAttributeDatetimesContractTest.mjs - tests/Unit/ServerParityRetriedChildCancellationContractTest.mjs @@ -2229,6 +2230,7 @@ jobs: tests/Unit/ServerParityChildVisibilityContractTest.mjs \ tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs \ tests/Unit/ServerParityDeepJsonBodiesContractTest.mjs \ + tests/Unit/ServerParityKeyOrderContractTest.mjs \ tests/Unit/ServerParityVisibilityCollationContractTest.mjs \ tests/Unit/ServerParitySearchAttributeDatetimesContractTest.mjs \ tests/Unit/ServerParityRetriedChildCancellationContractTest.mjs \ diff --git a/docs/rust-server-port-checkpoints/2026-11-01-key-order.md b/docs/rust-server-port-checkpoints/2026-11-01-key-order.md new file mode 100644 index 000000000..57c5afe73 --- /dev/null +++ b/docs/rust-server-port-checkpoints/2026-11-01-key-order.md @@ -0,0 +1,66 @@ +# November 1, key order in plain input and search attribute errors + +- **Before.** Native's plain JSON input reached the Avro writer through + `memos::from_json`, whose maps are `BTreeMap`s: + - a nested object was encoded with sorted keys, so its blob differed from + PHP's while the decoded values were equal; + - a nested object with keys 0 to n-1 in any order read as a list, so + `{"1": "a", "0": "b"}` became `["b", "a"]`; + - before #906 (`serde_json` without `preserve_order`), an object input's + values and several search attribute errors also came out in sorted key + order. +- **Contract** (published 2.11.7, Workflow 2.15.7). + `PayloadEnvelopeResolver::resolve` takes a plain input's values in + document order (`array_values`) and serializes them. `Avro::toDatum` + encodes a PHP list (`array_is_list`) as an Avro array and any other array + as a map in document order. Its keys must be strings, and `json_decode` + turns a canonical integer string into an integer key, so an integer-keyed + object that is not a list fails with `invalid_map_key`: + - on a start, Core's exception reaches the controller, which answers + `422 validation_failed` with the message under `workflow_type`, and no + run is created; + - on a signal, it answers `500 control_plane_internal_error` + (`operation_failed`, `indeterminate`). + + `SearchAttributeValueValidator` loops over the attributes in request + order, so several errors on a start (`search_attributes`) or on one + upsert command (`commands.N.attributes`) are reported in that order. +- **Native.** `plain_input::ordered` converts plain input to an + `OrderedValue` in document order with `Avro::toDatum`'s rules, and the + ordered Avro writer encodes it (`queries::encode_ordered_arguments`). Every + plain input path uses it: start, bridge start, standalone activity + start, signal, update, query and message stream append. A refusal on a + non-bridge start is PHP's validation failure under `workflow_type`; the + other paths keep `422 invalid_workflow_arguments` rather than PHP's 500. + Search attribute errors already followed request order after #906. +- **Ordered writer.** `ValueCodec::encode_ordered_value` used Apache's datum + writer over the binding schema and then read its own blob back. Plain + input can nest 511 levels (`deep-json-bodies`), and those two steps + overflowed even a 64 MiB stack there. The writer now emits the protocol + Value's Avro binary layout directly, one small frame per level, and does + not read the blob back. A 511-level value encodes in a 2 MiB stack. A unit + test checks it byte for byte against Apache's binding writer, and the + existing test against PHP's published bytes still passes. Query routing, + the other caller, writes the same bytes as before. +- **Fixture.** `key-order`, recorded against published 2.11.7 on SQLite, + in a fresh namespace without registered attributes. The probe sends each + body as the fixture's JSON text, so key order and integer keys survive: + - a nested map, whose stored input blob is PHP's exact bytes (`zeta` + before `alpha`, `yak` before `bee`); + - `{"1": "a", "0": "b"}` and `{"second": "b", "first": "a"}` as the whole + input, stored as `["a", "b"]` and `["b", "a"]`; native before #906 gave + the second as `["a", "b"]`; + - `[{"1": "a", "0": "b"}]` and `[{"1": "a"}]`, refused with the 422; + - a signal's nested map, stored in document order; + - three invalid search attributes, `Zz`, `9bad`, `Aa`, on a start and on + an upsert command, reported in that order; native before #906 gave + `9bad`, `Aa`, `Zz`. + + The fixture passes against published 2.11.7 (recorded twice) and against + native on SQLite. The native recording ran before the direct writer + replaced the binding writer; the two write identical bytes. +- **Tests.** `plain_input.rs` unit test; `wire_order.rs` + `the_direct_writer_matches_apache_s_binding_writer`; `key_order.rs` execution tests + (PHP's exact blobs, the refusal, the signal blob and the error order); + `ServerParityKeyOrderContractTest.mjs`, which rejects sorted maps, sorted + object values, out-of-order integer keys read as a list and sorted errors. diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index f50db6877..e71b34c83 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -429,9 +429,21 @@ contract fixture before its correction. 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. +- **Key order inside plain JSON input** (November 1, closed). For input + that is not an envelope, published PHP takes an object input's values in + document order (`array_values`) and encodes each nested object as an Avro + map in document order (`Avro::toDatum`). A nested object whose integer + keys are not 0 to n-1 in document order, such as `{"1": "a", "0": "b"}`, + is not a list, and its encoder fails with `invalid_map_key`. Native + encoded nested maps with sorted keys and read such an object as the list + `["b", "a"]`. It now encodes plain input in document order through the + ordered Avro writer, so its blobs are PHP's byte for byte, and refuses an + integer-keyed object that is not a list: on a start with PHP's 422 under + `workflow_type`. On a signal, update, query, message or bridge start it + keeps native's `422 invalid_workflow_arguments`, where PHP's signal answers + `500 control_plane_internal_error`. The `key-order` fixture records starts + and a signal against published 2.11.7 + ([checkpoint](rust-server-port-checkpoints/2026-11-01-key-order.md)). - **Child completion while the parent has an open workflow task** (November 1; ready task closed, leased task decided; [checkpoint](rust-server-port-checkpoints/2026-11-01-child-open-parent-task.md)). @@ -774,9 +786,14 @@ contract fixture before its correction. without trailing spaces). The `visibility-collation` fixture records both collations against published 2.11.7 ([checkpoint](rust-server-port-checkpoints/2026-10-30-visibility-collation.md)). -- **Visibility query key order.** When one command or query has several - errors, native reports them in sorted key order rather than request order. - The same holds for several invalid search attributes on one start. +- **Search attribute validation order** (November 1, closed). Published + PHP reports several invalid search attributes in request order, on a + start and on one `upsert_search_attributes` command, as + `SearchAttributeValueValidator` loops over them. Native reported them in + sorted key order. Since `serde_json`'s `preserve_order` (#906) its + validator visits the request's keys in document order, and the `key-order` + fixture records both orders against published 2.11.7 + ([checkpoint](rust-server-port-checkpoints/2026-11-01-key-order.md)). - **Inherited memos and search attributes on child runs** (October 29, closed). Published PHP copies the parent's memos and search attributes to a child run, and the failed run's to a retried child diff --git a/rust/src/codec/wire_order.rs b/rust/src/codec/wire_order.rs index 2d6265b95..710226aec 100644 --- a/rust/src/codec/wire_order.rs +++ b/rust/src/codec/wire_order.rs @@ -8,7 +8,7 @@ //! block and union, and the public schema still validates and frames. use super::{CodecError, CompleteInput, VALUE_FINGERPRINT, VALUE_SCHEMA, ValueCodec, php_base64}; use apache_avro::{ - Schema, reader::datum::GenericDatumReader, types::Value as Datum, writer::datum::GenericDatumWriter, + Schema, reader::datum::GenericDatumReader, types::Value as Datum, }; use base64::{Engine, engine::general_purpose::STANDARD}; use std::{collections::HashMap, sync::LazyLock}; @@ -62,45 +62,86 @@ impl ValueCodec { /// Encodes a value with its map entries in the order given, under the /// public single-object header. pub fn encode_ordered_value(&self, value: &OrderedValue) -> Result { - let datum = GenericDatumWriter::builder(&BINDING).build()?.write_value_to_vec(to_binding(value)?)?; - let mut bytes = Vec::with_capacity(MARKER.len() + VALUE_FINGERPRINT.len() + datum.len()); + // Written directly in the protocol Value's Avro binary layout: a deep + // value costs one small frame per level, where Apache's datum writer + // over the binding schema needs far more stack than a 511-level + // request leaves. + let mut bytes = Vec::with_capacity(64); bytes.extend_from_slice(&MARKER); bytes.extend_from_slice(&VALUE_FINGERPRINT); - bytes.extend_from_slice(&datum); - let blob = STANDARD.encode(bytes); - // The public reader must accept what the binding wrote. - self.decode(&blob)?; - Ok(blob) + write_value(value, &mut bytes)?; + // The layout is checked against Apache's writer and PHP's published + // bytes in tests; reading it back here would cost a deep value more + // stack than writing it. + Ok(STANDARD.encode(bytes)) } } -fn record(field: &str, value: Datum) -> Datum { - Datum::Record(vec![(field.into(), value)]) +/// Avro's zig-zag variable-length long. +fn write_long(number: i64, out: &mut Vec) { + let mut zigzag = ((number << 1) ^ (number >> 63)) as u64; + while zigzag >= 0x80 { + out.push((zigzag as u8 & 0x7f) | 0x80); + zigzag >>= 7; + } + out.push(zigzag as u8); +} + +fn write_bytes(bytes: &[u8], out: &mut Vec) { + write_long(bytes.len() as i64, out); + out.extend_from_slice(bytes); } -fn to_binding(value: &OrderedValue) -> Result { - let (index, branch) = match value { - OrderedValue::Null => (0, Datum::Null), - OrderedValue::Boolean(flag) => (1, record("boolean", Datum::Boolean(*flag))), - OrderedValue::Long(number) => (2, record("long", Datum::Long(*number))), +/// One protocol Value datum: the union branch index, then the branch +/// record's one field. An array or map is one block of all its items (none +/// when empty) and the end marker, as Apache's and PHP's writers emit. +fn write_value(value: &OrderedValue, out: &mut Vec) -> Result<(), CodecError> { + match value { + OrderedValue::Null => write_long(0, out), + OrderedValue::Boolean(flag) => { + write_long(1, out); + out.push(u8::from(*flag)); + } + OrderedValue::Long(number) => { + write_long(2, out); + write_long(*number, out); + } OrderedValue::Double(number) if !number.is_finite() => return Err(CodecError::NonFiniteDouble), - OrderedValue::Double(number) => (3, record("double", Datum::Double(*number))), - OrderedValue::Bytes(bytes) => (4, record("bytes", Datum::Bytes(bytes.clone()))), - OrderedValue::String(text) => (5, record("string", Datum::String(text.clone()))), + OrderedValue::Double(number) => { + write_long(3, out); + out.extend_from_slice(&number.to_le_bytes()); + } + OrderedValue::Bytes(bytes) => { + write_long(4, out); + write_bytes(bytes, out); + } + OrderedValue::String(text) => { + write_long(5, out); + write_bytes(text.as_bytes(), out); + } OrderedValue::Array(items) => { - (6, record("items", Datum::Array(items.iter().map(to_binding).collect::>()?))) + write_long(6, out); + if !items.is_empty() { + write_long(items.len() as i64, out); + for item in items { + write_value(item, out)?; + } + } + write_long(0, out); } OrderedValue::Map(entries) => { - let entries = entries - .iter() - .map(|(key, value)| { - Ok(Datum::Record(vec![("key".into(), Datum::String(key.clone())), ("value".into(), to_binding(value)?)])) - }) - .collect::>()?; - (7, record("entries", Datum::Array(entries))) + write_long(7, out); + if !entries.is_empty() { + write_long(entries.len() as i64, out); + for (key, item) in entries { + write_bytes(key.as_bytes(), out); + write_value(item, out)?; + } + } + write_long(0, out); } - }; - Ok(record("value", Datum::Union(index, Box::new(branch)))) + } + Ok(()) } /// The one field of a record the binding schema read. @@ -205,6 +246,59 @@ mod tests { assert!(codec.decode_ordered("AAAA").is_err()); } + /// Apache's datum writer over the binding schema, the reference the + /// direct writer replaced. + fn apache_reference(value: &OrderedValue) -> String { + use apache_avro::writer::datum::GenericDatumWriter; + fn record(field: &str, value: Datum) -> Datum { + Datum::Record(vec![(field.into(), value)]) + } + fn binding(value: &OrderedValue) -> Datum { + let (index, branch) = match value { + OrderedValue::Null => (0, Datum::Null), + OrderedValue::Boolean(flag) => (1, record("boolean", Datum::Boolean(*flag))), + OrderedValue::Long(number) => (2, record("long", Datum::Long(*number))), + OrderedValue::Double(number) => (3, record("double", Datum::Double(*number))), + OrderedValue::Bytes(bytes) => (4, record("bytes", Datum::Bytes(bytes.clone()))), + OrderedValue::String(text) => (5, record("string", Datum::String(text.clone()))), + OrderedValue::Array(items) => (6, record("items", Datum::Array(items.iter().map(binding).collect()))), + OrderedValue::Map(entries) => (7, record("entries", Datum::Array(entries.iter() + .map(|(key, value)| Datum::Record(vec![("key".into(), Datum::String(key.clone())), ("value".into(), binding(value))])) + .collect()))), + }; + record("value", Datum::Union(index, Box::new(branch))) + } + let datum = GenericDatumWriter::builder(&BINDING).build().unwrap().write_value_to_vec(binding(value)).unwrap(); + STANDARD.encode([MARKER.as_slice(), VALUE_FINGERPRINT.as_slice(), datum.as_slice()].concat()) + } + + #[test] + fn the_direct_writer_matches_apache_s_binding_writer() { + let codec = ValueCodec::new().unwrap(); + let many: Vec = (0..200).map(|index| OrderedValue::Long(index * 1_000_003 - 7)).collect(); + let entries: Vec<(String, OrderedValue)> = (0..70).map(|index| (format!("k{}", 70 - index), OrderedValue::Bytes(vec![index as u8; index]))).collect(); + for value in [ + OrderedValue::Null, + OrderedValue::Boolean(true), + OrderedValue::Long(i64::MIN), + OrderedValue::Long(i64::MAX), + OrderedValue::Long(-1), + OrderedValue::Double(-0.0), + OrderedValue::Double(1.5e300), + OrderedValue::String("λ ключ 🙂".into()), + OrderedValue::Bytes(vec![]), + OrderedValue::Array(vec![]), + OrderedValue::Map(vec![]), + OrderedValue::Array(many), + OrderedValue::Map(entries), + OrderedValue::Array(vec![OrderedValue::Map(vec![("z".into(), OrderedValue::Array(vec![OrderedValue::Null])), + ("a".into(), OrderedValue::Map(vec![("".into(), OrderedValue::Boolean(false))]))])]), + ] { + assert_eq!(codec.encode_ordered_value(&value).unwrap(), apache_reference(&value), "{value:?}"); + } + assert!(matches!(codec.encode_ordered_value(&OrderedValue::Double(f64::NAN)), Err(CodecError::NonFiniteDouble))); + } + #[test] fn a_repeated_key_keeps_its_first_position_and_last_value() { let codec = ValueCodec::new().unwrap(); diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 6bbb83d6c..d823e8895 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -469,17 +469,30 @@ impl Runtime { /// empty input is no arguments and any list or object other than an /// envelope is its values, both encoded here. An envelope is left as sent. async fn plain_arguments(&self, body: &Value) -> Result> { + self.plain_arguments_or(body, || refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_workflow_arguments")).await + } + + /// The plain input in document order (`super::plain_input`), or + /// `refusal` where PHP's serializer fails with `invalid_map_key`. + async fn plain_arguments_or( + &self, + body: &Value, + refusal: impl FnOnce() -> super::RuntimeError, + ) -> Result> { super::input_envelope::check(body)?; let arguments = match super::start_request::input(body) { super::start_request::Input::Envelope => return Ok(None), super::start_request::Input::Empty => Vec::new(), - super::start_request::Input::Values(values) => values - .into_iter() - .map(|value| super::memos::from_json(value.clone())) - .collect::>() - .map_err(|_| refuse(StatusCode::UNPROCESSABLE_ENTITY, "invalid_workflow_arguments"))?, + super::start_request::Input::Values(values) => match values.into_iter().map(super::plain_input::ordered).collect() { + Some(arguments) => arguments, + None => return Err(refusal()), + }, }; - let blob = super::queries::encode_arguments(self.signal_codec.clone(), crate::codec::Value::Array(arguments)).await?; + let blob = super::queries::encode_ordered_arguments( + self.signal_codec.clone(), + crate::codec::OrderedValue::Array(arguments), + ) + .await?; Ok(Some(blob)) } @@ -566,7 +579,21 @@ impl Runtime { let mut body = super::start_request::normalized(body); super::input_envelope::transport(&body)?; super::start_request::validate(&body)?; - match self.plain_arguments(&body).await? { + // Core's start reports the serializer's `invalid_map_key` under + // `workflow_type`, as the controller reports Core's LogicException. + let plain = if bridge { + self.plain_arguments(&body).await? + } else { + self.plain_arguments_or(&body, || { + super::laravel_validation::failure_messages(vec![( + "workflow_type".to_owned(), + vec![super::plain_input::INVALID_MAP_KEY.to_owned()], + )]) + .expect("one message") + }) + .await? + }; + match plain { Some(blob) => body["input"] = json!({"codec": "avro", "blob": blob}), // PHP 2.5.14 answers an undecodable start blob with a 500 // (server#601); native refuses it as PHP's signal does. diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index ebaadc2eb..79064b6ba 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -100,6 +100,7 @@ mod php_value; #[cfg(debug_assertions)] #[doc(hidden)] pub mod poll_pauses; +mod plain_input; mod poll_readiness; pub mod postgres; mod previews; diff --git a/rust/src/runtime/plain_input.rs b/rust/src/runtime/plain_input.rs new file mode 100644 index 000000000..2318b378e --- /dev/null +++ b/rust/src/runtime/plain_input.rs @@ -0,0 +1,71 @@ +//! Plain command input as published PHP encodes it. `PayloadEnvelopeResolver` +//! takes the request's values in document order (`array_values`), and +//! `Avro::toDatum` encodes each one: a PHP list (keys 0 to n-1 in that +//! order) is an Avro array, any other array is a map in document order, +//! and an array with an integer key that is not a list fails with +//! `invalid_map_key`. `json_decode` turns a canonical integer string key into +//! an integer, so `{"1": "a", "0": "b"}` is not a list. Native's JSON objects +//! keep document order (`serde_json` with `preserve_order`). +use crate::codec::OrderedValue; +use serde_json::Value; + +/// `Avro::toDatum`'s refusal of an integer key in a map. +pub(super) const INVALID_MAP_KEY: &str = + "invalid_map_key: Avro Value maps require string keys; keys are never stringified."; + +/// A key `json_decode` stores as an integer: a canonical decimal in range. +fn integer_key(key: &str) -> bool { + let digits = key.strip_prefix('-').unwrap_or(key); + let canonical = key == "0" || (!digits.is_empty() && !digits.starts_with('0') && digits.bytes().all(|byte| byte.is_ascii_digit())); + canonical && key.parse::().is_ok() +} + +/// One value as PHP's serializer encodes it, or `None` for `invalid_map_key`. +pub(super) fn ordered(value: &Value) -> Option { + Some(match value { + Value::Null => OrderedValue::Null, + Value::Bool(flag) => OrderedValue::Boolean(*flag), + Value::Number(number) => match number.as_i64() { + Some(integer) => OrderedValue::Long(integer), + None => OrderedValue::Double(number.as_f64()?), + }, + Value::String(text) => OrderedValue::String(text.clone()), + Value::Array(items) => OrderedValue::Array(items.iter().map(ordered).collect::>()?), + Value::Object(entries) => { + if entries.keys().enumerate().all(|(position, key)| *key == position.to_string()) { + OrderedValue::Array(entries.values().map(ordered).collect::>()?) + } else if entries.keys().any(|key| integer_key(key)) { + return None; + } else { + OrderedValue::Map(entries.iter().map(|(key, value)| Some((key.clone(), ordered(value)?))).collect::>()?) + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map(entries: &[(&str, OrderedValue)]) -> OrderedValue { + OrderedValue::Map(entries.iter().map(|(key, value)| ((*key).to_owned(), value.clone())).collect()) + } + + #[test] + fn objects_keep_document_order_and_lists_need_ordered_integer_keys() { + let string = |text: &str| OrderedValue::String(text.to_owned()); + assert_eq!(ordered(&json!({"zeta": 1, "alpha": {"yak": 2, "bee": 3}})), + Some(map(&[("zeta", OrderedValue::Long(1)), ("alpha", map(&[("yak", OrderedValue::Long(2)), ("bee", OrderedValue::Long(3))]))]))); + assert_eq!(ordered(&json!({"0": "a", "1": "b"})), Some(OrderedValue::Array(vec![string("a"), string("b")]))); + assert_eq!(ordered(&json!({})), Some(OrderedValue::Array(vec![]))); + // Integer keys out of list order, or with a gap, are not a list. + assert_eq!(ordered(&json!({"1": "a", "0": "b"})), None); + assert_eq!(ordered(&json!({"1": "a"})), None); + assert_eq!(ordered(&json!([{"a": 1, "-1": 2}])), None); + // Keys json_decode keeps as strings. + assert_eq!(ordered(&json!({"01": 1, "-0": 2, "+1": 3})), + Some(map(&[("01", OrderedValue::Long(1)), ("-0", OrderedValue::Long(2)), ("+1", OrderedValue::Long(3))]))); + assert_eq!(ordered(&json!(1.5)), Some(OrderedValue::Double(1.5))); + } +} diff --git a/rust/src/runtime/queries.rs b/rust/src/runtime/queries.rs index 279a681f9..63a333793 100644 --- a/rust/src/runtime/queries.rs +++ b/rust/src/runtime/queries.rs @@ -81,6 +81,25 @@ pub(super) async fn empty_arguments(semaphore: Arc) -> Result } /// One Avro Value blob with the official codec, off the async workers. +/// Encodes arguments with their map entries in the order given, as PHP's +/// serializer writes a decoded request. +pub(super) async fn encode_ordered_arguments(semaphore: Arc, arguments: crate::codec::OrderedValue) -> Result { + let permit = semaphore + .acquire_owned() + .await + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_codec_unavailable"))?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + let codec = crate::codec::ValueCodec::new() + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_codec_unavailable"))?; + codec + .encode_ordered_value(&arguments) + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_codec_unavailable")) + }) + .await + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "query_codec_unavailable"))? +} + pub(super) async fn encode_arguments(semaphore: Arc, arguments: crate::codec::Value) -> Result { let permit = semaphore .acquire_owned() diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index a76056e85..618a946c7 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -362,6 +362,8 @@ mod child_descriptions; mod child_retries; #[path = "execution/deep_json_bodies.rs"] mod deep_json_bodies; +#[path = "execution/key_order.rs"] +mod key_order; #[path = "execution/activity_result_repeats.rs"] mod activity_result_repeats; #[path = "execution/task_fairness.rs"] diff --git a/rust/tests/execution/key_order.rs b/rust/tests/execution/key_order.rs new file mode 100644 index 000000000..1b95b57da --- /dev/null +++ b/rust/tests/execution/key_order.rs @@ -0,0 +1,86 @@ +use super::*; + +// Plain JSON input keeps document order, as published PHP's +// PayloadEnvelopeResolver and Avro::toDatum encode it, and several invalid +// search attributes on a start are reported in request order. The key-order +// fixture records the same answers against published 2.11.7. + +async fn started(app: &Router, id: &str, input: Value) -> (StatusCode, Value) { + request(app, "POST", "/api/workflows", + json!({"workflow_id": id, "workflow_type": "echo", "task_queue": "test", "input": input})).await +} + +async fn stored_input(app: &Router, id: &str) -> (Value, Value) { + let (status, body) = request(app, "GET", &format!("/api/workflows/{id}"), Value::Null).await; + assert_eq!(status, StatusCode::OK, "{body}"); + (body["input"].clone(), body["input_envelope"]["blob"].clone()) +} + +#[tokio::test] +async fn plain_input_keeps_document_order_like_php() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + + // A nested map is encoded in document order: PHP's exact blob. + let (status, body) = started(&app, "nested-map", json!([{"zeta": 1, "alpha": {"yak": 2, "bee": 3}}, "x"])).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(stored_input(&app, "nested-map").await.1, + json!("wwHioz3/VYAiNwwEDgQIemV0YQQCCmFscGhhDgQGeWFrBAQGYmVlBAYAAAoCeAA=")); + + // An object input is its values in document order (array_values). + for (id, input, values) in [ + ("object-integer-keys", json!({"1": "a", "0": "b"}), json!(["a", "b"])), + ("object-named-keys", json!({"second": "b", "first": "a"}), json!(["b", "a"])), + ] { + let (status, body) = started(&app, id, input).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(stored_input(&app, id).await.0, values, "{id}"); + } + + // A nested object with an integer key is not a list unless its keys are + // 0 to n-1 in document order; PHP's serializer refuses it. + for (id, input) in [("nested-integer-keys", json!([{"1": "a", "0": "b"}])), ("nested-integer-key", json!([{"1": "a"}]))] { + let (status, body) = started(&app, id, input).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["reason"], "validation_failed", "{body}"); + assert_eq!(body["message"], "invalid_map_key: Avro Value maps require string keys; keys are never stringified."); + assert_eq!(body["errors"], json!({"workflow_type": + ["invalid_map_key: Avro Value maps require string keys; keys are never stringified."]})); + assert_eq!(request(&app, "GET", &format!("/api/workflows/{id}"), Value::Null).await.0, StatusCode::NOT_FOUND); + } + + // A signal's plain arguments are stored the same way. + let run = body_run(&app, "nested-map").await; + let (status, body) = request(&app, "POST", "/api/workflows/nested-map/signal/go", + json!({"input": [{"zeta": 1, "alpha": 2}]})).await; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let history = run_history(&app, &json!("nested-map"), &run).await; + let received: Vec<_> = history.as_array().unwrap().iter().filter(|event| event["event_type"] == "SignalReceived").collect(); + assert_eq!(received[0]["payload"]["arguments"]["blob"], json!("wwHioz3/VYAiNwwCDgQIemV0YQQCCmFscGhhBAQAAA==")); + + runtime.close().await; + database.remove().await; +} + +async fn body_run(app: &Router, id: &str) -> Value { + request(app, "GET", &format!("/api/workflows/{id}"), Value::Null).await.1["run_id"].clone() +} + +#[tokio::test] +async fn several_invalid_start_search_attributes_keep_request_order() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let (status, body) = request(&app, "POST", "/api/workflows", json!({"workflow_id": "attribute-errors", + "workflow_type": "echo", "task_queue": "test", "input": ["x"], + "search_attributes": {"Zz": ["l"], "9bad": "x", "Aa": {"m": 1}}})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["errors"]["search_attributes"], json!([ + "Search attribute [Zz] must be a scalar value or null unless it is registered as keyword_list.", + "Search attribute key [9bad] must start with a letter and contain only letters, numbers, and underscores.", + "Search attribute [Aa] must be a scalar value or null unless it is registered as keyword_list.", + ])); + runtime.close().await; + database.remove().await; +} diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index 8420cde0e..3a4602834 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -26,6 +26,7 @@ import {checkActivityLateOutcomes} from './activity-late-outcomes-contract.mjs'; import {checkChildVisibility} from './child-visibility-contract.mjs'; import {checkClusterCapabilities} from './cluster-capabilities-contract.mjs'; import {checkDeepJsonBodies} from './deep-json-bodies-contract.mjs'; +import {checkKeyOrder} from './key-order-contract.mjs'; import {checkVisibilityCollation} from './visibility-collation-contract.mjs'; import {checkSearchAttributeDatetimes} from './search-attribute-datetimes-contract.mjs'; import {checkRetriedChildCancellation} from './retried-child-cancellation-contract.mjs'; @@ -204,6 +205,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const childVisibility = fixture.child_visibility ? checkChildVisibility(fixture, observation) : null; const clusterCapabilities = fixture.cluster_capabilities ? checkClusterCapabilities(fixture, observation) : null; const deepJsonBodies = fixture.deep_json_bodies ? checkDeepJsonBodies(fixture, observation) : null; + const keyOrder = fixture.key_order ? checkKeyOrder(fixture, observation) : null; const visibilityCollation = fixture.visibility_collation ? checkVisibilityCollation(fixture, observation) : null; const searchAttributeDatetimes = fixture.search_attribute_datetimes ? checkSearchAttributeDatetimes(fixture, observation) : null; const retriedChildCancellation = fixture.retried_child_cancellation ? checkRetriedChildCancellation(fixture, observation) : null; @@ -800,6 +802,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(childVisibility ? {child_visibility: childVisibility} : {}), ...(clusterCapabilities ? {cluster_capabilities: clusterCapabilities} : {}), ...(deepJsonBodies ? {deep_json_bodies: deepJsonBodies} : {}), + ...(keyOrder ? {key_order: keyOrder} : {}), ...(visibilityCollation ? {visibility_collation: visibilityCollation} : {}), ...(searchAttributeDatetimes ? {search_attribute_datetimes: searchAttributeDatetimes} : {}), ...(retriedChildCancellation ? {retried_child_cancellation: retriedChildCancellation} : {}), diff --git a/scripts/conformance/server-parity/key-order-contract.mjs b/scripts/conformance/server-parity/key-order-contract.mjs new file mode 100644 index 000000000..37558ad23 --- /dev/null +++ b/scripts/conformance/server-parity/key-order-contract.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; + +// Reviewed key order in plain JSON input and search attribute validation +// (key-order-probe.php), recorded against published Server 2.11.7. Stored +// input blobs carry the encoded map order, and message lists carry the +// validation order. The fixture's `expected` holds the whole observation. +export function checkKeyOrder(fixture, observation) { + const definition = fixture.key_order; + const state = observation.key_order; + const projection = {scope: 'key_order'}; + assert.ok(state && typeof state === 'object', 'actual key order observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_request_bodies'}, + 'embedded key order explicitly inapplicable'); + return projection; + } + assert.deepStrictEqual(Object.keys(definition.expected.starts).sort(), Object.keys(definition.starts).sort(), + 'every key order start has an expectation'); + assert.deepStrictEqual(state, {applicable: true, ...definition.expected}, 'key order'); + return projection; +} diff --git a/scripts/conformance/server-parity/key-order-probe.php b/scripts/conformance/server-parity/key-order-probe.php new file mode 100644 index 000000000..bf8b45429 --- /dev/null +++ b/scripts/conformance/server-parity/key-order-probe.php @@ -0,0 +1,110 @@ +createNamespace($namespace, 'key order λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + // The upsert case polls the published PHP SQLite reference while its + // maintenance loop runs; its documented lock-pressure refusal is retried. + $stack = HandlerStack::create(); + $stack->push(LockPressurePollRetries::middleware(), 'lock_pressure_poll_retry'); + $http = new KeyOrderHttp(['base_uri' => rtrim($url, '/').'/api/', 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 15, 'handler' => $stack]); + $headers = array_filter([ + 'Authorization' => $token === null ? null : 'Bearer '.$token, + 'Accept' => 'application/json', 'X-Namespace' => $namespace, + 'X-Durable-Workflow-Control-Plane-Version' => '2', 'X-Durable-Workflow-Protocol-Version' => '1.20', + ]); + // The body is sent as the fixture's JSON text, so an object keeps its + // key order and integer keys stay object keys. + $raw = static function (string $method, string $path, ?string $body = null) use ($http, $headers): array { + $response = $http->request($method, $path, ['headers' => $headers + ($body === null ? [] : ['Content-Type' => 'application/json']), + ...($body === null ? [] : ['body' => $body])]); + + return ['status' => $response->getStatusCode(), + 'response' => json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + }; + $json = static fn (mixed $value): string => json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $answer = static fn (array $answer): array => ['status' => $answer['status'], + 'reason' => $answer['response']['reason'] ?? null, 'outcome' => $answer['response']['outcome'] ?? null, + 'message' => $answer['response']['message'] ?? null, 'errors' => $answer['response']['errors'] ?? null]; + $state = ['applicable' => true]; + $runs = []; + foreach ($definition['starts'] as $case => $shape) { + $id = $workflowId.'-ko-'.$case; + $body = '{"workflow_id":'.$json($id).',"workflow_type":'.$json($definition['workflow_type']) + .',"task_queue":'.$json($id.'-queue').',"input":'.$shape['input'] + .(isset($shape['search_attributes']) ? ',"search_attributes":'.$shape['search_attributes'] : '').'}'; + $started = $raw('POST', 'workflows', $body); + $observed = ['start' => $answer($started)]; + if ($started['status'] === 201) { + $runs[$case] = [$id, $started['response']['run_id']]; + $described = $raw('GET', 'workflows/'.rawurlencode($id))['response']; + $observed['input'] = $described['input'] ?? 'absent'; + $observed['input_blob'] = $described['input_envelope']['blob'] ?? 'absent'; + } + $state['starts'][$case] = $observed; + } + + $signal = $definition['signal']; + [$id, $runId] = $runs[$signal['start']]; + $signalled = $raw('POST', 'workflows/'.rawurlencode($id).'/signal/'.rawurlencode($signal['name']), + '{"input":'.$signal['input'].'}'); + $state['signal'] = ['answer' => $answer($signalled)]; + foreach ($raw('GET', 'workflows/'.rawurlencode($id).'/runs/'.$runId.'/history')['response']['events'] ?? [] as $event) { + if (($event['event_type'] ?? null) === 'SignalReceived') { + $state['signal']['arguments_blob'] = $event['payload']['arguments']['blob'] ?? 'absent'; + } + } + + $upsert = $definition['upsert']; + $id = $workflowId.'-ko-upsert'; + $queue = $id.'-queue'; + $worker = $id.'-worker'; + $manifest = array_fill_keys(['local_activities', 'worker_sessions', 'sticky_execution'], + ['supported' => false, 'minimum_protocol_version' => '1.18', 'reason' => 'raw_http']); + $registered = $raw('POST', 'worker/register', $json(['worker_id' => $worker, 'task_queue' => $queue, + 'runtime' => 'php', 'sdk_version' => 'raw-http', 'supported_workflow_types' => [$definition['workflow_type']], + 'supported_activity_types' => [], 'capabilities' => ['typed_search_attributes'], + 'capability_manifest' => $manifest])); + if ($registered['status'] !== 201) { + throw new RuntimeException('Key order registration: '.json_encode($registered)); + } + $started = $raw('POST', 'workflows', $json(['workflow_id' => $id, 'workflow_type' => $definition['workflow_type'], + 'task_queue' => $queue, 'input' => ['upsert']])); + if ($started['status'] !== 201) { + throw new RuntimeException('Key order upsert start: '.json_encode($started)); + } + $task = null; + for ($attempt = 0; $attempt < 10 && ! is_array($task); $attempt++) { + $task = $raw('POST', 'worker/workflow-tasks/poll', $json(['worker_id' => $worker, 'task_queue' => $queue, + 'timeout_seconds' => 1]))['response']['task'] ?? null; + } + if (! is_array($task)) { + throw new RuntimeException('Key order: no workflow task'); + } + $completed = $raw('POST', 'worker/workflow-tasks/'.$task['task_id'].'/complete', + '{"lease_owner":'.$json($task['lease_owner']).',"workflow_task_attempt":'.$json($task['workflow_task_attempt']) + .',"commands":[{"type":"upsert_search_attributes","attributes":'.$upsert['attributes'].'}]}'); + $state['upsert'] = ['status' => $completed['status'], 'reason' => $completed['response']['reason'] ?? null, + 'errors' => $completed['response']['errors'] ?? null]; + + return $state; +} diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index f58c064f1..b09756f43 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -444,6 +444,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $childVisibility = isset($fixture['child_visibility']) ? finishHttpChildVisibility($client, $fixture, $workflowId, $url) : null; $clusterCapabilities = isset($fixture['cluster_capabilities']) ? finishHttpClusterCapabilities($client, $fixture, $workflowId, $url) : null; $deepJsonBodies = isset($fixture['deep_json_bodies']) ? finishHttpDeepJsonBodies($client, $fixture, $workflowId, $url) : null; + $keyOrder = isset($fixture['key_order']) ? finishHttpKeyOrder($client, $fixture, $workflowId, $url) : null; $visibilityCollation = isset($fixture['visibility_collation']) ? finishHttpVisibilityCollation($client, $fixture, $workflowId, $url) : null; $searchAttributeDatetimes = isset($fixture['search_attribute_datetimes']) ? finishHttpSearchAttributeDatetimes($client, $fixture, $workflowId, $url) : null; $retriedChildCancellation = isset($fixture['retried_child_cancellation']) ? finishHttpRetriedChildCancellation($client, $fixture, $workflowId, $url) : null; @@ -593,6 +594,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'child_visibility' => $childVisibility, 'cluster_capabilities' => $clusterCapabilities, 'deep_json_bodies' => $deepJsonBodies, + 'key_order' => $keyOrder, 'visibility_collation' => $visibilityCollation, 'search_attribute_datetimes' => $searchAttributeDatetimes, 'retried_child_cancellation' => $retriedChildCancellation, @@ -945,6 +947,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/child-visibility-probe.php'; require __DIR__.'/cluster-capabilities-probe.php'; require __DIR__.'/deep-json-bodies-probe.php'; + require __DIR__.'/key-order-probe.php'; require __DIR__.'/visibility-collation-probe.php'; require __DIR__.'/search-attribute-datetimes-probe.php'; require __DIR__.'/retried-child-cancellation-probe.php'; @@ -1177,6 +1180,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['deep_json_bodies'])) { $observation['deep_json_bodies'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_request_bodies']; } + if ($mode === 'embedded' && isset($fixture['key_order'])) { + $observation['key_order'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_request_bodies']; + } if ($mode === 'embedded' && isset($fixture['visibility_collation'])) { $observation['visibility_collation'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_list_queries']; } diff --git a/tests/Fixtures/ServerParity/key-order.json b/tests/Fixtures/ServerParity/key-order.json new file mode 100644 index 000000000..b335ca198 --- /dev/null +++ b/tests/Fixtures/ServerParity/key-order.json @@ -0,0 +1,181 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "key-order", + "review_state": "reviewed", + "contract": "Key order in plain JSON input and in search attribute validation (published Server 2.11.7 with Workflow 2.15.7: PayloadEnvelopeResolver::resolve, Avro::toDatum and SearchAttributeValueValidator), over raw HTTP in a fresh namespace without registered search attributes. Each body is sent exactly as written here. A start's plain input becomes its values in document order (array_values), so an object input with integer keys out of order and an object input with named keys keep the order they were written in. A nested object is encoded as an Avro map in document order, which the stored input blob shows. An integer-keyed nested object that is not a list in document order (`{\"1\":\"a\",\"0\":\"b\"}`) is refused with 422 validation_failed and the serializer's invalid_map_key message under workflow_type, and no run is created. A signal's plain input is stored the same way. Several invalid search attributes on one start, and on one upsert_search_attributes command, are reported in request order.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "key order λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "key order λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "key_order": { + "workflow_type": "parity.v1.key_order", + "starts": { + "nested_map": { + "input": "[{\"zeta\":1,\"alpha\":{\"yak\":2,\"bee\":3}},\"x\"]" + }, + "object_integer_keys": { + "input": "{\"1\":\"a\",\"0\":\"b\"}" + }, + "object_named_keys": { + "input": "{\"second\":\"b\",\"first\":\"a\"}" + }, + "nested_integer_keys": { + "input": "[{\"1\":\"a\",\"0\":\"b\"}]" + }, + "nested_integer_key": { + "input": "[{\"1\":\"a\"}]" + }, + "search_attribute_errors": { + "input": "[\"x\"]", + "search_attributes": "{\"Zz\":[\"l\"],\"9bad\":\"x\",\"Aa\":{\"m\":1}}" + } + }, + "signal": { + "start": "nested_map", + "name": "go", + "input": "[{\"zeta\":1,\"alpha\":2}]" + }, + "upsert": { + "attributes": "{\"Zz\":[\"l\"],\"9bad\":\"x\",\"Aa\":{\"m\":1}}" + }, + "expected": { + "starts": { + "nested_map": { + "start": { + "status": 201, + "reason": null, + "outcome": "started_new", + "message": null, + "errors": null + }, + "input": [ + { + "zeta": 1, + "alpha": { + "yak": 2, + "bee": 3 + } + }, + "x" + ], + "input_blob": "wwHioz3/VYAiNwwEDgQIemV0YQQCCmFscGhhDgQGeWFrBAQGYmVlBAYAAAoCeAA=" + }, + "object_integer_keys": { + "start": { + "status": 201, + "reason": null, + "outcome": "started_new", + "message": null, + "errors": null + }, + "input": [ + "a", + "b" + ], + "input_blob": "wwHioz3/VYAiNwwECgJhCgJiAA==" + }, + "object_named_keys": { + "start": { + "status": 201, + "reason": null, + "outcome": "started_new", + "message": null, + "errors": null + }, + "input": [ + "b", + "a" + ], + "input_blob": "wwHioz3/VYAiNwwECgJiCgJhAA==" + }, + "nested_integer_keys": { + "start": { + "status": 422, + "reason": "validation_failed", + "outcome": null, + "message": "invalid_map_key: Avro Value maps require string keys; keys are never stringified.", + "errors": { + "workflow_type": [ + "invalid_map_key: Avro Value maps require string keys; keys are never stringified." + ] + } + } + }, + "nested_integer_key": { + "start": { + "status": 422, + "reason": "validation_failed", + "outcome": null, + "message": "invalid_map_key: Avro Value maps require string keys; keys are never stringified.", + "errors": { + "workflow_type": [ + "invalid_map_key: Avro Value maps require string keys; keys are never stringified." + ] + } + } + }, + "search_attribute_errors": { + "start": { + "status": 422, + "reason": "validation_failed", + "outcome": null, + "message": "Search attribute [Zz] must be a scalar value or null unless it is registered as keyword_list. (and 2 more errors)", + "errors": { + "search_attributes": [ + "Search attribute [Zz] must be a scalar value or null unless it is registered as keyword_list.", + "Search attribute key [9bad] must start with a letter and contain only letters, numbers, and underscores.", + "Search attribute [Aa] must be a scalar value or null unless it is registered as keyword_list." + ] + } + } + } + }, + "signal": { + "answer": { + "status": 202, + "reason": null, + "outcome": "signal_received", + "message": null, + "errors": null + }, + "arguments_blob": "wwHioz3/VYAiNwwCDgQIemV0YQQCCmFscGhhBAQAAA==" + }, + "upsert": { + "status": 422, + "reason": "validation_failed", + "errors": { + "commands.0.attributes": [ + "Search attribute [Zz] must be a scalar value or null unless it is registered as keyword_list.", + "Search attribute key [9bad] must start with a letter and contain only letters, numbers, and underscores.", + "Search attribute [Aa] must be a scalar value or null unless it is registered as keyword_list." + ] + } + } + } + } +} diff --git a/tests/Unit/ServerParityKeyOrderContractTest.mjs b/tests/Unit/ServerParityKeyOrderContractTest.mjs new file mode 100644 index 000000000..92f532577 --- /dev/null +++ b/tests/Unit/ServerParityKeyOrderContractTest.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {checkKeyOrder} from '../../scripts/conformance/server-parity/key-order-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/key-order.json', import.meta.url), 'utf8')); +const expected = fixture.key_order.expected; +const observation = () => structuredClone({mode: 'http', key_order: {applicable: true, ...expected}}); +const decode = blob => Buffer.from(blob, 'base64').toString('latin1'); + +test('the published answers pass', () => { + assert.deepStrictEqual(checkKeyOrder(fixture, observation()), {scope: 'key_order'}); +}); + +test('embedded applications have no HTTP request bodies', () => { + assert.deepStrictEqual(checkKeyOrder(fixture, + {mode: 'embedded', key_order: {applicable: false, reason: 'embedded_has_no_http_request_bodies'}}), + {scope: 'key_order'}); +}); + +test('plain input keeps document order', () => { + const nested = decode(expected.starts.nested_map.input_blob); + assert.ok(nested.indexOf('zeta') < nested.indexOf('alpha') && nested.indexOf('yak') < nested.indexOf('bee'), + 'nested maps are encoded in document order'); + assert.deepStrictEqual(expected.starts.object_integer_keys.input, ['a', 'b']); + assert.deepStrictEqual(expected.starts.object_named_keys.input, ['b', 'a']); + const signal = decode(expected.signal.arguments_blob); + assert.ok(signal.indexOf('zeta') < signal.indexOf('alpha'), 'signal arguments keep document order'); + for (const name of ['nested_integer_keys', 'nested_integer_key']) { + assert.deepStrictEqual([expected.starts[name].start.status, expected.starts[name].start.reason], [422, 'validation_failed']); + } +}); + +test('several invalid search attributes are reported in request order', () => { + const keys = messages => messages.map(message => message.match(/\[(\w+)\]/)[1]); + assert.deepStrictEqual(keys(expected.starts.search_attribute_errors.start.errors.search_attributes), ['Zz', '9bad', 'Aa']); + assert.deepStrictEqual(keys(expected.upsert.errors['commands.0.attributes']), ['Zz', '9bad', 'Aa']); +}); + +for (const [name, mutate] of [ + ['a nested map blob with another key order', state => { + state.starts.nested_map.input_blob = 'wwHioz3/VYAiNwwEDgQKYWxwaGEOBAZiZWUEBgZ5YWsEBAAIemV0YQQCAAoCeAA='; + }], + ['object input values in sorted key order', state => { state.starts.object_named_keys.input = ['a', 'b']; }], + ['out-of-order integer keys read as a list', state => { + state.starts.nested_integer_keys = {start: {status: 201, reason: null, outcome: 'started_new', message: null, errors: null}, + input: [['b', 'a']], input_blob: 'x'}; + }], + ['search attribute errors in sorted key order', state => { + state.upsert.errors['commands.0.attributes'].reverse(); + }], +]) test(`the contract rejects ${name}`, () => { + const raw = observation(); mutate(raw.key_order); + assert.throws(() => checkKeyOrder(fixture, raw)); +});