diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index d9ce633f5..98917b9cf 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -646,6 +646,13 @@ 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 + 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() && steps.prepare_runtime_comparison.outcome == 'success' }} # The preceding process-loss case deliberately kills native-a. @@ -1101,6 +1108,13 @@ 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 + 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() && steps.prepare_runtime_comparison.outcome == 'success' }} env: @@ -1308,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 \ @@ -1546,6 +1561,17 @@ 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 + 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() && steps.prepare_runtime_comparison.outcome == 'success' }} env: diff --git a/.gitignore b/.gitignore index c4f668c1f..077a501e9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ yarn-error.log /corrected-marker-adapters/ /parity-corrected-state/ /parity-external-consumers-state/ +/parity-upgrade-source-state/ /scripts/conformance/server-parity/corrected-markers/php/vendor/ /scripts/conformance/server-parity/corrected-markers/rust/target/ diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index bb791f0c2..aa63fc265 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -35,6 +35,12 @@ Updated October 19, 2026. the projection corpus has a released-projector case for the pending row. Remaining listing gaps are the 413 read budget for long-heartbeat or wide runs, six-digit activity timestamps and NULL-row tolerance. +- **#460 source.** Read-only inspection of an existing PHP database now refuses + PostgreSQL triggers and rules like other unknown catalog objects, measures + every SQLite queue text value before transfer and refuses values over 4 KiB, + bounds MySQL and MariaDB lock waits and statement time on the server, and + reports the published payload tables it does not scan yet. It still grants no + takeover, backup or quiescence claim. - **Diagnosed failure.** Shared run [39092116973](https://github.com/durable-workflow/server/actions/runs/39092116973) failed `execution-and-codec` step 9 with exit code 255. The same step reproduced locally at `543ed0485` with the exact published image and failed the diff --git a/docs/rust-server-storage-audit.md b/docs/rust-server-storage-audit.md index dd1f02153..b1cfd921e 100644 --- a/docs/rust-server-storage-audit.md +++ b/docs/rust-server-storage-audit.md @@ -32,6 +32,44 @@ 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. + +`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. + +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 1. Run a read-only preflight against a consistent PHP reference installation. diff --git a/docs/rust-server-upgrade.md b/docs/rust-server-upgrade.md new file mode 100644 index 000000000..421908fc4 --- /dev/null +++ b/docs/rust-server-upgrade.md @@ -0,0 +1,253 @@ +# 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 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 +development opt-in. SQLite inspection never creates a missing database or runs +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 +path accepts rollback-journal databases only and does not change their journal +mode. WAL inventory requires a separately qualified consistent snapshot path. +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`. +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 +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 +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 +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. 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 + +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. `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 +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: + +| 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 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 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 grants no conversion, deletion, execution or takeover authority. +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 +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 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 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 +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 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 +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. diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4257c6678..5e9d9240f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -421,6 +421,8 @@ dependencies = [ "futures-util", "getrandom 0.3.4", "hmac", + "libc", + "phpserz", "regex-lite", "serde", "serde_json", @@ -480,6 +482,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" @@ -1137,6 +1145,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 4e922d52a..fc1aa4842 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,6 +21,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"] } @@ -28,6 +29,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/codec/validation.rs b/rust/src/codec/validation.rs index 77f82a63a..2e622fb9f 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,93 @@ 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_bytes(self, _: &[u8]) -> 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; @@ -66,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) } @@ -109,6 +199,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 +252,73 @@ 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()); + // 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(); + 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 d73532697..22abcb22c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -125,11 +125,117 @@ 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" + | "upgrade-queue-inspect" + | "upgrade-payload-inspect" + ) { return Err("unknown development command".into()); } let backend = env::var("DB_CONNECTION").unwrap_or_default(); + if matches!( + command.as_str(), + "upgrade-inspect" | "upgrade-queue-inspect" | "upgrade-payload-inspect" + ) { + 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" { + 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" => { + let path = env::var("DB_DATABASE").map_err(|_| "DB_DATABASE is required")?; + upgrade::inspect_sqlite_cli( + &path, + queues.as_ref(), + command == "upgrade-payload-inspect", + ) + .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, + } + } + } + "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, + } + } + } + _ => { + return Err( + "upgrade inspection requires DB_CONNECTION=sqlite, pgsql, mysql or mariadb" + .into(), + ); + } + }; + match result { + 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, + _ => "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 ebeac4e90..0d051c048 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -59,6 +59,7 @@ mod timer_summary; mod transaction; mod update_summary; mod updates; +pub mod upgrade; mod visibility; mod wait_projection; mod wait_timeouts; diff --git a/rust/src/runtime/mysql.rs b/rust/src/runtime/mysql.rs index 6d4a5c83c..5ae70bc04 100644 --- a/rust/src/runtime/mysql.rs +++ b/rust/src/runtime/mysql.rs @@ -149,6 +149,62 @@ 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 flavor = Flavor::detect(connection).await?; + 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 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"), + 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)); + if actual != current { + 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-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 e40693516..f7200b79b 100644 --- a/rust/src/runtime/postgres.rs +++ b/rust/src/runtime/postgres.rs @@ -54,6 +54,52 @@ 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 { + 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) +} + +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?; @@ -543,6 +589,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/schema.rs b/rust/src/runtime/schema.rs index be874d35b..279e78d67 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,40 @@ 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 { + 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) +} + 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..6172da77c --- /dev/null +++ b/rust/src/runtime/upgrade.rs @@ -0,0 +1,514 @@ +//! 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; +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}; +use sqlx::{ + AssertSqlSafe, Connection, MySqlConnection, PgConnection, SqliteConnection, + 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, + "_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, + queue_inspection: Option, + payload_inspection: Option, +) -> Result { + let digest = Sha256::digest(serde_json::to_vec(catalog)?) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + 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; + } + if let Some(payloads) = payload_inspection { + if payloads["references_metadata_supported"] != true + || payloads["inline_values_verified"] != true + { + report["status"] = json!("unqualified"); + } + report["payload_inspection"] = payloads; + } + 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. +// 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 { + inspect_sqlite_inner(path, None, false).await +} + +pub async fn inspect_sqlite_queues(path: &str, config: &QueueSourceConfig<'_>) -> Result { + inspect_sqlite_inner(path, Some(config), false).await +} + +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<'_>>, + payloads: bool, +) -> Result { + queue_config(queues)?; + let deadline = Instant::now() + BUDGET; + if path.is_empty() || path.contains(['?', ':']) { + return Err(refuse( + StatusCode::BAD_REQUEST, + "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 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] { + 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( + &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, '"'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + 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, + payload_inspection, + ) + }) + .await + .map_err(|_| expired()) + .and_then(|result| result); + close(connection.close()).await?; + result +} + +pub async fn inspect_mysql(options: MySqlConnectOptions) -> Result { + inspect_mysql_inner(options, None, false).await +} + +pub async fn inspect_mysql_queues( + options: MySqlConnectOptions, + config: &QueueSourceConfig<'_>, +) -> Result { + 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; + 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" + }; + // 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?; + 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, '`'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + 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 { + return Err(refuse( + StatusCode::CONFLICT, + "upgrade_source_catalog_changed", + )); + } + tx.rollback().await?; + report( + backend, + &catalog, + counts, + queue_inspection, + payload_inspection, + ) + }) + .await + .map_err(|_| expired()) + .and_then(|result| result); + close(connection.close()).await?; + result +} + +pub async fn inspect_postgres(options: PgConnectOptions) -> Result { + inspect_postgres_inner(options, None, false).await +} + +pub async fn inspect_postgres_queues( + options: PgConnectOptions, + config: &QueueSourceConfig<'_>, +) -> Result { + 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; + 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, '"'); + let queue_inspection = match queues { + Some(config) => { + Some(queue_scan::inspect::(&mut *tx, config, counts["jobs"]).await?) + } + 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, + payload_inspection, + ) + }) + .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/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/rust/src/runtime/upgrade/payload_scan.rs b/rust/src/runtime/upgrade/payload_scan.rs new file mode 100644 index 000000000..555eea3b2 --- /dev/null +++ b/rust/src/runtime/upgrade/payload_scan.rs @@ -0,0 +1,229 @@ +//! 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; +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, + avro_inline_values: 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; + 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 + } 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-") + { + 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 { + 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) => { + references_supported = false; + 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":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, + "source_mutated":false, "takeover_allowed":false + })) +} 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/rust/src/runtime/upgrade/queue_binding.rs b/rust/src/runtime/upgrade/queue_binding.rs new file mode 100644 index 000000000..6079e7faf --- /dev/null +++ b/rust/src/runtime/upgrade/queue_binding.rs @@ -0,0 +1,414 @@ +//! 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') +} + +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> { + 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 { + 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/rust/src/runtime/upgrade/queue_scan.rs b/rust/src/runtime/upgrade/queue_scan.rs new file mode 100644 index 000000000..1ef38f328 --- /dev/null +++ b/rust/src/runtime/upgrade/queue_scan.rs @@ -0,0 +1,158 @@ +//! 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,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" + }; + 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::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")?, + 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"))?; + 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"))?; + 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")?, + }; + 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"))?; + 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())) +} + +// SQLite does not enforce varchar lengths, so a matching-catalog file can hold +// arbitrarily large text. Measure every selected value before transferring it. +// PostgreSQL and MySQL enforce the frozen catalog's lengths themselves. +const TASK_TEXT_BYTES: &str = "SELECT max(coalesce(length(CAST(id AS BLOB)),0),coalesce(length(CAST(workflow_run_id AS BLOB)),0),coalesce(length(CAST(namespace AS BLOB)),0),coalesce(length(CAST(task_type AS BLOB)),0),coalesce(length(CAST(status AS BLOB)),0),coalesce(length(CAST(connection AS BLOB)),0),coalesce(length(CAST(queue AS BLOB)),0),coalesce(length(CAST(available_at AS BLOB)),0),coalesce(length(CAST(lease_owner AS BLOB)),0),coalesce(length(CAST(lease_expires_at AS BLOB)),0)) AS bytes FROM workflow_tasks WHERE id=$1"; +const RUN_TEXT_BYTES: &str = "SELECT max(coalesce(length(CAST(id AS BLOB)),0),coalesce(length(CAST(workflow_instance_id AS BLOB)),0),coalesce(length(CAST(workflow_class AS BLOB)),0),coalesce(length(CAST(workflow_type AS BLOB)),0),coalesce(length(CAST(namespace AS BLOB)),0),coalesce(length(CAST(queue AS BLOB)),0),coalesce(length(CAST(status AS BLOB)),0)) AS bytes FROM workflow_runs WHERE id=$1"; +const MAX_TEXT_BYTES: i64 = 4096; + +async fn oversized( + 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 new file mode 100644 index 000000000..08ea8309d --- /dev/null +++ b/rust/src/runtime/upgrade_tests.rs @@ -0,0 +1,727 @@ +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) +} + +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", + ), + // 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; + 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() + .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 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; + 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); +} + +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_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"]["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), + ("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; + 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"], "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,"avro_inline_values":0,"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"] + .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", + "other_root_payload_columns_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 [ + "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); +} diff --git a/rust/tests/upgrade_inspect.rs b/rust/tests/upgrade_inspect.rs new file mode 100644 index 000000000..820a479a8 --- /dev/null +++ b/rust/tests/upgrade_inspect.rs @@ -0,0 +1,252 @@ +//! 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 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 command in [ + "upgrade-inspect", + "upgrade-queue-inspect", + "upgrade-payload-inspect", + ] { + 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)); + } + 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)); + } + } + 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_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}, + }; + // 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() + .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)); + apache_avro::writer::datum::GenericDatumWriter::builder(&apache_avro::Schema::Long) + .build() + .unwrap() + .write_value_ref( + &mut oversized, + &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 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!( + 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 output = + tokio::task::spawn_blocking(move || bounded_output(payload_inspection(&source))) + .await + .unwrap(); + assert_eq!( + output.status.success(), + expected == "inspected", + "{}", + 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!( + String::from_utf8_lossy(&output.stderr) + .contains("upgrade_source_payload_metadata_unqualified") + ); + assert_eq!( + report["payload_inspection"]["diagnostics"][0]["reason"], + "stored_inline_frame_unqualified" + ); + } + 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() +} 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..8f6270c76 --- /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, 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. + +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/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/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-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-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/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"; 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..35b995418 --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-cleanup.sh @@ -0,0 +1,53 @@ +#!/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=${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 + 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..2c7e58f5c --- /dev/null +++ b/scripts/conformance/server-parity/upgrade-source-profile.sh @@ -0,0 +1,174 @@ +#!/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 +# 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 +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" +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" >/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" +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 \ + -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" "${1:-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 +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 +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.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 + 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() { + 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 +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, + 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" 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/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"; diff --git a/tests/Fixtures/ServerUpgrade/README.md b/tests/Fixtures/ServerUpgrade/README.md new file mode 100644 index 000000000..20f2c81d0 --- /dev/null +++ b/tests/Fixtures/ServerUpgrade/README.md @@ -0,0 +1,53 @@ +# 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. + +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. 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" + } + } + } + ] +} 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;}" + } + ] +} 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 +}