diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 9d7174d50..80656376c 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -653,6 +653,18 @@ contract fixture before its correction. that check. The `deep-json-bodies` fixture records it against published 2.11.7 ([checkpoint](rust-server-port-checkpoints/2026-10-30-deep-json-bodies.md)). +- **JSON doubles** (November 4, closed in 3.1.0, #1010). Published PHP's + `json_decode` and `json_encode` give a double back with the digits it was + sent with. Native parsed with serde_json's fast float path, which can land + one ULP off for 16–17 digits, so a memo's `1.4820142857142857` was answered + as `…855` (5,852 of 20,000 random doubles changed). serde_json's + `float_roundtrip` now parses exactly. The `json-doubles` fixture checks a + memo, registered double search attributes and heartbeat details on both + implementations against the input digits. Two reviewed exceptions: + published PHP renders a typed double search attribute at PHP's 14-digit + `precision` in describe and the visibility list (native keeps every + digit), and MySQL's `json` column type parses doubles itself, so on MySQL + the history payloads hold MySQL's double for both implementations. - **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` diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9936e57d8..0b3946faa 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -17,7 +17,7 @@ miniz_oxide = "0.9" thiserror = "2" axum = { version = "=0.8.9", default-features = false, features = ["http1", "json", "query", "tokio"] } serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order", "raw_value", "unbounded_depth"] } +serde_json = { version = "1", features = ["float_roundtrip", "preserve_order", "raw_value", "unbounded_depth"] } sha2 = { version = "0.11", default-features = false } hmac = "0.13" form_urlencoded = "1.2" diff --git a/rust/tests/execution/memos.rs b/rust/tests/execution/memos.rs index 516ccead2..ff8cf4630 100644 --- a/rust/tests/execution/memos.rs +++ b/rust/tests/execution/memos.rs @@ -516,6 +516,74 @@ async fn initial_memo_survives_cold_open_and_uses_the_same_sequence_portable_val database.remove().await; } +/// The raw text of an answer, before any JSON parsing could round it. +async fn raw_text(app: &Router, method: &str, path: &str, body: &str) -> (StatusCode, String) { + let request = Request::builder() + .method(method) + .uri(path) + .header("authorization", "Bearer test-token") + .header("content-type", "application/json") + .header("x-namespace", "default") + .header("x-durable-workflow-control-plane-version", "2") + .body(Body::from(body.to_owned())) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024).await.unwrap(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) +} + +/// The double a backend's JSON column holds for `text`. MySQL's json type +/// parses doubles itself and can land one ULP off, for PHP and native alike; +/// MariaDB stores JSON as text, and SQLite and PostgreSQL keep the text. +async fn json_column_double(database: &TestDatabase, text: &str) -> f64 { + if !matches!(database, TestDatabase::MySql { .. }) { + return text.parse().unwrap(); + } + let query = format!(r#"SELECT 'value', CAST(JSON_EXTRACT('{{"x":{text}}}', '$.x') AS CHAR)"#); + let rows = text_rows(database, "", "", &query).await; + rows[0].1.parse().unwrap() +} + +// PHP's json_decode and json_encode give a double back with the digits it +// came in with. Without serde_json's float_roundtrip, parsing can land one +// ULP off, so a memo's 1.4820142857142857 was answered as …855. +#[tokio::test] +async fn memo_doubles_keep_every_digit_through_storage() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let doubles = ["1.4820142857142857", "124435.71428571429", "33.333333333333336", + "0.7071067811865476", "1.0000000000000002", "2.2250738585072014e-308"]; + let members: Vec = doubles.iter().enumerate().map(|(index, text)| format!("\"d{index}\":{text}")).collect(); + let start = format!(r#"{{"workflow_id":"memo-doubles","workflow_type":"echo","task_queue":"test","memo":{{"metadata":{{{}}}}}}}"#, + members.join(",")); + let (status, started) = raw_text(&app, "POST", "/api/workflows", &start).await; + assert_eq!(status, StatusCode::CREATED, "{started}"); + let run: Value = serde_json::from_str(&started).unwrap(); + runtime.close().await; + drop(app); + let runtime = database.open().await.unwrap(); + let app = router(runtime.clone()); + let path = format!("/api/workflows/memo-doubles/runs/{}", run["run_id"].as_str().unwrap()); + let (status, described) = raw_text(&app, "GET", &path, "").await; + assert_eq!(status, StatusCode::OK, "{described}"); + let (status, history) = raw_text(&app, "GET", &format!("{path}/history"), "").await; + assert_eq!(status, StatusCode::OK, "{history}"); + for (index, text) in doubles.iter().enumerate() { + // The described memo keeps every digit on every backend. + let exact: f64 = text.parse().unwrap(); + let printed = serde_json::to_string(&exact).unwrap(); + assert!(described.contains(&format!("\"d{index}\":{printed}")), "describe d{index} {text}: {described}"); + // History payloads live in a JSON column, so they hold the double + // that column stores, which the database itself names. + let stored = serde_json::to_string(&json_column_double(&database, text).await).unwrap(); + assert!(history.contains(&format!("\"d{index}\":{stored}")), "history d{index} {text} as {stored}: {history}"); + } + runtime.close().await; + database.remove().await; +} + #[tokio::test] async fn invalid_initial_memo_is_refused_before_any_original_is_admitted() { let database = TestDatabase::new().await; diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index 3a4602834..3d924d111 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -33,6 +33,7 @@ import {checkRetriedChildCancellation} from './retried-child-cancellation-contra import {checkParallelCancellation} from './parallel-cancellation-contract.mjs'; import {checkActivityResultRepeats} from './activity-result-repeats-contract.mjs'; import {checkStartSearchAttributes} from './start-search-attributes-contract.mjs'; +import {checkJsonDoubles} from './json-doubles-contract.mjs'; import {checkQueryArgumentRouting} from './query-arguments-contract.mjs'; import {checkQueryContractArguments} from './query-contract-arguments-contract.mjs'; import {checkParentCloseCancellation} from './parent-close-cancellation-contract.mjs'; @@ -212,6 +213,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const parallelCancellation = fixture.parallel_cancellation ? checkParallelCancellation(fixture, observation) : null; const activityResultRepeats = fixture.activity_result_repeats ? checkActivityResultRepeats(fixture, observation) : null; const startSearchAttributes = fixture.start_search_attributes ? checkStartSearchAttributes(fixture, observation) : null; + const jsonDoubles = fixture.json_doubles ? checkJsonDoubles(fixture, observation) : null; const queryArguments = fixture.query_argument_routing ? checkQueryArgumentRouting(fixture, observation) : null; const queryContractArguments = fixture.query_contract_arguments ? checkQueryContractArguments(fixture, observation) : null; const parentCloseCancellation = fixture.parent_close_cancellation ? checkParentCloseCancellation(fixture, observation) : null; @@ -809,6 +811,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(parallelCancellation ? {parallel_cancellation: parallelCancellation} : {}), ...(activityResultRepeats ? {activity_result_repeats: activityResultRepeats} : {}), ...(startSearchAttributes ? {start_search_attributes: startSearchAttributes} : {}), + ...(jsonDoubles ? {json_doubles: jsonDoubles} : {}), ...(queryArguments ? {query_argument_routing: queryArguments} : {}), ...(queryContractArguments ? {query_contract_arguments: queryContractArguments} : {}), ...(parentCloseCancellation ? {parent_close_cancellation: parentCloseCancellation} : {}), diff --git a/scripts/conformance/server-parity/json-doubles-contract.mjs b/scripts/conformance/server-parity/json-doubles-contract.mjs new file mode 100644 index 000000000..df03e660e --- /dev/null +++ b/scripts/conformance/server-parity/json-doubles-contract.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; + +// JSON doubles keep every digit (json-doubles-probe.php, server#1010). PHP's +// json_decode and json_encode give a double back with the digits it came in +// with, so each value read back must be exactly the double its fixture text +// names, with two reviewed exceptions: +// - published PHP renders a typed double search attribute through PHP's +// 14-digit `precision` in the run description and the visibility list +// (`php_search_attribute_precision`); native keeps every digit; +// - on MySQL, the json column type parses doubles itself, so history +// payloads (WorkflowStarted, heartbeat details) hold MySQL's double for +// both implementations (`mysql_json_doubles`, read from MySQL 8.0 with +// CAST(... AS JSON)). MariaDB, SQLite and PostgreSQL keep the text. +// Expectations come from the fixture's texts, never from a server recording. +const HISTORY_READERS = ['started_memo', 'started_search_attributes', 'heartbeat_details']; +const ATTRIBUTE_READERS = ['described_search_attributes', 'listed_search_attributes']; +const READERS = ['described_memo', ...ATTRIBUTE_READERS, ...HISTORY_READERS]; + +export function checkJsonDoubles(fixture, observation) { + const definition = fixture.json_doubles; + const state = observation.json_doubles; + const names = Object.keys(definition.doubles); + const projection = {scope: 'json_doubles', values: names.length}; + assert.ok(state && typeof state === 'object', 'actual json doubles observation'); + if (observation.mode === 'embedded') { + assert.deepStrictEqual(state, {applicable: false, reason: 'embedded_has_no_http_json_bodies'}, + 'embedded json doubles explicitly inapplicable'); + return projection; + } + assert.ok(names.length > 0, 'the fixture names doubles'); + assert.equal(state.applicable, true, 'HTTP targets record json doubles'); + const {implementation, storage} = state; + assert.ok(implementation === null || implementation === 'rust', `published PHP or native, not ${implementation}`); + assert.ok(storage === 'exact' || storage === 'mysql_json', `json doubles storage ${storage}`); + assert.deepStrictEqual(Object.keys(state).sort(), ['applicable', 'implementation', 'storage', ...READERS].sort(), + 'json doubles readers'); + const exact = (name) => { + const value = Number(definition.doubles[name]); + assert.ok(Number.isFinite(value) && !Number.isInteger(value), `fixture double ${name} has a fraction`); + return value; + }; + const stored = (name) => storage === 'mysql_json' && Object.hasOwn(definition.mysql_json_doubles, name) + ? Number(definition.mysql_json_doubles[name]) : exact(name); + const attribute = (name) => implementation === null + ? Number(exact(name).toPrecision(definition.php_search_attribute_precision)) : exact(name); + const mismatches = []; + const expectReader = (reader, keyed, value) => { + const actual = state[reader]; + if (!actual || typeof actual !== 'object') { + mismatches.push(`${reader} not recorded (${JSON.stringify(actual)})`); + return; + } + const keys = Object.fromEntries(names.map((name) => [keyed(name), name])); + assert.deepStrictEqual(Object.keys(actual).sort(), Object.keys(keys).sort(), `json doubles ${reader} names`); + for (const [key, name] of Object.entries(keys)) { + // Object.is: the exact double, so one ULP off fails. + if (!Object.is(actual[key], value(name))) mismatches.push(`${reader} ${key}: ${actual[key]} is not ${value(name)}`); + } + }; + const plain = (name) => name; + const prefixed = (name) => definition.search_attribute_prefix + name; + expectReader('described_memo', plain, exact); + for (const reader of ATTRIBUTE_READERS) expectReader(reader, prefixed, attribute); + expectReader('started_memo', plain, stored); + expectReader('started_search_attributes', prefixed, stored); + expectReader('heartbeat_details', plain, stored); + assert.deepStrictEqual(mismatches, [], `json doubles (${implementation ?? 'php'}, ${storage}): ${mismatches.join('; ')}`); + return projection; +} diff --git a/scripts/conformance/server-parity/json-doubles-probe.php b/scripts/conformance/server-parity/json-doubles-probe.php new file mode 100644 index 000000000..62d744681 --- /dev/null +++ b/scripts/conformance/server-parity/json-doubles-probe.php @@ -0,0 +1,110 @@ +createNamespace($namespace, 'json doubles λ', 30); + $token = getenv('DW_PARITY_TOKEN') ?: null; + $stack = HandlerStack::create(); + $stack->push(LockPressurePollRetries::middleware(), 'lock_pressure_poll_retry'); + $http = new JsonDoublesHttp(['base_uri' => rtrim($url, '/'), 'http_errors' => false, + 'connect_timeout' => 1, 'timeout' => 30, 'handler' => $stack]); + $raw = static function (string $method, string $path, ?array $body = null) use ($http, $token, $namespace): array { + $options = ['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', + ])]; + if ($body !== null) { + $options['json'] = $body; + } + $response = $http->request($method, $path, $options); + + return ['status' => $response->getStatusCode(), + 'response' => json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; + }; + $ok = static function (array $recorded, int $status, string $operation): array { + if ($recorded['status'] !== $status) { + throw new RuntimeException('JSON doubles '.$operation.': '.json_encode($recorded)); + } + + return $recorded['response']; + }; + // json_decode reads each fixture text exactly, as the published Server does. + $doubles = array_map(static fn (string $text): float => json_decode($text, flags: JSON_THROW_ON_ERROR), + $definition['doubles']); + $attributes = []; + foreach ($doubles as $name => $value) { + $attribute = $definition['search_attribute_prefix'].$name; + $ok($raw('POST', '/api/search-attributes', ['name' => $attribute, 'type' => 'double']), 201, 'definition '.$attribute); + $attributes[$attribute] = $value; + } + $id = $workflowId.'-dbl'; + $queue = $id.'-queue'; + $worker = $id.'-worker'; + $ok($raw('POST', '/api/worker/register', ['worker_id' => $worker, 'task_queue' => $queue, 'runtime' => 'php', + 'sdk_version' => 'raw-http', 'supported_workflow_types' => [$definition['workflow_type']], + 'supported_activity_types' => [$definition['activity_type']], 'capabilities' => [], + 'capability_manifest' => array_fill_keys(['local_activities', 'worker_sessions', 'sticky_execution'], + ['supported' => false, 'minimum_protocol_version' => '1.18', 'reason' => 'raw_http'])]), 201, 'registration'); + $empty = ['codec' => 'avro', 'blob' => $definition['empty_blob']]; + $started = $ok($raw('POST', '/api/workflows', ['workflow_id' => $id, 'workflow_type' => $definition['workflow_type'], + 'task_queue' => $queue, 'input' => $empty, 'memo' => [$definition['memo_name'] => $doubles], + 'search_attributes' => $attributes]), 201, 'start'); + $run = '/api/workflows/'.rawurlencode($id).'/runs/'.$started['run_id']; + $described = $ok($raw('GET', $run), 200, 'describe'); + $startedEvent = null; + foreach ($ok($raw('GET', $run.'/history'), 200, 'history')['events'] ?? [] as $event) { + if (($event['event_type'] ?? null) === 'WorkflowStarted') { + $startedEvent = $event['payload'] ?? []; + } + } + $poll = ['worker_id' => $worker, 'task_queue' => $queue, 'timeout_seconds' => 0]; + $task = $ok($raw('POST', '/api/worker/workflow-tasks/poll', $poll), 200, 'workflow claim')['task']; + $ok($raw('POST', '/api/worker/workflow-tasks/'.$task['task_id'].'/complete', [ + 'lease_owner' => $task['lease_owner'], 'workflow_task_attempt' => $task['workflow_task_attempt'], + 'commands' => [['type' => 'schedule_activity', 'activity_type' => $definition['activity_type'], + 'arguments' => $empty, 'retry_policy' => ['max_attempts' => 1, 'backoff_seconds' => []], + 'start_to_close_timeout' => 300]]]), 200, 'schedule activity'); + $activity = $ok($raw('POST', '/api/worker/activity-tasks/poll', $poll), 200, 'activity claim')['task']; + $ok($raw('POST', '/api/worker/activity-tasks/'.$activity['task_id'].'/heartbeat', [ + 'activity_attempt_id' => $activity['activity_attempt_id'], 'lease_owner' => $activity['lease_owner'], + 'details' => $doubles]), 200, 'heartbeat'); + $listed = $ok($raw('GET', '/api/workflows?query='.rawurlencode('WorkflowId = "'.$id.'"')), 200, 'list')['workflows'] ?? []; + $activities = $ok($raw('GET', $run.'/activities'), 200, 'activities')['activities'] ?? []; + // End the run so nothing stays for later fixtures. + $raw('POST', '/api/workflows/'.rawurlencode($id).'/terminate', ['reason' => 'json doubles recorded']); + $pick = static fn (mixed $values): mixed => is_array($values) + ? array_intersect_key($values, $attributes) : 'absent'; + + // MySQL's json column type parses doubles itself and can land one ULP + // off; MariaDB stores JSON as text, as SQLite and PostgreSQL keep it. + $mysql = (string) getenv('MYSQL_IMAGE'); + $storage = $mysql !== '' && ! str_contains($mysql, 'mariadb') ? 'mysql_json' : 'exact'; + + return ['applicable' => true, + // A failed cluster info read throws, so the implementation is never guessed. + 'implementation' => $admin->clusterInfo()->raw['implementation'] ?? null, + 'storage' => $storage, + 'described_memo' => $described['memo'][$definition['memo_name']] ?? 'absent', + 'described_search_attributes' => $pick($described['search_attributes'] ?? null), + 'listed_search_attributes' => $pick($listed[0]['search_attributes'] ?? null), + 'started_memo' => $startedEvent['memo'][$definition['memo_name']] ?? 'absent', + 'started_search_attributes' => $pick($startedEvent['search_attributes'] ?? null), + 'heartbeat_details' => $activities[0]['last_heartbeat_progress']['details'] ?? 'absent']; +} diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index 0727d3839..efcc62855 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -451,6 +451,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $parallelCancellation = isset($fixture['parallel_cancellation']) ? finishHttpParallelCancellation($client, $fixture, $workflowId, $url) : null; $activityResultRepeats = isset($fixture['activity_result_repeats']) ? finishHttpActivityResultRepeats($client, $fixture, $workflowId, $url) : null; $startSearchAttributes = isset($fixture['start_search_attributes']) ? finishHttpStartSearchAttributes($client, $fixture, $workflowId, $url) : null; + $jsonDoubles = isset($fixture['json_doubles']) ? finishHttpJsonDoubles($client, $fixture, $workflowId, $url) : null; $queryArgumentRouting = isset($fixture['query_argument_routing']) ? finishHttpQueryArgumentRouting($client, $fixture, $workflowId, $url) : null; $queryContractArguments = isset($fixture['query_contract_arguments']) ? finishHttpQueryContractArguments($client, $fixture, $workflowId, $url) : null; $parentCloseCancellation = isset($fixture['parent_close_cancellation']) ? finishHttpParentCloseCancellation($client, $fixture, $workflowId, $url) : null; @@ -601,6 +602,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'parallel_cancellation' => $parallelCancellation, 'activity_result_repeats' => $activityResultRepeats, 'start_search_attributes' => $startSearchAttributes, + 'json_doubles' => $jsonDoubles, 'query_argument_routing' => $queryArgumentRouting, 'query_contract_arguments' => $queryContractArguments, 'parent_close_cancellation' => $parentCloseCancellation, @@ -957,6 +959,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/parallel-cancellation-probe.php'; require __DIR__.'/activity-result-repeats-probe.php'; require __DIR__.'/start-search-attributes-probe.php'; + require __DIR__.'/json-doubles-probe.php'; require __DIR__.'/query-arguments-probe.php'; require __DIR__.'/query-contract-arguments-probe.php'; require __DIR__.'/parent-close-cancellation-probe.php'; @@ -1204,6 +1207,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['start_search_attributes'])) { $observation['start_search_attributes'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_search_attribute_registry']; } + if ($mode === 'embedded' && isset($fixture['json_doubles'])) { + $observation['json_doubles'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_json_bodies']; + } if ($mode === 'embedded' && isset($fixture['query_argument_routing'])) { $observation['query_argument_routing'] = ['applicable' => false, 'reason' => 'embedded_has_no_worker_protocol']; } diff --git a/tests/Fixtures/ServerParity/json-doubles.json b/tests/Fixtures/ServerParity/json-doubles.json new file mode 100644 index 000000000..f75ab7a29 --- /dev/null +++ b/tests/Fixtures/ServerParity/json-doubles.json @@ -0,0 +1,54 @@ +{ + "schema": "durable-workflow.server-parity-fixture/v1", + "id": "json-doubles", + "review_state": "reviewed", + "contract": "JSON doubles keep every digit (server#1010). Published Server 2.11.8 reads request bodies with json_decode and answers with json_encode, so a double comes back as the same double it was sent as. Over raw HTTP in a fresh namespace, one run starts with the fixture's doubles as a memo and as registered double search attributes, and its activity heartbeats with the same doubles as details. Each value read back from the run description (memo and search attributes), the visibility list (search attributes), the WorkflowStarted event (memo and search attributes) and the run's activity metadata (last_heartbeat_progress.details) must be exactly the double its fixture text names: one ULP off is a failure. Two reviewed exceptions apply. Published PHP renders a typed double search attribute through PHP's 14-digit precision in the run description and the visibility list (php_search_attribute_precision), so 1.4820142857142857 reads back as 1.4820142857143; native keeps every digit there. On MySQL, the json column type parses doubles itself, so the history payloads (WorkflowStarted and the heartbeat's details) hold MySQL's double for both implementations: mysql_json_doubles names the values MySQL 8.0's CAST(... AS JSON) stores, and every other double is unchanged. MariaDB stores JSON as text and, like SQLite and PostgreSQL, keeps every digit. The probe records the implementation from cluster info and the storage from the job's database image; any other implementation or storage is refused. The expectations come from the input texts and these two reviewed facts, never from a recording. Embedded applications have no HTTP request bodies, so the case is explicitly inapplicable there.", + "workflow_type": "parity.v1.echo", + "input": { + "message": "json doubles λ", + "count": 9007199254740993, + "optional": null + }, + "typed_value": { + "type": "map", + "value": { + "message": { + "type": "string", + "value": "json doubles λ" + }, + "count": { + "type": "int64", + "value": "9007199254740993" + }, + "optional": { + "type": "null", + "value": null + } + } + }, + "activity": false, + "expected_events": [ + "StartAccepted", + "WorkflowStarted", + "WorkflowCompleted" + ], + "json_doubles": { + "workflow_type": "parity.v1.json_doubles", + "activity_type": "parity.v1.json_doubles_activity", + "empty_blob": "wwHioz3/VYAiNwwA", + "memo_name": "doubles", + "search_attribute_prefix": "ParityDouble_", + "doubles": { + "d0": "1.4820142857142857", + "d1": "124435.71428571429", + "d2": "33.333333333333336", + "d3": "0.7071067811865476", + "d4": "1.0000000000000002" + }, + "php_search_attribute_precision": 14, + "mysql_json_doubles": { + "d0": "1.4820142857142855", + "d1": "124435.71428571428" + } + } +} diff --git a/tests/Unit/ServerParityJsonDoublesContractTest.mjs b/tests/Unit/ServerParityJsonDoublesContractTest.mjs new file mode 100644 index 000000000..8b5c5e986 --- /dev/null +++ b/tests/Unit/ServerParityJsonDoublesContractTest.mjs @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {checkJsonDoubles} from '../../scripts/conformance/server-parity/json-doubles-contract.mjs'; + +const fixture = JSON.parse(readFileSync(new URL('../Fixtures/ServerParity/json-doubles.json', import.meta.url), 'utf8')); +const definition = fixture.json_doubles; +const names = Object.keys(definition.doubles); +const exact = Object.fromEntries(names.map((name) => [name, Number(definition.doubles[name])])); +const keyed = (values) => Object.fromEntries(names.map((name) => [definition.search_attribute_prefix + name, values[name]])); +// PHP's 14-digit rendering of a typed double search attribute, as published +// 2.11.8 answered: 1.4820142857143, 124435.71428571, 33.333333333333, 0.70710678118655, 1. +const php14 = Object.fromEntries(names.map((name) => [name, Number(exact[name].toPrecision(14))])); +const mysql = {...exact, ...Object.fromEntries(Object.entries(definition.mysql_json_doubles).map(([name, text]) => [name, Number(text)]))}; +const projection = {scope: 'json_doubles', values: names.length}; + +// The observation each implementation recorded on each storage (lab2, published +// PHP 2.11.8 and native at this PR, SQLite and MySQL 8.0.46), written out as +// JSON text and read back, as the recorder's observation file is. +const observation = (implementation, storage) => { + const attributes = implementation === null ? php14 : exact; + const history = storage === 'mysql_json' ? mysql : exact; + return JSON.parse(JSON.stringify({mode: 'http', json_doubles: {applicable: true, implementation, storage, + described_memo: exact, described_search_attributes: keyed(attributes), listed_search_attributes: keyed(attributes), + started_memo: history, started_search_attributes: keyed(history), heartbeat_details: history}})); +}; + +for (const implementation of [null, 'rust']) { + for (const storage of ['exact', 'mysql_json']) { + test(`${implementation ?? 'php'} on ${storage} storage passes`, () => { + assert.deepStrictEqual(checkJsonDoubles(fixture, observation(implementation, storage)), projection); + }); + } +} + +test('embedded applications are explicitly inapplicable', () => { + assert.deepStrictEqual(checkJsonDoubles(fixture, {mode: 'embedded', + json_doubles: {applicable: false, reason: 'embedded_has_no_http_json_bodies'}}), projection); + assert.throws(() => checkJsonDoubles(fixture, {mode: 'embedded', json_doubles: {applicable: true}})); +}); + +test('the fixture names hard 17-digit doubles and the reviewed exceptions', () => { + assert.equal(definition.doubles.d0, '1.4820142857142857'); + for (const name of names) { + assert.equal(String(Number(definition.doubles[name])), definition.doubles[name], name); + } + assert.equal(definition.php_search_attribute_precision, 14); + assert.equal(String(php14.d0), '1.4820142857143'); + assert.equal(php14.d4, 1); + // MySQL's values are one ULP from the input, and only where MySQL changes them. + for (const [name, text] of Object.entries(definition.mysql_json_doubles)) { + assert.ok(Object.hasOwn(definition.doubles, name), name); + assert.notEqual(text, definition.doubles[name], name); + assert.equal(Number(text), oneUlpDown(exact[name]), name); + } +}); + +function oneUlpDown(value) { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + view.setBigUint64(0, view.getBigUint64(0) - 1n); + return view.getFloat64(0); +} + +// Native 3.0.5 parsed without float_roundtrip: one ULP below on every reader. +for (const reader of ['described_memo', 'described_search_attributes', 'listed_search_attributes', + 'started_memo', 'started_search_attributes', 'heartbeat_details']) { + test(`native one ULP off in ${reader} fails`, () => { + const recorded = observation('rust', 'exact'); + const key = reader.endsWith('search_attributes') ? definition.search_attribute_prefix + 'd0' : 'd0'; + recorded.json_doubles[reader][key] = oneUlpDown(exact.d0); + assert.throws(() => checkJsonDoubles(fixture, recorded), new RegExp(`${reader} ${key}: 1.4820142857142855`)); + }); +} + +for (const [label, implementation, storage, mutate] of [ + ['native with PHP\'s 14-digit search attributes', 'rust', 'exact', + (state) => { state.described_search_attributes = keyed(php14); }], + ['PHP with exact search attributes', null, 'exact', (state) => { state.listed_search_attributes = keyed(exact); }], + ['MySQL rounding on exact storage', 'rust', 'exact', (state) => { state.heartbeat_details = mysql; }], + ['exact history on MySQL storage', 'rust', 'mysql_json', (state) => { state.started_memo = exact; }], + ['MySQL rounding in the described memo', null, 'mysql_json', (state) => { state.described_memo = mysql; }], + ['an unknown implementation', 'go', 'exact', () => {}], + ['an unnamed storage', 'rust', 'exact', (state) => { delete state.storage; }], + ['an unknown storage', 'rust', 'exact', (state) => { state.storage = 'sqlite'; }], + ['a missing reader', 'rust', 'exact', (state) => { delete state.heartbeat_details; }], + ['an absent reader', 'rust', 'exact', (state) => { state.started_memo = 'absent'; }], + ['a missing value', 'rust', 'exact', (state) => { delete state.described_memo.d2; }], + ['an extra value', 'rust', 'exact', (state) => { state.described_memo.extra = 1.5; }], + ['a value as a string', 'rust', 'exact', (state) => { state.heartbeat_details.d0 = '1.4820142857142857'; }], + ['an inapplicable HTTP target', 'rust', 'exact', (state) => { state.applicable = false; }], +]) { + test(`${label} fails`, () => { + const recorded = observation(implementation, storage); + mutate(recorded.json_doubles); + assert.throws(() => checkJsonDoubles(fixture, recorded)); + }); +}