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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/rust-server-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions rust/tests/execution/memos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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;
Expand Down
3 changes: 3 additions & 0 deletions scripts/conformance/server-parity/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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} : {}),
Expand Down
69 changes: 69 additions & 0 deletions scripts/conformance/server-parity/json-doubles-contract.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
110 changes: 110 additions & 0 deletions scripts/conformance/server-parity/json-doubles-probe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

use DurableWorkflow\Client;
use GuzzleHttp\Client as JsonDoublesHttp;
use GuzzleHttp\HandlerStack;

// JSON doubles keep every digit (server#1010). PHP's json_decode and
// json_encode return a double with the digits it was sent with. Over raw HTTP
// 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 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'];
$namespace = strtolower($workflowId.'-dbl');
$admin->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'];
}
Loading
Loading