From 39284488ecacd45062e46b3b312ab00ee31bb673 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:15:18 +0000 Subject: [PATCH 01/26] Define read-only PHP source inspection before native takeover --- docs/rust-server-upgrade.md | 56 +++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/rust-server-upgrade.md diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md new file mode 100644 index 000000000..396c90820 --- /dev/null +++ b/docs/rust-server-upgrade.md @@ -0,0 +1,56 @@ +# Native in-place upgrade preparation + +[Server #325](https://github.com/durable-workflow/server/issues/325) requires a +backup-first, sequential upgrade of an existing PHP database. This document +describes the first executable preparation step; production takeover remains +unimplemented and unqualified. + +## Read-only source inspection contract + +The native `upgrade-inspect` command will inspect a configured existing PHP +database using a dedicated read-only transaction, without constructing a runtime +pool. It uses the same `DB_*` connection configuration as native schema checks +and supports SQLite, MySQL/MariaDB and PostgreSQL. It requires the existing +development opt-in. SQLite inspection never creates a missing database or runs +recovery pragmas. + +Inspection must refuse a native ownership marker, an unknown catalog, missing +permissions and unavailable storage before reporting durable-state counts. +The known PHP catalog is the existing compiled physical schema with only the +explicit native additions excluded. Database-generated next-ID counters remain +data; no other catalog differences are discarded. Schema equality identifies a +layout, not the installed PHP package version. + +A successful report contains the backend, visible-catalog fingerprint, counts +for the known PHP tables, and explicit `takeover_allowed: false`, +`backup_verified: false`, `php_quiescence_verified: false`. It contains no +workflow payloads, credential digests, provider secrets or connection URL. +Successful inspection means only that this read-only inventory completed under +the configured role's visibility. It does not establish a verified backup, +complete stored-representation compatibility, global writer quiescence or +permission to apply migrations. Source inspection is a native operator tool; +it adds no HTTP or worker capability. + +The whole inspection has a fixed 30-second observation budget. Timeout, +unknown schema or access failure produces an explicit refusal, with no source +DDL, ledger, marker, row, lease, sequence or provider mutation. Standalone +connections close on both success and refusal; no cancelled connection returns +to an application pool. + +## Qualification and next migration gates + +Use a separate cohort populated by the current published PHP artifact, record +its actual identity, and stop every PHP writer before refusal/byte-preservation +checks. Existing differential actors and frozen manifests remain unchanged. +Cover real pending workflow/activity/timer state, select-only inspection, +missing files, unknown schema, ownership markers and failures on all required +database families. An inventory report is not an executed upgrade. + +The subsequent migration must validate stored PHP-specific representations and +queued work, verify an independently restored consistent database/external-byte +backup with reclamation held, establish writer quiescence, apply versioned +native changes, and record ownership before PHP can resume. Interruptions must +resume safely or refuse. Actual SDK workers must continue the same original +pending work with preserved histories, credentials, deadlines and external +bytes. The existing PHP guard must refuse all upgraded startup/bootstrap/ +worker/scheduler paths. There is no mixed fleet or post-upgrade PHP rollback. From cd455d499cbb08200cb1c3c471ecd9634c18c2a6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:24:13 +0000 Subject: [PATCH 02/26] Inspect PHP source catalogs and row counts without runtime write access --- docs/rust-server-upgrade.md | 6 +- rust/src/main.rs | 40 +++++- rust/src/runtime/mod.rs | 1 + rust/src/runtime/mysql.rs | 43 ++++++ rust/src/runtime/postgres.rs | 39 ++++++ rust/src/runtime/schema.rs | 37 ++++- rust/src/runtime/upgrade.rs | 216 ++++++++++++++++++++++++++++++ rust/src/runtime/upgrade_tests.rs | 163 ++++++++++++++++++++++ 8 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 rust/src/runtime/upgrade.rs create mode 100644 rust/src/runtime/upgrade_tests.rs diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 396c90820..77baaede0 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -35,7 +35,11 @@ The whole inspection has a fixed 30-second observation budget. Timeout, unknown schema or access failure produces an explicit refusal, with no source DDL, ledger, marker, row, lease, sequence or provider mutation. Standalone connections close on both success and refusal; no cancelled connection returns -to an application pool. +to an application pool. Socket retirement has a separate one-second ceiling. +Inspection runs against the configured role's visible catalog. It cannot prove +absence of objects hidden by database privileges, nor infer an installed package +version from schema equality. PostgreSQL row-security policies require separate +qualification and refuse this first inventory path. ## Qualification and next migration gates diff --git a/rust/src/main.rs b/rust/src/main.rs index d73532697..93f048d98 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -125,11 +125,49 @@ async fn main() -> Result<(), Box> { } if let Some(command) = env::args().nth(1) { if env::args().count() != 2 - || !matches!(command.as_str(), "schema-bootstrap" | "schema-check") + || !matches!( + command.as_str(), + "schema-bootstrap" | "schema-check" | "upgrade-inspect" + ) { return Err("unknown development command".into()); } let backend = env::var("DB_CONNECTION").unwrap_or_default(); + if command == "upgrade-inspect" { + let result = match backend.as_str() { + "sqlite" => { + durable_workflow_server::runtime::upgrade::inspect_sqlite( + &env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?, + ) + .await + } + "mysql" | "mariadb" => { + durable_workflow_server::runtime::upgrade::inspect_mysql(mysql_options()?).await + } + "pgsql" => { + durable_workflow_server::runtime::upgrade::inspect_postgres(postgres_options()?) + .await + } + _ => { + return Err( + "upgrade-inspect requires DB_CONNECTION=sqlite, pgsql, mysql or mariadb" + .into(), + ); + } + }; + match result { + Ok(report) => println!("{report}"), + Err(error) => { + let reason = match error { + RuntimeError::Refused { reason, .. } => reason, + _ => "upgrade_inspection_unavailable", + }; + eprintln!("{reason}"); + return Err("upgrade source inspection refused".into()); + } + } + return Ok(()); + } let result = match backend.as_str() { "pgsql" => { let options = postgres_options()?; diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 0e64a5caa..38eb19c25 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -37,6 +37,7 @@ mod task_failures; mod task_repair; mod task_repair_http; mod updates; +pub mod upgrade; mod visibility; mod worker_incarnations; diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index 36c76ed38..885f75882 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -149,6 +149,49 @@ async fn catalog(connection: &mut MySqlConnection) -> Result { Ok(result) } +pub(super) async fn php_source_catalog(connection: &mut MySqlConnection) -> Result { + let marked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND LOWER(TABLE_NAME)='dw_server_schema'") + .fetch_one(&mut *connection).await?; + if marked != 0 { + return Err(refuse(StatusCode::CONFLICT, "upgrade_source_already_owned")); + } + // Table-only grants can hide an ownership marker from information_schema. + // A denied probe is unknown ownership, never proof of absence. + match sqlx::query("SELECT 1 FROM `dw_server_schema` LIMIT 0") + .fetch_all(&mut *connection) + .await + { + Err(sqlx::Error::Database(error)) + if error + .try_downcast_ref::() + .is_some_and(|error| error.number() == 1146 && error.code() == Some("42S02")) => {} + Ok(_) => return Err(refuse(StatusCode::CONFLICT, "upgrade_source_already_owned")), + Err(_) => { + return Err(refuse( + StatusCode::SERVICE_UNAVAILABLE, + "upgrade_source_ownership_unknown", + )); + } + } + let expected: Catalog = Flavor::detect(connection).await?.expected()?.into_iter() + .filter(|(table, _)| !super::upgrade::native_relation(table)) + .map(|(table, ddl)| (table, ddl.replace( + ",\n KEY `dw_workflow_tasks_poll` (`namespace`(64),`queue`(64),`task_type`(16),`status`(16),`available_at`)", ""))) + .collect(); + let actual = catalog(connection).await?; + if actual != expected { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + Ok(actual) +} + +pub(super) fn source_options(options: MySqlConnectOptions) -> MySqlConnectOptions { + self::options(options) +} + async fn history( connection: &mut MySqlConnection, flavor: Flavor, diff --git a/rust/src/runtime/postgres.rs b/rust/src/runtime/postgres.rs index 7d88e2da2..1dde3d429 100644 --- a/rust/src/runtime/postgres.rs +++ b/rust/src/runtime/postgres.rs @@ -54,6 +54,45 @@ async fn catalog(connection: &mut PgConnection) -> Result { Ok(sqlx::query_as(CATALOG_QUERY).fetch_all(connection).await?) } +pub(super) async fn php_source_catalog(connection: &mut PgConnection) -> Result { + verify_scope(connection).await?; + if marked(connection).await? { + return Err(refuse(StatusCode::CONFLICT, "upgrade_source_already_owned")); + } + let row_security: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND (c.relrowsecurity OR c.relforcerowsecurity))") + .fetch_one(&mut *connection).await?; + if row_security { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_row_security_unqualified", + )); + } + let native: Catalog = + serde_json::from_str(include_str!("../../migrations/postgres/0004_catalog.json"))?; + let expected: Catalog = native + .into_iter() + .filter(|(kind, name, _)| { + if kind == "column" || kind == "constraint" { + !super::upgrade::native_relation(name.split('.').next().unwrap_or(name)) + } else { + !super::upgrade::native_object(name) + } + }) + .collect(); + let actual = catalog(connection).await?; + if actual != expected { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + Ok(actual) +} + +pub(super) fn source_options(options: PgConnectOptions) -> PgConnectOptions { + self::options(options) +} + async fn empty(connection: &mut PgConnection) -> Result { let occupied: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relkind IN ('r','p','v','m','f','S')) OR EXISTS(SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public')") .fetch_one(connection).await?; diff --git a/rust/src/runtime/schema.rs b/rust/src/runtime/schema.rs index be874d35b..d9b1003ec 100644 --- a/rust/src/runtime/schema.rs +++ b/rust/src/runtime/schema.rs @@ -39,8 +39,8 @@ async fn catalog(connection: &mut SqliteConnection) -> Result { .fetch_all(connection).await?) } -async fn verify_catalog(connection: &mut SqliteConnection) -> Result<()> { - let expected = EXPECTED +async fn expected_catalog() -> Result<&'static Catalog> { + EXPECTED .get_or_try_init(|| async { // SQLite itself interprets the frozen DDL; do not invent a SQL parser. // This isolated in-memory catalog is built once per process on SQLx's @@ -53,7 +53,11 @@ async fn verify_catalog(connection: &mut SqliteConnection) -> Result<()> { reference.close().await?; Ok::<_, super::RuntimeError>(rows) }) - .await?; + .await +} + +async fn verify_catalog(connection: &mut SqliteConnection) -> Result<()> { + let expected = expected_catalog().await?; if &catalog(connection).await? != expected { return Err(refuse( StatusCode::CONFLICT, @@ -63,6 +67,33 @@ async fn verify_catalog(connection: &mut SqliteConnection) -> Result<()> { Ok(()) } +pub(super) async fn php_source_catalog(connection: &mut SqliteConnection) -> Result { + let owned: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='dw_server_schema' COLLATE NOCASE)", + ) + .fetch_one(&mut *connection) + .await?; + if owned { + return Err(refuse(StatusCode::CONFLICT, "upgrade_source_already_owned")); + } + let expected: Catalog = expected_catalog() + .await? + .iter() + .filter(|(_, name, table, _)| { + !super::upgrade::native_relation(table) && name != "dw_workflow_tasks_poll" + }) + .cloned() + .collect(); + let actual = catalog(connection).await?; + if actual != expected { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + Ok(actual) +} + pub(super) async fn verify(connection: &mut SqliteConnection) -> Result<()> { let markers: Vec<(String, i64)> = sqlx::query_as("SELECT engine,version FROM dw_server_schema") .fetch_all(&mut *connection) diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs new file mode 100644 index 000000000..814cfe109 --- /dev/null +++ b/rust/src/runtime/upgrade.rs @@ -0,0 +1,216 @@ +//! Read-only PHP source inventory. This command never grants takeover authority. +use super::{Result, mysql, postgres, refuse, schema}; +use axum::http::StatusCode; +use serde::Serialize; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use sqlx::{ + AssertSqlSafe, Connection, MySqlConnection, PgConnection, SqliteConnection, + mysql::MySqlConnectOptions, postgres::PgConnectOptions, sqlite::SqliteConnectOptions, +}; +use std::{collections::BTreeMap, future::Future, time::Duration}; +use tokio::time::{Instant, timeout, timeout_at}; + +const BUDGET: Duration = Duration::from_secs(30); + +pub(super) fn native_relation(name: &str) -> bool { + matches!( + name, + "_sqlx_migrations" + | "dw_server_schema" + | "dw_task_completions" + | "dw_poll_receipts" + | "dw_query_cache" + | "dw_worker_registration_incarnations" + ) +} + +pub(super) fn native_object(name: &str) -> bool { + native_relation(name) + || matches!( + name, + "_sqlx_migrations_pkey" + | "dw_task_completions_pkey" + | "dw_poll_receipts_pkey" + | "dw_query_cache_pkey" + | "dw_workflow_tasks_poll" + | "dw_worker_registration_incarnations_pkey" + | "dw_worker_incarnation_authority" + | "dw_worker_incarnation_retention" + ) +} + +fn expired() -> super::RuntimeError { + refuse( + StatusCode::SERVICE_UNAVAILABLE, + "upgrade_inspection_timeout", + ) +} + +async fn close(future: impl Future>) -> Result<()> { + // Dedicated connections never return to a pool. Dropping a timed-out close + // disposes of its socket, including the audited MySQL/Rustls hard-close path. + timeout(Duration::from_secs(1), future) + .await + .map_err(|_| expired())??; + Ok(()) +} + +fn report(backend: &str, catalog: &impl Serialize, counts: BTreeMap) -> Result { + let digest = Sha256::digest(serde_json::to_vec(catalog)?) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + Ok(json!({ + "schema":"durable-workflow.rust-upgrade-source-inspection.v1", + "status":"inspected", "backend":backend, "catalog_visibility":"configured_role", + "catalog_sha256":digest, "table_rows":counts, + "source_mutated":false, "takeover_allowed":false, "backup_verified":false, + "php_quiescence_verified":false, "stored_representation_compatibility_verified":false + })) +} + +// Names come only from a catalog equal to the compiled physical PHP layout. +// All values stay in SQL; the inventory contains counts, never source payloads. +macro_rules! count_tables { + ($tx:ident, $tables:expr, $quote:literal) => {{ + let mut counts = BTreeMap::::new(); + for table in $tables { + if table.is_empty() + || !table + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + let count: i64 = sqlx::query_scalar(AssertSqlSafe(format!( + "SELECT COUNT(*) FROM {}{}{}", + $quote, table, $quote + ))) + .fetch_one(&mut *$tx) + .await?; + counts.insert(table, count); + } + counts + }}; +} + +pub async fn inspect_sqlite(path: &str) -> Result { + let deadline = Instant::now() + BUDGET; + if path.is_empty() || path.contains(['?', ':']) { + return Err(refuse( + StatusCode::BAD_REQUEST, + "upgrade_source_file_required", + )); + } + let mut connection = timeout_at( + deadline, + SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .create_if_missing(false) + .foreign_keys(false), + ), + ) + .await + .map_err(|_| expired())??; + let result = timeout_at(deadline, async { + let mut tx = connection.begin().await?; + let catalog = schema::php_source_catalog(&mut tx).await?; + let tables: Vec = catalog + .iter() + .filter(|(kind, _, _, _)| kind == "table") + .map(|(_, name, _, _)| name.clone()) + .collect(); + let counts = count_tables!(tx, tables, '"'); + tx.rollback().await?; + report("sqlite", &catalog, counts) + }) + .await + .map_err(|_| expired()) + .and_then(|result| result); + close(connection.close()).await?; + result +} + +pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { + let deadline = Instant::now() + BUDGET; + let mut connection = timeout_at( + deadline, + MySqlConnection::connect_with(&mysql::source_options(options)), + ) + .await + .map_err(|_| expired())??; + let result = timeout_at(deadline, async { + let version: String = sqlx::query_scalar("SELECT VERSION()") + .fetch_one(&mut connection) + .await?; + let backend = if version.contains("MariaDB") { + "mariadb" + } else { + "mysql" + }; + sqlx::query("SET sql_quote_show_create=1") + .execute(&mut connection) + .await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut connection) + .await?; + let mut tx = connection.begin_with("START TRANSACTION READ ONLY").await?; + let catalog = mysql::php_source_catalog(&mut tx).await?; + let tables: Vec = catalog.iter().map(|(name, _)| name.clone()).collect(); + let counts = count_tables!(tx, tables, '`'); + // MySQL's metadata is not an MVCC snapshot. Refuse a changed visible + // catalog rather than joining counts to a different schema. + if mysql::php_source_catalog(&mut tx).await? != catalog { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_changed", + )); + } + tx.rollback().await?; + report(backend, &catalog, counts) + }) + .await + .map_err(|_| expired()) + .and_then(|result| result); + close(connection.close()).await?; + result +} + +pub async fn inspect_postgres(options: PgConnectOptions) -> Result { + let deadline = Instant::now() + BUDGET; + let mut connection = timeout_at( + deadline, + PgConnection::connect_with(&postgres::source_options(options)), + ) + .await + .map_err(|_| expired())??; + let result = timeout_at(deadline, async { + let mut tx = connection + .begin_with("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY") + .await?; + let catalog = postgres::php_source_catalog(&mut tx).await?; + let tables: Vec = catalog + .iter() + .filter(|(kind, _, data)| kind == "relation" && data.0["kind"] == "r") + .map(|(_, name, _)| name.clone()) + .collect(); + let counts = count_tables!(tx, tables, '"'); + tx.rollback().await?; + report("pgsql", &catalog, counts) + }) + .await + .map_err(|_| expired()) + .and_then(|result| result); + close(connection.close()).await?; + result +} + +#[cfg(test)] +#[path = "upgrade_tests.rs"] +mod tests; diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs new file mode 100644 index 000000000..fb9f486de --- /dev/null +++ b/rust/src/runtime/upgrade_tests.rs @@ -0,0 +1,163 @@ +use super::*; +use sqlx::{Executor, sqlite::SqliteJournalMode}; +use std::path::Path; + +async fn php_layout() -> (tempfile::TempDir, String) { + // A unit layout derived from the frozen DDL is a refusal/control fixture, + // not proof that an actual published PHP installation has been inspected. + let directory = tempfile::tempdir().unwrap(); + let path = directory + .path() + .join("php.sqlite") + .to_str() + .unwrap() + .to_owned(); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .foreign_keys(false) + .journal_mode(SqliteJournalMode::Delete), + ) + .await + .unwrap(); + connection.execute("BEGIN").await.unwrap(); + sqlx::raw_sql(concat!( + include_str!("../../migrations/sqlite/0003_full_schema.sql"), + "\n", + include_str!("../../migrations/sqlite/0004_worker_incarnations.sql") + )) + .execute(&mut connection) + .await + .unwrap(); + for table in [ + "dw_worker_registration_incarnations", + "dw_task_completions", + "dw_poll_receipts", + "dw_query_cache", + "dw_server_schema", + ] { + sqlx::query(AssertSqlSafe(format!("DROP TABLE {table}"))) + .execute(&mut connection) + .await + .unwrap(); + } + connection + .execute("DROP INDEX dw_workflow_tasks_poll") + .await + .unwrap(); + sqlx::query("INSERT INTO jobs(queue,payload,attempts,available_at,created_at) VALUES ('test','private-payload-λ',0,1,1)") + .execute(&mut connection).await.unwrap(); + connection.execute("COMMIT").await.unwrap(); + connection.close().await.unwrap(); + (directory, path) +} + +async fn mutate(path: &str, statement: &'static str) { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .foreign_keys(false) + .busy_timeout(Duration::from_millis(50)), + ) + .await + .unwrap(); + connection.execute(statement).await.unwrap(); + connection.close().await.unwrap(); +} + +fn reason(result: Result, expected: &str) { + assert!( + matches!(result, Err(super::super::RuntimeError::Refused { reason, .. }) if reason == expected) + ); +} + +#[tokio::test] +async fn missing_source_is_not_created() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.sqlite"); + assert!(inspect_sqlite(path.to_str().unwrap()).await.is_err()); + assert!(!path.exists()); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0); +} + +#[tokio::test] +async fn inspection_preserves_source_bytes_reports_counts_and_never_grants_takeover() { + let (_directory, path) = php_layout().await; + let before = std::fs::read(&path).unwrap(); + let inspected = inspect_sqlite(&path).await.unwrap(); + assert_eq!(inspected["status"], "inspected"); + assert_eq!(inspected["backend"], "sqlite"); + assert_eq!(inspected["table_rows"].as_object().unwrap().len(), 49); + assert_eq!(inspected["table_rows"]["jobs"], 1); + assert_eq!(inspected["table_rows"]["workflow_runs"], 0); + assert_eq!(inspected["catalog_sha256"].as_str().unwrap().len(), 64); + for flag in [ + "source_mutated", + "takeover_allowed", + "backup_verified", + "php_quiescence_verified", + "stored_representation_compatibility_verified", + ] { + assert_eq!(inspected[flag], false, "{flag}"); + } + assert!(!inspected.to_string().contains("private-payload")); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(!Path::new(&format!("{path}-wal")).exists()); + assert!(!Path::new(&format!("{path}-journal")).exists()); + // No reader or abandoned transaction survives the command's return. + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + assert_eq!(std::fs::read(&path).unwrap(), before); + let refused = super::super::sqlite::open(&path).await; + assert!(matches!( + refused, + Err(super::super::RuntimeError::Refused { + reason: "existing_database_requires_qualified_takeover", + .. + }) + )); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[tokio::test] +async fn unknown_source_catalog_refuses_without_repair_or_retained_reader() { + let (_directory, path) = php_layout().await; + mutate(&path, "ALTER TABLE jobs ADD COLUMN unexpected TEXT").await; + let before = std::fs::read(&path).unwrap(); + reason( + inspect_sqlite(&path).await, + "upgrade_source_catalog_unqualified", + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[tokio::test] +async fn empty_malformed_and_view_ownership_markers_refuse_before_definition_execution() { + for statement in [ + "CREATE TABLE dw_server_schema (unexpected INTEGER)", + "CREATE VIEW DW_SERVER_SCHEMA AS SELECT missing_function()", + ] { + let (_directory, path) = php_layout().await; + mutate(&path, statement).await; + let before = std::fs::read(&path).unwrap(); + reason(inspect_sqlite(&path).await, "upgrade_source_already_owned"); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + } +} + +#[cfg(unix)] +#[tokio::test] +async fn read_only_source_file_can_be_inspected_without_write_permissions() { + use std::os::unix::fs::PermissionsExt; + let (_directory, path) = php_layout().await; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert_eq!( + inspect_sqlite(&path).await.unwrap()["table_rows"]["jobs"], + 1 + ); + assert_eq!(std::fs::read(&path).unwrap(), before); +} From 7e337c24ec773fbb9abb1670b16399274757f7a6 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:36:29 +0000 Subject: [PATCH 03/26] Refuse SQLite WAL before opening and recognize exact published source catalog variants --- docs/rust-server-upgrade.md | 12 ++ rust/src/runtime/mysql.rs | 24 ++- rust/src/runtime/postgres.rs | 15 +- rust/src/runtime/schema.rs | 15 +- rust/src/runtime/upgrade.rs | 63 +++++- rust/src/runtime/upgrade_tests.rs | 31 +++ rust/upgrade/php-2.8.0/README.md | 26 +++ rust/upgrade/php-2.8.0/mysql.json | 26 +++ rust/upgrade/php-2.8.0/postgres.json | 204 ++++++++++++++++++ rust/upgrade/php-2.8.0/sqlite.json | 54 +++++ .../server-parity/upgrade-source-author.php | 87 ++++++++ 11 files changed, 543 insertions(+), 14 deletions(-) create mode 100644 rust/upgrade/php-2.8.0/README.md create mode 100644 rust/upgrade/php-2.8.0/mysql.json create mode 100644 rust/upgrade/php-2.8.0/postgres.json create mode 100644 rust/upgrade/php-2.8.0/sqlite.json create mode 100644 scripts/conformance/server-parity/upgrade-source-author.php diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 77baaede0..15b0c28e0 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -14,6 +14,13 @@ and supports SQLite, MySQL/MariaDB and PostgreSQL. It requires the existing development opt-in. SQLite inspection never creates a missing database or runs recovery pragmas. +SQLite WAL inspection is initially refused before opening SQLite: a read-only +SQLite connection can still create shared-memory and WAL sidecars. This first +path accepts rollback-journal databases only and does not change their journal +mode. WAL inventory requires a separately qualified consistent snapshot path. +Use a read-only filesystem mount as an additional boundary and stop writers +before inspection; the report does not certify that they have stopped. + Inspection must refuse a native ownership marker, an unknown catalog, missing permissions and unavailable storage before reporting durable-state counts. The known PHP catalog is the existing compiled physical schema with only the @@ -21,6 +28,11 @@ explicit native additions excluded. Database-generated next-ID counters remain data; no other catalog differences are discarded. Schema equality identifies a layout, not the installed PHP package version. +The separate exact PHP 2.8.0 catalog variant records the published service-call +deadline and worker incarnation additions. It is compared as a complete +catalog after applying a reviewed, exact delta in memory; it does not change +native schema migrations or qualify execution against the newer source layout. + A successful report contains the backend, visible-catalog fingerprint, counts for the known PHP tables, and explicit `takeover_allowed: false`, `backup_verified: false`, `php_quiescence_verified: false`. It contains no diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index 885f75882..92dae647d 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -173,17 +173,31 @@ pub(super) async fn php_source_catalog(connection: &mut MySqlConnection) -> Resu )); } } - let expected: Catalog = Flavor::detect(connection).await?.expected()?.into_iter() + let flavor = Flavor::detect(connection).await?; + let expected: Catalog = flavor.expected()?.into_iter() .filter(|(table, _)| !super::upgrade::native_relation(table)) .map(|(table, ddl)| (table, ddl.replace( ",\n KEY `dw_workflow_tasks_poll` (`namespace`(64),`queue`(64),`task_type`(16),`status`(16),`available_at`)", ""))) .collect(); let actual = catalog(connection).await?; if actual != expected { - return Err(refuse( - StatusCode::CONFLICT, - "upgrade_source_catalog_unqualified", - )); + let delta = match flavor { + Flavor::MySql => include_str!("../../upgrade/php-2.8.0/mysql.json"), + Flavor::MariaDb => { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + }; + let mut current = super::upgrade::source_catalog_variant(&expected, delta)?; + current.sort_by(|left, right| left.0.cmp(&right.0)); + if actual != current { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } } Ok(actual) } diff --git a/rust/src/runtime/postgres.rs b/rust/src/runtime/postgres.rs index 1dde3d429..234c04203 100644 --- a/rust/src/runtime/postgres.rs +++ b/rust/src/runtime/postgres.rs @@ -81,10 +81,17 @@ pub(super) async fn php_source_catalog(connection: &mut PgConnection) -> Result< .collect(); let actual = catalog(connection).await?; if actual != expected { - return Err(refuse( - StatusCode::CONFLICT, - "upgrade_source_catalog_unqualified", - )); + let mut current = super::upgrade::source_catalog_variant( + &expected, + include_str!("../../upgrade/php-2.8.0/postgres.json"), + )?; + current.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1))); + if actual != current { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } } Ok(actual) } diff --git a/rust/src/runtime/schema.rs b/rust/src/runtime/schema.rs index d9b1003ec..279e78d67 100644 --- a/rust/src/runtime/schema.rs +++ b/rust/src/runtime/schema.rs @@ -86,10 +86,17 @@ pub(super) async fn php_source_catalog(connection: &mut SqliteConnection) -> Res .collect(); let actual = catalog(connection).await?; if actual != expected { - return Err(refuse( - StatusCode::CONFLICT, - "upgrade_source_catalog_unqualified", - )); + let mut current = super::upgrade::source_catalog_variant( + &expected, + include_str!("../../upgrade/php-2.8.0/sqlite.json"), + )?; + current.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1))); + if actual != current { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } } Ok(actual) } diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 814cfe109..51ceaf32f 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,7 +1,7 @@ //! Read-only PHP source inventory. This command never grants takeover authority. use super::{Result, mysql, postgres, refuse, schema}; use axum::http::StatusCode; -use serde::Serialize; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::{ @@ -9,10 +9,45 @@ use sqlx::{ mysql::MySqlConnectOptions, postgres::PgConnectOptions, sqlite::SqliteConnectOptions, }; use std::{collections::BTreeMap, future::Future, time::Duration}; +use tokio::io::AsyncReadExt; use tokio::time::{Instant, timeout, timeout_at}; const BUDGET: Duration = Duration::from_secs(30); +/// Exact published-catalog additions/replacements, applied only in memory. +/// Neither unknown objects nor unmatched old definitions are discarded. +pub(super) fn source_catalog_variant(baseline: &[T], delta: &str) -> Result> +where + T: Clone + PartialEq + DeserializeOwned, +{ + #[derive(Deserialize)] + struct Delta { + remove: Vec, + add: Vec, + } + let delta: Delta = serde_json::from_str(delta)?; + let mut result = baseline.to_vec(); + for row in delta.remove { + let Some(position) = result.iter().position(|existing| existing == &row) else { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + }; + result.remove(position); + } + for row in delta.add { + if result.contains(&row) { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_unqualified", + )); + } + result.push(row); + } + Ok(result) +} + pub(super) fn native_relation(name: &str) -> bool { matches!( name, @@ -106,6 +141,32 @@ pub async fn inspect_sqlite(path: &str) -> Result { "upgrade_source_file_required", )); } + // SQLITE_OPEN_READONLY can still create WAL/shared-memory sidecars. Do + // not open an unqualified WAL source or attempt journal recovery. Unlike + // immutable=1 this preserves normal SQLite locking/change detection for + // the supported rollback-journal source, rather than ignoring live WAL. + timeout_at(deadline, async { + let mut file = tokio::fs::File::open(path).await?; + let mut header = [0_u8; 20]; + file.read_exact(&mut header).await?; + if &header[..16] != b"SQLite format 3\0" || header[18..20] != [1, 1] { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_sqlite_journal_unqualified", + )); + } + for suffix in ["-wal", "-shm", "-journal"] { + if tokio::fs::try_exists(format!("{path}{suffix}")).await? { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_sqlite_journal_unqualified", + )); + } + } + Ok::<_, super::RuntimeError>(()) + }) + .await + .map_err(|_| expired())??; let mut connection = timeout_at( deadline, SqliteConnection::connect_with( diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index fb9f486de..aa1df95b4 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -81,6 +81,37 @@ async fn missing_source_is_not_created() { assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0); } +#[tokio::test] +async fn wal_source_refuses_before_creating_sidecars_or_ignoring_wal() { + let (_directory, path) = php_layout().await; + mutate(&path, "PRAGMA journal_mode=WAL").await; + let before = std::fs::read(&path).unwrap(); + assert_eq!(&before[18..20], &[2, 2]); + assert!(!Path::new(&format!("{path}-wal")).exists()); + assert!(!Path::new(&format!("{path}-shm")).exists()); + reason( + inspect_sqlite(&path).await, + "upgrade_source_sqlite_journal_unqualified", + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(!Path::new(&format!("{path}-wal")).exists()); + assert!(!Path::new(&format!("{path}-shm")).exists()); + // A live WAL with committed changes must also refuse, never silently + // inspect only the main file as an immutable connection could do. + let mut writer = SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&path)) + .await + .unwrap(); + writer.execute("UPDATE jobs SET attempts=1").await.unwrap(); + let wal_path = format!("{path}-wal"); + let wal = std::fs::read(&wal_path).unwrap(); + reason( + inspect_sqlite(&path).await, + "upgrade_source_sqlite_journal_unqualified", + ); + assert_eq!(std::fs::read(&wal_path).unwrap(), wal); + writer.close().await.unwrap(); +} + #[tokio::test] async fn inspection_preserves_source_bytes_reports_counts_and_never_grants_takeover() { let (_directory, path) = php_layout().await; diff --git a/rust/upgrade/php-2.8.0/README.md b/rust/upgrade/php-2.8.0/README.md new file mode 100644 index 000000000..c699ddfda --- /dev/null +++ b/rust/upgrade/php-2.8.0/README.md @@ -0,0 +1,26 @@ +# Exact PHP source-catalog variant + +These small catalog deltas come from normal bootstrap of published Server +2.8.0, source `dd720e6b51844707be2885f8873c23de98c847a7`, image index +`sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac`. +The actual amd64 image configuration is +`sha256:4199bcbaf349a600cbb29a7ced6170b72c49a51828c39f0e7963dc102b642911`. + +They contain only complete physical catalog definitions: service-call deadline +storage/index and worker registration token/incarnation storage/indexes. Every +removed definition must match the compiled older PHP layout exactly. Applying +the delta in memory yields another complete expected catalog; unknown or changed +objects still refuse. Nothing is applied to a source database, and native +migrations, their checksums and the frozen differential catalog stay unchanged. + +SQLite definitions were read from an actual published-image database. MySQL +8.4.5 and PostgreSQL 17 definitions were collected after successful raw public +protocol preparation and explicit PHP SIGTERM/exit 0. The earlier partial MySQL +catalog read during bootstrap was discarded and is not fixture provenance. +Catalog inspection records no application row values or credentials. + +This recognizes physical layout for read-only inventory only. It neither +identifies the installed package from a schema nor qualifies native execution, +stored representations, backup, writer quiescence or takeover of that layout. +WAL SQLite remains explicitly refused before opening; its definitions alone +do not qualify WAL inspection. diff --git a/rust/upgrade/php-2.8.0/mysql.json b/rust/upgrade/php-2.8.0/mysql.json new file mode 100644 index 000000000..9c8c3d0c3 --- /dev/null +++ b/rust/upgrade/php-2.8.0/mysql.json @@ -0,0 +1,26 @@ +{ + "remove": [ + [ + "workflow_service_calls", + "CREATE TABLE `workflow_service_calls` (\n `id` varchar(26) COLLATE utf8mb4_unicode_ci NOT NULL,\n `workflow_service_endpoint_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `workflow_service_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `workflow_service_operation_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `endpoint_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `service_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `operation_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `caller_namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_workflow_instance_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_workflow_run_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `target_namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_instance_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_run_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_update_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `outcome` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `operation_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `resolved_binding_kind` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `resolved_target_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `payload_codec` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `input_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `output_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `failure_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `failure_message` text COLLATE utf8mb4_unicode_ci,\n `idempotency_key` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `deadline_policy` json DEFAULT NULL,\n `idempotency_policy` json DEFAULT NULL,\n `cancellation_policy` json DEFAULT NULL,\n `retry_policy` json DEFAULT NULL,\n `boundary_policy` json DEFAULT NULL,\n `metadata` json DEFAULT NULL,\n `outcome_category` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `outcome_reason` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `outcome_message` text COLLATE utf8mb4_unicode_ci,\n `outcome_metadata` json DEFAULT NULL,\n `policy_name` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `retry_after_seconds` int unsigned DEFAULT NULL,\n `caller_principal_subject` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_method` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_roles` json DEFAULT NULL,\n `caller_principal_tenant` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_claims` json DEFAULT NULL,\n `accepted_at` timestamp(6) NULL DEFAULT NULL,\n `started_at` timestamp(6) NULL DEFAULT NULL,\n `completed_at` timestamp(6) NULL DEFAULT NULL,\n `failed_at` timestamp(6) NULL DEFAULT NULL,\n `cancelled_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n PRIMARY KEY (`id`),\n KEY `wf_service_calls_namespace_status_idx` (`namespace`,`status`),\n KEY `wf_service_calls_caller_status_idx` (`caller_namespace`,`status`),\n KEY `wf_service_calls_target_status_idx` (`target_namespace`,`status`),\n KEY `wf_service_calls_namespace_outcome_idx` (`namespace`,`outcome`),\n KEY `wf_service_calls_caller_outcome_idx` (`caller_namespace`,`outcome`),\n KEY `wf_service_calls_target_outcome_idx` (`target_namespace`,`outcome`),\n KEY `wf_service_calls_endpoint_idx` (`workflow_service_endpoint_id`),\n KEY `wf_service_calls_service_idx` (`workflow_service_id`),\n KEY `wf_service_calls_operation_idx` (`workflow_service_operation_id`),\n KEY `wf_service_calls_namespace_idx` (`namespace`),\n KEY `wf_service_calls_caller_namespace_idx` (`caller_namespace`),\n KEY `wf_service_calls_caller_instance_idx` (`caller_workflow_instance_id`),\n KEY `wf_service_calls_caller_run_idx` (`caller_workflow_run_id`),\n KEY `wf_service_calls_target_namespace_idx` (`target_namespace`),\n KEY `wf_service_calls_linked_instance_idx` (`linked_workflow_instance_id`),\n KEY `wf_service_calls_linked_run_idx` (`linked_workflow_run_id`),\n KEY `wf_service_calls_linked_update_idx` (`linked_workflow_update_id`),\n KEY `wf_service_calls_status_idx` (`status`),\n KEY `wf_service_calls_outcome_idx` (`outcome`),\n KEY `wf_service_calls_mode_idx` (`operation_mode`),\n KEY `wf_service_calls_binding_kind_idx` (`resolved_binding_kind`),\n KEY `wf_service_calls_idempotency_idx` (`idempotency_key`),\n KEY `wf_service_calls_outcome_category_idx` (`outcome_category`),\n KEY `wf_service_calls_policy_idx` (`policy_name`),\n KEY `wf_service_calls_principal_subject_idx` (`caller_principal_subject`),\n KEY `wf_service_calls_accepted_at_idx` (`accepted_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registrations", + "CREATE TABLE `workflow_worker_registrations` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `worker_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `namespace` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,\n `task_queue` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `runtime` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `sdk_version` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `build_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `supported_workflow_types` json DEFAULT NULL,\n `workflow_definition_fingerprints` json DEFAULT NULL,\n `workflow_command_contracts` json DEFAULT NULL,\n `supported_activity_types` json DEFAULT NULL,\n `capabilities` json DEFAULT NULL,\n `capability_manifest` json DEFAULT NULL,\n `max_concurrent_workflow_tasks` int unsigned NOT NULL DEFAULT '100',\n `max_concurrent_activity_tasks` int unsigned NOT NULL DEFAULT '100',\n `max_concurrent_worker_sessions` int unsigned NOT NULL DEFAULT '10',\n `available_workflow_slots` int unsigned DEFAULT NULL,\n `available_activity_slots` int unsigned DEFAULT NULL,\n `available_session_slots` int unsigned DEFAULT NULL,\n `process_metrics` json DEFAULT NULL,\n `heartbeat_interval_seconds` int unsigned DEFAULT NULL,\n `last_heartbeat_at` timestamp NULL DEFAULT NULL,\n `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'active',\n `created_at` timestamp NULL DEFAULT NULL,\n `updated_at` timestamp NULL DEFAULT NULL,\n PRIMARY KEY (`id`),\n UNIQUE KEY `workflow_worker_registrations_worker_id_namespace_unique` (`worker_id`,`namespace`),\n KEY `workflow_worker_registrations_namespace_task_queue_status_index` (`namespace`,`task_queue`,`status`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ] + ], + "add": [ + [ + "workflow_service_calls", + "CREATE TABLE `workflow_service_calls` (\n `id` varchar(26) COLLATE utf8mb4_unicode_ci NOT NULL,\n `workflow_service_endpoint_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `workflow_service_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `workflow_service_operation_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `endpoint_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `service_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `operation_name` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL,\n `caller_namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_workflow_instance_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_workflow_run_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `target_namespace` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_instance_id` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_run_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `linked_workflow_update_id` varchar(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `outcome` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `operation_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `resolved_binding_kind` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `resolved_target_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `payload_codec` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `input_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `output_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `failure_payload_reference` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `failure_message` text COLLATE utf8mb4_unicode_ci,\n `idempotency_key` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `deadline_policy` json DEFAULT NULL,\n `idempotency_policy` json DEFAULT NULL,\n `cancellation_policy` json DEFAULT NULL,\n `retry_policy` json DEFAULT NULL,\n `boundary_policy` json DEFAULT NULL,\n `metadata` json DEFAULT NULL,\n `outcome_category` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `outcome_reason` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `outcome_message` text COLLATE utf8mb4_unicode_ci,\n `outcome_metadata` json DEFAULT NULL,\n `policy_name` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `retry_after_seconds` int unsigned DEFAULT NULL,\n `caller_principal_subject` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_method` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_roles` json DEFAULT NULL,\n `caller_principal_tenant` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `caller_principal_claims` json DEFAULT NULL,\n `accepted_at` timestamp(6) NULL DEFAULT NULL,\n `started_at` timestamp(6) NULL DEFAULT NULL,\n `completed_at` timestamp(6) NULL DEFAULT NULL,\n `failed_at` timestamp(6) NULL DEFAULT NULL,\n `cancelled_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n `call_deadline_at` datetime(6) DEFAULT NULL,\n PRIMARY KEY (`id`),\n KEY `wf_service_calls_namespace_status_idx` (`namespace`,`status`),\n KEY `wf_service_calls_caller_status_idx` (`caller_namespace`,`status`),\n KEY `wf_service_calls_target_status_idx` (`target_namespace`,`status`),\n KEY `wf_service_calls_namespace_outcome_idx` (`namespace`,`outcome`),\n KEY `wf_service_calls_caller_outcome_idx` (`caller_namespace`,`outcome`),\n KEY `wf_service_calls_target_outcome_idx` (`target_namespace`,`outcome`),\n KEY `wf_service_calls_endpoint_idx` (`workflow_service_endpoint_id`),\n KEY `wf_service_calls_service_idx` (`workflow_service_id`),\n KEY `wf_service_calls_operation_idx` (`workflow_service_operation_id`),\n KEY `wf_service_calls_namespace_idx` (`namespace`),\n KEY `wf_service_calls_caller_namespace_idx` (`caller_namespace`),\n KEY `wf_service_calls_caller_instance_idx` (`caller_workflow_instance_id`),\n KEY `wf_service_calls_caller_run_idx` (`caller_workflow_run_id`),\n KEY `wf_service_calls_target_namespace_idx` (`target_namespace`),\n KEY `wf_service_calls_linked_instance_idx` (`linked_workflow_instance_id`),\n KEY `wf_service_calls_linked_run_idx` (`linked_workflow_run_id`),\n KEY `wf_service_calls_linked_update_idx` (`linked_workflow_update_id`),\n KEY `wf_service_calls_status_idx` (`status`),\n KEY `wf_service_calls_outcome_idx` (`outcome`),\n KEY `wf_service_calls_mode_idx` (`operation_mode`),\n KEY `wf_service_calls_binding_kind_idx` (`resolved_binding_kind`),\n KEY `wf_service_calls_idempotency_idx` (`idempotency_key`),\n KEY `wf_service_calls_outcome_category_idx` (`outcome_category`),\n KEY `wf_service_calls_policy_idx` (`policy_name`),\n KEY `wf_service_calls_principal_subject_idx` (`caller_principal_subject`),\n KEY `wf_service_calls_accepted_at_idx` (`accepted_at`),\n KEY `wf_service_calls_deadline_idx` (`status`,`call_deadline_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registration_incarnations", + "CREATE TABLE `workflow_worker_registration_incarnations` (\n `token` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `namespace` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `worker_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `recovered_workflow_task_count` int unsigned NOT NULL DEFAULT '0',\n `finished_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n PRIMARY KEY (`token`),\n KEY `dw_worker_incarnation_scope_idx` (`namespace`,`worker_id`),\n KEY `workflow_worker_registration_incarnations_finished_at_index` (`finished_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registrations", + "CREATE TABLE `workflow_worker_registrations` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `worker_id` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `namespace` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL,\n `task_queue` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,\n `runtime` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,\n `sdk_version` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `build_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n `supported_workflow_types` json DEFAULT NULL,\n `workflow_definition_fingerprints` json DEFAULT NULL,\n `workflow_command_contracts` json DEFAULT NULL,\n `supported_activity_types` json DEFAULT NULL,\n `capabilities` json DEFAULT NULL,\n `capability_manifest` json DEFAULT NULL,\n `max_concurrent_workflow_tasks` int unsigned NOT NULL DEFAULT '100',\n `max_concurrent_activity_tasks` int unsigned NOT NULL DEFAULT '100',\n `max_concurrent_worker_sessions` int unsigned NOT NULL DEFAULT '10',\n `available_workflow_slots` int unsigned DEFAULT NULL,\n `available_activity_slots` int unsigned DEFAULT NULL,\n `available_session_slots` int unsigned DEFAULT NULL,\n `process_metrics` json DEFAULT NULL,\n `heartbeat_interval_seconds` int unsigned DEFAULT NULL,\n `last_heartbeat_at` timestamp NULL DEFAULT NULL,\n `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'active',\n `created_at` timestamp NULL DEFAULT NULL,\n `updated_at` timestamp NULL DEFAULT NULL,\n `registration_token` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL,\n PRIMARY KEY (`id`),\n UNIQUE KEY `workflow_worker_registrations_worker_id_namespace_unique` (`worker_id`,`namespace`),\n KEY `workflow_worker_registrations_namespace_task_queue_status_index` (`namespace`,`task_queue`,`status`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ] + ] +} diff --git a/rust/upgrade/php-2.8.0/postgres.json b/rust/upgrade/php-2.8.0/postgres.json new file mode 100644 index 000000000..aeb9bbd1a --- /dev/null +++ b/rust/upgrade/php-2.8.0/postgres.json @@ -0,0 +1,204 @@ +{ + "remove": [], + "add": [ + [ + "column", + "workflow_service_calls.51", + { + "name": "call_deadline_at", + "type": "timestamp(6) without time zone", + "default": null, + "identity": "", + "not_null": false, + "collation": null, + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.1", + { + "name": "token", + "type": "character varying(32)", + "default": null, + "identity": "", + "not_null": true, + "collation": "default", + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.2", + { + "name": "namespace", + "type": "character varying(255)", + "default": null, + "identity": "", + "not_null": true, + "collation": "default", + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.3", + { + "name": "worker_id", + "type": "character varying(255)", + "default": null, + "identity": "", + "not_null": true, + "collation": "default", + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.4", + { + "name": "status", + "type": "character varying(32)", + "default": null, + "identity": "", + "not_null": true, + "collation": "default", + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.5", + { + "name": "recovered_workflow_task_count", + "type": "integer", + "default": "0", + "identity": "", + "not_null": true, + "collation": null, + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.6", + { + "name": "finished_at", + "type": "timestamp(6) without time zone", + "default": null, + "identity": "", + "not_null": false, + "collation": null, + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.7", + { + "name": "created_at", + "type": "timestamp(6) without time zone", + "default": null, + "identity": "", + "not_null": false, + "collation": null, + "generated": "" + } + ], + [ + "column", + "workflow_worker_registration_incarnations.8", + { + "name": "updated_at", + "type": "timestamp(6) without time zone", + "default": null, + "identity": "", + "not_null": false, + "collation": null, + "generated": "" + } + ], + [ + "column", + "workflow_worker_registrations.26", + { + "name": "registration_token", + "type": "character varying(32)", + "default": null, + "identity": "", + "not_null": false, + "collation": "default", + "generated": "" + } + ], + [ + "constraint", + "workflow_worker_registration_incarnations.workflow_worker_registration_incarnations_pkey", + { + "deferred": false, + "validated": true, + "deferrable": false, + "definition": "PRIMARY KEY (token)" + } + ], + [ + "index", + "dw_worker_incarnation_scope_idx", + { + "ready": true, + "valid": true, + "unique": false, + "primary": false, + "definition": "CREATE INDEX dw_worker_incarnation_scope_idx ON public.workflow_worker_registration_incarnations USING btree (namespace, worker_id)" + } + ], + [ + "index", + "wf_service_calls_deadline_idx", + { + "ready": true, + "valid": true, + "unique": false, + "primary": false, + "definition": "CREATE INDEX wf_service_calls_deadline_idx ON public.workflow_service_calls USING btree (status, call_deadline_at)" + } + ], + [ + "index", + "workflow_worker_registration_incarnations_finished_at_index", + { + "ready": true, + "valid": true, + "unique": false, + "primary": false, + "definition": "CREATE INDEX workflow_worker_registration_incarnations_finished_at_index ON public.workflow_worker_registration_incarnations USING btree (finished_at)" + } + ], + [ + "index", + "workflow_worker_registration_incarnations_pkey", + { + "ready": true, + "valid": true, + "unique": true, + "primary": true, + "definition": "CREATE UNIQUE INDEX workflow_worker_registration_incarnations_pkey ON public.workflow_worker_registration_incarnations USING btree (token)" + } + ], + [ + "relation", + "workflow_worker_registration_incarnations", + { + "kind": "r", + "view": null, + "options": null, + "partition": false, + "persistence": "p", + "row_security": false, + "access_method": "heap", + "partition_key": null, + "force_row_security": false + } + ] + ] +} diff --git a/rust/upgrade/php-2.8.0/sqlite.json b/rust/upgrade/php-2.8.0/sqlite.json new file mode 100644 index 000000000..dd722b2d2 --- /dev/null +++ b/rust/upgrade/php-2.8.0/sqlite.json @@ -0,0 +1,54 @@ +{ + "remove": [ + [ + "table", + "workflow_service_calls", + "workflow_service_calls", + "CREATE TABLE \"workflow_service_calls\" (\"id\" varchar not null, \"workflow_service_endpoint_id\" varchar, \"workflow_service_id\" varchar, \"workflow_service_operation_id\" varchar, \"namespace\" varchar, \"endpoint_name\" varchar not null, \"service_name\" varchar not null, \"operation_name\" varchar not null, \"caller_namespace\" varchar, \"caller_workflow_instance_id\" varchar, \"caller_workflow_run_id\" varchar, \"target_namespace\" varchar, \"linked_workflow_instance_id\" varchar, \"linked_workflow_run_id\" varchar, \"linked_workflow_update_id\" varchar, \"status\" varchar not null, \"outcome\" varchar, \"operation_mode\" varchar not null, \"resolved_binding_kind\" varchar, \"resolved_target_reference\" varchar, \"payload_codec\" varchar, \"input_payload_reference\" varchar, \"output_payload_reference\" varchar, \"failure_payload_reference\" varchar, \"failure_message\" text, \"idempotency_key\" varchar, \"deadline_policy\" text, \"idempotency_policy\" text, \"cancellation_policy\" text, \"retry_policy\" text, \"boundary_policy\" text, \"metadata\" text, \"outcome_category\" varchar, \"outcome_reason\" varchar, \"outcome_message\" text, \"outcome_metadata\" text, \"policy_name\" varchar, \"retry_after_seconds\" integer, \"caller_principal_subject\" varchar, \"caller_principal_method\" varchar, \"caller_principal_roles\" text, \"caller_principal_tenant\" varchar, \"caller_principal_claims\" text, \"accepted_at\" datetime, \"started_at\" datetime, \"completed_at\" datetime, \"failed_at\" datetime, \"cancelled_at\" datetime, \"created_at\" datetime, \"updated_at\" datetime, primary key (\"id\"))" + ], + [ + "table", + "workflow_worker_registrations", + "workflow_worker_registrations", + "CREATE TABLE \"workflow_worker_registrations\" (\"id\" integer primary key autoincrement not null, \"worker_id\" varchar not null, \"namespace\" varchar not null, \"task_queue\" varchar not null, \"runtime\" varchar not null, \"sdk_version\" varchar, \"build_id\" varchar, \"supported_workflow_types\" text, \"workflow_definition_fingerprints\" text, \"supported_activity_types\" text, \"max_concurrent_workflow_tasks\" integer not null default '100', \"max_concurrent_activity_tasks\" integer not null default '100', \"last_heartbeat_at\" datetime, \"status\" varchar not null default 'active', \"created_at\" datetime, \"updated_at\" datetime, \"capabilities\" text, \"max_concurrent_worker_sessions\" integer not null default '10', \"available_workflow_slots\" integer, \"available_activity_slots\" integer, \"available_session_slots\" integer, \"process_metrics\" text, \"heartbeat_interval_seconds\" integer, \"workflow_command_contracts\" text, \"capability_manifest\" text)" + ] + ], + "add": [ + [ + "index", + "dw_worker_incarnation_scope_idx", + "workflow_worker_registration_incarnations", + "CREATE INDEX \"dw_worker_incarnation_scope_idx\" on \"workflow_worker_registration_incarnations\" (\"namespace\", \"worker_id\")" + ], + [ + "index", + "wf_service_calls_deadline_idx", + "workflow_service_calls", + "CREATE INDEX \"wf_service_calls_deadline_idx\" on \"workflow_service_calls\" (\"status\", \"call_deadline_at\")" + ], + [ + "index", + "workflow_worker_registration_incarnations_finished_at_index", + "workflow_worker_registration_incarnations", + "CREATE INDEX \"workflow_worker_registration_incarnations_finished_at_index\" on \"workflow_worker_registration_incarnations\" (\"finished_at\")" + ], + [ + "table", + "workflow_service_calls", + "workflow_service_calls", + "CREATE TABLE \"workflow_service_calls\" (\"id\" varchar not null, \"workflow_service_endpoint_id\" varchar, \"workflow_service_id\" varchar, \"workflow_service_operation_id\" varchar, \"namespace\" varchar, \"endpoint_name\" varchar not null, \"service_name\" varchar not null, \"operation_name\" varchar not null, \"caller_namespace\" varchar, \"caller_workflow_instance_id\" varchar, \"caller_workflow_run_id\" varchar, \"target_namespace\" varchar, \"linked_workflow_instance_id\" varchar, \"linked_workflow_run_id\" varchar, \"linked_workflow_update_id\" varchar, \"status\" varchar not null, \"outcome\" varchar, \"operation_mode\" varchar not null, \"resolved_binding_kind\" varchar, \"resolved_target_reference\" varchar, \"payload_codec\" varchar, \"input_payload_reference\" varchar, \"output_payload_reference\" varchar, \"failure_payload_reference\" varchar, \"failure_message\" text, \"idempotency_key\" varchar, \"deadline_policy\" text, \"idempotency_policy\" text, \"cancellation_policy\" text, \"retry_policy\" text, \"boundary_policy\" text, \"metadata\" text, \"outcome_category\" varchar, \"outcome_reason\" varchar, \"outcome_message\" text, \"outcome_metadata\" text, \"policy_name\" varchar, \"retry_after_seconds\" integer, \"caller_principal_subject\" varchar, \"caller_principal_method\" varchar, \"caller_principal_roles\" text, \"caller_principal_tenant\" varchar, \"caller_principal_claims\" text, \"accepted_at\" datetime, \"started_at\" datetime, \"completed_at\" datetime, \"failed_at\" datetime, \"cancelled_at\" datetime, \"created_at\" datetime, \"updated_at\" datetime, \"call_deadline_at\" datetime, primary key (\"id\"))" + ], + [ + "table", + "workflow_worker_registration_incarnations", + "workflow_worker_registration_incarnations", + "CREATE TABLE \"workflow_worker_registration_incarnations\" (\"token\" varchar not null, \"namespace\" varchar not null, \"worker_id\" varchar not null, \"status\" varchar not null, \"recovered_workflow_task_count\" integer not null default '0', \"finished_at\" datetime, \"created_at\" datetime, \"updated_at\" datetime, primary key (\"token\"))" + ], + [ + "table", + "workflow_worker_registrations", + "workflow_worker_registrations", + "CREATE TABLE \"workflow_worker_registrations\" (\"id\" integer primary key autoincrement not null, \"worker_id\" varchar not null, \"namespace\" varchar not null, \"task_queue\" varchar not null, \"runtime\" varchar not null, \"sdk_version\" varchar, \"build_id\" varchar, \"supported_workflow_types\" text, \"workflow_definition_fingerprints\" text, \"supported_activity_types\" text, \"max_concurrent_workflow_tasks\" integer not null default '100', \"max_concurrent_activity_tasks\" integer not null default '100', \"last_heartbeat_at\" datetime, \"status\" varchar not null default 'active', \"created_at\" datetime, \"updated_at\" datetime, \"capabilities\" text, \"max_concurrent_worker_sessions\" integer not null default '10', \"available_workflow_slots\" integer, \"available_activity_slots\" integer, \"available_session_slots\" integer, \"process_metrics\" text, \"heartbeat_interval_seconds\" integer, \"workflow_command_contracts\" text, \"capability_manifest\" text, \"registration_token\" varchar)" + ] + ] +} diff --git a/scripts/conformance/server-parity/upgrade-source-author.php b/scripts/conformance/server-parity/upgrade-source-author.php new file mode 100644 index 000000000..a2524546d --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-author.php @@ -0,0 +1,87 @@ + rtrim($url, '/'), 'http_errors' => false, 'connect_timeout' => 1]); +$headers = ['Authorization' => 'Bearer '.getenv('DW_PARITY_TOKEN'), 'X-Namespace' => 'default', + 'X-Durable-Workflow-Control-Plane-Version' => '2', 'X-Durable-Workflow-Protocol-Version' => '1.20']; +$request = static function (string $method, string $path, array $body = []) use ($http, $headers, $deadline): array { + if (microtime(true) >= $deadline) { + throw new RuntimeException('Original 30-second source preparation deadline expired'); + } + $response = $http->request($method, $path, ['headers' => $headers, 'json' => $body, + 'timeout' => min(5, max(0.001, $deadline - microtime(true)))]); + + return [$response->getStatusCode(), json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR)]; +}; +$check = static function (bool $condition, string $message): void { + if (! $condition) { + throw new RuntimeException($message); + } +}; +$expect = static function (array $response, int $status, string $operation) use ($check): array { + $check($response[0] === $status, $operation.': '.json_encode([ + 'status' => $response[0], 'reason' => $response[1]['reason'] ?? null, + 'errors' => $response[1]['errors'] ?? null, + ])); + + return $response[1]; +}; +if ($request('GET', '/api/namespaces/default')[0] === 404) { + $expect($request('POST', '/api/namespaces', ['name' => 'default']), 201, 'namespace'); +} +$worker = $prefix.'-worker'; +$expect($request('POST', '/api/worker/register', ['worker_id' => $worker, 'task_queue' => $prefix, + 'runtime' => 'php', 'sdk_version' => 'raw-upgrade-inspection-author', + 'supported_workflow_types' => ['parity.upgrade_activity', 'parity.upgrade_timer'], + 'supported_activity_types' => ['parity.upgrade_activity'], 'capabilities' => [], + 'capability_manifest' => array_fill_keys(['local_activities', 'worker_sessions', 'sticky_execution'], + ['supported' => false, 'minimum_protocol_version' => '1.18', 'reason' => 'raw_protocol_author']), +]), 201, 'registration'); +$value = [9007199254740993, null, 'upgradeλ']; +$envelope = ['codec' => 'avro', 'blob' => Avro::serialize([$value])]; +$poll = ['worker_id' => $worker, 'task_queue' => $prefix, 'timeout_seconds' => 0]; +$originals = []; +foreach (['activity', 'timer'] as $kind) { + $id = $prefix.'-'.$kind; + $run = $expect($request('POST', '/api/workflows', ['workflow_id' => $id, + 'workflow_type' => 'parity.upgrade_'.$kind, 'task_queue' => $prefix, 'input' => $envelope, + 'run_timeout_seconds' => 900, 'execution_timeout_seconds' => 1200]), 201, $kind.' admission'); + fwrite(STDERR, json_encode(['phase' => 'admitted', 'kind' => $kind, 'run_id' => $run['run_id'], + 'started_at' => $started, 'original_deadline' => $deadline], JSON_THROW_ON_ERROR)."\n"); + $task = $expect($request('POST', '/api/worker/workflow-tasks/poll', $poll), 200, $kind.' claim')['task']; + $check($task['run_id'] === $run['run_id'], 'Original workflow claim changed'); + $command = $kind === 'activity' + ? ['type' => 'schedule_activity', 'activity_type' => 'parity.upgrade_activity', 'arguments' => $envelope, + 'retry_policy' => ['max_attempts' => 1, 'backoff_seconds' => []], + 'start_to_close_timeout' => 600, 'schedule_to_close_timeout' => 900] + : ['type' => 'start_timer', 'delay_seconds' => 600]; + $expect($request('POST', '/api/worker/workflow-tasks/'.$task['task_id'].'/complete', [ + 'lease_owner' => $task['lease_owner'], 'workflow_task_attempt' => $task['workflow_task_attempt'], + 'commands' => [$command], + ]), 200, $kind.' command'); + $activity = null; + if ($kind === 'activity') { + $activity = $expect($request('POST', '/api/worker/activity-tasks/poll', $poll), 200, 'activity claim')['task']; + $check($activity['run_id'] === $run['run_id'] && $activity['attempt_number'] === 1, 'Original activity changed'); + } + $history = $expect($request('GET', '/api/workflows/'.$id.'/runs/'.$run['run_id'].'/history'), 200, 'history')['events']; + $check(array_column($history, 'event_type') === ($kind === 'activity' + ? ['StartAccepted', 'WorkflowStarted', 'ActivityScheduled', 'ActivityStarted'] + : ['StartAccepted', 'WorkflowStarted', 'TimerScheduled']), 'Original pending history differs'); + $originals[$kind] = ['workflow_id' => $id, 'run_id' => $run['run_id'], 'workflow_task' => $task, + 'activity_task' => $activity, 'history' => $history]; +} +$check(microtime(true) < $deadline, 'Original source preparation budget expired'); +echo json_encode(['outcome' => 'pass', 'scope' => 'published_php_raw_protocol_pending_source', + 'started_at' => $started, 'original_deadline' => $deadline, 'finished_at' => microtime(true), + 'originals' => $originals], JSON_THROW_ON_ERROR)."\n"; From 86d5cfd7e3d76a317967b652032a32b6b20eac3d Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:43:18 +0000 Subject: [PATCH 04/26] Qualify published PHP source inventory and refusal controls in every database cell --- .github/workflows/server-parity.yml | 28 ++++ docs/rust-server-port.md | 46 ++++--- rust/src/runtime/mysql.rs | 7 +- rust/upgrade/php-2.8.0/README.md | 2 +- rust/upgrade/php-2.8.0/mariadb.json | 26 ++++ .../server-parity/upgrade-source-cleanup.sh | 52 +++++++ .../server-parity/upgrade-source-controls.php | 43 ++++++ .../server-parity/upgrade-source-profile.sh | 127 ++++++++++++++++++ 8 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 rust/upgrade/php-2.8.0/mariadb.json create mode 100755 scripts/conformance/server-parity/upgrade-source-cleanup.sh create mode 100644 scripts/conformance/server-parity/upgrade-source-controls.php create mode 100755 scripts/conformance/server-parity/upgrade-source-profile.sh diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index db30d798b..47122e310 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -626,6 +626,14 @@ jobs: cmp parity-evidence/catalog-before.json parity-evidence/catalog-after.json - name: Compare separate configured role-token cohort run: bash scripts/conformance/server-parity/role-profile.sh mysql + - name: Inspect separate published PHP upgrade source without takeover + if: ${{ !cancelled() }} + timeout-minutes: 8 + run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql + - name: Retire upgrade source cohort + if: always() + timeout-minutes: 3 + run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh mysql - name: Compare corrected markers, ranges, clocks and activity expiry if: ${{ !cancelled() }} # The preceding process-loss case deliberately kills native-a. @@ -1058,6 +1066,14 @@ jobs: env: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/role-profile.sh pgsql + - name: Inspect separate published PHP upgrade source without takeover + if: ${{ !cancelled() }} + timeout-minutes: 8 + run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql + - name: Retire upgrade source cohort + if: always() + timeout-minutes: 3 + run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh pgsql - name: Compare corrected markers, ranges, clocks and activity expiry if: ${{ !cancelled() }} env: @@ -1470,6 +1486,18 @@ jobs: env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/role-profile.sh sqlite + - name: Inspect separate published PHP upgrade source without takeover + if: ${{ !cancelled() }} + timeout-minutes: 8 + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + run: bash scripts/conformance/server-parity/upgrade-source-profile.sh sqlite + - name: Retire upgrade source cohort + if: always() + timeout-minutes: 3 + env: + RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} + run: bash scripts/conformance/server-parity/upgrade-source-cleanup.sh sqlite - name: Compare corrected markers, ranges, clocks and activity expiry if: ${{ !cancelled() }} env: diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 0a6367542..d651d6496 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -6,10 +6,13 @@ The unpublished Rust crate has an Avro foundation and an opt-in SQLite/PostgreSQ execution slice. Full runtime parity, migration, performance improvement and cutover remain unqualified. -The latest delivered main foundation is [#444](https://github.com/durable-workflow/server/pull/444), -including initial portable memo and the earlier corrected marker/clock/retry -profiles. Its exact merged main passes [all 13 shared jobs across six databases](https://github.com/durable-workflow/server/actions/runs/38599251632) -and normal source/boundary checks. Frozen published PHP, embedded and SDK tuples +The latest delivered native foundation is [#450](https://github.com/durable-workflow/server/pull/450), +including memo transactions, actual SDK memo authoring and activity/continuation +replay from #445/#446/#448. Its exact merged main passes +[all 13 shared jobs across six databases](https://github.com/durable-workflow/server/actions/runs/38652717748) +and normal source/boundary checks. #446's first failed query observation remains +failed and unexplained; its unchanged second attempt reran all 13 jobs. +Frozen published PHP, embedded and SDK tuples remain separate from current PHP release work. Representative timer, signal, query, update, activity, cancellation, namespace and recovery checks do not establish complete parity for those families. The original failed records remain @@ -19,24 +22,23 @@ failed; later qualifying runs are separate observations. The active integration order is: -- [#445](https://github.com/durable-workflow/server/pull/445): canonical memo - transaction foundation, with public updates still disabled. Its current-main - combined [qualification](https://github.com/durable-workflow/server/actions/runs/38608723068) - is pending; the older source's pass is separate. -- [#446](https://github.com/durable-workflow/server/pull/446): cold continue-as-new - after a genuine activity. Prior candidate qualification passes; exact current-main - integration, promotion and fresh-main acceptance remain ahead. -- [#448](https://github.com/durable-workflow/server/pull/448): actual SDK memo - authoring and continuation inheritance. Its candidate passes all 13 shared - jobs across six databases; promotion and external-memo policy remain pending. -- [#450](https://github.com/durable-workflow/server/pull/450): verified local - external input, workflow/activity results and activity arguments. Installed - PHP/native originals, including a two-activity batch and cold parent, complete. - Two-reference atomic rollback/cold delivery controls pass. Latest-source hosted - qualification is pending. The older byte-only and input-only sources pass - their own full matrices. One local 100 ms transfer control - failed; three isolated controls and an unchanged full repeat pass, with the - original cause still unknown. +- [#457](https://github.com/durable-workflow/server/pull/457): run the existing + installed external-payload consumers on every required database, including + byte-preserving cold readers. Exact current-main qualification is pending. +- [#455](https://github.com/durable-workflow/server/pull/455): opt-in cross-kind + null-task poll wake hint with exact namespace/queue isolation and no delivery + authority. Current PHP query documentation is preserved at combined revision + 55. Hosted integration follows #457. +- [#458](https://github.com/durable-workflow/server/pull/458): bound activity + completion uploads. Actual PHP/native originals and strict readers pass + SQLite/MySQL 8.4/PostgreSQL 17 locally; current all-six qualification is pending. + Full external transport discovery remains false. +- [#460](https://github.com/durable-workflow/server/pull/460): read-only upgrade + source inspection, independently prepared from published PHP 2.8.0. Exact + known catalog checks and counts grant no backup, quiescence or takeover + authority. SQLite WAL is refused before opening; rollback-journal inventory + and network SELECT-only roles have separate actual source checks. Hosted + qualification and all actual migration/conversion gates remain ahead. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index 92dae647d..ae3015dd9 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -183,12 +183,7 @@ pub(super) async fn php_source_catalog(connection: &mut MySqlConnection) -> Resu if actual != expected { let delta = match flavor { Flavor::MySql => include_str!("../../upgrade/php-2.8.0/mysql.json"), - Flavor::MariaDb => { - return Err(refuse( - StatusCode::CONFLICT, - "upgrade_source_catalog_unqualified", - )); - } + Flavor::MariaDb => include_str!("../../upgrade/php-2.8.0/mariadb.json"), }; let mut current = super::upgrade::source_catalog_variant(&expected, delta)?; current.sort_by(|left, right| left.0.cmp(&right.0)); diff --git a/rust/upgrade/php-2.8.0/README.md b/rust/upgrade/php-2.8.0/README.md index c699ddfda..8f6270c76 100644 --- a/rust/upgrade/php-2.8.0/README.md +++ b/rust/upgrade/php-2.8.0/README.md @@ -14,7 +14,7 @@ objects still refuse. Nothing is applied to a source database, and native migrations, their checksums and the frozen differential catalog stay unchanged. SQLite definitions were read from an actual published-image database. MySQL -8.4.5 and PostgreSQL 17 definitions were collected after successful raw public +8.4.5, MariaDB 10.11.19 and PostgreSQL 17 definitions were collected after successful raw public protocol preparation and explicit PHP SIGTERM/exit 0. The earlier partial MySQL catalog read during bootstrap was discarded and is not fixture provenance. Catalog inspection records no application row values or credentials. diff --git a/rust/upgrade/php-2.8.0/mariadb.json b/rust/upgrade/php-2.8.0/mariadb.json new file mode 100644 index 000000000..6538c363e --- /dev/null +++ b/rust/upgrade/php-2.8.0/mariadb.json @@ -0,0 +1,26 @@ +{ + "remove": [ + [ + "workflow_service_calls", + "CREATE TABLE `workflow_service_calls` (\n `id` varchar(26) NOT NULL,\n `workflow_service_endpoint_id` varchar(26) DEFAULT NULL,\n `workflow_service_id` varchar(26) DEFAULT NULL,\n `workflow_service_operation_id` varchar(26) DEFAULT NULL,\n `namespace` varchar(255) DEFAULT NULL,\n `endpoint_name` varchar(191) NOT NULL,\n `service_name` varchar(191) NOT NULL,\n `operation_name` varchar(191) NOT NULL,\n `caller_namespace` varchar(255) DEFAULT NULL,\n `caller_workflow_instance_id` varchar(191) DEFAULT NULL,\n `caller_workflow_run_id` varchar(26) DEFAULT NULL,\n `target_namespace` varchar(255) DEFAULT NULL,\n `linked_workflow_instance_id` varchar(191) DEFAULT NULL,\n `linked_workflow_run_id` varchar(26) DEFAULT NULL,\n `linked_workflow_update_id` varchar(26) DEFAULT NULL,\n `status` varchar(32) NOT NULL,\n `outcome` varchar(64) DEFAULT NULL,\n `operation_mode` varchar(32) NOT NULL,\n `resolved_binding_kind` varchar(64) DEFAULT NULL,\n `resolved_target_reference` varchar(191) DEFAULT NULL,\n `payload_codec` varchar(255) DEFAULT NULL,\n `input_payload_reference` varchar(191) DEFAULT NULL,\n `output_payload_reference` varchar(191) DEFAULT NULL,\n `failure_payload_reference` varchar(191) DEFAULT NULL,\n `failure_message` text DEFAULT NULL,\n `idempotency_key` varchar(191) DEFAULT NULL,\n `deadline_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`deadline_policy`)),\n `idempotency_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`idempotency_policy`)),\n `cancellation_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`cancellation_policy`)),\n `retry_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`retry_policy`)),\n `boundary_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`boundary_policy`)),\n `metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),\n `outcome_category` varchar(32) DEFAULT NULL,\n `outcome_reason` varchar(191) DEFAULT NULL,\n `outcome_message` text DEFAULT NULL,\n `outcome_metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`outcome_metadata`)),\n `policy_name` varchar(191) DEFAULT NULL,\n `retry_after_seconds` int(10) unsigned DEFAULT NULL,\n `caller_principal_subject` varchar(191) DEFAULT NULL,\n `caller_principal_method` varchar(64) DEFAULT NULL,\n `caller_principal_roles` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`caller_principal_roles`)),\n `caller_principal_tenant` varchar(191) DEFAULT NULL,\n `caller_principal_claims` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`caller_principal_claims`)),\n `accepted_at` timestamp(6) NULL DEFAULT NULL,\n `started_at` timestamp(6) NULL DEFAULT NULL,\n `completed_at` timestamp(6) NULL DEFAULT NULL,\n `failed_at` timestamp(6) NULL DEFAULT NULL,\n `cancelled_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n PRIMARY KEY (`id`),\n KEY `wf_service_calls_namespace_status_idx` (`namespace`,`status`),\n KEY `wf_service_calls_caller_status_idx` (`caller_namespace`,`status`),\n KEY `wf_service_calls_target_status_idx` (`target_namespace`,`status`),\n KEY `wf_service_calls_namespace_outcome_idx` (`namespace`,`outcome`),\n KEY `wf_service_calls_caller_outcome_idx` (`caller_namespace`,`outcome`),\n KEY `wf_service_calls_target_outcome_idx` (`target_namespace`,`outcome`),\n KEY `wf_service_calls_endpoint_idx` (`workflow_service_endpoint_id`),\n KEY `wf_service_calls_service_idx` (`workflow_service_id`),\n KEY `wf_service_calls_operation_idx` (`workflow_service_operation_id`),\n KEY `wf_service_calls_namespace_idx` (`namespace`),\n KEY `wf_service_calls_caller_namespace_idx` (`caller_namespace`),\n KEY `wf_service_calls_caller_instance_idx` (`caller_workflow_instance_id`),\n KEY `wf_service_calls_caller_run_idx` (`caller_workflow_run_id`),\n KEY `wf_service_calls_target_namespace_idx` (`target_namespace`),\n KEY `wf_service_calls_linked_instance_idx` (`linked_workflow_instance_id`),\n KEY `wf_service_calls_linked_run_idx` (`linked_workflow_run_id`),\n KEY `wf_service_calls_linked_update_idx` (`linked_workflow_update_id`),\n KEY `wf_service_calls_status_idx` (`status`),\n KEY `wf_service_calls_outcome_idx` (`outcome`),\n KEY `wf_service_calls_mode_idx` (`operation_mode`),\n KEY `wf_service_calls_binding_kind_idx` (`resolved_binding_kind`),\n KEY `wf_service_calls_idempotency_idx` (`idempotency_key`),\n KEY `wf_service_calls_outcome_category_idx` (`outcome_category`),\n KEY `wf_service_calls_policy_idx` (`policy_name`),\n KEY `wf_service_calls_principal_subject_idx` (`caller_principal_subject`),\n KEY `wf_service_calls_accepted_at_idx` (`accepted_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registrations", + "CREATE TABLE `workflow_worker_registrations` (\n `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n `worker_id` varchar(255) NOT NULL,\n `namespace` varchar(128) NOT NULL,\n `task_queue` varchar(255) NOT NULL,\n `runtime` varchar(32) NOT NULL,\n `sdk_version` varchar(64) DEFAULT NULL,\n `build_id` varchar(255) DEFAULT NULL,\n `supported_workflow_types` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`supported_workflow_types`)),\n `workflow_definition_fingerprints` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`workflow_definition_fingerprints`)),\n `workflow_command_contracts` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`workflow_command_contracts`)),\n `supported_activity_types` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`supported_activity_types`)),\n `capabilities` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capabilities`)),\n `capability_manifest` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capability_manifest`)),\n `max_concurrent_workflow_tasks` int(10) unsigned NOT NULL DEFAULT 100,\n `max_concurrent_activity_tasks` int(10) unsigned NOT NULL DEFAULT 100,\n `max_concurrent_worker_sessions` int(10) unsigned NOT NULL DEFAULT 10,\n `available_workflow_slots` int(10) unsigned DEFAULT NULL,\n `available_activity_slots` int(10) unsigned DEFAULT NULL,\n `available_session_slots` int(10) unsigned DEFAULT NULL,\n `process_metrics` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`process_metrics`)),\n `heartbeat_interval_seconds` int(10) unsigned DEFAULT NULL,\n `last_heartbeat_at` timestamp NULL DEFAULT NULL,\n `status` varchar(32) NOT NULL DEFAULT 'active',\n `created_at` timestamp NULL DEFAULT NULL,\n `updated_at` timestamp NULL DEFAULT NULL,\n PRIMARY KEY (`id`),\n UNIQUE KEY `workflow_worker_registrations_worker_id_namespace_unique` (`worker_id`,`namespace`),\n KEY `workflow_worker_registrations_namespace_task_queue_status_index` (`namespace`,`task_queue`,`status`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ] + ], + "add": [ + [ + "workflow_service_calls", + "CREATE TABLE `workflow_service_calls` (\n `id` varchar(26) NOT NULL,\n `workflow_service_endpoint_id` varchar(26) DEFAULT NULL,\n `workflow_service_id` varchar(26) DEFAULT NULL,\n `workflow_service_operation_id` varchar(26) DEFAULT NULL,\n `namespace` varchar(255) DEFAULT NULL,\n `endpoint_name` varchar(191) NOT NULL,\n `service_name` varchar(191) NOT NULL,\n `operation_name` varchar(191) NOT NULL,\n `caller_namespace` varchar(255) DEFAULT NULL,\n `caller_workflow_instance_id` varchar(191) DEFAULT NULL,\n `caller_workflow_run_id` varchar(26) DEFAULT NULL,\n `target_namespace` varchar(255) DEFAULT NULL,\n `linked_workflow_instance_id` varchar(191) DEFAULT NULL,\n `linked_workflow_run_id` varchar(26) DEFAULT NULL,\n `linked_workflow_update_id` varchar(26) DEFAULT NULL,\n `status` varchar(32) NOT NULL,\n `outcome` varchar(64) DEFAULT NULL,\n `operation_mode` varchar(32) NOT NULL,\n `resolved_binding_kind` varchar(64) DEFAULT NULL,\n `resolved_target_reference` varchar(191) DEFAULT NULL,\n `payload_codec` varchar(255) DEFAULT NULL,\n `input_payload_reference` varchar(191) DEFAULT NULL,\n `output_payload_reference` varchar(191) DEFAULT NULL,\n `failure_payload_reference` varchar(191) DEFAULT NULL,\n `failure_message` text DEFAULT NULL,\n `idempotency_key` varchar(191) DEFAULT NULL,\n `deadline_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`deadline_policy`)),\n `idempotency_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`idempotency_policy`)),\n `cancellation_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`cancellation_policy`)),\n `retry_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`retry_policy`)),\n `boundary_policy` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`boundary_policy`)),\n `metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`metadata`)),\n `outcome_category` varchar(32) DEFAULT NULL,\n `outcome_reason` varchar(191) DEFAULT NULL,\n `outcome_message` text DEFAULT NULL,\n `outcome_metadata` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`outcome_metadata`)),\n `policy_name` varchar(191) DEFAULT NULL,\n `retry_after_seconds` int(10) unsigned DEFAULT NULL,\n `caller_principal_subject` varchar(191) DEFAULT NULL,\n `caller_principal_method` varchar(64) DEFAULT NULL,\n `caller_principal_roles` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`caller_principal_roles`)),\n `caller_principal_tenant` varchar(191) DEFAULT NULL,\n `caller_principal_claims` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`caller_principal_claims`)),\n `accepted_at` timestamp(6) NULL DEFAULT NULL,\n `started_at` timestamp(6) NULL DEFAULT NULL,\n `completed_at` timestamp(6) NULL DEFAULT NULL,\n `failed_at` timestamp(6) NULL DEFAULT NULL,\n `cancelled_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n `call_deadline_at` datetime(6) DEFAULT NULL,\n PRIMARY KEY (`id`),\n KEY `wf_service_calls_namespace_status_idx` (`namespace`,`status`),\n KEY `wf_service_calls_caller_status_idx` (`caller_namespace`,`status`),\n KEY `wf_service_calls_target_status_idx` (`target_namespace`,`status`),\n KEY `wf_service_calls_namespace_outcome_idx` (`namespace`,`outcome`),\n KEY `wf_service_calls_caller_outcome_idx` (`caller_namespace`,`outcome`),\n KEY `wf_service_calls_target_outcome_idx` (`target_namespace`,`outcome`),\n KEY `wf_service_calls_endpoint_idx` (`workflow_service_endpoint_id`),\n KEY `wf_service_calls_service_idx` (`workflow_service_id`),\n KEY `wf_service_calls_operation_idx` (`workflow_service_operation_id`),\n KEY `wf_service_calls_namespace_idx` (`namespace`),\n KEY `wf_service_calls_caller_namespace_idx` (`caller_namespace`),\n KEY `wf_service_calls_caller_instance_idx` (`caller_workflow_instance_id`),\n KEY `wf_service_calls_caller_run_idx` (`caller_workflow_run_id`),\n KEY `wf_service_calls_target_namespace_idx` (`target_namespace`),\n KEY `wf_service_calls_linked_instance_idx` (`linked_workflow_instance_id`),\n KEY `wf_service_calls_linked_run_idx` (`linked_workflow_run_id`),\n KEY `wf_service_calls_linked_update_idx` (`linked_workflow_update_id`),\n KEY `wf_service_calls_status_idx` (`status`),\n KEY `wf_service_calls_outcome_idx` (`outcome`),\n KEY `wf_service_calls_mode_idx` (`operation_mode`),\n KEY `wf_service_calls_binding_kind_idx` (`resolved_binding_kind`),\n KEY `wf_service_calls_idempotency_idx` (`idempotency_key`),\n KEY `wf_service_calls_outcome_category_idx` (`outcome_category`),\n KEY `wf_service_calls_policy_idx` (`policy_name`),\n KEY `wf_service_calls_principal_subject_idx` (`caller_principal_subject`),\n KEY `wf_service_calls_accepted_at_idx` (`accepted_at`),\n KEY `wf_service_calls_deadline_idx` (`status`,`call_deadline_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registrations", + "CREATE TABLE `workflow_worker_registrations` (\n `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n `worker_id` varchar(255) NOT NULL,\n `namespace` varchar(128) NOT NULL,\n `task_queue` varchar(255) NOT NULL,\n `runtime` varchar(32) NOT NULL,\n `sdk_version` varchar(64) DEFAULT NULL,\n `build_id` varchar(255) DEFAULT NULL,\n `supported_workflow_types` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`supported_workflow_types`)),\n `workflow_definition_fingerprints` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`workflow_definition_fingerprints`)),\n `workflow_command_contracts` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`workflow_command_contracts`)),\n `supported_activity_types` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`supported_activity_types`)),\n `capabilities` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capabilities`)),\n `capability_manifest` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`capability_manifest`)),\n `max_concurrent_workflow_tasks` int(10) unsigned NOT NULL DEFAULT 100,\n `max_concurrent_activity_tasks` int(10) unsigned NOT NULL DEFAULT 100,\n `max_concurrent_worker_sessions` int(10) unsigned NOT NULL DEFAULT 10,\n `available_workflow_slots` int(10) unsigned DEFAULT NULL,\n `available_activity_slots` int(10) unsigned DEFAULT NULL,\n `available_session_slots` int(10) unsigned DEFAULT NULL,\n `process_metrics` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`process_metrics`)),\n `heartbeat_interval_seconds` int(10) unsigned DEFAULT NULL,\n `last_heartbeat_at` timestamp NULL DEFAULT NULL,\n `status` varchar(32) NOT NULL DEFAULT 'active',\n `created_at` timestamp NULL DEFAULT NULL,\n `updated_at` timestamp NULL DEFAULT NULL,\n `registration_token` varchar(32) DEFAULT NULL,\n PRIMARY KEY (`id`),\n UNIQUE KEY `workflow_worker_registrations_worker_id_namespace_unique` (`worker_id`,`namespace`),\n KEY `workflow_worker_registrations_namespace_task_queue_status_index` (`namespace`,`task_queue`,`status`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ], + [ + "workflow_worker_registration_incarnations", + "CREATE TABLE `workflow_worker_registration_incarnations` (\n `token` varchar(32) NOT NULL,\n `namespace` varchar(255) NOT NULL,\n `worker_id` varchar(255) NOT NULL,\n `status` varchar(32) NOT NULL,\n `recovered_workflow_task_count` int(10) unsigned NOT NULL DEFAULT 0,\n `finished_at` timestamp(6) NULL DEFAULT NULL,\n `created_at` timestamp(6) NULL DEFAULT NULL,\n `updated_at` timestamp(6) NULL DEFAULT NULL,\n PRIMARY KEY (`token`),\n KEY `dw_worker_incarnation_scope_idx` (`namespace`,`worker_id`),\n KEY `workflow_worker_registration_incarnations_finished_at_index` (`finished_at`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ] + ] +} diff --git a/scripts/conformance/server-parity/upgrade-source-cleanup.sh b/scripts/conformance/server-parity/upgrade-source-cleanup.sh new file mode 100755 index 000000000..0fa92b21d --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-cleanup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Only the owning workflow's always-run step calls this, before its DB/network. +set -euo pipefail +test "$#" = 1 || exit 2 +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +evidence=parity-evidence/upgrade-source +state=parity-upgrade-source-state +test -f "$evidence/owner.txt" || exit 0 +test "$(cat "$evidence/owner.txt")" = "$RESOURCE_SCOPE" || exit 2 +resource="$RESOURCE_SCOPE-upgrade" +image=ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac +for role in author control inspect php; do + if docker inspect "$resource-$role" >/dev/null 2>&1; then + docker stop --signal SIGTERM --time 5 "$resource-$role" >/dev/null + docker rm "$resource-$role" >/dev/null + fi +done +password=${DW_PARITY_UPGRADE_DB_PASSWORD:-parity-disposable-password} +if test -f "$evidence/database-owned.txt" && test "$(cat "$evidence/database-owned.txt")" = "$RESOURCE_SCOPE"; then +case "$backend" in + mysql) + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h "${DW_PARITY_UPGRADE_DB_HOST:-parity-mysql}" -uroot -p"$password" \ + -e 'DROP DATABASE IF EXISTS upgrade_source_ref' \ + > "$evidence/database-cleanup.txt" 2>&1 ;; + pgsql) + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD="$password" \ + --entrypoint psql "$POSTGRES_IMAGE" -h "${DW_PARITY_UPGRADE_DB_HOST:-parity-pg}" -U parity -d postgres -v ON_ERROR_STOP=1 \ + -c 'DROP DATABASE IF EXISTS upgrade_source_ref' \ + > "$evidence/database-cleanup.txt" 2>&1 ;; + sqlite) ;; + *) exit 2 ;; +esac +fi +if test -f "$evidence/roles-owned.txt" && test "$(cat "$evidence/roles-owned.txt")" = "$RESOURCE_SCOPE"; then + if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h "${DW_PARITY_UPGRADE_DB_HOST:-parity-mysql}" -uroot -p"$password" \ + -e "DROP USER IF EXISTS 'upgrade_source_reader'@'%'; DROP USER IF EXISTS 'upgrade_source_denied'@'%';" >> "$evidence/database-cleanup.txt" 2>&1 + elif test "$backend" = pgsql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD="$password" \ + --entrypoint psql "$POSTGRES_IMAGE" -h "${DW_PARITY_UPGRADE_DB_HOST:-parity-pg}" -U parity -d postgres -v ON_ERROR_STOP=1 \ + -c 'DROP ROLE IF EXISTS upgrade_source_reader' -c 'DROP ROLE IF EXISTS upgrade_source_denied' >> "$evidence/database-cleanup.txt" 2>&1 + fi +fi +if test -d "$state"; then + docker run --rm --user=1000:1000 --network none --cpus=1 --memory=128m --memory-swap=128m \ + -v "$PWD/$state:/state" --entrypoint sh "$image" -c 'find /state -mindepth 1 -depth -delete' + rmdir "$state" +fi +printf 'pass\n' > "$evidence/cleanup-status.txt" diff --git a/scripts/conformance/server-parity/upgrade-source-controls.php b/scripts/conformance/server-parity/upgrade-source-controls.php new file mode 100644 index 000000000..b26c2068b --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-controls.php @@ -0,0 +1,43 @@ + PDO::ERRMODE_EXCEPTION]); +$statements = match ($mode) { + 'roles' => $backend === 'mysql' ? [ + "CREATE USER 'upgrade_source_reader'@'%' IDENTIFIED BY 'parity-disposable-password'", + 'GRANT SELECT ON '.$database.'.* TO \'upgrade_source_reader\'@\'%\'', + "CREATE USER 'upgrade_source_denied'@'%' IDENTIFIED BY 'parity-disposable-password'", + ] : [ + "CREATE ROLE upgrade_source_reader LOGIN PASSWORD 'parity-disposable-password'", + 'GRANT CONNECT ON DATABASE '.$database.' TO upgrade_source_reader', + 'GRANT USAGE ON SCHEMA public TO upgrade_source_reader', + 'GRANT SELECT ON ALL TABLES IN SCHEMA public TO upgrade_source_reader', + "CREATE ROLE upgrade_source_denied LOGIN PASSWORD 'parity-disposable-password'", + ], + 'unknown' => ['ALTER TABLE jobs ADD COLUMN upgrade_source_probe INTEGER NULL'], + 'marker' => ['CREATE TABLE dw_server_schema (unqualified_marker INTEGER NULL)'], + 'rls' => ['ALTER TABLE workflow_runs ENABLE ROW LEVEL SECURITY'], + 'rls-reset' => ['ALTER TABLE workflow_runs DISABLE ROW LEVEL SECURITY'], + default => throw new RuntimeException('Unknown source control'), +}; +foreach ($statements as $sql) { + $pdo->exec($sql); +} +echo json_encode(['outcome' => 'pass', 'control' => $mode], JSON_THROW_ON_ERROR)."\n"; diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh new file mode 100755 index 000000000..6bf26064d --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# A separate published PHP cohort; inventory is not migration or SDK takeover. +set -euo pipefail +test "$#" = 1 || exit 2 +backend=$1 +test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" +docker network inspect "$RESOURCE_SCOPE" >/dev/null +image=ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac +rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 +resource="$RESOURCE_SCOPE-upgrade" +state=parity-upgrade-source-state +evidence=parity-evidence/upgrade-source +test -f rust-target/debug/durable-workflow-server +test ! -e "$state" +test ! -e "$evidence" +mkdir -p "$state" "$evidence" +chmod a+rwx "$state" "$evidence" +printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/owner.txt" +trap 'printf "%s\n" "$?" > "$evidence/profile-status.txt"' EXIT +database=upgrade_source_ref +password=${DW_PARITY_UPGRADE_DB_PASSWORD:-parity-disposable-password} +case "$backend" in + mysql) host=${DW_PARITY_UPGRADE_DB_HOST:-parity-mysql}; admin=(-e DB_CONNECTION=mysql -e DB_HOST="$host" -e DB_USERNAME=root -e DB_PASSWORD="$password") ;; + pgsql) host=${DW_PARITY_UPGRADE_DB_HOST:-parity-pg}; admin=(-e DB_CONNECTION=pgsql -e DB_HOST="$host" -e DB_USERNAME=parity -e DB_PASSWORD="$password") ;; + sqlite) admin=(-e DB_CONNECTION=sqlite); database=/state/database.sqlite ;; + *) exit 2 ;; +esac +if test "$backend" = mysql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint mysql "$MYSQL_IMAGE" \ + -h "$host" -uroot -p"$password" \ + -e 'CREATE DATABASE upgrade_source_ref CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' > "$evidence/create.txt" 2>&1 +elif test "$backend" = pgsql; then + docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" -e PGPASSWORD="$password" \ + --entrypoint createdb "$POSTGRES_IMAGE" -h "$host" -U parity "$database" > "$evidence/create.txt" 2>&1 +else + touch "$state/database.sqlite" + chmod 0666 "$state/database.sqlite" +fi +printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/database-owned.txt" +configuration=("${admin[@]}" -e DB_DATABASE="$database" -e DW_AUTH_TOKEN=upgrade-fixture-token + -e CACHE_STORE=file -e QUEUE_CONNECTION=database -e DB_SQLITE_JOURNAL_MODE=DELETE) +php_run() { + docker run --rm --name "$resource-control" --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + --network "$RESOURCE_SCOPE" "${configuration[@]}" -v "$PWD/$state:/state" -v "$PWD:/source:ro" \ + --entrypoint php "$image" "$@" +} +php_run artisan server:bootstrap --force > "$evidence/bootstrap.txt" 2>&1 +docker run -d --name "$resource-php" --network "$RESOURCE_SCOPE" --network-alias parity-upgrade-php \ + --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m "${configuration[@]}" \ + -v "$PWD/$state:/state" "$image" > "$evidence/container.txt" +ready=0 +for attempt in $(seq 1 30); do + if docker run --rm --user=1000:1000 --network "$RESOURCE_SCOPE" --entrypoint curl "$image" \ + -fsS http://parity-upgrade-php:8080/api/ready > "$evidence/readiness.json"; then ready=1; break; fi + sleep 1 +done +test "$ready" = 1 +docker run --rm --name "$resource-author" --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + --network "$RESOURCE_SCOPE" -e DW_PARITY_TOKEN=upgrade-fixture-token -v "$PWD:/source:ro" --entrypoint php "$image" \ + /source/scripts/conformance/server-parity/upgrade-source-author.php http://parity-upgrade-php:8080 upgrade-source \ + > "$evidence/originals.json" 2> "$evidence/author.stderr" +docker stop --signal SIGTERM --time 10 "$resource-php" > "$evidence/stop.txt" +docker inspect --format '{{json .State}}' "$resource-php" > "$evidence/stopped-state.json" +jq -e '.Running==false and .ExitCode==0 and .OOMKilled==false' "$evidence/stopped-state.json" >/dev/null +if test "$backend" != sqlite; then + php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php roles > "$evidence/roles.txt" + printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/roles-owned.txt" +fi +reader=("${admin[@]}" -e DB_USERNAME=upgrade_source_reader -e DB_PASSWORD=parity-disposable-password) +if test "$backend" = sqlite; then reader=("${admin[@]}"); fi +inspect_source() { + docker run --rm --name "$resource-inspect" --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ + --network "$RESOURCE_SCOPE" "${reader[@]}" -e DB_DATABASE="$database" -e DW_RUST_EXPERIMENTAL=1 \ + -v "$PWD/$state:/state:ro" -v "$PWD/rust-target:/target:ro" \ + --entrypoint /target/debug/durable-workflow-server "$rust_image" upgrade-inspect +} +snapshot() { + if test "$backend" = sqlite; then + find "$state" -maxdepth 1 -type f -print0 | sort -z | xargs -0 -r sha256sum + else + local helper=mysql + if test "$backend" = pgsql; then helper=postgres; fi + php_run "/source/scripts/conformance/server-parity/$helper-schema.php" snapshot "$database" + fi +} +snapshot > "$evidence/before.json" +inspect_source > "$evidence/inspection.json" 2> "$evidence/inspection.stderr" +jq -e '.status=="inspected" and (.table_rows|length)==50 and .table_rows.workflow_runs==2 + and .table_rows.activity_executions==1 and .catalog_visibility=="configured_role" + and .source_mutated==false and .takeover_allowed==false and .backup_verified==false + and .php_quiescence_verified==false and .stored_representation_compatibility_verified==false' "$evidence/inspection.json" >/dev/null +snapshot > "$evidence/after.json" +cmp "$evidence/before.json" "$evidence/after.json" +refusal() { + local label=$1 expected=$2 + snapshot > "$evidence/$label-before.json" + if inspect_source > "$evidence/$label.json" 2> "$evidence/$label.stderr"; then exit 1; fi + if test -n "$expected"; then grep -Fxq "$expected" "$evidence/$label.stderr"; fi + snapshot > "$evidence/$label-after.json" + cmp "$evidence/$label-before.json" "$evidence/$label-after.json" +} +if test "$backend" != sqlite; then + reader+=( -e DB_USERNAME=upgrade_source_denied ) + refusal denied '' + reader+=( -e DB_USERNAME=upgrade_source_reader ) +fi +if test "$backend" = pgsql; then + php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php rls > "$evidence/rls-control.json" + refusal rls upgrade_source_row_security_unqualified + php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php rls-reset > "$evidence/rls-reset.json" +fi +php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php unknown > "$evidence/unknown-control.json" +refusal unknown upgrade_source_catalog_unqualified +php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php marker > "$evidence/marker-control.json" +refusal marker upgrade_source_already_owned +jq -n --arg revision "$(git rev-parse HEAD)" --arg native "$(git rev-parse HEAD:rust)" \ + --arg image "$image" --arg backend "$backend" --arg binary "$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1)" \ + --slurpfile inventory "$evidence/inspection.json" --slurpfile originals "$evidence/originals.json" \ + '{outcome:"pass",scope:"read_only_source_inventory_and_refusals",backend:$backend, + runner_revision:$revision,native_tree:$native,native_binary_sha256:$binary,php_image:$image, + original_runs:2,source_state_unchanged_by_inspection:true, + catalog_sha256:$inventory[0].catalog_sha256,table_count:($inventory[0].table_rows|length), + originals:($originals[0].originals|to_entries|map({kind:.key,workflow_id:.value.workflow_id, + run_id:.value.run_id,history_events:(.value.history|length),activity_attempt_id:.value.activity_task.activity_attempt_id})), + takeover_allowed:false,backup_verified:false,php_quiescence_verified:false, + stored_representation_compatibility_verified:false}' > "$evidence/comparison.json" +cat "$evidence/comparison.json" From 0b97278082df3cdeeb961cb09ff5cfc37f243127 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:46:44 +0000 Subject: [PATCH 05/26] Compare exact source catalog definitions independently of metadata collation order --- docs/rust-server-upgrade.md | 3 +++ rust/src/runtime/mysql.rs | 8 ++++++-- .../conformance/server-parity/upgrade-source-profile.sh | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 15b0c28e0..9c7783b60 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -27,6 +27,9 @@ The known PHP catalog is the existing compiled physical schema with only the explicit native additions excluded. Database-generated next-ID counters remain data; no other catalog differences are discarded. Schema equality identifies a layout, not the installed PHP package version. +MySQL/MariaDB source catalog rows use exact table-name byte ordering so that +server metadata collations cannot change comparison order. All names and +complete definitions remain exact; business row/event order is not transformed. The separate exact PHP 2.8.0 catalog variant records the published service-call deadline and worker incarnation additions. It is compared as a complete diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index ae3015dd9..67dcad7bb 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -174,12 +174,16 @@ pub(super) async fn php_source_catalog(connection: &mut MySqlConnection) -> Resu } } let flavor = Flavor::detect(connection).await?; - let expected: Catalog = flavor.expected()?.into_iter() + let mut expected: Catalog = flavor.expected()?.into_iter() .filter(|(table, _)| !super::upgrade::native_relation(table)) .map(|(table, ddl)| (table, ddl.replace( ",\n KEY `dw_workflow_tasks_poll` (`namespace`(64),`queue`(64),`task_type`(16),`status`(16),`available_at`)", ""))) .collect(); - let actual = catalog(connection).await?; + let mut actual = catalog(connection).await?; + // MariaDB's information_schema collation ignores some underscores. Only + // canonicalize catalog presentation; preserve every full definition. + expected.sort_by(|left, right| left.0.cmp(&right.0)); + actual.sort_by(|left, right| left.0.cmp(&right.0)); if actual != expected { let delta = match flavor { Flavor::MySql => include_str!("../../upgrade/php-2.8.0/mysql.json"), diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index 6bf26064d..006942bb0 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -59,7 +59,7 @@ docker run --rm --name "$resource-author" --user=1000:1000 --cpus=1 --memory=512 --network "$RESOURCE_SCOPE" -e DW_PARITY_TOKEN=upgrade-fixture-token -v "$PWD:/source:ro" --entrypoint php "$image" \ /source/scripts/conformance/server-parity/upgrade-source-author.php http://parity-upgrade-php:8080 upgrade-source \ > "$evidence/originals.json" 2> "$evidence/author.stderr" -docker stop --signal SIGTERM --time 10 "$resource-php" > "$evidence/stop.txt" +docker stop --signal SIGTERM --time 10 "$resource-php" >/dev/null docker inspect --format '{{json .State}}' "$resource-php" > "$evidence/stopped-state.json" jq -e '.Running==false and .ExitCode==0 and .OOMKilled==false' "$evidence/stopped-state.json" >/dev/null if test "$backend" != sqlite; then From ddd696c84d9444632fcb925fc65a2a5e2dc10939 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:48:07 +0000 Subject: [PATCH 06/26] Require stable source and executable identity throughout source inventory checks --- .../server-parity/upgrade-source-profile.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index 006942bb0..5115f1f68 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -11,6 +11,11 @@ resource="$RESOURCE_SCOPE-upgrade" state=parity-upgrade-source-state evidence=parity-evidence/upgrade-source test -f rust-target/debug/durable-workflow-server +git diff --quiet +git diff --cached --quiet +revision=$(git rev-parse HEAD) +native_tree=$(git rev-parse HEAD:rust) +binary_sha256=$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1) test ! -e "$state" test ! -e "$evidence" mkdir -p "$state" "$evidence" @@ -113,8 +118,12 @@ php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php un refusal unknown upgrade_source_catalog_unqualified php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php marker > "$evidence/marker-control.json" refusal marker upgrade_source_already_owned -jq -n --arg revision "$(git rev-parse HEAD)" --arg native "$(git rev-parse HEAD:rust)" \ - --arg image "$image" --arg backend "$backend" --arg binary "$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1)" \ +test "$(git rev-parse HEAD)" = "$revision" +test "$(sha256sum rust-target/debug/durable-workflow-server | cut -d ' ' -f1)" = "$binary_sha256" +git diff --quiet +git diff --cached --quiet +jq -n --arg revision "$revision" --arg native "$native_tree" \ + --arg image "$image" --arg backend "$backend" --arg binary "$binary_sha256" \ --slurpfile inventory "$evidence/inspection.json" --slurpfile originals "$evidence/originals.json" \ '{outcome:"pass",scope:"read_only_source_inventory_and_refusals",backend:$backend, runner_revision:$revision,native_tree:$native,native_binary_sha256:$binary,php_image:$image, From c3f09fddac217269a88c47b6f62479c1850f1f41 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 01:56:57 +0000 Subject: [PATCH 07/26] Document source inventory use and exact qualification limits --- docs/rust-server-upgrade.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 9c7783b60..58a3a6519 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -7,7 +7,7 @@ unimplemented and unqualified. ## Read-only source inspection contract -The native `upgrade-inspect` command will inspect a configured existing PHP +The native `upgrade-inspect` command inspects a configured existing PHP database using a dedicated read-only transaction, without constructing a runtime pool. It uses the same `DB_*` connection configuration as native schema checks and supports SQLite, MySQL/MariaDB and PostgreSQL. It requires the existing @@ -21,6 +21,15 @@ mode. WAL inventory requires a separately qualified consistent snapshot path. Use a read-only filesystem mount as an additional boundary and stop writers before inspection; the report does not certify that they have stopped. +With the development binary and existing connection environment configured, +run `DW_RUST_EXPERIMENTAL=1 durable-workflow-server upgrade-inspect`. +For SQLite, set `DB_CONNECTION=sqlite` and `DB_DATABASE` to the existing file +inside a read-only mount. Network databases use the existing `DB_HOST`, +`DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` and TLS settings. Use a +SELECT-only inspection role. Success prints one JSON inventory; refusal exits +nonzero with a bounded reason, without printing a connection URL or password. +No successful result authorizes a subsequent write or upgrade command. + Inspection must refuse a native ownership marker, an unknown catalog, missing permissions and unavailable storage before reporting durable-state counts. The known PHP catalog is the existing compiled physical schema with only the @@ -58,6 +67,16 @@ qualification and refuse this first inventory path. ## Qualification and next migration gates +The separate published PHP 2.8.0 profile prepares two genuine original runs: +one leased activity and one pending timer. It explicitly stops PHP before +inspection, compares source state and sequence positions before/after, and +checks denied access, changed catalog, ownership markers and PostgreSQL row +security. SQLite uses the explicitly selected DELETE journal profile. Its raw +public-protocol author does not establish SDK continuation or workload +completion. The independent SQLite WAL refusal remains a separate observation. +The CI profile adds an eight-minute phase and separate three-minute cleanup +ceiling; existing scenario, transaction and job budgets remain unchanged. + Use a separate cohort populated by the current published PHP artifact, record its actual identity, and stop every PHP writer before refusal/byte-preservation checks. Existing differential actors and frozen manifests remain unchanged. From 4459140cdd220a83d1f19f23100a838332a2785a Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 02:36:39 +0000 Subject: [PATCH 08/26] Refuse special upgrade sources and distinguish upload timeout from capacity recovery --- .github/workflows/server-parity.yml | 3 -- docs/rust-server-port.md | 13 ++++-- docs/rust-server-upgrade.md | 25 +++++++++++- rust/Cargo.lock | 1 + rust/Cargo.toml | 3 ++ rust/src/runtime/upgrade.rs | 20 ++++++++- rust/tests/execution/external_payloads.rs | 18 ++++++-- rust/tests/upgrade_inspect.rs | 50 +++++++++++++++++++++++ 8 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 rust/tests/upgrade_inspect.rs diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 58bbf24ec..b3907ae9c 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -627,7 +627,6 @@ jobs: - name: Compare separate configured role-token cohort run: bash scripts/conformance/server-parity/role-profile.sh mysql - name: Inspect separate published PHP upgrade source without takeover - if: ${{ !cancelled() }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh mysql - name: Retire upgrade source cohort @@ -1075,7 +1074,6 @@ jobs: POSTGRES_IMAGE: ${{ matrix.image }} run: bash scripts/conformance/server-parity/role-profile.sh pgsql - name: Inspect separate published PHP upgrade source without takeover - if: ${{ !cancelled() }} timeout-minutes: 8 run: bash scripts/conformance/server-parity/upgrade-source-profile.sh pgsql - name: Retire upgrade source cohort @@ -1507,7 +1505,6 @@ jobs: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} run: bash scripts/conformance/server-parity/role-profile.sh sqlite - name: Inspect separate published PHP upgrade source without takeover - if: ${{ !cancelled() }} timeout-minutes: 8 env: RESOURCE_SCOPE: dw-parity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 2bef1dd02..c0fb6757b 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -31,20 +31,27 @@ The active integration order is: - [#455](https://github.com/durable-workflow/server/pull/455): opt-in cross-kind null-task poll wake hint with exact namespace/queue isolation and no delivery authority. Current PHP query documentation is preserved at combined revision - 55. Hosted integration follows #457. + 55. Exact candidate 1fa passes all 13 jobs and all six execution logs. Delivery + through the combined current-main #458 candidate and fresh main remain pending. An earlier component failed after business completion at registration DELETE with lock-pressure 503, before later refusal/repair phases. That original remains failed without a current-release or historical-cause inference. - [#458](https://github.com/durable-workflow/server/pull/458): bound activity completion uploads. Actual PHP/native originals and strict readers pass SQLite/MySQL 8.4/PostgreSQL 17 locally; current all-six qualification is pending. - Full external transport discovery remains false. + The main-targeted stack includes reviewed #455. Strict up-to-date branch + protection remains in force. Full external transport discovery remains false. - [#460](https://github.com/durable-workflow/server/pull/460): read-only upgrade source inspection, independently prepared from published PHP 2.8.0. Exact known catalog checks and counts grant no backup, quiescence or takeover authority. SQLite WAL is refused before opening; rollback-journal inventory and network SELECT-only roles have separate actual source checks. Hosted - qualification and all actual migration/conversion gates remain ahead. + 68's original SQLite gate failed the existing fresh-upload 100ms control + before inventory admission. A revised control keeps the stalled request's + 100ms limit and checks shared capacity with a new normal-policy request; + the failed original remains failed. A CLI process control also refuses pipes + and directories without waiting for a writer. Current source and all actual + migration/conversion gates remain unqualified. Full transport discovery stays unadvertised. Complete batch workflow semantics, memo/query reference ingress, upload allowances, automatic result policy, S3, diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 58a3a6519..3fa4876ec 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -12,7 +12,9 @@ database using a dedicated read-only transaction, without constructing a runtime pool. It uses the same `DB_*` connection configuration as native schema checks and supports SQLite, MySQL/MariaDB and PostgreSQL. It requires the existing development opt-in. SQLite inspection never creates a missing database or runs -recovery pragmas. +recovery pragmas. It requires a regular source file and refuses directories, +pipes and devices before reading a database header. Unix opens remain +nonblocking if a source path changes to a pipe during validation. SQLite WAL inspection is initially refused before opening SQLite: a read-only SQLite connection can still create shared-memory and WAL sidecars. This first @@ -67,6 +69,23 @@ qualification and refuse this first inventory path. ## Qualification and next migration gates +Catalog recognition does not decode stored values. The source audit identifies +these separate compatibility boundaries: + +| Representation | Required takeover check | +| --- | --- | +| V2 arguments/results with a row codec, JSON task/history documents and external references | Validate the stored codec and exact typed values, preserving original bytes, IDs and deadlines. Do not infer a codec from table shape. | +| Laravel queue jobs | The V2 workflow/activity/timer jobs carry a task ID and claim canonical task state. Qualify replacement wakeups against those same tasks; never execute serialized PHP objects in Rust or discard unknown jobs. | +| PHP class names, legacy stored workflows and internal serialized closures | Class-name presence is not a portable type binding. Establish a tested conversion/read path or refuse unsupported active data before mutation. | +| Cache and Redis entries | Identify original query, poll and receipt state separately from reconstructable wakeups. A database-only snapshot does not prove these states survive. | + +The queue observation comes from the published Core's `TaskDispatcher` and +`RunWorkflowTask`, `RunActivityTask` and `RunTimerTask` handlers. It does not +qualify migration of a queue payload, a closure, a legacy workflow or a live +receipt. Current native SQLite timestamp reads and claim ordering already +handle the supported PHP UTC SQL form and native UTC form; an actual sequential +takeover must still prove pending work retains its original microseconds. + The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before inspection, compares source state and sequence positions before/after, and @@ -75,7 +94,9 @@ security. SQLite uses the explicitly selected DELETE journal profile. Its raw public-protocol author does not establish SDK continuation or workload completion. The independent SQLite WAL refusal remains a separate observation. The CI profile adds an eight-minute phase and separate three-minute cleanup -ceiling; existing scenario, transaction and job budgets remain unchanged. +ceiling and requires the preceding deployment checks to succeed. A failed +prerequisite skips inspection rather than counting a missing deployment as an +inspected source. Existing scenario, transaction and job budgets remain unchanged. Use a separate cohort populated by the current published PHP artifact, record its actual identity, and stop every PHP writer before refusal/byte-preservation diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 22854394f..8253f8479 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -419,6 +419,7 @@ dependencies = [ "chrono", "futures-util", "getrandom 0.3.4", + "libc", "regex-lite", "serde", "serde_json", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9940e55c6..b068a4052 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -26,6 +26,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal ulid = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 51ceaf32f..6086f2af6 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -146,7 +146,25 @@ pub async fn inspect_sqlite(path: &str) -> Result { // immutable=1 this preserves normal SQLite locking/change detection for // the supported rollback-journal source, rather than ignoring live WAL. timeout_at(deadline, async { - let mut file = tokio::fs::File::open(path).await?; + let regular_file_required = || { + refuse( + StatusCode::BAD_REQUEST, + "upgrade_source_regular_file_required", + ) + }; + if !tokio::fs::metadata(path).await?.is_file() { + return Err(regular_file_required()); + } + let mut options = tokio::fs::OpenOptions::new(); + options.read(true); + // Tokio filesystem opens use blocking workers. A replaced FIFO must + // not strand one beyond the observation budget or prevent CLI exit. + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + let mut file = options.open(path).await?; + if !file.metadata().await?.is_file() { + return Err(regular_file_required()); + } let mut header = [0_u8; 20]; file.read_exact(&mut header).await?; if &header[..16] != b"SQLite format 3\0" || header[18..20] != [1, 1] { diff --git a/rust/tests/execution/external_payloads.rs b/rust/tests/execution/external_payloads.rs index 31360d5fe..e5bac4539 100644 --- a/rust/tests/execution/external_payloads.rs +++ b/rust/tests/execution/external_payloads.rs @@ -1323,14 +1323,17 @@ async fn authentication_namespace_and_declared_limit_refuse_before_body_or_provi #[tokio::test] async fn stalled_upload_keeps_original_timeout_and_releases_capacity_for_next_original() { let temp = tempfile::tempdir().unwrap(); - let mut config = ExternalPayloadConfig::default(); + let recovery_config = ExternalPayloadConfig::default(); + // The clone shares the one-slot worker semaphore. Keep the stalled + // request's deadline separate from fresh durable filesystem/SQL work. + let mut config = recovery_config.clone(); config.timeout = Duration::from_millis(100); - let (database, runtime, router) = setup(config, temp.path()).await; + let (database, runtime, stalled_router) = setup(config, temp.path()).await; let meta = metadata(b"abc"); let start = tokio::time::Instant::now(); let body = Body::from_stream(futures_util::stream::pending::>()); let (status, _, _) = request( - &router, + &stalled_router, "POST", "/api/external-payloads/v1", "worker-token", @@ -1341,8 +1344,15 @@ async fn stalled_upload_keeps_original_timeout_and_releases_capacity_for_next_or .await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert!(start.elapsed() < Duration::from_secs(1)); + // This fresh request uses the normal policy with the same capacity pool. + // A leaked permit still times out; slow durable I/O is not a leak proof. + let recovery_router = router( + runtime + .clone() + .with_external_payload_config(recovery_config), + ); let (status, _, body) = request( - &router, + &recovery_router, "POST", "/api/external-payloads/v1", "worker-token", diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs new file mode 100644 index 000000000..45e70d572 --- /dev/null +++ b/rust/tests/upgrade_inspect.rs @@ -0,0 +1,50 @@ +//! Exercise process exit as well as refusal: a cancelled filesystem open can +//! otherwise keep Tokio's blocking pool alive after an async timeout. +#![cfg(unix)] + +use std::{ + ffi::CString, + os::unix::ffi::OsStrExt, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +#[test] +fn special_sqlite_sources_refuse_without_waiting_for_a_writer() { + let directory = tempfile::tempdir().unwrap(); + let fifo = directory.path().join("source.sqlite"); + let name = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + // The path belongs to this test's new private temporary directory. + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + + for source in [fifo.as_path(), directory.path()] { + let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) + .env_clear() + .env("DW_RUST_EXPERIMENTAL", "1") + .env("DB_CONNECTION", "sqlite") + .env("DB_DATABASE", source) + .arg("upgrade-inspect") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("source refusal did not retire the CLI process"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("upgrade_source_regular_file_required") + ); + } + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); +} From 0f7fbf423ddae0cbee54bfd3d3b9a8c7d1794580 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:13:22 +0000 Subject: [PATCH 09/26] Audit actual PHP fixture representations without executing queue objects --- docs/rust-server-upgrade.md | 16 +++ .../server-parity/upgrade-source-profile.sh | 13 ++ .../upgrade-source-sqlite-representations.php | 121 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 scripts/conformance/server-parity/upgrade-source-sqlite-representations.php diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 3fa4876ec..2178e79a4 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -86,6 +86,22 @@ receipt. Current native SQLite timestamp reads and claim ordering already handle the supported PHP UTC SQL form and native UTC form; an actual sequential takeover must still prove pending work retains its original microseconds. +The separate SQLite fixture reader +`scripts/conformance/server-parity/upgrade-source-sqlite-representations.php` +accepts only the source-author's two known fixture IDs through a read-only +`/state` mount. A stopped published PHP 2.8.0 sample preserves exact Avro int64, +null and Unicode arguments, JSON task/history documents, six-digit SQL times +and the full activity retry snapshot. Its queued `RunTimerTask` contains a +serialized Carbon object and references the original canonical timer task. +The reader observes that property without deserializing queue objects; its +pattern check is not a general PHP serialization validator or conversion path. +The sample's six mounted file-cache entries are opaque serialized strings. +The hosted recipe reports cache storage as unobserved when it is not mounted. +No query/poll/receipt semantics, Redis state, legacy closures, completed results +or external bytes are qualified. Exact database hashes before/after establish +read-only observation, with backup, representation compatibility and takeover +still explicitly false. The new hosted hook needs fresh combined qualification. + The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before inspection, compares source state and sequence positions before/after, and diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index 5115f1f68..20088e57b 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -67,6 +67,19 @@ docker run --rm --name "$resource-author" --user=1000:1000 --cpus=1 --memory=512 docker stop --signal SIGTERM --time 10 "$resource-php" >/dev/null docker inspect --format '{{json .State}}' "$resource-php" > "$evidence/stopped-state.json" jq -e '.Running==false and .ExitCode==0 and .OOMKilled==false' "$evidence/stopped-state.json" >/dev/null +if test "$backend" = sqlite; then + docker run --rm --name "$resource-control" --user=1000:1000 --cpus=1 --memory=512m --memory-swap=512m \ + --network=none -v "$PWD/$state:/state:ro" -v "$PWD:/source:ro" --entrypoint php "$image" \ + /source/scripts/conformance/server-parity/upgrade-source-sqlite-representations.php upgrade-source \ + > "$evidence/representations-comparison.json" + jq -e '.outcome=="pass" and .source_mutated==false and .php_objects_executed==false + and (.runs|length)==2 and (.tasks|length)==4 and (.activities|length)==1 + and (.history|length)==7 and (.queue_jobs|length)==1 + and .queue_jobs[0].canonical_timer_task_match==true + and all(.runs[]; .typed_arguments_match==true) + and .backup_verified==false and .stored_representation_compatibility_verified==false + and .takeover_allowed==false' "$evidence/representations-comparison.json" >/dev/null +fi if test "$backend" != sqlite; then php_run /source/scripts/conformance/server-parity/upgrade-source-controls.php roles > "$evidence/roles.txt" printf '%s\n' "$RESOURCE_SCOPE" > "$evidence/roles-owned.txt" diff --git a/scripts/conformance/server-parity/upgrade-source-sqlite-representations.php b/scripts/conformance/server-parity/upgrade-source-sqlite-representations.php new file mode 100644 index 000000000..da17fe171 --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-sqlite-representations.php @@ -0,0 +1,121 @@ + PDO::ERRMODE_EXCEPTION]); +$pdo->exec('PRAGMA query_only=ON'); +if ($pdo->query('PRAGMA journal_mode')->fetchColumn() !== 'delete') { + throw new RuntimeException('Audit requires the already stopped DELETE-journal fixture'); +} +$runs = $pdo->query('SELECT * FROM workflow_runs ORDER BY id LIMIT 3')->fetchAll(PDO::FETCH_ASSOC); +$workflowIds = array_column($runs, 'workflow_instance_id'); +sort($workflowIds); +if ($workflowIds !== [$prefix.'-activity', $prefix.'-timer']) { + throw new RuntimeException('This reader accepts only the two isolated source-author originals'); +} +$report = ['scope' => 'published_php_stored_representation_observation', 'runs' => [], + 'table_rows' => [], 'tasks' => [], 'activities' => [], 'history' => [], 'queue_jobs' => [], 'cache_files' => []]; +$expected = [[9007199254740993, null, 'upgradeλ']]; +foreach ($runs as $run) { + if ($run['payload_codec'] !== 'avro' || Avro::unserialize($run['arguments']) !== $expected) { + throw new RuntimeException('Original stored Avro values differ'); + } + $report['runs'][] = ['workflow_id' => $run['workflow_instance_id'], 'run_id' => $run['id'], + 'workflow_class' => $run['workflow_class'], 'workflow_type' => $run['workflow_type'], + 'status' => $run['status'], 'codec' => $run['payload_codec'], + 'arguments_sha256' => hash('sha256', $run['arguments']), 'typed_arguments_match' => true, + 'started_at' => $run['started_at'], 'run_deadline_at' => $run['run_deadline_at'], + 'execution_deadline_at' => $run['execution_deadline_at']]; +} +foreach ($pdo->query("SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") as $table) { + $name = $table['name']; + if (!preg_match('/\A[a-zA-Z0-9_]+\z/', $name)) throw new RuntimeException('Unexpected table name'); + $report['table_rows'][$name] = (int)$pdo->query('SELECT COUNT(*) FROM "'.$name.'"')->fetchColumn(); +} +foreach ($pdo->query('SELECT * FROM workflow_tasks ORDER BY id')->fetchAll(PDO::FETCH_ASSOC) as $task) { + $payload = $task['payload'] === null ? null : json_decode($task['payload'], true, flags: JSON_THROW_ON_ERROR); + $report['tasks'][] = ['id' => $task['id'], 'run_id' => $task['workflow_run_id'], 'kind' => $task['task_type'], + 'status' => $task['status'], 'attempt_count' => $task['attempt_count'], 'payload_keys' => array_keys($payload ?? []), + 'payload_sha256' => $task['payload'] === null ? null : hash('sha256', $task['payload']), + 'lease_expires_at' => $task['lease_expires_at'], 'available_at' => $task['available_at']]; +} +foreach ($pdo->query('SELECT * FROM activity_executions ORDER BY id')->fetchAll(PDO::FETCH_ASSOC) as $activity) { + if ($activity['payload_codec'] !== 'avro' || Avro::unserialize($activity['arguments']) !== $expected) { + throw new RuntimeException('Original activity Avro values differ'); + } + $report['activities'][] = ['id' => $activity['id'], 'run_id' => $activity['workflow_run_id'], + 'activity_class' => $activity['activity_class'], 'activity_type' => $activity['activity_type'], + 'codec' => $activity['payload_codec'], 'typed_arguments_match' => true, + 'arguments_sha256' => hash('sha256', $activity['arguments']), 'status' => $activity['status'], + 'attempt_count' => $activity['attempt_count'], 'retry_policy' => json_decode($activity['retry_policy'], true, flags: JSON_THROW_ON_ERROR)]; +} +foreach ($pdo->query('SELECT * FROM workflow_history_events ORDER BY workflow_run_id, sequence')->fetchAll(PDO::FETCH_ASSOC) as $event) { + $payload = json_decode($event['payload'], true, flags: JSON_THROW_ON_ERROR); + $report['history'][] = ['run_id' => $event['workflow_run_id'], 'sequence' => $event['sequence'], + 'event_type' => $event['event_type'], 'payload_keys' => array_keys($payload), + 'payload_sha256' => hash('sha256', $event['payload']), 'created_at' => $event['created_at']]; +} +foreach ($pdo->query('SELECT id,payload FROM jobs ORDER BY id')->fetchAll(PDO::FETCH_ASSOC) as $row) { + $job = json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR); + $command = $job['data']['command'] ?? null; + if (!is_string($command)) throw new RuntimeException('Queue command representation is unknown'); + preg_match_all('/(?:O|C):[0-9]+:"([^"\x00]+)"/', $command, $classes); + // This is an observation of the fixture's known public string property, + // never an authorizing PHP-object decoder or a queue conversion routine. + preg_match('/s:6:"taskId";s:([0-9]+):"([A-Za-z0-9_-]+)";/', $command, $taskId); + if (!isset($taskId[2]) || strlen($taskId[2]) !== (int)$taskId[1]) { + throw new RuntimeException('Known timer job task identity is absent'); + } + $canonical = array_values(array_filter($report['tasks'], static fn(array $task): bool => $task['id'] === $taskId[2])); + if (count($canonical) !== 1 || $canonical[0]['kind'] !== 'timer') { + throw new RuntimeException('Known timer job does not bind its canonical original task'); + } + $report['queue_jobs'][] = ['id' => $row['id'], 'display_name' => $job['displayName'] ?? null, + 'handler' => $job['job'] ?? null, 'command_kind' => preg_match('/\A[OC]:[0-9]+:/', $command) ? 'php_serialized_object' : 'unknown', + 'classes' => array_values(array_unique($classes[1])), 'command_bytes' => strlen($command), + 'command_sha256' => hash('sha256', $command), 'task_id' => $taskId[2], 'canonical_timer_task_match' => true]; +} +$cache = '/state/storage/framework/cache/data'; +$report['cache_observation'] = is_dir($cache) ? 'mounted_file_store_only' : 'not_mounted'; +$files = is_dir($cache) + ? new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cache, FilesystemIterator::SKIP_DOTS)) : []; +foreach ($files as $file) { + if (!$file->isFile()) continue; + $bytes = file_get_contents($file->getPathname()); + preg_match('/\A[0-9]{10}([aObidsNC]):/', $bytes, $kind); + preg_match_all('/(?:O|C):[0-9]+:"([^"\x00]+)"/', $bytes, $classes); + $inner = null; + $innerValid = false; + if (preg_match('/\A[0-9]{10}s:([0-9]+):"/', $bytes, $stringHeader) + && substr($bytes, strlen($stringHeader[0]) + (int)$stringHeader[1]) === '";') { + $string = substr($bytes, strlen($stringHeader[0]), (int)$stringHeader[1]); + $inner = json_decode($string, true); + $innerValid = json_last_error() === JSON_ERROR_NONE; + } + $report['cache_files'][] = ['size' => strlen($bytes), 'sha256' => hash('sha256', $bytes), + 'expiry_prefixed_serialized_kind' => $kind[1] ?? null, 'classes' => array_values(array_unique($classes[1])), + 'inner_json_valid' => $innerValid, 'inner_json_kind' => $innerValid ? get_debug_type($inner) : 'opaque_string', + 'inner_json_keys' => is_array($inner) ? array_slice(array_keys($inner), 0, 20) : []]; +} +$serializer = (new ReflectionClass(Avro::class))->getFileName(); +$report['serializer_sha256'] = hash_file('sha256', $serializer); +$pdo = null; +$report['database_sha256_before'] = $before; +$report['database_sha256_after'] = hash_file('sha256', $database); +if ($report['database_sha256_after'] !== $before) throw new RuntimeException('Source database changed'); +$report += ['outcome' => 'pass', 'source_mutated' => false, 'php_objects_executed' => false, + 'backup_verified' => false, 'stored_representation_compatibility_verified' => false, 'takeover_allowed' => false]; +echo json_encode($report, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)."\n"; From c65f4a6c463328119f08c60598dde50aea336574 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 07:42:08 +0000 Subject: [PATCH 10/26] Inspect bounded published PHP queue command representations --- docs/rust-server-port.md | 9 + docs/rust-server-upgrade.md | 16 + rust/Cargo.lock | 16 + rust/Cargo.toml | 1 + rust/src/runtime/upgrade.rs | 1 + rust/src/runtime/upgrade/queue.rs | 477 ++++++++++++++++++ .../server-parity/upgrade-queue-fixtures.php | 27 + .../php-2.8.0-queue-commands.json | 21 + 8 files changed, 568 insertions(+) create mode 100644 rust/src/runtime/upgrade/queue.rs create mode 100644 scripts/conformance/server-parity/upgrade-queue-fixtures.php create mode 100644 tests/Fixtures/ServerUpgrade/php-2.8.0-queue-commands.json diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index e21880569..da682fd07 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -3066,3 +3066,12 @@ WorkerController bytes match the qualified PHP handoff. This combined tree requires fresh normal and six-database qualification. The preceding `3409a776` component pass, its MySQL-only retry, and all failed originals remain their separate records; none qualifies this integration or a native release. + +Upgrade preparation now includes a non-executing queue-command reader under +`runtime::upgrade::queue`. The released token parser recognizes only the tested +V2 job/Carbon subset under explicit byte, depth and token limits. Published +class fixtures preserve original task IDs, a Unicode queue and microsecond +delay fields. Malformed and unqualified representations are refused. This is +an independently tested decoder, not a source-row migration or takeover path. +The next migration gates remain canonical queue/task binding, complete stored +value validation, restored backup verification and actual sequential takeover. diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 2178e79a4..592f41c98 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -102,6 +102,22 @@ or external bytes are qualified. Exact database hashes before/after establish read-only observation, with backup, representation compatibility and takeover still explicitly false. The new hosted hook needs fresh combined qualification. +The native queue-command inspector uses the pinned `phpserz` token parser with +its optional Serde feature disabled. It reads at most 64 KiB, eight nested +levels, 256 tokens and 32 properties per object. The initial recognized subset +is the published V2 workflow/activity/timer job with its original task ID, +optional connection/queue and optional Carbon delay fields. Unknown classes, +custom serialization, references, arrays/chains, duplicate or hidden property +aliases, malformed lengths and trailing values are refused. Dates and timezone +fields remain original strings, not recalculated task deadlines. + +The committed queue fixtures are produced by serializing the real classes in +the pinned published image without dispatching them. This independently tested +decoder does not yet consume source rows, validate the outer queue envelope or +join a job to canonical task/run state. It grants no conversion, deletion, +execution or takeover authority. Complete native integration and actual +same-database queue qualification remain separate gates. + The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before inspection, compares source state and sequence positions before/after, and diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8253f8479..ab980fb2c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -420,6 +420,7 @@ dependencies = [ "futures-util", "getrandom 0.3.4", "libc", + "phpserz", "regex-lite", "serde", "serde_json", @@ -479,6 +480,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.5.0" @@ -1136,6 +1143,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phpserz" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e1c56e7b9593a3dd62215f3e97db051885f282a947d8a7714b593cc5ace8d1" +dependencies = [ + "fast-float2", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b068a4052..0eec6d231 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,6 +19,7 @@ sqlx = { version = "=0.9.0", default-features = false, features = ["runtime-toki subtle = "=2.6.1" getrandom = "=0.3.4" regex-lite = "0.1" +phpserz = { version = "=0.3.0", default-features = false } tempfile = "3" futures-util = { version = "0.3", default-features = false, features = ["std"] } tokio-util = { version = "=0.7.19", features = ["io", "io-util"] } diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 6086f2af6..3cd6652f3 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,4 +1,5 @@ //! Read-only PHP source inventory. This command never grants takeover authority. +pub mod queue; use super::{Result, mysql, postgres, refuse, schema}; use axum::http::StatusCode; use serde::{Deserialize, Serialize, de::DeserializeOwned}; diff --git a/rust/src/runtime/upgrade/queue.rs b/rust/src/runtime/upgrade/queue.rs new file mode 100644 index 000000000..f4c8e486f --- /dev/null +++ b/rust/src/runtime/upgrade/queue.rs @@ -0,0 +1,477 @@ +//! Inspect a bounded subset of published V2 queue commands without PHP execution. +//! Recognition is not migration authority. A later takeover must validate the +//! outer queue envelope and join the original task/run, deadlines and ownership. +use phpserz::{PhpParser, PhpToken}; +use std::collections::BTreeSet; + +const MAX_BYTES: usize = 64 * 1024; +const MAX_DEPTH: usize = 8; +const MAX_TOKENS: usize = 256; +const MAX_PROPERTIES: u32 = 32; +const WORKFLOW: &[u8] = b"Workflow\\V2\\Jobs\\RunWorkflowTask"; +const ACTIVITY: &[u8] = b"Workflow\\V2\\Jobs\\RunActivityTask"; +const TIMER: &[u8] = b"Workflow\\V2\\Jobs\\RunTimerTask"; +const CARBON: &[u8] = b"Illuminate\\Support\\Carbon"; + +#[derive(Debug, PartialEq, Eq)] +pub enum QueueDecodeError { + TooLarge, + TooDeep, + TooManyValues, + Malformed, + DuplicateProperty, + UnsupportedReference, + UnsupportedCustomObject, + UnsupportedClass, + UnsupportedValue, + UnsupportedProperty, + InvalidShape, + InvalidTaskId, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum QueueTaskKind { + Workflow, + Activity, + Timer, +} + +/// Original serialized fields. These are not a new availability/deadline budget. +#[derive(Debug, PartialEq, Eq)] +pub struct CarbonDelay<'a> { + pub date: &'a str, + pub timezone: &'a str, + pub timezone_type: i64, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct QueueCommand<'a> { + pub kind: QueueTaskKind, + pub task_id: &'a str, + pub connection: Option<&'a str>, + pub queue: Option<&'a str>, + pub delay: Option>, +} + +enum Value<'a> { + Null, + Boolean(bool), + Integer(i64), + Bytes(&'a [u8]), + Object { + class: &'a [u8], + properties: Vec<(&'a [u8], Value<'a>)>, + }, +} + +struct Reader<'a> { + parser: PhpParser<'a>, + tokens: usize, +} + +impl<'a> Reader<'a> { + fn token(&mut self) -> Result, QueueDecodeError> { + self.tokens += 1; + if self.tokens > MAX_TOKENS { + return Err(QueueDecodeError::TooManyValues); + } + self.parser + .read_token() + .map_err(|_| QueueDecodeError::Malformed) + } + + fn value(&mut self, depth: usize) -> Result, QueueDecodeError> { + if depth > MAX_DEPTH { + return Err(QueueDecodeError::TooDeep); + } + Ok(match self.token()? { + PhpToken::Null => Value::Null, + PhpToken::Boolean(value) => Value::Boolean(value), + PhpToken::Integer(value) => Value::Integer(value), + PhpToken::String(value) => Value::Bytes(value.as_bytes()), + PhpToken::Object { class, properties } => { + let class = class.as_bytes(); + if ![WORKFLOW, ACTIVITY, TIMER, CARBON].contains(&class) { + return Err(QueueDecodeError::UnsupportedClass); + } + if properties > MAX_PROPERTIES { + return Err(QueueDecodeError::TooManyValues); + } + let mut values = Vec::new(); + let mut keys = BTreeSet::new(); + for _ in 0..properties { + let PhpToken::String(key) = self.token()? else { + return Err(QueueDecodeError::InvalidShape); + }; + let key = key.as_bytes(); + // Preserve the complete property bytes. Stripping PHP's + // visibility prefix could alias a different taskId field. + if !keys.insert(key) { + return Err(QueueDecodeError::DuplicateProperty); + } + values.push((key, self.value(depth + 1)?)); + } + if self.token()? != PhpToken::End { + return Err(QueueDecodeError::Malformed); + } + Value::Object { + class, + properties: values, + } + } + PhpToken::Reference { .. } => return Err(QueueDecodeError::UnsupportedReference), + PhpToken::CustomObject { .. } => return Err(QueueDecodeError::UnsupportedCustomObject), + // Arrays/chains, floating delays and other unqualified values are + // refused, never flattened into a superficially recognized job. + PhpToken::Array { .. } | PhpToken::Float(_) => { + return Err(QueueDecodeError::UnsupportedValue); + } + PhpToken::End => return Err(QueueDecodeError::Malformed), + }) + } +} + +fn text(value: Value<'_>) -> Result<&str, QueueDecodeError> { + let Value::Bytes(value) = value else { + return Err(QueueDecodeError::InvalidShape); + }; + let value = std::str::from_utf8(value).map_err(|_| QueueDecodeError::InvalidShape)?; + if value.contains('\0') || value.chars().count() > 255 { + return Err(QueueDecodeError::InvalidShape); + } + Ok(value) +} + +fn optional_text(value: Value<'_>) -> Result, QueueDecodeError> { + match value { + Value::Null => Ok(None), + value => text(value).map(Some), + } +} + +fn carbon(value: Value<'_>) -> Result>, QueueDecodeError> { + if matches!(value, Value::Null) { + return Ok(None); + } + let Value::Object { + class: CARBON, + properties, + } = value + else { + return Err(QueueDecodeError::InvalidShape); + }; + let (mut date, mut timezone, mut timezone_type) = (None, None, None); + for (key, value) in properties { + match key { + b"date" => date = Some(text(value)?), + b"timezone" => timezone = Some(text(value)?), + b"timezone_type" => { + let Value::Integer(kind @ 1..=3) = value else { + return Err(QueueDecodeError::InvalidShape); + }; + timezone_type = Some(kind); + } + _ => return Err(QueueDecodeError::UnsupportedProperty), + } + } + Ok(Some(CarbonDelay { + date: date.ok_or(QueueDecodeError::InvalidShape)?, + timezone: timezone.ok_or(QueueDecodeError::InvalidShape)?, + timezone_type: timezone_type.ok_or(QueueDecodeError::InvalidShape)?, + })) +} + +pub fn inspect_queue_command(input: &[u8]) -> Result, QueueDecodeError> { + if input.len() > MAX_BYTES { + return Err(QueueDecodeError::TooLarge); + } + let mut reader = Reader { + parser: PhpParser::new(input), + tokens: 0, + }; + let value = reader.value(0)?; + if reader.parser.position() != input.len() { + return Err(QueueDecodeError::Malformed); + } + let Value::Object { class, properties } = value else { + return Err(QueueDecodeError::InvalidShape); + }; + let kind = match class { + WORKFLOW => QueueTaskKind::Workflow, + ACTIVITY => QueueTaskKind::Activity, + TIMER => QueueTaskKind::Timer, + _ => return Err(QueueDecodeError::UnsupportedClass), + }; + let (mut task_id, mut after_commit) = (None, false); + let (mut connection, mut queue, mut delay) = (None, None, None); + for (key, value) in properties { + match key { + b"taskId" => task_id = Some(text(value)?), + b"afterCommit" => { + if !matches!(value, Value::Boolean(true)) { + return Err(QueueDecodeError::InvalidShape); + } + after_commit = true; + } + b"connection" => connection = optional_text(value)?, + b"queue" => queue = optional_text(value)?, + b"delay" => delay = carbon(value)?, + _ => return Err(QueueDecodeError::UnsupportedProperty), + } + } + let task_id = task_id.ok_or(QueueDecodeError::InvalidTaskId)?; + if task_id.is_empty() + || !task_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_-".contains(&byte)) + { + return Err(QueueDecodeError::InvalidTaskId); + } + if !after_commit { + return Err(QueueDecodeError::InvalidShape); + } + Ok(QueueCommand { + kind, + task_id, + connection, + queue, + delay, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn object(class: &str, properties: &[(&str, String)]) -> Vec { + let body = properties + .iter() + .map(|(key, value)| format!("s:{}:\"{}\";{}", key.len(), key, value)) + .collect::(); + format!( + "O:{}:\"{}\":{}:{{{}}}", + class.len(), + class, + properties.len(), + body + ) + .into_bytes() + } + + fn job(properties: &[(&str, String)]) -> Vec { + object("Workflow\\V2\\Jobs\\RunTimerTask", properties) + } + + #[test] + fn published_class_fixtures_preserve_original_identity_queue_and_carbon_fields() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../tests/Fixtures/ServerUpgrade/php-2.8.0-queue-commands.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = + inspect_queue_command(case["command"].as_str().unwrap().as_bytes()).unwrap(); + assert_eq!(result.task_id, case["task_id"].as_str().unwrap()); + match case["kind"].as_str().unwrap() { + "workflow" => assert_eq!(result.kind, QueueTaskKind::Workflow), + "activity" => { + assert_eq!(result.kind, QueueTaskKind::Activity); + assert_eq!(result.connection, Some("database")); + assert_eq!(result.queue, Some("upgradeλ")); + } + "timer" => { + assert_eq!(result.kind, QueueTaskKind::Timer); + assert_eq!( + result.delay, + Some(CarbonDelay { + date: "2026-10-15 07:15:26.289171", + timezone: "Z", + timezone_type: 2 + }) + ); + } + _ => panic!("unexpected fixture kind"), + } + } + } + + #[test] + fn duplicate_and_visibility_aliased_properties_cannot_replace_task_identity() { + let duplicate = job(&[ + ("taskId", "s:1:\"a\";".into()), + ("taskId", "s:1:\"b\";".into()), + ]); + assert_eq!( + inspect_queue_command(&duplicate), + Err(QueueDecodeError::DuplicateProperty) + ); + for key in ["\0*\0taskId", "\0Fake\0taskId"] { + let hidden = job(&[(key, "s:1:\"a\";".into()), ("afterCommit", "b:1;".into())]); + assert_eq!( + inspect_queue_command(&hidden), + Err(QueueDecodeError::UnsupportedProperty) + ); + } + } + + #[test] + fn malformed_lengths_counts_trailing_values_and_every_truncation_are_refused() { + let valid = job(&[ + ("taskId", "s:1:\"a\";".into()), + ("afterCommit", "b:1;".into()), + ]); + for length in 0..valid.len() { + assert!( + inspect_queue_command(&valid[..length]).is_err(), + "prefix {length}" + ); + } + let mut trailing = valid.clone(); + trailing.extend_from_slice(b"N;"); + assert_eq!( + inspect_queue_command(&trailing), + Err(QueueDecodeError::Malformed) + ); + for value in [ + "s:2:\"a\";", + "s:4294967295:\"a\";", + "s:4294967296:\"a\";", + "s:184467440737095516160:\"a\";", + ] { + assert!(inspect_queue_command(&job(&[("taskId", value.into())])).is_err()); + } + let missing = String::from_utf8(valid) + .unwrap() + .replacen(":2:{", ":3:{", 1); + assert!(inspect_queue_command(missing.as_bytes()).is_err()); + } + + #[test] + fn binary_strings_and_overflowing_numbers_are_not_repaired_or_coerced() { + let bytes = job(&[ + ("taskId", "s:1:\"x\";".into()), + ("afterCommit", "b:1;".into()), + ]); + let mut binary = bytes.clone(); + let index = binary + .windows(8) + .position(|value| value == b"s:1:\"x\";") + .unwrap(); + binary[index + 5] = 255; + assert_eq!( + inspect_queue_command(&binary), + Err(QueueDecodeError::InvalidShape) + ); + let wrong_unicode_length = job(&[("queue", "s:8:\"upgradeλ\";".into())]); + assert_eq!( + inspect_queue_command(&wrong_unicode_length), + Err(QueueDecodeError::Malformed) + ); + for number in ["i:184467440737095516160;", "i:-184467440737095516160;"] { + assert_eq!( + inspect_queue_command(&job(&[("delay", number.into())])), + Err(QueueDecodeError::Malformed) + ); + } + } + + #[test] + fn references_custom_objects_unknown_classes_and_unqualified_values_are_refused() { + for reference in ["r:1;", "R:1;"] { + assert_eq!( + inspect_queue_command(&job(&[("delay", reference.into())])), + Err(QueueDecodeError::UnsupportedReference) + ); + } + assert_eq!( + inspect_queue_command(b"C:4:\"Evil\":0:{}"), + Err(QueueDecodeError::UnsupportedCustomObject) + ); + assert_eq!( + inspect_queue_command(&object("Laravel\\SerializableClosure", &[])), + Err(QueueDecodeError::UnsupportedClass) + ); + let nested = String::from_utf8(object("Unknown", &[])).unwrap(); + assert_eq!( + inspect_queue_command(&job(&[("delay", nested)])), + Err(QueueDecodeError::UnsupportedClass) + ); + for value in ["a:0:{}", "d:1.0;"] { + assert_eq!( + inspect_queue_command(&job(&[("delay", value.into())])), + Err(QueueDecodeError::UnsupportedValue) + ); + } + } + + #[test] + fn input_depth_and_declared_collection_sizes_are_bounded() { + assert_eq!( + inspect_queue_command(&vec![b'x'; MAX_BYTES + 1]), + Err(QueueDecodeError::TooLarge) + ); + let oversized = b"O:29:\"Workflow\\V2\\Jobs\\RunTimerTask\":4294967295:{}"; + assert_eq!( + inspect_queue_command(oversized), + Err(QueueDecodeError::TooManyValues) + ); + let mut nested = "N;".to_string(); + for _ in 0..MAX_DEPTH + 2 { + nested = String::from_utf8(object("Illuminate\\Support\\Carbon", &[("date", nested)])) + .unwrap(); + } + assert_eq!( + inspect_queue_command(nested.as_bytes()), + Err(QueueDecodeError::TooDeep) + ); + let child = String::from_utf8(object( + "Illuminate\\Support\\Carbon", + &[ + ("date", "N;".into()), + ("timezone", "N;".into()), + ("timezone_type", "N;".into()), + ], + )) + .unwrap(); + let keys: Vec<_> = (0..32).map(|index| format!("property{index}")).collect(); + let properties: Vec<_> = keys + .iter() + .map(|key| (key.as_str(), child.clone())) + .collect(); + assert_eq!( + inspect_queue_command(&job(&properties)), + Err(QueueDecodeError::TooManyValues) + ); + } + + #[test] + fn root_shape_and_transport_fields_are_not_silently_coerced() { + for properties in [ + vec![("taskId", "s:1:\"a\";".into())], + vec![ + ("taskId", "s:1:\"a\";".into()), + ("afterCommit", "i:1;".into()), + ], + vec![ + ("taskId", "s:1:\"a\";".into()), + ("afterCommit", "b:1;".into()), + ("chained", "s:1:\"x\";".into()), + ], + ] { + assert!(inspect_queue_command(&job(&properties)).is_err()); + } + let null_id = job(&[("taskId", "N;".into()), ("afterCommit", "b:1;".into())]); + assert_eq!( + inspect_queue_command(&null_id), + Err(QueueDecodeError::InvalidShape) + ); + let invalid = job(&[ + ("taskId", "s:1:\"/\";".into()), + ("afterCommit", "b:1;".into()), + ]); + assert_eq!( + inspect_queue_command(&invalid), + Err(QueueDecodeError::InvalidTaskId) + ); + } +} diff --git a/scripts/conformance/server-parity/upgrade-queue-fixtures.php b/scripts/conformance/server-parity/upgrade-queue-fixtures.php new file mode 100644 index 000000000..33321519a --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-queue-fixtures.php @@ -0,0 +1,27 @@ + Workflow\V2\Jobs\RunWorkflowTask::class, + 'activity' => Workflow\V2\Jobs\RunActivityTask::class, + 'timer' => Workflow\V2\Jobs\RunTimerTask::class, +]; +$cases = []; +foreach ($classes as $kind => $class) { + $job = new $class('01m4z62k0n2hhv9nv95kmb1rcr'); + if ($kind === 'activity') { + $job->onConnection('database')->onQueue('upgradeλ'); + } + if ($kind === 'timer') { + $job->delay(Illuminate\Support\Carbon::parse('2026-10-15T07:15:26.289171Z')); + } + $cases[] = ['kind' => $kind, 'task_id' => $job->taskId, 'command' => serialize($job)]; +} +echo json_encode([ + 'source_image' => 'ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac', + 'scope' => 'published_classes_serialized_without_dispatch_or_database_admission', + 'cases' => $cases, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)."\n"; diff --git a/tests/Fixtures/ServerUpgrade/php-2.8.0-queue-commands.json b/tests/Fixtures/ServerUpgrade/php-2.8.0-queue-commands.json new file mode 100644 index 000000000..a11bcbbb8 --- /dev/null +++ b/tests/Fixtures/ServerUpgrade/php-2.8.0-queue-commands.json @@ -0,0 +1,21 @@ +{ + "source_image": "ghcr.io\/durable-workflow\/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac", + "scope": "published_classes_serialized_without_dispatch_or_database_admission", + "cases": [ + { + "kind": "workflow", + "task_id": "01m4z62k0n2hhv9nv95kmb1rcr", + "command": "O:32:\"Workflow\\V2\\Jobs\\RunWorkflowTask\":2:{s:6:\"taskId\";s:26:\"01m4z62k0n2hhv9nv95kmb1rcr\";s:11:\"afterCommit\";b:1;}" + }, + { + "kind": "activity", + "task_id": "01m4z62k0n2hhv9nv95kmb1rcr", + "command": "O:32:\"Workflow\\V2\\Jobs\\RunActivityTask\":4:{s:6:\"taskId\";s:26:\"01m4z62k0n2hhv9nv95kmb1rcr\";s:10:\"connection\";s:8:\"database\";s:5:\"queue\";s:9:\"upgradeλ\";s:11:\"afterCommit\";b:1;}" + }, + { + "kind": "timer", + "task_id": "01m4z62k0n2hhv9nv95kmb1rcr", + "command": "O:29:\"Workflow\\V2\\Jobs\\RunTimerTask\":3:{s:6:\"taskId\";s:26:\"01m4z62k0n2hhv9nv95kmb1rcr\";s:5:\"delay\";O:25:\"Illuminate\\Support\\Carbon\":3:{s:4:\"date\";s:26:\"2026-10-15 07:15:26.289171\";s:13:\"timezone_type\";i:2;s:8:\"timezone\";s:1:\"Z\";}s:11:\"afterCommit\";b:1;}" + } + ] +} From da0525ecba8d72e908a4ad012c523c75fc4446fb Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 08:17:56 +0000 Subject: [PATCH 11/26] Bind published PHP queue jobs to original canonical tasks --- docs/rust-server-port.md | 10 +- docs/rust-server-upgrade.md | 31 +- rust/src/runtime/upgrade.rs | 1 + rust/src/runtime/upgrade/queue_binding.rs | 396 ++++++++++++++++++ .../upgrade-source-queue-fixture.php | 39 ++ .../php-2.8.2-pending-queue.json | 98 +++++ 6 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 rust/src/runtime/upgrade/queue_binding.rs create mode 100644 scripts/conformance/server-parity/upgrade-source-queue-fixture.php create mode 100644 tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index da682fd07..a5fd7dd1f 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -3073,5 +3073,11 @@ V2 job/Carbon subset under explicit byte, depth and token limits. Published class fixtures preserve original task IDs, a Unicode queue and microsecond delay fields. Malformed and unqualified representations are refused. This is an independently tested decoder, not a source-row migration or takeover path. -The next migration gates remain canonical queue/task binding, complete stored -value validation, restored backup verification and actual sequential takeover. +A pure binding validator now checks one genuine published PHP 2.8.2 pending +timer envelope against its original task/run. Twelve focused decoder/binding +controls pass. The source database has Laravel transport queue `default` and +SDK queue `upgrade-source`; its rounded transport wakeup does not replace the +canonical microsecond timer availability. Borrowed source rows retain their +original state and a separate leased activity. Full native/CLI integration, +complete source-row validation, restored backup verification and actual +sequential takeover remain unqualified. diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 592f41c98..1547a1711 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -113,10 +113,33 @@ fields remain original strings, not recalculated task deadlines. The committed queue fixtures are produced by serializing the real classes in the pinned published image without dispatching them. This independently tested -decoder does not yet consume source rows, validate the outer queue envelope or -join a job to canonical task/run state. It grants no conversion, deletion, -execution or takeover authority. Complete native integration and actual -same-database queue qualification remain separate gates. +decoder grants no conversion, deletion, execution or takeover authority. +Complete native integration and actual same-database queue qualification remain +separate gates. + +A separate genuine published PHP 2.8.2 source fixture now contains one queued +timer job, its original task/run, and a separate leased activity. Its outer +Laravel handler, command class and default transport policy are checked before +binding the exact canonical task ID, kind, run and namespace. Unknown or +duplicate envelope fields, unsupported policies and changed identities refuse. +The validator returns borrowed source rows, preserving their status, attempts, +leases and availability without any execution/deletion action. + +The database job uses Laravel queue `default`, while its canonical timer task +uses SDK queue `upgrade-source`. Those are distinct routing domains. The +serialized Carbon wakeup and integer queue availability are rounded up to the +next second, while the canonical timer keeps its original six fractional +digits. A transport wakeup must never replace that deadline. The pure binding +validator checks the parent SDK queue for workflow/timer tasks and the +independent configured transport queue. It does not compare those two queues +for equality. Activity destinations may differ from their parent's queue. + +The fixture is exported read-only after the sole source API exits. This proves +only the recorded source/decoder/binding component. It does not yet scan every +source row, establish full PHP/embedded data compatibility, validate all +timestamp semantics, restore a backup or continue the original workflows in +Rust. `upgrade-inspect` remains the earlier catalog/count inventory and its +takeover flags remain false. The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 3cd6652f3..5bbee932e 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,5 +1,6 @@ //! Read-only PHP source inventory. This command never grants takeover authority. pub mod queue; +pub mod queue_binding; use super::{Result, mysql, postgres, refuse, schema}; use axum::http::StatusCode; use serde::{Deserialize, Serialize, de::DeserializeOwned}; diff --git a/rust/src/runtime/upgrade/queue_binding.rs b/rust/src/runtime/upgrade/queue_binding.rs new file mode 100644 index 000000000..fc1ce7401 --- /dev/null +++ b/rust/src/runtime/upgrade/queue_binding.rs @@ -0,0 +1,396 @@ +//! Bind a known PHP transport job to an existing canonical task and run. +//! The borrowed rows retain their original state. This never authorizes a new +//! claim, deletes a job or replaces a durable deadline with a transport wakeup. +use super::queue::{QueueDecodeError, QueueTaskKind, inspect_queue_command}; +use serde::Deserialize; + +#[derive(Debug, PartialEq, Eq)] +pub enum QueueBindingError { + Decode(QueueDecodeError), + MalformedEnvelope, + UnsupportedEnvelope, + InvalidTransportRow, + InvalidCanonicalState, + TaskIdentity, + TaskKind, + RunIdentity, + Namespace, + WorkerQueue, + TransportQueue, + TransportConnection, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourceQueueJob { + pub id: i64, + pub queue: String, + pub payload: String, + pub attempts: i64, + pub reserved_at: Option, + pub available_at: i64, + pub created_at: i64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourceTask { + pub id: String, + pub workflow_run_id: String, + pub namespace: Option, + pub task_type: String, + pub status: String, + pub connection: Option, + pub queue: Option, + pub available_at: Option, + pub lease_owner: Option, + pub lease_expires_at: Option, + pub attempt_count: i64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourceRun { + pub id: String, + pub workflow_instance_id: String, + pub workflow_class: String, + pub workflow_type: String, + pub namespace: Option, + pub queue: Option, + pub status: String, +} + +pub struct QueueSourceConfig<'a> { + /// The logical database queue connection whose jobs table is being read. + pub connection: &'a str, + pub default_connection: &'a str, + pub default_queue: &'a str, +} + +#[derive(Debug)] +pub struct QueueTaskBinding<'a> { + pub kind: QueueTaskKind, + pub job: &'a SourceQueueJob, + pub task: &'a SourceTask, + pub run: &'a SourceRun, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Envelope { + uuid: String, + display_name: String, + job: String, + max_tries: Option, + max_exceptions: Option, + fail_on_timeout: bool, + count_crashes_as_exceptions: bool, + backoff: Option, + timeout: Option, + retry_until: Option, + delete_when_missing_models: bool, + data: Command, + created_at: i64, + delay: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Command { + command_name: String, + command: String, + batch_id: Option, +} + +fn identifier(value: &str) -> bool { + !value.is_empty() && value.chars().count() <= 255 && !value.contains('\0') +} + +pub fn bind_queue_job<'a>( + job: &'a SourceQueueJob, + task: &'a SourceTask, + run: &'a SourceRun, + config: &QueueSourceConfig<'_>, +) -> Result, QueueBindingError> { + // Check the outer body before allocating/unescaping its PHP command. + if job.payload.len() > 128 * 1024 { + return Err(QueueBindingError::MalformedEnvelope); + } + let envelope: Envelope = + serde_json::from_str(&job.payload).map_err(|_| QueueBindingError::MalformedEnvelope)?; + let command = inspect_queue_command(envelope.data.command.as_bytes()) + .map_err(QueueBindingError::Decode)?; + let (class, kind, tries) = match command.kind { + QueueTaskKind::Workflow => ("Workflow\\V2\\Jobs\\RunWorkflowTask", "workflow", 1), + QueueTaskKind::Activity => ("Workflow\\V2\\Jobs\\RunActivityTask", "activity", 1), + QueueTaskKind::Timer => ("Workflow\\V2\\Jobs\\RunTimerTask", "timer", 5), + }; + if envelope.job != "Illuminate\\Queue\\CallQueuedHandler@call" + || envelope.display_name != class + || envelope.data.command_name != class + || !identifier(&envelope.uuid) + || envelope.max_tries != Some(tries) + || envelope.max_exceptions.is_some() + || envelope.fail_on_timeout + || envelope.count_crashes_as_exceptions + || envelope.backoff.is_some() + || envelope.timeout.is_some() + || envelope.retry_until.is_some() + || envelope.delete_when_missing_models + || envelope.data.batch_id.is_some() + || envelope.created_at < 0 + || envelope.delay.is_some_and(|delay| delay < 0) + { + return Err(QueueBindingError::UnsupportedEnvelope); + } + if job.id <= 0 + || !(0..=255).contains(&job.attempts) + || job.reserved_at.is_some_and(|time| time < 0) + || job.available_at < 0 + || job.created_at < 0 + { + return Err(QueueBindingError::InvalidTransportRow); + } + if task.id != command.task_id { + return Err(QueueBindingError::TaskIdentity); + } + if task.task_type != kind { + return Err(QueueBindingError::TaskKind); + } + if task.workflow_run_id != run.id { + return Err(QueueBindingError::RunIdentity); + } + let namespace = task + .namespace + .as_deref() + .ok_or(QueueBindingError::Namespace)?; + if !identifier(namespace) || run.namespace.as_deref() != Some(namespace) { + return Err(QueueBindingError::Namespace); + } + if !identifier(&run.id) + || !identifier(&run.workflow_instance_id) + || !identifier(&run.workflow_class) + || !identifier(&run.workflow_type) + || !identifier(&run.status) + || !identifier(&task.status) + || task.attempt_count < 0 + { + return Err(QueueBindingError::InvalidCanonicalState); + } + // Workflow/timer tasks return to the parent's SDK queue. An activity may + // target a different SDK queue. Neither is Laravel's transport queue. + if kind != "activity" + && (task.queue.as_deref().is_none_or(|queue| !identifier(queue)) || task.queue != run.queue) + { + return Err(QueueBindingError::WorkerQueue); + } + if !identifier(config.default_queue) + || command.queue.unwrap_or(config.default_queue) != job.queue + { + return Err(QueueBindingError::TransportQueue); + } + if !identifier(config.connection) + || !identifier(config.default_connection) + || command.connection.unwrap_or(config.default_connection) != config.connection + { + return Err(QueueBindingError::TransportConnection); + } + Ok(QueueTaskBinding { + kind: command.kind, + job, + task, + run, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Deserialize)] + struct Source { + jobs: Vec, + tasks: Vec, + runs: Vec, + } + fn source() -> Source { + serde_json::from_str(include_str!( + "../../../../tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json" + )) + .unwrap() + } + fn original(source: &Source) -> (&SourceQueueJob, &SourceTask, &SourceRun) { + let job = &source.jobs[0]; + let task = source + .tasks + .iter() + .find(|task| task.task_type == "timer") + .unwrap(); + let run = source + .runs + .iter() + .find(|run| run.id == task.workflow_run_id) + .unwrap(); + (job, task, run) + } + fn config() -> QueueSourceConfig<'static> { + QueueSourceConfig { + connection: "database", + default_connection: "database", + default_queue: "default", + } + } + + #[test] + fn actual_pending_job_binds_its_original_task_without_rebasing_or_replacing_queues() { + let source = source(); + let (job, task, run) = original(&source); + let bound = bind_queue_job(job, task, run, &config()).unwrap(); + assert_eq!(bound.kind, QueueTaskKind::Timer); + assert!( + std::ptr::eq(bound.task, task) + && std::ptr::eq(bound.run, run) + && std::ptr::eq(bound.job, job) + ); + assert_eq!(bound.job.queue, "default"); + assert_eq!(bound.task.queue.as_deref(), Some("upgrade-source")); + assert_eq!( + bound.task.available_at.as_deref(), + Some("2026-10-15 08:16:21.566122") + ); + assert_eq!(bound.job.available_at, 1792052182); + assert_eq!(bound.task.attempt_count, 0); + assert_eq!(bound.task.status, "ready"); + let leased = source + .tasks + .iter() + .find(|task| task.task_type == "activity") + .unwrap(); + assert_eq!( + leased.lease_expires_at.as_deref(), + Some("2026-10-15 08:11:21.169593") + ); + assert_eq!(leased.attempt_count, 1); + } + + #[test] + fn wrong_task_kind_run_namespace_and_worker_queue_refuse_the_original_job() { + let source = source(); + let (job, original, run) = original(&source); + let mut task = original.clone(); + task.id.push('x'); + assert!(matches!( + bind_queue_job(job, &task, run, &config()), + Err(QueueBindingError::TaskIdentity) + )); + task = original.clone(); + task.task_type = "activity".into(); + assert!(matches!( + bind_queue_job(job, &task, run, &config()), + Err(QueueBindingError::TaskKind) + )); + task = original.clone(); + task.workflow_run_id.push('x'); + assert!(matches!( + bind_queue_job(job, &task, run, &config()), + Err(QueueBindingError::RunIdentity) + )); + task = original.clone(); + task.namespace = Some("foreign".into()); + assert!(matches!( + bind_queue_job(job, &task, run, &config()), + Err(QueueBindingError::Namespace) + )); + task = original.clone(); + task.queue = Some("foreign".into()); + assert!(matches!( + bind_queue_job(job, &task, run, &config()), + Err(QueueBindingError::WorkerQueue) + )); + } + + #[test] + fn outer_handler_names_policy_and_transport_scope_cannot_fabricate_a_binding() { + let source = source(); + let (original, task, run) = original(&source); + for (key, value) in [ + ("job", serde_json::json!("Unknown@call")), + ("displayName", serde_json::json!("Unknown")), + ("maxTries", serde_json::json!(99)), + ("backoff", serde_json::json!([1, 2])), + ("unexpected", serde_json::json!(true)), + ] { + let mut body: serde_json::Value = serde_json::from_str(&original.payload).unwrap(); + body[key] = value; + let mut job = original.clone(); + job.payload = body.to_string(); + assert!(bind_queue_job(&job, task, run, &config()).is_err(), "{key}"); + } + let mut job = original.clone(); + job.queue = "foreign".into(); + assert!(matches!( + bind_queue_job(&job, task, run, &config()), + Err(QueueBindingError::TransportQueue) + )); + let other = QueueSourceConfig { + connection: "redis", + ..config() + }; + assert!(matches!( + bind_queue_job(original, task, run, &other), + Err(QueueBindingError::TransportConnection) + )); + } + + #[test] + fn duplicate_json_fields_and_oversized_outer_bodies_are_refused() { + let source = source(); + let (original, task, run) = original(&source); + let mut job = original.clone(); + job.payload = original + .payload + .replacen("{", "{\"job\":\"Unknown@call\",", 1); + assert!(matches!( + bind_queue_job(&job, task, run, &config()), + Err(QueueBindingError::MalformedEnvelope) + )); + job.payload = original + .payload + .replace("\"data\":{", "\"data\":{\"command\":\"N;\","); + assert!(matches!( + bind_queue_job(&job, task, run, &config()), + Err(QueueBindingError::MalformedEnvelope) + )); + job.payload = "x".repeat(128 * 1024 + 1); + assert!(matches!( + bind_queue_job(&job, task, run, &config()), + Err(QueueBindingError::MalformedEnvelope) + )); + } + + #[test] + fn absent_canonical_identity_and_namespace_are_not_filled_from_transport_metadata() { + let source = source(); + let (job, original_task, original_run) = original(&source); + let mut task = original_task.clone(); + let mut run = original_run.clone(); + task.workflow_run_id.clear(); + run.id.clear(); + assert!(matches!( + bind_queue_job(job, &task, &run, &config()), + Err(QueueBindingError::InvalidCanonicalState) + )); + task = original_task.clone(); + task.namespace = None; + assert!(matches!( + bind_queue_job(job, &task, original_run, &config()), + Err(QueueBindingError::Namespace) + )); + task = original_task.clone(); + task.attempt_count = -1; + assert!(matches!( + bind_queue_job(job, &task, original_run, &config()), + Err(QueueBindingError::InvalidCanonicalState) + )); + } +} diff --git a/scripts/conformance/server-parity/upgrade-source-queue-fixture.php b/scripts/conformance/server-parity/upgrade-source-queue-fixture.php new file mode 100644 index 000000000..35a42bbb6 --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-queue-fixture.php @@ -0,0 +1,39 @@ + PDO::ERRMODE_EXCEPTION]); +$pdo->exec('PRAGMA query_only=ON'); +if ($pdo->query('PRAGMA journal_mode')->fetchColumn() !== 'delete') { + throw new RuntimeException('Expected the stopped DELETE-journal fixture'); +} +$runs = $pdo->query('SELECT id,workflow_instance_id,workflow_class,workflow_type,namespace,queue,status FROM workflow_runs ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); +$ids = array_column($runs, 'workflow_instance_id'); +sort($ids); +if ($ids !== ['upgrade-source-activity', 'upgrade-source-timer']) { + throw new RuntimeException('Refusing unrelated application data'); +} +$jobs = $pdo->query('SELECT id,queue,payload,attempts,reserved_at,available_at,created_at FROM jobs ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); +$tasks = $pdo->query('SELECT id,workflow_run_id,namespace,task_type,status,connection,queue,available_at,lease_owner,lease_expires_at,attempt_count FROM workflow_tasks ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); +if (count($jobs) !== 1 || count($tasks) !== 4) { + throw new RuntimeException('Original pending source differs'); +} +$pdo = null; +if (hash_file('sha256', $database) !== $before) { + throw new RuntimeException('Source database changed'); +} +echo json_encode([ + 'scope' => 'genuine_published_php_pending_queue_and_canonical_tasks', + 'source_image' => $image, + 'database_sha256' => $before, + 'configuration' => ['connection' => 'database', 'default_connection' => 'database', 'default_queue' => 'default'], + 'jobs' => $jobs, 'tasks' => $tasks, 'runs' => $runs, + 'source_mutated' => false, 'php_objects_executed' => false, 'takeover_allowed' => false, +], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)."\n"; diff --git a/tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json b/tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json new file mode 100644 index 000000000..4e2192bc7 --- /dev/null +++ b/tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json @@ -0,0 +1,98 @@ +{ + "scope": "genuine_published_php_pending_queue_and_canonical_tasks", + "source_image": "ghcr.io\/durable-workflow\/server@sha256:91a5bb87dbf280c1966693af3eb1d13eac6912d88fd82ae2095f05830a38dab0", + "database_sha256": "d0e6b4697bc58e7d3770a8d8beb39b01111d25614855002de18795ef41411cdc", + "configuration": { + "connection": "database", + "default_connection": "database", + "default_queue": "default" + }, + "jobs": [ + { + "id": 1, + "queue": "default", + "payload": "{\"uuid\":\"6087e1ba-a850-4c05-9038-b8e0b8cea1e9\",\"displayName\":\"Workflow\\\\V2\\\\Jobs\\\\RunTimerTask\",\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"maxTries\":5,\"maxExceptions\":null,\"failOnTimeout\":false,\"countCrashesAsExceptions\":false,\"backoff\":null,\"timeout\":null,\"retryUntil\":null,\"deleteWhenMissingModels\":false,\"data\":{\"commandName\":\"Workflow\\\\V2\\\\Jobs\\\\RunTimerTask\",\"command\":\"O:29:\\\"Workflow\\\\V2\\\\Jobs\\\\RunTimerTask\\\":3:{s:6:\\\"taskId\\\";s:26:\\\"01m4z9j4m1xzdhhsz81n2jck0s\\\";s:5:\\\"delay\\\";O:25:\\\"Illuminate\\\\Support\\\\Carbon\\\":3:{s:4:\\\"date\\\";s:26:\\\"2026-10-15 08:16:22.000000\\\";s:13:\\\"timezone_type\\\";i:3;s:8:\\\"timezone\\\";s:3:\\\"UTC\\\";}s:11:\\\"afterCommit\\\";b:1;}\",\"batchId\":null},\"createdAt\":1792051581,\"delay\":601}", + "attempts": 0, + "reserved_at": null, + "available_at": 1792052182, + "created_at": 1792051581 + } + ], + "tasks": [ + { + "id": "01m4z9j3t31s1671kvkym0bb31", + "workflow_run_id": "01m4z9j3s6txy344xderc5qd3y", + "namespace": "default", + "task_type": "workflow", + "status": "completed", + "connection": null, + "queue": "upgrade-source", + "available_at": "2026-10-15 08:06:20.739318", + "lease_owner": "upgrade-source-worker", + "lease_expires_at": null, + "attempt_count": 1 + }, + { + "id": "01m4z9j430njna84p73hcca0jg", + "workflow_run_id": "01m4z9j3s6txy344xderc5qd3y", + "namespace": "default", + "task_type": "activity", + "status": "leased", + "connection": null, + "queue": "upgrade-source", + "available_at": "2026-10-15 08:06:21.023789", + "lease_owner": "upgrade-source-worker", + "lease_expires_at": "2026-10-15 08:11:21.169593", + "attempt_count": 1 + }, + { + "id": "01m4z9j4c4w5nbt2sa4r8tdzhg", + "workflow_run_id": "01m4z9j4bv8563btw4n0pnxraa", + "namespace": "default", + "task_type": "workflow", + "status": "completed", + "connection": null, + "queue": "upgrade-source", + "available_at": "2026-10-15 08:06:21.316700", + "lease_owner": "upgrade-source-worker", + "lease_expires_at": null, + "attempt_count": 1 + }, + { + "id": "01m4z9j4m1xzdhhsz81n2jck0s", + "workflow_run_id": "01m4z9j4bv8563btw4n0pnxraa", + "namespace": "default", + "task_type": "timer", + "status": "ready", + "connection": null, + "queue": "upgrade-source", + "available_at": "2026-10-15 08:16:21.566122", + "lease_owner": null, + "lease_expires_at": null, + "attempt_count": 0 + } + ], + "runs": [ + { + "id": "01m4z9j3s6txy344xderc5qd3y", + "workflow_instance_id": "upgrade-source-activity", + "workflow_class": "parity.upgrade_activity", + "workflow_type": "parity.upgrade_activity", + "namespace": "default", + "queue": "upgrade-source", + "status": "waiting" + }, + { + "id": "01m4z9j4bv8563btw4n0pnxraa", + "workflow_instance_id": "upgrade-source-timer", + "workflow_class": "parity.upgrade_timer", + "workflow_type": "parity.upgrade_timer", + "namespace": "default", + "queue": "upgrade-source", + "status": "waiting" + } + ], + "source_mutated": false, + "php_objects_executed": false, + "takeover_allowed": false +} From 7c8015507b36256edc16f5cdb5a2884ef6dfbdc0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 08:27:42 +0000 Subject: [PATCH 12/26] Inspect database queue bindings in a read-only source snapshot --- docs/rust-server-port.md | 14 +- docs/rust-server-upgrade.md | 24 +++ rust/src/main.rs | 62 +++++-- rust/src/runtime/upgrade.rs | 90 +++++++++- rust/src/runtime/upgrade/queue_binding.rs | 30 +++- rust/src/runtime/upgrade/queue_scan.rs | 127 ++++++++++++++ rust/src/runtime/upgrade_tests.rs | 158 ++++++++++++++++++ rust/tests/upgrade_inspect.rs | 12 +- .../server-parity/upgrade-source-profile.sh | 10 +- 9 files changed, 495 insertions(+), 32 deletions(-) create mode 100644 rust/src/runtime/upgrade/queue_scan.rs diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index a5fd7dd1f..dacfc10b0 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -3078,6 +3078,14 @@ timer envelope against its original task/run. Twelve focused decoder/binding controls pass. The source database has Laravel transport queue `default` and SDK queue `upgrade-source`; its rounded transport wakeup does not replace the canonical microsecond timer availability. Borrowed source rows retain their -original state and a separate leased activity. Full native/CLI integration, -complete source-row validation, restored backup verification and actual -sequential takeover remain unqualified. +original state and a separate leased activity. + +The separate `upgrade-queue-inspect` command now scans every database-queue row +inside the existing read-only observation. Bounded pages and transfer limits +precede canonical joins. Unknown representations, orphaned/foreign bindings +and skipped nonpositive IDs refuse before a success report. Local native checks +pass 93 library cases with 18 existing database/subprocess ignores and the +process refusal control for both inspection commands. Actual published-source +CLI and fresh all-database integration remain pending. Complete stored-state +validation, restored backup verification and sequential takeover stay open. + diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 1547a1711..e3c326784 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -141,6 +141,30 @@ timestamp semantics, restore a backup or continue the original workflows in Rust. `upgrade-inspect` remains the earlier catalog/count inventory and its takeover flags remain false. +The separate development command `upgrade-queue-inspect` adds database-queue +binding checks inside that same read-only snapshot and 30-second observation +budget. Set all three original transport settings explicitly: + +```sh +DW_RUST_EXPERIMENTAL=1 \ +DW_RUST_UPGRADE_QUEUE_CONNECTION=database \ +DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION=database \ +DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE=default \ +durable-workflow-server upgrade-queue-inspect +``` + +These settings describe the original Laravel connection and defaults, not a +new SDK destination. Ordinary `DB_*` settings still select the source database. +The command reads 32 jobs per primary-key page and limits each transferred +payload to 128 KiB before decoding. Every job must bind its existing canonical +task and run; unknown representations, missing/changed bindings and count +disagreements refuse without a partial success report. The output adds only +job-kind counts, not payloads or identities. No jobs are deleted or executed, +and all takeover, backup, quiescence and complete representation flags remain +false. An empty database queue does not establish the state of Redis, another +queue connection or another transport. The hosted published-source recipe now +requires this separate component; its fresh all-database execution is pending. + The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before inspection, compares source state and sequence positions before/after, and diff --git a/rust/src/main.rs b/rust/src/main.rs index 93f048d98..5091af264 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -127,30 +127,64 @@ async fn main() -> Result<(), Box> { if env::args().count() != 2 || !matches!( command.as_str(), - "schema-bootstrap" | "schema-check" | "upgrade-inspect" + "schema-bootstrap" | "schema-check" | "upgrade-inspect" | "upgrade-queue-inspect" ) { return Err("unknown development command".into()); } let backend = env::var("DB_CONNECTION").unwrap_or_default(); - if command == "upgrade-inspect" { + if matches!( + command.as_str(), + "upgrade-inspect" | "upgrade-queue-inspect" + ) { + use durable_workflow_server::runtime::upgrade::{ + self, queue_binding::QueueSourceConfig, + }; + // These describe the original Laravel queue transport, not SDK + // task queues or a newly selected native execution destination. + let queue_options = if command == "upgrade-queue-inspect" { + Some(( + env::var("DW_RUST_UPGRADE_QUEUE_CONNECTION") + .map_err(|_| "original queue connection is required")?, + env::var("DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION") + .map_err(|_| "original default queue connection is required")?, + env::var("DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE") + .map_err(|_| "original default transport queue is required")?, + )) + } else { + None + }; + let queues = + queue_options + .as_ref() + .map( + |(connection, default_connection, default_queue)| QueueSourceConfig { + connection, + default_connection, + default_queue, + }, + ); let result = match backend.as_str() { "sqlite" => { - durable_workflow_server::runtime::upgrade::inspect_sqlite( - &env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?, - ) - .await - } - "mysql" | "mariadb" => { - durable_workflow_server::runtime::upgrade::inspect_mysql(mysql_options()?).await - } - "pgsql" => { - durable_workflow_server::runtime::upgrade::inspect_postgres(postgres_options()?) - .await + let path = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; + match queues.as_ref() { + Some(config) => upgrade::inspect_sqlite_queues(&path, config).await, + None => upgrade::inspect_sqlite(&path).await, + } } + "mysql" | "mariadb" => match queues.as_ref() { + Some(config) => upgrade::inspect_mysql_queues(mysql_options()?, config).await, + None => upgrade::inspect_mysql(mysql_options()?).await, + }, + "pgsql" => match queues.as_ref() { + Some(config) => { + upgrade::inspect_postgres_queues(postgres_options()?, config).await + } + None => upgrade::inspect_postgres(postgres_options()?).await, + }, _ => { return Err( - "upgrade-inspect requires DB_CONNECTION=sqlite, pgsql, mysql or mariadb" + "upgrade inspection requires DB_CONNECTION=sqlite, pgsql, mysql or mariadb" .into(), ); } diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 5bbee932e..241f4dce5 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,8 +1,10 @@ //! Read-only PHP source inventory. This command never grants takeover authority. pub mod queue; pub mod queue_binding; +mod queue_scan; use super::{Result, mysql, postgres, refuse, schema}; use axum::http::StatusCode; +use queue_binding::QueueSourceConfig; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -93,18 +95,37 @@ async fn close(future: impl Future Ok(()) } -fn report(backend: &str, catalog: &impl Serialize, counts: BTreeMap) -> Result { +fn report( + backend: &str, + catalog: &impl Serialize, + counts: BTreeMap, + queue_inspection: Option, +) -> Result { let digest = Sha256::digest(serde_json::to_vec(catalog)?) .iter() .map(|b| format!("{b:02x}")) .collect::(); - Ok(json!({ + let mut report = json!({ "schema":"durable-workflow.rust-upgrade-source-inspection.v1", "status":"inspected", "backend":backend, "catalog_visibility":"configured_role", "catalog_sha256":digest, "table_rows":counts, "source_mutated":false, "takeover_allowed":false, "backup_verified":false, "php_quiescence_verified":false, "stored_representation_compatibility_verified":false - })) + }); + if let Some(queues) = queue_inspection { + report["queue_inspection"] = queues; + } + Ok(report) +} + +fn queue_config(config: Option<&QueueSourceConfig<'_>>) -> Result<()> { + if config.is_some_and(|config| !queue_binding::valid_config(config)) { + return Err(refuse( + StatusCode::BAD_REQUEST, + "upgrade_source_queue_config_required", + )); + } + Ok(()) } // Names come only from a catalog equal to the compiled physical PHP layout. @@ -136,6 +157,15 @@ macro_rules! count_tables { } pub async fn inspect_sqlite(path: &str) -> Result { + inspect_sqlite_inner(path, None).await +} + +pub async fn inspect_sqlite_queues(path: &str, config: &QueueSourceConfig<'_>) -> Result { + inspect_sqlite_inner(path, Some(config)).await +} + +async fn inspect_sqlite_inner(path: &str, queues: Option<&QueueSourceConfig<'_>>) -> Result { + queue_config(queues)?; let deadline = Instant::now() + BUDGET; if path.is_empty() || path.contains(['?', ':']) { return Err(refuse( @@ -208,8 +238,14 @@ pub async fn inspect_sqlite(path: &str) -> Result { .map(|(_, name, _, _)| name.clone()) .collect(); let counts = count_tables!(tx, tables, '"'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + None => None, + }; tx.rollback().await?; - report("sqlite", &catalog, counts) + report("sqlite", &catalog, counts, queue_inspection) }) .await .map_err(|_| expired()) @@ -219,6 +255,21 @@ pub async fn inspect_sqlite(path: &str) -> Result { } pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { + inspect_mysql_inner(options, None).await +} + +pub async fn inspect_mysql_queues( + options: MySqlConnectOptions, + config: &QueueSourceConfig<'_>, +) -> Result { + inspect_mysql_inner(options, Some(config)).await +} + +async fn inspect_mysql_inner( + options: MySqlConnectOptions, + queues: Option<&QueueSourceConfig<'_>>, +) -> Result { + queue_config(queues)?; let deadline = Instant::now() + BUDGET; let mut connection = timeout_at( deadline, @@ -245,6 +296,12 @@ pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { let catalog = mysql::php_source_catalog(&mut tx).await?; let tables: Vec = catalog.iter().map(|(name, _)| name.clone()).collect(); let counts = count_tables!(tx, tables, '`'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + None => None, + }; // MySQL's metadata is not an MVCC snapshot. Refuse a changed visible // catalog rather than joining counts to a different schema. if mysql::php_source_catalog(&mut tx).await? != catalog { @@ -254,7 +311,7 @@ pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { )); } tx.rollback().await?; - report(backend, &catalog, counts) + report(backend, &catalog, counts, queue_inspection) }) .await .map_err(|_| expired()) @@ -264,6 +321,21 @@ pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { } pub async fn inspect_postgres(options: PgConnectOptions) -> Result { + inspect_postgres_inner(options, None).await +} + +pub async fn inspect_postgres_queues( + options: PgConnectOptions, + config: &QueueSourceConfig<'_>, +) -> Result { + inspect_postgres_inner(options, Some(config)).await +} + +async fn inspect_postgres_inner( + options: PgConnectOptions, + queues: Option<&QueueSourceConfig<'_>>, +) -> Result { + queue_config(queues)?; let deadline = Instant::now() + BUDGET; let mut connection = timeout_at( deadline, @@ -282,8 +354,14 @@ pub async fn inspect_postgres(options: PgConnectOptions) -> Result { .map(|(_, name, _)| name.clone()) .collect(); let counts = count_tables!(tx, tables, '"'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + None => None, + }; tx.rollback().await?; - report("pgsql", &catalog, counts) + report("pgsql", &catalog, counts, queue_inspection) }) .await .map_err(|_| expired()) diff --git a/rust/src/runtime/upgrade/queue_binding.rs b/rust/src/runtime/upgrade/queue_binding.rs index fc1ce7401..6079e7faf 100644 --- a/rust/src/runtime/upgrade/queue_binding.rs +++ b/rust/src/runtime/upgrade/queue_binding.rs @@ -106,18 +106,36 @@ fn identifier(value: &str) -> bool { !value.is_empty() && value.chars().count() <= 255 && !value.contains('\0') } +fn envelope(job: &SourceQueueJob) -> Result { + // Check the outer body before allocating/unescaping its PHP command. + if job.payload.len() > 128 * 1024 { + return Err(QueueBindingError::MalformedEnvelope); + } + serde_json::from_str(&job.payload).map_err(|_| QueueBindingError::MalformedEnvelope) +} + +/// Locate the canonical row without executing the transport command. This +/// alone does not validate the outer policy or authorize a binding. +pub(super) fn referenced_task_id(job: &SourceQueueJob) -> Result { + let envelope = envelope(job)?; + let command = inspect_queue_command(envelope.data.command.as_bytes()) + .map_err(QueueBindingError::Decode)?; + Ok(command.task_id.to_owned()) +} + +pub(super) fn valid_config(config: &QueueSourceConfig<'_>) -> bool { + identifier(config.connection) + && identifier(config.default_connection) + && identifier(config.default_queue) +} + pub fn bind_queue_job<'a>( job: &'a SourceQueueJob, task: &'a SourceTask, run: &'a SourceRun, config: &QueueSourceConfig<'_>, ) -> Result, QueueBindingError> { - // Check the outer body before allocating/unescaping its PHP command. - if job.payload.len() > 128 * 1024 { - return Err(QueueBindingError::MalformedEnvelope); - } - let envelope: Envelope = - serde_json::from_str(&job.payload).map_err(|_| QueueBindingError::MalformedEnvelope)?; + let envelope = envelope(job)?; let command = inspect_queue_command(envelope.data.command.as_bytes()) .map_err(QueueBindingError::Decode)?; let (class, kind, tries) = match command.kind { diff --git a/rust/src/runtime/upgrade/queue_scan.rs b/rust/src/runtime/upgrade/queue_scan.rs new file mode 100644 index 000000000..6946031a7 --- /dev/null +++ b/rust/src/runtime/upgrade/queue_scan.rs @@ -0,0 +1,127 @@ +//! Inspect every database-queue row inside the caller's read-only snapshot. +//! Unknown jobs refuse. No queue row is removed and no task is claimed. +use super::queue::QueueTaskKind; +use super::queue_binding::{ + QueueSourceConfig, SourceQueueJob, SourceRun, SourceTask, bind_queue_job, referenced_task_id, + valid_config, +}; +use crate::runtime::{Result, backend::Backend, refuse}; +use axum::http::StatusCode; +use serde_json::{Value, json}; +use sqlx::{AssertSqlSafe, Encode, Executor, IntoArguments, Type}; + +fn refused(reason: &'static str) -> crate::runtime::RuntimeError { + refuse(StatusCode::CONFLICT, reason) +} + +fn query(sql: &'static str) -> sqlx::query::Query<'static, DB, DB::Arguments> +where + DB::Arguments: IntoArguments, +{ + sqlx::query(AssertSqlSafe(DB::statement(sql))) +} + +pub(super) async fn inspect( + connection: &mut DB::Connection, + config: &QueueSourceConfig<'_>, + expected_jobs: i64, +) -> Result +where + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> &'q str: Encode<'q, DB> + Type, + for<'q> i64: Encode<'q, DB> + Type, +{ + if !valid_config(config) { + return Err(refused("upgrade_source_queue_config_required")); + } + // Bound transferred bodies before driver allocation. Each page has at + // most 32 * 128KiB payload bytes. The enclosing observation deadline caps + // total work; the primary-key cursor never changes source row order. + let page_sql = if DB::NAME == "SQLite" { + "SELECT id,queue,CASE WHEN length(CAST(payload AS BLOB))<=131072 THEN payload ELSE NULL END AS payload,attempts,reserved_at,available_at,created_at FROM jobs WHERE id>$1 ORDER BY id LIMIT 32" + } else { + "SELECT id,queue,CASE WHEN octet_length(payload)<=131072 THEN payload ELSE NULL END AS payload,attempts,reserved_at,available_at,created_at FROM jobs WHERE id>$1 ORDER BY id LIMIT 32" + }; + let (mut cursor, mut jobs, mut workflow, mut activity, mut timer) = (0, 0_i64, 0, 0, 0); + loop { + let page = query::(page_sql) + .bind(cursor) + .fetch_all(&mut *connection) + .await?; + if page.is_empty() { + break; + } + for row in page { + let job = SourceQueueJob { + id: DB::number(&row, "id")?, + queue: DB::string(&row, "queue")?, + payload: DB::optional_string(&row, "payload")? + .ok_or_else(|| refused("upgrade_source_queue_representation_unqualified"))?, + attempts: DB::number(&row, "attempts")?, + reserved_at: DB::optional_number(&row, "reserved_at")?, + available_at: DB::number(&row, "available_at")?, + created_at: DB::number(&row, "created_at")?, + }; + let task_id = referenced_task_id(&job) + .map_err(|_| refused("upgrade_source_queue_representation_unqualified"))?; + let task = query::("SELECT id,workflow_run_id,namespace,task_type,status,connection,queue,available_at,lease_owner,lease_expires_at,attempt_count FROM workflow_tasks WHERE id=$1") + .bind(task_id.as_str()).fetch_optional(&mut *connection).await? + .ok_or_else(|| refused("upgrade_source_queue_task_missing"))?; + let task = SourceTask { + id: DB::string(&task, "id")?, + workflow_run_id: DB::string(&task, "workflow_run_id")?, + namespace: DB::optional_string(&task, "namespace")?, + task_type: DB::string(&task, "task_type")?, + status: DB::string(&task, "status")?, + connection: DB::optional_string(&task, "connection")?, + queue: DB::optional_string(&task, "queue")?, + available_at: original_time::(&task, "available_at")?, + lease_owner: DB::optional_string(&task, "lease_owner")?, + lease_expires_at: original_time::(&task, "lease_expires_at")?, + attempt_count: DB::number(&task, "attempt_count")?, + }; + let run = query::("SELECT id,workflow_instance_id,workflow_class,workflow_type,namespace,queue,status FROM workflow_runs WHERE id=$1") + .bind(task.workflow_run_id.as_str()).fetch_optional(&mut *connection).await? + .ok_or_else(|| refused("upgrade_source_queue_run_missing"))?; + let run = SourceRun { + id: DB::string(&run, "id")?, + workflow_instance_id: DB::string(&run, "workflow_instance_id")?, + workflow_class: DB::string(&run, "workflow_class")?, + workflow_type: DB::string(&run, "workflow_type")?, + namespace: DB::optional_string(&run, "namespace")?, + queue: DB::optional_string(&run, "queue")?, + status: DB::string(&run, "status")?, + }; + let bound = bind_queue_job(&job, &task, &run, config) + .map_err(|_| refused("upgrade_source_queue_binding_unqualified"))?; + match bound.kind { + QueueTaskKind::Workflow => workflow += 1, + QueueTaskKind::Activity => activity += 1, + QueueTaskKind::Timer => timer += 1, + } + cursor = job.id; + jobs += 1; + } + } + // A malformed zero/negative ID must not disappear behind the cursor. + if jobs != expected_jobs { + return Err(refused("upgrade_source_queue_binding_unqualified")); + } + Ok(json!({ + "status":"known_subset_bound", "jobs":jobs, + "workflow_jobs":workflow, "activity_jobs":activity, "timer_jobs":timer, + "source_mutated":false, "takeover_allowed":false + })) +} + +fn original_time(row: &DB::Row, field: &str) -> Result> { + // SQLite text remains byte-for-byte original. Network timestamp columns + // are decoded by their actual SQL type, with original UTC microseconds. + // Neither form is emitted or written back to the source database. + if DB::NAME == "SQLite" { + return DB::optional_string(row, field); + } + Ok(DB::optional_instant(row, field)? + .map(|time| time.format("%Y-%m-%d %H:%M:%S%.6f").to_string())) +} diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index aa1df95b4..789248ba5 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -53,6 +53,164 @@ async fn php_layout() -> (tempfile::TempDir, String) { (directory, path) } +fn queue_config() -> QueueSourceConfig<'static> { + QueueSourceConfig { + connection: "database", + default_connection: "database", + default_queue: "default", + } +} + +async fn queued_layout(job_count: i64) -> (tempfile::TempDir, String) { + use super::queue_binding::{SourceQueueJob, SourceRun, SourceTask}; + #[derive(Deserialize)] + struct Fixture { + jobs: Vec, + tasks: Vec, + runs: Vec, + } + // Reconstruct the selected synthetic rows for scanner controls. The + // committed JSON has a genuine PHP producer; this SQL layout is a unit + // control, not a new actual PHP installation or takeover. + let fixture: Fixture = serde_json::from_str(include_str!( + "../../../tests/Fixtures/ServerUpgrade/php-2.8.2-pending-queue.json" + )) + .unwrap(); + let (directory, path) = php_layout().await; + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + connection.execute("DELETE FROM jobs").await.unwrap(); + for run in fixture.runs { + sqlx::query("INSERT INTO workflow_runs(id,workflow_instance_id,run_number,workflow_class,workflow_type,namespace,queue,status) VALUES (?,?,1,?,?,?,?,?)") + .bind(run.id).bind(run.workflow_instance_id).bind(run.workflow_class).bind(run.workflow_type) + .bind(run.namespace).bind(run.queue).bind(run.status).execute(&mut connection).await.unwrap(); + } + for task in fixture.tasks { + sqlx::query("INSERT INTO workflow_tasks(id,workflow_run_id,namespace,task_type,status,connection,queue,available_at,lease_owner,lease_expires_at,attempt_count) VALUES (?,?,?,?,?,?,?,?,?,?,?)") + .bind(task.id).bind(task.workflow_run_id).bind(task.namespace).bind(task.task_type).bind(task.status) + .bind(task.connection).bind(task.queue).bind(task.available_at).bind(task.lease_owner) + .bind(task.lease_expires_at).bind(task.attempt_count).execute(&mut connection).await.unwrap(); + } + let job = &fixture.jobs[0]; + for id in 1..=job_count { + sqlx::query("INSERT INTO jobs(id,queue,payload,attempts,reserved_at,available_at,created_at) VALUES (?,?,?,?,?,?,?)") + .bind(id).bind(&job.queue).bind(&job.payload).bind(job.attempts).bind(job.reserved_at) + .bind(job.available_at).bind(job.created_at).execute(&mut connection).await.unwrap(); + } + connection.close().await.unwrap(); + (directory, path) +} + +#[tokio::test] +async fn queue_scan_checks_every_page_without_mutating_original_tasks_or_jobs() { + let (_directory, path) = queued_layout(33).await; + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_queues(&path, &queue_config()).await.unwrap(); + assert_eq!(report["queue_inspection"]["jobs"], 33); + assert_eq!(report["queue_inspection"]["timer_jobs"], 33); + assert_eq!(report["queue_inspection"]["workflow_jobs"], 0); + assert_eq!(report["table_rows"]["workflow_tasks"], 4); + assert_eq!(report["takeover_allowed"], false); + assert_eq!( + report["stored_representation_compatibility_verified"], + false + ); + assert!(!report.to_string().contains("08:16:21.566122")); + assert!(!report.to_string().contains("RunTimerTask")); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[tokio::test] +async fn queue_scan_refuses_unknown_jobs_after_the_first_page_without_partial_success() { + let (_directory, path) = queued_layout(33).await; + mutate(&path, "INSERT INTO jobs(id,queue,payload,attempts,available_at,created_at) VALUES (34,'default','private-unknown-job',0,1,1)").await; + let before = std::fs::read(&path).unwrap(); + reason( + inspect_sqlite_queues(&path, &queue_config()).await, + "upgrade_source_queue_representation_unqualified", + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; +} + +#[tokio::test] +async fn queue_scan_refuses_missing_or_foreign_canonical_bindings() { + for (statement, expected) in [ + ( + "DELETE FROM workflow_tasks WHERE task_type='timer'", + "upgrade_source_queue_task_missing", + ), + ( + "DELETE FROM workflow_runs WHERE workflow_type='parity.upgrade_timer'", + "upgrade_source_queue_run_missing", + ), + ( + "UPDATE workflow_tasks SET namespace='foreign' WHERE task_type='timer'", + "upgrade_source_queue_binding_unqualified", + ), + ( + "UPDATE jobs SET queue='foreign'", + "upgrade_source_queue_binding_unqualified", + ), + ] { + let (_directory, path) = queued_layout(1).await; + mutate(&path, statement).await; + let before = std::fs::read(&path).unwrap(); + reason( + inspect_sqlite_queues(&path, &queue_config()).await, + expected, + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + } +} + +#[tokio::test] +async fn queue_scan_cannot_skip_nonpositive_ids_or_transfer_oversized_payloads() { + for (statement, expected) in [ + ( + "INSERT INTO jobs(id,queue,payload,attempts,available_at,created_at) VALUES (0,'default','unread',0,1,1)", + "upgrade_source_queue_binding_unqualified", + ), + ( + "UPDATE jobs SET payload=CAST(zeroblob(131073) AS TEXT)", + "upgrade_source_queue_representation_unqualified", + ), + ] { + let (_directory, path) = queued_layout(1).await; + mutate(&path, statement).await; + let before = std::fs::read(&path).unwrap(); + reason( + inspect_sqlite_queues(&path, &queue_config()).await, + expected, + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + } +} + +#[tokio::test] +async fn queue_scan_requires_explicit_transport_context_before_opening_source() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing.sqlite"); + let invalid = QueueSourceConfig { + default_queue: "", + ..queue_config() + }; + reason( + inspect_sqlite_queues(path.to_str().unwrap(), &invalid).await, + "upgrade_source_queue_config_required", + ); + assert!(!path.exists()); +} + async fn mutate(path: &str, statement: &'static str) { let mut connection = SqliteConnection::connect_with( &SqliteConnectOptions::new() diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 45e70d572..231261585 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -17,13 +17,21 @@ fn special_sqlite_sources_refuse_without_waiting_for_a_writer() { // The path belongs to this test's new private temporary directory. assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); - for source in [fifo.as_path(), directory.path()] { + for (source, command) in [ + (fifo.as_path(), "upgrade-inspect"), + (directory.path(), "upgrade-inspect"), + (fifo.as_path(), "upgrade-queue-inspect"), + (directory.path(), "upgrade-queue-inspect"), + ] { let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) .env_clear() .env("DW_RUST_EXPERIMENTAL", "1") .env("DB_CONNECTION", "sqlite") .env("DB_DATABASE", source) - .arg("upgrade-inspect") + .env("DW_RUST_UPGRADE_QUEUE_CONNECTION", "database") + .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION", "database") + .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE", "default") + .arg(command) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index 20088e57b..c5f51006a 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -89,8 +89,10 @@ if test "$backend" = sqlite; then reader=("${admin[@]}"); fi inspect_source() { docker run --rm --name "$resource-inspect" --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network "$RESOURCE_SCOPE" "${reader[@]}" -e DB_DATABASE="$database" -e DW_RUST_EXPERIMENTAL=1 \ + -e DW_RUST_UPGRADE_QUEUE_CONNECTION=database -e DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION=database \ + -e DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE=default \ -v "$PWD/$state:/state:ro" -v "$PWD/rust-target:/target:ro" \ - --entrypoint /target/debug/durable-workflow-server "$rust_image" upgrade-inspect + --entrypoint /target/debug/durable-workflow-server "$rust_image" "${1:-upgrade-inspect}" } snapshot() { if test "$backend" = sqlite; then @@ -107,6 +109,12 @@ jq -e '.status=="inspected" and (.table_rows|length)==50 and .table_rows.workflo and .table_rows.activity_executions==1 and .catalog_visibility=="configured_role" and .source_mutated==false and .takeover_allowed==false and .backup_verified==false and .php_quiescence_verified==false and .stored_representation_compatibility_verified==false' "$evidence/inspection.json" >/dev/null +inspect_source upgrade-queue-inspect > "$evidence/queue-inspection.json" 2> "$evidence/queue-inspection.stderr" +jq -e '.status=="inspected" and .queue_inspection.status=="known_subset_bound" + and .queue_inspection.jobs==1 and .queue_inspection.timer_jobs==1 + and .queue_inspection.workflow_jobs==0 and .queue_inspection.activity_jobs==0 + and .source_mutated==false and .takeover_allowed==false and .backup_verified==false + and .php_quiescence_verified==false and .stored_representation_compatibility_verified==false' "$evidence/queue-inspection.json" >/dev/null snapshot > "$evidence/after.json" cmp "$evidence/before.json" "$evidence/after.json" refusal() { From f4f28664073de38e749cda02289f9125a730f066 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 08:44:05 +0000 Subject: [PATCH 13/26] Record distinct PHP and native stored external reference formats --- docs/rust-server-storage-audit.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/rust-server-storage-audit.md b/docs/rust-server-storage-audit.md index dd1f02153..c89cdba2b 100644 --- a/docs/rust-server-storage-audit.md +++ b/docs/rust-server-storage-audit.md @@ -32,6 +32,18 @@ work can resume. Database-specific column types, collations, timestamps, JSON, transaction isolation, locking and migration history still need a complete mapping and executable tests. +The published Workflow source at +[`fbccb54b`](https://github.com/durable-workflow/workflow/blob/fbccb54b8f52e8724847296ee5b89d093b4a91d7/src/V2/Support/ExternalPayloads.php) +stores provider references as `dw-external-payload:v1:` followed by base64 JSON +containing `{codec, external_storage}`. The native development runtime instead +stores `dw-native-external-payload-v1:` followed by JSON containing its opaque +runtime reference. These formats are not interchangeable with inline Avro or +with each other. Common HTTP envelopes do not establish storage compatibility. +Preflight must identify both formats, bind provider and registry state, and +preserve the original URI, reference identity, codec, size, hash and object +bytes before a qualified read path or one-time conversion can be used. No such +conversion or external backup/restore is qualified by the queue inspection. + ## Backup-first, sequential upgrade gates 1. Run a read-only preflight against a consistent PHP reference installation. From 7753ba80c8801dc1081733b66bd0ec90232a22af Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 10:00:45 +0000 Subject: [PATCH 14/26] Inspect stored PHP external-reference metadata without adoption --- docs/rust-server-port.md | 8 + docs/rust-server-storage-audit.md | 15 + rust/src/runtime/upgrade.rs | 1 + .../src/runtime/upgrade/external_reference.rs | 273 ++++++++++++++++++ tests/Fixtures/ServerUpgrade/README.md | 42 +++ .../php-2.5.20-external-references.json | 41 +++ 6 files changed, 380 insertions(+) create mode 100644 rust/src/runtime/upgrade/external_reference.rs create mode 100644 tests/Fixtures/ServerUpgrade/README.md create mode 100644 tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index dacfc10b0..d32190d36 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -3089,3 +3089,11 @@ process refusal control for both inspection commands. Actual published-source CLI and fresh all-database integration remain pending. Complete stored-state validation, restored backup verification and sequential takeover stay open. +The separate #460 source preparation now recognizes canonical PHP stored +external-reference metadata under a 64 KiB bound, using a fixture emitted by +released Workflow 2.5.20 serializer classes. Four focused groups cover preserved +fields, format separation, malformed/duplicate fields, codec and integer bounds. +All 97 local library tests pass, with 18 existing database/subprocess cases +explicitly ignored. This reader is not yet wired to source-row scanning or +conversion and fetches no object. Registry/provider binding, restored backup +bytes, complete representation coverage and sequential takeover remain open. diff --git a/docs/rust-server-storage-audit.md b/docs/rust-server-storage-audit.md index c89cdba2b..df9c5a843 100644 --- a/docs/rust-server-storage-audit.md +++ b/docs/rust-server-storage-audit.md @@ -44,6 +44,21 @@ preserve the original URI, reference identity, codec, size, hash and object bytes before a qualified read path or one-time conversion can be used. No such conversion or external backup/restore is qualified by the queue inspection. +`runtime::upgrade::external_reference` now prepares a separate metadata reader +for the canonical PHP v1 format. Its fixture uses released Workflow 2.5.20 +serializer classes whose loaded hashes also match 2.5.21. The locations are +fictional; no external object was created or fetched. The reader preserves the +URI, integer size and digest text, requires matching Avro codecs, and refuses +unknown versions, native opaque references, duplicate/unknown fields, malformed +base64/JSON, invalid digests and invalid integer sizes. It bounds the complete +stored string to 64 KiB before decoding. Only canonical padded base64 and the +tested object fields are supported; other representations refuse explicitly. + +This decoder is not yet connected to source-row scanning or execution. It +cannot establish registry ownership, object availability, restored bytes, +reclamation holds or takeover readiness. The existing inspection reports and +their false compatibility/backup/quiescence/takeover flags are unchanged. + ## Backup-first, sequential upgrade gates 1. Run a read-only preflight against a consistent PHP reference installation. diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 241f4dce5..80baa18b6 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,4 +1,5 @@ //! Read-only PHP source inventory. This command never grants takeover authority. +pub mod external_reference; pub mod queue; pub mod queue_binding; mod queue_scan; diff --git a/rust/src/runtime/upgrade/external_reference.rs b/rust/src/runtime/upgrade/external_reference.rs new file mode 100644 index 000000000..8fc587224 --- /dev/null +++ b/rust/src/runtime/upgrade/external_reference.rs @@ -0,0 +1,273 @@ +//! Read PHP's stored reference metadata without fetching or adopting an object. +//! Recognizing this representation does not bind its registry/provider state, +//! verify its bytes or authorize conversion or takeover. +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use serde_json::Value; + +const PHP_PREFIX: &str = "dw-external-payload:v1:"; +const MAX_STORED_BYTES: usize = 64 * 1024; +const REFERENCE_SCHEMA: &str = "durable-workflow.v2.external-payload-reference.v1"; + +/// Errors contain no URI, encoded envelope or provider-controlled data. +#[derive(Debug, PartialEq, Eq)] +pub enum ReferenceDecodeError { + TooLarge, + UnsupportedFormat, + InvalidBase64, + InvalidEnvelope, + CodecMismatch, + InvalidReference, +} + +// Deliberately no Debug/Serialize: inspection reports must not expose locations. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhpStoredEnvelope { + codec: String, + external_storage: PhpStoredReference, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhpStoredReference { + schema: String, + uri: String, + sha256: String, + size_bytes: i64, + codec: String, +} + +impl PhpStoredEnvelope { + pub fn codec(&self) -> &str { + &self.codec + } + + pub fn reference(&self) -> &PhpStoredReference { + &self.external_storage + } +} + +impl PhpStoredReference { + pub fn uri(&self) -> &str { + &self.uri + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } + + pub fn size_bytes(&self) -> i64 { + self.size_bytes + } +} + +/// `None` means only that this string is not a recognized reference prefix. +/// Inline Avro validation is a separate obligation. Unknown reference versions +/// and native opaque references refuse instead of masquerading as inline data. +pub fn inspect_php_reference( + stored: &str, + row_codec: Option<&str>, +) -> Result, ReferenceDecodeError> { + let Some(encoded) = stored.strip_prefix(PHP_PREFIX) else { + return if stored.starts_with("dw-external-payload:") + || stored.starts_with("dw-native-external-payload-") + { + Err(ReferenceDecodeError::UnsupportedFormat) + } else { + Ok(None) + }; + }; + if stored.len() > MAX_STORED_BYTES { + return Err(ReferenceDecodeError::TooLarge); + } + let decoded = STANDARD + .decode(encoded) + .map_err(|_| ReferenceDecodeError::InvalidBase64)?; + // Serde structs can also accept positional sequences. PHP's stored format + // requires two JSON objects; check that shape without using the projection + // for the typed decode, so duplicate fields still fail on the original JSON. + let shape: Value = + serde_json::from_slice(&decoded).map_err(|_| ReferenceDecodeError::InvalidEnvelope)?; + if !shape.is_object() || !shape["external_storage"].is_object() { + return Err(ReferenceDecodeError::InvalidEnvelope); + } + let envelope: PhpStoredEnvelope = + serde_json::from_slice(&decoded).map_err(|_| ReferenceDecodeError::InvalidEnvelope)?; + let reference = &envelope.external_storage; + if envelope.codec != "avro" + || reference.codec != "avro" + || row_codec.is_some_and(|codec| !codec.is_empty() && codec != "avro") + { + return Err(ReferenceDecodeError::CodecMismatch); + } + if reference.schema != REFERENCE_SCHEMA + || reference.uri.is_empty() + || reference.size_bytes < 0 + || reference.sha256.len() != 64 + || !reference + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err(ReferenceDecodeError::InvalidReference); + } + Ok(Some(envelope)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json" + )) + .unwrap() + } + + fn stored(json: &str) -> String { + format!("{PHP_PREFIX}{}", STANDARD.encode(json)) + } + + fn error(input: &str, codec: Option<&str>, expected: ReferenceDecodeError) { + match inspect_php_reference(input, codec) { + Err(actual) => assert_eq!(actual, expected), + Ok(_) => panic!("unqualified stored reference accepted"), + } + } + + #[test] + fn released_php_serializer_preserves_original_reference_fields() { + for case in fixture()["cases"].as_array().unwrap() { + let original = case["stored"].as_str().unwrap(); + for row_codec in [None, Some(""), Some("avro")] { + let decoded = inspect_php_reference(original, row_codec).unwrap().unwrap(); + let expected = &case["envelope"]["external_storage"]; + assert_eq!(decoded.codec(), "avro"); + assert_eq!(decoded.reference().uri(), expected["uri"].as_str().unwrap()); + assert_eq!( + decoded.reference().sha256(), + expected["sha256"].as_str().unwrap() + ); + assert_eq!( + decoded.reference().size_bytes(), + expected["size_bytes"].as_i64().unwrap() + ); + } + error(original, Some("json"), ReferenceDecodeError::CodecMismatch); + } + } + + #[test] + fn reference_versions_and_oversized_metadata_never_become_inline_data() { + assert!( + inspect_php_reference("inline-avro-is-validated-elsewhere", None) + .unwrap() + .is_none() + ); + for original in [ + "dw-external-payload:v2:any", + "dw-native-external-payload-v1:{}", + ] { + error(original, None, ReferenceDecodeError::UnsupportedFormat); + } + error( + &format!("{PHP_PREFIX}{}", "A".repeat(MAX_STORED_BYTES)), + None, + ReferenceDecodeError::TooLarge, + ); + error( + &format!("{PHP_PREFIX}%%%"), + None, + ReferenceDecodeError::InvalidBase64, + ); + } + + #[test] + fn duplicate_unknown_positional_and_trailing_fields_refuse_without_projection() { + let envelope = fixture()["cases"][0]["envelope"].to_string(); + let reference = fixture()["cases"][0]["envelope"]["external_storage"].to_string(); + for json in [ + "null".to_string(), + format!("[\"avro\",{reference}]"), + format!("{{\"codec\":\"avro\",\"codec\":\"avro\",\"external_storage\":{reference}}}"), + envelope.replacen("\"uri\":", "\"uri\":\"foreign\",\"uri\":", 1), + envelope.replacen("{", "{\"extra\":true,", 1), + envelope.replacen( + "\"external_storage\":{", + "\"external_storage\":{\"extra\":true,", + 1, + ), + format!("{envelope}{{}}"), + "{\"codec\":\"avro\",\"external_storage\":[]}".to_string(), + ] { + error(&stored(&json), None, ReferenceDecodeError::InvalidEnvelope); + } + } + + #[test] + fn exact_codec_integer_digest_and_original_uri_remain_authoritative() { + let original = fixture()["cases"][0]["envelope"].clone(); + for (field, value, expected) in [ + ( + "schema", + Value::String("foreign".into()), + ReferenceDecodeError::InvalidReference, + ), + ( + "uri", + Value::String("".into()), + ReferenceDecodeError::InvalidReference, + ), + ( + "size_bytes", + Value::from(-1), + ReferenceDecodeError::InvalidReference, + ), + ( + "size_bytes", + Value::String("14".into()), + ReferenceDecodeError::InvalidEnvelope, + ), + ( + "size_bytes", + serde_json::json!(14.0), + ReferenceDecodeError::InvalidEnvelope, + ), + ( + "sha256", + Value::String("A".repeat(63)), + ReferenceDecodeError::InvalidReference, + ), + ( + "sha256", + Value::String("g".repeat(64)), + ReferenceDecodeError::InvalidReference, + ), + ( + "codec", + Value::String("json".into()), + ReferenceDecodeError::CodecMismatch, + ), + ] { + let mut envelope = original.clone(); + envelope["external_storage"][field] = value; + error(&stored(&envelope.to_string()), None, expected); + } + let mut envelope = original; + envelope["external_storage"]["sha256"] = Value::String("A".repeat(64)); + envelope["external_storage"]["size_bytes"] = Value::from(i64::MAX); + let decoded = inspect_php_reference(&stored(&envelope.to_string()), None) + .unwrap() + .unwrap(); + assert_eq!(decoded.reference().sha256(), "A".repeat(64)); + assert_eq!(decoded.reference().size_bytes(), i64::MAX); + envelope["external_storage"]["size_bytes"] = serde_json::json!(9223372036854775808_u64); + error( + &stored(&envelope.to_string()), + None, + ReferenceDecodeError::InvalidEnvelope, + ); + } +} diff --git a/tests/Fixtures/ServerUpgrade/README.md b/tests/Fixtures/ServerUpgrade/README.md new file mode 100644 index 000000000..40e8955f5 --- /dev/null +++ b/tests/Fixtures/ServerUpgrade/README.md @@ -0,0 +1,42 @@ +# PHP upgrade source fixtures + +These small fixtures exercise recognition of stored PHP data. They do not +authorize migration or claim that pending work resumes under Rust. + +`php-2.5.20-external-references.json` was generated with normally installed +Workflow 2.5.20 classes. The recorded Composer reference is the image's path +package reference, distinct from the package's published tag. All three loaded +class hashes match the published 2.5.20 and 2.5.21 source. The two locations are +fictional. This is a genuine serializer output, without a provider object, +payload fetch or end-to-end upload claim. + +To regenerate its stored strings with a normal package installation, run this +PHP through that installation's autoloader: + +```php + 'avro', 'external_storage' => $reference->toArray()]; + $stored = ExternalPayloads::encodeStoredEnvelope($envelope); + if (ExternalPayloads::storedEnvelope($stored) != $envelope) { + throw new RuntimeException('Reference round trip failed.'); + } + echo $stored, PHP_EOL; +} +``` + +The Rust reader tests distinguish this prefix from inline Avro and the native +opaque-reference prefix. Duplicate/unknown fields and unsupported versions +refuse. The reader returns metadata only, keeps provider locations out of +inspection reports, and is not yet wired to source-row conversion or takeover. diff --git a/tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json b/tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json new file mode 100644 index 000000000..119d79c09 --- /dev/null +++ b/tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json @@ -0,0 +1,41 @@ +{ + "schema": "durable-workflow.php-stored-reference-fixture.v1", + "producer": { + "workflow_version": "2.5.20", + "composer_reference": "9bdcd3eee11ccf6afa4635c57b2b3a9adb332429", + "loaded_class_hashes": { + "Workflow\\V2\\Support\\ExternalPayloads": "035b8d1ba80bacfa16282145ddcaebfccd93f7579bcc9cd0ba2ff98975928707", + "Workflow\\V2\\Support\\ExternalPayloadReference": "c742263ff985d8f28485eef1404ad70c124802464fcae0577cacc2eaad29a8be", + "Workflow\\Serializers\\CodecRegistry": "f17911a01c6bb8cb436a9ada79e134d13738eb72ca7f834219e0ac3607352773" + }, + "scope": "Released PHP serializer only; fictional provider locations, no stored object or provider fetch." + }, + "cases": [ + { + "stored": "dw-external-payload:v1:eyJjb2RlYyI6ImF2cm8iLCJleHRlcm5hbF9zdG9yYWdlIjp7ImNvZGVjIjoiYXZybyIsInNjaGVtYSI6ImR1cmFibGUtd29ya2Zsb3cudjIuZXh0ZXJuYWwtcGF5bG9hZC1yZWZlcmVuY2UudjEiLCJzaGEyNTYiOiJkZjhhOWViMzA4NmM4ZGUyNTI5NGYyYWNmYWU0ODllMTJlMTJmNjQ0YWJiZGQwNzhkY2NjMTM1NmZiZDdiOWJkIiwic2l6ZV9ieXRlcyI6MTQsInVyaSI6InMzOi8vbWlncmF0aW9uLWZpeHR1cmUvcmVzdWx0LyVDRSVCQj92ZXJzaW9uSWQ9b3JpZ2luYWwtMTcifX0=", + "envelope": { + "codec": "avro", + "external_storage": { + "schema": "durable-workflow.v2.external-payload-reference.v1", + "uri": "s3://migration-fixture/result/%CE%BB?versionId=original-17", + "sha256": "df8a9eb3086c8de25294f2acfae489e12e12f644abbdd078dccc1356fbd7b9bd", + "size_bytes": 14, + "codec": "avro" + } + } + }, + { + "stored": "dw-external-payload:v1:eyJjb2RlYyI6ImF2cm8iLCJleHRlcm5hbF9zdG9yYWdlIjp7ImNvZGVjIjoiYXZybyIsInNjaGVtYSI6ImR1cmFibGUtd29ya2Zsb3cudjIuZXh0ZXJuYWwtcGF5bG9hZC1yZWZlcmVuY2UudjEiLCJzaGEyNTYiOiJlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1Iiwic2l6ZV9ieXRlcyI6MCwidXJpIjoiZmlsZTovLy9taWdyYXRpb24tZml4dHVyZS9cdTY1ZTVcdTY3MmNcdThhOWUvZW1wdHkifX0=", + "envelope": { + "codec": "avro", + "external_storage": { + "schema": "durable-workflow.v2.external-payload-reference.v1", + "uri": "file:///migration-fixture/日本語/empty", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size_bytes": 0, + "codec": "avro" + } + } + } + ] +} From d938fed0f85324a86c68452c8151a3865b049263 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 11:01:57 +0000 Subject: [PATCH 15/26] Inspect stored PHP reference metadata in read-only source columns --- docs/rust-server-port.md | 18 ++ docs/rust-server-storage-audit.md | 19 +- rust/src/main.rs | 58 ++-- rust/src/runtime/upgrade.rs | 79 +++++- rust/src/runtime/upgrade/payload_scan.rs | 191 +++++++++++++ rust/src/runtime/upgrade_tests.rs | 253 ++++++++++++++++++ rust/tests/upgrade_inspect.rs | 2 + .../server-parity/upgrade-payload-source.php | 79 ++++++ .../server-parity/upgrade-source-profile.sh | 9 + tests/Fixtures/ServerUpgrade/README.md | 11 + 10 files changed, 690 insertions(+), 29 deletions(-) create mode 100644 rust/src/runtime/upgrade/payload_scan.rs create mode 100644 scripts/conformance/server-parity/upgrade-payload-source.php diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index d32190d36..117b2b944 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -3097,3 +3097,21 @@ All 97 local library tests pass, with 18 existing database/subprocess cases explicitly ignored. This reader is not yet wired to source-row scanning or conversion and fetches no object. Registry/provider binding, restored backup bytes, complete representation coverage and sequential takeover remain open. + +The next preparation connects that decoder to `upgrade-payload-inspect`, a +separate read-only root-column scan. It reports bounded counts and affected row +IDs for unsupported metadata, with no provider fetch, payload/URI output or +takeover authority. New controls cover all four columns, pagination, omitted +diagnostics, oversized values, source identity and byte preservation. Existing +inventory and queue inspection behavior is unchanged. Exact local and hosted +qualification belongs to #460; constructed source rows are not executed workflows. + +The corrected preparation passes 101 library cases and the CLI filesystem +refusal test, with 18 existing database/subprocess ignores. Fifteen separate CLI +controls on SQLite DELETE, MySQL 8.4 and PostgreSQL 17 inspect metadata, refuse +unsupported/malformed/oversized references and accept explicit restoration. +Network readers have SELECT-only authority; each source fingerprint is unchanged. +The first MySQL fixture ID overflow and prototype reserved SQL alias remain failed +observations. The fixture ID and alias are corrected, and the shared upgrade +profile now exercises the new scan. Hosted integration and every takeover gate +remain pending. diff --git a/docs/rust-server-storage-audit.md b/docs/rust-server-storage-audit.md index df9c5a843..b1cfd921e 100644 --- a/docs/rust-server-storage-audit.md +++ b/docs/rust-server-storage-audit.md @@ -54,10 +54,21 @@ base64/JSON, invalid digests and invalid integer sizes. It bounds the complete stored string to 64 KiB before decoding. Only canonical padded base64 and the tested object fields are supported; other representations refuse explicitly. -This decoder is not yet connected to source-row scanning or execution. It -cannot establish registry ownership, object availability, restored bytes, -reclamation holds or takeover readiness. The existing inspection reports and -their false compatibility/backup/quiescence/takeover flags are unchanged. +The separate `upgrade-payload-inspect` command now scans only +`workflow_runs.arguments/output` and `activity_executions.arguments/result` +inside the same read-only snapshot as the schema inventory. It reports per-column +counts and at most sixteen affected row IDs with static diagnostics. It never +reports reference locations, hashes or payload contents. A malformed or unsupported +reference produces an `unqualified` report and nonzero exit. The existing inventory +and queue commands keep their behavior. + +The scan uses primary-key pages of 32 and bounds transferred values to 64 KiB +before driver allocation. Large inline values remain explicitly unvalidated. +The original 30-second inspection deadline covers the complete operation. +No object is fetched. This does not establish nested history/command/memo +compatibility, registry ownership, object availability, restored bytes, +reclamation holds or takeover readiness. Every compatibility, backup, quiescence +and takeover flag stays false. ## Backup-first, sequential upgrade gates diff --git a/rust/src/main.rs b/rust/src/main.rs index 5091af264..0d381b0e1 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -127,7 +127,11 @@ async fn main() -> Result<(), Box> { if env::args().count() != 2 || !matches!( command.as_str(), - "schema-bootstrap" | "schema-check" | "upgrade-inspect" | "upgrade-queue-inspect" + "schema-bootstrap" + | "schema-check" + | "upgrade-inspect" + | "upgrade-queue-inspect" + | "upgrade-payload-inspect" ) { return Err("unknown development command".into()); @@ -135,7 +139,7 @@ async fn main() -> Result<(), Box> { let backend = env::var("DB_CONNECTION").unwrap_or_default(); if matches!( command.as_str(), - "upgrade-inspect" | "upgrade-queue-inspect" + "upgrade-inspect" | "upgrade-queue-inspect" | "upgrade-payload-inspect" ) { use durable_workflow_server::runtime::upgrade::{ self, queue_binding::QueueSourceConfig, @@ -167,21 +171,39 @@ async fn main() -> Result<(), Box> { let result = match backend.as_str() { "sqlite" => { let path = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; - match queues.as_ref() { - Some(config) => upgrade::inspect_sqlite_queues(&path, config).await, - None => upgrade::inspect_sqlite(&path).await, + if command == "upgrade-payload-inspect" { + upgrade::inspect_sqlite_payloads(&path).await + } else { + match queues.as_ref() { + Some(config) => upgrade::inspect_sqlite_queues(&path, config).await, + None => upgrade::inspect_sqlite(&path).await, + } + } + } + "mysql" | "mariadb" => { + if command == "upgrade-payload-inspect" { + upgrade::inspect_mysql_payloads(mysql_options()?).await + } else { + match queues.as_ref() { + Some(config) => { + upgrade::inspect_mysql_queues(mysql_options()?, config).await + } + None => upgrade::inspect_mysql(mysql_options()?).await, + } } } - "mysql" | "mariadb" => match queues.as_ref() { - Some(config) => upgrade::inspect_mysql_queues(mysql_options()?, config).await, - None => upgrade::inspect_mysql(mysql_options()?).await, - }, - "pgsql" => match queues.as_ref() { - Some(config) => { - upgrade::inspect_postgres_queues(postgres_options()?, config).await + "pgsql" => { + if command == "upgrade-payload-inspect" { + upgrade::inspect_postgres_payloads(postgres_options()?).await + } else { + match queues.as_ref() { + Some(config) => { + upgrade::inspect_postgres_queues(postgres_options()?, config).await + } + None => upgrade::inspect_postgres(postgres_options()?).await, + } } - None => upgrade::inspect_postgres(postgres_options()?).await, - }, + } _ => { return Err( "upgrade inspection requires DB_CONNECTION=sqlite, pgsql, mysql or mariadb" @@ -190,7 +212,13 @@ async fn main() -> Result<(), Box> { } }; match result { - Ok(report) => println!("{report}"), + Ok(report) => { + println!("{report}"); + if report["status"] == "unqualified" { + eprintln!("upgrade_source_payload_metadata_unqualified"); + return Err("upgrade source inspection refused".into()); + } + } Err(error) => { let reason = match error { RuntimeError::Refused { reason, .. } => reason, diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 80baa18b6..f8060c51d 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -1,5 +1,6 @@ //! Read-only PHP source inventory. This command never grants takeover authority. pub mod external_reference; +mod payload_scan; pub mod queue; pub mod queue_binding; mod queue_scan; @@ -101,6 +102,7 @@ fn report( catalog: &impl Serialize, counts: BTreeMap, queue_inspection: Option, + payload_inspection: Option, ) -> Result { let digest = Sha256::digest(serde_json::to_vec(catalog)?) .iter() @@ -116,6 +118,12 @@ fn report( if let Some(queues) = queue_inspection { report["queue_inspection"] = queues; } + if let Some(payloads) = payload_inspection { + if payloads["references_metadata_supported"] != true { + report["status"] = json!("unqualified"); + } + report["payload_inspection"] = payloads; + } Ok(report) } @@ -158,14 +166,22 @@ macro_rules! count_tables { } pub async fn inspect_sqlite(path: &str) -> Result { - inspect_sqlite_inner(path, None).await + inspect_sqlite_inner(path, None, false).await } pub async fn inspect_sqlite_queues(path: &str, config: &QueueSourceConfig<'_>) -> Result { - inspect_sqlite_inner(path, Some(config)).await + inspect_sqlite_inner(path, Some(config), false).await } -async fn inspect_sqlite_inner(path: &str, queues: Option<&QueueSourceConfig<'_>>) -> Result { +pub async fn inspect_sqlite_payloads(path: &str) -> Result { + inspect_sqlite_inner(path, None, true).await +} + +async fn inspect_sqlite_inner( + path: &str, + queues: Option<&QueueSourceConfig<'_>>, + payloads: bool, +) -> Result { queue_config(queues)?; let deadline = Instant::now() + BUDGET; if path.is_empty() || path.contains(['?', ':']) { @@ -245,8 +261,19 @@ async fn inspect_sqlite_inner(path: &str, queues: Option<&QueueSourceConfig<'_>> } None => None, }; + let payload_inspection = if payloads { + Some(payload_scan::inspect::(&mut *tx, &counts).await?) + } else { + None + }; tx.rollback().await?; - report("sqlite", &catalog, counts, queue_inspection) + report( + "sqlite", + &catalog, + counts, + queue_inspection, + payload_inspection, + ) }) .await .map_err(|_| expired()) @@ -256,19 +283,24 @@ async fn inspect_sqlite_inner(path: &str, queues: Option<&QueueSourceConfig<'_>> } pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { - inspect_mysql_inner(options, None).await + inspect_mysql_inner(options, None, false).await } pub async fn inspect_mysql_queues( options: MySqlConnectOptions, config: &QueueSourceConfig<'_>, ) -> Result { - inspect_mysql_inner(options, Some(config)).await + inspect_mysql_inner(options, Some(config), false).await +} + +pub async fn inspect_mysql_payloads(options: MySqlConnectOptions) -> Result { + inspect_mysql_inner(options, None, true).await } async fn inspect_mysql_inner( options: MySqlConnectOptions, queues: Option<&QueueSourceConfig<'_>>, + payloads: bool, ) -> Result { queue_config(queues)?; let deadline = Instant::now() + BUDGET; @@ -303,6 +335,11 @@ async fn inspect_mysql_inner( } None => None, }; + let payload_inspection = if payloads { + Some(payload_scan::inspect::(&mut *tx, &counts).await?) + } else { + None + }; // MySQL's metadata is not an MVCC snapshot. Refuse a changed visible // catalog rather than joining counts to a different schema. if mysql::php_source_catalog(&mut tx).await? != catalog { @@ -312,7 +349,13 @@ async fn inspect_mysql_inner( )); } tx.rollback().await?; - report(backend, &catalog, counts, queue_inspection) + report( + backend, + &catalog, + counts, + queue_inspection, + payload_inspection, + ) }) .await .map_err(|_| expired()) @@ -322,19 +365,24 @@ async fn inspect_mysql_inner( } pub async fn inspect_postgres(options: PgConnectOptions) -> Result { - inspect_postgres_inner(options, None).await + inspect_postgres_inner(options, None, false).await } pub async fn inspect_postgres_queues( options: PgConnectOptions, config: &QueueSourceConfig<'_>, ) -> Result { - inspect_postgres_inner(options, Some(config)).await + inspect_postgres_inner(options, Some(config), false).await +} + +pub async fn inspect_postgres_payloads(options: PgConnectOptions) -> Result { + inspect_postgres_inner(options, None, true).await } async fn inspect_postgres_inner( options: PgConnectOptions, queues: Option<&QueueSourceConfig<'_>>, + payloads: bool, ) -> Result { queue_config(queues)?; let deadline = Instant::now() + BUDGET; @@ -361,8 +409,19 @@ async fn inspect_postgres_inner( } None => None, }; + let payload_inspection = if payloads { + Some(payload_scan::inspect::(&mut *tx, &counts).await?) + } else { + None + }; tx.rollback().await?; - report("pgsql", &catalog, counts, queue_inspection) + report( + "pgsql", + &catalog, + counts, + queue_inspection, + payload_inspection, + ) }) .await .map_err(|_| expired()) diff --git a/rust/src/runtime/upgrade/payload_scan.rs b/rust/src/runtime/upgrade/payload_scan.rs new file mode 100644 index 000000000..12724a272 --- /dev/null +++ b/rust/src/runtime/upgrade/payload_scan.rs @@ -0,0 +1,191 @@ +//! Root-column reference metadata only. Inline values, nested history, +//! provider bytes and registry ownership remain unqualified. +use super::external_reference::{ReferenceDecodeError, inspect_php_reference}; +use crate::runtime::{Result, backend::Backend, refuse}; +use axum::http::StatusCode; +use serde::Serialize; +use serde_json::{Value, json}; +use sqlx::{AssertSqlSafe, Encode, Executor, IntoArguments, Type}; +use std::collections::BTreeMap; + +const MAX_DIAGNOSTICS: usize = 16; + +struct Column { + table: &'static str, + name: &'static str, + sqlite_sql: &'static str, + network_sql: &'static str, +} + +macro_rules! column { + ($table:literal, $name:literal, $codec:literal) => { + Column { + table: $table, + name: $name, + sqlite_sql: concat!( + "SELECT CASE WHEN length(CAST(id AS BLOB))<=256 THEN id ELSE NULL END AS id,", + "length(CAST(", + $name, + " AS BLOB)) AS stored_bytes,substr(", + $name, + ",1,32) AS prefix,", + "CASE WHEN length(CAST(", + $name, + " AS BLOB))<=65536 THEN ", + $name, + " ELSE NULL END AS stored_value,", + "CASE WHEN length(CAST(", + $codec, + " AS BLOB))<=64 THEN ", + $codec, + " WHEN ", + $codec, + " IS NULL THEN NULL ELSE 'unsupported-codec-size' END AS codec ", + "FROM ", + $table, + " WHERE id>$1 ORDER BY id LIMIT 32" + ), + network_sql: concat!( + "SELECT CASE WHEN octet_length(id)<=256 THEN id ELSE NULL END AS id,", + "octet_length(", + $name, + ") AS stored_bytes,substr(", + $name, + ",1,32) AS prefix,", + "CASE WHEN octet_length(", + $name, + ")<=65536 THEN ", + $name, + " ELSE NULL END AS stored_value,", + "CASE WHEN octet_length(", + $codec, + ")<=64 THEN ", + $codec, + " WHEN ", + $codec, + " IS NULL THEN NULL ELSE 'unsupported-codec-size' END AS codec ", + "FROM ", + $table, + " WHERE id>$1 ORDER BY id LIMIT 32" + ), + } + }; +} + +const COLUMNS: [Column; 4] = [ + column!("workflow_runs", "arguments", "payload_codec"), + column!("workflow_runs", "output", "output_payload_codec"), + column!("activity_executions", "arguments", "payload_codec"), + column!("activity_executions", "result", "payload_codec"), +]; + +#[derive(Default, Serialize)] +struct Counts { + rows: i64, + absent: i64, + other_unvalidated: i64, + php_v1_references: i64, + unqualified_references: i64, +} + +fn reason(error: ReferenceDecodeError) -> &'static str { + match error { + ReferenceDecodeError::TooLarge => "external_reference_too_large", + ReferenceDecodeError::UnsupportedFormat => "external_reference_format_unsupported", + ReferenceDecodeError::InvalidBase64 => "external_reference_base64_invalid", + ReferenceDecodeError::InvalidEnvelope => "external_reference_envelope_invalid", + ReferenceDecodeError::CodecMismatch => "external_reference_codec_mismatch", + ReferenceDecodeError::InvalidReference => "external_reference_metadata_invalid", + } +} + +pub(super) async fn inspect( + connection: &mut DB::Connection, + expected_rows: &BTreeMap, +) -> Result +where + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> &'q str: Encode<'q, DB> + Type, +{ + let mut columns = Vec::new(); + let mut diagnostics = Vec::new(); + let mut omitted = 0_i64; + for column in COLUMNS { + let sql = if DB::NAME == "SQLite" { + column.sqlite_sql + } else { + column.network_sql + }; + let mut cursor = String::new(); + let mut counts = Counts::default(); + loop { + // Each page transfers at most 32 * 64 KiB of values plus bounded + // IDs/codecs/prefixes. Large inline values never reach the driver. + let page = sqlx::query(AssertSqlSafe(DB::statement(sql))) + .bind(cursor.as_str()) + .fetch_all(&mut *connection) + .await?; + if page.is_empty() { + break; + } + for row in page { + cursor = DB::optional_string(&row, "id")?.ok_or_else(|| { + refuse( + StatusCode::CONFLICT, + "upgrade_source_payload_identity_unqualified", + ) + })?; + counts.rows += 1; + let Some(bytes) = DB::optional_number(&row, "stored_bytes")? else { + counts.absent += 1; + continue; + }; + let prefix = DB::string(&row, "prefix")?; + if !prefix.starts_with("dw-external-payload:") + && !prefix.starts_with("dw-native-external-payload-") + { + counts.other_unvalidated += 1; + continue; + } + let decoded = if bytes > 65536 { + Err(ReferenceDecodeError::TooLarge) + } else { + let stored = DB::string(&row, "stored_value")?; + let codec = DB::optional_string(&row, "codec")?; + inspect_php_reference(&stored, codec.as_deref()) + }; + match decoded { + Ok(Some(_)) => counts.php_v1_references += 1, + Ok(None) => unreachable!("reference prefixes were checked before decoding"), + Err(error) => { + counts.unqualified_references += 1; + if diagnostics.len() < MAX_DIAGNOSTICS { + diagnostics.push(json!({"table":column.table,"column":column.name, + "record_id":cursor,"reason":reason(error)})); + } else { + omitted += 1; + } + } + } + } + } + // Empty IDs or collation-equivalent cursor values cannot disappear + // behind pagination and produce a complete-looking report. + if expected_rows.get(column.table) != Some(&counts.rows) { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_payload_rows_unqualified", + )); + } + columns.push(json!({"table":column.table,"column":column.name,"counts":counts})); + } + Ok(json!({ + "status":"known_columns_inspected", "columns":columns, + "references_metadata_supported":diagnostics.is_empty() && omitted==0, + "diagnostics":diagnostics, "diagnostics_omitted":omitted, + "inline_values_verified":false, "nested_payloads_verified":false, + "provider_bytes_verified":false, "registry_binding_verified":false, + "source_mutated":false, "takeover_allowed":false + })) +} diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index 789248ba5..027c33ce0 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -322,6 +322,259 @@ async fn unknown_source_catalog_refuses_without_repair_or_retained_reader() { assert_eq!(std::fs::read(&path).unwrap(), before); } +fn stored_reference() -> String { + let fixture: Value = serde_json::from_str(include_str!( + "../../../tests/Fixtures/ServerUpgrade/php-2.5.20-external-references.json" + )) + .unwrap(); + fixture["cases"][0]["stored"].as_str().unwrap().to_owned() +} + +async fn payload_run( + connection: &mut SqliteConnection, + id: &str, + arguments: Option<&str>, + output: Option<&str>, + codec: &str, +) { + sqlx::query("INSERT INTO workflow_runs(id,workflow_instance_id,run_number,workflow_class,workflow_type,status,payload_codec,arguments,output,output_payload_codec) VALUES (?, ?,1,'SourceFixture','source.fixture','completed',?,?,?,'avro')") + .bind(id).bind(format!("instance-{id}")).bind(codec).bind(arguments).bind(output) + .execute(connection).await.unwrap(); +} + +fn column_counts<'a>(report: &'a Value, table: &str, column: &str) -> &'a Value { + &report["payload_inspection"]["columns"] + .as_array() + .unwrap() + .iter() + .find(|value| value["table"] == table && value["column"] == column) + .unwrap()["counts"] +} + +#[tokio::test] +async fn payload_scan_checks_all_four_columns_and_every_page_without_mutation_or_locations() { + let (_directory, path) = php_layout().await; + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + let reference = stored_reference(); + let large_inline = "private-inline-value".repeat(100_000); + for index in 0..33 { + let arguments = match index { + 0 => Some(reference.as_str()), + 1 => None, + _ => Some(large_inline.as_str()), + }; + payload_run( + &mut connection, + &format!("run-{index:02}"), + arguments, + (index == 32).then_some(reference.as_str()), + "avro", + ) + .await; + } + sqlx::query("INSERT INTO activity_executions(id,workflow_run_id,sequence,activity_class,activity_type,status,payload_codec,arguments,result) VALUES ('activity-1','run-00',1,'SourceFixture','source.fixture','completed','avro',?,?)") + .bind(&reference).bind(&reference).execute(&mut connection).await.unwrap(); + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_payloads(&path).await.unwrap(); + assert_eq!(report["status"], "inspected"); + assert_eq!( + report["payload_inspection"]["references_metadata_supported"], + true + ); + assert_eq!( + column_counts(&report, "workflow_runs", "arguments"), + &json!({"rows":33,"absent":1,"other_unvalidated":31,"php_v1_references":1,"unqualified_references":0}) + ); + assert_eq!( + column_counts(&report, "workflow_runs", "output")["php_v1_references"], + 1 + ); + for field in ["arguments", "result"] { + assert_eq!( + column_counts(&report, "activity_executions", field)["php_v1_references"], + 1 + ); + } + assert_eq!(report["payload_inspection"]["diagnostics"], json!([])); + for flag in [ + "inline_values_verified", + "nested_payloads_verified", + "provider_bytes_verified", + "registry_binding_verified", + "source_mutated", + "takeover_allowed", + ] { + assert_eq!(report["payload_inspection"][flag], false, "{flag}"); + } + assert!(!report.to_string().contains(&reference)); + assert!(!report.to_string().contains("private-inline-value")); + let decoded = external_reference::inspect_php_reference(&reference, Some("avro")) + .unwrap() + .unwrap(); + assert!(!report.to_string().contains(decoded.reference().uri())); + assert!(!report.to_string().contains(decoded.reference().sha256())); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[tokio::test] +async fn payload_scan_reports_unknown_and_malformed_references_after_first_page() { + let (_directory, path) = php_layout().await; + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + for index in 0..33 { + payload_run( + &mut connection, + &format!("run-{index:02}"), + None, + None, + "avro", + ) + .await; + } + let reference = stored_reference(); + for (id, value, codec) in [ + ("run-33", "dw-external-payload:v2:private-uri", "avro"), + ("run-34", "dw-external-payload:v1:%%%", "avro"), + ( + "run-35", + "dw-native-external-payload-v1:private-uri", + "avro", + ), + ("run-36", reference.as_str(), "json"), + ] { + payload_run(&mut connection, id, Some(value), None, codec).await; + } + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_payloads(&path).await.unwrap(); + assert_eq!(report["status"], "unqualified"); + assert_eq!( + column_counts(&report, "workflow_runs", "arguments")["unqualified_references"], + 4 + ); + let diagnostics = &report["payload_inspection"]["diagnostics"]; + assert_eq!(diagnostics.as_array().unwrap().len(), 4); + assert_eq!( + diagnostics[0], + json!({"table":"workflow_runs","column":"arguments","record_id":"run-33","reason":"external_reference_format_unsupported"}) + ); + assert_eq!( + diagnostics[1]["reason"], + "external_reference_base64_invalid" + ); + assert_eq!( + diagnostics[2]["reason"], + "external_reference_format_unsupported" + ); + assert_eq!( + diagnostics[3]["reason"], + "external_reference_codec_mismatch" + ); + assert!(!report.to_string().contains("private-uri")); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!( + inspect_sqlite(&path) + .await + .unwrap() + .get("payload_inspection") + .is_none() + ); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; +} + +#[tokio::test] +async fn payload_scan_bounds_diagnostics_and_refuses_oversized_reference_metadata() { + let (_directory, path) = php_layout().await; + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + let oversized = format!("dw-external-payload:v1:{}", "A".repeat(65536)); + for index in 0..20 { + payload_run( + &mut connection, + &format!("run-{index:02}"), + Some(&oversized), + None, + "avro", + ) + .await; + } + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_payloads(&path).await.unwrap(); + assert_eq!(report["status"], "unqualified"); + assert_eq!( + column_counts(&report, "workflow_runs", "arguments")["unqualified_references"], + 20 + ); + assert_eq!( + report["payload_inspection"]["diagnostics"] + .as_array() + .unwrap() + .len(), + 16 + ); + assert_eq!(report["payload_inspection"]["diagnostics_omitted"], 4); + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["reason"], + "external_reference_too_large" + ); + assert!(report.to_string().len() < 8192); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[tokio::test] +async fn payload_scan_does_not_skip_empty_or_oversized_source_identities() { + for id in [String::new(), "x".repeat(257)] { + let (_directory, path) = php_layout().await; + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + payload_run( + &mut connection, + &id, + Some(&stored_reference()), + None, + "avro", + ) + .await; + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + reason( + inspect_sqlite_payloads(&path).await, + if id.is_empty() { + "upgrade_source_payload_rows_unqualified" + } else { + "upgrade_source_payload_identity_unqualified" + }, + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + mutate(&path, "BEGIN EXCLUSIVE; ROLLBACK").await; + } +} + #[tokio::test] async fn empty_malformed_and_view_ownership_markers_refuse_before_definition_execution() { for statement in [ diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 231261585..131c4828b 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -22,6 +22,8 @@ fn special_sqlite_sources_refuse_without_waiting_for_a_writer() { (directory.path(), "upgrade-inspect"), (fifo.as_path(), "upgrade-queue-inspect"), (directory.path(), "upgrade-queue-inspect"), + (fifo.as_path(), "upgrade-payload-inspect"), + (directory.path(), "upgrade-payload-inspect"), ] { let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) .env_clear() diff --git a/scripts/conformance/server-parity/upgrade-payload-source.php b/scripts/conformance/server-parity/upgrade-payload-source.php new file mode 100644 index 000000000..0a9c6ed9e --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-payload-source.php @@ -0,0 +1,79 @@ +make(Kernel::class)->bootstrap(); +set_exception_handler(static function (Throwable $error): never { + fwrite(STDERR, 'upgrade_payload_fixture_failed: '.$error::class."\n"); + exit(1); +}); + +// Constructed database rows for metadata inspection, not executed workflows. +// Run only on a new disposable schema created by the normal PHP bootstrap. +$mode = $argv[1] ?? ''; +if (! in_array($mode, ['seed', 'unsupported', 'malformed', 'oversized', 'restore'], true)) { + throw new RuntimeException('Expected an explicit payload fixture mode.'); +} +$type = 'parity.upgrade_payload_fixture'; +if (DB::table('workflow_runs')->where('workflow_type', '!=', $type)->exists() + || DB::table('workflow_instances')->where('workflow_type', '!=', $type)->exists() + || DB::table('activity_executions')->where('activity_type', '!=', $type)->exists()) { + throw new RuntimeException('Payload fixture refuses unrelated source rows.'); +} +$bytes = Avro::serialize([9007199254740993, null, 'upgradeλ']); +$reference = new ExternalPayloadReference('file:///source-fixture-unavailable-object', hash('sha256', $bytes), strlen($bytes), 'avro'); +$stored = ExternalPayloads::encodeStoredEnvelope(['codec' => 'avro', 'external_storage' => $reference->toArray()]); +$last = 'payload-inspection-run-032'; +DB::transaction(static function () use ($mode, $type, $bytes, $stored, $last): void { + if ($mode !== 'seed') { + if (DB::table('workflow_runs')->count() !== 33 || DB::table('activity_executions')->count() !== 1) { + throw new RuntimeException('Original constructed payload fixture is missing.'); + } + $value = match ($mode) { + 'unsupported' => 'dw-external-payload:v2:unqualified', + 'malformed' => 'dw-external-payload:v1:%%%', + 'oversized' => 'dw-external-payload:v1:'.str_repeat('A', 65536), + 'restore' => null, + }; + DB::table('workflow_runs')->where('id', $last)->update(['output' => $value]); + + return; + } + if (DB::table('workflow_runs')->exists() || DB::table('activity_executions')->exists()) { + throw new RuntimeException('Payload seed requires an empty disposable source.'); + } + for ($index = 0; $index < 33; $index++) { + $id = sprintf('payload-inspection-run-%03d', $index); + $instance = sprintf('payload-inspection-instance-%03d', $index); + DB::table('workflow_instances')->insert(['id' => $instance, 'workflow_class' => $type, + 'workflow_type' => $type, 'namespace' => 'default', 'current_run_id' => $id, 'run_count' => 1]); + DB::table('workflow_runs')->insert(['id' => $id, 'workflow_instance_id' => $instance, + 'run_number' => 1, 'workflow_class' => $type, 'workflow_type' => $type, 'namespace' => 'default', + 'status' => 'completed', 'payload_codec' => 'avro', 'output_payload_codec' => 'avro', + 'arguments' => $index === 0 ? $stored : $bytes, 'output' => $index === 0 ? $stored : null]); + } + DB::table('activity_executions')->insert(['id' => 'payload-inspection-act-01', + 'workflow_run_id' => 'payload-inspection-run-000', 'sequence' => 1, 'activity_class' => $type, + 'activity_type' => $type, 'status' => 'completed', 'payload_codec' => 'avro', + 'arguments' => $stored, 'result' => $stored]); +}); +$hashes = []; +foreach ([Avro::class, ExternalPayloads::class, ExternalPayloadReference::class] as $class) { + $hashes[$class] = hash_file('sha256', (new ReflectionClass($class))->getFileName()); +} +echo json_encode(['outcome' => 'pass', 'fixture_mode' => $mode, + 'scope' => 'constructed_source_rows_with_installed_php_serializers', + 'workflow_version' => InstalledVersions::getPrettyVersion('durable-workflow/workflow'), + 'composer_reference' => InstalledVersions::getReference('durable-workflow/workflow'), + 'loaded_class_sha256' => $hashes, 'runs' => 33, 'activities' => 1, + 'provider_object_created' => false, 'workflow_execution_claimed' => false, + 'takeover_allowed' => false], JSON_THROW_ON_ERROR)."\n"; diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index c5f51006a..302a8bc12 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -115,6 +115,15 @@ jq -e '.status=="inspected" and .queue_inspection.status=="known_subset_bound" and .queue_inspection.workflow_jobs==0 and .queue_inspection.activity_jobs==0 and .source_mutated==false and .takeover_allowed==false and .backup_verified==false and .php_quiescence_verified==false and .stored_representation_compatibility_verified==false' "$evidence/queue-inspection.json" >/dev/null +inspect_source upgrade-payload-inspect > "$evidence/payload-inspection.json" 2> "$evidence/payload-inspection.stderr" +jq -e '.status=="inspected" and (.payload_inspection.columns|length)==4 + and .payload_inspection.references_metadata_supported==true + and ([.payload_inspection.columns[].counts.php_v1_references]|add)==0 + and ([.payload_inspection.columns[].counts.other_unvalidated]|add)==3 + and .payload_inspection.provider_bytes_verified==false + and .payload_inspection.registry_binding_verified==false + and .source_mutated==false and .takeover_allowed==false and .backup_verified==false + and .php_quiescence_verified==false and .stored_representation_compatibility_verified==false' "$evidence/payload-inspection.json" >/dev/null snapshot > "$evidence/after.json" cmp "$evidence/before.json" "$evidence/after.json" refusal() { diff --git a/tests/Fixtures/ServerUpgrade/README.md b/tests/Fixtures/ServerUpgrade/README.md index 40e8955f5..20f2c81d0 100644 --- a/tests/Fixtures/ServerUpgrade/README.md +++ b/tests/Fixtures/ServerUpgrade/README.md @@ -40,3 +40,14 @@ The Rust reader tests distinguish this prefix from inline Avro and the native opaque-reference prefix. Duplicate/unknown fields and unsupported versions refuse. The reader returns metadata only, keeps provider locations out of inspection reports, and is not yet wired to source-row conversion or takeover. + +The optional `upgrade-payload-inspect` command applies that reader to the four +root run/activity columns listed in the storage audit. Use the normal inspection +database configuration and a SELECT-only role; SQLite still requires the supported +DELETE-journal source. `scripts/conformance/server-parity/upgrade-payload-source.php` +provides `seed`, `unsupported`, `malformed`, `oversized` and `restore` controls for +a new disposable PHP-bootstrapped schema. These are constructed rows using the +installed released serializer, not executed workflows. No provider object exists. +Compare source fingerprints before and after each inspection. An unqualified +metadata report must exit nonzero and identify its affected row without exposing +the stored reference. A successful metadata inspection still permits no takeover. From b2d13d17d32bb1b66824cdcfc6578df96e889b2c Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:04:42 +0000 Subject: [PATCH 16/26] Bound inline Avro inspection of stored PHP payload columns --- rust/src/codec/validation.rs | 169 ++++++++++++++++++++++- rust/src/main.rs | 8 ++ rust/src/runtime/upgrade.rs | 4 +- rust/src/runtime/upgrade/payload_scan.rs | 45 +++++- rust/src/runtime/upgrade_tests.rs | 108 ++++++++++++++- rust/tests/upgrade_inspect.rs | 128 +++++++++++++++++ 6 files changed, 452 insertions(+), 10 deletions(-) diff --git a/rust/src/codec/validation.rs b/rust/src/codec/validation.rs index 77f82a63a..988f5825d 100644 --- a/rust/src/codec/validation.rs +++ b/rust/src/codec/validation.rs @@ -8,7 +8,7 @@ use base64::{ }; use serde::{ Deserialize, Deserializer, - de::{self, MapAccess, SeqAccess, Visitor}, + de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}, }; use std::{ fmt, @@ -17,6 +17,90 @@ use std::{ struct Validated; struct Key; + +// Operator inspection has a smaller input/CPU envelope than runtime payload +// transport. Apache still owns framing and schema traversal; this seed only +// bounds visits and stack depth, without retaining or rewriting user values. +struct InlineValidated; +struct InlineSeed<'a> { + remaining: &'a mut usize, + depth: usize, +} + +impl<'de> Deserialize<'de> for InlineValidated { + fn deserialize>(deserializer: D) -> Result { + let mut remaining = 16384; + InlineSeed { + remaining: &mut remaining, + depth: 0, + } + .deserialize(deserializer)?; + Ok(Self) + } +} + +impl<'de> DeserializeSeed<'de> for InlineSeed<'_> { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + if self.depth > 64 || *self.remaining == 0 { + return Err(de::Error::custom("stored_inline_payload_traversal_limit")); + } + *self.remaining -= 1; + deserializer.deserialize_ignored_any(self) + } +} + +impl<'de> Visitor<'de> for InlineSeed<'_> { + type Value = (); + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a bounded finite Avro Value") + } + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } + fn visit_bool(self, _: bool) -> Result<(), E> { + Ok(()) + } + fn visit_i64(self, _: i64) -> Result<(), E> { + Ok(()) + } + fn visit_f64(self, value: f64) -> Result<(), E> { + if value.is_finite() { + Ok(()) + } else { + Err(E::custom("non_finite_float")) + } + } + fn visit_str(self, _: &str) -> Result<(), E> { + Ok(()) + } + fn visit_string(self, _: String) -> Result<(), E> { + Ok(()) + } + fn visit_byte_buf(self, _: Vec) -> Result<(), E> { + Ok(()) + } + fn visit_seq>(self, mut values: A) -> Result<(), A::Error> { + while values + .next_element_seed(InlineSeed { + remaining: self.remaining, + depth: self.depth + 1, + })? + .is_some() + {} + Ok(()) + } + fn visit_map>(self, mut values: A) -> Result<(), A::Error> { + while values.next_key::()?.is_some() { + values.next_value_seed(InlineSeed { + remaining: self.remaining, + depth: self.depth + 1, + })?; + } + Ok(()) + } +} impl<'de> Deserialize<'de> for Key { fn deserialize>(deserializer: D) -> Result { struct Identifier; @@ -109,6 +193,30 @@ impl Read for WhitespaceReader { } impl ValueCodec { + /// Inspect one stored inline frame without constructing arrays/maps. The + /// standalone upgrade command sets Apache's allocation ceiling before use. + pub fn validate_stored_inline(&self, blob: &str) -> Result<(), CodecError> { + use super::CompleteInput; + use base64::{Engine, engine::general_purpose::STANDARD}; + if blob.len() > 65536 { + return Err(CodecError::InvalidValue); + } + let bytes = STANDARD.decode(blob)?; + let mut input = CompleteInput { + remaining: bytes.as_slice(), + truncated: false, + }; + let result = self.reader.read_deser::(&mut input); + if input.truncated { + return Err(CodecError::Truncated); + } + result?; + if !input.remaining.is_empty() { + return Err(CodecError::TrailingBytes); + } + Ok(()) + } + /// The external PHP boundary permits whitespace, omitted padding and /// noncanonical padding bits. Integrity measures original encoded bytes; /// this adapter never rewrites the stored object or its reference hash. @@ -138,6 +246,65 @@ mod tests { use base64::{Engine, engine::general_purpose::STANDARD}; use std::collections::BTreeMap; + #[test] + fn stored_inline_validation_preserves_all_value_branches_without_materializing_collections() { + let codec = ValueCodec::new().unwrap(); + for value in [ + Value::Null, + Value::Boolean(false), + Value::Long(9007199254740993), + Value::Double(-0.0), + Value::Bytes(b"O:6:\"Sample\":0:{}".to_vec()), + Value::String("日本語 λ".into()), + Value::Array(vec![Value::Null]), + Value::Map(BTreeMap::from([("0".into(), Value::Long(i64::MIN))])), + ] { + let blob = codec.encode(&value).unwrap(); + let original = blob.clone(); + codec.validate_stored_inline(&blob).unwrap(); + assert_eq!(blob, original); + assert_eq!(codec.decode(&blob).unwrap(), value); + } + } + + #[test] + fn stored_inline_validation_refuses_corrupt_deep_and_excessive_values() { + let codec = ValueCodec::new().unwrap(); + let bytes = STANDARD + .decode(codec.encode(&Value::Long(7)).unwrap()) + .unwrap(); + for length in 0..bytes.len() { + assert!( + codec + .validate_stored_inline(&STANDARD.encode(&bytes[..length])) + .is_err() + ); + } + let mut trailing = bytes; + trailing.push(0); + assert!( + codec + .validate_stored_inline(&STANDARD.encode(trailing)) + .is_err() + ); + assert!(codec.validate_stored_inline("invalid!").is_err()); + let mut deep = Value::Null; + for _ in 0..100 { + deep = Value::Array(vec![deep]); + } + assert!( + codec + .validate_stored_inline(&codec.encode(&deep).unwrap()) + .is_err() + ); + let wide = codec + .encode(&Value::Array(vec![Value::Null; 20000])) + .unwrap(); + assert!(wide.len() < 65536); + assert!(codec.validate_stored_inline(&wide).is_err()); + assert!(codec.validate_stored_inline(&"A".repeat(65537)).is_err()); + } + #[test] fn schema_traversal_validates_nested_types_and_external_base64_without_collections() { let codec = ValueCodec::new().unwrap(); diff --git a/rust/src/main.rs b/rust/src/main.rs index 0d381b0e1..4409ec5d0 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -144,6 +144,14 @@ async fn main() -> Result<(), Box> { use durable_workflow_server::runtime::upgrade::{ self, queue_binding::QueueSourceConfig, }; + // Apache's allocation setting is process-global and write-once. + // This standalone command sets its small ceiling before any Avro + // decoder is constructed; the HTTP runtime keeps its own policy. + if command == "upgrade-payload-inspect" + && apache_avro::util::max_allocation_bytes(65536) != 65536 + { + return Err("upgrade_inline_codec_budget_unavailable".into()); + } // These describe the original Laravel queue transport, not SDK // task queues or a newly selected native execution destination. let queue_options = if command == "upgrade-queue-inspect" { diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index f8060c51d..26272fb57 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -119,7 +119,9 @@ fn report( report["queue_inspection"] = queues; } if let Some(payloads) = payload_inspection { - if payloads["references_metadata_supported"] != true { + if payloads["references_metadata_supported"] != true + || payloads["inline_values_verified"] != true + { report["status"] = json!("unqualified"); } report["payload_inspection"] = payloads; diff --git a/rust/src/runtime/upgrade/payload_scan.rs b/rust/src/runtime/upgrade/payload_scan.rs index 12724a272..172e7002c 100644 --- a/rust/src/runtime/upgrade/payload_scan.rs +++ b/rust/src/runtime/upgrade/payload_scan.rs @@ -1,5 +1,5 @@ -//! Root-column reference metadata only. Inline values, nested history, -//! provider bytes and registry ownership remain unqualified. +//! Known root-column Avro frames and external-reference metadata only. +//! Nested history, provider bytes and registry ownership remain unqualified. use super::external_reference::{ReferenceDecodeError, inspect_php_reference}; use crate::runtime::{Result, backend::Backend, refuse}; use axum::http::StatusCode; @@ -84,6 +84,7 @@ struct Counts { rows: i64, absent: i64, other_unvalidated: i64, + avro_inline_values: i64, php_v1_references: i64, unqualified_references: i64, } @@ -111,6 +112,14 @@ where let mut columns = Vec::new(); let mut diagnostics = Vec::new(); let mut omitted = 0_i64; + let mut references_supported = true; + let mut inline_supported = true; + let codec = crate::codec::ValueCodec::new().map_err(|_| { + refuse( + StatusCode::SERVICE_UNAVAILABLE, + "upgrade_source_inline_codec_unavailable", + ) + })?; for column in COLUMNS { let sql = if DB::NAME == "SQLite" { column.sqlite_sql @@ -145,7 +154,30 @@ where if !prefix.starts_with("dw-external-payload:") && !prefix.starts_with("dw-native-external-payload-") { - counts.other_unvalidated += 1; + let invalid = if bytes > 65536 { + Some("stored_inline_payload_too_large") + } else if DB::optional_string(&row, "codec")?.as_deref() != Some("avro") { + Some("stored_inline_codec_unqualified") + } else if codec + .validate_stored_inline(&DB::string(&row, "stored_value")?) + .is_err() + { + Some("stored_inline_frame_unqualified") + } else { + None + }; + if let Some(reason) = invalid { + inline_supported = false; + counts.other_unvalidated += 1; + if diagnostics.len() < MAX_DIAGNOSTICS { + diagnostics.push(json!({"table":column.table,"column":column.name, + "record_id":cursor,"reason":reason})); + } else { + omitted += 1; + } + } else { + counts.avro_inline_values += 1; + } continue; } let decoded = if bytes > 65536 { @@ -159,6 +191,7 @@ where Ok(Some(_)) => counts.php_v1_references += 1, Ok(None) => unreachable!("reference prefixes were checked before decoding"), Err(error) => { + references_supported = false; counts.unqualified_references += 1; if diagnostics.len() < MAX_DIAGNOSTICS { diagnostics.push(json!({"table":column.table,"column":column.name, @@ -182,9 +215,11 @@ where } Ok(json!({ "status":"known_columns_inspected", "columns":columns, - "references_metadata_supported":diagnostics.is_empty() && omitted==0, + "references_metadata_supported":references_supported, "diagnostics":diagnostics, "diagnostics_omitted":omitted, - "inline_values_verified":false, "nested_payloads_verified":false, + "inline_values_verified":inline_supported, "nested_payloads_verified":false, + "inline_validation_limits":{"encoded_frame_bytes":65536,"traversal_depth":64, + "traversal_visits":16384,"scalar_allocation_bytes":apache_avro::util::max_allocation_bytes(apache_avro::util::DEFAULT_MAX_ALLOCATION_BYTES)}, "provider_bytes_verified":false, "registry_binding_verified":false, "source_mutated":false, "takeover_allowed":false })) diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index 027c33ce0..46cdff547 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -351,6 +351,97 @@ fn column_counts<'a>(report: &'a Value, table: &str, column: &str) -> &'a Value .unwrap()["counts"] } +#[tokio::test] +async fn payload_scan_validates_inline_pages_and_reports_late_bad_values_without_rewriting() { + use crate::codec::{Value as Payload, ValueCodec}; + let (_directory, path) = php_layout().await; + let codec = ValueCodec::new().unwrap(); + let arguments = codec + .encode(&Payload::Array(vec![ + Payload::Long(9007199254740993), + Payload::Null, + Payload::String("日本語 λ".into()), + Payload::Bytes(b"O:6:\"Sample\":0:{}".to_vec()), + ])) + .unwrap(); + let result = codec.encode(&Payload::Boolean(false)).unwrap(); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .foreign_keys(false), + ) + .await + .unwrap(); + for index in 0..33 { + payload_run( + &mut connection, + &format!("run-{index:02}"), + Some(&arguments), + Some(&result), + "avro", + ) + .await; + } + sqlx::query("INSERT INTO activity_executions(id,workflow_run_id,sequence,activity_class,activity_type,status,payload_codec,arguments,result) VALUES ('inline-activity','run-00',1,'SourceFixture','source.fixture','completed','avro',?,?)") + .bind(&arguments).bind(codec.encode(&Payload::Null).unwrap()).execute(&mut connection).await.unwrap(); + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_payloads(&path).await.unwrap(); + assert_eq!(report["status"], "inspected"); + assert_eq!(report["payload_inspection"]["inline_values_verified"], true); + assert_eq!(report["payload_inspection"]["diagnostics"], json!([])); + for (table, column, count) in [ + ("workflow_runs", "arguments", 33), + ("workflow_runs", "output", 33), + ("activity_executions", "arguments", 1), + ("activity_executions", "result", 1), + ] { + assert_eq!( + column_counts(&report, table, column)["avro_inline_values"], + count + ); + } + assert_eq!( + report["stored_representation_compatibility_verified"], + false + ); + assert_eq!(report["takeover_allowed"], false); + assert_eq!(std::fs::read(&path).unwrap(), before); + for (sql, expected) in [ + ( + "UPDATE workflow_runs SET arguments='private-invalid-inline' WHERE id='run-32'", + "stored_inline_frame_unqualified", + ), + ( + "UPDATE workflow_runs SET payload_codec='legacy' WHERE id='run-32'", + "stored_inline_codec_unqualified", + ), + ( + "UPDATE workflow_runs SET payload_codec=NULL WHERE id='run-32'", + "stored_inline_codec_unqualified", + ), + ] { + mutate(&path, sql).await; + let before = std::fs::read(&path).unwrap(); + let report = inspect_sqlite_payloads(&path).await.unwrap(); + assert_eq!(report["status"], "unqualified"); + assert_eq!( + report["payload_inspection"]["inline_values_verified"], + false + ); + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["record_id"], + "run-32" + ); + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["reason"], + expected + ); + assert!(!report.to_string().contains("private-invalid-inline")); + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} + #[tokio::test] async fn payload_scan_checks_all_four_columns_and_every_page_without_mutation_or_locations() { let (_directory, path) = php_layout().await; @@ -383,14 +474,14 @@ async fn payload_scan_checks_all_four_columns_and_every_page_without_mutation_or connection.close().await.unwrap(); let before = std::fs::read(&path).unwrap(); let report = inspect_sqlite_payloads(&path).await.unwrap(); - assert_eq!(report["status"], "inspected"); + assert_eq!(report["status"], "unqualified"); assert_eq!( report["payload_inspection"]["references_metadata_supported"], true ); assert_eq!( column_counts(&report, "workflow_runs", "arguments"), - &json!({"rows":33,"absent":1,"other_unvalidated":31,"php_v1_references":1,"unqualified_references":0}) + &json!({"rows":33,"absent":1,"other_unvalidated":31,"avro_inline_values":0,"php_v1_references":1,"unqualified_references":0}) ); assert_eq!( column_counts(&report, "workflow_runs", "output")["php_v1_references"], @@ -402,7 +493,18 @@ async fn payload_scan_checks_all_four_columns_and_every_page_without_mutation_or 1 ); } - assert_eq!(report["payload_inspection"]["diagnostics"], json!([])); + assert_eq!( + report["payload_inspection"]["diagnostics"] + .as_array() + .unwrap() + .len(), + 16 + ); + assert_eq!(report["payload_inspection"]["diagnostics_omitted"], 15); + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["reason"], + "stored_inline_payload_too_large" + ); for flag in [ "inline_values_verified", "nested_payloads_verified", diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 131c4828b..4ceeb74a9 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -58,3 +58,131 @@ fn special_sqlite_sources_refuse_without_waiting_for_a_writer() { } assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); } + +#[tokio::test] +async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_original_bytes() { + use base64::{Engine, engine::general_purpose::STANDARD}; + use durable_workflow_server::codec::{Value, ValueCodec}; + use sqlx::{ + Connection, Executor, SqliteConnection, + sqlite::{SqliteConnectOptions, SqliteJournalMode}, + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("source.sqlite"); + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true) + .foreign_keys(false) + .journal_mode(SqliteJournalMode::Delete), + ) + .await + .unwrap(); + sqlx::raw_sql(concat!( + include_str!("../migrations/sqlite/0003_full_schema.sql"), + "\n", + include_str!("../migrations/sqlite/0004_worker_incarnations.sql") + )) + .execute(&mut connection) + .await + .unwrap(); + for table in [ + "dw_worker_registration_incarnations", + "dw_task_completions", + "dw_poll_receipts", + "dw_query_cache", + "dw_server_schema", + ] { + sqlx::query(sqlx::AssertSqlSafe(format!("DROP TABLE {table}"))) + .execute(&mut connection) + .await + .unwrap(); + } + connection + .execute("DROP INDEX dw_workflow_tasks_poll") + .await + .unwrap(); + sqlx::query("INSERT INTO workflow_runs(id,workflow_instance_id,run_number,workflow_class,workflow_type,status,payload_codec) VALUES ('run','workflow',1,'SourceFixture','source.fixture','waiting','avro')") + .execute(&mut connection).await.unwrap(); + let codec = ValueCodec::new().unwrap(); + let valid = codec + .encode(&Value::Array(vec![ + Value::Long(9007199254740993), + Value::Null, + Value::String("λ".into()), + ])) + .unwrap(); + // Only the length is corrupt. Apache produces the length encoding and owns + // parsing it in the child; no second production Avro implementation exists. + let mut oversized = STANDARD + .decode(codec.encode(&Value::String(String::new())).unwrap()) + .unwrap(); + assert_eq!(oversized.pop(), Some(0)); + oversized.extend( + apache_avro::to_avro_datum( + &apache_avro::Schema::Long, + apache_avro::types::Value::Long(500 * 1024 * 1024), + ) + .unwrap(), + ); + connection.close().await.unwrap(); + for (blob, expected) in [ + (valid, "inspected"), + (STANDARD.encode(oversized), "unqualified"), + ] { + let mut connection = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(&path)) + .await + .unwrap(); + sqlx::query("UPDATE workflow_runs SET arguments=? WHERE id='run'") + .bind(blob) + .execute(&mut connection) + .await + .unwrap(); + connection.close().await.unwrap(); + let before = std::fs::read(&path).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) + .env_clear() + .env("DW_RUST_EXPERIMENTAL", "1") + .env("DB_CONNECTION", "sqlite") + .env("DB_DATABASE", &path) + .arg("upgrade-payload-inspect") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("payload inspection did not retire its process"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["status"], expected); + assert_eq!( + report["payload_inspection"]["inline_validation_limits"]["scalar_allocation_bytes"], + 65536 + ); + assert_eq!(report["takeover_allowed"], false); + assert_eq!( + report["stored_representation_compatibility_verified"], + false + ); + if expected == "unqualified" { + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["reason"], + "stored_inline_frame_unqualified" + ); + } + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} From 6eb7bcfeabab47809471e62bf5576823f0a749e5 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:10:00 +0000 Subject: [PATCH 17/26] Construct the deep inspection fixture on a separate producer stack --- rust/src/codec/validation.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/rust/src/codec/validation.rs b/rust/src/codec/validation.rs index 988f5825d..4b5bdc2f6 100644 --- a/rust/src/codec/validation.rs +++ b/rust/src/codec/validation.rs @@ -288,15 +288,23 @@ mod tests { .is_err() ); assert!(codec.validate_stored_inline("invalid!").is_err()); - let mut deep = Value::Null; - for _ in 0..100 { - deep = Value::Array(vec![deep]); - } - assert!( - codec - .validate_stored_inline(&codec.encode(&deep).unwrap()) - .is_err() - ); + // Fixture encoding is a separate existing recursive path. Give that + // producer stack space; validation still runs on the ordinary test + // thread, against the unchanged hundred-level encoded original. + let deep = std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(|| { + let codec = ValueCodec::new().unwrap(); + let mut deep = Value::Null; + for _ in 0..100 { + deep = Value::Array(vec![deep]); + } + codec.encode(&deep).unwrap() + }) + .unwrap() + .join() + .unwrap(); + assert!(codec.validate_stored_inline(&deep).is_err()); let wide = codec .encode(&Value::Array(vec![Value::Null; 20000])) .unwrap(); From 2e6c04ade2819c18befa1e643fa97c6449bf8723 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:13:02 +0000 Subject: [PATCH 18/26] Preserve the existing CLI refusal for unqualified payload reports --- rust/tests/upgrade_inspect.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 4ceeb74a9..07806f421 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -118,13 +118,14 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina .decode(codec.encode(&Value::String(String::new())).unwrap()) .unwrap(); assert_eq!(oversized.pop(), Some(0)); - oversized.extend( - apache_avro::to_avro_datum( - &apache_avro::Schema::Long, - apache_avro::types::Value::Long(500 * 1024 * 1024), + apache_avro::GenericDatumWriter::new(&apache_avro::Schema::Long) + .build() + .unwrap() + .write_value_ref( + &mut oversized, + &apache_avro::types::Value::Long(500 * 1024 * 1024), ) - .unwrap(), - ); + .unwrap(); connection.close().await.unwrap(); for (blob, expected) in [ (valid, "inspected"), @@ -161,8 +162,9 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina std::thread::sleep(Duration::from_millis(10)); } let output = child.wait_with_output().unwrap(); - assert!( + assert_eq!( output.status.success(), + expected == "inspected", "{}", String::from_utf8_lossy(&output.stderr) ); @@ -178,6 +180,10 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina false ); if expected == "unqualified" { + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("upgrade_source_payload_metadata_unqualified") + ); assert_eq!( report["payload_inspection"]["diagnostics"][0]["reason"], "stored_inline_frame_unqualified" From a72340ee390b21d83f949f3886c61b3715216009 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:14:50 +0000 Subject: [PATCH 19/26] Use the published Apache datum writer module --- rust/tests/upgrade_inspect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 07806f421..f45524b41 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -118,7 +118,7 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina .decode(codec.encode(&Value::String(String::new())).unwrap()) .unwrap(); assert_eq!(oversized.pop(), Some(0)); - apache_avro::GenericDatumWriter::new(&apache_avro::Schema::Long) + apache_avro::writer::datum::GenericDatumWriter::new(&apache_avro::Schema::Long) .build() .unwrap() .write_value_ref( From 1b51e40a0e5eeb99670e1490d1b922d8435f9629 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:16:18 +0000 Subject: [PATCH 20/26] Use the documented datum writer builder for the corrupt length fixture --- rust/tests/upgrade_inspect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index f45524b41..21be642c8 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -118,7 +118,7 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina .decode(codec.encode(&Value::String(String::new())).unwrap()) .unwrap(); assert_eq!(oversized.pop(), Some(0)); - apache_avro::writer::datum::GenericDatumWriter::new(&apache_avro::Schema::Long) + apache_avro::writer::datum::GenericDatumWriter::builder(&apache_avro::Schema::Long) .build() .unwrap() .write_value_ref( From 5fe19c19b5a5aa3f760fc8a8efe27e24b682d160 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:26:42 +0000 Subject: [PATCH 21/26] Require bounded inline validation in the stopped PHP source recipe --- docs/rust-server-upgrade.md | 61 +++++++++++++++++-- .../server-parity/upgrade-source-profile.sh | 12 +++- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index e3c326784..ddf209aae 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -69,6 +69,54 @@ qualification and refuse this first inventory path. ## Qualification and next migration gates +The separate `upgrade-payload-inspect` command now also checks recorded `avro` +frames in `workflow_runs.arguments`, `workflow_runs.output`, +`activity_executions.arguments` and `activity_executions.result`. It uses Apache +Avro's schema traversal without retaining decoded arrays or maps. It reads +32 rows per primary-key page, caps encoded frames at 64 KiB, and refuses unknown +or missing codecs, malformed/trailing frames, more than 64 traversal levels or +16,384 visits. Before any decoder is created, this standalone command sets and +verifies Apache's process-wide scalar allocation ceiling of 64 KiB; HTTP runtime +allocation policy is unchanged. Oversized stored values are masked in SQL +before transfer to the client. + +The report distinguishes validated inline counts from unsupported values and +external-reference metadata. `inline_values_verified` applies only to these +enumerated root columns; nested history, provider bytes, registry ownership and +complete representation remain unverified. Refusal prints the bounded report +and exits nonzero, preserving source data. Known valid frames retain exact +int64, null, Unicode and byte-string semantics; PHP-looking text in a byte +string is never deserialized as an object. + +Preparation `c12fe089` passes 112 library tests, two actual CLI tests and 133 +SQLite execution tests, with 18 existing library ignores. A malformed frame +declaring a 500 MiB scalar is refused under the actual CLI ceiling without +changing source bytes. These are constructed source controls; the earlier +published-PHP PostgreSQL observation left 22 inline values unvalidated and is +not retroactively qualified by this source change. + +Source `03b3bd3c`, native tree `029ab981`, separately passes the actual stopped +PHP 2.8.5 image `f6a6507a` recipe on SQLite DELETE, MySQL 8.4 and PostgreSQL 17. +Each database has two original pending runs, three inline argument frames and +one queued timer bound to its original canonical task. All three inspector +commands pass, with the actual 64 KiB allocation ceiling. Source fingerprints +remain exact: SQLite database bytes and network logical rows/sequences, not +network physical files or backups. Denied readers, unknown catalogs, ownership +markers and PostgreSQL row security refuse where applicable. APIs and databases +exit 0 without OOM after the observation and owned cleanup. + +These six raw-protocol originals contain int64, null and Unicode arguments; +their run outputs and activity results are absent. This does not add completed +result, nested history, provider-byte or actual SDK continuation qualification. +The 22 earlier inline values remain the earlier unvalidated observation. +The full hosted database-version matrix, complete representation and every +takeover gate remain open. + +The published-source recipe retains its immutable PHP 2.8.0 default. A separate +observation may explicitly set `DW_PARITY_UPGRADE_PHP_IMAGE` to an official +`ghcr.io/durable-workflow/server@sha256:...` image; the exact selected digest is +recorded in its outcome. This does not change any frozen differential actor. + Catalog recognition does not decode stored values. The source audit identifies these separate compatibility boundaries: @@ -114,8 +162,8 @@ fields remain original strings, not recalculated task deadlines. The committed queue fixtures are produced by serializing the real classes in the pinned published image without dispatching them. This independently tested decoder grants no conversion, deletion, execution or takeover authority. -Complete native integration and actual same-database queue qualification remain -separate gates. +The read-only source-row binding below is a separate check; actual replacement +of PHP queue execution remains unqualified. A separate genuine published PHP 2.8.2 source fixture now contains one queued timer job, its original task/run, and a separate leased activity. Its outer @@ -162,8 +210,13 @@ disagreements refuse without a partial success report. The output adds only job-kind counts, not payloads or identities. No jobs are deleted or executed, and all takeover, backup, quiescence and complete representation flags remain false. An empty database queue does not establish the state of Redis, another -queue connection or another transport. The hosted published-source recipe now -requires this separate component; its fresh all-database execution is pending. +queue connection or another transport. The published-source recipe now requires +this separate component. Local source `5d54f25e` passes the actual recipe on +SQLite DELETE, MySQL 8.4 and PostgreSQL 17, with two original pending runs and +one timer job per database. Inspection preserves each source snapshot, and +denied access, unknown catalogs, ownership markers and PostgreSQL row security +remain explicit refusals where applicable. The full hosted database-version +matrix and subsequent migration gates remain pending. The separate published PHP 2.8.0 profile prepares two genuine original runs: one leased activity and one pending timer. It explicitly stops PHP before diff --git a/scripts/conformance/server-parity/upgrade-source-profile.sh b/scripts/conformance/server-parity/upgrade-source-profile.sh index 302a8bc12..2c7e58f5c 100755 --- a/scripts/conformance/server-parity/upgrade-source-profile.sh +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -5,7 +5,11 @@ test "$#" = 1 || exit 2 backend=$1 test -n "${RESOURCE_SCOPE:?Set the owning isolated network}" docker network inspect "$RESOURCE_SCOPE" >/dev/null -image=ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac +# The frozen default remains PHP 2.8.0. A separate published-source observation +# may explicitly select another immutable official image; its identity is +# recorded in the outcome and does not retuple the default differential actors. +image=${DW_PARITY_UPGRADE_PHP_IMAGE:-ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac} +[[ "$image" =~ ^ghcr.io/durable-workflow/server@sha256:[0-9a-f]{64}$ ]] || exit 2 rust_image=mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 resource="$RESOURCE_SCOPE-upgrade" state=parity-upgrade-source-state @@ -119,7 +123,11 @@ inspect_source upgrade-payload-inspect > "$evidence/payload-inspection.json" 2> jq -e '.status=="inspected" and (.payload_inspection.columns|length)==4 and .payload_inspection.references_metadata_supported==true and ([.payload_inspection.columns[].counts.php_v1_references]|add)==0 - and ([.payload_inspection.columns[].counts.other_unvalidated]|add)==3 + and ([.payload_inspection.columns[].counts.avro_inline_values]|add)==3 + and ([.payload_inspection.columns[].counts.other_unvalidated]|add)==0 + and .payload_inspection.inline_values_verified==true + and .payload_inspection.inline_validation_limits=={encoded_frame_bytes:65536,traversal_depth:64,traversal_visits:16384,scalar_allocation_bytes:65536} + and .payload_inspection.nested_payloads_verified==false and .payload_inspection.provider_bytes_verified==false and .payload_inspection.registry_binding_verified==false and .source_mutated==false and .takeover_allowed==false and .backup_verified==false From 870b733a316b32c49017c8eca21a881f1471bf69 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:27:17 +0000 Subject: [PATCH 22/26] Use the explicitly selected PHP image for owned source cleanup --- scripts/conformance/server-parity/upgrade-source-cleanup.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/server-parity/upgrade-source-cleanup.sh b/scripts/conformance/server-parity/upgrade-source-cleanup.sh index 0fa92b21d..35b995418 100755 --- a/scripts/conformance/server-parity/upgrade-source-cleanup.sh +++ b/scripts/conformance/server-parity/upgrade-source-cleanup.sh @@ -9,7 +9,8 @@ state=parity-upgrade-source-state test -f "$evidence/owner.txt" || exit 0 test "$(cat "$evidence/owner.txt")" = "$RESOURCE_SCOPE" || exit 2 resource="$RESOURCE_SCOPE-upgrade" -image=ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac +image=${DW_PARITY_UPGRADE_PHP_IMAGE:-ghcr.io/durable-workflow/server@sha256:cba7c4c6732529293ef6c2625a64785dcb21b4f7fe0013fdc74f1af466f534ac} +[[ "$image" =~ ^ghcr.io/durable-workflow/server@sha256:[0-9a-f]{64}$ ]] || exit 2 for role in author control inspect php; do if docker inspect "$resource-$role" >/dev/null 2>&1; then docker stop --signal SIGTERM --time 5 "$resource-$role" >/dev/null From 58542a5e84f7f1fd03feee4560eb3b3f8216c4b1 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 14:45:19 +0000 Subject: [PATCH 23/26] Describe isolated upgrade qualification and preserved failed originals --- docs/rust-server-port.md | 43 ++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 117b2b944..6ad44139f 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -6,13 +6,14 @@ The unpublished Rust crate has an Avro foundation and an opt-in SQLite/PostgreSQ execution slice. Full runtime parity, migration, performance improvement and cutover remain unqualified. -The latest qualified merged foundation is [#457](https://github.com/durable-workflow/server/pull/457), -at `303ffc633`, including #445's memo transaction preparation, #446's bounded -continuation, #448's SDK memo authoring and #450's local external transport. -Its separate fresh-main [38668891437](https://github.com/durable-workflow/server/actions/runs/38668891437) -passes all 13 jobs across six databases; all six execution logs were read. -Required PHP/source and boundary checks also pass. Earlier failed observations -remain failed; this current-tree result is not carried across later changes. +The delivered foundation is [#463](https://github.com/durable-workflow/server/pull/463), +at `0934c46d`, whole tree `98775e68`, native tree `a633a639`. It includes the +earlier #455/#458 foundation and #457 consumers. Exact fresh-main +[38738455163](https://github.com/durable-workflow/server/actions/runs/38738455163) +passes all 13 jobs across six databases. Each execution summary independently +passes 29 comparisons, 20 exit records and 125 native HTTP tests. Required +PHP/source and boundary checks pass. Earlier failed originals remain failed; +these source results qualify no later tree, image or complete query family. The merged native development runtime enables memo upserts, while full external transport discovery stays unadvertised. Frozen published PHP, embedded and SDK tuples remain separate from current PHP release work. Representative timer, signal, @@ -22,7 +23,33 @@ failed; later qualifying runs are separate observations. ## Work order and current status -The current integration order is: +The next independent qualification stages are: + +- [#460](https://github.com/durable-workflow/server/pull/460) is isolated on + query main with read-only inventory, queue binding, external-reference + metadata and bounded inline Avro inspection. Integration `870b733a`, native + tree `180e0104`, needs fresh exact-tree qualification. It includes no pending + metadata/result endpoint or worker capability change. Earlier combined + source `03b3bd3c` separately passes stopped published PHP 2.8.5 originals on + SQLite DELETE, MySQL 8.4 and PostgreSQL 17: six pending runs, nine inline + argument frames and three bound timer jobs. That component has no populated + workflow outputs or activity results. Backup, quiescence, complete + representation and takeover remain false. See the + [upgrade bounds and remaining gates](rust-server-upgrade.md). +- Activity metadata #476 candidate `062ce91a` retains its original SQLite + recorder failure and five passing network originals. Exactly one same-SHA, + unchanged-budget SQLite-only repeat is running; the other five were not + rerun. Its original peer/poll phase/cause remain unknown. +- Targeted result #470 candidate `d83a3cb9` retains a MySQL 8.4 preadmission + image-pull failure and a PostgreSQL 17 renewal observer failure. A separate + diagnostic runner `01046823` passes fresh local PostgreSQL originals using + the exact d83 binary. It does not reproduce, explain or replace the CI + failure. Both endpoint branches remain separate from this inspector. + +## Earlier qualification stages + +The following entries preserve earlier components and failed originals. Their +pending descriptions are historical, not the current disposition above. - Merged [#445](https://github.com/durable-workflow/server/pull/445), [#446](https://github.com/durable-workflow/server/pull/446), From 7066a50ef10cd25958fc6ad5bfd305a7d901e969 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Thu, 15 Oct 2026 16:30:49 +0000 Subject: [PATCH 24/26] Require read-only source and parent mounts for SQLite inspection --- .github/workflows/server-parity.yml | 1 + docs/rust-server-port.md | 12 +++ docs/rust-server-upgrade.md | 8 +- rust/src/main.rs | 14 ++-- rust/src/runtime/upgrade.rs | 71 ++++++++++++++++ rust/tests/upgrade_inspect.rs | 123 +++++++++++++++++++--------- 6 files changed, 180 insertions(+), 49 deletions(-) diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 000d58d64..24002c129 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -1322,6 +1322,7 @@ jobs: mkdir -p cargo-home rust-target parity-evidence docker run --rm --user="$(id -u):$(id -g)" --cpus=2 --memory=2g --memory-swap=2g \ -e CARGO_HOME=/cargo -e CARGO_TARGET_DIR=/target -e CARGO_BUILD_JOBS=2 \ + -e DW_UPGRADE_TEST_WRITABLE=/evidence -e DW_UPGRADE_TEST_READONLY=/source/parity-evidence \ -v "$PWD:/source:ro" -v "$PWD/cargo-home:/cargo" -v "$PWD/rust-target:/target" \ -v "$PWD/parity-evidence:/evidence" -w /source/rust --entrypoint bash \ mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0 -c \ diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index 6ad44139f..e15159ec7 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -229,6 +229,18 @@ Broader runtime/operational parity, safe sequential PHP database takeover, matched performance measurements and release/cutover preparation remain #325 requirements. No native release or deployment is qualified. +Inspector `58542a5e` passes hosted run `38754495860` across all six databases, +but promotion is held for a separate constructed path-replacement control. +After its header check, SQLite can reopen a substituted WAL file and create +sidecars on a writable mount before refusing the catalog. Main-file bytes stay +exact. The correction enforces the documented kernel read-only mount for the +source and resolved parent directory at the CLI boundary, before SQLite opens. +It retains SQLite locking, header/journal refusal and the original overall +30-second budget. Hosted CLI controls use read-only and writable aliases of the +same private fixture to prove writable refusal and actual valid/malformed Avro +decoding. New exact-source acceptance is still required; the older passes do +not establish the corrected boundary or takeover safety. + Completed local raw archives, transport journals and databases have been deleted. Historical retention descriptions below do not guarantee that those bytes are still available. Code, readable owning outcomes and bounded temporary job diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index ddf209aae..168a623e0 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -20,8 +20,12 @@ SQLite WAL inspection is initially refused before opening SQLite: a read-only SQLite connection can still create shared-memory and WAL sidecars. This first path accepts rollback-journal databases only and does not change their journal mode. WAL inventory requires a separately qualified consistent snapshot path. -Use a read-only filesystem mount as an additional boundary and stop writers -before inspection; the report does not certify that they have stopped. +The CLI requires a kernel read-only mount for both the source file and its +resolved parent directory. A read-only file mount with a writable parent is +insufficient because sidecars are created beside the file. A writable mount +refuses with `upgrade_source_readonly_filesystem_required` before SQLite opens. +Stop writers before inspection; the mount check does not prove that another +process cannot write through a different mount or certify that writers stopped. With the development binary and existing connection environment configured, run `DW_RUST_EXPERIMENTAL=1 durable-workflow-server upgrade-inspect`. diff --git a/rust/src/main.rs b/rust/src/main.rs index 4409ec5d0..22abcb22c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -179,14 +179,12 @@ async fn main() -> Result<(), Box> { let result = match backend.as_str() { "sqlite" => { let path = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; - if command == "upgrade-payload-inspect" { - upgrade::inspect_sqlite_payloads(&path).await - } else { - match queues.as_ref() { - Some(config) => upgrade::inspect_sqlite_queues(&path, config).await, - None => upgrade::inspect_sqlite(&path).await, - } - } + upgrade::inspect_sqlite_cli( + &path, + queues.as_ref(), + command == "upgrade-payload-inspect", + ) + .await } "mysql" | "mariadb" => { if command == "upgrade-payload-inspect" { diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 26272fb57..3d3401193 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -179,6 +179,77 @@ pub async fn inspect_sqlite_payloads(path: &str) -> Result { inspect_sqlite_inner(path, None, true).await } +/// The operator CLI requires the documented kernel read-only mount. SQLite's +/// READONLY flag alone does not prohibit creating WAL/shared-memory sidecars +/// if another process replaces a checked path before SQLite opens it. +pub async fn inspect_sqlite_cli( + path: &str, + queues: Option<&QueueSourceConfig<'_>>, + payloads: bool, +) -> Result { + timeout(BUDGET, async { + queue_config(queues)?; + if path.is_empty() || path.contains(['?', ':']) { + return Err(refuse( + StatusCode::BAD_REQUEST, + "upgrade_source_file_required", + )); + } + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SQLite resolves symlinks before locating its sidecars. Check the + // resolved parent as well: a file-only read-only bind mount can + // still have a writable directory beside it. + let resolved = tokio::fs::canonicalize(path).await?; + let mut options = tokio::fs::OpenOptions::new(); + options.read(true).custom_flags(libc::O_NONBLOCK); + let file = options.open(&resolved).await?; + if !file.metadata().await?.is_file() { + return Err(refuse( + StatusCode::BAD_REQUEST, + "upgrade_source_regular_file_required", + )); + } + let parent = options + .open(resolved.parent().ok_or_else(|| { + refuse(StatusCode::BAD_REQUEST, "upgrade_source_file_required") + })?) + .await?; + for descriptor in [&file, &parent] { + let mut filesystem = std::mem::MaybeUninit::::uninit(); + // fstatvfs uses this live descriptor's mount. Only read the + // returned structure after success. + if unsafe { libc::fstatvfs(descriptor.as_raw_fd(), filesystem.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + let filesystem = unsafe { filesystem.assume_init() }; + if filesystem.f_flag & libc::ST_RDONLY == 0 { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_readonly_filesystem_required", + )); + } + } + return inspect_sqlite_inner( + resolved.to_str().ok_or_else(|| { + refuse(StatusCode::BAD_REQUEST, "upgrade_source_file_required") + })?, + queues, + payloads, + ) + .await; + } + #[cfg(not(unix))] + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_readonly_filesystem_unavailable", + )); + }) + .await + .map_err(|_| expired())? +} + async fn inspect_sqlite_inner( path: &str, queues: Option<&QueueSourceConfig<'_>>, diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 21be642c8..7da0298f2 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -13,61 +13,84 @@ use std::{ fn special_sqlite_sources_refuse_without_waiting_for_a_writer() { let directory = tempfile::tempdir().unwrap(); let fifo = directory.path().join("source.sqlite"); + let regular = directory.path().join("writable.sqlite"); + let header = b"SQLite format 3\0\0\0\x01\x01"; + std::fs::write(®ular, header).unwrap(); let name = CString::new(fifo.as_os_str().as_bytes()).unwrap(); // The path belongs to this test's new private temporary directory. assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); - for (source, command) in [ - (fifo.as_path(), "upgrade-inspect"), - (directory.path(), "upgrade-inspect"), - (fifo.as_path(), "upgrade-queue-inspect"), - (directory.path(), "upgrade-queue-inspect"), - (fifo.as_path(), "upgrade-payload-inspect"), - (directory.path(), "upgrade-payload-inspect"), + for command in [ + "upgrade-inspect", + "upgrade-queue-inspect", + "upgrade-payload-inspect", ] { - let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) - .env_clear() - .env("DW_RUST_EXPERIMENTAL", "1") - .env("DB_CONNECTION", "sqlite") - .env("DB_DATABASE", source) - .env("DW_RUST_UPGRADE_QUEUE_CONNECTION", "database") - .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION", "database") - .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE", "default") - .arg(command) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(3); - while child.try_wait().unwrap().is_none() { - if Instant::now() >= deadline { - child.kill().unwrap(); - child.wait().unwrap(); - panic!("source refusal did not retire the CLI process"); + for (source, reason) in [ + (fifo.as_path(), "upgrade_source_regular_file_required"), + (directory.path(), "upgrade_source_regular_file_required"), + ( + regular.as_path(), + "upgrade_source_readonly_filesystem_required", + ), + ] { + let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) + .env_clear() + .env("DW_RUST_EXPERIMENTAL", "1") + .env("DB_CONNECTION", "sqlite") + .env("DB_DATABASE", source) + .env("DW_RUST_UPGRADE_QUEUE_CONNECTION", "database") + .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_CONNECTION", "database") + .env("DW_RUST_UPGRADE_QUEUE_DEFAULT_QUEUE", "default") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("source refusal did not retire the CLI process"); + } + std::thread::sleep(Duration::from_millis(10)); } - std::thread::sleep(Duration::from_millis(10)); + let output = child.wait_with_output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr).unwrap().contains(reason)); } - let output = child.wait_with_output().unwrap(); - assert!(!output.status.success()); - assert!(output.stdout.is_empty()); - assert!( - String::from_utf8(output.stderr) - .unwrap() - .contains("upgrade_source_regular_file_required") - ); } - assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); + assert_eq!(std::fs::read(®ular).unwrap(), header); + assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 2); } #[tokio::test] -async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_original_bytes() { +async fn payload_cli_refuses_writable_sources_and_checks_mounted_payloads() { use base64::{Engine, engine::general_purpose::STANDARD}; use durable_workflow_server::codec::{Value, ValueCodec}; use sqlx::{ Connection, Executor, SqliteConnection, sqlite::{SqliteConnectOptions, SqliteJournalMode}, }; - let directory = tempfile::tempdir().unwrap(); + // CI mounts its evidence directory through both writable and read-only + // aliases. The child must use the read-only alias, while this producer + // writes the next standalone fixture through the writable one. + let aliases = match ( + std::env::var_os("DW_UPGRADE_TEST_WRITABLE"), + std::env::var_os("DW_UPGRADE_TEST_READONLY"), + ) { + (Some(writable), Some(readonly)) => Some(( + std::path::PathBuf::from(writable), + std::path::PathBuf::from(readonly), + )), + (None, None) => None, + _ => panic!("both upgrade test mount aliases are required"), + }; + let directory = match &aliases { + Some((writable, _)) => tempfile::tempdir_in(writable).unwrap(), + None => tempfile::tempdir().unwrap(), + }; let path = directory.path().join("source.sqlite"); let mut connection = SqliteConnection::connect_with( &SqliteConnectOptions::new() @@ -142,12 +165,34 @@ async fn payload_cli_bounds_allocation_and_preserves_valid_and_malformed_origina .unwrap(); connection.close().await.unwrap(); let before = std::fs::read(&path).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) + let refused = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) .env_clear() .env("DW_RUST_EXPERIMENTAL", "1") .env("DB_CONNECTION", "sqlite") .env("DB_DATABASE", &path) .arg("upgrade-payload-inspect") + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(refused.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&refused.stderr) + .contains("upgrade_source_readonly_filesystem_required") + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + let Some((writable, readonly)) = &aliases else { + // A normal local cargo test still verifies the mandatory writable + // refusal. The hosted mount profile additionally executes both + // actual decoding cases below; absence is explicit in its setup. + continue; + }; + let source = readonly.join(path.strip_prefix(writable).unwrap()); + let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) + .env_clear() + .env("DW_RUST_EXPERIMENTAL", "1") + .env("DB_CONNECTION", "sqlite") + .env("DB_DATABASE", &source) + .arg("upgrade-payload-inspect") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() From 6cf98db105cae010df251e4f53ce6fc62cda9acb Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Mon, 19 Oct 2026 16:49:17 +0000 Subject: [PATCH 25/26] Close inspection gaps in catalogs, queue transfers and report coverage PostgreSQL catalog reads now include non-internal triggers and rules other than a view's own _RETURN rule, so a rule or a trigger calling a built-in function is refused like other unknown objects. MySQL and SQLite already refused them. SQLite does not enforce varchar lengths, so the queue scan measures every transferred text value first and refuses values over 4 KiB instead of loading them. Controls cover an oversized queue name, lease owner and workflow class. MySQL and MariaDB inspection sessions bound lock waits and statement time on the server, so a statement abandoned by the client deadline cannot keep its locks and snapshot. The payload report now states that other root payload columns are not verified and names the published tables whose codec-tagged payloads are not scanned yet. Co-Authored-By: Claude Opus 5.5 --- docs/rust-server-upgrade.md | 4 ++- rust/src/runtime/postgres-catalog.sql | 15 ++++++++++ rust/src/runtime/postgres.rs | 9 ++++++ rust/src/runtime/upgrade.rs | 12 ++++++-- rust/src/runtime/upgrade/payload_scan.rs | 3 ++ rust/src/runtime/upgrade/queue_scan.rs | 35 ++++++++++++++++++++++-- rust/src/runtime/upgrade_tests.rs | 20 ++++++++++++++ 7 files changed, 92 insertions(+), 6 deletions(-) diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md index 168a623e0..421908fc4 100644 --- a/docs/rust-server-upgrade.md +++ b/docs/rust-server-upgrade.md @@ -86,7 +86,9 @@ before transfer to the client. The report distinguishes validated inline counts from unsupported values and external-reference metadata. `inline_values_verified` applies only to these -enumerated root columns; nested history, provider bytes, registry ownership and +enumerated root columns. `other_root_payload_columns_verified` stays false and +`unscanned_payload_codec_tables` names the tables whose codec-tagged payloads +are not scanned yet. Nested history, provider bytes, registry ownership and complete representation remain unverified. Refusal prints the bounded report and exits nonzero, preserving source data. Known valid frames retain exact int64, null, Unicode and byte-string semantics; PHP-looking text in a byte diff --git a/rust/src/runtime/postgres-catalog.sql b/rust/src/runtime/postgres-catalog.sql index 384226aa6..083453e66 100644 --- a/rust/src/runtime/postgres-catalog.sql +++ b/rust/src/runtime/postgres-catalog.sql @@ -51,6 +51,21 @@ SELECT kind, name, definition FROM ( LEFT JOIN pg_attribute a ON a.attrelid=dep.refobjid AND a.attnum=dep.refobjsubid WHERE n.nspname='public' UNION ALL + -- Internal triggers implement foreign keys and are covered by constraints. + SELECT 'trigger', c.relname || '.' || t.tgname, + jsonb_build_object('definition', pg_get_triggerdef(t.oid, false), + 'enabled', t.tgenabled::text) + FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT t.tgisinternal + UNION ALL + -- A view's _RETURN rule is its definition, already recorded above. + SELECT 'rule', c.relname || '.' || r.rulename, + jsonb_build_object('definition', pg_get_ruledef(r.oid, false)) + FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT (r.rulename='_RETURN' AND c.relkind IN ('v','m')) + UNION ALL SELECT 'routine', p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', jsonb_build_object('kind', p.prokind) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' diff --git a/rust/src/runtime/postgres.rs b/rust/src/runtime/postgres.rs index 234c04203..3c92468f4 100644 --- a/rust/src/runtime/postgres.rs +++ b/rust/src/runtime/postgres.rs @@ -554,6 +554,15 @@ mod tests { "CREATE FUNCTION unexpected() RETURNS integer LANGUAGE sql AS 'SELECT 1'", "native_schema_catalog_mismatch", ), + // Neither needs a public function, so only trigger/rule catalog rows see them. + ( + "CREATE TRIGGER unexpected BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION suppress_redundant_updates_trigger()", + "native_schema_catalog_mismatch", + ), + ( + "CREATE RULE unexpected AS ON DELETE TO jobs DO INSTEAD NOTHING", + "native_schema_catalog_mismatch", + ), ] { let db = Database::new().await; let storage = PostgresStorage::open(db.options.clone()).await.unwrap(); diff --git a/rust/src/runtime/upgrade.rs b/rust/src/runtime/upgrade.rs index 3d3401193..6172da77c 100644 --- a/rust/src/runtime/upgrade.rs +++ b/rust/src/runtime/upgrade.rs @@ -392,9 +392,15 @@ async fn inspect_mysql_inner( } else { "mysql" }; - sqlx::query("SET sql_quote_show_create=1") - .execute(&mut connection) - .await?; + // The client deadline drops the socket, but the server would keep a running + // statement, its metadata locks and its snapshot. Bound them server-side. + sqlx::query(if backend == "mariadb" { + "SET sql_quote_show_create=1,lock_wait_timeout=5,innodb_lock_wait_timeout=5,max_statement_time=30" + } else { + "SET sql_quote_show_create=1,lock_wait_timeout=5,innodb_lock_wait_timeout=5,max_execution_time=30000" + }) + .execute(&mut connection) + .await?; sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") .execute(&mut connection) .await?; diff --git a/rust/src/runtime/upgrade/payload_scan.rs b/rust/src/runtime/upgrade/payload_scan.rs index 172e7002c..555eea3b2 100644 --- a/rust/src/runtime/upgrade/payload_scan.rs +++ b/rust/src/runtime/upgrade/payload_scan.rs @@ -218,6 +218,9 @@ where "references_metadata_supported":references_supported, "diagnostics":diagnostics, "diagnostics_omitted":omitted, "inline_values_verified":inline_supported, "nested_payloads_verified":false, + // Published migrations also give these tables codec-tagged payloads. + "other_root_payload_columns_verified":false, + "unscanned_payload_codec_tables":["workflow_commands","workflow_signal_records","workflow_updates"], "inline_validation_limits":{"encoded_frame_bytes":65536,"traversal_depth":64, "traversal_visits":16384,"scalar_allocation_bytes":apache_avro::util::max_allocation_bytes(apache_avro::util::DEFAULT_MAX_ALLOCATION_BYTES)}, "provider_bytes_verified":false, "registry_binding_verified":false, diff --git a/rust/src/runtime/upgrade/queue_scan.rs b/rust/src/runtime/upgrade/queue_scan.rs index 6946031a7..1ef38f328 100644 --- a/rust/src/runtime/upgrade/queue_scan.rs +++ b/rust/src/runtime/upgrade/queue_scan.rs @@ -39,7 +39,7 @@ where // most 32 * 128KiB payload bytes. The enclosing observation deadline caps // total work; the primary-key cursor never changes source row order. let page_sql = if DB::NAME == "SQLite" { - "SELECT id,queue,CASE WHEN length(CAST(payload AS BLOB))<=131072 THEN payload ELSE NULL END AS payload,attempts,reserved_at,available_at,created_at FROM jobs WHERE id>$1 ORDER BY id LIMIT 32" + "SELECT id,CASE WHEN length(CAST(queue AS BLOB))<=4096 THEN queue ELSE NULL END AS queue,CASE WHEN length(CAST(payload AS BLOB))<=131072 THEN payload ELSE NULL END AS payload,attempts,reserved_at,available_at,created_at FROM jobs WHERE id>$1 ORDER BY id LIMIT 32" } else { "SELECT id,queue,CASE WHEN octet_length(payload)<=131072 THEN payload ELSE NULL END AS payload,attempts,reserved_at,available_at,created_at FROM jobs WHERE id>$1 ORDER BY id LIMIT 32" }; @@ -55,7 +55,8 @@ where for row in page { let job = SourceQueueJob { id: DB::number(&row, "id")?, - queue: DB::string(&row, "queue")?, + queue: DB::optional_string(&row, "queue")? + .ok_or_else(|| refused("upgrade_source_queue_representation_unqualified"))?, payload: DB::optional_string(&row, "payload")? .ok_or_else(|| refused("upgrade_source_queue_representation_unqualified"))?, attempts: DB::number(&row, "attempts")?, @@ -65,6 +66,7 @@ where }; let task_id = referenced_task_id(&job) .map_err(|_| refused("upgrade_source_queue_representation_unqualified"))?; + oversized::(connection, TASK_TEXT_BYTES, task_id.as_str()).await?; let task = query::("SELECT id,workflow_run_id,namespace,task_type,status,connection,queue,available_at,lease_owner,lease_expires_at,attempt_count FROM workflow_tasks WHERE id=$1") .bind(task_id.as_str()).fetch_optional(&mut *connection).await? .ok_or_else(|| refused("upgrade_source_queue_task_missing"))?; @@ -81,6 +83,7 @@ where lease_expires_at: original_time::(&task, "lease_expires_at")?, attempt_count: DB::number(&task, "attempt_count")?, }; + oversized::(connection, RUN_TEXT_BYTES, task.workflow_run_id.as_str()).await?; let run = query::("SELECT id,workflow_instance_id,workflow_class,workflow_type,namespace,queue,status FROM workflow_runs WHERE id=$1") .bind(task.workflow_run_id.as_str()).fetch_optional(&mut *connection).await? .ok_or_else(|| refused("upgrade_source_queue_run_missing"))?; @@ -125,3 +128,31 @@ fn original_time(row: &DB::Row, field: &str) -> Result( + connection: &mut DB::Connection, + sql: &'static str, + id: &str, +) -> Result<()> +where + for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, + DB::Arguments: IntoArguments, + for<'q> &'q str: Encode<'q, DB> + Type, +{ + if DB::NAME != "SQLite" { + return Ok(()); + } + if let Some(row) = query::(sql).bind(id).fetch_optional(&mut *connection).await? + && DB::number(&row, "bytes")? > MAX_TEXT_BYTES + { + return Err(refused("upgrade_source_queue_representation_unqualified")); + } + Ok(()) +} diff --git a/rust/src/runtime/upgrade_tests.rs b/rust/src/runtime/upgrade_tests.rs index 46cdff547..08ea8309d 100644 --- a/rust/src/runtime/upgrade_tests.rs +++ b/rust/src/runtime/upgrade_tests.rs @@ -183,6 +183,20 @@ async fn queue_scan_cannot_skip_nonpositive_ids_or_transfer_oversized_payloads() "UPDATE jobs SET payload=CAST(zeroblob(131073) AS TEXT)", "upgrade_source_queue_representation_unqualified", ), + // SQLite keeps oversized varchar values that the frozen catalog cannot + // reject, so every transferred text column is measured first. + ( + "UPDATE jobs SET queue=printf('%.5000c','q')", + "upgrade_source_queue_representation_unqualified", + ), + ( + "UPDATE workflow_tasks SET lease_owner=printf('%.5000c','o')", + "upgrade_source_queue_representation_unqualified", + ), + ( + "UPDATE workflow_runs SET workflow_class=printf('%.5000c','c')", + "upgrade_source_queue_representation_unqualified", + ), ] { let (_directory, path) = queued_layout(1).await; mutate(&path, statement).await; @@ -389,6 +403,11 @@ async fn payload_scan_validates_inline_pages_and_reports_late_bad_values_without let report = inspect_sqlite_payloads(&path).await.unwrap(); assert_eq!(report["status"], "inspected"); assert_eq!(report["payload_inspection"]["inline_values_verified"], true); + assert_eq!(report["payload_inspection"]["other_root_payload_columns_verified"], false); + assert_eq!( + report["payload_inspection"]["unscanned_payload_codec_tables"], + json!(["workflow_commands", "workflow_signal_records", "workflow_updates"]) + ); assert_eq!(report["payload_inspection"]["diagnostics"], json!([])); for (table, column, count) in [ ("workflow_runs", "arguments", 33), @@ -508,6 +527,7 @@ async fn payload_scan_checks_all_four_columns_and_every_page_without_mutation_or for flag in [ "inline_values_verified", "nested_payloads_verified", + "other_root_payload_columns_verified", "provider_bytes_verified", "registry_binding_verified", "source_mutated", From 5e5b8090e37659f3f0a75af0cba17807c38613f0 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Mon, 19 Oct 2026 20:37:46 +0000 Subject: [PATCH 26/26] Accept borrowed bytes in validators and keep process waits off async tests Sonar flagged two serde visitors that implemented visit_byte_buf without visit_bytes, so a deserializer offering borrowed bytes would be refused. Both now accept either form. The payload inspection integration test waited on child processes and slept with blocking calls inside its async runtime. The process runs and the bounded wait now happen on the blocking pool; assertions and the three-second retirement bound are unchanged. Co-Authored-By: Claude Opus 5.5 --- rust/src/codec/validation.rs | 6 +++ rust/tests/upgrade_inspect.rs | 69 +++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/rust/src/codec/validation.rs b/rust/src/codec/validation.rs index 4b5bdc2f6..2e622fb9f 100644 --- a/rust/src/codec/validation.rs +++ b/rust/src/codec/validation.rs @@ -78,6 +78,9 @@ impl<'de> Visitor<'de> for InlineSeed<'_> { fn visit_string(self, _: String) -> Result<(), E> { Ok(()) } + fn visit_bytes(self, _: &[u8]) -> Result<(), E> { + Ok(()) + } fn visit_byte_buf(self, _: Vec) -> Result<(), E> { Ok(()) } @@ -150,6 +153,9 @@ impl<'de> Deserialize<'de> for Validated { fn visit_str(self, _: &str) -> Result { Ok(Validated) } + fn visit_bytes(self, _: &[u8]) -> Result { + Ok(Validated) + } fn visit_byte_buf(self, _: Vec) -> Result { Ok(Validated) } diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs index 7da0298f2..820a479a8 100644 --- a/rust/tests/upgrade_inspect.rs +++ b/rust/tests/upgrade_inspect.rs @@ -165,14 +165,12 @@ async fn payload_cli_refuses_writable_sources_and_checks_mounted_payloads() { .unwrap(); connection.close().await.unwrap(); let before = std::fs::read(&path).unwrap(); - let refused = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) - .env_clear() - .env("DW_RUST_EXPERIMENTAL", "1") - .env("DB_CONNECTION", "sqlite") - .env("DB_DATABASE", &path) - .arg("upgrade-payload-inspect") - .output() - .unwrap(); + let refused = { + let path = path.clone(); + tokio::task::spawn_blocking(move || payload_inspection(&path).output().unwrap()) + .await + .unwrap() + }; assert!(!refused.status.success()); assert!(refused.stdout.is_empty()); assert!( @@ -187,26 +185,10 @@ async fn payload_cli_refuses_writable_sources_and_checks_mounted_payloads() { continue; }; let source = readonly.join(path.strip_prefix(writable).unwrap()); - let mut child = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")) - .env_clear() - .env("DW_RUST_EXPERIMENTAL", "1") - .env("DB_CONNECTION", "sqlite") - .env("DB_DATABASE", &source) - .arg("upgrade-payload-inspect") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(3); - while child.try_wait().unwrap().is_none() { - if Instant::now() >= deadline { - child.kill().unwrap(); - child.wait().unwrap(); - panic!("payload inspection did not retire its process"); - } - std::thread::sleep(Duration::from_millis(10)); - } - let output = child.wait_with_output().unwrap(); + let output = + tokio::task::spawn_blocking(move || bounded_output(payload_inspection(&source))) + .await + .unwrap(); assert_eq!( output.status.success(), expected == "inspected", @@ -237,3 +219,34 @@ async fn payload_cli_refuses_writable_sources_and_checks_mounted_payloads() { assert_eq!(std::fs::read(&path).unwrap(), before); } } + +fn payload_inspection(database: &std::path::Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_durable-workflow-server")); + command + .env_clear() + .env("DW_RUST_EXPERIMENTAL", "1") + .env("DB_CONNECTION", "sqlite") + .env("DB_DATABASE", database) + .arg("upgrade-payload-inspect"); + command +} + +// Waiting on a child is synchronous. Callers in async tests run this on the +// blocking pool so the runtime's worker threads stay free. +fn bounded_output(mut command: Command) -> std::process::Output { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("payload inspection did not retire its process"); + } + std::thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() +}