diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 073d9e4ea..9d7174d50 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -337,9 +337,10 @@ contract fixture before its correction. `cluster-capabilities` fixture qualifies the activity result read against published 2.11.7. Native advertises no manifest it does not publish and no response compression. `native_unqualified` names each member it still - leaves out: `supported_sdk_versions`, `client_compatibility`, `topology`, - the diagnostics view, the external payload transaction limit and the - conformance manifests. + leaves out: `topology`, the diagnostics view, the external payload + transaction limit and the conformance manifests. Since server#1005 (3.1.0) + native serves `supported_sdk_versions` and `client_compatibility` from one + source with its own qualified ranges, citing only the protocols it serves. - **Fairness within a priority tier** (October 31, closed). Published PHP tries each batch of a poll's candidates (10 at the default `max_tasks_per_poll`) in `TaskFairnessScheduler` order: fairness classes diff --git a/rust/src/runtime/cluster_info.rs b/rust/src/runtime/cluster_info.rs index fa5617c5f..fa7f787ba 100644 --- a/rust/src/runtime/cluster_info.rs +++ b/rust/src/runtime/cluster_info.rs @@ -127,12 +127,68 @@ pub(super) fn discovery() -> Value { "task_repair_path": "/api/system/repair"}) } +/// Native-qualified client ranges: the single source of both +/// `supported_sdk_versions` and `client_compatibility.clients`, so the two can +/// never disagree (server#1005). Each lower bound is the oldest release that +/// passed installed acceptance against native; the cluster-capabilities +/// fixture records the source of each. The upper bound is the next major. +const CLIENT_RANGES: [(&str, &str, &str); 4] = [ + ("php", "sdk-php", ">=3.8.1,<4.0.0"), + ("python", "sdk-python", ">=2.13.0,<3.0.0"), + ("rust", "sdk-rust", ">=4.10.3,<5.0.0"), + ("cli", "cli", ">=2.0.0,<3.0.0"), +]; + +/// `supported_sdk_versions`, keyed as published PHP keys it. +pub(super) fn supported_sdk_versions() -> Value { + Value::Object( + CLIENT_RANGES + .iter() + .map(|(key, _, range)| ((*key).to_owned(), json!(range))) + .collect(), + ) +} + +/// `client_compatibility` (published PHP's `ClientCompatibility::info`, schema +/// version 2), citing only protocols native serves: the auth composition +/// contract, the control plane with its request contract, and the worker +/// protocol. Their identities are read from the values cluster info serves, so +/// the block cannot cite one native does not. PHP's skew refusal matrix and +/// external execution contracts are not published by native, so they are not +/// cited: the CLI checks every protocol the block requires. +pub(super) fn client_compatibility(auth_composition: &Value, request_contract: &Value) -> Value { + let identity = |contract: &Value| json!({"schema": contract["schema"], "version": contract["version"]}); + let served = ["auth_composition.version", "control_plane.version", "control_plane.request_contract"]; + let mut clients = serde_json::Map::new(); + // PHP's order: sdk-php, cli, sdk-python, sdk-rust. + for name in ["sdk-php", "cli", "sdk-python", "sdk-rust"] { + let (_, _, range) = CLIENT_RANGES.iter().find(|(_, client, _)| *client == name).expect("every client has a range"); + let mut requires: Vec<&str> = served.to_vec(); + if name != "cli" { + requires.push("worker_protocol.version"); + } + clients.insert(name.to_owned(), json!({"supported_versions": range, "requires": requires})); + } + json!({ + "schema": "durable-workflow.v2.client-compatibility", + "version": 2, + "authority": "protocol_manifests", + "top_level_version_role": "informational", + "fail_closed": true, + "required_protocols": { + "auth_composition": identity(auth_composition), + "control_plane": {"version": "2", "header": "X-Durable-Workflow-Control-Plane-Version", + "request_contract": identity(request_contract)}, + "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "header": "X-Durable-Workflow-Protocol-Version"}, + }, + "clients": clients, + }) +} + /// Members published PHP's cluster info carries that native leaves out, each /// with the reason. pub(super) fn unqualified() -> Value { json!({ - "supported_sdk_versions": "Native has not qualified SDK version ranges of its own.", - "client_compatibility": "Native publishes no client compatibility matrix yet.", "topology": "Native runs as one process role and publishes no topology manifest.", "discovery.diagnostics_query": "Native serves no include=diagnostics view.", "discovery.diagnostics_path": "Native serves no include=diagnostics view.", @@ -165,4 +221,50 @@ mod tests { fn the_version_is_never_empty() { assert!(!version().is_empty()); } + + fn served_compatibility() -> Value { + let auth: Value = serde_json::from_str(include_str!("auth-composition.v1.json")).unwrap(); + client_compatibility(&auth, &super::super::visibility::request_contract()) + } + + #[test] + fn supported_sdk_versions_and_client_compatibility_never_disagree() { + // server#1005: one source, so every client's range is the same in both. + let compatibility = served_compatibility(); + let ranges = supported_sdk_versions(); + for (key, client) in [("php", "sdk-php"), ("python", "sdk-python"), ("rust", "sdk-rust"), ("cli", "cli")] { + assert_eq!(compatibility["clients"][client]["supported_versions"], ranges[key], "{client}"); + } + assert_eq!(ranges.as_object().unwrap().len(), 4); + assert_eq!(compatibility["clients"].as_object().unwrap().len(), 4); + assert_eq!(ranges, json!({"php": ">=3.8.1,<4.0.0", "python": ">=2.13.0,<3.0.0", + "rust": ">=4.10.3,<5.0.0", "cli": ">=2.0.0,<3.0.0"})); + } + + #[test] + fn client_compatibility_cites_only_protocols_native_serves() { + let compatibility = served_compatibility(); + assert_eq!(compatibility["authority"], "protocol_manifests"); + assert_eq!(compatibility["fail_closed"], true); + let required = compatibility["required_protocols"].as_object().unwrap(); + let mut keys: Vec<&str> = required.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!(keys, ["auth_composition", "control_plane", "worker_protocol"]); + // The identities are the ones cluster info serves beside the block. + assert_eq!(required["auth_composition"], json!({"schema": "durable-workflow.v2.auth-composition.contract", "version": 1})); + assert_eq!(required["control_plane"]["request_contract"], + json!({"schema": "durable-workflow.v2.control-plane-request.contract", "version": 1})); + assert_eq!(required["worker_protocol"], json!({"version": super::super::WORKER_PROTOCOL_VERSION, + "header": "X-Durable-Workflow-Protocol-Version"})); + // No external execution contract is cited anywhere: native publishes none. + assert!(!compatibility.to_string().contains("external_"), "{compatibility}"); + // Every client requirement resolves to a cited protocol member. + for (client, entry) in compatibility["clients"].as_object().unwrap() { + for requirement in entry["requires"].as_array().unwrap() { + let (protocol, member) = requirement.as_str().unwrap().split_once('.').unwrap(); + assert!(required.get(protocol).and_then(|value| value.get(member)).is_some(), + "{client} requires {protocol}.{member}, which the block does not cite"); + } + } + } } diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 90dabf957..f3fc180d3 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -1079,13 +1079,17 @@ async fn cluster( let namespace = runtime.in_namespace(&discovery_namespace(&headers, &uri)).namespace_discovery().await?; let limits = super::payload_discovery::limits()?; let structural_limits = runtime.cluster_structural_limits(&metrics_config(&runtime)).await?; + let request_contract = super::visibility::request_contract(); + let client_compatibility = super::cluster_info::client_compatibility(&auth_composition, &request_contract); Ok(Json( json!({"version": super::cluster_info::version(), "implementation": "rust", "development": super::cluster_info::build_identity().0, "capabilities": super::cluster_info::capabilities(), "structural_limits": structural_limits, "discovery": super::cluster_info::discovery(), "native_unqualified": super::cluster_info::unqualified(), "server_id": super::payload_discovery::server_id(), "default_namespace": "default", "namespace": namespace, "limits": limits, - "control_plane": {"version": "2", "request_contract": super::visibility::request_contract()}, "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "server_capabilities": capabilities()}, + "supported_sdk_versions": super::cluster_info::supported_sdk_versions(), + "client_compatibility": client_compatibility, + "control_plane": {"version": "2", "request_contract": request_contract}, "worker_protocol": {"version": super::WORKER_PROTOCOL_VERSION, "server_capabilities": capabilities()}, "auth_composition_contract": auth_composition, "principal_attribution_contract": principal_attribution, "service_catalog_read_contract": service_catalog, diff --git a/rust/tests/execution/activity_results.rs b/rust/tests/execution/activity_results.rs index 2c9270943..86a50a276 100644 --- a/rust/tests/execution/activity_results.rs +++ b/rust/tests/execution/activity_results.rs @@ -129,7 +129,12 @@ async fn activity_result_read_preserves_encoded_values_and_closed_parent_history assert_eq!(info["structural_limits"]["pending_signal_count"], json!(5000), "{info}"); assert_eq!(info["discovery"]["task_repair_path"], json!("/api/system/repair"), "{info}"); assert!(info["native_unqualified"]["topology"].is_string(), "{info}"); - assert!(info.get("supported_sdk_versions").is_none(), "{info}"); + // Native's own qualified ranges, from the same source as + // client_compatibility (server#1005). + assert_eq!(info["supported_sdk_versions"]["rust"], json!(">=4.10.3,<5.0.0"), "{info}"); + assert_eq!(info["client_compatibility"]["clients"]["sdk-rust"]["supported_versions"], + info["supported_sdk_versions"]["rust"], "{info}"); + assert!(info["native_unqualified"].get("client_compatibility").is_none(), "{info}"); reopened.close().await; database.remove().await; } diff --git a/scripts/conformance/server-parity/cluster-capabilities-contract.mjs b/scripts/conformance/server-parity/cluster-capabilities-contract.mjs index 3dbfa5380..0b5cb2999 100644 --- a/scripts/conformance/server-parity/cluster-capabilities-contract.mjs +++ b/scripts/conformance/server-parity/cluster-capabilities-contract.mjs @@ -40,6 +40,38 @@ export function checkClusterCapabilities(fixture, observation) { } const {implementation, ...rest} = state; assert.ok(implementation === null || implementation === 'rust', 'published PHP or native'); + checkClientManifests(rest, implementation); assert.deepStrictEqual(rest, expectedFor(definition, implementation), `cluster capabilities (${implementation ?? 'php'})`); return projection; } + +// The protocols native serves beside its client_compatibility block, and so +// the only ones the block may cite (server#1005). The CLI checks every +// protocol the block requires. +const NATIVE_SERVED_PROTOCOLS = ['auth_composition', 'control_plane', 'worker_protocol']; + +/// Independent of the reviewed values: each client's range is the same in +/// supported_sdk_versions and client_compatibility, and native's block cites +/// only protocols native serves, with every client requirement resolving. +export function checkClientManifests(state, implementation) { + const ranges = state.client_ranges; + assert.ok(ranges && typeof ranges === 'object', 'client ranges recorded'); + for (const [client, {supported_sdk_versions: sdk, client_compatibility: matrix}] of Object.entries(ranges)) { + assert.ok(typeof sdk === 'string' && sdk !== '', `${client}: supported_sdk_versions range`); + assert.equal(matrix, sdk, `${client}: client_compatibility and supported_sdk_versions agree`); + } + if (implementation !== 'rust') return; + const block = state.native_client_compatibility; + assert.ok(block && typeof block === 'object', 'native client_compatibility block'); + assert.equal(block.authority, 'protocol_manifests', 'native compatibility authority'); + const required = block.required_protocols; + assert.deepStrictEqual(Object.keys(required).sort(), NATIVE_SERVED_PROTOCOLS, 'native cites only protocols it serves'); + assert.ok(!JSON.stringify(block).includes('external_'), 'native cites no external execution contract'); + for (const [client, entry] of Object.entries(block.clients)) { + for (const requirement of entry.requires) { + const [protocol, member] = requirement.split('.'); + assert.ok(required[protocol] && Object.hasOwn(required[protocol], member), + `${client} requires ${requirement}, which the block does not cite`); + } + } +} diff --git a/scripts/conformance/server-parity/cluster-capabilities-probe.php b/scripts/conformance/server-parity/cluster-capabilities-probe.php index 56d5b0d99..bcc221729 100644 --- a/scripts/conformance/server-parity/cluster-capabilities-probe.php +++ b/scripts/conformance/server-parity/cluster-capabilities-probe.php @@ -53,6 +53,19 @@ function finishHttpClusterCapabilities(Client $admin, array $fixture, string $wo static fn (string $member): bool => array_key_exists($member, $info), $definition['php_only_members'])), 'native_unqualified' => array_keys($info['native_unqualified'] ?? [])]; sort($state['native_unqualified']); + // Each client's range as both members state it (server#1005): the two + // must agree. Native's full client_compatibility block is pinned too. + $compatibility = is_array($info['client_compatibility'] ?? null) ? $info['client_compatibility'] : []; + $state['client_ranges'] = []; + foreach (['php' => 'sdk-php', 'python' => 'sdk-python', 'rust' => 'sdk-rust', 'cli' => 'cli'] as $key => $client) { + $state['client_ranges'][$key] = [ + 'supported_sdk_versions' => $info['supported_sdk_versions'][$key] ?? null, + 'client_compatibility' => $compatibility['clients'][$client]['supported_versions'] ?? null, + ]; + } + if (($info['implementation'] ?? null) === 'rust') { + $state['native_client_compatibility'] = $info['client_compatibility'] ?? 'absent'; + } $empty = ['codec' => 'avro', 'blob' => $definition['empty_blob']]; $id = $workflowId.'-cc-run'; diff --git a/tests/Fixtures/ServerParity/cluster-capabilities.json b/tests/Fixtures/ServerParity/cluster-capabilities.json index 059b1e675..9b63ef0ef 100644 --- a/tests/Fixtures/ServerParity/cluster-capabilities.json +++ b/tests/Fixtures/ServerParity/cluster-capabilities.json @@ -190,6 +190,24 @@ "message": "Activity not found in this run." } } + }, + "client_ranges": { + "php": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + }, + "python": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + }, + "rust": { + "supported_sdk_versions": ">=2.0.0,<4.0.0", + "client_compatibility": ">=2.0.0,<4.0.0" + }, + "cli": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + } } }, "native_differences": { @@ -293,14 +311,6 @@ "value": "{absent}", "reason": "Native serves no include=diagnostics view." }, - "present.supported_sdk_versions": { - "value": false, - "reason": "Native leaves this member out and names it in native_unqualified." - }, - "present.client_compatibility": { - "value": false, - "reason": "Native leaves this member out and names it in native_unqualified." - }, "present.topology": { "value": false, "reason": "Native leaves this member out and names it in native_unqualified." @@ -327,21 +337,105 @@ }, "native_unqualified": { "value": [ - "client_compatibility", "conformance_manifests", "discovery.diagnostics_path", "discovery.diagnostics_query", "structural_limits.external_payload_transaction_bytes", - "supported_sdk_versions", "topology" ], "reason": "Native names each member it leaves out." + }, + "client_ranges": { + "value": { + "php": { + "supported_sdk_versions": ">=3.8.1,<4.0.0", + "client_compatibility": ">=3.8.1,<4.0.0" + }, + "python": { + "supported_sdk_versions": ">=2.13.0,<3.0.0", + "client_compatibility": ">=2.13.0,<3.0.0" + }, + "rust": { + "supported_sdk_versions": ">=4.10.3,<5.0.0", + "client_compatibility": ">=4.10.3,<5.0.0" + }, + "cli": { + "supported_sdk_versions": ">=2.0.0,<3.0.0", + "client_compatibility": ">=2.0.0,<3.0.0" + } + }, + "reason": "Native states the ranges its installed qualification supports, from one source for both members (server#1005). sdk-php >=3.8.1: 3.8.0 lost activity claims with heartbeat_timeout under lease maintenance (server#985), fixed in 3.8.1; 3.8.3 passed installed acceptance against native 3.0.4 (https://github.com/durable-workflow/sdk-php/pull/243#issuecomment-6612840439). sdk-python >=2.13.0: 2.13.0 passed installed acceptance against native 3.0.1 (https://github.com/durable-workflow/sdk-python/issues/228, sdk-python#168). sdk-rust >=4.10.3: 4.10.2 failed native describe parity (server#967, fixed by server#968 in 3.0.2); 4.10.3 declares and is qualified on native from 3.0.2 (https://github.com/durable-workflow/sdk-rust/pull/160, harness sdk-rust#155). cli >=2.0.0,<3.0.0: the Rust CLI ships as a 2.x minor (cli#72); its raw worker commands need native 3.1.0 (server#1001). Each upper bound is the next major." } }, "native_additions": { "capabilities.child_workflow_start_turn": { "value": true, "reason": "Native serves start_child_workflow await: \"started\", a parent turn when the child start commits (Server #966); published PHP 2.x has no such mode." + }, + "native_client_compatibility": { + "value": { + "schema": "durable-workflow.v2.client-compatibility", + "version": 2, + "authority": "protocol_manifests", + "top_level_version_role": "informational", + "fail_closed": true, + "required_protocols": { + "auth_composition": { + "schema": "durable-workflow.v2.auth-composition.contract", + "version": 1 + }, + "control_plane": { + "version": "2", + "header": "X-Durable-Workflow-Control-Plane-Version", + "request_contract": { + "schema": "durable-workflow.v2.control-plane-request.contract", + "version": 1 + } + }, + "worker_protocol": { + "version": "1.20", + "header": "X-Durable-Workflow-Protocol-Version" + } + }, + "clients": { + "sdk-php": { + "supported_versions": ">=3.8.1,<4.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + }, + "cli": { + "supported_versions": ">=2.0.0,<3.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract" + ] + }, + "sdk-python": { + "supported_versions": ">=2.13.0,<3.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + }, + "sdk-rust": { + "supported_versions": ">=4.10.3,<5.0.0", + "requires": [ + "auth_composition.version", + "control_plane.version", + "control_plane.request_contract", + "worker_protocol.version" + ] + } + } + }, + "reason": "Native's client_compatibility (server#1005) cites only protocols it serves beside the block: the auth composition contract, the control plane with its request contract, and worker protocol 1.20. Published PHP's block also cites the skew refusal matrix and five worker_protocol external execution contracts, which native does not publish; the CLI checks every protocol the block requires, so native must not cite them." } } } diff --git a/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs b/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs index c32cd6dfa..97d6e89fd 100644 --- a/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs +++ b/tests/Unit/ServerParityClusterCapabilitiesContractTest.mjs @@ -57,3 +57,64 @@ for (const [name, mutate] of [ const raw = observation('rust'); mutate(raw.cluster_capabilities); assert.throws(() => checkClusterCapabilities(fixture, raw)); }); + +// server#1005: native serves client_compatibility and supported_sdk_versions +// from one source, citing only protocols it serves. +test('both implementations state each client range the same way in both members', () => { + for (const implementation of [null, 'rust']) { + for (const [client, columns] of Object.entries(expectedFor(definition, implementation).client_ranges)) { + assert.equal(columns.client_compatibility, columns.supported_sdk_versions, `${implementation ?? 'php'} ${client}`); + } + } + assert.equal(expectedFor(definition, null).client_ranges.rust.supported_sdk_versions, '>=2.0.0,<4.0.0', 'published PHP'); + assert.equal(expectedFor(definition, 'rust').client_ranges.rust.supported_sdk_versions, '>=4.10.3,<5.0.0', 'native'); +}); + +test('every native range bound names its qualification source', () => { + const reason = definition.native_differences.client_ranges.reason; + for (const source of ['sdk-php/pull/243', 'server#985', 'sdk-python/issues/228', 'sdk-rust/pull/160', 'cli#72']) { + assert.ok(reason.includes(source), source); + } +}); + +const phpLikeBlock = state => { + const block = state.native_client_compatibility; + block.required_protocols.worker_protocol.external_task_input_contract = {schema: 'durable-workflow.v2.external-task-input.contract', version: 1}; + block.clients.cli.requires.push('worker_protocol.external_task_input_contract'); +}; +for (const [name, mutate] of [ + ['native 3.0.5 without a client compatibility block', state => { + state.native_client_compatibility = 'absent'; + for (const columns of Object.values(state.client_ranges)) { + columns.supported_sdk_versions = null; columns.client_compatibility = null; + } + }], + ['native citing an external execution contract it does not publish, as published PHP does', phpLikeBlock], + ['native citing the skew refusal matrix', state => { + state.native_client_compatibility.required_protocols.skew_refusal_matrix = {schema: 'x', version: 1}; + }], + ['a client requirement the block does not cite', state => { + state.native_client_compatibility.clients['sdk-rust'].requires.push('control_plane.unserved'); + }], + ['a client range that differs between the two members', state => { + state.client_ranges.php.client_compatibility = '>=3.0.0,<4.0.0'; + }], + ['a native range that differs from the reviewed one in both members', state => { + state.client_ranges.rust = {supported_sdk_versions: '>=4.10.2,<5.0.0', client_compatibility: '>=4.10.2,<5.0.0'}; + }], +]) test(`the contract rejects ${name}`, () => { + const raw = observation('rust'); mutate(raw.cluster_capabilities); + assert.throws(() => checkClusterCapabilities(fixture, raw)); +}); + +test('published PHP ranges that disagree between the two members are rejected', () => { + const raw = observation(null); + raw.cluster_capabilities.client_ranges.cli.client_compatibility = '>=1.0.0,<2.0.0'; + assert.throws(() => checkClusterCapabilities(fixture, raw)); +}); + +test('an unknown implementation is refused', () => { + const raw = observation('rust'); + raw.cluster_capabilities.implementation = 'native'; + assert.throws(() => checkClusterCapabilities(fixture, raw), /published PHP or native/); +});