From c685fa26d3fcc36e4ae912c63878f09c62bed94f Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:59:50 -0600 Subject: [PATCH 1/3] Keep every digit of JSON doubles: enable serde_json float_roundtrip (#1010) Without float_roundtrip, serde_json's fast float parser can land one ULP off for 16-17 digit doubles, so a memo's 1.4820142857142857 was answered as 1.4820142857142855. PHP's json_decode/json_encode returns the digits it was given. The feature switches to exact parsing; it adds no dependency and leaves Cargo.lock unchanged. The new memo test reads describe and history as raw text after a cold reopen, so no JSON parsing on the test side can hide a change. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.toml | 2 +- rust/tests/execution/memos.rs | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c539fbed3..b5ba69994 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", "unbounded_depth"] } +serde_json = { version = "1", features = ["float_roundtrip", "preserve_order", "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..046e33925 100644 --- a/rust/tests/execution/memos.rs +++ b/rust/tests/execution/memos.rs @@ -516,6 +516,61 @@ 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()) +} + +// 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() { + // serde_json writes the smallest exponent form; compare by value bits + // as well as by the digits PHP would print. + let expected: f64 = text.parse().unwrap(); + let printed = serde_json::to_string(&expected).unwrap(); + for (answer, name) in [(&described, "describe"), (&history, "history")] { + assert!(answer.contains(&format!("\"d{index}\":{printed}")), "{name} d{index} {text}: {answer}"); + } + } + 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; From f49e951f791f682660297782201af9fcccb78745 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:17:23 -0600 Subject: [PATCH 2/3] JSON doubles conformance: memo, search attributes and heartbeat details (#1010) A shared-corpus fixture, json-doubles, checks native against published PHP for the digits of JSON doubles. Over raw HTTP in a fresh namespace, a run starts with five hard 16-17 digit doubles as a memo and as registered double search attributes, then heartbeats its activity with them as details. The probe reads each one back from the run description, the WorkflowStarted event and the run's activity metadata. The contract derives every expectation from the fixture's input texts and compares with Object.is, so one ULP off fails and no recording can carry a rounded value into the fixture. Embedded mode is explicitly inapplicable. Unit controls cover one ULP off in each reader, missing or extra values, string or truncated values, and an inapplicable HTTP target. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 8 ++ .../conformance/server-parity/contract.mjs | 3 + .../server-parity/json-doubles-contract.mjs | 44 +++++++++ .../server-parity/json-doubles-probe.php | 99 +++++++++++++++++++ scripts/conformance/server-parity/probe.php | 6 ++ tests/Fixtures/ServerParity/json-doubles.json | 49 +++++++++ .../ServerParityJsonDoublesContractTest.mjs | 74 ++++++++++++++ 7 files changed, 283 insertions(+) create mode 100644 scripts/conformance/server-parity/json-doubles-contract.mjs create mode 100644 scripts/conformance/server-parity/json-doubles-probe.php create mode 100644 tests/Fixtures/ServerParity/json-doubles.json create mode 100644 tests/Unit/ServerParityJsonDoublesContractTest.mjs diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 9d7174d50..719f3814f 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -653,6 +653,14 @@ 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. - **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/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..19e1e7fe4 --- /dev/null +++ b/scripts/conformance/server-parity/json-doubles-contract.mjs @@ -0,0 +1,44 @@ +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. Expectations come from the fixture's input texts, never from a +// recording, so a rounded answer cannot be written into the fixture. +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'); + const doubles = {}; + const attributes = {}; + for (const name of names) { + const value = Number(definition.doubles[name]); + assert.ok(Number.isFinite(value) && !Number.isInteger(value), `fixture double ${name} has a fraction`); + doubles[name] = value; + attributes[definition.search_attribute_prefix + name] = value; + } + const expected = {applicable: true, described_memo: doubles, described_search_attributes: attributes, + started_memo: doubles, started_search_attributes: attributes, heartbeat_details: doubles}; + assert.deepStrictEqual(Object.keys(state).sort(), Object.keys(expected).sort(), 'json doubles readers'); + for (const [reader, values] of Object.entries(expected)) { + if (reader === 'applicable') { + assert.equal(state.applicable, true, 'HTTP targets record json doubles'); + continue; + } + assert.ok(state[reader] && typeof state[reader] === 'object', `json doubles ${reader} recorded`); + assert.deepStrictEqual(Object.keys(state[reader]).sort(), Object.keys(values).sort(), `json doubles ${reader} names`); + for (const [name, value] of Object.entries(values)) { + // Object.is: the exact double, so one ULP off fails. + assert.ok(Object.is(state[reader][name], value), `json doubles ${reader} ${name}: ${state[reader][name]} is not ${value}`); + } + } + 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..30313f889 --- /dev/null +++ b/scripts/conformance/server-parity/json-doubles-probe.php @@ -0,0 +1,99 @@ +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'); + $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'; + + return ['applicable' => true, + 'described_memo' => $described['memo'][$definition['memo_name']] ?? 'absent', + 'described_search_attributes' => $pick($described['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..6f408b05a --- /dev/null +++ b/tests/Fixtures/ServerParity/json-doubles.json @@ -0,0 +1,49 @@ +{ + "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 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. The expectations come from the input texts, not 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" + } + } +} diff --git a/tests/Unit/ServerParityJsonDoublesContractTest.mjs b/tests/Unit/ServerParityJsonDoublesContractTest.mjs new file mode 100644 index 000000000..ac63d282e --- /dev/null +++ b/tests/Unit/ServerParityJsonDoublesContractTest.mjs @@ -0,0 +1,74 @@ +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 doubles = Object.fromEntries(names.map((name) => [name, Number(definition.doubles[name])])); +const attributes = Object.fromEntries(names.map((name) => [definition.search_attribute_prefix + name, doubles[name]])); +// What an exact server answers, written out as JSON text and read back, as +// the recorder's observation file is. +const exact = () => JSON.parse(JSON.stringify({mode: 'http', json_doubles: {applicable: true, + described_memo: doubles, described_search_attributes: attributes, + started_memo: doubles, started_search_attributes: attributes, heartbeat_details: doubles}})); + +test('exact doubles pass on every reader', () => { + assert.deepStrictEqual(checkJsonDoubles(fixture, exact()), {scope: 'json_doubles', values: names.length}); +}); + +test('embedded applications are explicitly inapplicable', () => { + assert.deepStrictEqual(checkJsonDoubles(fixture, {mode: 'embedded', + json_doubles: {applicable: false, reason: 'embedded_has_no_http_json_bodies'}}), {scope: 'json_doubles', values: names.length}); + assert.throws(() => checkJsonDoubles(fixture, {mode: 'embedded', json_doubles: {applicable: true}})); +}); + +test('the fixture names hard 17-digit doubles that round-trip in JavaScript', () => { + assert.equal(definition.doubles.d0, '1.4820142857142857'); + for (const name of names) { + assert.equal(String(Number(definition.doubles[name])), definition.doubles[name], name); + } +}); + +// The value native 3.0.5 answered before float_roundtrip: one ULP below. +const oneUlpDown = (value) => { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + view.setBigUint64(0, view.getBigUint64(0) - 1n); + return view.getFloat64(0); +}; + +for (const reader of ['described_memo', 'started_memo', 'heartbeat_details']) { + test(`one ULP off in ${reader} fails`, () => { + const observation = exact(); + observation.json_doubles[reader].d0 = oneUlpDown(doubles.d0); + assert.equal(String(observation.json_doubles[reader].d0), '1.4820142857142855'); + assert.throws(() => checkJsonDoubles(fixture, observation), new RegExp(`${reader} d0`)); + }); +} + +for (const reader of ['described_search_attributes', 'started_search_attributes']) { + test(`one ULP off in ${reader} fails`, () => { + const observation = exact(); + const name = definition.search_attribute_prefix + 'd1'; + observation.json_doubles[reader][name] = oneUlpDown(doubles.d1); + assert.throws(() => checkJsonDoubles(fixture, observation), new RegExp(`${reader} ${name}`)); + }); +} + +for (const [label, mutate] of [ + ['a missing reader', (state) => { delete state.heartbeat_details; }], + ['an absent reader', (state) => { state.started_memo = 'absent'; }], + ['a missing value', (state) => { delete state.described_memo.d2; }], + ['an extra value', (state) => { state.described_memo.extra = 1.5; }], + ['a value as a string', (state) => { state.heartbeat_details.d0 = '1.4820142857142857'; }], + ['an integer-truncated value', (state) => { state.described_search_attributes.ParityDouble_d4 = 1; }], + ['an inapplicable HTTP target', (state) => { state.applicable = false; }], +]) { + test(`${label} fails`, () => { + const observation = exact(); + mutate(observation.json_doubles); + assert.throws(() => checkJsonDoubles(fixture, observation)); + }); +} From fb37f4c4cb4113e1404830d0cb8cc1533ee71641 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:54:57 -0600 Subject: [PATCH 3/3] JSON doubles: record PHP's 14-digit search attributes and MySQL's JSON doubles (#1010) The first corpus run showed two facts the contract assumed away, both confirmed against published PHP 2.11.8 and native locally on SQLite and MySQL 8.0.46: - Published PHP renders a typed double search attribute through PHP's 14-digit precision in describe and the visibility list, so 1.4820142857142857 reads back as 1.4820142857143. Native keeps every digit. The fixture names the precision; the contract expects PHP's rendering only from the published implementation. - MySQL's json column type parses doubles itself (CAST and JSON_EXTRACT both give 1.4820142857142855), so on MySQL the history payloads (WorkflowStarted, heartbeat details) hold MySQL's double for PHP and native alike. MariaDB stores JSON as text and keeps every digit. The probe now records the implementation from cluster info, the storage from the job's database image, and the visibility list. The contract refuses any other implementation or storage, reports every mismatch at once, and still derives each expectation from the fixture's texts. The memo regression test asks the database what its JSON column holds for the history check; the described memo stays exact on every backend. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-port.md | 6 +- rust/tests/execution/memos.rs | 27 +++-- .../server-parity/json-doubles-contract.mjs | 65 +++++++---- .../server-parity/json-doubles-probe.php | 15 ++- tests/Fixtures/ServerParity/json-doubles.json | 7 +- .../ServerParityJsonDoublesContractTest.mjs | 104 +++++++++++------- 6 files changed, 153 insertions(+), 71 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 719f3814f..80656376c 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -660,7 +660,11 @@ contract fixture before its correction. 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. + 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/tests/execution/memos.rs b/rust/tests/execution/memos.rs index 046e33925..ff8cf4630 100644 --- a/rust/tests/execution/memos.rs +++ b/rust/tests/execution/memos.rs @@ -533,6 +533,18 @@ async fn raw_text(app: &Router, method: &str, path: &str, body: &str) -> (Status (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. @@ -559,13 +571,14 @@ async fn memo_doubles_keep_every_digit_through_storage() { let (status, history) = raw_text(&app, "GET", &format!("{path}/history"), "").await; assert_eq!(status, StatusCode::OK, "{history}"); for (index, text) in doubles.iter().enumerate() { - // serde_json writes the smallest exponent form; compare by value bits - // as well as by the digits PHP would print. - let expected: f64 = text.parse().unwrap(); - let printed = serde_json::to_string(&expected).unwrap(); - for (answer, name) in [(&described, "describe"), (&history, "history")] { - assert!(answer.contains(&format!("\"d{index}\":{printed}")), "{name} d{index} {text}: {answer}"); - } + // 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; diff --git a/scripts/conformance/server-parity/json-doubles-contract.mjs b/scripts/conformance/server-parity/json-doubles-contract.mjs index 19e1e7fe4..df03e660e 100644 --- a/scripts/conformance/server-parity/json-doubles-contract.mjs +++ b/scripts/conformance/server-parity/json-doubles-contract.mjs @@ -3,8 +3,19 @@ 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. Expectations come from the fixture's input texts, never from a -// recording, so a rounded answer cannot be written into the fixture. +// 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; @@ -17,28 +28,42 @@ export function checkJsonDoubles(fixture, observation) { return projection; } assert.ok(names.length > 0, 'the fixture names doubles'); - const doubles = {}; - const attributes = {}; - for (const name of names) { + 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`); - doubles[name] = value; - attributes[definition.search_attribute_prefix + name] = value; - } - const expected = {applicable: true, described_memo: doubles, described_search_attributes: attributes, - started_memo: doubles, started_search_attributes: attributes, heartbeat_details: doubles}; - assert.deepStrictEqual(Object.keys(state).sort(), Object.keys(expected).sort(), 'json doubles readers'); - for (const [reader, values] of Object.entries(expected)) { - if (reader === 'applicable') { - assert.equal(state.applicable, true, 'HTTP targets record json doubles'); - continue; + 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; } - assert.ok(state[reader] && typeof state[reader] === 'object', `json doubles ${reader} recorded`); - assert.deepStrictEqual(Object.keys(state[reader]).sort(), Object.keys(values).sort(), `json doubles ${reader} names`); - for (const [name, value] of Object.entries(values)) { + 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. - assert.ok(Object.is(state[reader][name], value), `json doubles ${reader} ${name}: ${state[reader][name]} is not ${value}`); + 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 index 30313f889..62d744681 100644 --- a/scripts/conformance/server-parity/json-doubles-probe.php +++ b/scripts/conformance/server-parity/json-doubles-probe.php @@ -11,8 +11,9 @@ // in a fresh namespace, one run starts with the fixture's doubles as a memo and // as registered double search attributes, then heartbeats its activity with // the same doubles as details. The probe records each value as it is read back -// from the run description, the WorkflowStarted event and the run's activity -// metadata. The contract compares them with the fixture's input digits. +// from the run description, the visibility list, the WorkflowStarted event and +// the run's activity metadata, plus the implementation and the storage. The +// contract compares them with the fixture's input digits. function finishHttpJsonDoubles(Client $admin, array $fixture, string $workflowId, string $url): array { $definition = $fixture['json_doubles']; @@ -84,15 +85,25 @@ function finishHttpJsonDoubles(Client $admin, array $fixture, string $workflowId $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/tests/Fixtures/ServerParity/json-doubles.json b/tests/Fixtures/ServerParity/json-doubles.json index 6f408b05a..f75ab7a29 100644 --- a/tests/Fixtures/ServerParity/json-doubles.json +++ b/tests/Fixtures/ServerParity/json-doubles.json @@ -2,7 +2,7 @@ "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 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. The expectations come from the input texts, not from a recording. Embedded applications have no HTTP request bodies, so the case is explicitly inapplicable there.", + "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 λ", @@ -44,6 +44,11 @@ "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 index ac63d282e..8b5c5e986 100644 --- a/tests/Unit/ServerParityJsonDoublesContractTest.mjs +++ b/tests/Unit/ServerParityJsonDoublesContractTest.mjs @@ -6,69 +6,93 @@ import {checkJsonDoubles} from '../../scripts/conformance/server-parity/json-dou 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 doubles = Object.fromEntries(names.map((name) => [name, Number(definition.doubles[name])])); -const attributes = Object.fromEntries(names.map((name) => [definition.search_attribute_prefix + name, doubles[name]])); -// What an exact server answers, written out as JSON text and read back, as -// the recorder's observation file is. -const exact = () => JSON.parse(JSON.stringify({mode: 'http', json_doubles: {applicable: true, - described_memo: doubles, described_search_attributes: attributes, - started_memo: doubles, started_search_attributes: attributes, heartbeat_details: 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}; -test('exact doubles pass on every reader', () => { - assert.deepStrictEqual(checkJsonDoubles(fixture, exact()), {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'}}), {scope: 'json_doubles', values: names.length}); + 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 that round-trip in JavaScript', () => { +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); + } }); -// The value native 3.0.5 answered before float_roundtrip: one ULP below. -const oneUlpDown = (value) => { +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); -}; - -for (const reader of ['described_memo', 'started_memo', 'heartbeat_details']) { - test(`one ULP off in ${reader} fails`, () => { - const observation = exact(); - observation.json_doubles[reader].d0 = oneUlpDown(doubles.d0); - assert.equal(String(observation.json_doubles[reader].d0), '1.4820142857142855'); - assert.throws(() => checkJsonDoubles(fixture, observation), new RegExp(`${reader} d0`)); - }); } -for (const reader of ['described_search_attributes', 'started_search_attributes']) { - test(`one ULP off in ${reader} fails`, () => { - const observation = exact(); - const name = definition.search_attribute_prefix + 'd1'; - observation.json_doubles[reader][name] = oneUlpDown(doubles.d1); - assert.throws(() => checkJsonDoubles(fixture, observation), new RegExp(`${reader} ${name}`)); +// 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, mutate] of [ - ['a missing reader', (state) => { delete state.heartbeat_details; }], - ['an absent reader', (state) => { state.started_memo = 'absent'; }], - ['a missing value', (state) => { delete state.described_memo.d2; }], - ['an extra value', (state) => { state.described_memo.extra = 1.5; }], - ['a value as a string', (state) => { state.heartbeat_details.d0 = '1.4820142857142857'; }], - ['an integer-truncated value', (state) => { state.described_search_attributes.ParityDouble_d4 = 1; }], - ['an inapplicable HTTP target', (state) => { state.applicable = false; }], +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 observation = exact(); - mutate(observation.json_doubles); - assert.throws(() => checkJsonDoubles(fixture, observation)); + const recorded = observation(implementation, storage); + mutate(recorded.json_doubles); + assert.throws(() => checkJsonDoubles(fixture, recorded)); }); }